/** * @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 = {}) { return createKitModel({ 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) }