medieval-kit/contemporary-props/models/wine-glass/model.ts

196 lines
8.9 KiB
TypeScript

/**
* @contemporary-props/wine-glass
*
* Second object in the kit, and it is here to prove two things before anything
* expensive depends on them: wall thickness on a revolve, and the glass slot.
*
* The vase got away with a wall of zero thickness because ceramic is opaque
* and the bore is dark. Glass is not opaque. Look through a wine glass and
* what you actually see of it is its edges: the bright ellipse of the rim, the
* disc edge of the foot, and the thick mass where the bowl closes onto the
* stem. So the bowl here is a real shell: the profile runs UP the outside,
* steps across the rim, and comes back DOWN the inside to a dished floor that
* sits well above the outer bottom. That step across the rim is the whole
* point — a single-surface bowl in a transparent material has a rim one
* triangle wide, which flickers, and a bowl bottom with nothing in it, which
* reads as a soap bubble on a stick.
*
* One continuous lathe carries foot, stem, bowl and inner surface. Not three
* stacked pieces, for the reason the lathe exists at all: stacked revolves
* leave coincident faces where they meet, and in a transparent material every
* internal face is visible forever.
*
* The stem is the hard part of the silhouette. Too thick and the object is a
* goblet; too thin and at prop distance it disappears and the bowl floats.
* The config exposes it, clamped to 3.5–8 mm radius, which spans a delicate
* tasting glass to a sturdy bistro stem and nothing outside what a stem is.
*
* The family: bowl radius and rim tuck move it from a wide tucked-in red
* through a narrower white to a small open port glass; bowl depth against
* total height trades stem for bowl.
*
* Parts: `body` alone. Nothing on a wine glass moves, and slicing one revolve
* into named thirds would only invent seams.
*/
import { type BufferGeometry } from 'three'
import {
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
smoothNormals,
type Level,
} from '../core/index.ts'
export interface WineGlassConfig {
/** Overall height (metres). */
readonly height: number
/** Widest radius of the bowl (metres). */
readonly bowlRadius: number
/** Bowl height as a fraction of the overall height. */
readonly bowlFraction: number
/** Rim radius as a fraction of the bowl radius. Low values tuck the rim in. */
readonly rim: number
/** Stem radius (metres). The goblet/disappearing line is clamped at both ends. */
readonly stemRadius: number
/** Sides around the revolve. */
readonly segments: number
readonly seed: number
}
export const wineGlassDefaults: WineGlassConfig = {
height: 0.215,
bowlRadius: 0.044,
// Just under half. More bowl than this reads as a brandy balloon, more stem
// reads as laboratory glassware.
bowlFraction: 0.48,
rim: 0.68,
stemRadius: 0.0045,
segments: 40,
seed: 7,
}
export type WineGlassParts = 'body'
export function createModel(overrides: Partial<WineGlassConfig> = {}) {
return createKitModel<WineGlassConfig, 'glass', WineGlassParts>({
id: 'wine-glass',
defaults: wineGlassDefaults,
slots: ['glass'],
build: ({ config, random }) => {
const tint = createTinter(random)
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v))
const H = clamp(config.height, 0.12, 0.3)
const bowlR = clamp(config.bowlRadius, 0.025, Math.min(0.065, H * 0.42))
const bowlH = H * clamp(config.bowlFraction, 0.32, 0.62)
const rimR = bowlR * clamp(config.rim, 0.52, 0.95)
// The stem is clamped twice: absolutely, and against the bowl. The
// absolute clamp keeps it a stem at all; the relative one stops a small
// glass given a fat stem turning into a candlestick, which is exactly
// what the first render of that corner was.
const stemR = clamp(config.stemRadius, 0.0035, Math.min(0.008, bowlR * 0.2))
// The foot follows the bowl rather than being free: a foot wider than
// the bowl is a cake stand, a foot narrower than three stem widths falls
// over in the hand and in the eye.
const footR = Math.max(bowlR * 0.78, stemR * 3)
// Wall thickness scales gently with the bowl so a big balloon does not
// end up with cigarette-paper walls, floored where thinner would vanish.
const wall = Math.max(0.0016, bowlR * 0.042)
const bowlBaseY = H - bowlH
/**
* Outer bowl profile, u = 0 at the stem junction, 1 at the rim.
* Below the belly an eased sine gives the rounded bottom; above it a
* cosine ease tucks the wall back in toward the rim, with a nonzero
* slope at the top so the rim tilts slightly inward like a red should.
*/
const bellyU = 0.45
const rOut = (u: number): number =>
u <= bellyU
? bowlR * Math.sin((u / bellyU) * (Math.PI / 2)) ** 0.8
: rimR + (bowlR - rimR) * Math.cos(((u - bellyU) / (1 - bellyU)) * (Math.PI / 2)) ** 1.35
const yAt = (u: number): number => bowlBaseY + bowlH * u
// The foot's vertical build scales with the glass: fixed heights that
// look right on a 21 cm red are a bell jar on a 12 cm port glass.
const fs = clamp(H / 0.215, 0.65, 1.15)
// Stem levels are fractions of the run between foot and bowl, so a short
// glass with a deep bowl cannot fold the stem back through the foot.
const footTipY = 0.0165 * fs
const s = (t: number): number => footTipY + (bowlBaseY - footTipY) * t
const levels: Level[] = [
// Foot: a disc with a rolled edge, rising as a shallow dome. The edge
// band is what the eye gets of the foot through the glass above it.
{ y: 0, radius: footR * 0.985 },
{ y: 0.0018 * fs, radius: footR },
{ y: 0.0032 * fs, radius: footR * 0.94 },
{ y: 0.007 * fs, radius: footR * 0.5 },
{ y: 0.0125 * fs, radius: Math.max(footR * 0.2, stemR * 1.9) },
{ y: footTipY, radius: stemR * 1.5 },
// Stem: a hair of entasis. Dead straight looks extruded; the swell at
// the top is where the pull into the bowl begins.
{ y: s(0.22), radius: stemR * 1.06 },
{ y: s(0.6), radius: stemR * 0.94 },
{ y: s(0.93), radius: stemR * 1.05 },
// Bowl, outside, base to rim. The lowest samples are floored at the
// stem radius: on a squat bowl the raw curve starts below the stem and
// the profile pinches in before flaring, which reads as a crack.
{ y: yAt(0.03), radius: Math.max(rOut(0.03), stemR * 1.2) },
{ y: yAt(0.07), radius: Math.max(rOut(0.07), stemR * 1.45) },
{ y: yAt(0.13), radius: rOut(0.13) },
{ y: yAt(0.21), radius: rOut(0.21) },
{ y: yAt(0.31), radius: rOut(0.31) },
{ y: yAt(bellyU), radius: bowlR },
{ y: yAt(0.57), radius: rOut(0.57) },
{ y: yAt(0.7), radius: rOut(0.7) },
{ y: yAt(0.82), radius: rOut(0.82) },
{ y: yAt(0.92), radius: rOut(0.92) },
{ y: H, radius: rimR },
// The rim: two levels at the same height, one wall thickness apart.
// The quad between them is the flat annulus that carries the whole
// top of the silhouette.
{ y: H, radius: rimR - wall },
// Inside, descending. Same profile inset by the wall, stopped short of
// the bottom so the bowl closes onto a solid mass above the stem.
{ y: yAt(0.92), radius: rOut(0.92) - wall },
{ y: yAt(0.8), radius: rOut(0.8) - wall },
{ y: yAt(0.66), radius: rOut(0.66) - wall },
{ y: yAt(0.52), radius: rOut(0.52) - wall },
{ y: yAt(bellyU), radius: bowlR - wall },
{ y: yAt(0.36), radius: rOut(0.36) - wall },
{ y: yAt(0.27), radius: rOut(0.27) - wall },
{ y: yAt(0.2), radius: rOut(0.2) - wall },
// Dished floor, then the cap closes it. Its height above the outer
// base is the thick lens of glass every stemmed glass has where the
// bowl was gathered onto the stem, and it is the third edge (after
// rim and foot) that makes transparent glass readable.
{ y: yAt(0.13), radius: bowlR * 0.16 },
]
const pieces: BufferGeometry[] = [
latheGeometry(
levels,
Math.max(12, Math.round(config.segments)),
[0, 0, 0],
tint('glass', jitter(random, 0.02)),
// Slightly lighter along the run of the profile, which ends on the
// inner surface: light collects in the bowl rather than on the foot.
{ colourTop: tint('glass', 0.05), capBottom: true, capTop: true },
),
]
// 40 degrees: the walls and the stem are curves and smooth out, while
// the rim annulus, the foot edge and the floor cap all turn through
// close to a right angle and keep their edges — and on glass the edges
// are the object.
return {
body: { slot: 'glass' as const, geometry: smoothNormals(mergeColoured(pieces), 40) },
}
},
}, overrides)
}