Add the bus shelter, glazed in the clear slot rather than the tinted one because its whole job is being seen through, and record the check camera's blind spot: an extent along Z cannot be judged from a view that looks down Z.

This commit is contained in:
CRTkafa 2026-09-05 06:07:46 +07:00
parent 9db9ffcbeb
commit 948078edde
6 changed files with 776 additions and 30 deletions

View file

@ -0,0 +1,350 @@
/**
* @contemporary-props/bus-shelter
*
* A cantilevered roof over glazed bays: the largest thing in the kit that is
* still a thing rather than a building, which is what the catalogue puts it
* ninety-ninth for and is the line this model is built to stay on the right
* side of. A shelter is furniture. It has four feet you can see all of, a roof
* you can see under, and no floor -- and the moment it gets a floor or a door
* it stops being a prop somebody drops into a street and starts being
* architecture somebody has to model a street around.
*
* Measured off the reference against a 2.4 m shelter: 3.2 m long, 1.4 m deep,
* a roof overhanging 0.3 at the front and 0.15 behind, glazing from 0.32 up to
* the roof beam, a seat at 0.45 and its back rail at 0.75.
*
* The open side faces +Z, where the kit's renderer stands. Three models this
* session were written the other way round and photographed from behind.
*
* Everything repeated here goes through `splitRuns`: the glazed bays across the
* back, and the slats of the seat. It is the fourth and fifth use of a rule the
* park bench settled -- derive the pitch, never type it -- and on an object
* this size it is what stops a change of length leaving a sliver at one end.
*/
import { type BufferGeometry } from 'three'
import {
bakeOcclusion,
chamferedBoxGeometry,
createKitModel,
createTinter,
jitter,
mergeColoured,
smoothNormals,
splitRuns,
} from '../core/index.ts'
export interface BusShelterConfig {
/** Length along the kerb (metres). */
readonly length: number
/** Height to the top of the roof (metres). */
readonly height: number
/** Depth front to back (metres). */
readonly depth: number
/** Glazed bays across the back. */
readonly bays: number
/** Slats in the seat. */
readonly slats: number
readonly seed: number
}
export const busShelterDefaults: BusShelterConfig = {
length: 3.2,
height: 2.4,
depth: 1.4,
bays: 2,
slats: 5,
seed: 43,
}
export type BusShelterParts = 'frame' | 'glass' | 'seat'
export function createModel(overrides: Partial<BusShelterConfig> = {}) {
return createKitModel<
BusShelterConfig, 'aluminium' | 'glass', BusShelterParts, Record<string, never>
>({
id: 'bus-shelter',
defaults: busShelterDefaults,
slots: ['aluminium', 'glass'],
build: ({ config, random }) => {
const tint = createTinter(random)
const L = Math.min(8, Math.max(1.4, config.length))
const H = Math.min(3.2, Math.max(1.8, config.height))
const D = Math.min(2.6, Math.max(0.8, config.depth))
const bays = Math.max(1, Math.round(config.bays))
const slats = Math.max(2, Math.round(config.slats))
const alu = tint('aluminium', 0.04 + jitter(random, 0.015))
const shade = tint('aluminium', -0.06, 0.5)
/*
* CLEAR glass, not the tinted slot.
*
* `glassTinted` is a smoked panel at 0x3a4750 and it is the right slot
* for something meant to be looked AT -- the street lamp's unlit lens
* uses it one row back. A shelter's glazing is toughened and clear,
* because its whole job is that a waiting passenger can see the bus
* coming and a passing driver can see the passenger. Glazed in the
* tinted slot the bays read as filled-in dark panels and the shelter
* turns into a shed.
*/
const glass = tint('glass', 0.02, 0.4)
// 0.045 of the total height, measured off the reference. A shelter's
// posts carry a roof in wind and they are stockier than they look in a
// drawing; at 0.037 the whole frame reads as wire.
const post = H * 0.045
/*
* The roof is a DEEP slab, and that is measured rather than chosen.
*
* On the reference the slab is 0.119 of the clear height under it. Built
* at 0.05 -- the thickness a roof wants to be if you think of it as a
* lid -- it reads as a sheet of card on four legs, which is what the
* first render of this was. A shelter's canopy is that deep because the
* drainage and the lighting live inside it.
*/
const roofH = H * 0.1
const beamY = H - roofH
const backZ = -D / 2 + post / 2
const frontZ = D / 2 - post / 2
const halfL = L / 2 - post / 2
const framePieces: BufferGeometry[] = []
const glassPieces: BufferGeometry[] = []
const seatPieces: BufferGeometry[] = []
/* -------------------------------------------------------------- posts */
/**
* Four posts on bolted feet, and the feet matter.
*
* A shelter that meets the pavement as four bare tubes reads as a model
* standing on a table. Every real one is bolted down through a plate at
* each corner, and those plates are the only thing in the object that
* says it was installed rather than placed.
*/
const footH = H * 0.008
for (const x of [-halfL, halfL]) {
for (const z of [backZ, frontZ]) {
framePieces.push(chamferedBoxGeometry(
[post, post], [post * 0.96, post * 0.96],
beamY - footH, post * 0.09, [x, footH + (beamY - footH) / 2, z], alu,
))
framePieces.push(chamferedBoxGeometry(
[post * 2.4, post * 2.4], [post * 2.2, post * 2.2],
footH, post * 0.1, [x, footH / 2, z], shade,
))
}
}
/* --------------------------------------------------------------- roof */
/**
* The roof CANTILEVERS, and that is the whole silhouette.
*
* It runs past the front posts by a fifth of the depth and past the back
* by half that, so from the side it is a slab floating on two legs rather
* than a lid sitting on a box. Built flush with the posts the shelter
* reads as a glass cabinet.
*/
/*
* The overhang, and it is asymmetric on purpose.
*
* The roof stands about 0.30 of the depth past the FRONT posts on the
* reference and half that behind, because the front is the side you
* stand under while the bus is arriving. Made even, the roof centres on
* the frame and the object loses the one thing that says which way it
* faces.
*/
const over = D * 0.26
// The bright cap is the THIN half and the fascia below it the deep one.
// Split evenly the roof reads as two slabs; the reference is a shallow
// tray sitting on a deep skirt, and the skirt is what casts the shadow
// that says there is somewhere to stand under it.
// The cap stands 2 mm proud of the fascia and no more. It cannot stand
// flush -- two boxes with the same footprint put their side faces on one
// plane over the band where they overlap, which is the fault the beams
// had -- but anything the eye can read as a step makes the roof two
// slabs, and the reference's is one.
const lip = 0.002
framePieces.push(chamferedBoxGeometry(
[L + post * 1.24 + lip * 2, D + over * 1.36 + lip * 2],
[L + post * 1.24 + lip * 2, D + over * 1.36 + lip * 2],
roofH * 0.34, post * 0.12, [0, beamY + roofH * 0.83, over * 0.3], alu,
))
// The fascia is ONE flat face: the same section top and bottom, with the
// cap standing a few millimetres proud of it as a lip. Written as a
// taper it steps in visibly and the roof reads as two slabs stacked
// rather than as a tray with a skirt.
framePieces.push(chamferedBoxGeometry(
[L + post * 1.24, D + over * 1.36], [L + post * 1.24, D + over * 1.36],
roofH * 0.68, post * 0.1, [0, beamY + roofH * 0.33, over * 0.3], shade,
))
/*
* The beams, front and back, spanning the posts.
*
* They hang a quarter of a post BELOW the post heads rather than finishing
* level with them. Written flush -- which is the obvious way to say `the
* roof sits on these` -- the beam's top face and the four post tops are
* eight faces on one plane at the exact height the eye is drawn to, and
* the renderer has no way to order them. Dropped, the posts run past the
* beam into the roof, which is also how one of these is actually built.
*/
for (const z of [backZ, frontZ]) {
framePieces.push(chamferedBoxGeometry(
[L + post, post * 1.1], [L + post, post * 1.1],
post * 1.5, post * 0.1, [0, beamY - post * 1.05, z], alu,
))
}
/* ------------------------------------------------------------ glazing */
/**
* The glass, in bays across the back and one pane at each end.
*
* The bays come from `splitRuns` so their mullions land where they land
* rather than where somebody typed, and the panes stop short of the frame
* on every side -- glass is held in a channel with a gasket, and a pane
* run right into the steel is the giveaway that nobody looked.
*/
const glassLo = H * 0.133
const glassHi = beamY - post * 1.5
const glassT = H * 0.005
const inset = post * 0.35
for (const run of splitRuns(L - post, post * 1.6, Array.from({ length: bays }, () => 1))) {
glassPieces.push(chamferedBoxGeometry(
[run.size - inset, glassT], [run.size - inset, glassT],
glassHi - glassLo - inset, glassT * 0.4,
[run.at, (glassLo + glassHi) / 2, backZ], glass,
))
}
// The mullions between the bays, which have to exist or the glass floats.
for (let i = 1; i < bays; i += 1) {
const x = -L / 2 + (L / bays) * i
framePieces.push(chamferedBoxGeometry(
[post * 0.7, post * 0.9], [post * 0.7, post * 0.9],
glassHi - glassLo, post * 0.08,
[x, (glassLo + glassHi) / 2, backZ], alu,
))
}
// The end panes.
for (const x of [-halfL, halfL]) {
glassPieces.push(chamferedBoxGeometry(
[glassT, D - post * 2 - inset], [glassT, D - post * 2 - inset],
glassHi - glassLo - inset, glassT * 0.4,
[x, (glassLo + glassHi) / 2, (backZ + frontZ) / 2], glass,
))
}
/*
* The CLIPS down each pane edge.
*
* Glass this size is not glued in; it is held by bolted clamps at
* intervals, and on the reference they are the only detail on an
* otherwise blank surface -- which makes them the thing that says the
* pane is glass rather than an absence. They go on the frame, not on the
* glass, so they shade with the frame.
*/
const clipR = post * 0.16
const clipAt = (x: number, z: number, along: 'x' | 'z'): void => {
for (let i = 0; i < 3; i += 1) {
const y = glassLo + (glassHi - glassLo) * (0.12 + i * 0.38)
const box = chamferedBoxGeometry(
along === 'x' ? [clipR * 2.6, clipR * 1.4] : [clipR * 1.4, clipR * 2.6],
along === 'x' ? [clipR * 2.2, clipR * 1.1] : [clipR * 1.1, clipR * 2.2],
clipR * 1.8, clipR * 0.3, [x, y, z], shade,
)
framePieces.push(box)
}
}
for (const x of [-halfL + post * 0.42, halfL - post * 0.42]) {
clipAt(x, backZ + post * 0.36, 'x')
clipAt(x + (x < 0 ? -post * 0.06 : post * 0.06), 0, 'z')
}
// The rail along the foot of the back glazing, which is what the panes
// actually stand in.
framePieces.push(chamferedBoxGeometry(
[L + post, post * 0.9], [L + post, post * 0.9],
post * 0.8, post * 0.1, [0, glassLo - post * 0.4, backZ], alu,
))
/*
* And the same rail under each END pane, which the first pass left out.
*
* Without them the side glazing has nothing along its bottom edge and
* hangs in the frame; the reference has a section there on all three
* sides, and it is the member the whole shelter is squared up by.
*
* They sit a fraction LOWER than the back rail and are narrower than the
* posts they run into. Level with it, the two rails cross at each back
* corner with their top faces on one plane, which is the same pair of
* unorderable faces the roof beams had; flush with the posts, their sides
* land 0.6 mm off the post faces, under the kit's 1 mm rule.
*/
for (const x of [-halfL, halfL]) {
framePieces.push(chamferedBoxGeometry(
[post * 0.7, D - post * 1.6], [post * 0.7, D - post * 1.6],
post * 0.8, post * 0.1, [x, glassLo - post * 0.52, 0], alu,
))
}
/* --------------------------------------------------------------- seat */
/**
* A cantilevered bench: slats on brackets off the back posts, with
* nothing under it.
*
* That is not a saving, it is the object. A shelter's seat hangs so the
* pavement under it can be swept and so nobody can leave anything behind
* it, and a bench with legs in here would read as a bench somebody
* carried in.
*/
// 0.237 of the clear height, off the reference. Written at 0.197 the
// bench is a step rather than a seat, and the gap up to the back rail
// opens far enough that the two stop reading as one piece of furniture.
const seatY = H * 0.213
const railY = H * 0.312
const seatD = D * 0.3
const slatRuns = splitRuns(seatD, seatD * 0.045, Array.from({ length: slats }, () => 1))
for (const run of slatRuns) {
seatPieces.push(chamferedBoxGeometry(
[L * 0.86, run.size], [L * 0.86, run.size],
post * 0.55, run.size * 0.18,
[0, seatY, backZ + post * 0.6 + seatD / 2 + run.at], alu,
))
}
/*
* The APRON along the front edge, which is what gives a bench its
* thickness.
*
* Slats alone are read from the side as a row of pencils: the seat has a
* visible depth of one slat and none of the mass a thing you sit on
* needs. Every bench of this pattern closes its front edge with a
* continuous section, and it is the member the slats are actually
* screwed down to.
*/
seatPieces.push(chamferedBoxGeometry(
[L * 0.86, post * 0.34], [L * 0.86, post * 0.34],
post * 0.7, post * 0.06,
[0, seatY - post * 0.44, backZ + post * 0.6 + seatD - post * 0.12], alu,
))
// The back rail, and the two brackets carrying the whole thing.
seatPieces.push(chamferedBoxGeometry(
[L * 0.86, post * 0.5], [L * 0.86, post * 0.5],
post * 1.5, post * 0.08, [0, railY, backZ + post * 0.75], alu,
))
for (const x of [-L * 0.3, L * 0.3]) {
seatPieces.push(chamferedBoxGeometry(
[post * 0.5, seatD * 0.9], [post * 0.5, seatD * 0.3],
post * 1.4, post * 0.08,
[x, seatY - post * 0.8, backZ + post * 0.6 + seatD * 0.45], shade,
))
}
bakeOcclusion(framePieces, { strength: 0.35 })
bakeOcclusion(seatPieces, { strength: 0.35 })
return {
frame: { slot: 'aluminium' as const, geometry: smoothNormals(mergeColoured(framePieces), 30) },
glass: { slot: 'glass' as const, geometry: smoothNormals(mergeColoured(glassPieces), 30) },
seat: { slot: 'aluminium' as const, geometry: smoothNormals(mergeColoured(seatPieces), 30) },
}
},
}, overrides)
}