medieval-kit/contemporary-props/models/traffic-cone/model.ts

197 lines
8.4 KiB
TypeScript

/**
* @contemporary-props/traffic-cone
*
* The clearest outline in the set, and the object where colour banding on a
* revolve gets defined. Every striped revolve after this one — the bollard,
* the channelizer drum, the barber pole if it ever comes — should copy the
* collar technique here rather than invent its own.
*
* The technique: the body is ONE radius profile, a single function of height,
* and a band is nothing but a y-range of that profile emitted into a different
* slot's geometry. Both sides of a boundary sample the profile at the same t,
* so the seam is watertight by construction, with no coincident faces and no
* gap. The collars additionally stand 3 mm proud of the wall — real cone
* sleeves are a wrap, not a print — which buys three things at once: the step
* ring catches light at the top and bottom of the band, the silhouette shows
* the sleeve, and the normal seam between the two geometries is hidden on an
* edge that is supposed to be there.
*
* The profile itself is not a straight cone. A moulded cone sags: the wall
* leaves the base wide and flares in quickly, then climbs to the tip nearly
* straight. That is a power curve, (1-t)^p with p above 1, and `flare` moves p.
* At flare 0 the profile is the straight cone of a traffic-cone pictogram,
* which is deliberately still in range.
*
* The base is square with a stepped lip: two chamfered tiers, the upper one
* smaller, which is how the moulding actually looks and what stops the cone
* reading as a witch's hat on a board. The body is planted 8 mm into the
* upper tier so nothing coincides with the base's top face.
*
* Parts: `base` and `body`. They are separate because a knocked-over cone is
* half the reason the object exists in scenes, and tipping is a rotation of
* the body against its base only in the cheap staged sense; more honestly,
* the base is the part a consumer recolours (recycled black PVC bases are
* everywhere) while the body stays hi-vis.
*/
import { type BufferGeometry } from 'three'
import {
chamferedBoxGeometry,
createKitModel,
createTinter,
latheGeometry,
mergeColoured,
smoothNormals,
type Level,
} from '../core/index.ts'
export interface TrafficConeConfig {
/** Overall height (metres). 0.45 site cone up to 1.0 motorway cone. */
readonly height: number
/** Square base width (metres). Clamped against height so the cone stays stable and stays a cone. */
readonly baseWidth: number
/** Retroreflective collars: 1 (single wide sleeve) or 2 (motorway pair). */
readonly collars: number
/** How concave the wall is. 0 is a straight cone, 1 a heavy moulded sag. */
readonly flare: number
/** Sides around the revolve. */
readonly segments: number
readonly seed: number
}
export const trafficConeDefaults: TrafficConeConfig = {
height: 0.75,
baseWidth: 0.4,
collars: 2,
flare: 0.55,
segments: 28,
seed: 7,
}
export type TrafficConeParts = 'base' | 'body'
export function createModel(overrides: Partial<TrafficConeConfig> = {}) {
return createKitModel<TrafficConeConfig, 'plastic' | 'retroreflective', TrafficConeParts>({
id: 'traffic-cone',
defaults: trafficConeDefaults,
slots: ['plastic', 'retroreflective'],
build: ({ config, random }) => {
const tint = createTinter(random)
const H = Math.min(1.2, Math.max(0.3, config.height))
// The base is clamped RELATIVE to the height. Real cones keep the base
// between roughly half and two thirds of the height; outside that the
// object either topples (too narrow) or reads as a pyramid on a raft.
const w = Math.min(0.6, Math.max(0.2,
Math.min(H * 0.72, Math.max(H * 0.42, config.baseWidth))))
const collars = Math.round(config.collars) >= 2 ? 2 : 1
const p = 1 + 0.9 * Math.min(1, Math.max(0, config.flare))
const segments = Math.max(12, Math.round(config.segments))
// Spread kept low on the body: hi-vis orange is a controlled colour and
// a lightness jitter that is charming on ceramic drifts it toward salmon.
const bodyOrange = tint('retroreflective', -0.02, 0.4)
const baseOrange = tint('retroreflective', -0.09, 0.5)
const sleeveWhite = tint('paper', 0.03, 0.4)
/* ------------------------------------------------------------- base */
// Two chamfered tiers, the upper sunk 3 mm into the lower so its bottom
// face ends inside the solid rather than level with the lower's top.
const h1 = 0.014 + H * 0.010
const h2 = 0.012 + H * 0.008
const w2 = w * 0.8
const baseTop = h1 - 0.003 + h2
const basePieces: BufferGeometry[] = [
chamferedBoxGeometry([w, w], [w * 0.96, w * 0.96], h1, 0.006,
[0, h1 / 2, 0], baseOrange),
chamferedBoxGeometry([w2, w2], [w2 * 0.94, w2 * 0.94], h2, 0.005,
[0, h1 - 0.003 + h2 / 2, 0], tint('retroreflective', -0.07, 0.5)),
]
/* ------------------------------------------------------------- body */
const bodyH = H - baseTop
const rFoot = w * 0.34
const rTip = Math.min(0.02 + H * 0.008, rFoot * 0.45)
// The whole cone in one line. Everything below samples this and only
// this, which is what makes the banding seam-free.
const profile = (t: number): number => rTip + (rFoot - rTip) * Math.pow(1 - t, p)
const levelAt = (t: number, proud = 0): Level =>
({ y: baseTop + bodyH * t, radius: profile(t) + proud })
// Band positions as fractions of the body. The two-collar layout is the
// motorway one: the wider band on top, a gap, a narrower band below.
const bands: ReadonlyArray<readonly [number, number]> =
collars === 2 ? [[0.42, 0.56], [0.64, 0.85]] : [[0.5, 0.86]]
const proud = 0.003
// Interior samples on the concave curve, endpoints exact.
const sample = (t0: number, t1: number, proudBy: number): Level[] => {
const steps = Math.max(2, Math.ceil((t1 - t0) / 0.045))
const levels: Level[] = []
for (let i = 0; i <= steps; i += 1) {
levels.push(levelAt(t0 + ((t1 - t0) * i) / steps, proudBy))
}
return levels
}
const plasticPieces: BufferGeometry[] = []
const collarPieces: BufferGeometry[] = []
const cuts = [0, ...bands.flat(), 1]
for (let i = 0; i < cuts.length - 1; i += 1) {
const t0 = cuts[i]!
const t1 = cuts[i + 1]!
const isBand = i % 2 === 1
if (isBand) {
// A collar is the same profile span pushed out by `proud`, with a
// flat step ring at each end (two levels at one y) and OPEN ends:
// the first and last level sit ON the cone wall, so the sleeve
// meets the body on the body's own edge rings.
const levels: Level[] = [
levelAt(t0),
...sample(t0, t1, proud),
levelAt(t1),
]
collarPieces.push(latheGeometry(levels, segments, [0, 0, 0], sleeveWhite,
{ capBottom: false, capTop: false }))
continue
}
const first = i === 0
const last = i === cuts.length - 2
const levels: Level[] = sample(t0, last ? 1 - 0.008 / bodyH : t1, 0)
if (first) {
// Plant the foot 8 mm inside the base's upper tier. The bottom cap
// is buried in solid plastic, which is the only place a cap that
// nothing should ever see is allowed to be.
levels.unshift({ y: baseTop - 0.008, radius: profile(0) })
}
if (last) {
// Blunt rolled tip. A cone does not come to a point; the mould
// rounds it off at about 70% of the tip radius.
levels.push({ y: H, radius: rTip * 0.7 })
}
plasticPieces.push(latheGeometry(levels, segments, [0, 0, 0], bodyOrange,
{ capBottom: first, capTop: last }))
}
// Smoothed at 40 degrees: the concave wall and the sleeve faces read as
// curves, while the collar step rings, the blunt tip and every base
// chamfer turn through more than that and stay edges.
return {
base: {
slot: 'plastic' as const,
geometry: smoothNormals(mergeColoured(basePieces), 40),
},
body: {
slot: 'plastic' as const,
geometry: smoothNormals(mergeColoured(plasticPieces), 40),
extras: [{
slot: 'retroreflective' as const,
geometry: smoothNormals(mergeColoured(collarPieces), 40),
}],
},
}
},
}, overrides)
}