276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
/**
|
|
* @contemporary-props/pepper-mill
|
|
*
|
|
* Table pepper mill: the longest profile in the kit at the lowest cost, and
|
|
* the first radial cut.
|
|
*
|
|
* SECOND VERSION, and the first one built against a photograph. The first was
|
|
* a turned baluster: a drum, a cove into a waist, a swell back out and a domed
|
|
* head, with the flutes doing the work of telling you it was not a chess
|
|
* piece. It was a good bottle. What a present-day mill actually is, is a
|
|
* STRAIGHT tube: one cylinder from the collar to the flat top, a polished
|
|
* steel ring at the base, a band of vertical flutes cut around the middle for
|
|
* grip, a flat top and a small knurled steel knob. There is no waist and there
|
|
* is no dome, and the profile that has neither is both simpler and right.
|
|
*
|
|
* The flute band is the model's reason for being fourth in the build order:
|
|
* the catalogue's row for it reads "the flute grip is the first radial cut",
|
|
* and everything after it that arrays anything around an axis copies what
|
|
* happens here. So the band is not decoration and must not be optional in the
|
|
* sense of being absent by default.
|
|
*
|
|
* The band is a RECESS with ribs standing back out of it, not ribs stuck on a
|
|
* plain tube. Two things follow from that and both are visible. The rib ends
|
|
* terminate against the step at either end of the recess instead of stopping
|
|
* in mid-air, which is what a machined flute does. And the ribs stay under the
|
|
* tube's own radius, so the silhouette of the mill is a clean cylinder and the
|
|
* flutes read from shading rather than from a bumpy outline.
|
|
*
|
|
* Proportions are measured, not chosen: the reference is 3.6 diameters tall,
|
|
* the steel collar takes the bottom 0.12 of it and the flute band runs from
|
|
* 0.31 to 0.79 of the tube. The band is nearly half the height, which is far
|
|
* more than it looks like it should be until it is drawn at anything less.
|
|
*
|
|
* Parts: `body` and `cap`. The cap is its own part with its origin at the seat
|
|
* so `cap.anchor.rotation.y` is the grinding action. A smooth head would turn
|
|
* invisibly, so the knurl on the knob is what carries the motion.
|
|
*/
|
|
import { type BufferGeometry } from 'three'
|
|
|
|
import {
|
|
bakeOcclusion,
|
|
chamferedBoxGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
latheGeometry,
|
|
mergeColoured,
|
|
smoothNormals,
|
|
type Level,
|
|
} from '../core/index.ts'
|
|
|
|
export interface PepperMillConfig {
|
|
/** Overall height, knob included (metres). */
|
|
readonly height: number
|
|
/** Radius of the tube (metres). */
|
|
readonly baseRadius: number
|
|
/** How deep the flute band is cut, as a fraction of the radius. */
|
|
readonly bandDepth: number
|
|
/** Centre of the flute band, as a fraction of the tube's height. */
|
|
readonly bandAt: number
|
|
/** Number of grip flutes around the band. */
|
|
readonly flutes: number
|
|
/** Sides around the revolve. */
|
|
readonly segments: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const pepperMillDefaults: PepperMillConfig = {
|
|
height: 0.23,
|
|
// 3.65 diameters tall, which is what the reference measures. The first
|
|
// version was 3.8 and the difference is not the point: the point is that
|
|
// this one is a tube and that one was a baluster.
|
|
baseRadius: 0.0315,
|
|
bandDepth: 0.075,
|
|
bandAt: 0.55,
|
|
// The reference carries about forty and forty is wrong here, which is the
|
|
// difference between copying a photograph and reading one. At prop scale a
|
|
// mill is a hundred and fifty pixels wide, half of them facing the camera:
|
|
// forty ribs is four pixels each and the band turns into a grey smear.
|
|
// Twenty-two is the same object at the size it is actually seen.
|
|
flutes: 22,
|
|
segments: 40,
|
|
seed: 23,
|
|
}
|
|
|
|
export type PepperMillParts = 'body' | 'cap'
|
|
|
|
export interface PepperMillActions {
|
|
/** Turns the cap — the grind. Default is a sixth of a turn. */
|
|
twist(radians?: number): void
|
|
}
|
|
|
|
export function createModel(overrides: Partial<PepperMillConfig> = {}) {
|
|
return createKitModel<PepperMillConfig, 'wood' | 'stainless', PepperMillParts, PepperMillActions>({
|
|
id: 'pepper-mill',
|
|
defaults: pepperMillDefaults,
|
|
slots: ['wood', 'stainless'],
|
|
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
const H = Math.min(0.45, Math.max(0.12, config.height))
|
|
// The radius is clamped against the height, not just on its own: a mill
|
|
// is between roughly two and six diameters tall. Outside that band the
|
|
// squat end turns into a jar and the thin end into a taper candle.
|
|
const R = Math.min(H * 0.21, Math.max(H * 0.08, Math.min(0.05, Math.max(0.015, config.baseRadius))))
|
|
const segments = Math.max(12, Math.round(config.segments))
|
|
const flutes = Math.max(12, Math.min(64, Math.round(config.flutes)))
|
|
const bandDepth = Math.min(0.12, Math.max(0.02, config.bandDepth))
|
|
const bandAt = Math.min(0.72, Math.max(0.38, config.bandAt))
|
|
|
|
/* ------------------------------------------------------------ layout */
|
|
// Read bottom up, all as fractions of the whole height: the steel collar
|
|
// covers the grinder housing, the tube runs to the seat, the cap disc
|
|
// sits on the seat and the knob stands on the disc.
|
|
const collarH = H * 0.12
|
|
const knobH = H * 0.042
|
|
const capDisc = H * 0.028
|
|
const seat = H - knobH - capDisc
|
|
const bodyH = seat
|
|
|
|
const bandHalf = bodyH * 0.24
|
|
const bandLo = bodyH * bandAt - bandHalf
|
|
const bandHi = bodyH * bandAt + bandHalf
|
|
const recessR = R * (1 - bandDepth)
|
|
|
|
/* -------------------------------------------------------------- tube */
|
|
// A step at each end of the recess rather than a ramp: the flutes have
|
|
// to stop against something, and on the reference they stop against a
|
|
// machined shoulder you can see the shadow of.
|
|
const bodyLevels: Level[] = [
|
|
{ y: 0, radius: R },
|
|
{ y: bandLo - 0.0008, radius: R },
|
|
{ y: bandLo, radius: recessR },
|
|
{ y: bandHi, radius: recessR },
|
|
{ y: bandHi + 0.0008, radius: R },
|
|
{ y: bodyH - 0.0012, radius: R },
|
|
// The top edge is broken, not sharp: a tube cut off square catches a
|
|
// white line along its rim from every angle.
|
|
{ y: bodyH, radius: R * 0.988 },
|
|
]
|
|
|
|
const woodBase = tint('wood', jitter(random, 0.02))
|
|
const pieces: BufferGeometry[] = [
|
|
latheGeometry(bodyLevels, segments, [0, 0, 0], woodBase, {
|
|
colourTop: tint('wood', 0.035),
|
|
capBottom: true,
|
|
capTop: true,
|
|
}),
|
|
]
|
|
|
|
/* ------------------------------------------------------------ flutes */
|
|
// The ribs come back out of the recess and stop just under the tube's
|
|
// own flats. Under, not level: a revolve of `segments` sides has its
|
|
// faces at R·cos(pi/segments), and a rib reaching exactly that height
|
|
// fights the flat it is sitting on.
|
|
const inscribed = Math.cos(Math.PI / segments)
|
|
const ribOuter = R * inscribed * 0.99
|
|
const ribDepth = Math.max(0.0004, ribOuter - recessR)
|
|
// Nearly touching: the grip reads from the narrow dark gaps between the
|
|
// ribs, not from the ribs themselves.
|
|
// 0.78 of the pitch: wide ribs and narrow grooves, the way a machined
|
|
// grip goes. At 0.66 the lit faces came out narrower than the shadows
|
|
// between them and the band read as a picket fence.
|
|
const ribWidth = Math.min((2 * Math.PI * recessR) / flutes * 0.78, R * 0.2)
|
|
const ribCentre = recessR + ribDepth / 2
|
|
|
|
for (let i = 0; i < flutes; i += 1) {
|
|
const angle = (i / flutes) * Math.PI * 2
|
|
const rib = chamferedBoxGeometry(
|
|
[ribWidth, ribDepth],
|
|
[ribWidth, ribDepth],
|
|
bandHi - bandLo,
|
|
Math.min(0.0005, ribWidth * 0.22),
|
|
[0, 0, 0],
|
|
// Turned from the same blank as the tube, so they may not jump in
|
|
// tone. Relief and occlusion carry them.
|
|
tint('wood', jitter(random, 0.012)),
|
|
)
|
|
rib.rotateY(angle)
|
|
rib.translate(
|
|
Math.sin(angle) * ribCentre,
|
|
(bandLo + bandHi) / 2,
|
|
Math.cos(angle) * ribCentre,
|
|
)
|
|
pieces.push(rib)
|
|
}
|
|
|
|
/**
|
|
* Darken what the ribs shade, before anything is merged.
|
|
*
|
|
* Vertical ribs on a vertical tube are the one case where relief alone
|
|
* does nothing: every rib and every groove between them faces the light
|
|
* at the same angle, so a band with a millimetre and a half of real
|
|
* relief still rendered as pale stripes. What separates them on the
|
|
* reference is not the highlight, it is the dark in the bottom of each
|
|
* groove. This is the kit's first use of it and it is the reason the
|
|
* band reads at all.
|
|
*/
|
|
bakeOcclusion(pieces, { strength: 0.5 })
|
|
|
|
/* ------------------------------------------------------------ collar */
|
|
// Proud of the tube by a hair, which is what makes it read as a ring
|
|
// fitted over the bottom rather than as a painted stripe.
|
|
const collarLevels: Level[] = [
|
|
{ y: 0, radius: R * 0.985 },
|
|
{ y: 0.0012, radius: R * 1.012 },
|
|
{ y: collarH - 0.0012, radius: R * 1.012 },
|
|
{ y: collarH, radius: R * 0.998 },
|
|
]
|
|
pieces.push(latheGeometry(
|
|
collarLevels, segments, [0, 0, 0],
|
|
tint('stainless', jitter(random, 0.02), 0.5),
|
|
{ colourTop: tint('stainless', 0.03, 0.5), capBottom: true },
|
|
))
|
|
|
|
/* --------------------------------------------------------------- cap */
|
|
// Everything below is LOCAL to the seat: the part's origin sits there so
|
|
// rotation.y grinds.
|
|
const discLevels: Level[] = [
|
|
{ y: 0, radius: R * 0.985 },
|
|
{ y: 0.0008, radius: R },
|
|
{ y: capDisc - 0.0012, radius: R },
|
|
{ y: capDisc, radius: R * 0.988 },
|
|
]
|
|
const disc = latheGeometry(discLevels, segments, [0, 0, 0], tint('wood', jitter(random, 0.02)), {
|
|
colourTop: tint('wood', 0.05),
|
|
capBottom: true,
|
|
capTop: true,
|
|
})
|
|
|
|
// The knurled knob. Its sides are a radial array too, at a pitch fine
|
|
// enough to read as knurling rather than as a nut, and it is the only
|
|
// thing on the mill whose rotation is visible.
|
|
const knobR = R * 0.28
|
|
const knurl = Math.max(10, Math.round(knobR / R * 40))
|
|
const knobLevels: Level[] = [
|
|
{ y: capDisc - 0.002, radius: knobR * 0.55 },
|
|
{ y: capDisc + 0.0015, radius: knobR },
|
|
{ y: capDisc + knobH * 0.82, radius: knobR },
|
|
{ y: capDisc + knobH * 0.93, radius: knobR * 0.86 },
|
|
{ y: capDisc + knobH, radius: knobR * 0.62 },
|
|
]
|
|
const knob = latheGeometry(
|
|
knobLevels, knurl, [0, 0, 0],
|
|
tint('stainless', jitter(random, 0.02), 0.5),
|
|
{ colourTop: tint('stainless', 0.05, 0.5), capBottom: true, capTop: true },
|
|
)
|
|
|
|
// 40 degrees: the revolve smooths, and the collar step, the recess
|
|
// shoulders, the rib chamfers and the knurl facets all turn harder than
|
|
// that and stay edges.
|
|
return {
|
|
body: {
|
|
slot: 'wood' as const,
|
|
geometry: smoothNormals(mergeColoured(pieces.slice(0, pieces.length - 1)), 40),
|
|
extras: [{
|
|
slot: 'stainless' as const,
|
|
geometry: smoothNormals(mergeColoured([pieces[pieces.length - 1]!]), 30),
|
|
}],
|
|
},
|
|
cap: {
|
|
slot: 'wood' as const,
|
|
geometry: smoothNormals(mergeColoured([disc]), 40),
|
|
extras: [{ slot: 'stainless' as const, geometry: smoothNormals(mergeColoured([knob]), 30) }],
|
|
origin: [0, seat, 0] as const,
|
|
},
|
|
}
|
|
},
|
|
|
|
actions: ({ parts }) => ({
|
|
twist: (radians = Math.PI / 3) => {
|
|
parts.cap.anchor.rotation.y += radians
|
|
},
|
|
}),
|
|
}, overrides)
|
|
}
|