304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
/**
|
|
* @contemporary-props/temporary-fence-panel
|
|
*
|
|
* Welded mesh in a tube frame, which the catalogue puts thirty-first with the
|
|
* note that thousands of identical bars is exactly the work generation should
|
|
* absorb. That is the whole argument for a source-first kit in one object: a
|
|
* modeller draws this mesh once and never changes its pitch again, and here the
|
|
* pitch is a slider and the wires are a loop.
|
|
*
|
|
* The mesh is NOT `perforate`. That helper punches a surface -- it emits the
|
|
* metal left between holes, as flat web on a described surface -- and it is
|
|
* right for a bin's drum or a colander's shell. Welded mesh is the opposite
|
|
* construction: it is bars with nothing between them, seen from both sides and
|
|
* usually at a grazing angle, where a flat ribbon would vanish edge on. So the
|
|
* wires here are solid stock, and the helper's own comment says which rows want
|
|
* it instead.
|
|
*
|
|
* The frame is one bent tube, which is `tubeGeometry`'s third use: up one leg,
|
|
* round a radius, across the top, round again and down the other. A real panel
|
|
* is bent from a single length and the two top corners are the give-away --
|
|
* built as three straight tubes with mitres it reads as scaffold rather than as
|
|
* a pressing.
|
|
*
|
|
* Measured off the reference, which is the 2.4 m panel rather than the 3.45 m
|
|
* Heras: vertical wires at a 50 mm pitch, six horizontals, a frame tube of
|
|
* 32 mm, and two moulded feet the legs drop into.
|
|
*/
|
|
import { type BufferGeometry } from 'three'
|
|
|
|
import {
|
|
bakeOcclusion,
|
|
chamferedBoxGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
mergeColoured,
|
|
smoothNormals,
|
|
tubeGeometry,
|
|
type RuntimeContext,
|
|
type Vec3,
|
|
} from '../core/index.ts'
|
|
|
|
export interface FencePanelConfig {
|
|
/** Width of the panel, leg centre to leg centre (metres). */
|
|
readonly width: number
|
|
/** Height to the top of the frame (metres). */
|
|
readonly height: number
|
|
/** Pitch of the vertical wires (metres). */
|
|
readonly pitch: number
|
|
/** Horizontal wires across the mesh. */
|
|
readonly rails: number
|
|
/** How far the panel is swung about its coupler, 0 in line to 1 square. */
|
|
readonly swing: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const fencePanelDefaults: FencePanelConfig = {
|
|
width: 2.4,
|
|
height: 2,
|
|
pitch: 0.05,
|
|
rails: 6,
|
|
swing: 0,
|
|
seed: 51,
|
|
}
|
|
|
|
export type FencePanelParts = 'panel' | 'feet'
|
|
|
|
export interface FencePanelActions {
|
|
/** Swings the panel about the coupler at its left leg. 1 is a right angle. */
|
|
swing(amount?: number): void
|
|
}
|
|
|
|
/**
|
|
* The swing, in one place because a slider and a method both drive it -- the
|
|
* lesson the pavement sign, the extinguisher and the litter bin all paid for.
|
|
*/
|
|
function applySwing(
|
|
runtime: RuntimeContext<FencePanelConfig, FencePanelParts>,
|
|
amount: number,
|
|
): void {
|
|
const turn = Math.min(1, Math.max(0, amount)) * (Math.PI / 2)
|
|
for (const part of Object.values(runtime.parts)) part.anchor.rotation.y = turn
|
|
}
|
|
|
|
export function createModel(overrides: Partial<FencePanelConfig> = {}) {
|
|
let heldSwing = 0
|
|
let seenSwing = Number.NaN
|
|
|
|
return createKitModel<
|
|
FencePanelConfig, 'galvanised' | 'rubber', FencePanelParts, FencePanelActions
|
|
>({
|
|
id: 'temporary-fence-panel',
|
|
defaults: fencePanelDefaults,
|
|
slots: ['galvanised', 'rubber'],
|
|
// Hot-dip zinc, and a panel is the largest single expanse of it the kit
|
|
// has: a flat finish here would show more than anywhere else.
|
|
mottle: { amount: 0.24, cell: 0.12 },
|
|
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
const W = Math.min(3.6, Math.max(1, config.width))
|
|
const H = Math.min(2.6, Math.max(1, config.height))
|
|
const rails = Math.max(2, Math.round(config.rails))
|
|
|
|
const zinc = tint('galvanised', jitter(random, 0.02), 0.6)
|
|
const wire = tint('galvanised', 0.03, 0.5)
|
|
|
|
const tube = H * 0.008
|
|
const corner = H * 0.1
|
|
const half = W / 2
|
|
// The legs run past the bottom rail and into the feet, which is what
|
|
// stops the panel reading as a hurdle standing on its own frame.
|
|
const legFoot = H * 0.03
|
|
const railY = H * 0.16
|
|
|
|
const panelPieces: BufferGeometry[] = []
|
|
|
|
/* -------------------------------------------------------------- frame */
|
|
/**
|
|
* One tube, bent twice. The corners are sampled arcs rather than mitres
|
|
* because a mitre is a weld and a radius is a bender, and the reference
|
|
* was plainly bent.
|
|
*/
|
|
const path: Vec3[] = []
|
|
path.push([-half, legFoot, 0])
|
|
for (const t of [0.3, 0.6]) path.push([-half, legFoot + (H - corner - legFoot) * t, 0])
|
|
path.push([-half, H - corner, 0])
|
|
const steps = 8
|
|
for (let i = 1; i < steps; i += 1) {
|
|
const a = Math.PI - (i / steps) * (Math.PI / 2)
|
|
path.push([-half + corner + Math.cos(a) * corner, H - corner + Math.sin(a) * corner, 0])
|
|
}
|
|
path.push([-half + corner, H, 0])
|
|
path.push([half - corner, H, 0])
|
|
for (let i = 1; i < steps; i += 1) {
|
|
const a = (Math.PI / 2) - (i / steps) * (Math.PI / 2)
|
|
path.push([half - corner + Math.cos(a) * corner, H - corner + Math.sin(a) * corner, 0])
|
|
}
|
|
path.push([half, H - corner, 0])
|
|
for (const t of [0.6, 0.3]) path.push([half, legFoot + (H - corner - legFoot) * t, 0])
|
|
path.push([half, legFoot, 0])
|
|
panelPieces.push(tubeGeometry(path, tube, 10, zinc, { capStart: true, capEnd: true }))
|
|
|
|
// The bottom rail, which carries the mesh's lower edge and is the only
|
|
// straight run in the frame.
|
|
panelPieces.push(tubeGeometry(
|
|
[[-half, railY, 0], [0, railY, 0], [half, railY, 0]],
|
|
tube, 10, zinc, { capStart: false, capEnd: false },
|
|
))
|
|
|
|
/* --------------------------------------------------------------- mesh */
|
|
/**
|
|
* The wires: a derived count, never a typed one.
|
|
*
|
|
* The pitch is what a panel is specified by, so the COUNT falls out of
|
|
* the width and the pitch and is then used to re-derive an exact pitch --
|
|
* otherwise the last bay is a sliver, which on a fence is the one thing
|
|
* everybody has seen and nobody can name.
|
|
*/
|
|
const meshLo = railY + tube
|
|
const meshHi = H - tube * 1.2
|
|
const inner = W - tube * 4
|
|
const columns = Math.max(4, Math.round(inner / Math.min(0.3, Math.max(0.02, config.pitch))))
|
|
const wireR = H * 0.0022
|
|
|
|
/*
|
|
* Every wire is cut to the FRAME, not to a rectangle.
|
|
*
|
|
* The frame's top corners are radiused and the mesh is not, so wires cut
|
|
* to one length stand proud of the bend at both ends -- four or five of
|
|
* them at each corner, sticking out into the air above the tube they are
|
|
* supposed to be welded to. It is the sort of fault that is invisible
|
|
* head on and obvious from anywhere else.
|
|
*
|
|
* The cut is the arc's own equation, so it follows the corner radius
|
|
* whatever it is set to.
|
|
*/
|
|
const topAt = (x: number): number => {
|
|
const over = Math.abs(x) - (half - corner)
|
|
if (over <= 0) return meshHi
|
|
const drop = corner - Math.sqrt(Math.max(0, corner * corner - over * over))
|
|
return meshHi - drop
|
|
}
|
|
for (let i = 0; i <= columns; i += 1) {
|
|
const x = -inner / 2 + (inner / columns) * i
|
|
const hi = topAt(x)
|
|
if (hi - meshLo <= wireR * 2) continue
|
|
panelPieces.push(chamferedBoxGeometry(
|
|
[wireR * 2, wireR * 2], [wireR * 2, wireR * 2],
|
|
hi - meshLo, wireR * 0.5, [x, (meshLo + hi) / 2, 0], wire,
|
|
))
|
|
}
|
|
for (let i = 0; i < rails; i += 1) {
|
|
// Spread over the mesh's own height rather than the panel's, so the top
|
|
// wire sits under the frame instead of on it.
|
|
const y = meshLo + ((meshHi - meshLo) / (rails - 1 || 1)) * i
|
|
const bar = chamferedBoxGeometry(
|
|
[wireR * 2, wireR * 2], [wireR * 2, wireR * 2],
|
|
inner + tube, wireR * 0.5, [0, 0, 0], wire,
|
|
)
|
|
// Built up Y, laid across, and set a wire's thickness behind the
|
|
// verticals: welded mesh is two layers, not one plane.
|
|
bar.rotateZ(Math.PI / 2)
|
|
bar.translate(0, y, -wireR * 1.6)
|
|
panelPieces.push(bar)
|
|
}
|
|
|
|
// The coupler tabs, two a leg, which is what a run is bolted together
|
|
// through and the only reason a single panel stands up in a scene.
|
|
for (const side of [-1, 1]) {
|
|
for (const at of [0.28, 0.72]) {
|
|
const tab = chamferedBoxGeometry(
|
|
[tube * 0.5, tube * 2.4], [tube * 0.5, tube * 2.4],
|
|
tube * 2.6, tube * 0.12, [0, 0, 0], zinc,
|
|
)
|
|
tab.rotateZ(Math.PI / 2)
|
|
tab.translate(side * (half + tube * 1.1), legFoot + (H - legFoot) * at, 0)
|
|
panelPieces.push(tab)
|
|
}
|
|
}
|
|
|
|
/* --------------------------------------------------------------- feet */
|
|
/**
|
|
* The feet, and they lie ACROSS the panel.
|
|
*
|
|
* A moulded block a panel's leg drops into is what makes a temporary
|
|
* fence temporary, and its long axis is perpendicular to the fence for
|
|
* the obvious reason: the panel falls over the other way. Drawn in line
|
|
* with the fence the whole object stops making sense, which is worth more
|
|
* than the two minutes the shape costs.
|
|
*/
|
|
const footPieces: BufferGeometry[] = []
|
|
const footL = H * 0.34
|
|
const footH = H * 0.05
|
|
for (const side of [-1, 1]) {
|
|
const block = chamferedBoxGeometry(
|
|
[H * 0.075, footL], [H * 0.062, footL * 0.9],
|
|
footH, footH * 0.16, [0, 0, 0], tint('rubber', -0.04),
|
|
)
|
|
block.translate(side * half, footH / 2, 0)
|
|
footPieces.push(block)
|
|
|
|
// The notches at each end, which every moulded foot has: they are how
|
|
// a forklift or a hand gets under it, and without them the foot reads
|
|
// as a bevelled brick.
|
|
for (const end of [-1, 1]) {
|
|
footPieces.push(chamferedBoxGeometry(
|
|
[H * 0.05, footL * 0.16], [H * 0.05, footL * 0.16],
|
|
footH * 0.55, footH * 0.06,
|
|
[side * half, footH * 0.28, end * footL * 0.34], tint('rubber', -0.3, 0.3),
|
|
))
|
|
}
|
|
// ...and the moulded recess on the top face either side of the boss.
|
|
for (const end of [-1, 1]) {
|
|
footPieces.push(chamferedBoxGeometry(
|
|
[H * 0.042, footL * 0.2], [H * 0.036, footL * 0.18],
|
|
footH * 0.12, footH * 0.04,
|
|
[side * half, footH * 0.96, end * footL * 0.24], tint('rubber', -0.16, 0.3),
|
|
))
|
|
}
|
|
// The boss the leg goes into, so the leg lands in something rather
|
|
// than on it.
|
|
footPieces.push(chamferedBoxGeometry(
|
|
[tube * 4.4, tube * 4.4], [tube * 3.6, tube * 3.6],
|
|
footH * 0.5, tube * 0.4, [side * half, footH * 1.2, 0], tint('rubber', 0.02),
|
|
))
|
|
}
|
|
|
|
bakeOcclusion(panelPieces, { strength: 0.3 })
|
|
|
|
// Both parts turn about the coupler at the left-hand leg, so a run of
|
|
// these can be laid out by swinging each against the last.
|
|
const coupler = [-half, 0, 0] as const
|
|
const move = (pieces: BufferGeometry[]): BufferGeometry =>
|
|
mergeColoured(pieces).translate(-coupler[0], -coupler[1], -coupler[2])
|
|
|
|
return {
|
|
panel: {
|
|
slot: 'galvanised' as const,
|
|
geometry: smoothNormals(move(panelPieces), 40),
|
|
origin: coupler,
|
|
},
|
|
feet: {
|
|
slot: 'rubber' as const,
|
|
geometry: smoothNormals(move(footPieces), 30),
|
|
origin: coupler,
|
|
},
|
|
}
|
|
},
|
|
|
|
actions: (runtime) => {
|
|
heldSwing = runtime.getConfig().swing
|
|
seenSwing = heldSwing
|
|
applySwing(runtime, heldSwing)
|
|
return { swing: (amount = 1) => { heldSwing = amount; applySwing(runtime, amount) } }
|
|
},
|
|
|
|
update: (_dt, runtime) => {
|
|
const wanted = runtime.getConfig().swing
|
|
if (wanted !== seenSwing) { seenSwing = wanted; heldSwing = wanted }
|
|
applySwing(runtime, heldSwing)
|
|
},
|
|
}, overrides)
|
|
}
|