/** * @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.1, 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 = {}) { return createKitModel({ 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. // The foot's corner radius. A fifth of the mug's radius, which is what // the reference measures and is generous next to anything turned. const foot = R * 0.2 const half = wall / 2 const rimY = H - half const rimX = R - half const s45 = half * Math.SQRT1_2 const levels: Level[] = [ /** * A ROUNDED foot, not a chamfer. * * The 3.5 mm bevel that was here read as a trimmed edge, which is * what a thrown pot has and what a slip-cast mug does not: the * reference turns the corner over about a fifth of the radius and * that soft corner is most of why it reads as a mug rather than as a * tube with a lid missing. Sampled at 30 and 60 degrees so the * smoothing takes it as one curve. */ { y: 0, radius: F - foot }, { y: foot * 0.134, radius: F - foot * 0.5 }, { y: foot * 0.5, radius: F - foot * 0.134 }, { y: foot, radius: rOut(foot) }, { y: H * 0.3, radius: rOut(H * 0.3) }, { 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: nearly square, and DEEPER in the plane of the loop. * * It was the other way round, 11.5 mm across the wall against 6 mm * radially, so the widest face of the handle was the one turned toward * anyone looking at the mug from the side. That is a ribbon, and a * handle you could not get a finger round. A real one is close to * square and if anything deeper the way the loop runs, because that is * the direction it has to be stiff in. */ const scaleT = R / 0.041 const tRad = Math.min(0.0098, Math.max(0.0062, 0.0082 * scaleT)) const widthZ = Math.min(0.0092, Math.max(0.0058, 0.0074 * 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) // Twelve-sided, so the loop has no hard edge running its length. A // square strap's four corners catch light as ridges and no amount of // getting the proportions right stops that reading as a folded ribbon. 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(), 12, ) // 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) }