Add the parcel locker bank, and with it the unequal split the park bench's rule could not do: weights rather than counts, a different pattern per bay, and one merged pair carrying doors two bays wide.

This commit is contained in:
CRTkafa 2026-09-05 05:01:47 +07:00
parent 3c86896f73
commit 2e25e91d80
10 changed files with 698 additions and 29 deletions

View file

@ -209,6 +209,15 @@ 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 "mixed size distribution" means width too, and it is easy to only do
height.** The parcel locker bank's first cut varied its tier heights per bay and
every door still came out one bay wide -- which is a column exercise, not the
harder case the catalogue asked for, and a parcel locker's whole reason for
existing is that a parcel is not one size. The fix is a seeded merge of one
adjacent PAIR of bays whose lower two thirds carry doors spanning both. Worth
remembering for the server rack and the office locker bank, which share the
split arithmetic and will want the same thing.
**`createTinter`'s lift lands on the LINEAR value, and clamps to black long **`createTinter`'s lift lands on the LINEAR value, and clamps to black long
before it looks as though it should.** `steelPainted` reads as a mid grey and before it looks as though it should.** `steelPainted` reads as a mid grey and
measures 0.202 in linear lightness, not the 0.48 it appears to be, so a lift measures 0.202 in linear lightness, not the 0.48 it appears to be, so a lift
@ -532,13 +541,13 @@ asking GitHub Support to purge actually closes it.
## Open ## Open
- **`contemporary-props`**: 21 of the core 100 modelled, and the build order is - **`contemporary-props`**: 22 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. Six to go after the utility cabinet. The reasoning is written into `CATALOGUE.md` under anything else starts. Five to go after the parcel locker bank. 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

@ -303,6 +303,48 @@ export function latheGeometry(
return finish(sink) return finish(sink)
} }
/** One run in a split: where it starts, and how long it is. */
export interface Run {
/** Centre of the run along the axis, measured from the middle of the total. */
readonly at: number
readonly size: number
}
/**
* Divides a length into runs of given relative weights, with a gap between
* each, so the whole thing fills EXACTLY.
*
* This is the slat-with-gap rule the park bench settled, generalised to runs
* that are not all the same size. That rule -- derive the pitch from the run
* and the count, never type it -- only handles equal divisions, and the moment
* a bank has one tall door and four short ones it is no longer enough.
*
* Three rows in three domains want the unequal version: the locker bank (44,
* office), the parcel locker bank (50, street, which the catalogue calls the
* generator's harder case) and the server rack (78, computing). The arithmetic
* is the shared part; the box each run gets filled with is not, and stays in
* the models.
*
* Weights are relative, so [1, 1, 2] is two shorts and a double whatever the
* total is, and changing the total moves every run rather than leaving a
* remainder at one end.
*/
export function splitRuns(total: number, gap: number, weights: readonly number[]): Run[] {
if (weights.length === 0) return []
const sum = weights.reduce((a, b) => a + b, 0)
if (sum <= 0) return []
// The gaps come out of the total first; whatever is left is shared by weight.
const usable = Math.max(0, total - gap * (weights.length - 1))
const runs: Run[] = []
let cursor = -total / 2
for (const weight of weights) {
const size = (usable * weight) / sum
runs.push({ at: cursor + size / 2, size })
cursor += size + gap
}
return runs
}
/** /**
* A bank of louvres: pressed blades in an opening, facing -Z. * A bank of louvres: pressed blades in an opening, facing -Z.
* *

View file

@ -20,12 +20,13 @@ export {
roughenGeometry, roughenGeometry,
staveGeometry, staveGeometry,
smoothNormals, smoothNormals,
splitRuns,
tubeGeometry, tubeGeometry,
wheelGeometry, wheelGeometry,
taperedBoxGeometry, taperedBoxGeometry,
} from './geometry.ts' } from './geometry.ts'
export type { Level, MottleOptions, PerforationPattern, Plan, PlanLevel, export type { Level, MottleOptions, PerforationPattern, Plan, PlanLevel,
Wheel, RoughenOptions, Section, SheetLevel, Vec3 } from './geometry.ts' Run, Wheel, 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": "Parcel Locker Bank",
"description": "A bank of parcel lockers whose bays are each divided differently, from a seeded set of tier patterns. The catalogue's harder locker case: the door sizes are the object, and the split arithmetic is shared with the office locker bank and the server rack.",
"category": "Street",
"tags": ["contemporary", "street", "retail", "procedural"],
"controls": {
"width": { "type": "number", "label": "Width", "min": 0.6, "max": 4, "step": 0.05, "unit": "m" },
"height": { "type": "number", "label": "Height", "min": 1, "max": 2.6, "step": 0.02, "unit": "m" },
"depth": { "type": "number", "label": "Depth", "min": 0.3, "max": 1, "step": 0.02, "unit": "m" },
"bays": { "type": "number", "label": "Bays", "min": 1, "max": 10, "step": 1 },
"open": { "type": "number", "label": "Doors open", "min": 0, "max": 1, "step": 0.05 },
"seed": { "type": "number", "label": "Variation seed", "min": 1, "max": 64, "step": 1 }
},
"materialSlots": ["steelPainted"],
"parts": ["carcass", "doors"]
}

View file

@ -0,0 +1,269 @@
/**
* @contemporary-props/parcel-locker-bank
*
* A door grid with a mixed size distribution, which the catalogue calls the
* locker generator's harder case and puts fiftieth. The easy case is a grid:
* every door the same, pitch derived from the count, and the park bench settled
* that rule at row thirteen. This is what happens when the doors are not all
* the same size, which is the whole point of a parcel locker -- a bank that can
* only take one size of parcel is a bank half of which is always empty.
*
* The arithmetic lives in core as `splitRuns`, because three rows in three
* domains want it: the locker bank in the office, this in the street, and the
* server rack in computing. What stays here is the door itself, which is a box
* with a handle and is not worth sharing.
*
* Each bay gets its OWN pattern, chosen from the seeded random. That is what
* makes the reference look like a parcel locker rather than a filing cabinet:
* five bays, one of six equal doors, one of three tall ones, one of four small
* over two large. The distribution is the object.
*
* Measured off the reference: 2.0 m tall including a 120 mm recessed kick,
* 2.2 m across five bays, 600 deep, with a top cap standing slightly proud.
* Doors face +Z, which is where the kit's renderer stands -- written the other
* way round, as the utility cabinet was, every picture is of the back.
*/
import { type BufferGeometry } from 'three'
import {
bakeOcclusion,
chamferedBoxGeometry,
createKitModel,
createTinter,
jitter,
mergeColoured,
smoothNormals,
splitRuns,
type RuntimeContext,
} from '../core/index.ts'
export interface LockerBankConfig {
/** Width across all bays (metres). */
readonly width: number
/** Height including the kick (metres). */
readonly height: number
/** Depth front to back (metres). */
readonly depth: number
/** Bays across. */
readonly bays: number
/** How far every door swings, 0 shut to 1 wide. */
readonly open: number
readonly seed: number
}
export const lockerBankDefaults: LockerBankConfig = {
width: 2.2,
height: 2,
depth: 0.6,
bays: 5,
open: 0,
seed: 17,
}
export type LockerBankParts = 'carcass' | 'doors'
export interface LockerBankActions {
/** Swings every door. 1 is wide open. */
open(amount?: number): void
}
function applyOpen(
runtime: RuntimeContext<LockerBankConfig, LockerBankParts>,
amount: number,
): void {
runtime.parts.doors.anchor.rotation.y = -Math.min(1, Math.max(0, amount)) * 1.9
}
export function createModel(overrides: Partial<LockerBankConfig> = {}) {
let heldOpen = 0
let seenOpen = Number.NaN
return createKitModel<
LockerBankConfig, 'steelPainted', LockerBankParts, LockerBankActions
>({
id: 'parcel-locker-bank',
defaults: lockerBankDefaults,
slots: ['steelPainted'],
build: ({ config, random }) => {
const tint = createTinter(random)
const W = Math.min(4, Math.max(0.6, config.width))
const H = Math.min(2.6, Math.max(1, config.height))
const D = Math.min(1, Math.max(0.3, config.depth))
const bays = Math.max(1, Math.round(config.bays))
// Small lifts only: the palette's painted steel measures 0.202 in linear
// lightness and anything past -0.19 clamps to black, which the utility
// cabinet found the hard way.
const paint = tint('steelPainted', 0.02 + jitter(random, 0.015))
const shade = tint('steelPainted', -0.1, 0.5)
const dark = tint('steelPainted', -0.15, 0.4)
const kickH = H * 0.06
const capH = H * 0.026
const frontH = H - kickH - capH
const frontY = kickH + frontH / 2
const doorT = D * 0.03
const faceZ = D / 2
const carcassPieces: BufferGeometry[] = []
const doorPieces: BufferGeometry[] = []
/* ------------------------------------------------------------ carcass */
carcassPieces.push(chamferedBoxGeometry(
[W, D], [W, D], frontH, W * 0.004, [0, frontY, 0], shade,
))
// The kick, set back so the bank reads as standing on a plinth rather
// than sitting on the floor.
carcassPieces.push(chamferedBoxGeometry(
[W * 0.985, D * 0.94], [W * 0.985, D * 0.94],
kickH, W * 0.004, [0, kickH / 2, 0], dark,
))
// The cap, standing proud all round, which is what stops the top edge
// reading as a cut.
carcassPieces.push(chamferedBoxGeometry(
[W * 1.012, D * 1.02], [W * 1.008, D * 1.016],
capH, W * 0.004, [0, H - capH / 2, 0], paint,
))
/**
* The bays, and then the tiers inside each one.
*
* Both splits go through the same helper. The bays are equal, so their
* weights are all one; the tiers are not, and each bay draws its own
* pattern. The patterns are written as weights rather than as heights so
* they hold whatever the bank's height is set to.
*/
const PATTERNS: ReadonlyArray<readonly number[]> = [
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[2, 3, 3],
[1, 1, 1, 1, 3, 3],
[2, 2, 2, 3, 3],
[1, 1, 2, 2, 2],
]
const gap = H * 0.006
const bayRuns = splitRuns(W * 0.99, gap * 1.4, Array.from({ length: bays }, () => 1))
/**
* One PAIR of bays is merged, and its lower half carries doors spanning
* both.
*
* Varying only the tier heights gives a bank whose every door is one bay
* wide, and a parcel locker's whole reason for existing is that a parcel
* is not one size. The reference has two double-width doors stacked
* across its right-hand pair, and without them the distribution is a
* column exercise rather than the harder case the catalogue asked for.
*
* The merge is seeded and it is one pair, not a scatter: two adjacent
* bays give up their bottom two thirds to a pair of wide doors and keep
* their own patterns above.
*/
const mergeAt = bays >= 2 ? Math.floor(random() * (bays - 1)) : -1
const mergeFrac = 0.56
const chosen = new Set<number>()
const patternFor = (): readonly number[] => {
// Drawn WITHOUT replacement while there are patterns left, so five bays
// get five different divisions. Drawn with replacement the seeded pick
// repeats and the bank comes out looking ruled.
for (let tries = 0; tries < 24; tries += 1) {
const i = Math.floor(random() * PATTERNS.length) % PATTERNS.length
if (!chosen.has(i) || chosen.size >= PATTERNS.length) {
chosen.add(i)
return PATTERNS[i]!
}
}
return PATTERNS[0]!
}
/** One door, plus the handle that is the only thing distinguishing it. */
const addDoor = (x: number, y: number, w: number, h: number): void => {
doorPieces.push(chamferedBoxGeometry(
[w, doorT], [w * 0.995, doorT],
h, Math.min(w, h) * 0.035, [x, y, faceZ + doorT / 2], paint,
))
/*
* The handle: a RECESSED slot in a surround, not a lever.
*
* A parcel locker's door is flush, because there is nothing to catch a
* shoulder on in a doorway, so the grip is pressed into the skin. The
* surround is what makes it read -- a bare slot at this size is a mark,
* and the reference's has a clear rectangular border round it.
*/
const grip = Math.min(h * 0.5, W * 0.045)
const gx = x - w * 0.4
doorPieces.push(chamferedBoxGeometry(
[grip * 0.62, doorT * 0.55], [grip * 0.58, doorT * 0.45],
grip, grip * 0.1, [gx, y, faceZ + doorT * 1.05], shade,
))
doorPieces.push(chamferedBoxGeometry(
[grip * 0.3, doorT * 0.4], [grip * 0.26, doorT * 0.3],
grip * 0.6, grip * 0.06, [gx, y, faceZ + doorT * 1.25], dark,
))
}
for (let b = 0; b < bayRuns.length; b += 1) {
const bay = bayRuns[b]!
const merged = b === mergeAt || b === mergeAt + 1
// A merged bay keeps only its top, so the wide doors below have room.
const span = merged ? frontH * (1 - mergeFrac) : frontH * 0.985
const top = merged ? frontY + frontH / 2 - span / 2 : frontY
for (const tier of splitRuns(span, gap, patternFor())) {
addDoor(bay.at, top + tier.at, bay.size * 0.97, tier.size)
}
}
if (mergeAt >= 0) {
const left = bayRuns[mergeAt]!
const right = bayRuns[mergeAt + 1]!
const wide = (right.at + right.size / 2) - (left.at - left.size / 2)
const centre = (left.at - left.size / 2 + right.at + right.size / 2) / 2
const span = frontH * mergeFrac - gap
const top = frontY - frontH / 2 + span / 2
for (const tier of splitRuns(span, gap, [1, 1])) {
addDoor(centre, top + tier.at, wide * 0.985, tier.size)
}
}
bakeOcclusion(carcassPieces, { strength: 0.4 })
bakeOcclusion(doorPieces, { strength: 0.35 })
// Every door turns about the bank's left edge, which is a simplification
// the catalogue's own action signature invites -- it asks for individual
// doors, and a per-door part would be forty parts and forty anchors. One
// hinge line demonstrates the mechanism; a consumer who needs one door
// open takes the geometry and does it themselves.
const hinge = [-W / 2, 0, faceZ] as const
return {
carcass: {
slot: 'steelPainted' as const,
geometry: smoothNormals(mergeColoured(carcassPieces), 30),
},
doors: {
slot: 'steelPainted' as const,
geometry: smoothNormals(
mergeColoured(doorPieces).translate(-hinge[0], -hinge[1], -hinge[2]), 30,
),
origin: hinge,
},
}
},
actions: (runtime) => {
heldOpen = runtime.getConfig().open
seenOpen = heldOpen
applyOpen(runtime, heldOpen)
return { open: (amount = 1) => { heldOpen = amount; applyOpen(runtime, amount) } }
},
update: (_dt, runtime) => {
const wanted = runtime.getConfig().open
if (wanted !== seenOpen) { seenOpen = wanted; heldOpen = wanted }
applyOpen(runtime, heldOpen)
},
}, 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:50:02.664Z", "installedAt": "2026-09-04T21:58:20.088Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\core\\geometry.ts", "path": "src\\models\\contemporary-props\\core\\geometry.ts",
"sourceHash": "55264ad5b0fb9d38a0cda123512a60153268a7811870c6f29bf4d50417ed53c5" "sourceHash": "16b8b34c15140d166051dc58cd7d3da378cdf0deb72695b05ce226bb1617458b"
}, },
{ {
"path": "src\\models\\contemporary-props\\core\\index.ts", "path": "src\\models\\contemporary-props\\core\\index.ts",
"sourceHash": "a68393faddbe74c84bff778fb63dc3bb52ce85e73def8127acb88f637a32491f" "sourceHash": "650d95332e3a21a39ad1042dafdd2a4d11d6df92745ebd0a056878c056712b2d"
}, },
{ {
"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:50:02.669Z", "installedAt": "2026-09-04T21:58:20.094Z",
"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:50:02.671Z", "installedAt": "2026-09-04T21:58:20.095Z",
"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:50:02.683Z", "installedAt": "2026-09-04T21:58:20.109Z",
"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:50:02.685Z", "installedAt": "2026-09-04T21:58:20.112Z",
"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:50:02.689Z", "installedAt": "2026-09-04T21:58:20.117Z",
"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:50:02.693Z", "installedAt": "2026-09-04T21:58:20.121Z",
"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:50:02.693Z", "installedAt": "2026-09-04T21:58:20.121Z",
"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:50:02.686Z", "installedAt": "2026-09-04T21:58:20.114Z",
"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:50:02.673Z", "installedAt": "2026-09-04T21:58:20.098Z",
"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:50:02.672Z", "installedAt": "2026-09-04T21:58:20.097Z",
"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:50:02.681Z", "installedAt": "2026-09-04T21:58:20.108Z",
"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:50:02.675Z", "installedAt": "2026-09-04T21:58:20.099Z",
"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:50:02.684Z", "installedAt": "2026-09-04T21:58:20.111Z",
"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:50:02.679Z", "installedAt": "2026-09-04T21:58:20.105Z",
"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:50:02.680Z", "installedAt": "2026-09-04T21:58:20.107Z",
"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:50:02.676Z", "installedAt": "2026-09-04T21:58:20.101Z",
"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:50:02.667Z", "installedAt": "2026-09-04T21:58:20.091Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\bike-rack\\model.ts", "path": "src\\models\\contemporary-props\\bike-rack\\model.ts",
@ -972,7 +972,7 @@
"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:50:02.668Z", "installedAt": "2026-09-04T21:58:20.092Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\cable-drum\\model.ts", "path": "src\\models\\contemporary-props\\cable-drum\\model.ts",
@ -987,7 +987,7 @@
"address": "@contemporary-props/litter-bin", "address": "@contemporary-props/litter-bin",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:50:02.677Z", "installedAt": "2026-09-04T21:58:20.102Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\litter-bin\\model.ts", "path": "src\\models\\contemporary-props\\litter-bin\\model.ts",
@ -1002,7 +1002,7 @@
"address": "@contemporary-props/temporary-fence-panel", "address": "@contemporary-props/temporary-fence-panel",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:50:02.688Z", "installedAt": "2026-09-04T21:58:20.115Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\temporary-fence-panel\\model.ts", "path": "src\\models\\contemporary-props\\temporary-fence-panel\\model.ts",
@ -1017,7 +1017,7 @@
"address": "@contemporary-props/wheeled-waste-bin", "address": "@contemporary-props/wheeled-waste-bin",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:50:02.691Z", "installedAt": "2026-09-04T21:58:20.120Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\wheeled-waste-bin\\model.ts", "path": "src\\models\\contemporary-props\\wheeled-waste-bin\\model.ts",
@ -1032,7 +1032,7 @@
"address": "@contemporary-props/utility-cabinet", "address": "@contemporary-props/utility-cabinet",
"source": "file:contemporary-props/dist/registry.json", "source": "file:contemporary-props/dist/registry.json",
"version": "workspace", "version": "workspace",
"installedAt": "2026-09-04T21:50:02.690Z", "installedAt": "2026-09-04T21:58:20.118Z",
"files": [ "files": [
{ {
"path": "src\\models\\contemporary-props\\utility-cabinet\\model.ts", "path": "src\\models\\contemporary-props\\utility-cabinet\\model.ts",
@ -1042,6 +1042,21 @@
"dependencies": [ "dependencies": [
"three@>=0.185.0" "three@>=0.185.0"
] ]
},
"@contemporary-props/parcel-locker-bank": {
"address": "@contemporary-props/parcel-locker-bank",
"source": "file:contemporary-props/dist/registry.json",
"version": "workspace",
"installedAt": "2026-09-04T21:58:20.104Z",
"files": [
{
"path": "src\\models\\contemporary-props\\parcel-locker-bank\\model.ts",
"sourceHash": "e84f1c56a5aaefc28d7a3035f3c8c41a79f29d2ae9158297a3f7cbf49d66c99d"
}
],
"dependencies": [
"three@>=0.185.0"
]
} }
} }
} }

