/** * @contemporary-props/pepper-mill * * Table pepper mill: the longest profile curve in the kit at the lowest cost, * and the first radial cut. * * The body is one lathe and the whole object is decided by where that curve * turns. Reading from the table up: a drum for the grinder housing, a turn * inward to the waist, a swell back out to an upper belly that stays narrower * than the base, then a quick fall into the neck the cap sits over. The upper * belly NARROWER than the base is the load-bearing choice: make them equal and * the silhouette is a baluster, make the top wider and it is a chess queen * about to tip over. * * The flutes are what stop it reading as a chess piece even so. A lathe cannot * cut a groove, so the grip is built the other way round: a radial array of * slim chamfered bars standing a millimetre proud of the waist. Because the * waist is the narrowest point of the profile, a straight vertical bar held at * a fixed radius is proud exactly in the middle and sinks into the body at * both ends on its own — no cap, no fade, both ends terminate inside the * solid. The proud height is recomputed against the actual profile for every * configuration, so a fat waist under a low bulge cannot push a bar end * through the surface. * * 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, and the `twist` action * does exactly that. A turned dome is rotationally invisible, so the motion is * carried by the crown: the stainless finial nut is a 6-segment lathe — a hex * acorn — and its facets are what you see move. */ import { type BufferGeometry } from 'three' import { chamferedBoxGeometry, createKitModel, createTinter, jitter, latheGeometry, mergeColoured, smoothNormals, type Level, } from '../core/index.ts' export interface PepperMillConfig { /** Overall height, finial included (metres). */ readonly height: number /** Radius of the base drum (metres). */ readonly baseRadius: number /** Waist radius as a fraction of the base radius. */ readonly waist: number /** Where the waist sits, as a fraction of the body height. */ readonly waistAt: number /** Number of grip flutes around the waist. */ readonly flutes: number /** Sides around the revolve. */ readonly segments: number readonly seed: number } export const pepperMillDefaults: PepperMillConfig = { height: 0.23, baseRadius: 0.03, // The waist is what separates a mill from a rolling pin. Below ~0.5 it // reads as an hourglass, above ~0.7 the profile goes straight and the // flutes are doing all the work alone. waist: 0.58, waistAt: 0.5, flutes: 16, segments: 32, 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 } /** Linear read of a lathe profile at an arbitrary height. */ function radiusAt(levels: readonly Level[], y: number): number { if (y <= levels[0]!.y) return levels[0]!.radius for (let i = 0; i < levels.length - 1; i += 1) { const a = levels[i]! const b = levels[i + 1]! if (y <= b.y) { const t = (y - a.y) / Math.max(1e-9, b.y - a.y) return a.radius + t * (b.radius - a.radius) } } return levels.at(-1)!.radius } export function createModel(overrides: Partial = {}) { return createKitModel({ 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 on a pedestal and the thin end into a // taper candle, and both were seen in a render before this clamp. 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 waistFraction = Math.min(0.68, Math.max(0.45, config.waist)) const waistAt = Math.min(0.6, Math.max(0.42, config.waistAt)) const flutes = Math.max(8, Math.min(28, Math.round(config.flutes))) // The cap takes the top ~16% of the height and overlaps the neck by a // few millimetres, so the parting line is a step rather than a seam of // two coplanar rings. const bodyH = H * 0.84 const seat = bodyH - 0.004 const W = R * waistFraction const waistY = bodyH * waistAt // The grip is a band, not a point: the waist holds its radius for a // stretch so the flutes sit on something, and the cove into and out of // it is what makes the profile a mill rather than a bottle. const bandHalf = bodyH * 0.09 // The upper belly is clearly SMALLER than the drum: the masses must // step down going up — drum, belly, head — or the object turns into a // chess bishop. It still follows the waist up when the waist is fat, so // the swell never dips below the flute tops. const upperR = R * Math.max(0.72, waistFraction * 1.08) const upperY = bodyH * 0.74 /* ------------------------------------------------------------- body */ const waistLo = waistY - bandHalf const waistHi = waistY + bandHalf // The shoulder backs off when the waist sits low, so the cove into the // grip keeps some run and the drum does not turn into a pedestal. const shoulderY = Math.min(bodyH * 0.3, waistLo - bodyH * 0.08) const drumTop = shoulderY - bodyH * 0.1 const bodyLevels: Level[] = [ { y: 0, radius: R * 0.97 }, // Crisp foot, same reasoning as the vase: the profile must leave the // table vertically or the mill looks like it is sinking into it. { y: 0.005, radius: R }, // The grinder drum: a near-cylinder, because that is the one straight // stretch a mill has and softening it is what turned the first render // into a crayon. { y: drumTop, radius: R * 0.985 }, { y: shoulderY, radius: R * 0.93 }, { y: (shoulderY + waistLo) / 2, radius: ((R * 0.93 + W) / 2) * 1.015 }, { y: waistLo, radius: W * 1.01 }, { y: waistY, radius: W }, { y: waistHi, radius: W * 1.01 }, // The rise off the grip band is deliberately quick, so the flute bar // ends go under the surface within a couple of millimetres. { y: waistHi + (upperY - waistHi) * 0.4, radius: ((W + upperR) / 2) * 1.02 }, { y: upperY, radius: upperR }, { y: bodyH * 0.83, radius: upperR * 0.9 }, // The throat: the belly turns in to a short neck the cap's lip steps // out over, so the parting line reads as a groove under the head. { y: bodyH * 0.91, radius: R * 0.56 }, { y: bodyH, radius: R * 0.52 }, ] const woodBase = tint('wood', jitter(random, 0.02)) const pieces: BufferGeometry[] = [ latheGeometry(bodyLevels, segments, [0, 0, 0], woodBase, { colourTop: tint('wood', 0.04), capBottom: true, capTop: true, }), ] /* ----------------------------------------------------------- flutes */ // Half the vertical span of a flute bar: the grip band plus enough of // the cove beyond it for the profile to rise over the bar ends. const fluteHalf = bodyH * 0.13 // How far a bar stands proud of the waist. Capped by the profile: the // bar ends must stay under the surface, including the flat-to-flat // shrink of the revolve's polygon. const inscribed = Math.cos(Math.PI / segments) const endRadius = Math.min( radiusAt(bodyLevels, waistY - fluteHalf), radiusAt(bodyLevels, waistY + fluteHalf), ) * inscribed const proud = Math.max(0.0005, Math.min(R * 0.04, endRadius - W - 0.0005)) const depth = R * 0.22 // Nearly touching: the grip reads from the narrow dark gaps between // ridges, not from the ridges themselves. const barWidth = Math.min((2 * Math.PI * W) / flutes * 0.72, R * 0.2) const barCentreRadius = W + proud - depth / 2 for (let i = 0; i < flutes; i += 1) { const angle = (i / flutes) * Math.PI * 2 const bar = chamferedBoxGeometry( [barWidth, depth], [barWidth, depth], fluteHalf * 2, Math.min(0.0008, barWidth * 0.25), [0, 0, 0], // Subtle: the flutes are turned from the same blank as the body, // so they may not jump in tone. Relief and occlusion carry them. tint('wood', jitter(random, 0.015)), ) // Built at the origin, turned, then carried out to the waist. bar.rotateY(angle) bar.translate(Math.sin(angle) * barCentreRadius, waistY, Math.cos(angle) * barCentreRadius) pieces.push(bar) } /* -------------------------------------------------------------- cap */ // Everything below is LOCAL to the seat: the part's origin sits there // so rotation.y grinds. const capH = H - seat const domeH = capH * 0.6 const capLevels: Level[] = [ { y: 0, radius: R * 0.55 }, // The lip rolls out well past the neck so the cap reads as a // separate piece that comes off, not a continuation of the profile. { y: 0.004, radius: R * 0.6 }, // The head swells a touch before rounding over: a straight-sided cap // read as a bottle top, and this is the swell that makes it a turban. { y: domeH * 0.3, radius: R * 0.61 }, { y: domeH * 0.6, radius: R * 0.55 }, { y: domeH * 0.85, radius: R * 0.42 }, { y: domeH, radius: R * 0.32 }, ] const dome = latheGeometry(capLevels, segments, [0, 0, 0], tint('wood', jitter(random, 0.02)), { colourTop: tint('wood', 0.05), capBottom: true, capTop: true, }) // The crown: a hex acorn, 6 segments on purpose. Its base is sunk into // the dome, its facets are what make the twist visible. const nutR = R * 0.22 const nutLevels: Level[] = [ { y: domeH - 0.003, radius: nutR * 0.72 }, { y: domeH + 0.0008, radius: nutR }, { y: capH * 0.87, radius: nutR }, { y: capH * 0.95, radius: nutR * 0.7 }, { y: capH, radius: nutR * 0.36 }, ] const nut = latheGeometry(nutLevels, 6, [0, 0, 0], tint('stainless', jitter(random, 0.02), 0.5), { colourTop: tint('stainless', 0.04, 0.5), capBottom: true, capTop: true, }) // 40 degrees: the revolve and the dome smooth, the foot, the lip, the // flute chamfers and the hex facets all turn harder than that and stay // edges. return { body: { slot: 'wood' as const, geometry: smoothNormals(mergeColoured(pieces), 40) }, cap: { slot: 'wood' as const, geometry: smoothNormals(mergeColoured([dome]), 40), extras: [{ slot: 'stainless' as const, geometry: smoothNormals(mergeColoured([nut]), 30) }], origin: [0, seat, 0] as const, }, } }, actions: ({ parts }) => ({ twist: (radians = Math.PI / 3) => { parts.cap.anchor.rotation.y += radians }, }), }, overrides) }