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:
parent
3c86896f73
commit
2e25e91d80
10 changed files with 698 additions and 29 deletions
16
contemporary-props/models/parcel-locker-bank/meta.json
Normal file
16
contemporary-props/models/parcel-locker-bank/meta.json
Normal 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"]
|
||||
}
|
||||
269
contemporary-props/models/parcel-locker-bank/model.ts
Normal file
269
contemporary-props/models/parcel-locker-bank/model.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue