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
13
PROGRESS.md
13
PROGRESS.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
269
src/models/contemporary-props/parcel-locker-bank/model.ts
Normal file
269
src/models/contemporary-props/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