medieval-kit/contemporary-props/models/fire-extinguisher/model.ts

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)
}