Add the litter bin, and with it a perforation helper that punches a surface the caller describes rather than a cylinder it assumes; then catch with a new ground check the two models that were quietly hovering because a declared origin had placed them twice.
This commit is contained in:
parent
3f8831e41c
commit
9a9be5f313
13 changed files with 946 additions and 35 deletions
21
PROGRESS.md
21
PROGRESS.md
|
|
@ -209,6 +209,23 @@ is `latheGeometry`'s and always was: walk from the bottom of the outer wall
|
||||||
upward. Write the profile whichever way round reads best and reverse it at the
|
upward. Write the profile whichever way round reads best and reverse it at the
|
||||||
call site.
|
call site.
|
||||||
|
|
||||||
|
**A part with an origin is written in that origin's own space.** The kit does
|
||||||
|
not subtract a declared origin from world-written geometry -- it moves parts
|
||||||
|
into place only long enough to bake occlusion, and puts them back. Write the
|
||||||
|
geometry where it stands AND give it an origin and it is placed twice. The cable
|
||||||
|
drum shipped that way, a flange radius in the air, and so did the litter bin's
|
||||||
|
hood.
|
||||||
|
|
||||||
|
**Nothing was checking that a model touches the ground.** `findFloating`
|
||||||
|
measures a model against ITSELF -- it looks for pieces with nothing under them
|
||||||
|
-- so an assembly whose every piece is correctly stacked passes while the whole
|
||||||
|
thing hovers. `verify:props` now takes the precise bounding box and requires its
|
||||||
|
floor within a millimetre of zero. The tolerance is asymmetric on purpose: a
|
||||||
|
millimetre in the air is a model that will hover over any surface it is put on,
|
||||||
|
while a couple of millimetres under is the outermost corner of a chamfered foot
|
||||||
|
and the ground hides it, so sinking is allowed up to a percent of the model's
|
||||||
|
own height. The check found two more models the moment it was written.
|
||||||
|
|
||||||
**`latheGeometry` caps by default, and the cap on an annulus is a solid disc.**
|
**`latheGeometry` caps by default, and the cap on an annulus is a solid disc.**
|
||||||
The cable drum's flanges were built as an outer tube, an inner tube and two
|
The cable drum's flanges were built as an outer tube, an inner tube and two
|
||||||
annulus faces -- four pieces, two of them quietly filling in the hole the other
|
annulus faces -- four pieces, two of them quietly filling in the hole the other
|
||||||
|
|
@ -477,13 +494,13 @@ asking GitHub Support to purge actually closes it.
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
- **`contemporary-props`**: 17 of the core 100 modelled, and the build order is
|
- **`contemporary-props`**: 18 of the core 100 modelled, and the build order is
|
||||||
now DOMAIN FIRST. The catalogue's own section 5 argued against spreading the
|
now DOMAIN FIRST. The catalogue's own section 5 argued against spreading the
|
||||||
first hundred across nine domains and named the failure mode: nine domains at
|
first hundred across nine domains and named the failure mode: nine domains at
|
||||||
a third each, with the helper set shaped by whichever got built first. Fifteen
|
a third each, with the helper set shaped by whichever got built first. Fifteen
|
||||||
models in, that had happened -- five street, four kitchen, two tools, one each
|
models in, that had happened -- five street, four kitchen, two tools, one each
|
||||||
of four more -- so the remaining street rows are being finished before
|
of four more -- so the remaining street rows are being finished before
|
||||||
anything else starts. Ten to go after the cable drum. The reasoning is written into `CATALOGUE.md` under
|
anything else starts. Nine to go after the litter bin. The reasoning is written into `CATALOGUE.md` under
|
||||||
"Decided: street first"; the short version is that seventeen street objects is
|
"Decided: street first"; the short version is that seventeen street objects is
|
||||||
a scene somebody can assemble, and what carried the medieval kit was one
|
a scene somebody can assemble, and what carried the medieval kit was one
|
||||||
flythrough rather than a model count.
|
flythrough rather than a model count.
|
||||||
|
|
|
||||||
|
|
@ -264,14 +264,19 @@ export function createModel(overrides: Partial<CableDrumConfig> = {}) {
|
||||||
if (pieces.length === 0) return undefined
|
if (pieces.length === 0) return undefined
|
||||||
const merged = mergeColoured(pieces)
|
const merged = mergeColoured(pieces)
|
||||||
merged.rotateX(Math.PI / 2)
|
merged.rotateX(Math.PI / 2)
|
||||||
merged.translate(0, R, 0)
|
// ...and NOT lifted. A part that declares an origin is written in that
|
||||||
|
// origin's own space and the anchor carries it into place; the kit does
|
||||||
|
// not subtract the origin from world-written geometry, it only moves
|
||||||
|
// things temporarily to bake occlusion and moves them back. Lifting
|
||||||
|
// here as well put the whole drum a flange radius into the air, where
|
||||||
|
// it stayed, because the support check measures a model against itself
|
||||||
|
// rather than against the ground.
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every part declares the AXLE as its origin, which is what lets one
|
// Every part declares the AXLE as its origin, which is what lets one
|
||||||
// rotation roll all three together. The kit subtracts the origin from the
|
// rotation roll all three together, and every part's geometry is written
|
||||||
// geometry and puts the anchor there, so the geometry above stays written
|
// about that axle rather than about the ground.
|
||||||
// in world terms and nothing here has to be built twice.
|
|
||||||
const axle = [0, R, 0] as const
|
const axle = [0, R, 0] as const
|
||||||
const cable = tip(cablePieces)
|
const cable = tip(cablePieces)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,102 @@ export function latheGeometry(
|
||||||
return finish(sink)
|
return finish(sink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How a surface is punched, for `perforate`. */
|
||||||
|
export interface PerforationPattern {
|
||||||
|
/** Rows of holes up the surface. */
|
||||||
|
readonly rows: number
|
||||||
|
/** Columns of holes around it. */
|
||||||
|
readonly columns: number
|
||||||
|
/** Open fraction of each cell: 0 is solid, 1 is all hole. */
|
||||||
|
readonly open: number
|
||||||
|
/** Offset alternate rows by half a column, which is what a real punch does. */
|
||||||
|
readonly stagger?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Punches a surface, by building the METAL rather than the holes.
|
||||||
|
*
|
||||||
|
* There is no boolean subtraction in this kit and there is not going to be, so
|
||||||
|
* a perforated plate cannot be a plate with circles taken out of it. What it
|
||||||
|
* can be is the web between the holes, emitted directly: a ring of solid band
|
||||||
|
* at every row boundary, and a short bar between every pair of holes in the
|
||||||
|
* rows. Light passes through what is left, which is the entire point -- a bin
|
||||||
|
* you cannot see into is a drum with a pattern painted on it.
|
||||||
|
*
|
||||||
|
* The holes come out square rather than round, and that is a considered loss.
|
||||||
|
* At the size any of these objects is looked at -- a litter bin a metre tall,
|
||||||
|
* its holes 14 mm -- a hole is three pixels, and what reads is the PITCH and
|
||||||
|
* the open area, neither of which cares about the shape. Round holes at this
|
||||||
|
* density are a texture, and the kit has no textures.
|
||||||
|
*
|
||||||
|
* The surface is passed in as a function of (u, v), so this helper knows
|
||||||
|
* nothing about cylinders. That is deliberate and it is the whole reason it is
|
||||||
|
* written here rather than inside the litter bin: the catalogue defines
|
||||||
|
* perforation across three rows in three different domains -- the mesh waste
|
||||||
|
* bin (28, office, a tapered drum), the colander (29, kitchen, a curved shell,
|
||||||
|
* and the harder case) and the litter bin (30, street) -- and a helper that
|
||||||
|
* only knew how to punch a cylinder would be rewritten by the second of them.
|
||||||
|
* A caller that can describe its own surface gets punched for free.
|
||||||
|
*
|
||||||
|
* @param surface maps u (0..1 around, wrapping) and v (0..1 up) to a point.
|
||||||
|
*/
|
||||||
|
export function perforate(
|
||||||
|
surface: (u: number, v: number) => Vec3,
|
||||||
|
pattern: PerforationPattern,
|
||||||
|
colour: Color,
|
||||||
|
options: { readonly colourTop?: Color } = {},
|
||||||
|
): BufferGeometry {
|
||||||
|
const rows = Math.max(1, Math.round(pattern.rows))
|
||||||
|
const columns = Math.max(3, Math.round(pattern.columns))
|
||||||
|
const open = Math.min(0.92, Math.max(0.05, pattern.open))
|
||||||
|
const sink: Sink = { position: [], color: [] }
|
||||||
|
const top = options.colourTop ?? colour
|
||||||
|
|
||||||
|
const shade = (v: number): Color => new Color().copy(colour).lerp(top, v)
|
||||||
|
const quad = (a: Vec3, b: Vec3, c: Vec3, d: Vec3, cLo: Color, cHi: Color): void => {
|
||||||
|
triShaded(sink, a, b, c, cLo, cLo, cHi)
|
||||||
|
triShaded(sink, a, c, d, cLo, cHi, cHi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half the solid left at each boundary, in cell units.
|
||||||
|
const webV = (1 - open) / 2
|
||||||
|
const webU = (1 - open) / 2
|
||||||
|
|
||||||
|
// The rings: one at every row boundary, top and bottom included, so the
|
||||||
|
// punched band is closed by solid metal at both ends rather than ending on a
|
||||||
|
// row of half holes.
|
||||||
|
for (let i = 0; i <= rows; i += 1) {
|
||||||
|
const lo = Math.max(0, (i - webV) / rows)
|
||||||
|
const hi = Math.min(1, (i + webV) / rows)
|
||||||
|
if (hi - lo <= 1e-6) continue
|
||||||
|
const cLo = shade(lo)
|
||||||
|
const cHi = shade(hi)
|
||||||
|
for (let j = 0; j < columns; j += 1) {
|
||||||
|
const u0 = j / columns
|
||||||
|
const u1 = (j + 1) / columns
|
||||||
|
quad(surface(u0, lo), surface(u1, lo), surface(u1, hi), surface(u0, hi), cLo, cHi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bars: one between each pair of holes, spanning the gap the rings left.
|
||||||
|
for (let i = 0; i < rows; i += 1) {
|
||||||
|
const lo = (i + webV) / rows
|
||||||
|
const hi = (i + 1 - webV) / rows
|
||||||
|
if (hi - lo <= 1e-6) continue
|
||||||
|
const cLo = shade(lo)
|
||||||
|
const cHi = shade(hi)
|
||||||
|
const shift = pattern.stagger === true && i % 2 === 1 ? 0.5 : 0
|
||||||
|
for (let j = 0; j < columns; j += 1) {
|
||||||
|
const centre = (j + shift) / columns
|
||||||
|
const u0 = centre - webU / columns
|
||||||
|
const u1 = centre + webU / columns
|
||||||
|
quad(surface(u0, lo), surface(u1, lo), surface(u1, hi), surface(u0, hi), cLo, cHi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finish(sink)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a round section along an arbitrary path: the fourth way of making a
|
* Runs a round section along an arbitrary path: the fourth way of making a
|
||||||
* solid here, after the lathe, the plan sweep and the extrusion.
|
* solid here, after the lathe, the plan sweep and the extrusion.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export {
|
||||||
latheGeometry,
|
latheGeometry,
|
||||||
planSweepGeometry,
|
planSweepGeometry,
|
||||||
mergeColoured,
|
mergeColoured,
|
||||||
|
perforate,
|
||||||
mottleGeometry,
|
mottleGeometry,
|
||||||
prismGeometry,
|
prismGeometry,
|
||||||
roughenGeometry,
|
roughenGeometry,
|
||||||
|
|
@ -21,7 +22,7 @@ export {
|
||||||
tubeGeometry,
|
tubeGeometry,
|
||||||
taperedBoxGeometry,
|
taperedBoxGeometry,
|
||||||
} from './geometry.ts'
|
} from './geometry.ts'
|
||||||
export type { Level, MottleOptions, Plan, PlanLevel, RoughenOptions, Section, SheetLevel, Vec3 } from './geometry.ts'
|
export type { Level, MottleOptions, PerforationPattern, Plan, PlanLevel, RoughenOptions, Section, SheetLevel, Vec3 } from './geometry.ts'
|
||||||
export { bakeOcclusion } from './occlusion.ts'
|
export { bakeOcclusion } from './occlusion.ts'
|
||||||
export type { OcclusionOptions } from './occlusion.ts'
|
export type { OcclusionOptions } from './occlusion.ts'
|
||||||
export { createKitModel } from './kit.ts'
|
export { createKitModel } from './kit.ts'
|
||||||
|
|
|
||||||
16
contemporary-props/models/litter-bin/meta.json
Normal file
16
contemporary-props/models/litter-bin/meta.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"title": "Litter Bin",
|
||||||
|
"description": "A punched steel drum hung off a post under an overhanging hood, on a bolted flange with gussets. The row that spends the perforation helper, and the first here whose hinge carries real mass.",
|
||||||
|
"category": "Street",
|
||||||
|
"tags": ["contemporary", "street", "waste", "procedural"],
|
||||||
|
"controls": {
|
||||||
|
"height": { "type": "number", "label": "Height", "min": 0.8, "max": 1.7, "step": 0.01, "unit": "m" },
|
||||||
|
"diameter": { "type": "number", "label": "Drum diameter", "min": 0.2, "max": 0.6, "step": 0.005, "unit": "m" },
|
||||||
|
"rows": { "type": "number", "label": "Hole rows", "min": 3, "max": 20, "step": 1 },
|
||||||
|
"columns": { "type": "number", "label": "Holes around", "min": 8, "max": 44, "step": 1 },
|
||||||
|
"lift": { "type": "number", "label": "Hood lift", "min": 0, "max": 1, "step": 0.05 },
|
||||||
|
"seed": { "type": "number", "label": "Variation seed", "min": 1, "max": 64, "step": 1 }
|
||||||
|
},
|
||||||
|
"materialSlots": ["steelPainted", "galvanised"],
|
||||||
|
"parts": ["post", "drum", "hood"]
|
||||||
|
}
|
||||||
314
contemporary-props/models/litter-bin/model.ts
Normal file
314
contemporary-props/models/litter-bin/model.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
/**
|
||||||
|
* @contemporary-props/litter-bin
|
||||||
|
*
|
||||||
|
* A perforated drum under an overhanging hood, hung off a post: the catalogue's
|
||||||
|
* thirtieth row, and the first object here where a hinge carries real mass.
|
||||||
|
*
|
||||||
|
* It is also the row that spends `perforate`, which is written in core rather
|
||||||
|
* than here for the reason the catalogue gives: perforation is defined across
|
||||||
|
* three rows in three domains -- the mesh waste bin in the office, the colander
|
||||||
|
* in the kitchen, and this -- and a helper that only knew how to punch a
|
||||||
|
* cylinder would be rewritten by the second of them. This model hands it a
|
||||||
|
* cylinder; the colander will hand it a curved shell and get the same code.
|
||||||
|
*
|
||||||
|
* Measured off the reference against a 1.2 m overall height:
|
||||||
|
*
|
||||||
|
* drum 368 mm across, 550 mm deep, hung with its base 400 mm up
|
||||||
|
* hood 450 mm across, 116 mm deep, overhanging the drum on both sides
|
||||||
|
* post 55 mm, tapered, on a 190 mm flange with four gussets
|
||||||
|
* punching 14 mm holes on a 24 mm pitch, staggered, with solid bands top
|
||||||
|
* and bottom and a solid strip where the bracket lands
|
||||||
|
*
|
||||||
|
* The hood hinges at the back of its bracket. That is the action the catalogue
|
||||||
|
* asks for and it is a real one: emptying the bin means swinging the hood up
|
||||||
|
* and lifting the liner out, so the pivot has to be behind the drum rather than
|
||||||
|
* over it, and the hood has to clear the drum's rim through the whole travel.
|
||||||
|
*/
|
||||||
|
import { type BufferGeometry } from 'three'
|
||||||
|
|
||||||
|
import {
|
||||||
|
bakeOcclusion,
|
||||||
|
chamferedBoxGeometry,
|
||||||
|
createKitModel,
|
||||||
|
createTinter,
|
||||||
|
jitter,
|
||||||
|
latheGeometry,
|
||||||
|
mergeColoured,
|
||||||
|
perforate,
|
||||||
|
smoothNormals,
|
||||||
|
type RuntimeContext,
|
||||||
|
type Vec3,
|
||||||
|
} from '../core/index.ts'
|
||||||
|
|
||||||
|
export interface LitterBinConfig {
|
||||||
|
/** Height to the top of the hood (metres). */
|
||||||
|
readonly height: number
|
||||||
|
/** Diameter of the drum (metres). */
|
||||||
|
readonly diameter: number
|
||||||
|
/** Rows of holes up the drum. */
|
||||||
|
readonly rows: number
|
||||||
|
/** Holes around the drum. */
|
||||||
|
readonly columns: number
|
||||||
|
/** How far the hood is swung up, 0 closed to 1 open. */
|
||||||
|
readonly lift: number
|
||||||
|
readonly seed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const litterBinDefaults: LitterBinConfig = {
|
||||||
|
height: 1.2,
|
||||||
|
diameter: 0.368,
|
||||||
|
// 14 mm holes on a 24 mm pitch, which is what the reference punches: at 26
|
||||||
|
// columns they came out 26 mm across and read as a grille rather than a
|
||||||
|
// perforation.
|
||||||
|
rows: 21,
|
||||||
|
columns: 34,
|
||||||
|
lift: 0,
|
||||||
|
seed: 33,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LitterBinParts = 'post' | 'drum' | 'hood'
|
||||||
|
|
||||||
|
export interface LitterBinActions {
|
||||||
|
/** Swings the hood up off the drum. 1 is fully open. */
|
||||||
|
lift(amount?: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hood's pose, in one place because the config and the action both drive
|
||||||
|
* it -- the lesson the pavement sign and the fire extinguisher both paid for.
|
||||||
|
* A pose that lives only in an anchor rotation is a pose no rebuild re-applies,
|
||||||
|
* so the slider moves and nothing happens.
|
||||||
|
*/
|
||||||
|
function applyLift(
|
||||||
|
runtime: RuntimeContext<LitterBinConfig, LitterBinParts>,
|
||||||
|
amount: number,
|
||||||
|
): void {
|
||||||
|
runtime.parts.hood.anchor.rotation.x = -Math.min(1, Math.max(0, amount)) * 1.15
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModel(overrides: Partial<LitterBinConfig> = {}) {
|
||||||
|
let heldLift = 0
|
||||||
|
let seenLift = Number.NaN
|
||||||
|
|
||||||
|
return createKitModel<
|
||||||
|
LitterBinConfig, 'steelPainted' | 'galvanised', LitterBinParts, LitterBinActions
|
||||||
|
>({
|
||||||
|
id: 'litter-bin',
|
||||||
|
defaults: litterBinDefaults,
|
||||||
|
slots: ['steelPainted', 'galvanised'],
|
||||||
|
|
||||||
|
build: ({ config, random }) => {
|
||||||
|
const tint = createTinter(random)
|
||||||
|
const H = Math.min(1.7, Math.max(0.8, config.height))
|
||||||
|
const D = Math.min(0.6, Math.max(0.2, config.diameter))
|
||||||
|
const rows = Math.max(3, Math.round(config.rows))
|
||||||
|
const columns = Math.max(8, Math.round(config.columns))
|
||||||
|
|
||||||
|
const R = D / 2
|
||||||
|
const drumH = H * 0.458
|
||||||
|
const drumBase = H * 0.333
|
||||||
|
const drumTop = drumBase + drumH
|
||||||
|
// The post stands behind the drum, not through it.
|
||||||
|
const postZ = R * 1.16
|
||||||
|
const postR = H * 0.023
|
||||||
|
const hoodY = H - H * 0.097
|
||||||
|
|
||||||
|
const paint = tint('steelPainted', jitter(random, 0.02))
|
||||||
|
const dark = tint('steelPainted', -0.5, 0.3)
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- post */
|
||||||
|
const postPieces: BufferGeometry[] = []
|
||||||
|
postPieces.push(latheGeometry([
|
||||||
|
{ y: H * 0.02, radius: postR * 1.18 },
|
||||||
|
{ y: H * 0.05, radius: postR },
|
||||||
|
// Up to the hood's arm, not to a number that looked about right: at
|
||||||
|
// 0.86 the post stopped 38 mm short of the arm it carries and the
|
||||||
|
// whole hood hung in the air, which the support check found and no
|
||||||
|
// amount of looking at the render from the front would have.
|
||||||
|
{ y: hoodY + H * 0.006, radius: postR * 0.92 },
|
||||||
|
], 18, [0, 0, postZ], paint, { capBottom: false, capTop: true }))
|
||||||
|
|
||||||
|
// The bolted foot: a flange with four gussets, which is how a post this
|
||||||
|
// slender is actually kept upright and the only place the object is
|
||||||
|
// fixed to anything.
|
||||||
|
postPieces.push(latheGeometry([
|
||||||
|
{ y: 0, radius: H * 0.079 },
|
||||||
|
{ y: H * 0.008, radius: H * 0.079 },
|
||||||
|
{ y: H * 0.013, radius: H * 0.07 },
|
||||||
|
], 24, [0, 0, postZ], tint('galvanised', 0, 0.5).offsetHSL(0, -1, 0),
|
||||||
|
{ capBottom: false, capTop: true }))
|
||||||
|
for (let i = 0; i < 4; i += 1) {
|
||||||
|
const a = (i / 4) * Math.PI * 2 + Math.PI / 4
|
||||||
|
const gusset = chamferedBoxGeometry(
|
||||||
|
[H * 0.008, H * 0.052], [H * 0.008, H * 0.012],
|
||||||
|
H * 0.1, H * 0.003, [0, 0, 0], paint,
|
||||||
|
)
|
||||||
|
gusset.translate(0, H * 0.063, H * 0.03)
|
||||||
|
gusset.rotateY(a)
|
||||||
|
gusset.translate(0, 0, postZ)
|
||||||
|
postPieces.push(gusset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- drum */
|
||||||
|
/**
|
||||||
|
* The punched band, and the two solid ones that close it.
|
||||||
|
*
|
||||||
|
* A drum punched edge to edge has a row of half holes at each end and
|
||||||
|
* reads as torn. The reference's has 75 mm of plain sheet at the top and
|
||||||
|
* 65 at the bottom, which is where the rolled rims are, and it is those
|
||||||
|
* bands rather than the holes that make the perforation look cut into
|
||||||
|
* something.
|
||||||
|
*/
|
||||||
|
const drumPieces: BufferGeometry[] = []
|
||||||
|
const bandTop = drumH * 0.14
|
||||||
|
const bandBot = drumH * 0.12
|
||||||
|
const punchLo = drumBase + bandBot
|
||||||
|
const punchHi = drumTop - bandTop
|
||||||
|
|
||||||
|
for (const [lo, hi] of [[drumBase, punchLo], [punchHi, drumTop]] as const) {
|
||||||
|
drumPieces.push(latheGeometry([
|
||||||
|
{ y: lo, radius: R },
|
||||||
|
{ y: hi, radius: R },
|
||||||
|
], 36, [0, 0, 0], paint, { capBottom: false, capTop: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* THREE fields, not one, split by two solid bands.
|
||||||
|
*
|
||||||
|
* The reference's punching is interrupted twice on the way up, and those
|
||||||
|
* bands are structural: a drum of continuous perforation has nothing to
|
||||||
|
* hold its shape, so a real one is stiffened at the third points. They
|
||||||
|
* are also most of what stops the punching reading as a printed texture,
|
||||||
|
* because they give it edges in the middle rather than only at the ends.
|
||||||
|
*
|
||||||
|
* The helper is called once per field rather than being taught about
|
||||||
|
* bands. It punches a surface; where the surface stops is the caller's
|
||||||
|
* business, and this way the colander can interrupt its own punching for
|
||||||
|
* a completely different reason without changing anything here.
|
||||||
|
*/
|
||||||
|
const fields = 3
|
||||||
|
const bandMid = drumH * 0.045
|
||||||
|
const fieldH = (punchHi - punchLo - bandMid * (fields - 1)) / fields
|
||||||
|
for (let f = 0; f < fields; f += 1) {
|
||||||
|
const lo = punchLo + f * (fieldH + bandMid)
|
||||||
|
const hi = lo + fieldH
|
||||||
|
drumPieces.push(perforate(
|
||||||
|
// The surface the punch is mapped onto: a plain cylinder, described
|
||||||
|
// by the caller because the helper is not allowed to assume one.
|
||||||
|
(u, v): Vec3 => {
|
||||||
|
const a = u * Math.PI * 2
|
||||||
|
return [Math.sin(a) * R, lo + (hi - lo) * v, Math.cos(a) * R]
|
||||||
|
},
|
||||||
|
{ rows: Math.max(2, Math.round(rows / fields)), columns, open: 0.55, stagger: true },
|
||||||
|
paint,
|
||||||
|
))
|
||||||
|
if (f < fields - 1) {
|
||||||
|
drumPieces.push(latheGeometry([
|
||||||
|
{ y: hi, radius: R },
|
||||||
|
{ y: hi + bandMid, radius: R },
|
||||||
|
], 36, [0, 0, 0], paint, { capBottom: false, capTop: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The liner: a dark drum just inside, so the holes have something behind
|
||||||
|
// them. Without it they open onto whatever is behind the bin and the
|
||||||
|
// punching reads as a stencil rather than as a hole.
|
||||||
|
drumPieces.push(latheGeometry([
|
||||||
|
{ y: drumBase + drumH * 0.03, radius: R * 0.955 },
|
||||||
|
{ y: drumTop - drumH * 0.02, radius: R * 0.955 },
|
||||||
|
], 30, [0, 0, 0], dark, { capBottom: true, capTop: false }))
|
||||||
|
|
||||||
|
// The rolled rims top and bottom, and the bracket that hangs the drum on
|
||||||
|
// the post.
|
||||||
|
for (const [y, lift] of [[drumBase, -0.04], [drumTop, 0.03]] as const) {
|
||||||
|
drumPieces.push(latheGeometry([
|
||||||
|
{ y: y - drumH * 0.018, radius: R * 1.035 },
|
||||||
|
{ y: y + drumH * 0.018, radius: R * 1.035 },
|
||||||
|
], 36, [0, 0, 0], tint('steelPainted', lift), { capBottom: false, capTop: false }))
|
||||||
|
}
|
||||||
|
// TWO compact fittings rather than one long block. A bracket two thirds
|
||||||
|
// of the drum's height reads as a spine the bin is welded to; the
|
||||||
|
// reference carries it on a pair of short lugs, which is both what a
|
||||||
|
// hinge looks like and what says the drum comes off.
|
||||||
|
for (const at of [0.16, 0.84]) {
|
||||||
|
// Narrow enough to tuck between the drum and the post rather than
|
||||||
|
// standing out either side of it: the lug is a fitting, and a fitting
|
||||||
|
// that reads as a block is a fitting nobody believes carries anything.
|
||||||
|
const lug = chamferedBoxGeometry(
|
||||||
|
[H * 0.032, postZ - R * 0.4], [H * 0.027, postZ - R * 0.4],
|
||||||
|
drumH * 0.13, H * 0.004, [0, 0, 0], paint,
|
||||||
|
)
|
||||||
|
lug.translate(0, drumBase + drumH * at, (R * 0.85 + postZ) / 2)
|
||||||
|
drumPieces.push(lug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- hood */
|
||||||
|
/**
|
||||||
|
* A shallow dome on an arm, and it OVERHANGS.
|
||||||
|
*
|
||||||
|
* The hood is half again the drum's diameter in the reference, which is
|
||||||
|
* what keeps rain out of a bin whose whole side is holes. Drawn to the
|
||||||
|
* drum's own width it stops reading as a hood at all and becomes a lid.
|
||||||
|
*/
|
||||||
|
const hoodPieces: BufferGeometry[] = []
|
||||||
|
const hoodR = R * 1.22
|
||||||
|
// A shallow shield, not a mushroom. Taken to a point the hood reads as a
|
||||||
|
// parasol; the reference's is a pressing with a flat crown and a rolled
|
||||||
|
// edge, and its whole job is to be wider than the drum rather than taller
|
||||||
|
// than it.
|
||||||
|
hoodPieces.push(latheGeometry([
|
||||||
|
{ y: hoodY, radius: hoodR },
|
||||||
|
// A real vertical lip, deep enough to throw a shadow line round the
|
||||||
|
// whole rim: at 8 mm it was an edge, and an edge is not a pressing.
|
||||||
|
{ y: hoodY + H * 0.014, radius: hoodR },
|
||||||
|
{ y: hoodY + H * 0.026, radius: hoodR * 0.95 },
|
||||||
|
{ y: hoodY + H * 0.043, radius: hoodR * 0.79 },
|
||||||
|
{ y: hoodY + H * 0.055, radius: hoodR * 0.48 },
|
||||||
|
{ y: hoodY + H * 0.06, radius: hoodR * 0.22 },
|
||||||
|
], 30, [0, 0, 0], tint('steelPainted', 0.04), { capBottom: true, capTop: true }))
|
||||||
|
|
||||||
|
// The arm back to the post, and the pivot it swings about.
|
||||||
|
const arm = chamferedBoxGeometry(
|
||||||
|
[H * 0.028, postZ + hoodR * 0.55], [H * 0.028, postZ + hoodR * 0.55],
|
||||||
|
H * 0.03, H * 0.004, [0, 0, 0], paint,
|
||||||
|
)
|
||||||
|
arm.translate(0, hoodY - H * 0.012, (postZ - hoodR * 0.55) / 2)
|
||||||
|
hoodPieces.push(arm)
|
||||||
|
|
||||||
|
bakeOcclusion(postPieces, { strength: 0.35 })
|
||||||
|
bakeOcclusion(drumPieces, { strength: 0.4 })
|
||||||
|
|
||||||
|
// The hood's origin is the pivot, behind the drum on the post, so one
|
||||||
|
// rotation swings it up and clear rather than through the rim.
|
||||||
|
const pivot = [0, hoodY - H * 0.012, postZ] as const
|
||||||
|
return {
|
||||||
|
post: { slot: 'steelPainted' as const, geometry: smoothNormals(mergeColoured(postPieces), 34) },
|
||||||
|
drum: { slot: 'steelPainted' as const, geometry: smoothNormals(mergeColoured(drumPieces), 34) },
|
||||||
|
hood: {
|
||||||
|
// Written where it sits and then moved into the PIVOT'S OWN SPACE,
|
||||||
|
// because that is the space a part with an origin is expected in:
|
||||||
|
// the anchor carries it back. Left in world coordinates it is placed
|
||||||
|
// twice and ends up a pivot height above itself.
|
||||||
|
slot: 'steelPainted' as const,
|
||||||
|
geometry: smoothNormals(
|
||||||
|
mergeColoured(hoodPieces).translate(-pivot[0], -pivot[1], -pivot[2]), 34,
|
||||||
|
),
|
||||||
|
origin: pivot,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: (runtime) => {
|
||||||
|
heldLift = runtime.getConfig().lift
|
||||||
|
seenLift = heldLift
|
||||||
|
applyLift(runtime, heldLift)
|
||||||
|
return { lift: (amount = 1) => { heldLift = amount; applyLift(runtime, amount) } }
|
||||||
|
},
|
||||||
|
|
||||||
|
update: (_dt, runtime) => {
|
||||||
|
const wanted = runtime.getConfig().lift
|
||||||
|
if (wanted !== seenLift) { seenLift = wanted; heldLift = wanted }
|
||||||
|
applyLift(runtime, heldLift)
|
||||||
|
},
|
||||||
|
}, overrides)
|
||||||
|
}
|
||||||
|
|
@ -677,15 +677,15 @@
|
||||||
"address": "@contemporary-props/core",
|
"address": "@contemporary-props/core",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.024Z",
|
"installedAt": "2026-09-04T21:23:51.309Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\core\\geometry.ts",
|
"path": "src\\models\\contemporary-props\\core\\geometry.ts",
|
||||||
"sourceHash": "d2d6d3a5eb914ad25ddb0d848480f73f4d3cf3b4d50b6655d9d96e2e384b55c2"
|
"sourceHash": "98188ee830bc2566cd791fdd077021f4539076e58a23bd0075876c5273a8ccb4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\core\\index.ts",
|
"path": "src\\models\\contemporary-props\\core\\index.ts",
|
||||||
"sourceHash": "7407f52b8585d0344d7c788bfb69e5cc0440ab04022240c8cb858e06c8e5f5a5"
|
"sourceHash": "38d1152c66c2a72cf293e6b2233fd72b4413ae02ebd0b8409a4db7c4b4cbed82"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\core\\kit.ts",
|
"path": "src\\models\\contemporary-props\\core\\kit.ts",
|
||||||
|
|
@ -724,7 +724,7 @@
|
||||||
"address": "@contemporary-props/ceramic-vase",
|
"address": "@contemporary-props/ceramic-vase",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.030Z",
|
"installedAt": "2026-09-04T21:23:51.315Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\ceramic-vase\\model.ts",
|
"path": "src\\models\\contemporary-props\\ceramic-vase\\model.ts",
|
||||||
|
|
@ -739,7 +739,7 @@
|
||||||
"address": "@contemporary-props/coffee-mug",
|
"address": "@contemporary-props/coffee-mug",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.031Z",
|
"installedAt": "2026-09-04T21:23:51.316Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\coffee-mug\\model.ts",
|
"path": "src\\models\\contemporary-props\\coffee-mug\\model.ts",
|
||||||
|
|
@ -754,7 +754,7 @@
|
||||||
"address": "@contemporary-props/pepper-mill",
|
"address": "@contemporary-props/pepper-mill",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.045Z",
|
"installedAt": "2026-09-04T21:23:51.329Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\pepper-mill\\model.ts",
|
"path": "src\\models\\contemporary-props\\pepper-mill\\model.ts",
|
||||||
|
|
@ -769,7 +769,7 @@
|
||||||
"address": "@contemporary-props/stockpot",
|
"address": "@contemporary-props/stockpot",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.048Z",
|
"installedAt": "2026-09-04T21:23:51.332Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\stockpot\\model.ts",
|
"path": "src\\models\\contemporary-props\\stockpot\\model.ts",
|
||||||
|
|
@ -784,7 +784,7 @@
|
||||||
"address": "@contemporary-props/traffic-cone",
|
"address": "@contemporary-props/traffic-cone",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.051Z",
|
"installedAt": "2026-09-04T21:23:51.335Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\traffic-cone\\model.ts",
|
"path": "src\\models\\contemporary-props\\traffic-cone\\model.ts",
|
||||||
|
|
@ -799,7 +799,7 @@
|
||||||
"address": "@contemporary-props/wine-glass",
|
"address": "@contemporary-props/wine-glass",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.053Z",
|
"installedAt": "2026-09-04T21:23:51.337Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\wine-glass\\model.ts",
|
"path": "src\\models\\contemporary-props\\wine-glass\\model.ts",
|
||||||
|
|
@ -814,7 +814,7 @@
|
||||||
"address": "@contemporary-props/kit",
|
"address": "@contemporary-props/kit",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.053Z",
|
"installedAt": "2026-09-04T21:23:51.337Z",
|
||||||
"files": [],
|
"files": [],
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
},
|
},
|
||||||
|
|
@ -822,7 +822,7 @@
|
||||||
"address": "@contemporary-props/street-bollard",
|
"address": "@contemporary-props/street-bollard",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.049Z",
|
"installedAt": "2026-09-04T21:23:51.334Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\street-bollard\\model.ts",
|
"path": "src\\models\\contemporary-props\\street-bollard\\model.ts",
|
||||||
|
|
@ -837,7 +837,7 @@
|
||||||
"address": "@contemporary-props/gas-cylinder",
|
"address": "@contemporary-props/gas-cylinder",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.035Z",
|
"installedAt": "2026-09-04T21:23:51.319Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\gas-cylinder\\model.ts",
|
"path": "src\\models\\contemporary-props\\gas-cylinder\\model.ts",
|
||||||
|
|
@ -852,7 +852,7 @@
|
||||||
"address": "@contemporary-props/fire-extinguisher",
|
"address": "@contemporary-props/fire-extinguisher",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.033Z",
|
"installedAt": "2026-09-04T21:23:51.318Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\fire-extinguisher\\model.ts",
|
"path": "src\\models\\contemporary-props\\fire-extinguisher\\model.ts",
|
||||||
|
|
@ -867,7 +867,7 @@
|
||||||
"address": "@contemporary-props/pedestal-basin",
|
"address": "@contemporary-props/pedestal-basin",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.043Z",
|
"installedAt": "2026-09-04T21:23:51.328Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\pedestal-basin\\model.ts",
|
"path": "src\\models\\contemporary-props\\pedestal-basin\\model.ts",
|
||||||
|
|
@ -882,7 +882,7 @@
|
||||||
"address": "@contemporary-props/jersey-barrier",
|
"address": "@contemporary-props/jersey-barrier",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.036Z",
|
"installedAt": "2026-09-04T21:23:51.321Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\jersey-barrier\\model.ts",
|
"path": "src\\models\\contemporary-props\\jersey-barrier\\model.ts",
|
||||||
|
|
@ -897,7 +897,7 @@
|
||||||
"address": "@contemporary-props/picnic-table",
|
"address": "@contemporary-props/picnic-table",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.046Z",
|
"installedAt": "2026-09-04T21:23:51.331Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\picnic-table\\model.ts",
|
"path": "src\\models\\contemporary-props\\picnic-table\\model.ts",
|
||||||
|
|
@ -912,7 +912,7 @@
|
||||||
"address": "@contemporary-props/park-bench",
|
"address": "@contemporary-props/park-bench",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.040Z",
|
"installedAt": "2026-09-04T21:23:51.325Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\park-bench\\model.ts",
|
"path": "src\\models\\contemporary-props\\park-bench\\model.ts",
|
||||||
|
|
@ -927,7 +927,7 @@
|
||||||
"address": "@contemporary-props/pavement-sign-board",
|
"address": "@contemporary-props/pavement-sign-board",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.041Z",
|
"installedAt": "2026-09-04T21:23:51.326Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\pavement-sign-board\\model.ts",
|
"path": "src\\models\\contemporary-props\\pavement-sign-board\\model.ts",
|
||||||
|
|
@ -942,7 +942,7 @@
|
||||||
"address": "@contemporary-props/lectern",
|
"address": "@contemporary-props/lectern",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.038Z",
|
"installedAt": "2026-09-04T21:23:51.322Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\lectern\\model.ts",
|
"path": "src\\models\\contemporary-props\\lectern\\model.ts",
|
||||||
|
|
@ -957,7 +957,7 @@
|
||||||
"address": "@contemporary-props/bike-rack",
|
"address": "@contemporary-props/bike-rack",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.027Z",
|
"installedAt": "2026-09-04T21:23:51.312Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\bike-rack\\model.ts",
|
"path": "src\\models\\contemporary-props\\bike-rack\\model.ts",
|
||||||
|
|
@ -972,11 +972,26 @@
|
||||||
"address": "@contemporary-props/cable-drum",
|
"address": "@contemporary-props/cable-drum",
|
||||||
"source": "file:contemporary-props/dist/registry.json",
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
"version": "workspace",
|
"version": "workspace",
|
||||||
"installedAt": "2026-09-04T21:08:46.028Z",
|
"installedAt": "2026-09-04T21:23:51.313Z",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "src\\models\\contemporary-props\\cable-drum\\model.ts",
|
"path": "src\\models\\contemporary-props\\cable-drum\\model.ts",
|
||||||
"sourceHash": "23e13f4efccf35cc760f3a3210621e1a6d9e881ae499d949fff97d97b2dff0d6"
|
"sourceHash": "6702777ecb403eaf34be940551da48daea2f2d32df6830a3c1aaf1ceb6b4c551"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": [
|
||||||
|
"three@>=0.185.0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@contemporary-props/litter-bin": {
|
||||||
|
"address": "@contemporary-props/litter-bin",
|
||||||
|
"source": "file:contemporary-props/dist/registry.json",
|
||||||
|
"version": "workspace",
|
||||||
|
"installedAt": "2026-09-04T21:23:51.323Z",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "src\\models\\contemporary-props\\litter-bin\\model.ts",
|
||||||
|
"sourceHash": "5665fcb71912620a4952758ae8778b4b38519ad15d8a920eb9333e1b3bca5373"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,37 @@ for (const [id, entry] of Object.entries(CATALOG)) {
|
||||||
// Precise, for the reason `support.ts` says: the loose bound is the box
|
// Precise, for the reason `support.ts` says: the loose bound is the box
|
||||||
// around each part's own rotated box, and a hinged model reads 60 mm taller
|
// around each part's own rotated box, and a hinged model reads 60 mm taller
|
||||||
// than its tallest vertex.
|
// than its tallest vertex.
|
||||||
const size = new Box3().setFromObject(root, true).getSize(new Vector3())
|
const box = new Box3().setFromObject(root, true)
|
||||||
|
const size = box.getSize(new Vector3())
|
||||||
if (!Number.isFinite(size.x + size.y + size.z) || size.y <= 0) {
|
if (!Number.isFinite(size.x + size.y + size.z) || size.y <= 0) {
|
||||||
problems.push('bounds are not finite')
|
problems.push('bounds are not finite')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ...and it has to be ON the ground, which nothing here was checking.
|
||||||
|
*
|
||||||
|
* `findFloating` measures a model against ITSELF: it looks for pieces with
|
||||||
|
* nothing under them, so a model whose every piece is correctly stacked
|
||||||
|
* passes even when the whole assembly is a metre in the air. The cable drum
|
||||||
|
* shipped like that -- 600 mm up, because it was written in world coordinates
|
||||||
|
* and then given an origin as well, which places it twice -- and every gate
|
||||||
|
* in the repository said it rested on the ground.
|
||||||
|
*
|
||||||
|
* The tolerance is ASYMMETRIC, because the two directions are not the same
|
||||||
|
* fault. A millimetre in the air is a model that will hover over any surface
|
||||||
|
* it is placed on. A couple of millimetres under is the outermost corner of a
|
||||||
|
* chamfered foot, or the end of a centre line offset perpendicular to its own
|
||||||
|
* direction, and the ground hides it -- so it is allowed up to a percent of
|
||||||
|
* the model's own height, which scales from a mug to a bus shelter.
|
||||||
|
*/
|
||||||
|
if (Number.isFinite(box.min.y)) {
|
||||||
|
if (box.min.y > 0.001) {
|
||||||
|
problems.push(`floats ${(box.min.y * 1000).toFixed(0)}mm above the ground`)
|
||||||
|
} else if (-box.min.y > Math.max(0.004, size.y * 0.01)) {
|
||||||
|
problems.push(`sinks ${(-box.min.y * 1000).toFixed(0)}mm below the ground`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (problems.length > 0) {
|
if (problems.length > 0) {
|
||||||
failed += 1
|
failed += 1
|
||||||
console.log(`\n FAIL ${id}`)
|
console.log(`\n FAIL ${id}`)
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ import { createModel as createCpPicnicTable } from '@/models/contemporary-props/
|
||||||
import { createModel as createCpParkBench } from '@/models/contemporary-props/park-bench/model.ts'
|
import { createModel as createCpParkBench } from '@/models/contemporary-props/park-bench/model.ts'
|
||||||
import { createModel as createCpBikeRack } from '@/models/contemporary-props/bike-rack/model.ts'
|
import { createModel as createCpBikeRack } from '@/models/contemporary-props/bike-rack/model.ts'
|
||||||
import { createModel as createCpCableDrum } from '@/models/contemporary-props/cable-drum/model.ts'
|
import { createModel as createCpCableDrum } from '@/models/contemporary-props/cable-drum/model.ts'
|
||||||
|
import { createModel as createCpLitterBin } from '@/models/contemporary-props/litter-bin/model.ts'
|
||||||
import { createModel as createCpPavementSign } from '@/models/contemporary-props/pavement-sign-board/model.ts'
|
import { createModel as createCpPavementSign } from '@/models/contemporary-props/pavement-sign-board/model.ts'
|
||||||
import { createModel as createCpLectern } from '@/models/contemporary-props/lectern/model.ts'
|
import { createModel as createCpLectern } from '@/models/contemporary-props/lectern/model.ts'
|
||||||
import cpMetaCeramicVase from '../contemporary-props/models/ceramic-vase/meta.json'
|
import cpMetaCeramicVase from '../contemporary-props/models/ceramic-vase/meta.json'
|
||||||
|
|
@ -83,6 +84,7 @@ import cpMetaPicnicTable from '../contemporary-props/models/picnic-table/meta.js
|
||||||
import cpMetaParkBench from '../contemporary-props/models/park-bench/meta.json'
|
import cpMetaParkBench from '../contemporary-props/models/park-bench/meta.json'
|
||||||
import cpMetaBikeRack from '../contemporary-props/models/bike-rack/meta.json'
|
import cpMetaBikeRack from '../contemporary-props/models/bike-rack/meta.json'
|
||||||
import cpMetaCableDrum from '../contemporary-props/models/cable-drum/meta.json'
|
import cpMetaCableDrum from '../contemporary-props/models/cable-drum/meta.json'
|
||||||
|
import cpMetaLitterBin from '../contemporary-props/models/litter-bin/meta.json'
|
||||||
import cpMetaPavementSign from '../contemporary-props/models/pavement-sign-board/meta.json'
|
import cpMetaPavementSign from '../contemporary-props/models/pavement-sign-board/meta.json'
|
||||||
import cpMetaLectern from '../contemporary-props/models/lectern/meta.json'
|
import cpMetaLectern from '../contemporary-props/models/lectern/meta.json'
|
||||||
import { createModel as createShield } from '@/models/medieval-kit/round-shield/model.ts'
|
import { createModel as createShield } from '@/models/medieval-kit/round-shield/model.ts'
|
||||||
|
|
@ -304,6 +306,7 @@ const CONTEMPORARY_ORDER = [
|
||||||
'lectern',
|
'lectern',
|
||||||
'bike-rack',
|
'bike-rack',
|
||||||
'cable-drum',
|
'cable-drum',
|
||||||
|
'litter-bin',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const CONTEMPORARY_FACTORIES: Readonly<Record<string, () => KitModel>> = {
|
const CONTEMPORARY_FACTORIES: Readonly<Record<string, () => KitModel>> = {
|
||||||
|
|
@ -322,6 +325,7 @@ const CONTEMPORARY_FACTORIES: Readonly<Record<string, () => KitModel>> = {
|
||||||
'park-bench': as(createCpParkBench),
|
'park-bench': as(createCpParkBench),
|
||||||
'bike-rack': as(createCpBikeRack),
|
'bike-rack': as(createCpBikeRack),
|
||||||
'cable-drum': as(createCpCableDrum),
|
'cable-drum': as(createCpCableDrum),
|
||||||
|
'litter-bin': as(createCpLitterBin),
|
||||||
'pavement-sign-board': as(createCpPavementSign),
|
'pavement-sign-board': as(createCpPavementSign),
|
||||||
'lectern': as(createCpLectern),
|
'lectern': as(createCpLectern),
|
||||||
}
|
}
|
||||||
|
|
@ -342,6 +346,7 @@ const CONTEMPORARY_META: Readonly<Record<string, ModelMetaLike>> = {
|
||||||
'park-bench': cpMetaParkBench as ModelMetaLike,
|
'park-bench': cpMetaParkBench as ModelMetaLike,
|
||||||
'bike-rack': cpMetaBikeRack as ModelMetaLike,
|
'bike-rack': cpMetaBikeRack as ModelMetaLike,
|
||||||
'cable-drum': cpMetaCableDrum as ModelMetaLike,
|
'cable-drum': cpMetaCableDrum as ModelMetaLike,
|
||||||
|
'litter-bin': cpMetaLitterBin as ModelMetaLike,
|
||||||
'pavement-sign-board': cpMetaPavementSign as ModelMetaLike,
|
'pavement-sign-board': cpMetaPavementSign as ModelMetaLike,
|
||||||
'lectern': cpMetaLectern as ModelMetaLike,
|
'lectern': cpMetaLectern as ModelMetaLike,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -264,14 +264,19 @@ export function createModel(overrides: Partial<CableDrumConfig> = {}) {
|
||||||
if (pieces.length === 0) return undefined
|
if (pieces.length === 0) return undefined
|
||||||
const merged = mergeColoured(pieces)
|
const merged = mergeColoured(pieces)
|
||||||
merged.rotateX(Math.PI / 2)
|
merged.rotateX(Math.PI / 2)
|
||||||
merged.translate(0, R, 0)
|
// ...and NOT lifted. A part that declares an origin is written in that
|
||||||
|
// origin's own space and the anchor carries it into place; the kit does
|
||||||
|
// not subtract the origin from world-written geometry, it only moves
|
||||||
|
// things temporarily to bake occlusion and moves them back. Lifting
|
||||||
|
// here as well put the whole drum a flange radius into the air, where
|
||||||
|
// it stayed, because the support check measures a model against itself
|
||||||
|
// rather than against the ground.
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every part declares the AXLE as its origin, which is what lets one
|
// Every part declares the AXLE as its origin, which is what lets one
|
||||||
// rotation roll all three together. The kit subtracts the origin from the
|
// rotation roll all three together, and every part's geometry is written
|
||||||
// geometry and puts the anchor there, so the geometry above stays written
|
// about that axle rather than about the ground.
|
||||||
// in world terms and nothing here has to be built twice.
|
|
||||||
const axle = [0, R, 0] as const
|
const axle = [0, R, 0] as const
|
||||||
const cable = tip(cablePieces)
|
const cable = tip(cablePieces)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,102 @@ export function latheGeometry(
|
||||||
return finish(sink)
|
return finish(sink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How a surface is punched, for `perforate`. */
|
||||||
|
export interface PerforationPattern {
|
||||||
|
/** Rows of holes up the surface. */
|
||||||
|
readonly rows: number
|
||||||
|
/** Columns of holes around it. */
|
||||||
|
readonly columns: number
|
||||||
|
/** Open fraction of each cell: 0 is solid, 1 is all hole. */
|
||||||
|
readonly open: number
|
||||||
|
/** Offset alternate rows by half a column, which is what a real punch does. */
|
||||||
|
readonly stagger?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Punches a surface, by building the METAL rather than the holes.
|
||||||
|
*
|
||||||
|
* There is no boolean subtraction in this kit and there is not going to be, so
|
||||||
|
* a perforated plate cannot be a plate with circles taken out of it. What it
|
||||||
|
* can be is the web between the holes, emitted directly: a ring of solid band
|
||||||
|
* at every row boundary, and a short bar between every pair of holes in the
|
||||||
|
* rows. Light passes through what is left, which is the entire point -- a bin
|
||||||
|
* you cannot see into is a drum with a pattern painted on it.
|
||||||
|
*
|
||||||
|
* The holes come out square rather than round, and that is a considered loss.
|
||||||
|
* At the size any of these objects is looked at -- a litter bin a metre tall,
|
||||||
|
* its holes 14 mm -- a hole is three pixels, and what reads is the PITCH and
|
||||||
|
* the open area, neither of which cares about the shape. Round holes at this
|
||||||
|
* density are a texture, and the kit has no textures.
|
||||||
|
*
|
||||||
|
* The surface is passed in as a function of (u, v), so this helper knows
|
||||||
|
* nothing about cylinders. That is deliberate and it is the whole reason it is
|
||||||
|
* written here rather than inside the litter bin: the catalogue defines
|
||||||
|
* perforation across three rows in three different domains -- the mesh waste
|
||||||
|
* bin (28, office, a tapered drum), the colander (29, kitchen, a curved shell,
|
||||||
|
* and the harder case) and the litter bin (30, street) -- and a helper that
|
||||||
|
* only knew how to punch a cylinder would be rewritten by the second of them.
|
||||||
|
* A caller that can describe its own surface gets punched for free.
|
||||||
|
*
|
||||||
|
* @param surface maps u (0..1 around, wrapping) and v (0..1 up) to a point.
|
||||||
|
*/
|
||||||
|
export function perforate(
|
||||||
|
surface: (u: number, v: number) => Vec3,
|
||||||
|
pattern: PerforationPattern,
|
||||||
|
colour: Color,
|
||||||
|
options: { readonly colourTop?: Color } = {},
|
||||||
|
): BufferGeometry {
|
||||||
|
const rows = Math.max(1, Math.round(pattern.rows))
|
||||||
|
const columns = Math.max(3, Math.round(pattern.columns))
|
||||||
|
const open = Math.min(0.92, Math.max(0.05, pattern.open))
|
||||||
|
const sink: Sink = { position: [], color: [] }
|
||||||
|
const top = options.colourTop ?? colour
|
||||||
|
|
||||||
|
const shade = (v: number): Color => new Color().copy(colour).lerp(top, v)
|
||||||
|
const quad = (a: Vec3, b: Vec3, c: Vec3, d: Vec3, cLo: Color, cHi: Color): void => {
|
||||||
|
triShaded(sink, a, b, c, cLo, cLo, cHi)
|
||||||
|
triShaded(sink, a, c, d, cLo, cHi, cHi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half the solid left at each boundary, in cell units.
|
||||||
|
const webV = (1 - open) / 2
|
||||||
|
const webU = (1 - open) / 2
|
||||||
|
|
||||||
|
// The rings: one at every row boundary, top and bottom included, so the
|
||||||
|
// punched band is closed by solid metal at both ends rather than ending on a
|
||||||
|
// row of half holes.
|
||||||
|
for (let i = 0; i <= rows; i += 1) {
|
||||||
|
const lo = Math.max(0, (i - webV) / rows)
|
||||||
|
const hi = Math.min(1, (i + webV) / rows)
|
||||||
|
if (hi - lo <= 1e-6) continue
|
||||||
|
const cLo = shade(lo)
|
||||||
|
const cHi = shade(hi)
|
||||||
|
for (let j = 0; j < columns; j += 1) {
|
||||||
|
const u0 = j / columns
|
||||||
|
const u1 = (j + 1) / columns
|
||||||
|
quad(surface(u0, lo), surface(u1, lo), surface(u1, hi), surface(u0, hi), cLo, cHi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bars: one between each pair of holes, spanning the gap the rings left.
|
||||||
|
for (let i = 0; i < rows; i += 1) {
|
||||||
|
const lo = (i + webV) / rows
|
||||||
|
const hi = (i + 1 - webV) / rows
|
||||||
|
if (hi - lo <= 1e-6) continue
|
||||||
|
const cLo = shade(lo)
|
||||||
|
const cHi = shade(hi)
|
||||||
|
const shift = pattern.stagger === true && i % 2 === 1 ? 0.5 : 0
|
||||||
|
for (let j = 0; j < columns; j += 1) {
|
||||||
|
const centre = (j + shift) / columns
|
||||||
|
const u0 = centre - webU / columns
|
||||||
|
const u1 = centre + webU / columns
|
||||||
|
quad(surface(u0, lo), surface(u1, lo), surface(u1, hi), surface(u0, hi), cLo, cHi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finish(sink)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a round section along an arbitrary path: the fourth way of making a
|
* Runs a round section along an arbitrary path: the fourth way of making a
|
||||||
* solid here, after the lathe, the plan sweep and the extrusion.
|
* solid here, after the lathe, the plan sweep and the extrusion.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export {
|
||||||
latheGeometry,
|
latheGeometry,
|
||||||
planSweepGeometry,
|
planSweepGeometry,
|
||||||
mergeColoured,
|
mergeColoured,
|
||||||
|
perforate,
|
||||||
mottleGeometry,
|
mottleGeometry,
|
||||||
prismGeometry,
|
prismGeometry,
|
||||||
roughenGeometry,
|
roughenGeometry,
|
||||||
|
|
@ -21,7 +22,7 @@ export {
|
||||||
tubeGeometry,
|
tubeGeometry,
|
||||||
taperedBoxGeometry,
|
taperedBoxGeometry,
|
||||||
} from './geometry.ts'
|
} from './geometry.ts'
|
||||||
export type { Level, MottleOptions, Plan, PlanLevel, RoughenOptions, Section, SheetLevel, Vec3 } from './geometry.ts'
|
export type { Level, MottleOptions, PerforationPattern, Plan, PlanLevel, RoughenOptions, Section, SheetLevel, Vec3 } from './geometry.ts'
|
||||||
export { bakeOcclusion } from './occlusion.ts'
|
export { bakeOcclusion } from './occlusion.ts'
|
||||||
export type { OcclusionOptions } from './occlusion.ts'
|
export type { OcclusionOptions } from './occlusion.ts'
|
||||||
export { createKitModel } from './kit.ts'
|
export { createKitModel } from './kit.ts'
|
||||||
|
|
|
||||||
314
src/models/contemporary-props/litter-bin/model.ts
Normal file
314
src/models/contemporary-props/litter-bin/model.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
/**
|
||||||
|
* @contemporary-props/litter-bin
|
||||||
|
*
|
||||||
|
* A perforated drum under an overhanging hood, hung off a post: the catalogue's
|
||||||
|
* thirtieth row, and the first object here where a hinge carries real mass.
|
||||||
|
*
|
||||||
|
* It is also the row that spends `perforate`, which is written in core rather
|
||||||
|
* than here for the reason the catalogue gives: perforation is defined across
|
||||||
|
* three rows in three domains -- the mesh waste bin in the office, the colander
|
||||||
|
* in the kitchen, and this -- and a helper that only knew how to punch a
|
||||||
|
* cylinder would be rewritten by the second of them. This model hands it a
|
||||||
|
* cylinder; the colander will hand it a curved shell and get the same code.
|
||||||
|
*
|
||||||
|
* Measured off the reference against a 1.2 m overall height:
|
||||||
|
*
|
||||||
|
* drum 368 mm across, 550 mm deep, hung with its base 400 mm up
|
||||||
|
* hood 450 mm across, 116 mm deep, overhanging the drum on both sides
|
||||||
|
* post 55 mm, tapered, on a 190 mm flange with four gussets
|
||||||
|
* punching 14 mm holes on a 24 mm pitch, staggered, with solid bands top
|
||||||
|
* and bottom and a solid strip where the bracket lands
|
||||||
|
*
|
||||||
|
* The hood hinges at the back of its bracket. That is the action the catalogue
|
||||||
|
* asks for and it is a real one: emptying the bin means swinging the hood up
|
||||||
|
* and lifting the liner out, so the pivot has to be behind the drum rather than
|
||||||
|
* over it, and the hood has to clear the drum's rim through the whole travel.
|
||||||
|
*/
|
||||||
|
import { type BufferGeometry } from 'three'
|
||||||
|
|
||||||
|
import {
|
||||||
|
bakeOcclusion,
|
||||||
|
chamferedBoxGeometry,
|
||||||
|
createKitModel,
|
||||||
|
createTinter,
|
||||||
|
jitter,
|
||||||
|
latheGeometry,
|
||||||
|
mergeColoured,
|
||||||
|
perforate,
|
||||||
|
smoothNormals,
|
||||||
|
type RuntimeContext,
|
||||||
|
type Vec3,
|
||||||
|
} from '../core/index.ts'
|
||||||
|
|
||||||
|
export interface LitterBinConfig {
|
||||||
|
/** Height to the top of the hood (metres). */
|
||||||
|
readonly height: number
|
||||||
|
/** Diameter of the drum (metres). */
|
||||||
|
readonly diameter: number
|
||||||
|
/** Rows of holes up the drum. */
|
||||||
|
readonly rows: number
|
||||||
|
/** Holes around the drum. */
|
||||||
|
readonly columns: number
|
||||||
|
/** How far the hood is swung up, 0 closed to 1 open. */
|
||||||
|
readonly lift: number
|
||||||
|
readonly seed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const litterBinDefaults: LitterBinConfig = {
|
||||||
|
height: 1.2,
|
||||||
|
diameter: 0.368,
|
||||||
|
// 14 mm holes on a 24 mm pitch, which is what the reference punches: at 26
|
||||||
|
// columns they came out 26 mm across and read as a grille rather than a
|
||||||
|
// perforation.
|
||||||
|
rows: 21,
|
||||||
|
columns: 34,
|
||||||
|
lift: 0,
|
||||||
|
seed: 33,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LitterBinParts = 'post' | 'drum' | 'hood'
|
||||||
|
|
||||||
|
export interface LitterBinActions {
|
||||||
|
/** Swings the hood up off the drum. 1 is fully open. */
|
||||||
|
lift(amount?: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hood's pose, in one place because the config and the action both drive
|
||||||
|
* it -- the lesson the pavement sign and the fire extinguisher both paid for.
|
||||||
|
* A pose that lives only in an anchor rotation is a pose no rebuild re-applies,
|
||||||
|
* so the slider moves and nothing happens.
|
||||||
|
*/
|
||||||
|
function applyLift(
|
||||||
|
runtime: RuntimeContext<LitterBinConfig, LitterBinParts>,
|
||||||
|
amount: number,
|
||||||
|
): void {
|
||||||
|
runtime.parts.hood.anchor.rotation.x = -Math.min(1, Math.max(0, amount)) * 1.15
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModel(overrides: Partial<LitterBinConfig> = {}) {
|
||||||
|
let heldLift = 0
|
||||||
|
let seenLift = Number.NaN
|
||||||
|
|
||||||
|
return createKitModel<
|
||||||
|
LitterBinConfig, 'steelPainted' | 'galvanised', LitterBinParts, LitterBinActions
|
||||||
|
>({
|
||||||
|
id: 'litter-bin',
|
||||||
|
defaults: litterBinDefaults,
|
||||||
|
slots: ['steelPainted', 'galvanised'],
|
||||||
|
|
||||||
|
build: ({ config, random }) => {
|
||||||
|
const tint = createTinter(random)
|
||||||
|
const H = Math.min(1.7, Math.max(0.8, config.height))
|
||||||
|
const D = Math.min(0.6, Math.max(0.2, config.diameter))
|
||||||
|
const rows = Math.max(3, Math.round(config.rows))
|
||||||
|
const columns = Math.max(8, Math.round(config.columns))
|
||||||
|
|
||||||
|
const R = D / 2
|
||||||
|
const drumH = H * 0.458
|
||||||
|
const drumBase = H * 0.333
|
||||||
|
const drumTop = drumBase + drumH
|
||||||
|
// The post stands behind the drum, not through it.
|
||||||
|
const postZ = R * 1.16
|
||||||
|
const postR = H * 0.023
|
||||||
|
const hoodY = H - H * 0.097
|
||||||
|
|
||||||
|
const paint = tint('steelPainted', jitter(random, 0.02))
|
||||||
|
const dark = tint('steelPainted', -0.5, 0.3)
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- post */
|
||||||
|
const postPieces: BufferGeometry[] = []
|
||||||
|
postPieces.push(latheGeometry([
|
||||||
|
{ y: H * 0.02, radius: postR * 1.18 },
|
||||||
|
{ y: H * 0.05, radius: postR },
|
||||||
|
// Up to the hood's arm, not to a number that looked about right: at
|
||||||
|
// 0.86 the post stopped 38 mm short of the arm it carries and the
|
||||||
|
// whole hood hung in the air, which the support check found and no
|
||||||
|
// amount of looking at the render from the front would have.
|
||||||
|
{ y: hoodY + H * 0.006, radius: postR * 0.92 },
|
||||||
|
], 18, [0, 0, postZ], paint, { capBottom: false, capTop: true }))
|
||||||
|
|
||||||
|
// The bolted foot: a flange with four gussets, which is how a post this
|
||||||
|
// slender is actually kept upright and the only place the object is
|
||||||
|
// fixed to anything.
|
||||||
|
postPieces.push(latheGeometry([
|
||||||
|
{ y: 0, radius: H * 0.079 },
|
||||||
|
{ y: H * 0.008, radius: H * 0.079 },
|
||||||
|
{ y: H * 0.013, radius: H * 0.07 },
|
||||||
|
], 24, [0, 0, postZ], tint('galvanised', 0, 0.5).offsetHSL(0, -1, 0),
|
||||||
|
{ capBottom: false, capTop: true }))
|
||||||
|
for (let i = 0; i < 4; i += 1) {
|
||||||
|
const a = (i / 4) * Math.PI * 2 + Math.PI / 4
|
||||||
|
const gusset = chamferedBoxGeometry(
|
||||||
|
[H * 0.008, H * 0.052], [H * 0.008, H * 0.012],
|
||||||
|
H * 0.1, H * 0.003, [0, 0, 0], paint,
|
||||||
|
)
|
||||||
|
gusset.translate(0, H * 0.063, H * 0.03)
|
||||||
|
gusset.rotateY(a)
|
||||||
|
gusset.translate(0, 0, postZ)
|
||||||
|
postPieces.push(gusset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- drum */
|
||||||
|
/**
|
||||||
|
* The punched band, and the two solid ones that close it.
|
||||||
|
*
|
||||||
|
* A drum punched edge to edge has a row of half holes at each end and
|
||||||
|
* reads as torn. The reference's has 75 mm of plain sheet at the top and
|
||||||
|
* 65 at the bottom, which is where the rolled rims are, and it is those
|
||||||
|
* bands rather than the holes that make the perforation look cut into
|
||||||
|
* something.
|
||||||
|
*/
|
||||||
|
const drumPieces: BufferGeometry[] = []
|
||||||
|
const bandTop = drumH * 0.14
|
||||||
|
const bandBot = drumH * 0.12
|
||||||
|
const punchLo = drumBase + bandBot
|
||||||
|
const punchHi = drumTop - bandTop
|
||||||
|
|
||||||
|
for (const [lo, hi] of [[drumBase, punchLo], [punchHi, drumTop]] as const) {
|
||||||
|
drumPieces.push(latheGeometry([
|
||||||
|
{ y: lo, radius: R },
|
||||||
|
{ y: hi, radius: R },
|
||||||
|
], 36, [0, 0, 0], paint, { capBottom: false, capTop: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* THREE fields, not one, split by two solid bands.
|
||||||
|
*
|
||||||
|
* The reference's punching is interrupted twice on the way up, and those
|
||||||
|
* bands are structural: a drum of continuous perforation has nothing to
|
||||||
|
* hold its shape, so a real one is stiffened at the third points. They
|
||||||
|
* are also most of what stops the punching reading as a printed texture,
|
||||||
|
* because they give it edges in the middle rather than only at the ends.
|
||||||
|
*
|
||||||
|
* The helper is called once per field rather than being taught about
|
||||||
|
* bands. It punches a surface; where the surface stops is the caller's
|
||||||
|
* business, and this way the colander can interrupt its own punching for
|
||||||
|
* a completely different reason without changing anything here.
|
||||||
|
*/
|
||||||
|
const fields = 3
|
||||||
|
const bandMid = drumH * 0.045
|
||||||
|
const fieldH = (punchHi - punchLo - bandMid * (fields - 1)) / fields
|
||||||
|
for (let f = 0; f < fields; f += 1) {
|
||||||
|
const lo = punchLo + f * (fieldH + bandMid)
|
||||||
|
const hi = lo + fieldH
|
||||||
|
drumPieces.push(perforate(
|
||||||
|
// The surface the punch is mapped onto: a plain cylinder, described
|
||||||
|
// by the caller because the helper is not allowed to assume one.
|
||||||
|
(u, v): Vec3 => {
|
||||||
|
const a = u * Math.PI * 2
|
||||||
|
return [Math.sin(a) * R, lo + (hi - lo) * v, Math.cos(a) * R]
|
||||||
|
},
|
||||||
|
{ rows: Math.max(2, Math.round(rows / fields)), columns, open: 0.55, stagger: true },
|
||||||
|
paint,
|
||||||
|
))
|
||||||
|
if (f < fields - 1) {
|
||||||
|
drumPieces.push(latheGeometry([
|
||||||
|
{ y: hi, radius: R },
|
||||||
|
{ y: hi + bandMid, radius: R },
|
||||||
|
], 36, [0, 0, 0], paint, { capBottom: false, capTop: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The liner: a dark drum just inside, so the holes have something behind
|
||||||
|
// them. Without it they open onto whatever is behind the bin and the
|
||||||
|
// punching reads as a stencil rather than as a hole.
|
||||||
|
drumPieces.push(latheGeometry([
|
||||||
|
{ y: drumBase + drumH * 0.03, radius: R * 0.955 },
|
||||||
|
{ y: drumTop - drumH * 0.02, radius: R * 0.955 },
|
||||||
|
], 30, [0, 0, 0], dark, { capBottom: true, capTop: false }))
|
||||||
|
|
||||||
|
// The rolled rims top and bottom, and the bracket that hangs the drum on
|
||||||
|
// the post.
|
||||||
|
for (const [y, lift] of [[drumBase, -0.04], [drumTop, 0.03]] as const) {
|
||||||
|
drumPieces.push(latheGeometry([
|
||||||
|
{ y: y - drumH * 0.018, radius: R * 1.035 },
|
||||||
|
{ y: y + drumH * 0.018, radius: R * 1.035 },
|
||||||
|
], 36, [0, 0, 0], tint('steelPainted', lift), { capBottom: false, capTop: false }))
|
||||||
|
}
|
||||||
|
// TWO compact fittings rather than one long block. A bracket two thirds
|
||||||
|
// of the drum's height reads as a spine the bin is welded to; the
|
||||||
|
// reference carries it on a pair of short lugs, which is both what a
|
||||||
|
// hinge looks like and what says the drum comes off.
|
||||||
|
for (const at of [0.16, 0.84]) {
|
||||||
|
// Narrow enough to tuck between the drum and the post rather than
|
||||||
|
// standing out either side of it: the lug is a fitting, and a fitting
|
||||||
|
// that reads as a block is a fitting nobody believes carries anything.
|
||||||
|
const lug = chamferedBoxGeometry(
|
||||||
|
[H * 0.032, postZ - R * 0.4], [H * 0.027, postZ - R * 0.4],
|
||||||
|
drumH * 0.13, H * 0.004, [0, 0, 0], paint,
|
||||||
|
)
|
||||||
|
lug.translate(0, drumBase + drumH * at, (R * 0.85 + postZ) / 2)
|
||||||
|
drumPieces.push(lug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- hood */
|
||||||
|
/**
|
||||||
|
* A shallow dome on an arm, and it OVERHANGS.
|
||||||
|
*
|
||||||
|
* The hood is half again the drum's diameter in the reference, which is
|
||||||
|
* what keeps rain out of a bin whose whole side is holes. Drawn to the
|
||||||
|
* drum's own width it stops reading as a hood at all and becomes a lid.
|
||||||
|
*/
|
||||||
|
const hoodPieces: BufferGeometry[] = []
|
||||||
|
const hoodR = R * 1.22
|
||||||
|
// A shallow shield, not a mushroom. Taken to a point the hood reads as a
|
||||||
|
// parasol; the reference's is a pressing with a flat crown and a rolled
|
||||||
|
// edge, and its whole job is to be wider than the drum rather than taller
|
||||||
|
// than it.
|
||||||
|
hoodPieces.push(latheGeometry([
|
||||||
|
{ y: hoodY, radius: hoodR },
|
||||||
|
// A real vertical lip, deep enough to throw a shadow line round the
|
||||||
|
// whole rim: at 8 mm it was an edge, and an edge is not a pressing.
|
||||||
|
{ y: hoodY + H * 0.014, radius: hoodR },
|
||||||
|
{ y: hoodY + H * 0.026, radius: hoodR * 0.95 },
|
||||||
|
{ y: hoodY + H * 0.043, radius: hoodR * 0.79 },
|
||||||
|
{ y: hoodY + H * 0.055, radius: hoodR * 0.48 },
|
||||||
|
{ y: hoodY + H * 0.06, radius: hoodR * 0.22 },
|
||||||
|
], 30, [0, 0, 0], tint('steelPainted', 0.04), { capBottom: true, capTop: true }))
|
||||||
|
|
||||||
|
// The arm back to the post, and the pivot it swings about.
|
||||||
|
const arm = chamferedBoxGeometry(
|
||||||
|
[H * 0.028, postZ + hoodR * 0.55], [H * 0.028, postZ + hoodR * 0.55],
|
||||||
|
H * 0.03, H * 0.004, [0, 0, 0], paint,
|
||||||
|
)
|
||||||
|
arm.translate(0, hoodY - H * 0.012, (postZ - hoodR * 0.55) / 2)
|
||||||
|
hoodPieces.push(arm)
|
||||||
|
|
||||||
|
bakeOcclusion(postPieces, { strength: 0.35 })
|
||||||
|
bakeOcclusion(drumPieces, { strength: 0.4 })
|
||||||
|
|
||||||
|
// The hood's origin is the pivot, behind the drum on the post, so one
|
||||||
|
// rotation swings it up and clear rather than through the rim.
|
||||||
|
const pivot = [0, hoodY - H * 0.012, postZ] as const
|
||||||
|
return {
|
||||||
|
post: { slot: 'steelPainted' as const, geometry: smoothNormals(mergeColoured(postPieces), 34) },
|
||||||
|
drum: { slot: 'steelPainted' as const, geometry: smoothNormals(mergeColoured(drumPieces), 34) },
|
||||||
|
hood: {
|
||||||
|
// Written where it sits and then moved into the PIVOT'S OWN SPACE,
|
||||||
|
// because that is the space a part with an origin is expected in:
|
||||||
|
// the anchor carries it back. Left in world coordinates it is placed
|
||||||
|
// twice and ends up a pivot height above itself.
|
||||||
|
slot: 'steelPainted' as const,
|
||||||
|
geometry: smoothNormals(
|
||||||
|
mergeColoured(hoodPieces).translate(-pivot[0], -pivot[1], -pivot[2]), 34,
|
||||||
|
),
|
||||||
|
origin: pivot,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: (runtime) => {
|
||||||
|
heldLift = runtime.getConfig().lift
|
||||||
|
seenLift = heldLift
|
||||||
|
applyLift(runtime, heldLift)
|
||||||
|
return { lift: (amount = 1) => { heldLift = amount; applyLift(runtime, amount) } }
|
||||||
|
},
|
||||||
|
|
||||||
|
update: (_dt, runtime) => {
|
||||||
|
const wanted = runtime.getConfig().lift
|
||||||
|
if (wanted !== seenLift) { seenLift = wanted; heldLift = wanted }
|
||||||
|
applyLift(runtime, heldLift)
|
||||||
|
},
|
||||||
|
}, overrides)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue