medieval-kit/contemporary-props/models/stockpot/model.ts

264 lines
11 KiB
TypeScript

/**
* @contemporary-props/stockpot
*
* The kit's first metal vessel, and it exists to settle three details that
* every metal vessel afterwards reuses: the rolled rim, the riveted lug, and
* the separable sit-in lid.
*
* The rolled rim is the whole read. A pot without one is a cylinder, so the
* rim here is a real curl: the profile reaches the top of the wall and then
* travels 280 degrees around a small circle — down into the crevice under the
* roll, out, over the top, and back in — before tucking into the bore. That
* traversal direction is not decorative: the lathe orients faces by which way
* the profile walks (up gives outward, out gives down, down gives inward), so
* a curl sampled the obvious way, straight over the top from the wall, comes
* out inside-out on its underside. Walking the circle in one consistent sense
* keeps every face of the curl pointing at the air it actually touches.
*
* Unlike the vase, the pot models a short bore: a real interior wall drops
* from inside the rim to a false floor at 42% of the height. The vase never
* shows its inside; this object's one action is lifting the lid off, and an
* open pot that is a hollow shell with the world visible through it is worse
* than any number of extra triangles. The floor is deep enough to sit in
* occlusion shadow and shallow enough that nothing pays for the part of the
* bore no camera reaches.
*
* The lid is one profile too: a flange that drops inside the mouth (a lid
* that merely rests on top reads as a plate), a step that seats on the rim, a
* rolled edge matching the pot's, a dome, and the knob — stem, flare, cap —
* continued from the same curve so the whole lid is a single connected solid.
* The knob keeps a fixed hand size rather than scaling with the pot, for the
* same reason the lugs do: hands do not scale with cookware.
*
* Each lug is a strap loop swept as a square-section arc, tilted up 25
* degrees and standing about 30mm clear of the wall so a hand fits, with both
* ends buried inside a chamfered mounting plate that is itself embedded into
* the wall. The rivet heads sit over the buried strap ends, which is where a
* rivet would actually be.
*
* One slot, `stainless`, everywhere. A stainless stockpot really is one
* material; the lugs and rivets separate from the body by tint and by the
* occlusion crevices around the plate, not by a material change.
*
* Parts: `body` and `lid`. The lid's anchor sits at the rim plane, so
* `liftLid` is a straight translation and anything the consumer stood on the
* lid rises with it.
*/
import { type BufferGeometry } from 'three'
import {
arcBarGeometry,
chamferedBoxGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
smoothNormals,
type Level,
} from '../core/index.ts'
export interface StockpotConfig {
/** Body outer diameter (metres). */
readonly diameter: number
/** Body height to the rim (metres). The lid and knob add to it. */
readonly height: number
/** Lid dome rise as a fraction of the body radius. Low is a saucepot lid, high a casserole. */
readonly lidDome: number
/** Sides around the revolve. */
readonly segments: number
readonly seed: number
}
export const stockpotDefaults: StockpotConfig = {
// A 26 x 24 cm pot, roughly eleven litres: unmistakably a stockpot rather
// than a saucepan, and still a size that exists in an ordinary kitchen.
diameter: 0.26,
height: 0.24,
lidDome: 0.16,
segments: 48,
seed: 7,
}
export type StockpotParts = 'body' | 'lid'
export interface StockpotActions {
/** Lifts the lid clear of the rim, or seats it again. No argument toggles. */
liftLid(lifted?: boolean): void
}
const clampDiameter = (v: number): number => Math.min(0.4, Math.max(0.14, v))
const clampHeight = (v: number): number => Math.min(0.36, Math.max(0.1, v))
const deg = (d: number): number => (d * Math.PI) / 180
export function createModel(overrides: Partial<StockpotConfig> = {}) {
return createKitModel<StockpotConfig, 'stainless', StockpotParts, StockpotActions>({
id: 'stockpot',
defaults: stockpotDefaults,
slots: ['stainless'],
build: ({ config, random }) => {
const tint = createTinter(random)
const R = clampDiameter(config.diameter) / 2
const H = clampHeight(config.height)
const segments = Math.max(16, Math.round(config.segments))
// Roll radius: scales gently with the pot but stays in the few-millimetre
// band a pressed rim actually has. Bigger and the rim reads as a pipe
// welded on; smaller and it vanishes at prop distance.
const rr = Math.min(0.0045, Math.max(0.002, R * 0.026))
const wall = 0.004
const bore = R - wall
const floorY = H * 0.42
const steel = tint('stainless', jitter(random, 0.02))
/* ------------------------------------------------------------- body */
const bodyLevels: Level[] = [
{ y: 0, radius: R * 0.96 },
// A short chamfer off the table, then the wall. Stockpot walls are
// straight; the character lives entirely at the top.
{ y: 0.006, radius: R },
{ y: H * 0.45, radius: R },
// Tangent point where the wall meets the roll.
{ y: H - rr, radius: R },
]
// The curl, walked in increasing angle so every face points outward:
// under the roll (200-280), out and over the top (280-450), back in
// toward the mouth (450-480).
for (let a = 200; a <= 480; a += 40) {
bodyLevels.push({
y: H - rr + Math.sin(deg(a)) * rr,
radius: R + rr + Math.cos(deg(a)) * rr,
})
}
bodyLevels.push(
// Tuck from the inner lip into the bore, then the false floor.
{ y: H - rr * 0.8, radius: bore },
{ y: floorY + 0.005, radius: bore },
{ y: floorY, radius: bore - 0.004 },
)
const pieces: BufferGeometry[] = [
latheGeometry(bodyLevels, segments, [0, 0, 0], steel, {
capBottom: true,
capTop: true, // the false floor, facing up inside the bore
}),
]
/* ------------------------------------------------------------- lugs */
const ra = 0.021 + R * 0.024 // loop radius: mostly hand, a little pot
const bar = 0.009 // strap section; thinner reads as wire
const tilt = deg(18)
const lugY = Math.min(H - 0.03, H * 0.8)
// Ends of the 260-degree arc sit at (±cos40, -sin40) of the loop's local
// frame; the centre is placed so those ends land just outside the wall
// surface, buried inside the plate.
const endIn = Math.sin(deg(40)) * ra * Math.cos(tilt)
const endDown = Math.sin(deg(40)) * ra * Math.sin(tilt)
const plateW = 0.046 + R * 0.04
const plateH = 0.032
for (const spin of [0, Math.PI]) {
const loop = arcBarGeometry(ra, bar, deg(-40), deg(220), 20, [0, 0, 0],
tint('stainless', 0.01))
// Built in XY with the bulge up: turn the spread onto the tangent (Z),
// then lean the bulge out and 18 degrees above horizontal.
loop.rotateY(-Math.PI / 2)
loop.rotateZ(tilt - Math.PI / 2)
loop.translate(R + 0.001 + endIn, lugY, 0)
loop.rotateY(spin)
pieces.push(loop)
// Mounting plate, embedded 5mm into the wall so its corners cannot
// daylight on a small pot where the flat plate leaves the curve.
const plate = chamferedBoxGeometry([0.01, plateW], [0.01, plateW], plateH,
0.0012, [R + 0.001, lugY, 0], tint('stainless', -0.03))
plate.rotateY(spin)
pieces.push(plate)
// Rivet heads on the plate's lower half, clear of the strap, where the
// rivets that hold the plate to the wall actually sit. Under the strap
// ends they exist but cannot be seen, which is the same as not existing.
for (const side of [-1, 1]) {
const rivet = latheGeometry([
{ y: 0, radius: 0.005 },
{ y: 0.0024, radius: 0.0041 },
{ y: 0.0038, radius: 0.0018 },
], 10, [0, 0, 0], tint('stainless', -0.06))
rivet.rotateZ(-Math.PI / 2)
rivet.translate(R + 0.0045, lugY - 0.009, side * 0.011)
rivet.rotateY(spin)
pieces.push(rivet)
}
}
/* -------------------------------------------------------------- lid */
const flange = bore - 0.0008
const domeH = R * Math.min(0.22, Math.max(0.04, config.lidDome))
const knobBase = 0.007 + domeH
const lidLevels: Level[] = [
// Flange: drops inside the mouth. Its bottom is capped so the lifted
// lid shows a closed underside rather than a hollow shell.
{ y: -0.011, radius: flange * 0.9 },
{ y: -0.008, radius: flange },
{ y: -0.0005, radius: flange },
// Seat step: walked outward, so it faces DOWN onto the rim.
{ y: 0.0008, radius: R + rr * 1.4 },
// Rolled lid edge, matching the pot's roll in reach.
{ y: 0.0035, radius: R + rr * 2 },
{ y: 0.006, radius: R + rr * 1.2 },
]
// The dome is an elliptical arc, not straight lines to the knob: three
// linear levels read as a tent at any dome height worth having.
for (const phi of [18, 36, 54, 72, 84]) {
lidLevels.push({
y: 0.006 + Math.sin(deg(phi)) * domeH,
radius: Math.cos(deg(phi)) * (R + rr * 1.2),
})
}
lidLevels.push(
// Knob: fixed hand size, continued from the same profile.
{ y: knobBase + 0.001, radius: 0.0065 },
{ y: knobBase + 0.014, radius: 0.0065 },
{ y: knobBase + 0.02, radius: 0.0125 },
{ y: knobBase + 0.024, radius: 0.0125 },
{ y: knobBase + 0.0265, radius: 0.0075 },
{ y: knobBase + 0.028, radius: 0.003 },
)
const lid = latheGeometry(lidLevels, segments, [0, 0, 0],
tint('stainless', jitter(random, 0.02)), { capBottom: true, capTop: true })
// Smoothed at 40 degrees: the drum, the curl and the dome read as the
// pressed surfaces they are, while the strap's square section, the rim
// crevice, the seat step and every chamfer turn harder than that and
// survive as edges.
return {
body: { slot: 'stainless' as const, geometry: smoothNormals(mergeColoured(pieces), 40) },
lid: {
slot: 'stainless' as const,
geometry: smoothNormals(mergeColoured([lid]), 40),
// Anchor at the rim plane: geometry is seat-relative, so lifting the
// lid is a translation of the anchor and nothing else.
origin: [0, H, 0] as const,
},
}
},
actions: ({ parts, getConfig }) => {
let lifted = false
return {
liftLid(next?: boolean): void {
lifted = next ?? !lifted
const c = getConfig()
parts.lid.anchor.position.y =
clampHeight(c.height) + (lifted ? clampDiameter(c.diameter) * 0.55 : 0)
},
}
},
}, overrides)
}