Add the litter bin, and with it a perforation helper that punches a surface the caller describes rather than a cylinder it assumes; then catch with a new ground check the two models that were quietly hovering because a declared origin had placed them twice.

This commit is contained in:
CRTkafa 2026-09-05 04:26:24 +07:00
parent 3f8831e41c
commit 9a9be5f313
13 changed files with 946 additions and 35 deletions

View file

@ -209,6 +209,23 @@ is `latheGeometry`'s and always was: walk from the bottom of the outer wall
upward. Write the profile whichever way round reads best and reverse it at the upward. Write the profile whichever way round reads best and reverse it at the
call site. call site.
**A part with an origin is written in that origin's own space.** The kit does
not subtract a declared origin from world-written geometry -- it moves parts
into place only long enough to bake occlusion, and puts them back. Write the
geometry where it stands AND give it an origin and it is placed twice. The cable
drum shipped that way, a flange radius in the air, and so did the litter bin's
hood.
**Nothing was checking that a model touches the ground.** `findFloating`
measures a model against ITSELF -- it looks for pieces with nothing under them
-- so an assembly whose every piece is correctly stacked passes while the whole
thing hovers. `verify:props` now takes the precise bounding box and requires its
floor within a millimetre of zero. The tolerance is asymmetric on purpose: a
millimetre in the air is a model that will hover over any surface it is put on,
while a couple of millimetres under is the outermost corner of a chamfered foot
and the ground hides it, so sinking is allowed up to a percent of the model's
own height. The check found two more models the moment it was written.
**`latheGeometry` caps by default, and the cap on an annulus is a solid disc.** **`latheGeometry` caps by default, and the cap on an annulus is a solid disc.**
The cable drum's flanges were built as an outer tube, an inner tube and two The cable drum's flanges were built as an outer tube, an inner tube and two
annulus faces -- four pieces, two of them quietly filling in the hole the other annulus faces -- four pieces, two of them quietly filling in the hole the other
@ -477,13 +494,13 @@ asking GitHub Support to purge actually closes it.
## Open ## Open
- **`contemporary-props`**: 17 of the core 100 modelled, and the build order is - **`contemporary-props`**: 18 of the core 100 modelled, and the build order is
now DOMAIN FIRST. The catalogue's own section 5 argued against spreading the now DOMAIN FIRST. The catalogue's own section 5 argued against spreading the
first hundred across nine domains and named the failure mode: nine domains at first hundred across nine domains and named the failure mode: nine domains at
a third each, with the helper set shaped by whichever got built first. Fifteen a third each, with the helper set shaped by whichever got built first. Fifteen
models in, that had happened -- five street, four kitchen, two tools, one each models in, that had happened -- five street, four kitchen, two tools, one each
of four more -- so the remaining street rows are being finished before of four more -- so the remaining street rows are being finished before
anything else starts. Ten to go after the cable drum. The reasoning is written into `CATALOGUE.md` under anything else starts. Nine to go after the litter bin. The reasoning is written into `CATALOGUE.md` under
"Decided: street first"; the short version is that seventeen street objects is "Decided: street first"; the short version is that seventeen street objects is
a scene somebody can assemble, and what carried the medieval kit was one a scene somebody can assemble, and what carried the medieval kit was one
flythrough rather than a model count. flythrough rather than a model count.

View file