View file

@ -70,6 +70,7 @@ import { createModel as createCpLitterBin } from '@/models/contemporary-props/li
import { createModel as createCpFencePanel } from '@/models/contemporary-props/temporary-fence-panel/model.ts' import { createModel as createCpFencePanel } from '@/models/contemporary-props/temporary-fence-panel/model.ts'
import { createModel as createCpWasteBin } from '@/models/contemporary-props/wheeled-waste-bin/model.ts' import { createModel as createCpWasteBin } from '@/models/contemporary-props/wheeled-waste-bin/model.ts'
import { createModel as createCpUtilityCabinet } from '@/models/contemporary-props/utility-cabinet/model.ts' import { createModel as createCpUtilityCabinet } from '@/models/contemporary-props/utility-cabinet/model.ts'
import { createModel as createCpLockerBank } from '@/models/contemporary-props/parcel-locker-bank/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'
@ -91,6 +92,7 @@ import cpMetaLitterBin from '../contemporary-props/models/litter-bin/meta.json'
import cpMetaFencePanel from '../contemporary-props/models/temporary-fence-panel/meta.json' import cpMetaFencePanel from '../contemporary-props/models/temporary-fence-panel/meta.json'
import cpMetaWasteBin from '../contemporary-props/models/wheeled-waste-bin/meta.json' import cpMetaWasteBin from '../contemporary-props/models/wheeled-waste-bin/meta.json'
import cpMetaUtilityCabinet from '../contemporary-props/models/utility-cabinet/meta.json' import cpMetaUtilityCabinet from '../contemporary-props/models/utility-cabinet/meta.json'
import cpMetaLockerBank from '../contemporary-props/models/parcel-locker-bank/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'
@ -316,6 +318,7 @@ const CONTEMPORARY_ORDER = [
'temporary-fence-panel', 'temporary-fence-panel',
'wheeled-waste-bin', 'wheeled-waste-bin',
'utility-cabinet', 'utility-cabinet',
'parcel-locker-bank',
] as const ] as const
const CONTEMPORARY_FACTORIES: Readonly<Record<string, () => KitModel>> = { const CONTEMPORARY_FACTORIES: Readonly<Record<string, () => KitModel>> = {
@ -338,6 +341,7 @@ const CONTEMPORARY_FACTORIES: Readonly<Record<string, () => KitModel>> = {
'temporary-fence-panel': as(createCpFencePanel), 'temporary-fence-panel': as(createCpFencePanel),
'wheeled-waste-bin': as(createCpWasteBin), 'wheeled-waste-bin': as(createCpWasteBin),
'utility-cabinet': as(createCpUtilityCabinet), 'utility-cabinet': as(createCpUtilityCabinet),
'parcel-locker-bank': as(createCpLockerBank),
'pavement-sign-board': as(createCpPavementSign), 'pavement-sign-board': as(createCpPavementSign),
'lectern': as(createCpLectern), 'lectern': as(createCpLectern),
} }
@ -362,6 +366,7 @@ const CONTEMPORARY_META: Readonly<Record<string, ModelMetaLike>> = {
'temporary-fence-panel': cpMetaFencePanel as ModelMetaLike, 'temporary-fence-panel': cpMetaFencePanel as ModelMetaLike,
'wheeled-waste-bin': cpMetaWasteBin as ModelMetaLike, 'wheeled-waste-bin': cpMetaWasteBin as ModelMetaLike,
'utility-cabinet': cpMetaUtilityCabinet as ModelMetaLike, 'utility-cabinet': cpMetaUtilityCabinet as ModelMetaLike,
'parcel-locker-bank': cpMetaLockerBank as ModelMetaLike,
'pavement-sign-board': cpMetaPavementSign as ModelMetaLike, 'pavement-sign-board': cpMetaPavementSign as ModelMetaLike,
'lectern': cpMetaLectern as ModelMetaLike, 'lectern': cpMetaLectern as ModelMetaLike,
} }

