medieval-kit/contemporary-props/models/coffee-mug/model.ts

220 lines
9.6 KiB
TypeScript

/**
* @contemporary-props/coffee-mug
*
* The kit's second revolve, and its first join: a swept handle attached to a
* lathed body. The join is the point. The kettle, the jug, the watering can
* and the saucepan all reuse it, so the mug is where it has to be got right.
*
* Two things separate a mug from the vase it inherits its lathe from:
*
* the bore a mug is looked INTO. The profile goes up the outside, rolls
* over the rim, and comes back down inside to a floor, so the
* rim has a measurable thickness and the inner wall catches
* light. The vase could skip its bore; a mug with no bore is a
* dummy prop and reads as one from any angle above the rim.
*
* the handle a strap, not a rod. The section is wider across than it is
* deep, which is what says "ceramic pulled from the wall"
* rather than "wire bent into a loop". It is one arc whose two
* ends land INSIDE the wall: the end depth is computed from the
* wall thickness and the cap tilt so that at every value of the
* `handle` control the caps stay buried, clear of the outer
* surface on one side and of the bore on the other. On a
* tapered body the loop tilts to follow the wall, the way a
* real handle is stuck on parallel to the side it joins.
*
* The wall is deliberately chunky, diner-mug thick. Partly because that is
* what a stoneware mug is, and partly because the wall is the material the
* handle ends are buried in: a fine porcelain wall leaves no solid for the
* join to live inside.
*
* Parts: `body` and `handle`. The handle is the part a consumer will want to
* reach separately, whether to drop it for an espresso cup or to recolour a
* glazed accent.
*/
import { type BufferGeometry } from 'three'
import {
arcBarGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
smoothNormals,
type Level,
} from '../core/index.ts'
export interface CoffeeMugConfig {
/** Overall height (metres). */
readonly height: number
/** Outer radius at the rim (metres). */
readonly radius: number
/** Foot radius as a fraction of the rim radius. 1 is a straight diner mug. */
readonly taper: number
/** How far the handle bows out. 1 is a standard loop. */
readonly handle: number
/** Sides around the revolve. Low values are a deliberate faceted look. */
readonly segments: number
readonly seed: number
}
export const coffeeMugDefaults: CoffeeMugConfig = {
height: 0.095,
radius: 0.041,
// A slight taper. Dead straight is a value in the family, not the default:
// most mugs narrow a little toward the foot and it keeps the silhouette
// from reading as a cut length of pipe.
taper: 0.9,
handle: 1,
segments: 32,
seed: 7,
}
export type CoffeeMugParts = 'body' | 'handle'
export function createModel(overrides: Partial<CoffeeMugConfig> = {}) {
return createKitModel<CoffeeMugConfig, 'ceramic', CoffeeMugParts>({
id: 'coffee-mug',
defaults: coffeeMugDefaults,
slots: ['ceramic'],
build: ({ config, random }) => {
const tint = createTinter(random)
const H = config.height
const R = config.radius
const taper = Math.min(1, Math.max(0.7, config.taper))
const F = R * taper
const segments = Math.max(10, Math.round(config.segments))
// The wall. Thick enough to read at the rim and, just as important,
// thick enough that the handle ends can be buried in it with margin on
// both faces. The floor stops a small mug from getting a paper wall.
const wall = Math.max(0.0058, R * 0.15)
// A mug's base is heavier than its wall; the bore floor sits up here.
const base = Math.max(0.006, H * 0.085)
// Outer radius at height y: a straight taper with a faint barrel bow.
// The bow is under two percent, which is invisible as a shape but keeps
// a highlight moving along the wall instead of sitting in one stripe.
const rOut = (y: number): number => {
const f = y / H
return (F + (R - F) * f) * (1 + 0.013 * Math.sin(Math.PI * f))
}
/* ------------------------------------------------------------- body */
// One profile, up the outside, over the rim, down the bore. A single
// lathe leaves no coincident faces where an outer and an inner shell
// would meet, and the rim thickness falls out of the turn-around.
const half = wall / 2
const rimY = H - half
const rimX = R - half
const s45 = half * Math.SQRT1_2
const levels: Level[] = [
// A short chamfer off the table, so the foot has an edge that reads
// as trimmed rather than the wall dying into the ground plane.
{ y: 0, radius: F * 0.96 },
{ y: 0.0035, radius: rOut(0.0035) },
{ y: H * 0.2, radius: rOut(H * 0.2) },
{ y: H * 0.45, radius: rOut(H * 0.45) },
{ y: H * 0.7, radius: rOut(H * 0.7) },
{ y: H * 0.88, radius: rOut(H * 0.88) },
// The rim, rolled as a half circle of the wall's own thickness,
// sampled every 45 degrees so smoothing reads it as a curve.
{ y: rimY, radius: R },
{ y: H - half + s45, radius: rimX + s45 },
{ y: H, radius: rimX },
{ y: H - half + s45, radius: rimX - s45 },
{ y: rimY, radius: R - wall },
// Down the bore. Same profile as the outside, one wall in, so the
// rim reads as an even thickness from every angle that sees into it.
{ y: H * 0.66, radius: rOut(H * 0.66) - wall },
{ y: H * 0.42, radius: rOut(H * 0.42) - wall },
{ y: base + 0.0025, radius: rOut(base + 0.0025) - wall },
{ y: base, radius: rOut(base + 0.0025) - wall - 0.0012 },
]
const glaze = tint('ceramic', jitter(random, 0.02))
const body = latheGeometry(
levels,
segments,
[0, 0, 0],
glaze,
// The index ramp runs foot -> rim -> floor, so the outside lightens
// toward the rim the way thinning glaze does, and the bore stays a
// touch lighter than the outside, which stops it going to mud once
// the renderer shades the cavity.
{ colourTop: tint('ceramic', 0.05), capBottom: true, capTop: true },
)
/* ----------------------------------------------------------- handle */
// Strap section: deeper across (z, along the wall) than radially. Built
// square by arcBarGeometry, flattened by a scale before it moves.
const scaleT = R / 0.041
const tRad = Math.min(0.0072, Math.max(0.0048, 0.006 * scaleT))
const widthZ = Math.min(0.0135, Math.max(0.0088, 0.0115 * scaleT))
// Where the loop leaves the wall.
const topY = H * 0.78
const botY = H * 0.36
const wallTop = rOut(topY)
const wallBot = rOut(botY)
// End angle past 90 degrees turns the ends in toward the wall; the
// `handle` control widens the bow. Capped where the cap tilt would
// start pushing corners through the bore.
const bow = Math.min(1.3, Math.max(0.7, config.handle))
const thetaE = (Math.PI / 180) * Math.min(125, Math.max(109, 95 + 23 * bow))
// Bury depth for the end caps. The cap is a tilted rectangle: its
// corners reach `capTilt` past the end centre radially, and at the
// strap's z-edges the round wall has receded by `recess`. The depth
// sits centred between "corner breaks out of the wall" and "corner
// breaks into the bore", with margin on both sides.
const capTilt = Math.abs(Math.cos(thetaE)) * (tRad / 2)
const recess = wallBot - Math.sqrt(Math.max(0, wallBot * wallBot - (widthZ / 2) ** 2))
const dMin = recess + 0.0006 + capTilt
const dMax = wall - 0.0006 - capTilt
const depth = dMin < dMax ? (dMin + dMax) / 2 : wall * 0.5
const eTop = { x: wallTop - depth, y: topY }
const eBot = { x: wallBot - depth, y: botY }
const chordX = eTop.x - eBot.x
const chordY = eTop.y - eBot.y
const chord = Math.hypot(chordX, chordY)
const rLoop = chord / 2 / Math.sin(thetaE)
// Tilt that lays the loop's chord along the wall slope, so on a tapered
// body both ends sit at the same depth instead of one floating proud.
const alpha = Math.atan2(-chordX, chordY)
const strap = arcBarGeometry(
rLoop, tRad, -thetaE, thetaE, 22, [0, 0, 0],
// The body's own glaze colour: a handle tinted separately shows the
// join as a colour edge before it shows it as geometry.
glaze.clone(),
)
// Flatten to a strap, tilt to the wall, then carry to the wall. Built
// at the origin in the XY plane, which is already the vertical plane
// the loop lives in, so no other rotation is needed.
strap.scale(1, 1, widthZ / tRad)
strap.rotateZ(alpha)
const endBotX = rLoop * Math.cos(thetaE)
const endBotY = -rLoop * Math.sin(thetaE)
strap.translate(
eBot.x - (Math.cos(alpha) * endBotX - Math.sin(alpha) * endBotY),
eBot.y - (Math.sin(alpha) * endBotX + Math.cos(alpha) * endBotY),
0,
)
const bodyPieces: BufferGeometry[] = [body]
// Smoothed at 50: the rim's 45 degree steps round over, while the foot
// chamfer against the base cap stays an edge. The handle smooths along
// its sweep at 40 and keeps its four strap edges crisp, which is what
// makes the section read as a section.
return {
body: { slot: 'ceramic' as const, geometry: smoothNormals(mergeColoured(bodyPieces), 50) },
handle: { slot: 'ceramic' as const, geometry: smoothNormals(strap, 40) },
}
},
}, overrides)
}