@ -264,14 +264,19 @@ export function createModel(overrides: Partial<CableDrumConfig> = {}) {
if (pieces.length === 0) return undefined if (pieces.length === 0) return undefined
const merged = mergeColoured(pieces) const merged = mergeColoured(pieces)
merged.rotateX(Math.PI / 2) merged.rotateX(Math.PI / 2)
merged.translate(0, R, 0) // ...and NOT lifted. A part that declares an origin is written in that
// origin's own space and the anchor carries it into place; the kit does
// not subtract the origin from world-written geometry, it only moves
// things temporarily to bake occlusion and moves them back. Lifting
// here as well put the whole drum a flange radius into the air, where
// it stayed, because the support check measures a model against itself
// rather than against the ground.
return merged return merged
} }
// Every part declares the AXLE as its origin, which is what lets one // Every part declares the AXLE as its origin, which is what lets one
// rotation roll all three together. The kit subtracts the origin from the // rotation roll all three together, and every part's geometry is written
// geometry and puts the anchor there, so the geometry above stays written // about that axle rather than about the ground.
// in world terms and nothing here has to be built twice.
const axle = [0, R, 0] as const const axle = [0, R, 0] as const
const cable = tip(cablePieces) const cable = tip(cablePieces)
return { return {

View file

@ -303,6 +303,102 @@ export function latheGeometry(
return finish(sink) return finish(sink)
} }
/** How a surface is punched, for `perforate`. */
export interface PerforationPattern {
/** Rows of holes up the surface. */
readonly rows: number
/** Columns of holes around it. */
readonly columns: number
/** Open fraction of each cell: 0 is solid, 1 is all hole. */
readonly open: number
/** Offset alternate rows by half a column, which is what a real punch does. */
readonly stagger?: boolean
}
/**
* Punches a surface, by building the METAL rather than the holes.
*
* There is no boolean subtraction in this kit and there is not going to be, so
* a perforated plate cannot be a plate with circles taken out of it. What it
* can be is the web between the holes, emitted directly: a ring of solid band
* at every row boundary, and a short bar between every pair of holes in the
* rows. Light passes through what is left, which is the entire point -- a bin
* you cannot see into is a drum with a pattern painted on it.
*
* The holes come out square rather than round, and that is a considered loss.
* At the size any of these objects is looked at -- a litter bin a metre tall,
* its holes 14 mm -- a hole is three pixels, and what reads is the PITCH and
* the open area, neither of which cares about the shape. Round holes at this
* density are a texture, and the kit has no textures.
*
* The surface is passed in as a function of (u, v), so this helper knows
* nothing about cylinders. That is deliberate and it is the whole reason it is
* written here rather than inside the litter bin: the catalogue defines
* perforation across three rows in three different domains -- the mesh waste
* bin (28, office, a tapered drum), the colander (29, kitchen, a curved shell,
* and the harder case) and the litter bin (30, street) -- and a helper that
* only knew how to punch a cylinder would be rewritten by the second of them.
* A caller that can describe its own surface gets punched for free.
*
* @param surface maps u (0..1 around, wrapping) and v (0..1 up) to a point.
*/
export function perforate(
surface: (u: number, v: number) => Vec3,
pattern: PerforationPattern,
colour: Color,
options: { readonly colourTop?: Color } = {},
): BufferGeometry {
const rows = Math.max(1, Math.round(pattern.rows))
const columns = Math.max(3, Math.round(pattern.columns))
const open = Math.min(0.92, Math.max(0.05, pattern.open))
const sink: Sink = { position: [], color: [] }
const top = options.colourTop ?? colour
const shade = (v: number): Color => new Color().copy(colour).lerp(top, v)
const quad = (a: Vec3, b: Vec3, c: Vec3, d: Vec3, cLo: Color, cHi: Color): void => {
triShaded(sink, a, b, c, cLo, cLo, cHi)
triShaded(sink, a, c, d, cLo, cHi, cHi)
}
// Half the solid left at each boundary, in cell units.
const webV = (1 - open) / 2
const webU = (1 - open) / 2
// The rings: one at every row boundary, top and bottom included, so the
// punched band is closed by solid metal at both ends rather than ending on a
// row of half holes.
for (let i = 0; i <= rows; i += 1) {
const lo = Math.max(0, (i - webV) / rows)
const hi = Math.min(1, (i + webV) / rows)
if (hi - lo <= 1e-6) continue
const cLo = shade(lo)
const cHi = shade(hi)
for (let j = 0; j < columns; j += 1) {
const u0 = j / columns
const u1 = (j + 1) / columns
quad(surface(u0, lo), surface(u1, lo), surface(u1, hi), surface(u0, hi), cLo, cHi)
}
}
// The bars: one between each pair of holes, spanning the gap the rings left.
for (let i = 0; i < rows; i += 1) {
const lo = (i + webV) / rows
const hi = (i + 1 - webV) / rows
if (hi - lo <= 1e-6) continue
const cLo = shade(lo)
const cHi = shade(hi)
const shift = pattern.stagger === true && i % 2 === 1 ? 0.5 : 0
for (let j = 0; j < columns; j += 1) {
const centre = (j + shift) / columns
const u0 = centre - webU / columns
const u1 = centre + webU / columns
quad(surface(u0, lo), surface(u1, lo), surface(u1, hi), surface(u0, hi), cLo, cHi)
}
}
return finish(sink)
}
/** /**
* Runs a round section along an arbitrary path: the fourth way of making a * Runs a round section along an arbitrary path: the fourth way of making a
* solid here, after the lathe, the plan sweep and the extrusion. * solid here, after the lathe, the plan sweep and the extrusion.

View file

@ -13,6 +13,7 @@ export {
latheGeometry, latheGeometry,
planSweepGeometry, planSweepGeometry,
mergeColoured, mergeColoured,
perforate,
mottleGeometry, mottleGeometry,
prismGeometry, prismGeometry,
roughenGeometry, roughenGeometry,
@ -21,7 +22,7 @@ export {
tubeGeometry, tubeGeometry,
taperedBoxGeometry, taperedBoxGeometry,
} from './geometry.ts' } from './geometry.ts'
export type { Level, MottleOptions, Plan, PlanLevel, RoughenOptions, Section, SheetLevel, Vec3 } from './geometry.ts' export type { Level, MottleOptions, PerforationPattern, Plan, PlanLevel, RoughenOptions, Section, SheetLevel, Vec3 } from './geometry.ts'
export { bakeOcclusion } from './occlusion.ts' export { bakeOcclusion } from './occlusion.ts'
export type { OcclusionOptions } from './occlusion.ts' export type { OcclusionOptions } from './occlusion.ts'
export { createKitModel } from './kit.ts' export { createKitModel } from './kit.ts'

View file

@ -0,0 +1,16 @@
{
"title": "Litter Bin",
"description": "A punched steel drum hung off a post under an overhanging hood, on a bolted flange with gussets. The row that spends the perforation helper, and the first here whose hinge carries real mass.",
"category": "Street",
"tags": ["contemporary", "street", "waste", "procedural"],
"controls": {
"height": { "type": "number", "label": "Height", "min": 0.8, "max": 1.7, "step": 0.01, "unit": "m" },
"diameter": { "type": "number", "label": "Drum diameter", "min": 0.2, "max": 0.6, "step": 0.005, "unit": "m" },
"rows": { "type": "number", "label": "Hole rows", "min": 3, "max": 20, "step": 1 },
"columns": { "type": "number", "label": "Holes around", "min": 8, "max": 44, "step": 1 },
"lift": { "type": "number", "label": "Hood lift", "min": 0, "max": 1, "step": 0.05 },
"seed": { "type": "number", "label": "Variation seed", "min": 1, "max": 64, "step": 1 }
},
"materialSlots": ["steelPainted", "galvanised"],
"parts": ["post", "drum", "hood"]
}

View file

@ -0,0 +1,314 @@
/**
* @contemporary-props/litter-bin
*
* A perforated drum under an overhanging hood, hung off a post: the catalogue's
* thirtieth row, and the first object here where a hinge carries real mass.
*
* It is also the row that spends `perforate`, which is written in core rather
* than here for the reason the catalogue gives: perforation is defined across
* three rows in three domains -- the mesh waste bin in the office, the colander
* in the kitchen, and this -- and a helper that only knew how to punch a
* cylinder would be rewritten by the second of them. This model hands it a
* cylinder; the colander will hand it a curved shell and get the same code.
*
* Measured off the reference against a 1.2 m overall height:
*
* drum 368 mm across, 550 mm deep, hung with its base 400 mm up
* hood 450 mm across, 116 mm deep, overhanging the drum on both sides
* post 55 mm, tapered, on a 190 mm flange with four gussets
* punching 14 mm holes on a 24 mm pitch, staggered, with solid bands top
* and bottom and a solid strip where the bracket lands
*
* The hood hinges at the back of its bracket. That is the action the catalogue
* asks for and it is a real one: emptying the bin means swinging the hood up
* and lifting the liner out, so the pivot has to be behind the drum rather than
* over it, and the hood has to clear the drum's rim through the whole travel.
*/
import { type BufferGeometry } from 'three'
import {
bakeOcclusion,
chamferedBoxGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
perforate,
smoothNormals,
type RuntimeContext,
type Vec3,
} from '../core/index.ts'
export interface LitterBinConfig {
/** Height to the top of the hood (metres). */
readonly height: number
/** Diameter of the drum (metres). */
readonly diameter: number
/** Rows of holes up the drum. */
readonly rows: number
/** Holes around the drum. */
readonly columns: number
/** How far the hood is swung up, 0 closed to 1 open. */
readonly lift: number
readonly seed: number
}
export const litterBinDefaults: LitterBinConfig = {
height: 1.2,
diameter: 0.368,
// 14 mm holes on a 24 mm pitch, which is what the reference punches: at 26
// columns they came out 26 mm across and read as a grille rather than a
// perforation.
rows: 21,
columns: 34,
lift: 0,
seed: 33,
}
export type LitterBinParts = 'post' | 'drum' | 'hood'
export interface LitterBinActions {
/** Swings the hood up off the drum. 1 is fully open. */
lift(amount?: number): void
}
/**
* The hood's pose, in one place because the config and the action both drive
* it -- the lesson the pavement sign and the fire extinguisher both paid for.
* A pose that lives only in an anchor rotation is a pose no rebuild re-applies,
* so the slider moves and nothing happens.
*/
function applyLift(
runtime: RuntimeContext<LitterBinConfig, LitterBinParts>,
amount: number,
): void {
runtime.parts.hood.anchor.rotation.x = -Math.min(1, Math.max(0, amount)) * 1.15
}
export function createModel(overrides: Partial<LitterBinConfig> = {}) {
let heldLift = 0
let seenLift = Number.NaN
return createKitModel<
LitterBinConfig, 'steelPainted' | 'galvanised', LitterBinParts, LitterBinActions
>({
id: 'litter-bin',
defaults: litterBinDefaults,
slots: ['steelPainted', 'galvanised'],
build: ({ config, random }) => {
const tint = createTinter(random)
const H = Math.min(1.7, Math.max(0.8, config.height))
const D = Math.min(0.6, Math.max(0.2, config.diameter))
const rows = Math.max(3, Math.round(config.rows))
const columns = Math.max(8, Math.round(config.columns))
const R = D / 2
const drumH = H * 0.458
const drumBase = H * 0.333
const drumTop = drumBase + drumH
// The post stands behind the drum, not through it.
const postZ = R * 1.16
const postR = H * 0.023
const hoodY = H - H * 0.097
const paint = tint('steelPainted', jitter(random, 0.02))
const dark = tint('steelPainted', -0.5, 0.3)
/* --------------------------------------------------------------- post */
const postPieces: BufferGeometry[] = []
postPieces.push(latheGeometry([
{ y: H * 0.02, radius: postR * 1.18 },
{ y: H * 0.05, radius: postR },
// Up to the hood's arm, not to a number that looked about right: at
// 0.86 the post stopped 38 mm short of the arm it carries and the
// whole hood hung in the air, which the support check found and no
// amount of looking at the render from the front would have.
{ y: hoodY + H * 0.006, radius: postR * 0.92 },
], 18, [0, 0, postZ], paint, { capBottom: false, capTop: true }))
// The bolted foot: a flange with four gussets, which is how a post this
// slender is actually kept upright and the only place the object is
// fixed to anything.
postPieces.push(latheGeometry([
{ y: 0, radius: H * 0.079 },
{ y: H * 0.008, radius: H * 0.079 },
{ y: H * 0.013, radius: H * 0.07 },
], 24, [0, 0, postZ], tint('galvanised', 0, 0.5).offsetHSL(0, -1, 0),
{ capBottom: false, capTop: true }))
for (let i = 0; i < 4; i += 1) {
const a = (i / 4) * Math.PI * 2 + Math.PI / 4
const gusset = chamferedBoxGeometry(
[H * 0.008, H * 0.052], [H * 0.008, H * 0.012],
H * 0.1, H * 0.003, [0, 0, 0], paint,
)
gusset.translate(0, H * 0.063, H * 0.03)
gusset.rotateY(a)
gusset.translate(0, 0, postZ)
postPieces.push(gusset)
}
/* --------------------------------------------------------------- drum */
/**
* The punched band, and the two solid ones that close it.
*
* A drum punched edge to edge has a row of half holes at each end and
* reads as torn. The reference's has 75 mm of plain sheet at the top and
* 65 at the bottom, which is where the rolled rims are, and it is those
* bands rather than the holes that make the perforation look cut into
* something.
*/
const drumPieces: BufferGeometry[] = []
const bandTop = drumH * 0.14
const bandBot = drumH * 0.12
const punchLo = drumBase + bandBot
const punchHi = drumTop - bandTop
for (const [lo, hi] of [[drumBase, punchLo], [punchHi, drumTop]] as const) {
drumPieces.push(latheGeometry([
{ y: lo, radius: R },
{ y: hi, radius: R },
], 36, [0, 0, 0], paint, { capBottom: false, capTop: false }))
}
/*
* THREE fields, not one, split by two solid bands.
*
* The reference's punching is interrupted twice on the way up, and those
* bands are structural: a drum of continuous perforation has nothing to
* hold its shape, so a real one is stiffened at the third points. They
* are also most of what stops the punching reading as a printed texture,
* because they give it edges in the middle rather than only at the ends.
*
* The helper is called once per field rather than being taught about
* bands. It punches a surface; where the surface stops is the caller's
* business, and this way the colander can interrupt its own punching for
* a completely different reason without changing anything here.
*/
const fields = 3
const bandMid = drumH * 0.045
const fieldH = (punchHi - punchLo - bandMid * (fields - 1)) / fields
for (let f = 0; f < fields; f += 1) {
const lo = punchLo + f * (fieldH + bandMid)
const hi = lo + fieldH
drumPieces.push(perforate(
// The surface the punch is mapped onto: a plain cylinder, described
// by the caller because the helper is not allowed to assume one.
(u, v): Vec3 => {
const a = u * Math.PI * 2
return [Math.sin(a) * R, lo + (hi - lo) * v, Math.cos(a) * R]
},
{ rows: Math.max(2, Math.round(rows / fields)), columns, open: 0.55, stagger: true },
paint,
))
if (f < fields - 1) {
drumPieces.push(latheGeometry([
{ y: hi, radius: R },
{ y: hi + bandMid, radius: R },
], 36, [0, 0, 0], paint, { capBottom: false, capTop: false }))
}
}
// The liner: a dark drum just inside, so the holes have something behind
// them. Without it they open onto whatever is behind the bin and the
// punching reads as a stencil rather than as a hole.
drumPieces.push(latheGeometry([
{ y: drumBase + drumH * 0.03, radius: R * 0.955 },
{ y: drumTop - drumH * 0.02, radius: R * 0.955 },
], 30, [0, 0, 0], dark, { capBottom: true, capTop: false }))
// The rolled rims top and bottom, and the bracket that hangs the drum on
// the post.
for (const [y, lift] of [[drumBase, -0.04], [drumTop, 0.03]] as const) {
drumPieces.push(latheGeometry([
{ y: y - drumH * 0.018, radius: R * 1.035 },
{ y: y + drumH * 0.018, radius: R * 1.035 },
], 36, [0, 0, 0], tint('steelPainted', lift), { capBottom: false, capTop: false }))
}
// TWO compact fittings rather than one long block. A bracket two thirds
// of the drum's height reads as a spine the bin is welded to; the
// reference carries it on a pair of short lugs, which is both what a
// hinge looks like and what says the drum comes off.
for (const at of [0.16, 0.84]) {
// Narrow enough to tuck between the drum and the post rather than
// standing out either side of it: the lug is a fitting, and a fitting
// that reads as a block is a fitting nobody believes carries anything.
const lug = chamferedBoxGeometry(
[H * 0.032, postZ - R * 0.4], [H * 0.027, postZ - R * 0.4],
drumH * 0.13, H * 0.004, [0, 0, 0], paint,
)
lug.translate(0, drumBase + drumH * at, (R * 0.85 + postZ) / 2)
drumPieces.push(lug)
}
/* --------------------------------------------------------------- hood */
/**
* A shallow dome on an arm, and it OVERHANGS.
*
* The hood is half again the drum's diameter in the reference, which is
* what keeps rain out of a bin whose whole side is holes. Drawn to the
* drum's own width it stops reading as a hood at all and becomes a lid.
*/
const hoodPieces: BufferGeometry[] = []
const hoodR = R * 1.22
// A shallow shield, not a mushroom. Taken to a point the hood reads as a
// parasol; the reference's is a pressing with a flat crown and a rolled
// edge, and its whole job is to be wider than the drum rather than taller
// than it.
hoodPieces.push(latheGeometry([
{ y: hoodY, radius: hoodR },
// A real vertical lip, deep enough to throw a shadow line round the
// whole rim: at 8 mm it was an edge, and an edge is not a pressing.
{ y: hoodY + H * 0.014, radius: hoodR },
{ y: hoodY + H * 0.026, radius: hoodR * 0.95 },
{ y: hoodY + H * 0.043, radius: hoodR * 0.79 },
{ y: hoodY + H * 0.055, radius: hoodR * 0.48 },
{ y: hoodY + H * 0.06, radius: hoodR * 0.22 },
], 30, [0, 0, 0], tint('steelPainted', 0.04), { capBottom: true, capTop: true }))
// The arm back to the post, and the pivot it swings about.
const arm = chamferedBoxGeometry(
[H * 0.028, postZ + hoodR * 0.55], [H * 0.028, postZ + hoodR * 0.55],
H * 0.03, H * 0.004, [0, 0, 0], paint,
)
arm.translate(0, hoodY - H * 0.012, (postZ - hoodR * 0.55) / 2)
hoodPieces.push(arm)
bakeOcclusion(postPieces, { strength: 0.35 })
bakeOcclusion(drumPieces, { strength: 0.4 })
// The hood's origin is the pivot, behind the drum on the post, so one
// rotation swings it up and clear rather than through the rim.
const pivot = [0, hoodY - H * 0.012, postZ] as const
return {
post: { slot: 'steelPainted' as const, geometry: smoothNormals(mergeColoured(postPieces), 34) },
drum: { slot: 'steelPainted' as const, geometry: smoothNormals(mergeColoured(drumPieces), 34) },
hood: {
// Written where it sits and then moved into the PIVOT'S OWN SPACE,
// because that is the space a part with an origin is expected in:
// the anchor carries it back. Left in world coordinates it is placed
// twice and ends up a pivot height above itself.
slot: 'steelPainted' as const,
geometry: smoothNormals(
mergeColoured(hoodPieces).translate(-pivot[0], -pivot[1], -pivot[2]), 34,
),
origin: pivot,
},
}
},
actions: (runtime) => {
heldLift = runtime.getConfig().lift
seenLift = heldLift
applyLift(runtime, heldLift)
return { lift: (amount = 1) => { heldLift = amount; applyLift(runtime, amount) } }
},
update: (_dt, runtime) => {
const wanted = runtime.getConfig().lift
if (wanted !== seenLift) { seenLift = wanted; heldLift = wanted }
applyLift(runtime, heldLift)
},
}, overrides)
}

View file

@ -677,15 +677,15 @@
"address": "@contemporary-props/core", "address": "@contemporary-props/core",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.024Z", "installedAt": "2026-09-04T21:23:51.309Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\core\\geometry.ts", "path": "src\\models\\contemporary-props\\core\\geometry.ts",
"sourceHash": "d2d6d3a5eb914ad25ddb0d848480f73f4d3cf3b4d50b6655d9d96e2e384b55c2" "sourceHash": "98188ee830bc2566cd791fdd077021f4539076e58a23bd0075876c5273a8ccb4"
}, },
{ {
"path": "src\\models\\contemporary-props\\core\\index.ts", "path": "src\\models\\contemporary-props\\core\\index.ts",
"sourceHash": "7407f52b8585d0344d7c788bfb69e5cc0440ab04022240c8cb858e06c8e5f5a5" "sourceHash": "38d1152c66c2a72cf293e6b2233fd72b4413ae02ebd0b8409a4db7c4b4cbed82"
}, },
{ {
"path": "src\\models\\contemporary-props\\core\\kit.ts", "path": "src\\models\\contemporary-props\\core\\kit.ts",
@ -724,7 +724,7 @@
"address": "@contemporary-props/ceramic-vase", "address": "@contemporary-props/ceramic-vase",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.030Z", "installedAt": "2026-09-04T21:23:51.315Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\ceramic-vase\\model.ts", "path": "src\\models\\contemporary-props\\ceramic-vase\\model.ts",
@ -739,7 +739,7 @@
"address": "@contemporary-props/coffee-mug", "address": "@contemporary-props/coffee-mug",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.031Z", "installedAt": "2026-09-04T21:23:51.316Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\coffee-mug\\model.ts", "path": "src\\models\\contemporary-props\\coffee-mug\\model.ts",
@ -754,7 +754,7 @@
"address": "@contemporary-props/pepper-mill", "address": "@contemporary-props/pepper-mill",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.045Z", "installedAt": "2026-09-04T21:23:51.329Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\pepper-mill\\model.ts", "path": "src\\models\\contemporary-props\\pepper-mill\\model.ts",
@ -769,7 +769,7 @@
"address": "@contemporary-props/stockpot", "address": "@contemporary-props/stockpot",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.048Z", "installedAt": "2026-09-04T21:23:51.332Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\stockpot\\model.ts", "path": "src\\models\\contemporary-props\\stockpot\\model.ts",
@ -784,7 +784,7 @@
"address": "@contemporary-props/traffic-cone", "address": "@contemporary-props/traffic-cone",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.051Z", "installedAt": "2026-09-04T21:23:51.335Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\traffic-cone\\model.ts", "path": "src\\models\\contemporary-props\\traffic-cone\\model.ts",
@ -799,7 +799,7 @@
"address": "@contemporary-props/wine-glass", "address": "@contemporary-props/wine-glass",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.053Z", "installedAt": "2026-09-04T21:23:51.337Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\wine-glass\\model.ts", "path": "src\\models\\contemporary-props\\wine-glass\\model.ts",
@ -814,7 +814,7 @@
"address": "@contemporary-props/kit", "address": "@contemporary-props/kit",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.053Z", "installedAt": "2026-09-04T21:23:51.337Z",
"files": [], "files": [],
"dependencies": [] "dependencies": []
}, },
@ -822,7 +822,7 @@
"address": "@contemporary-props/street-bollard", "address": "@contemporary-props/street-bollard",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.049Z", "installedAt": "2026-09-04T21:23:51.334Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\street-bollard\\model.ts", "path": "src\\models\\contemporary-props\\street-bollard\\model.ts",
@ -837,7 +837,7 @@
"address": "@contemporary-props/gas-cylinder", "address": "@contemporary-props/gas-cylinder",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.035Z", "installedAt": "2026-09-04T21:23:51.319Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\gas-cylinder\\model.ts", "path": "src\\models\\contemporary-props\\gas-cylinder\\model.ts",
@ -852,7 +852,7 @@
"address": "@contemporary-props/fire-extinguisher", "address": "@contemporary-props/fire-extinguisher",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.033Z", "installedAt": "2026-09-04T21:23:51.318Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\fire-extinguisher\\model.ts", "path": "src\\models\\contemporary-props\\fire-extinguisher\\model.ts",
@ -867,7 +867,7 @@
"address": "@contemporary-props/pedestal-basin", "address": "@contemporary-props/pedestal-basin",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.043Z", "installedAt": "2026-09-04T21:23:51.328Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\pedestal-basin\\model.ts", "path": "src\\models\\contemporary-props\\pedestal-basin\\model.ts",
@ -882,7 +882,7 @@
"address": "@contemporary-props/jersey-barrier", "address": "@contemporary-props/jersey-barrier",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.036Z", "installedAt": "2026-09-04T21:23:51.321Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\jersey-barrier\\model.ts", "path": "src\\models\\contemporary-props\\jersey-barrier\\model.ts",
@ -897,7 +897,7 @@
"address": "@contemporary-props/picnic-table", "address": "@contemporary-props/picnic-table",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.046Z", "installedAt": "2026-09-04T21:23:51.331Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\picnic-table\\model.ts", "path": "src\\models\\contemporary-props\\picnic-table\\model.ts",
@ -912,7 +912,7 @@
"address": "@contemporary-props/park-bench", "address": "@contemporary-props/park-bench",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.040Z", "installedAt": "2026-09-04T21:23:51.325Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\park-bench\\model.ts", "path": "src\\models\\contemporary-props\\park-bench\\model.ts",
@ -927,7 +927,7 @@
"address": "@contemporary-props/pavement-sign-board", "address": "@contemporary-props/pavement-sign-board",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.041Z", "installedAt": "2026-09-04T21:23:51.326Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\pavement-sign-board\\model.ts", "path": "src\\models\\contemporary-props\\pavement-sign-board\\model.ts",
@ -942,7 +942,7 @@
"address": "@contemporary-props/lectern", "address": "@contemporary-props/lectern",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.038Z", "installedAt": "2026-09-04T21:23:51.322Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\lectern\\model.ts", "path": "src\\models\\contemporary-props\\lectern\\model.ts",
@ -957,7 +957,7 @@
"address": "@contemporary-props/bike-rack", "address": "@contemporary-props/bike-rack",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.027Z", "installedAt": "2026-09-04T21:23:51.312Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\bike-rack\\model.ts", "path": "src\\models\\contemporary-props\\bike-rack\\model.ts",
@ -972,11 +972,26 @@
"address": "@contemporary-props/cable-drum", "address": "@contemporary-props/cable-drum",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:08:46.028Z", "installedAt": "2026-09-04T21:23:51.313Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\cable-drum\\model.ts", "path": "src\\models\\contemporary-props\\cable-drum\\model.ts",
"sourceHash": "23e13f4efccf35cc760f3a3210621e1a6d9e881ae499d949fff97d97b2dff0d6" "sourceHash": "6702777ecb403eaf34be940551da48daea2f2d32df6830a3c1aaf1ceb6b4c551"
}
],
"dependencies": [
"three@>=0.185.0"
]
},
"@contemporary-props/litter-bin": {
"address": "@contemporary-props/litter-bin",
"source": "file:contemporary-props/dist/registry.json",
"version": "workspace",
"installedAt": "2026-09-04T21:23:51.323Z",
"files": [
{
"path": "src\\models\\contemporary-props\\litter-bin\\model.ts",
"sourceHash": "5665fcb71912620a4952758ae8778b4b38519ad15d8a920eb9333e1b3bca5373"
} }
], ],
"dependencies": [ "dependencies": [

View file

@ -64,11 +64,37 @@ for (const [id, entry] of Object.entries(CATALOG)) {
// Precise, for the reason `support.ts` says: the loose bound is the box // Precise, for the reason `support.ts` says: the loose bound is the box
// around each part's own rotated box, and a hinged model reads 60 mm taller // around each part's own rotated box, and a hinged model reads 60 mm taller
// than its tallest vertex. // than its tallest vertex.
const size = new Box3().setFromObject(root, true).getSize(new Vector3()) const box = new Box3().setFromObject(root, true)
const size = box.getSize(new Vector3())
if (!Number.isFinite(size.x + size.y + size.z) || size.y <= 0) { if (!Number.isFinite(size.x + size.y + size.z) || size.y <= 0) {
problems.push('bounds are not finite') problems.push('bounds are not finite')
} }
/*
* ...and it has to be ON the ground, which nothing here was checking.
*
* `findFloating` measures a model against ITSELF: it looks for pieces with
* nothing under them, so a model whose every piece is correctly stacked
* passes even when the whole assembly is a metre in the air. The cable drum
* shipped like that -- 600 mm up, because it was written in world coordinates
* and then given an origin as well, which places it twice -- and every gate
* in the repository said it rested on the ground.
*
* The tolerance is ASYMMETRIC, because the two directions are not the same
* fault. A millimetre in the air is a model that will hover over any surface
* it is placed on. A couple of millimetres under is the outermost corner of a
* chamfered foot, or the end of a centre line offset perpendicular to its own
* direction, and the ground hides it -- so it is allowed up to a percent of
* the model's own height, which scales from a mug to a bus shelter.
*/
if (Number.isFinite(box.min.y)) {
if (box.min.y > 0.001) {
problems.push(`floats ${(box.min.y * 1000).toFixed(0)}mm above the ground`)
} else if (-box.min.y > Math.max(0.004, size.y * 0.01)) {
problems.push(`sinks ${(-box.min.y * 1000).toFixed(0)}mm below the ground`)
}
}
if (problems.length > 0) { if (problems.length > 0) {
failed += 1 failed += 1
console.log(`\n FAIL ${id}`) console.log(`\n FAIL ${id}`)

View file

@ -66,6 +66,7 @@ import { createModel as createCpPicnicTable } from '@/models/contemporary-props/
import { createModel as createCpParkBench } from '@/models/contemporary-props/park-bench/model.ts' import { createModel as createCpParkBench } from '@/models/contemporary-props/park-bench/model.ts'
import { createModel as createCpBikeRack } from '@/models/contemporary-props/bike-rack/model.ts' import { createModel as createCpBikeRack } from '@/models/contemporary-props/bike-rack/model.ts'
import { createModel as createCpCableDrum } from '@/models/contemporary-props/cable-drum/model.ts' import { createModel as createCpCableDrum } from '@/models/contemporary-props/cable-drum/model.ts'
import { createModel as createCpLitterBin } from '@/models/contemporary-props/litter-bin/model.ts'
import { createModel as createCpPavementSign } from '@/models/contemporary-props/pavement-sign-board/model.ts' import { createModel as createCpPavementSign } from '@/models/contemporary-props/pavement-sign-board/model.ts'
import { createModel as createCpLectern } from '@/models/contemporary-props/lectern/model.ts' import { createModel as createCpLectern } from '@/models/contemporary-props/lectern/model.ts'
import cpMetaCeramicVase from '../contemporary-props/models/ceramic-vase/meta.json' import cpMetaCeramicVase from '../contemporary-props/models/ceramic-vase/meta.json'
@ -83,6 +84,7 @@ import cpMetaPicnicTable from '../contemporary-props/models/picnic-table/meta.js
import cpMetaParkBench from '../contemporary-props/models/park-bench/meta.json' import cpMetaParkBench from '../contemporary-props/models/park-bench/meta.json'
import cpMetaBikeRack from '../contemporary-props/models/bike-rack/meta.json' import cpMetaBikeRack from '../contemporary-props/models/bike-rack/meta.json'
import cpMetaCableDrum from '../contemporary-props/models/cable-drum/meta.json' import cpMetaCableDrum from '../contemporary-props/models/cable-drum/meta.json'
import cpMetaLitterBin from '../contemporary-props/models/litter-bin/meta.json'
import cpMetaPavementSign from '../contemporary-props/models/pavement-sign-board/meta.json' import cpMetaPavementSign from '../contemporary-props/models/pavement-sign-board/meta.json'
import cpMetaLectern from '../contemporary-props/models/lectern/meta.json' import cpMetaLectern from '../contemporary-props/models/lectern/meta.json'
import { createModel as createShield } from '@/models/medieval-kit/round-shield/model.ts' import { createModel as createShield } from '@/models/medieval-kit/round-shield/model.ts'
@ -304,6 +306,7 @@ const CONTEMPORARY_ORDER = [
'lectern', 'lectern',
'bike-rack', 'bike-rack',
'cable-drum', 'cable-drum',
'litter-bin',
] as const ] as const
const CONTEMPORARY_FACTORIES: Readonly<Record<string, () => KitModel>> = { const CONTEMPORARY_FACTORIES: Readonly<Record<string, () => KitModel>> = {
@ -322,6 +325,7 @@ const CONTEMPORARY_FACTORIES: Readonly<Record<string, () => KitModel>> = {
'park-bench': as(createCpParkBench), 'park-bench': as(createCpParkBench),
'bike-rack': as(createCpBikeRack), 'bike-rack': as(createCpBikeRack),
'cable-drum': as(createCpCableDrum), 'cable-drum': as(createCpCableDrum),
'litter-bin': as(createCpLitterBin),
'pavement-sign-board': as(createCpPavementSign), 'pavement-sign-board': as(createCpPavementSign),
'lectern': as(createCpLectern), 'lectern': as(createCpLectern),
} }
@ -342,6 +346,7 @@ const CONTEMPORARY_META: Readonly<Record<string, ModelMetaLike>> = {
'park-bench': cpMetaParkBench as ModelMetaLike, 'park-bench': cpMetaParkBench as ModelMetaLike,
'bike-rack': cpMetaBikeRack as ModelMetaLike, 'bike-rack': cpMetaBikeRack as ModelMetaLike,
'cable-drum': cpMetaCableDrum as ModelMetaLike, 'cable-drum': cpMetaCableDrum as ModelMetaLike,
'litter-bin': cpMetaLitterBin as ModelMetaLike,
'pavement-sign-board': cpMetaPavementSign as ModelMetaLike, 'pavement-sign-board': cpMetaPavementSign as ModelMetaLike,
'lectern': cpMetaLectern as ModelMetaLike, 'lectern': cpMetaLectern as ModelMetaLike,
} }

View file

@ -264,14 +264,19 @@ export function createModel(overrides: Partial<CableDrumConfig> = {}) {
if (pieces.length === 0) return undefined if (pieces.length === 0) return undefined
const merged = mergeColoured(pieces) const merged = mergeColoured(pieces)
merged.rotateX(Math.PI / 2) merged.rotateX(Math.PI / 2)
merged.translate(0, R, 0) // ...and NOT lifted. A part that declares an origin is written in that
// origin's own space and the anchor carries it into place; the kit does
// not subtract the origin from world-written geometry, it only moves
// things temporarily to bake occlusion and moves them back. Lifting
// here as well put the whole drum a flange radius into the air, where
// it stayed, because the support check measures a model against itself
// rather than against the ground.
return merged return merged
} }
// Every part declares the AXLE as its origin, which is what lets one // Every part declares the AXLE as its origin, which is what lets one
// rotation roll all three together. The kit subtracts the origin from the // rotation roll all three together, and every part's geometry is written
// geometry and puts the anchor there, so the geometry above stays written // about that axle rather than about the ground.
// in world terms and nothing here has to be built twice.
const axle = [0, R, 0] as const const axle = [0, R, 0] as const
const cable = tip(cablePieces) const cable = tip(cablePieces)
return { return {

View file

@ -303,6 +303,102 @@ export function latheGeometry(
return finish(sink) return finish(sink)
} }
/** How a surface is punched, for `perforate`. */
export interface PerforationPattern {
/** Rows of holes up the surface. */
readonly rows: number
/** Columns of holes around it. */
readonly columns: number
/** Open fraction of each cell: 0 is solid, 1 is all hole. */
readonly open: number
/** Offset alternate rows by half a column, which is what a real punch does. */
readonly stagger?: boolean
}
/**
* Punches a surface, by building the METAL rather than the holes.
*
* There is no boolean subtraction in this kit and there is not going to be, so
* a perforated plate cannot be a plate with circles taken out of it. What it
* can be is the web between the holes, emitted directly: a ring of solid band
* at every row boundary, and a short bar between every pair of holes in the
* rows. Light passes through what is left, which is the entire point -- a bin
* you cannot see into is a drum with a pattern painted on it.
*
* The holes come out square rather than round, and that is a considered loss.
* At the size any of these objects is looked at -- a litter bin a metre tall,
* its holes 14 mm -- a hole is three pixels, and what reads is the PITCH and
* the open area, neither of which cares about the shape. Round holes at this
* density are a texture, and the kit has no textures.
*
* The surface is passed in as a function of (u, v), so this helper knows
* nothing about cylinders. That is deliberate and it is the whole reason it is
* written here rather than inside the litter bin: the catalogue defines
* perforation across three rows in three different domains -- the mesh waste
* bin (28, office, a tapered drum), the colander (29, kitchen, a curved shell,
* and the harder case) and the litter bin (30, street) -- and a helper that
* only knew how to punch a cylinder would be rewritten by the second of them.
* A caller that can describe its own surface gets punched for free.
*
* @param surface maps u (0..1 around, wrapping) and v (0..1 up) to a point.
*/
export function perforate(
surface: (u: number, v: number) => Vec3,
pattern: PerforationPattern,
colour: Color,
options: { readonly colourTop?: Color } = {},
): BufferGeometry {
const rows = Math.max(1, Math.round(pattern.rows))
const columns = Math.max(3, Math.round(pattern.columns))
const open = Math.min(0.92, Math.max(0.05, pattern.open))
const sink: Sink = { position: [], color: [] }
const top = options.colourTop ?? colour
const shade = (v: number): Color => new Color().copy(colour).lerp(top, v)
const quad = (a: Vec3, b: Vec3, c: Vec3, d: Vec3, cLo: Color, cHi: Color): void => {
triShaded(sink, a, b, c, cLo, cLo, cHi)
triShaded(sink, a, c, d, cLo, cHi, cHi)
}
// Half the solid left at each boundary, in cell units.
const webV = (1 - open) / 2
const webU = (1 - open) / 2
// The rings: one at every row boundary, top and bottom included, so the
// punched band is closed by solid metal at both ends rather than ending on a
// row of half holes.
for (let i = 0; i <= rows; i += 1) {
const lo = Math.max(0, (i - webV) / rows)
const hi = Math.min(1, (i + webV) / rows)
if (hi - lo <= 1e-6) continue
const cLo = shade(lo)
const cHi = shade(hi)
for (let j = 0; j < columns; j += 1) {
const u0 = j / columns
const u1 = (j + 1) / columns
quad(surface(u0, lo), surface(u1, lo), surface(u1, hi), surface(u0, hi), cLo, cHi)
}
}
// The bars: one between each pair of holes, spanning the gap the rings left.
for (let i = 0; i < rows; i += 1) {
const lo = (i + webV) / rows
const hi = (i + 1 - webV) / rows
if (hi - lo <= 1e-6) continue
const cLo = shade(lo)
const cHi = shade(hi)
const shift = pattern.stagger === true && i % 2 === 1 ? 0.5 : 0
for (let j = 0; j < columns; j += 1) {
const centre = (j + shift) / columns
const u0 = centre - webU / columns
const u1 = centre + webU / columns
quad(surface(u0, lo), surface(u1, lo), surface(u1, hi), surface(u0, hi), cLo, cHi)
}
}
return finish(sink)
}
/** /**
* Runs a round section along an arbitrary path: the fourth way of making a * Runs a round section along an arbitrary path: the fourth way of making a
* solid here, after the lathe, the plan sweep and the extrusion. * solid here, after the lathe, the plan sweep and the extrusion.

View file

@ -13,6 +13,7 @@ export {
latheGeometry, latheGeometry,
planSweepGeometry, planSweepGeometry,
mergeColoured, mergeColoured,
perforate,
mottleGeometry, mottleGeometry,
prismGeometry, prismGeometry,
roughenGeometry, roughenGeometry,
@ -21,7 +22,7 @@ export {
tubeGeometry, tubeGeometry,
taperedBoxGeometry, taperedBoxGeometry,
} from './geometry.ts' } from './geometry.ts'
export type { Level, MottleOptions, Plan, PlanLevel, RoughenOptions, Section, SheetLevel, Vec3 } from './geometry.ts' export type { Level, MottleOptions, PerforationPattern, Plan, PlanLevel, RoughenOptions, Section, SheetLevel, Vec3 } from './geometry.ts'
export { bakeOcclusion } from './occlusion.ts' export { bakeOcclusion } from './occlusion.ts'
export type { OcclusionOptions } from './occlusion.ts' export type { OcclusionOptions } from './occlusion.ts'
export { createKitModel } from './kit.ts' export { createKitModel } from './kit.ts'

View file

@ -0,0 +1,314 @@
/**
* @contemporary-props/litter-bin
*
* A perforated drum under an overhanging hood, hung off a post: the catalogue's
* thirtieth row, and the first object here where a hinge carries real mass.
*
* It is also the row that spends `perforate`, which is written in core rather
* than here for the reason the catalogue gives: perforation is defined across
* three rows in three domains -- the mesh waste bin in the office, the colander
* in the kitchen, and this -- and a helper that only knew how to punch a
* cylinder would be rewritten by the second of them. This model hands it a
* cylinder; the colander will hand it a curved shell and get the same code.
*
* Measured off the reference against a 1.2 m overall height:
*
* drum 368 mm across, 550 mm deep, hung with its base 400 mm up
* hood 450 mm across, 116 mm deep, overhanging the drum on both sides
* post 55 mm, tapered, on a 190 mm flange with four gussets
* punching 14 mm holes on a 24 mm pitch, staggered, with solid bands top
* and bottom and a solid strip where the bracket lands
*
* The hood hinges at the back of its bracket. That is the action the catalogue
* asks for and it is a real one: emptying the bin means swinging the hood up
* and lifting the liner out, so the pivot has to be behind the drum rather than
* over it, and the hood has to clear the drum's rim through the whole travel.
*/
import { type BufferGeometry } from 'three'
import {
bakeOcclusion,
chamferedBoxGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
perforate,
smoothNormals,
type RuntimeContext,
type Vec3,
} from '../core/index.ts'
export interface LitterBinConfig {
/** Height to the top of the hood (metres). */
readonly height: number
/** Diameter of the drum (metres). */
readonly diameter: number
/** Rows of holes up the drum. */
readonly rows: number
/** Holes around the drum. */
readonly columns: number
/** How far the hood is swung up, 0 closed to 1 open. */
readonly lift: number
readonly seed: number
}
export const litterBinDefaults: LitterBinConfig = {
height: 1.2,
diameter: 0.368,
// 14 mm holes on a 24 mm pitch, which is what the reference punches: at 26
// columns they came out 26 mm across and read as a grille rather than a
// perforation.
rows: 21,
columns: 34,
lift: 0,
seed: 33,
}
export type LitterBinParts = 'post' | 'drum' | 'hood'
export interface LitterBinActions {
/** Swings the hood up off the drum. 1 is fully open. */
lift(amount?: number): void
}
/**
* The hood's pose, in one place because the config and the action both drive
* it -- the lesson the pavement sign and the fire extinguisher both paid for.
* A pose that lives only in an anchor rotation is a pose no rebuild re-applies,
* so the slider moves and nothing happens.
*/
function applyLift(
runtime: RuntimeContext<LitterBinConfig, LitterBinParts>,
amount: number,
): void {
runtime.parts.hood.anchor.rotation.x = -Math.min(1, Math.max(0, amount)) * 1.15
}
export function createModel(overrides: Partial<LitterBinConfig> = {}) {
let heldLift = 0
let seenLift = Number.NaN
return createKitModel<
LitterBinConfig, 'steelPainted' | 'galvanised', LitterBinParts, LitterBinActions
>({
id: 'litter-bin',
defaults: litterBinDefaults,
slots: ['steelPainted', 'galvanised'],
build: ({ config, random }) => {
const tint = createTinter(random)
const H = Math.min(1.7, Math.max(0.8, config.height))
const D = Math.min(0.6, Math.max(0.2, config.diameter))
const rows = Math.max(3, Math.round(config.rows))
const columns = Math.max(8, Math.round(config.columns))
const R = D / 2
const drumH = H * 0.458
const drumBase = H * 0.333
const drumTop = drumBase + drumH
// The post stands behind the drum, not through it.
const postZ = R * 1.16
const postR = H * 0.023
const hoodY = H - H * 0.097
const paint = tint('steelPainted', jitter(random, 0.02))
const dark = tint('steelPainted', -0.5, 0.3)
/* --------------------------------------------------------------- post */
const postPieces: BufferGeometry[] = []
postPieces.push(latheGeometry([
{ y: H * 0.02, radius: postR * 1.18 },
{ y: H * 0.05, radius: postR },
// Up to the hood's arm, not to a number that looked about right: at
// 0.86 the post stopped 38 mm short of the arm it carries and the
// whole hood hung in the air, which the support check found and no
// amount of looking at the render from the front would have.
{ y: hoodY + H * 0.006, radius: postR * 0.92 },
], 18, [0, 0, postZ], paint, { capBottom: false, capTop: true }))
// The bolted foot: a flange with four gussets, which is how a post this
// slender is actually kept upright and the only place the object is
// fixed to anything.
postPieces.push(latheGeometry([
{ y: 0, radius: H * 0.079 },
{ y: H * 0.008, radius: H * 0.079 },
{ y: H * 0.013, radius: H * 0.07 },
], 24, [0, 0, postZ], tint('galvanised', 0, 0.5).offsetHSL(0, -1, 0),
{ capBottom: false, capTop: true }))
for (let i = 0; i < 4; i += 1) {
const a = (i / 4) * Math.PI * 2 + Math.PI / 4
const gusset = chamferedBoxGeometry(
[H * 0.008, H * 0.052], [H * 0.008, H * 0.012],
H * 0.1, H * 0.003, [0, 0, 0], paint,
)
gusset.translate(0, H * 0.063, H * 0.03)
gusset.rotateY(a)
gusset.translate(0, 0, postZ)
postPieces.push(gusset)
}
/* --------------------------------------------------------------- drum */
/**
* The punched band, and the two solid ones that close it.
*
* A drum punched edge to edge has a row of half holes at each end and
* reads as torn. The reference's has 75 mm of plain sheet at the top and
* 65 at the bottom, which is where the rolled rims are, and it is those
* bands rather than the holes that make the perforation look cut into
* something.
*/
const drumPieces: BufferGeometry[] = []
const bandTop = drumH * 0.14
const bandBot = drumH * 0.12
const punchLo = drumBase + bandBot
const punchHi = drumTop - bandTop
for (const [lo, hi] of [[drumBase, punchLo], [punchHi, drumTop]] as const) {
drumPieces.push(latheGeometry([
{ y: lo, radius: R },
{ y: hi, radius: R },
], 36, [0, 0, 0], paint, { capBottom: false, capTop: false }))
}
/*
* THREE fields, not one, split by two solid bands.
*
* The reference's punching is interrupted twice on the way up, and those
* bands are structural: a drum of continuous perforation has nothing to
* hold its shape, so a real one is stiffened at the third points. They
* are also most of what stops the punching reading as a printed texture,
* because they give it edges in the middle rather than only at the ends.
*
* The helper is called once per field rather than being taught about
* bands. It punches a surface; where the surface stops is the caller's
* business, and this way the colander can interrupt its own punching for
* a completely different reason without changing anything here.
*/
const fields = 3
const bandMid = drumH * 0.045
const fieldH = (punchHi - punchLo - bandMid * (fields - 1)) / fields
for (let f = 0; f < fields; f += 1) {
const lo = punchLo + f * (fieldH + bandMid)
const hi = lo + fieldH
drumPieces.push(perforate(
// The surface the punch is mapped onto: a plain cylinder, described
// by the caller because the helper is not allowed to assume one.
(u, v): Vec3 => {
const a = u * Math.PI * 2
return [Math.sin(a) * R, lo + (hi - lo) * v, Math.cos(a) * R]
},
{ rows: Math.max(2, Math.round(rows / fields)), columns, open: 0.55, stagger: true },
paint,
))
if (f < fields - 1) {
drumPieces.push(latheGeometry([
{ y: hi, radius: R },
{ y: hi + bandMid, radius: R },
], 36, [0, 0, 0], paint, { capBottom: false, capTop: false }))
}
}
// The liner: a dark drum just inside, so the holes have something behind
// them. Without it they open onto whatever is behind the bin and the
// punching reads as a stencil rather than as a hole.
drumPieces.push(latheGeometry([
{ y: drumBase + drumH * 0.03, radius: R * 0.955 },
{ y: drumTop - drumH * 0.02, radius: R * 0.955 },
], 30, [0, 0, 0], dark, { capBottom: true, capTop: false }))
// The rolled rims top and bottom, and the bracket that hangs the drum on
// the post.
for (const [y, lift] of [[drumBase, -0.04], [drumTop, 0.03]] as const) {
drumPieces.push(latheGeometry([
{ y: y - drumH * 0.018, radius: R * 1.035 },
{ y: y + drumH * 0.018, radius: R * 1.035 },
], 36, [0, 0, 0], tint('steelPainted', lift), { capBottom: false, capTop: false }))
}
// TWO compact fittings rather than one long block. A bracket two thirds
// of the drum's height reads as a spine the bin is welded to; the
// reference carries it on a pair of short lugs, which is both what a
// hinge looks like and what says the drum comes off.
for (const at of [0.16, 0.84]) {
// Narrow enough to tuck between the drum and the post rather than
// standing out either side of it: the lug is a fitting, and a fitting
// that reads as a block is a fitting nobody believes carries anything.
const lug = chamferedBoxGeometry(
[H * 0.032, postZ - R * 0.4], [H * 0.027, postZ - R * 0.4],
drumH * 0.13, H * 0.004, [0, 0, 0], paint,
)
lug.translate(0, drumBase + drumH * at, (R * 0.85 + postZ) / 2)
drumPieces.push(lug)
}
/* --------------------------------------------------------------- hood */
/**
* A shallow dome on an arm, and it OVERHANGS.
*
* The hood is half again the drum's diameter in the reference, which is
* what keeps rain out of a bin whose whole side is holes. Drawn to the
* drum's own width it stops reading as a hood at all and becomes a lid.
*/
const hoodPieces: BufferGeometry[] = []
const hoodR = R * 1.22
// A shallow shield, not a mushroom. Taken to a point the hood reads as a
// parasol; the reference's is a pressing with a flat crown and a rolled
// edge, and its whole job is to be wider than the drum rather than taller
// than it.
hoodPieces.push(latheGeometry([
{ y: hoodY, radius: hoodR },
// A real vertical lip, deep enough to throw a shadow line round the
// whole rim: at 8 mm it was an edge, and an edge is not a pressing.
{ y: hoodY + H * 0.014, radius: hoodR },
{ y: hoodY + H * 0.026, radius: hoodR * 0.95 },
{ y: hoodY + H * 0.043, radius: hoodR * 0.79 },
{ y: hoodY + H * 0.055, radius: hoodR * 0.48 },
{ y: hoodY + H * 0.06, radius: hoodR * 0.22 },
], 30, [0, 0, 0], tint('steelPainted', 0.04), { capBottom: true, capTop: true }))
// The arm back to the post, and the pivot it swings about.
const arm = chamferedBoxGeometry(
[H * 0.028, postZ + hoodR * 0.55], [H * 0.028, postZ + hoodR * 0.55],
H * 0.03, H * 0.004, [0, 0, 0], paint,
)
arm.translate(0, hoodY - H * 0.012, (postZ - hoodR * 0.55) / 2)
hoodPieces.push(arm)
bakeOcclusion(postPieces, { strength: 0.35 })
bakeOcclusion(drumPieces, { strength: 0.4 })
// The hood's origin is the pivot, behind the drum on the post, so one
// rotation swings it up and clear rather than through the rim.
const pivot = [0, hoodY - H * 0.012, postZ] as const
return {
post: { slot: 'steelPainted' as const, geometry: smoothNormals(mergeColoured(postPieces), 34) },
drum: { slot: 'steelPainted' as const, geometry: smoothNormals(mergeColoured(drumPieces), 34) },
hood: {
// Written where it sits and then moved into the PIVOT'S OWN SPACE,
// because that is the space a part with an origin is expected in:
// the anchor carries it back. Left in world coordinates it is placed
// twice and ends up a pivot height above itself.
slot: 'steelPainted' as const,
geometry: smoothNormals(
mergeColoured(hoodPieces).translate(-pivot[0], -pivot[1], -pivot[2]), 34,
),
origin: pivot,
},
}
},
actions: (runtime) => {
heldLift = runtime.getConfig().lift
seenLift = heldLift
applyLift(runtime, heldLift)
return { lift: (amount = 1) => { heldLift = amount; applyLift(runtime, amount) } }
},
update: (_dt, runtime) => {
const wanted = runtime.getConfig().lift
if (wanted !== seenLift) { seenLift = wanted; heldLift = wanted }
applyLift(runtime, heldLift)
},
}, overrides)
}