294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
/**
|
|
* @contemporary-props/jersey-barrier
|
|
*
|
|
* The extrusion helper's reference implementation, which is what the catalogue
|
|
* puts it eleventh for, and the cheapest model in the kit by a distance: the
|
|
* whole body is one polyline and a length.
|
|
*
|
|
* The polyline is not invented. The New Jersey profile is a published shape and
|
|
* every barrier on every road is a casting of it, so the section here is the
|
|
* standard one in inches and converted, rather than a curve drawn to look
|
|
* right. On a 32 inch barrier:
|
|
*
|
|
* 0 in 24 in wide the base
|
|
* 3 in 24 in wide the top of the vertical toe
|
|
* 13 in 9.75 in the 55 degree face, which is the one a tyre climbs
|
|
* 32 in 6 in the 84 degree face, and the top
|
|
*
|
|
* Those four numbers are the entire reason the shape is recognisable. The lower
|
|
* face is shallow enough that a car rides up it and comes back down; the upper
|
|
* one is steep enough that it does not roll. Draw it as a single straight taper
|
|
* and it is a plinth, not a barrier.
|
|
*
|
|
* The end connector is a half lap: the upper half of the casting runs past the
|
|
* lower at one end and the lower past the upper at the other. A single barrier
|
|
* is never the object -- they come in runs -- and a run of these placed end to
|
|
* end at `length` spacing interleaves.
|
|
*/
|
|
import { type BufferGeometry } from 'three'
|
|
|
|
import {
|
|
bakeOcclusion,
|
|
createKitModel,
|
|
createTinter,
|
|
extrudeGeometry,
|
|
jitter,
|
|
mergeColoured,
|
|
smoothNormals,
|
|
type Section,
|
|
} from '../core/index.ts'
|
|
|
|
export interface JerseyBarrierConfig {
|
|
/** Height of the barrier (metres). */
|
|
readonly height: number
|
|
/** Length of the cast section (metres). */
|
|
readonly length: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const jerseyBarrierDefaults: JerseyBarrierConfig = {
|
|
// 32 inches, which is the standard highway height and the one the published
|
|
// section is dimensioned at.
|
|
height: 0.81,
|
|
// Precast sections come at 2, 3 and 6 metres. Two is the one that gets
|
|
// craned around a works entrance, which is where most of them are seen.
|
|
length: 2,
|
|
seed: 7,
|
|
}
|
|
|
|
export type JerseyBarrierParts = 'body'
|
|
|
|
export function createModel(overrides: Partial<JerseyBarrierConfig> = {}) {
|
|
return createKitModel<
|
|
JerseyBarrierConfig, 'concrete', JerseyBarrierParts, Record<string, never>
|
|
>({
|
|
id: 'jersey-barrier',
|
|
defaults: jerseyBarrierDefaults,
|
|
slots: ['concrete'],
|
|
/*
|
|
* Concrete is the coarsest surface in the palette and this is the largest
|
|
* flat expanse of it in the kit, so it takes more than the default -- but
|
|
* at a LARGER cell, not a finer one.
|
|
*
|
|
* The obvious reading of the reference is fine aggregate speckle, and the
|
|
* first attempt chased it with a 50 mm cell. Mottle here is written into
|
|
* vertex colours, so a cell smaller than the distance between rings is
|
|
* sampled about once per cell and aliases: the barrier came out with long
|
|
* smeared bands down it and read as brushed metal. Speckle at that scale
|
|
* is a texture and this kit does not have textures. What it can carry is
|
|
* the other half of what concrete looks like -- broad tonal drift across a
|
|
* pour -- and that wants a cell several rings wide.
|
|
*
|
|
* The amount is then held DOWN, because a long constant section can only
|
|
* vary along its length and one octave of noise on it reads as stripes. At
|
|
* 0.2 the barrier came out banded like corrugated sheet. This is the point
|
|
* where more variation is worse than less, and it is a property of the
|
|
* shape rather than of the setting: nothing else in either kit is two
|
|
* metres of the same cross-section.
|
|
*/
|
|
mottle: { amount: 0.11, cell: 0.32 },
|
|
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
const H = Math.min(1.2, Math.max(0.5, config.height))
|
|
const L = Math.min(6, Math.max(0.8, config.length))
|
|
|
|
/**
|
|
* The section, in fractions of the height, straight off the published
|
|
* profile: 24, 24, 9.75 and 6 inches of width at 0, 3, 13 and 32 inches
|
|
* of height, all divided by 32.
|
|
*
|
|
* Written as one side and mirrored, because the two faces of a jersey
|
|
* barrier are the same face and writing them out twice is how they end
|
|
* up not being.
|
|
*/
|
|
const HALF: ReadonlyArray<readonly [number, number]> = [
|
|
[0.375, 0],
|
|
[0.375, 0.094],
|
|
[0.1523, 0.406],
|
|
// A small flat before the top edge: the casting has an arris rather
|
|
// than a knife edge, and at prop scale one chamfer is the difference
|
|
// between concrete and cardboard.
|
|
[0.0938, 0.978],
|
|
[0.078, 1],
|
|
]
|
|
|
|
/**
|
|
* ...and every corner of it is ROUNDED, which is not decoration.
|
|
*
|
|
* A casting comes out of a mould with radiused arrises -- the reference's
|
|
* are 20 to 30 mm on an 810 mm barrier -- and a section with mathematical
|
|
* corners reads as folded card at any size. Each corner is replaced by
|
|
* two points a radius back along each of its edges, which is a chamfer
|
|
* rather than a true fillet; across 20 mm nothing can tell, and the
|
|
* alternative is arcs at every vertex of every extruded thing in the
|
|
* catalogue.
|
|
*/
|
|
const R = 0.022
|
|
const back = (
|
|
p: readonly [number, number], to: readonly [number, number],
|
|
): readonly [number, number] => {
|
|
const dx = to[0] - p[0]
|
|
const dy = to[1] - p[1]
|
|
const len = Math.hypot(dx, dy) || 1
|
|
const d = Math.min(R, len * 0.45)
|
|
return [p[0] + (dx / len) * d, p[1] + (dy / len) * d]
|
|
}
|
|
const CORNERS: Array<readonly [number, number]> = []
|
|
for (let i = 0; i < HALF.length; i += 1) {
|
|
const p = HALF[i]!
|
|
// The first and last points sit ON the mirror line, so the edge on
|
|
// that side is the one the mirror supplies: the neighbour there is the
|
|
// same point with its x negated.
|
|
const prev = HALF[i - 1] ?? ([-p[0], p[1]] as const)
|
|
const next = HALF[i + 1] ?? ([-p[0], p[1]] as const)
|
|
CORNERS.push(back(p, prev), back(p, next))
|
|
}
|
|
|
|
/**
|
|
* ...and then the long edges are SUBDIVIDED.
|
|
*
|
|
* The two faces of this section are 0.3 of the height each, and a ring
|
|
* every 6 cm makes them into quads three times longer than they are
|
|
* wide. A quad is two triangles, its four corners carry four different
|
|
* mottle values, and the interpolation across the shared diagonal does
|
|
* not match on both sides of it: the flat faces came out crossed by
|
|
* broad diagonal facets. Nothing is wrong with the surface -- it is one
|
|
* plane -- and no amount of normal smoothing will help, because the seam
|
|
* is in the colour.
|
|
*
|
|
* Squarer quads fix it, and half of squarer is the section's job.
|
|
*/
|
|
const STEP = 0.075
|
|
for (let i = CORNERS.length - 1; i > 0; i -= 1) {
|
|
const a = CORNERS[i - 1]!
|
|
const b = CORNERS[i]!
|
|
const len = Math.hypot(b[0] - a[0], b[1] - a[1])
|
|
const cuts = Math.floor(len / STEP)
|
|
for (let k = cuts; k >= 1; k -= 1) {
|
|
const t = k / (cuts + 1)
|
|
CORNERS.splice(i, 0, [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
|
|
}
|
|
}
|
|
|
|
const widthAt = (y: number): number => {
|
|
for (let i = 0; i < CORNERS.length - 1; i += 1) {
|
|
const [x0, y0] = CORNERS[i]!
|
|
const [x1, y1] = CORNERS[i + 1]!
|
|
if (y >= y0 && y <= y1) {
|
|
const t = y1 === y0 ? 0 : (y - y0) / (y1 - y0)
|
|
return x0 + (x1 - x0) * t
|
|
}
|
|
}
|
|
return CORNERS.at(-1)![0]
|
|
}
|
|
|
|
/**
|
|
* One horizontal band of the section, mirrored and closed.
|
|
*
|
|
* `band(0, 1)` is the whole barrier; the two halves are what the end
|
|
* joint is made of, and a third band with its floor lifted is what the
|
|
* drainage openings are made of. Counter-clockwise seen from +Z -- up the
|
|
* right-hand face, across the top, back down the left -- which is the
|
|
* helper's whole contract and the same direction the plan sweep wanted.
|
|
*/
|
|
const band = (from: number, to: number): Section => {
|
|
const side: Array<readonly [number, number]> = [[widthAt(from), from]]
|
|
for (const c of CORNERS) if (c[1] > from && c[1] < to) side.push(c)
|
|
side.push([widthAt(to), to])
|
|
return [
|
|
...side.map(([x, y]) => [x * H, y * H] as const),
|
|
...[...side].reverse().map(([x, y]) => [-x * H, y * H] as const),
|
|
]
|
|
}
|
|
|
|
const section = band(0, 1)
|
|
|
|
const concrete = tint('concrete', jitter(random, 0.02))
|
|
// Lighter along the top, because the top of a barrier is the face that
|
|
// never gets splashed.
|
|
const crest = tint('concrete', 0.05)
|
|
// One ring every 6 cm, which is what gives the mottle somewhere to sit:
|
|
// three samples across its cell, where one sample gives smears. See the
|
|
// helper's `steps`.
|
|
const ringsPer = (span: number): number => Math.max(1, Math.round(span / 0.06))
|
|
|
|
/**
|
|
* The end joint is a HALF LAP, and it is the same joint at both ends.
|
|
*
|
|
* The first cut had a spigot on one end and a dark plate standing in for
|
|
* a pocket on the other, and the plate could not win: dark enough to read
|
|
* as a hole it became a black panel the size of the end face, light
|
|
* enough not to and it became a second projecting rib, which is exactly
|
|
* what the critic called it. A recess drawn in paint is a recess that is
|
|
* not there.
|
|
*
|
|
* A lap is a recess that is there. The upper half of the casting runs
|
|
* past the lower at one end and the lower past the upper at the other, so
|
|
* a row of identical barriers interleaves, and each end carries a real
|
|
* step that is visible from any angle with no shade of grey doing the
|
|
* work.
|
|
*/
|
|
const lap = 0.5
|
|
// Shallow. The reference's ends are very nearly flush, and the catalogue
|
|
// still wants a connector, so this is as little as will still read as a
|
|
// step from across a road: 3 cm on an 81 cm casting.
|
|
const lapD = H * 0.04
|
|
|
|
/**
|
|
* The drainage openings, and they are REAL openings.
|
|
*
|
|
* Water has to cross the line a barrier makes, so every casting has a
|
|
* pair of holes through the bottom of it; they are the only thing that
|
|
* breaks the silhouette of an otherwise entirely constant section. The
|
|
* cheap way to suggest one is a dark patch, and it does not work here,
|
|
* because a barrier is looked at end on as often as not and a patch has
|
|
* no daylight behind it.
|
|
*/
|
|
// Small, and near the ENDS, which is where the reference's is: a single
|
|
// slot cut into the bottom edge just in from the corner. Put at the
|
|
// third points and made twice this size they stopped reading as
|
|
// openings in a casting and started reading as a shape decision.
|
|
const notchH = 0.06
|
|
const gap = (H * 0.16) / L
|
|
|
|
interface Span { readonly from: number; readonly to: number; readonly cut: Section }
|
|
const lapFrac = lapD / L
|
|
const spans: Span[] = [
|
|
{ from: 0, to: lapFrac, cut: band(lap, 1) },
|
|
{ from: lapFrac, to: 0.14 - gap / 2, cut: section },
|
|
{ from: 0.14 - gap / 2, to: 0.14 + gap / 2, cut: band(notchH, 1) },
|
|
{ from: 0.14 + gap / 2, to: 0.86 - gap / 2, cut: section },
|
|
{ from: 0.86 - gap / 2, to: 0.86 + gap / 2, cut: band(notchH, 1) },
|
|
{ from: 0.86 + gap / 2, to: 1 - lapFrac, cut: section },
|
|
{ from: 1 - lapFrac, to: 1, cut: band(0, lap) },
|
|
]
|
|
|
|
const pieces: BufferGeometry[] = spans.map((span) => {
|
|
const length = (span.to - span.from) * L
|
|
return extrudeGeometry(span.cut, length, [0, 0, span.from * L - L / 2], concrete, {
|
|
colourTop: crest,
|
|
steps: ringsPer(length),
|
|
// Every span is capped at both ends. Between two spans of the same
|
|
// section that would leave a pair of coincident faces, which is the
|
|
// trap `latheGeometry` exists to avoid -- but here no two neighbours
|
|
// share a section, so every one of these caps is a real step that has
|
|
// to be drawn.
|
|
capStart: true,
|
|
capEnd: true,
|
|
})
|
|
})
|
|
|
|
bakeOcclusion(pieces, { strength: 0.35 })
|
|
|
|
return {
|
|
body: {
|
|
slot: 'concrete' as const,
|
|
// A low crease angle: concrete castings have arrises, and every edge
|
|
// on this shape is a real one that should stay sharp.
|
|
geometry: smoothNormals(mergeColoured(pieces), 18),
|
|
},
|
|
}
|
|
},
|
|
}, overrides)
|
|
}
|