medieval-kit/contemporary-props/models/bike-rack/model.ts

198 lines
8.2 KiB
TypeScript

/**
* @contemporary-props/bike-rack
*
* One swept hoop instanced along a rail, which is what the catalogue puts it
* twenty-first for and the whole of what it is: build the shape once, place it
* a derived number of times, and let the count be a slider.
*
* It is the row that introduces `tubeGeometry`, the kit's fourth way of making
* a solid. A hoop is a constant round section following a line that BENDS, and
* none of the three helpers before it can do that: a lathe turns about an axis,
* a plan sweep varies with height, an extrusion runs straight. The hoop's path
* is four numbers -- up one leg, round the crown, down the other -- and the
* same helper is waiting for the street lamp's swan neck and the traffic
* signal's mast arm later in this same domain.
*
* Measured against the fitted dimensions, which for a cycle stand are set by
* the bicycle rather than by taste: 750 mm above ground so the frame is held at
* two heights, and a 48 mm tube, which is the size a D-lock closes around. The
* narrow hoop in the reference is 300 mm across on a 38 mm tube, which is the
* toast-rack element rather than the 750 mm Sheffield stand: the same object,
* half the plan, and the one you see in a rank.
*
* The pitch is the slat-with-gap rule from the park bench, in its second use:
* the spacing is `run / (count - 1)`, never a number anybody typed, so a rank
* of four and a rank of nine both fill the same length of pavement.
*/
import { type BufferGeometry } from 'three'
import {
bakeOcclusion,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
smoothNormals,
tubeGeometry,
type Vec3,
} from '../core/index.ts'
export interface BikeRackConfig {
/** Hoops in the rank. */
readonly hoops: number
/** Length of pavement the rank occupies, first hoop to last (metres). */
readonly run: number
/** Height of a hoop above the ground (metres). */
readonly height: number
/** Width of a hoop across its legs (metres). */
readonly width: number
readonly seed: number
}
export const bikeRackDefaults: BikeRackConfig = {
hoops: 6,
// 600 mm between hoops. The pitch is the one proportion the critic and the
// measurement agreed on: two hoop widths, near enough, against a photograph
// that reads 1.9.
run: 3,
// 750 mm, which is not a choice: it is the height that puts the top bar
// across a bicycle's top tube and the bottom of the arc beside its wheel.
height: 0.75,
/*
* The narrow toast-rack element rather than the 750 mm Sheffield stand, and
* this number went out and came back.
*
* A round of critique said the hoops were too short and wide against a
* reference it read as four tall to one across, so they went to 240 mm on
* that word alone. The next round then said the tube was far too thick for
* the opening -- which is the same complaint from the other side, and both
* cannot be acted on.
*
* Measuring settled it. The reference's SIX hoops are not interchangeable:
* the far ones are seen obliquely and their width is foreshortened, so hoop
* one measures 4.6 tall to one across and hoop six, nearest and squarest to
* the camera, measures 2.45. Against hoop six a 300 mm hoop on a 750 mm
* stand is 2.5, its 38 mm tube is 0.127 of the width against the photo's
* 0.122, and the rank's pitch is 1.7 widths against 1.9. All three inside a
* few percent, from one measurement, after two rounds of moving on a word.
*/
width: 0.3,
seed: 41,
}
export type BikeRackParts = 'hoops'
export function createModel(overrides: Partial<BikeRackConfig> = {}) {
return createKitModel<
BikeRackConfig, 'galvanised', BikeRackParts, Record<string, never>
>({
id: 'bike-rack',
defaults: bikeRackDefaults,
slots: ['galvanised'],
// Hot-dip zinc is the one metal in the palette with a real spangle to it,
// and a rank of identical hoops is where a flat finish would show most.
mottle: { amount: 0.28, cell: 0.09 },
build: ({ config, random }) => {
const tint = createTinter(random)
const hoops = Math.max(2, Math.round(config.hoops))
const run = Math.min(8, Math.max(0.4, config.run))
const H = Math.min(1.1, Math.max(0.45, config.height))
const W = Math.min(0.9, Math.max(0.18, config.width))
// 38 mm outside diameter, off the reference rather than off memory: a
// 48 mm tube is made, but it is the Sheffield stand's gauge and on a
// 300 mm hoop it fills a fifth of the opening instead of an eighth.
const tube = 0.019
const zinc = tint('galvanised', jitter(random, 0.02), 0.6)
/**
* The hoop's path, as a polyline the sweep follows.
*
* Two straight legs and a half turn between them, and the arc is sampled
* rather than drawn: `arcBarGeometry` would give the crown but not the
* legs, and joining three pieces would leave two pairs of end caps inside
* the tube where the sections meet. One path, one solid, no joints.
*
* The crown's radius is half the hoop's width, so the arc is a true
* semicircle and the legs are tangent to it -- which is what a tube
* bender actually produces and what stops the corner reading as a mitre.
*/
const crown = W / 2
const legTop = H - crown
const path: Vec3[] = [[-crown, 0, 0]]
// A couple of intermediate points up each leg so the mottle has somewhere
// to sit; a two-point leg is one long quad and takes one colour.
for (const t of [0.35, 0.7]) path.push([-crown, legTop * t, 0])
const arcSteps = 14
for (let i = 0; i <= arcSteps; i += 1) {
const a = Math.PI - (i / arcSteps) * Math.PI
path.push([Math.cos(a) * crown, legTop + Math.sin(a) * crown, 0])
}
for (const t of [0.7, 0.35]) path.push([crown, legTop * t, 0])
path.push([crown, 0, 0])
/**
* ...and the pitch, which is derived.
*
* `run` is first hoop to last, so the gaps are one fewer than the hoops
* and the spacing is the run over that. A rank of two fills the same
* pavement as a rank of nine, and no count leaves a hoop hanging off one
* end -- the same rule the park bench's slats settled, in its second use.
*/
const pitch = hoops > 1 ? run / (hoops - 1) : 0
const pieces: BufferGeometry[] = []
for (let i = 0; i < hoops; i += 1) {
const z = -run / 2 + i * pitch
pieces.push(tubeGeometry(
path.map(([x, y]) => [x, y, z] as Vec3),
tube, 12, zinc,
// No caps: both ends are buried in their own flange below, and a cap
// there would be a disc inside solid metal.
{ capStart: false, capEnd: false },
))
/*
* The flange each leg is bolted down through, and its BOLTS.
*
* The flange alone is the only thing saying the hoop was fitted rather
* than pushed into mud, and on its own it did not say it: a plain pad
* reads as the bottom of the tube. Three bolt heads do, and they are
* drawn at 20 mm across rather than the 12 mm a real M10 head is --
* the same call the picnic table's coach bolts needed, for the same
* reason. A fastening nobody can see is a fastening that is not there.
*/
const flange = tint('galvanised', -0.05, 0.6)
for (const side of [-crown, crown]) {
pieces.push(latheGeometry([
{ y: 0, radius: tube * 3 },
{ y: 0.005, radius: tube * 2.9 },
{ y: 0.011, radius: tube * 2.3 },
{ y: 0.015, radius: tube * 1.3 },
], 16, [side, 0, z], flange, { capBottom: false, capTop: false }))
for (let b = 0; b < 3; b += 1) {
const a = (b / 3) * Math.PI * 2 + 0.5
pieces.push(latheGeometry([
{ y: 0.009, radius: 0.0095 },
{ y: 0.014, radius: 0.009 },
{ y: 0.017, radius: 0.005 },
], 8, [side + Math.cos(a) * tube * 2.1, 0, z + Math.sin(a) * tube * 2.1],
tint('galvanised', -0.12, 0.4), { capBottom: false, capTop: true }))
}
}
}
bakeOcclusion(pieces, { strength: 0.3 })
return {
hoops: {
slot: 'galvanised' as const,
geometry: smoothNormals(mergeColoured(pieces), 46),
},
}
},
}, overrides)
}