635 lines
28 KiB
TypeScript
635 lines
28 KiB
TypeScript
/**
|
|
* @contemporary-props/fire-extinguisher
|
|
*
|
|
* The first small-part cluster in the kit, and the first squeeze lever, which
|
|
* is what the catalogue puts it ninth for. The bottle underneath is the same
|
|
* revolve the gas cylinder already settled; everything new is in the head.
|
|
*
|
|
* A cluster is not a list of small pieces, it is small pieces that are only
|
|
* legible TOGETHER. The valve block on its own is a lump of chrome. With a
|
|
* gauge on its face, a pin through it, a ring on the pin and a lever over the
|
|
* top it becomes a thing that is obviously operated, and every one of those
|
|
* parts is under a centimetre. So the rule the cluster is built to is that no
|
|
* piece may be smaller than it can be seen at: the gauge is 0.27 of the
|
|
* bottle's radius, which is large for a gauge and is the size at which a
|
|
* viewer reads "dial" rather than "grey dot".
|
|
*
|
|
* Measured off the reference. 4.0 diameters tall, and:
|
|
*
|
|
* 0.000 the foot, a rolled band
|
|
* 0.054 the shell
|
|
* 0.724 the shoulder
|
|
* 0.796 the valve, and the cluster on it
|
|
* 0.905 the lever
|
|
* 0.294 the hose band, which is what stops the hose being a loose noodle
|
|
*
|
|
* The lever is its own part with its origin AT THE PIVOT, so squeezing it is
|
|
* `lever.anchor.rotation.z` and nothing else. Its travel stops against the
|
|
* carry handle rather than at an arbitrary angle, because that is what it
|
|
* stops against on the real thing and it is the difference between an action
|
|
* and a slider.
|
|
*/
|
|
import { type BufferGeometry, type Color } from 'three'
|
|
|
|
import {
|
|
arcBarGeometry,
|
|
bakeOcclusion,
|
|
chamferedBoxGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
latheGeometry,
|
|
mergeColoured,
|
|
smoothNormals,
|
|
type Level,
|
|
} from '../core/index.ts'
|
|
|
|
export interface FireExtinguisherConfig {
|
|
/** Overall height to the top of the lever (metres). */
|
|
readonly height: number
|
|
/** Radius of the shell (metres). */
|
|
readonly radius: number
|
|
/** How far the lever is squeezed, 0 released to 1 against the handle. */
|
|
readonly squeeze: number
|
|
/** Sides around the revolve. */
|
|
readonly segments: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const fireExtinguisherDefaults: FireExtinguisherConfig = {
|
|
height: 0.42,
|
|
// 4.0 diameters tall, off the reference. This is the household size; the
|
|
// slider reaches the tall industrial one without changing the head, which is
|
|
// correct, because the head is the same casting on both.
|
|
radius: 0.052,
|
|
squeeze: 0,
|
|
segments: 30,
|
|
seed: 29,
|
|
}
|
|
|
|
export type FireExtinguisherParts = 'body' | 'valve' | 'handle' | 'lever' | 'hose'
|
|
|
|
export interface FireExtinguisherActions {
|
|
/** Squeezes the lever. 0 released, 1 hard against the handle. */
|
|
squeeze(amount?: number): void
|
|
}
|
|
|
|
export function createModel(overrides: Partial<FireExtinguisherConfig> = {}) {
|
|
// Shared by the action and the frame hook; they are two properties of one
|
|
// options object and otherwise have no way to agree.
|
|
let heldSqueeze = 0
|
|
let seenSqueeze = Number.NaN
|
|
|
|
return createKitModel<
|
|
FireExtinguisherConfig,
|
|
'retroreflective' | 'chrome' | 'steelPainted' | 'rubber' | 'brass',
|
|
FireExtinguisherParts, FireExtinguisherActions
|
|
>({
|
|
id: 'fire-extinguisher',
|
|
defaults: fireExtinguisherDefaults,
|
|
slots: ['retroreflective', 'chrome', 'steelPainted', 'rubber', 'brass'],
|
|
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
const H = Math.min(1.1, Math.max(0.25, config.height))
|
|
const R = Math.min(H * 0.16, Math.max(H * 0.085, Math.min(0.12, Math.max(0.03, config.radius))))
|
|
const segments = Math.max(12, Math.round(config.segments))
|
|
|
|
// Where the hose hangs, round from the gauge. Named because the band's
|
|
// clamp has to be put at the same angle and the two must not drift.
|
|
const HOSE_YAW = 0.75
|
|
|
|
const footTop = H * 0.054
|
|
const shellTop = H * 0.724
|
|
const neckY = H * 0.796
|
|
const neckR = R * 0.36
|
|
const valveTop = H * 0.905
|
|
|
|
/* -------------------------------------------------------------- shell */
|
|
const red = tint('retroreflective', -0.06, 0.85)
|
|
const shell: Level[] = [
|
|
{ y: 0, radius: R * 0.97 },
|
|
{ y: H * 0.006, radius: R * 1.04 },
|
|
{ y: footTop, radius: R * 1.04 },
|
|
{ y: footTop + H * 0.008, radius: R },
|
|
// Two rolled ribs just above the foot. They are 4 mm of relief on a
|
|
// 420 mm bottle and they are the only thing that says the shell was
|
|
// pressed from sheet and seamed rather than turned from a billet; the
|
|
// reference has both and the first cut of this had neither.
|
|
{ y: H * 0.086, radius: R },
|
|
{ y: H * 0.094, radius: R * 1.028 },
|
|
{ y: H * 0.104, radius: R * 1.028 },
|
|
{ y: H * 0.112, radius: R },
|
|
{ y: H * 0.132, radius: R },
|
|
{ y: H * 0.14, radius: R * 1.028 },
|
|
{ y: H * 0.15, radius: R * 1.028 },
|
|
{ y: H * 0.158, radius: R },
|
|
{ y: shellTop, radius: R },
|
|
{ y: shellTop + (neckY - shellTop) * 0.4, radius: R * 0.93 },
|
|
{ y: shellTop + (neckY - shellTop) * 0.72, radius: R * 0.74 },
|
|
{ y: neckY, radius: neckR * 1.15 },
|
|
{ y: neckY + H * 0.006, radius: neckR },
|
|
]
|
|
const bodyPieces: BufferGeometry[] = [
|
|
latheGeometry(shell, segments, [0, 0, 0], red, {
|
|
colourTop: tint('retroreflective', 0.04, 0.85),
|
|
capBottom: true,
|
|
capTop: true,
|
|
}),
|
|
]
|
|
|
|
const black = tint('rubber', jitter(random, 0.02))
|
|
// The band that holds the hose to the bottle. It is the only reason the
|
|
// hose reads as belonging to the extinguisher rather than lying against
|
|
// it, and it costs one ring.
|
|
bodyPieces.push(latheGeometry([
|
|
{ y: H * 0.294, radius: R * 1.02 },
|
|
{ y: H * 0.328, radius: R * 1.02 },
|
|
], segments, [0, 0, 0], black, { capBottom: false, capTop: false }))
|
|
|
|
// Declared up here because the carry handle is sized to it: the jaw and
|
|
// the lever are the same casting length on the real thing.
|
|
const leverLen = R * 1.75
|
|
|
|
/**
|
|
* The two members, and they are ONE SHAPE built twice.
|
|
*
|
|
* The first cut of this made the handle a straight chrome bar and the
|
|
* lever a straight black one, and it read as a hammer resting on a pipe.
|
|
* The reference has two black members that taper almost to a point, rise
|
|
* away from the valve and hook back toward each other at the tips. That
|
|
* silhouette is the whole reason the head reads as something you grip
|
|
* rather than a lump with a stick on it, and it costs three segments
|
|
* each.
|
|
*
|
|
* Knots are [x, y, half-width] in shell radii, so the pair can be read
|
|
* against each other: what matters is the GAP down the middle, because
|
|
* that is what the squeeze closes.
|
|
*/
|
|
const member = (
|
|
knots: readonly (readonly [number, number, number])[],
|
|
colour: Color,
|
|
): BufferGeometry[] => knots.slice(0, -1).map((knot, i) => {
|
|
const [x0, y0, w0] = knot
|
|
const [x1, y1, w1] = knots[i + 1]!
|
|
const dx = (x1 - x0) * R
|
|
const dy = (y1 - y0) * R
|
|
const bar = chamferedBoxGeometry(
|
|
[w0 * 2 * R, w0 * 1.2 * R], [w1 * 2 * R, w1 * 1.2 * R],
|
|
Math.hypot(dx, dy), Math.min(w0, w1) * R * 0.45, [0, 0, 0], colour,
|
|
)
|
|
// Built up +Y about its own centre, so it swings onto the segment's
|
|
// direction and then moves to the segment's midpoint.
|
|
bar.rotateZ(Math.atan2(dy, dx) - Math.PI / 2)
|
|
bar.translate((x0 + x1) * 0.5 * R, (y0 + y1) * 0.5 * R, 0)
|
|
return bar
|
|
})
|
|
|
|
/*
|
|
* Painted steel, taken most of the way to black.
|
|
*
|
|
* The palette's `steelPainted` is a mid grey because that is what most
|
|
* painted steel in the kit is. These two members are the exception: on
|
|
* every extinguisher they are black, and at grey they merged with the
|
|
* chrome casting behind them and the whole head read as one lump. The
|
|
* slot stays `steelPainted`, so a consumer who repaints painted steel
|
|
* still gets these; only the tint knows they are the black ones.
|
|
*/
|
|
const blackSteel = tint('steelPainted', -0.3, 0.5)
|
|
|
|
/* -------------------------------------------------------------- valve */
|
|
const steel = tint('chrome', jitter(random, 0.02), 0.5)
|
|
|
|
/**
|
|
* The casting, and it is deliberately STEPPED.
|
|
*
|
|
* The first cut was one straight barrel with a taper at each end, and it
|
|
* read as a single pale lump half a bottle wide. The reference divides
|
|
* the same zone into four things you can name: the red shell collar, the
|
|
* chrome skirt swaged onto it, the hex the spanner goes on, and the
|
|
* bonnet the lever pivots in. None of them costs more than two levels of
|
|
* the same lathe, and the difference is whether the head is a component
|
|
* or a blob.
|
|
*/
|
|
const hexY = neckY + H * 0.03
|
|
const valvePieces: BufferGeometry[] = [
|
|
latheGeometry([
|
|
{ y: neckY, radius: neckR * 1.18 },
|
|
{ y: neckY + H * 0.01, radius: neckR * 1.22 },
|
|
{ y: neckY + H * 0.018, radius: neckR * 1.0 },
|
|
{ y: hexY, radius: neckR * 0.98 },
|
|
{ y: hexY + H * 0.024, radius: neckR * 0.98 },
|
|
{ y: hexY + H * 0.03, radius: neckR * 0.82 },
|
|
{ y: valveTop - H * 0.014, radius: neckR * 0.82 },
|
|
{ y: valveTop - H * 0.008, radius: neckR * 0.94 },
|
|
{ y: valveTop, radius: neckR * 0.9 },
|
|
], Math.max(10, Math.round(segments * 0.6)), [0, 0, 0], steel,
|
|
{ colourTop: tint('chrome', 0.05, 0.5), capBottom: true, capTop: true }),
|
|
]
|
|
|
|
// The hex the spanner goes on: six flats, so the eye gets one corner in
|
|
// silhouette and knows the casting is machined rather than moulded.
|
|
valvePieces.push(latheGeometry([
|
|
{ y: hexY + H * 0.002, radius: neckR * 1.16 },
|
|
{ y: hexY + H * 0.022, radius: neckR * 1.16 },
|
|
], 6, [0, 0, 0], tint('chrome', -0.03, 0.5), { capBottom: true, capTop: true }))
|
|
|
|
// The shell's own collar under it, in the shell's red, which is what
|
|
// separates the chrome from the bottle instead of letting them run
|
|
// together into one pale-topped shape.
|
|
valvePieces.push(latheGeometry([
|
|
{ y: neckY - H * 0.016, radius: neckR * 1.3 },
|
|
{ y: neckY - H * 0.004, radius: neckR * 1.34 },
|
|
{ y: neckY + H * 0.004, radius: neckR * 1.28 },
|
|
], Math.max(10, Math.round(segments * 0.6)), [0, 0, 0],
|
|
tint('retroreflective', -0.1, 0.6), { capBottom: false, capTop: false }))
|
|
|
|
/**
|
|
* The gauge, and it is deliberately oversized.
|
|
*
|
|
* A pressure gauge on a 2 kg extinguisher is about 35 mm across on a
|
|
* 105 mm bottle, and at prop scale that is a grey dot. Drawn at 0.27 of
|
|
* the shell radius it is the size at which the eye takes it as a dial,
|
|
* which is the only thing it is there to say. The face is a separate
|
|
* disc a hair proud of the bezel so it catches its own light.
|
|
*/
|
|
const gaugeR = R * 0.27
|
|
const gaugeY = neckY + (valveTop - neckY) * 0.45
|
|
const gauge = latheGeometry([
|
|
{ y: 0, radius: gaugeR * 0.72 },
|
|
{ y: gaugeR * 0.42, radius: gaugeR },
|
|
{ y: gaugeR * 0.72, radius: gaugeR },
|
|
{ y: gaugeR * 0.8, radius: gaugeR * 0.88 },
|
|
], Math.max(10, Math.round(segments * 0.5)), [0, 0, 0], steel,
|
|
{ capBottom: false, capTop: true })
|
|
gauge.rotateX(Math.PI / 2)
|
|
gauge.translate(0, gaugeY, neckR * 0.8)
|
|
valvePieces.push(gauge)
|
|
|
|
/**
|
|
* And what is ON the face, which is the difference between a dial and a
|
|
* disc.
|
|
*
|
|
* A blank disc at this size reads as a bolt head. Three marks fix it and
|
|
* none of them is a material: a pale face, one coloured sector, one
|
|
* needle. They are printed artwork rather than surfaces, which is why
|
|
* this is the one place in the model where a colour is swung around the
|
|
* wheel instead of picked out of the palette.
|
|
*/
|
|
const dialZ = neckR * 0.8 + gaugeR * 0.78
|
|
const dial = (geometry: BufferGeometry, lift: number): void => {
|
|
geometry.rotateX(Math.PI / 2)
|
|
geometry.translate(0, gaugeY, dialZ + lift)
|
|
valvePieces.push(geometry)
|
|
}
|
|
|
|
dial(latheGeometry([
|
|
{ y: 0, radius: gaugeR * 0.82 },
|
|
{ y: gaugeR * 0.06, radius: gaugeR * 0.82 },
|
|
], Math.max(10, Math.round(segments * 0.5)), [0, 0, 0],
|
|
tint('chrome', 0.34, 0.2), { capBottom: false, capTop: true }), 0)
|
|
|
|
// The charged band. Green, because that is the only thing anybody reads
|
|
// off an extinguisher gauge, and it is the shell's own red swung round
|
|
// the wheel rather than a new entry in the palette.
|
|
dial(arcBarGeometry(
|
|
gaugeR * 0.55, gaugeR * 0.2, Math.PI * 0.3, Math.PI * 0.7, 10,
|
|
[0, 0, 0], tint('retroreflective', 0.02, 0.3).offsetHSL(0.3, -0.1, 0), 4,
|
|
), gaugeR * 0.07)
|
|
|
|
// The over- and under-charge sectors either side of it, in the shell's
|
|
// own red. Three marks is what the reference's face carries and it is
|
|
// the count, not the artwork, that makes it read as a scale.
|
|
for (const start of [Math.PI * 0.04, Math.PI * 0.7]) {
|
|
dial(arcBarGeometry(
|
|
gaugeR * 0.55, gaugeR * 0.2, start, start + Math.PI * 0.26, 8,
|
|
[0, 0, 0], tint('retroreflective', 0.06, 0.3), 4,
|
|
), gaugeR * 0.07)
|
|
}
|
|
|
|
/*
|
|
* The tick ring.
|
|
*
|
|
* Twelve marks round the rim, and they are the last thing that turns a
|
|
* coloured disc into an instrument. A viewer does not count them; what
|
|
* they do is make the needle POINT AT something, and a needle with
|
|
* nothing to point at is a scratch on paint. At 14 mm each tick is under
|
|
* a millimetre, which is below the size rule the rest of this cluster is
|
|
* built to — but the rule is about pieces read on their own, and these
|
|
* are only ever read as a ring.
|
|
*/
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const long = i % 3 === 0
|
|
const mark = chamferedBoxGeometry(
|
|
[gaugeR * (long ? 0.075 : 0.05), gaugeR * 0.05],
|
|
[gaugeR * (long ? 0.075 : 0.05), gaugeR * 0.05],
|
|
gaugeR * (long ? 0.2 : 0.12), gaugeR * 0.012, [0, 0, 0], black,
|
|
)
|
|
mark.translate(0, gaugeR * (long ? 0.66 : 0.7), 0)
|
|
mark.rotateZ((i / 12) * Math.PI * 2)
|
|
dial(mark, gaugeR * 0.075)
|
|
}
|
|
|
|
// The needle, standing in the band, and the hub it turns on.
|
|
const needle = chamferedBoxGeometry(
|
|
[gaugeR * 0.22, gaugeR * 0.09], [gaugeR * 0.06, gaugeR * 0.06],
|
|
gaugeR * 0.66, gaugeR * 0.02, [0, 0, 0], black,
|
|
)
|
|
needle.translate(0, gaugeR * 0.28, 0)
|
|
needle.rotateZ(0.42)
|
|
dial(needle, gaugeR * 0.1)
|
|
dial(latheGeometry([
|
|
{ y: 0, radius: gaugeR * 0.13 },
|
|
{ y: gaugeR * 0.08, radius: gaugeR * 0.1 },
|
|
], 8, [0, 0, 0], steel, { capBottom: false, capTop: true }), gaugeR * 0.09)
|
|
|
|
// The pin and its ring: brass, small, and the one part that says the
|
|
// thing has not been used.
|
|
const brass = tint('brass', jitter(random, 0.02), 0.6)
|
|
const pin = latheGeometry([
|
|
{ y: 0, radius: R * 0.022 },
|
|
{ y: R * 0.62, radius: R * 0.022 },
|
|
], 8, [0, 0, 0], brass, { capBottom: true, capTop: true })
|
|
pin.rotateZ(Math.PI / 2)
|
|
pin.translate(-R * 0.3, valveTop - H * 0.014, 0)
|
|
valvePieces.push(pin)
|
|
|
|
const ring = arcBarGeometry(
|
|
R * 0.16, R * 0.03, 0, Math.PI * 2, 16, [0, 0, 0], brass, 8,
|
|
)
|
|
ring.rotateY(Math.PI / 2)
|
|
ring.translate(-R * 0.42, valveTop - H * 0.014, 0)
|
|
valvePieces.push(ring)
|
|
|
|
/**
|
|
* The carry handle: the fixed jaw the lever closes onto, which is the
|
|
* whole reason the squeeze has something to stop against.
|
|
*
|
|
* Painted the same black as the lever and reaching as far, because on
|
|
* the reference they are a matched pair. A chrome jaw half the lever's
|
|
* length left the lever's outer half hanging over nothing. It is its own
|
|
* part rather than a lump of the valve so that a consumer swapping the
|
|
* chrome casting does not also repaint the grip.
|
|
*/
|
|
const handlePieces = member([
|
|
[0.02, -0.19, 0.085],
|
|
[0.68, -0.42, 0.075],
|
|
[1.28, -0.52, 0.055],
|
|
[1.72, -0.30, 0.032],
|
|
], blackSteel)
|
|
for (const piece of handlePieces) piece.translate(0, valveTop, 0)
|
|
|
|
/* -------------------------------------------------------------- lever */
|
|
// Built along +X from the pivot at the origin, so the part's own origin
|
|
// is the hinge and `rotation.z` is the squeeze and nothing else.
|
|
// The same table as the handle, mirrored about the gap. It rises where
|
|
// the handle dips, so the two are furthest apart at the tips, which is
|
|
// where a hand closes them.
|
|
const leverPieces = member([
|
|
[0.00, 0.00, 0.085],
|
|
[0.62, 0.24, 0.075],
|
|
[1.22, 0.42, 0.055],
|
|
[1.70, 0.34, 0.028],
|
|
], blackSteel)
|
|
|
|
/* --------------------------------------------------------------- hose */
|
|
// A quarter turn out of the valve and then a straight run down the
|
|
// bottle to a nozzle. Built from an arc rather than a chain of boxes so
|
|
// the bend has no facets in its outline.
|
|
const hoseR = R * 0.11
|
|
/**
|
|
* Where the run sits, which took three goes and one measurement.
|
|
*
|
|
* At 0.72 R it was inside a bottle of radius R and the render simply had
|
|
* no hose in it. At 1.27 R it cleared the shell and read as a black cane
|
|
* standing NEXT TO the extinguisher, so it was pulled in to lie against
|
|
* the paint — and then the reference was measured: 1.29 R, standing a
|
|
* fifth of a radius off the shell exactly as the first guess had it.
|
|
*
|
|
* The cane reading was never about the distance. It was that the hose
|
|
* hung at the shell's mid-depth where the bottle hid it, and that
|
|
* nothing bridged the gap. The yaw fixed the first and the bracket below
|
|
* fixes the second, so the run can go back out where it belongs.
|
|
*/
|
|
const runX = -R * 1.29
|
|
const exitY = valveTop - H * 0.03
|
|
/**
|
|
* The hose does not come out of the casting, it comes out of a FITTING.
|
|
*
|
|
* With the bend rooted at the neck the joint was 0.32 shell radii from
|
|
* the axis and the valve body is 0.43 wide, so the coupling was inside
|
|
* the casting and invisible: the hose read as growing out of the metal.
|
|
* A stub carries the joint clear, which is also what the reference has
|
|
* and why its hose has a bright ring where it starts.
|
|
*/
|
|
const stubEnd = R * 0.62
|
|
const bendR = -runX - stubEnd
|
|
const hosePieces: BufferGeometry[] = []
|
|
|
|
const bend = arcBarGeometry(
|
|
bendR, hoseR * 2, Math.PI / 2, Math.PI, 14, [0, 0, 0], black, 10,
|
|
)
|
|
bend.translate(-stubEnd, exitY - bendR, 0)
|
|
hosePieces.push(bend)
|
|
|
|
const runTop = exitY - bendR
|
|
// Measured: the reference's nozzle stops about a ninth of the body
|
|
// height clear of the floor, because a hose that reaches the ground is a
|
|
// hose that drags.
|
|
const runBot = H * 0.165
|
|
hosePieces.push(latheGeometry([
|
|
{ y: runBot, radius: hoseR },
|
|
{ y: runTop, radius: hoseR },
|
|
], 10, [runX, 0, 0], black, { capBottom: false, capTop: false }))
|
|
|
|
// The nozzle: a short sleeve at the end, slightly fatter than the hose,
|
|
// because a hose that just stops is a hose that was cut.
|
|
hosePieces.push(latheGeometry([
|
|
{ y: runBot - H * 0.055, radius: hoseR * 1.25 },
|
|
{ y: runBot - H * 0.012, radius: hoseR * 1.25 },
|
|
{ y: runBot, radius: hoseR * 1.05 },
|
|
], 10, [runX, 0, 0], black, { capBottom: true, capTop: false }))
|
|
|
|
/**
|
|
* The two crimped couplings, which are what say the hose was ATTACHED.
|
|
*
|
|
* Rubber does not join to brass on its own; every real hose has a
|
|
* ferrule swaged over it at each end, and they are the only bright thing
|
|
* on an otherwise black run. Without them the hose reads as one moulded
|
|
* noodle that happens to touch a valve at the top and stop at the
|
|
* bottom. They are two rings.
|
|
*/
|
|
const ferrule = (y: number, x: number, half: number): void => {
|
|
hosePieces.push(latheGeometry([
|
|
{ y: y - half, radius: hoseR * 1.02 },
|
|
{ y: y - half * 0.7, radius: hoseR * 1.3 },
|
|
{ y: y + half * 0.7, radius: hoseR * 1.3 },
|
|
{ y: y + half, radius: hoseR * 1.02 },
|
|
], 10, [x, 0, 0], steel, { capBottom: false, capTop: false }))
|
|
}
|
|
ferrule(runBot + H * 0.055, runX, H * 0.016)
|
|
|
|
// The upper one sits on the outlet stub, so it is a ring lying on its
|
|
// side rather than standing up.
|
|
const collar = latheGeometry([
|
|
{ y: -H * 0.016, radius: hoseR * 1.02 },
|
|
{ y: -H * 0.011, radius: hoseR * 1.34 },
|
|
{ y: H * 0.011, radius: hoseR * 1.34 },
|
|
{ y: H * 0.016, radius: hoseR * 1.02 },
|
|
], 10, [0, 0, 0], steel, { capBottom: false, capTop: false })
|
|
collar.rotateZ(Math.PI / 2)
|
|
collar.translate(-stubEnd + H * 0.006, exitY, 0)
|
|
hosePieces.push(collar)
|
|
|
|
/**
|
|
* And then the whole hose swings round to the FRONT of the bottle.
|
|
*
|
|
* Built in the XY plane it hangs at z = 0, which is the shell's mid
|
|
* depth: outside the silhouette, but on any three-quarter view the line
|
|
* of sight to it passes straight through the bottle, so it was there and
|
|
* invisible in the render. Clearing the radius is not the same as being
|
|
* seen. A real one is clipped to the front for the same reason a viewer
|
|
* needs it there — it is the part you reach for.
|
|
*/
|
|
for (const piece of hosePieces) piece.rotateY(HOSE_YAW)
|
|
|
|
/**
|
|
* And the clamp where the band crosses the hose.
|
|
*
|
|
* The band was a ring on the bottle and the hose was a tube beside it:
|
|
* the two touched and nothing held. A strap that carries a hose pinches
|
|
* OVER it, so this is a short cuff round the run at the band's height,
|
|
* in the band's own black, reaching back to the shell. One piece, and it
|
|
* is the difference between banded and adjacent.
|
|
*/
|
|
const bandY = (H * 0.294 + H * 0.328) * 0.5
|
|
const cuff = latheGeometry([
|
|
{ y: bandY - H * 0.019, radius: hoseR * 1.5 },
|
|
{ y: bandY + H * 0.019, radius: hoseR * 1.5 },
|
|
], 10, [runX, 0, 0], black, { capBottom: false, capTop: false })
|
|
cuff.rotateY(HOSE_YAW)
|
|
bodyPieces.push(cuff)
|
|
|
|
// ...and the strap that reaches out to it. The hose stands a fifth of a
|
|
// radius off the paint, so without this the cuff floats in the gap and
|
|
// the band is just a stripe that happens to be at the same height.
|
|
const reach = -runX - R * 0.96
|
|
const bridge = chamferedBoxGeometry(
|
|
[hoseR * 1.5, H * 0.03], [hoseR * 1.5, H * 0.03],
|
|
reach, hoseR * 0.3, [0, 0, 0], black,
|
|
)
|
|
bridge.rotateZ(Math.PI / 2)
|
|
bridge.translate(runX + reach * 0.5, bandY, 0)
|
|
bridge.rotateY(HOSE_YAW)
|
|
bodyPieces.push(bridge)
|
|
|
|
/**
|
|
* The fork the lever pivots in, which is the part that was missing.
|
|
*
|
|
* The lever's origin sat 0.16 shell radii above the bonnet with nothing
|
|
* between them, so it hung in the air over the casting: an action with
|
|
* no hinge. Two ears and a pin cost four small pieces and they are the
|
|
* whole difference between a lever and a stick balanced on a valve. The
|
|
* pin is at exactly the lever part's origin, which is the only way the
|
|
* rotation and the visible hinge stay the same thing.
|
|
*/
|
|
const pivotY = valveTop + R * 0.16
|
|
for (const side of [-1, 1]) {
|
|
const ear = chamferedBoxGeometry(
|
|
[R * 0.09, R * 0.075], [R * 0.09, R * 0.075],
|
|
pivotY - valveTop + R * 0.12, R * 0.02, [0, 0, 0], steel,
|
|
)
|
|
ear.translate(R * 0.06, (valveTop + pivotY) * 0.5 - R * 0.02, side * R * 0.13)
|
|
valvePieces.push(ear)
|
|
}
|
|
const hinge = latheGeometry([
|
|
{ y: -R * 0.2, radius: R * 0.032 },
|
|
{ y: -R * 0.17, radius: R * 0.045 },
|
|
{ y: R * 0.17, radius: R * 0.045 },
|
|
{ y: R * 0.2, radius: R * 0.032 },
|
|
], 8, [0, 0, 0], tint('chrome', -0.06, 0.5), { capBottom: true, capTop: true })
|
|
hinge.rotateX(Math.PI / 2)
|
|
hinge.translate(R * 0.06, pivotY, 0)
|
|
valvePieces.push(hinge)
|
|
|
|
const stub = latheGeometry([
|
|
{ y: neckR * 0.34, radius: hoseR * 1.5 },
|
|
{ y: stubEnd - H * 0.014, radius: hoseR * 1.5 },
|
|
{ y: stubEnd - H * 0.01, radius: hoseR * 1.15 },
|
|
{ y: stubEnd + H * 0.008, radius: hoseR * 1.15 },
|
|
], 10, [0, 0, 0], steel, { capBottom: false, capTop: true })
|
|
stub.rotateZ(Math.PI / 2)
|
|
stub.translate(0, exitY, 0)
|
|
stub.rotateY(HOSE_YAW)
|
|
valvePieces.push(stub)
|
|
|
|
bakeOcclusion(valvePieces, { strength: 0.4 })
|
|
|
|
return {
|
|
body: { slot: 'retroreflective' as const, geometry: smoothNormals(mergeColoured(bodyPieces), 40) },
|
|
valve: { slot: 'chrome' as const, geometry: smoothNormals(mergeColoured(valvePieces), 35) },
|
|
handle: { slot: 'steelPainted' as const, geometry: smoothNormals(mergeColoured(handlePieces), 35) },
|
|
lever: {
|
|
slot: 'steelPainted' as const,
|
|
geometry: smoothNormals(mergeColoured(leverPieces), 35),
|
|
origin: [R * 0.06, pivotY, 0] as const,
|
|
},
|
|
hose: { slot: 'rubber' as const, geometry: smoothNormals(mergeColoured(hosePieces), 30) },
|
|
}
|
|
},
|
|
|
|
actions: ({ parts, getConfig }) => {
|
|
const set = (amount: number): void => {
|
|
/*
|
|
* Travel stops where the lever meets the handle, and the angle is
|
|
* MEASURED off the built geometry rather than reasoned from the knot
|
|
* tables.
|
|
*
|
|
* Reasoning from the tables gave 0.34 rad and the tips never touched:
|
|
* the lever's knots are relative to its own origin, which is already
|
|
* 0.16 shell radii above the valve, so the real gap at the tip is 0.74
|
|
* radii of clear air and not the 0.58 the two tables seem to say. The
|
|
* tip is 1.64 radii out from the hinge, which puts closure at 0.45.
|
|
* A little under that so the two rest against each other.
|
|
*
|
|
* At 0.46 the closest approach between the two members is 0.8 mm and
|
|
* it is at the tip, which is the only place it should be: at rest the
|
|
* nearest point is 11 mm and sits back at the pivot instead. That is deliberate: two
|
|
* painted castings that arrive at exactly zero co-planar surfaces
|
|
* z-fight, and a millimetre reads as closed at every distance the
|
|
* model is ever seen from.
|
|
*
|
|
* The check that found the wrong angle measures the closest approach
|
|
* between the two members as point clouds. A bounding box will not
|
|
* show it -- the boxes overlap from the first degree of travel, since
|
|
* they overlap in x the whole way -- and neither will a silhouette
|
|
* binned by x, because a member built from four knots has no vertices
|
|
* across most of its length to bin.
|
|
*/
|
|
parts.lever.anchor.rotation.z = -Math.min(1, Math.max(0, amount)) * 0.46
|
|
}
|
|
heldSqueeze = getConfig().squeeze
|
|
seenSqueeze = heldSqueeze
|
|
set(heldSqueeze)
|
|
return {
|
|
squeeze: (amount = 1) => { heldSqueeze = amount; set(amount) },
|
|
}
|
|
},
|
|
|
|
/**
|
|
* ...and the slider has to reach it, which the action alone does not do.
|
|
*
|
|
* `squeeze` is both a control and a method. Moving the control calls
|
|
* `configure()`, which rebuilds the geometry -- and the lever's pose is a
|
|
* single anchor rotation that no rebuild re-applies, so in the viewer the
|
|
* slider was inert while the method worked. Adopting the config into the
|
|
* held value whenever it changes, and re-applying every frame, makes the
|
|
* two agree without letting either override the other.
|
|
*/
|
|
update: (_dt, { parts, getConfig }) => {
|
|
const wanted = getConfig().squeeze
|
|
if (wanted !== seenSqueeze) { seenSqueeze = wanted; heldSqueeze = wanted }
|
|
parts.lever.anchor.rotation.z = -Math.min(1, Math.max(0, heldSqueeze)) * 0.46
|
|
},
|
|
}, overrides)
|
|
}
|