View file

@ -303,6 +303,48 @@ export function latheGeometry(
return finish(sink) return finish(sink)
} }
/** One run in a split: where it starts, and how long it is. */
export interface Run {
/** Centre of the run along the axis, measured from the middle of the total. */
readonly at: number
readonly size: number
}
/**
* Divides a length into runs of given relative weights, with a gap between
* each, so the whole thing fills EXACTLY.
*
* This is the slat-with-gap rule the park bench settled, generalised to runs
* that are not all the same size. That rule -- derive the pitch from the run
* and the count, never type it -- only handles equal divisions, and the moment
* a bank has one tall door and four short ones it is no longer enough.
*
* Three rows in three domains want the unequal version: the locker bank (44,
* office), the parcel locker bank (50, street, which the catalogue calls the
* generator's harder case) and the server rack (78, computing). The arithmetic
* is the shared part; the box each run gets filled with is not, and stays in
* the models.
*
* Weights are relative, so [1, 1, 2] is two shorts and a double whatever the
* total is, and changing the total moves every run rather than leaving a
* remainder at one end.
*/
export function splitRuns(total: number, gap: number, weights: readonly number[]): Run[] {
if (weights.length === 0) return []
const sum = weights.reduce((a, b) => a + b, 0)
if (sum <= 0) return []
// The gaps come out of the total first; whatever is left is shared by weight.
const usable = Math.max(0, total - gap * (weights.length - 1))
const runs: Run[] = []
let cursor = -total / 2
for (const weight of weights) {
const size = (usable * weight) / sum
runs.push({ at: cursor + size / 2, size })
cursor += size + gap
}
return runs
}
/** /**
* A bank of louvres: pressed blades in an opening, facing -Z. * A bank of louvres: pressed blades in an opening, facing -Z.
* *

View file

@ -20,12 +20,13 @@ export {
roughenGeometry, roughenGeometry,
staveGeometry, staveGeometry,
smoothNormals, smoothNormals,
splitRuns,
tubeGeometry, tubeGeometry,
wheelGeometry, wheelGeometry,
taperedBoxGeometry, taperedBoxGeometry,
} from './geometry.ts' } from './geometry.ts'
export type { Level, MottleOptions, PerforationPattern, Plan, PlanLevel, export type { Level, MottleOptions, PerforationPattern, Plan, PlanLevel,
Wheel, RoughenOptions, Section, SheetLevel, Vec3 } from './geometry.ts' Run, Wheel, 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,269 @@
/**
* @contemporary-props/parcel-locker-bank
*
* A door grid with a mixed size distribution, which the catalogue calls the
* locker generator's harder case and puts fiftieth. The easy case is a grid:
* every door the same, pitch derived from the count, and the park bench settled
* that rule at row thirteen. This is what happens when the doors are not all
* the same size, which is the whole point of a parcel locker -- a bank that can
* only take one size of parcel is a bank half of which is always empty.
*
* The arithmetic lives in core as `splitRuns`, because three rows in three
* domains want it: the locker bank in the office, this in the street, and the
* server rack in computing. What stays here is the door itself, which is a box
* with a handle and is not worth sharing.
*
* Each bay gets its OWN pattern, chosen from the seeded random. That is what
* makes the reference look like a parcel locker rather than a filing cabinet:
* five bays, one of six equal doors, one of three tall ones, one of four small
* over two large. The distribution is the object.
*
* Measured off the reference: 2.0 m tall including a 120 mm recessed kick,
* 2.2 m across five bays, 600 deep, with a top cap standing slightly proud.
* Doors face +Z, which is where the kit's renderer stands -- written the other
* way round, as the utility cabinet was, every picture is of the back.
*/
import { type BufferGeometry } from 'three'
import {
bakeOcclusion,
chamferedBoxGeometry,
createKitModel,
createTinter,
jitter,
mergeColoured,
smoothNormals,
splitRuns,
type RuntimeContext,
} from '../core/index.ts'
export interface LockerBankConfig {
/** Width across all bays (metres). */
readonly width: number
/** Height including the kick (metres). */
readonly height: number
/** Depth front to back (metres). */
readonly depth: number
/** Bays across. */
readonly bays: number
/** How far every door swings, 0 shut to 1 wide. */
readonly open: number
readonly seed: number
}
export const lockerBankDefaults: LockerBankConfig = {
width: 2.2,
height: 2,
depth: 0.6,
bays: 5,
open: 0,
seed: 17,
}
export type LockerBankParts = 'carcass' | 'doors'
export interface LockerBankActions {
/** Swings every door. 1 is wide open. */
open(amount?: number): void
}
function applyOpen(
runtime: RuntimeContext<LockerBankConfig, LockerBankParts>,
amount: number,
): void {
runtime.parts.doors.anchor.rotation.y = -Math.min(1, Math.max(0, amount)) * 1.9
}
export function createModel(overrides: Partial<LockerBankConfig> = {}) {
let heldOpen = 0
let seenOpen = Number.NaN
return createKitModel<
LockerBankConfig, 'steelPainted', LockerBankParts, LockerBankActions
>({
id: 'parcel-locker-bank',
defaults: lockerBankDefaults,
slots: ['steelPainted'],
build: ({ config, random }) => {
const tint = createTinter(random)
const W = Math.min(4, Math.max(0.6, config.width))
const H = Math.min(2.6, Math.max(1, config.height))
const D = Math.min(1, Math.max(0.3, config.depth))
const bays = Math.max(1, Math.round(config.bays))
// Small lifts only: the palette's painted steel measures 0.202 in linear
// lightness and anything past -0.19 clamps to black, which the utility
// cabinet found the hard way.
const paint = tint('steelPainted', 0.02 + jitter(random, 0.015))
const shade = tint('steelPainted', -0.1, 0.5)
const dark = tint('steelPainted', -0.15, 0.4)
const kickH = H * 0.06
const capH = H * 0.026
const frontH = H - kickH - capH
const frontY = kickH + frontH / 2
const doorT = D * 0.03
const faceZ = D / 2
const carcassPieces: BufferGeometry[] = []
const doorPieces: BufferGeometry[] = []
/* ------------------------------------------------------------ carcass */
carcassPieces.push(chamferedBoxGeometry(
[W, D], [W, D], frontH, W * 0.004, [0, frontY, 0], shade,
))
// The kick, set back so the bank reads as standing on a plinth rather
// than sitting on the floor.
carcassPieces.push(chamferedBoxGeometry(
[W * 0.985, D * 0.94], [W * 0.985, D * 0.94],
kickH, W * 0.004, [0, kickH / 2, 0], dark,
))
// The cap, standing proud all round, which is what stops the top edge
// reading as a cut.
carcassPieces.push(chamferedBoxGeometry(
[W * 1.012, D * 1.02], [W * 1.008, D * 1.016],
capH, W * 0.004, [0, H - capH / 2, 0], paint,
))
/**
* The bays, and then the tiers inside each one.
*
* Both splits go through the same helper. The bays are equal, so their
* weights are all one; the tiers are not, and each bay draws its own
* pattern. The patterns are written as weights rather than as heights so
* they hold whatever the bank's height is set to.
*/
const PATTERNS: ReadonlyArray<readonly number[]> = [
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[2, 3, 3],
[1, 1, 1, 1, 3, 3],
[2, 2, 2, 3, 3],
[1, 1, 2, 2, 2],
]
const gap = H * 0.006
const bayRuns = splitRuns(W * 0.99, gap * 1.4, Array.from({ length: bays }, () => 1))
/**
* One PAIR of bays is merged, and its lower half carries doors spanning
* both.
*
* Varying only the tier heights gives a bank whose every door is one bay
* wide, and a parcel locker's whole reason for existing is that a parcel
* is not one size. The reference has two double-width doors stacked
* across its right-hand pair, and without them the distribution is a
* column exercise rather than the harder case the catalogue asked for.
*
* The merge is seeded and it is one pair, not a scatter: two adjacent
* bays give up their bottom two thirds to a pair of wide doors and keep
* their own patterns above.
*/
const mergeAt = bays >= 2 ? Math.floor(random() * (bays - 1)) : -1
const mergeFrac = 0.56
const chosen = new Set<number>()
const patternFor = (): readonly number[] => {
// Drawn WITHOUT replacement while there are patterns left, so five bays
// get five different divisions. Drawn with replacement the seeded pick
// repeats and the bank comes out looking ruled.
for (let tries = 0; tries < 24; tries += 1) {
const i = Math.floor(random() * PATTERNS.length) % PATTERNS.length
if (!chosen.has(i) || chosen.size >= PATTERNS.length) {
chosen.add(i)
return PATTERNS[i]!
}
}
return PATTERNS[0]!
}
/** One door, plus the handle that is the only thing distinguishing it. */
const addDoor = (x: number, y: number, w: number, h: number): void => {
doorPieces.push(chamferedBoxGeometry(
[w, doorT], [w * 0.995, doorT],
h, Math.min(w, h) * 0.035, [x, y, faceZ + doorT / 2], paint,
))
/*
* The handle: a RECESSED slot in a surround, not a lever.
*
* A parcel locker's door is flush, because there is nothing to catch a
* shoulder on in a doorway, so the grip is pressed into the skin. The
* surround is what makes it read -- a bare slot at this size is a mark,
* and the reference's has a clear rectangular border round it.
*/
const grip = Math.min(h * 0.5, W * 0.045)
const gx = x - w * 0.4
doorPieces.push(chamferedBoxGeometry(
[grip * 0.62, doorT * 0.55], [grip * 0.58, doorT * 0.45],
grip, grip * 0.1, [gx, y, faceZ + doorT * 1.05], shade,
))
doorPieces.push(chamferedBoxGeometry(
[grip * 0.3, doorT * 0.4], [grip * 0.26, doorT * 0.3],
grip * 0.6, grip * 0.06, [gx, y, faceZ + doorT * 1.25], dark,
))
}
for (let b = 0; b < bayRuns.length; b += 1) {
const bay = bayRuns[b]!
const merged = b === mergeAt || b === mergeAt + 1
// A merged bay keeps only its top, so the wide doors below have room.
const span = merged ? frontH * (1 - mergeFrac) : frontH * 0.985
const top = merged ? frontY + frontH / 2 - span / 2 : frontY
for (const tier of splitRuns(span, gap, patternFor())) {
addDoor(bay.at, top + tier.at, bay.size * 0.97, tier.size)
}
}
if (mergeAt >= 0) {
const left = bayRuns[mergeAt]!
const right = bayRuns[mergeAt + 1]!
const wide = (right.at + right.size / 2) - (left.at - left.size / 2)
const centre = (left.at - left.size / 2 + right.at + right.size / 2) / 2
const span = frontH * mergeFrac - gap
const top = frontY - frontH / 2 + span / 2
for (const tier of splitRuns(span, gap, [1, 1])) {
addDoor(centre, top + tier.at, wide * 0.985, tier.size)
}
}
bakeOcclusion(carcassPieces, { strength: 0.4 })
bakeOcclusion(doorPieces, { strength: 0.35 })
// Every door turns about the bank's left edge, which is a simplification
// the catalogue's own action signature invites -- it asks for individual
// doors, and a per-door part would be forty parts and forty anchors. One
// hinge line demonstrates the mechanism; a consumer who needs one door
// open takes the geometry and does it themselves.
const hinge = [-W / 2, 0, faceZ] as const
return {
carcass: {
slot: 'steelPainted' as const,
geometry: smoothNormals(mergeColoured(carcassPieces), 30),
},
doors: {
slot: 'steelPainted' as const,
geometry: smoothNormals(
mergeColoured(doorPieces).translate(-hinge[0], -hinge[1], -hinge[2]), 30,
),
origin: hinge,
},
}
},
actions: (runtime) => {
heldOpen = runtime.getConfig().open
seenOpen = heldOpen
applyOpen(runtime, heldOpen)
return { open: (amount = 1) => { heldOpen = amount; applyOpen(runtime, amount) } }
},
update: (_dt, runtime) => {
const wanted = runtime.getConfig().open
if (wanted !== seenOpen) { seenOpen = wanted; heldOpen = wanted }
applyOpen(runtime, heldOpen)
},
}, overrides)
}