Bring the anvil's horn back to its reference proportion, rebuild the book's metal furniture so no two overlapping faces share a plane, and release 0.2.0.
This commit is contained in:
parent
3f57165d06
commit
9b4ce16c59
82 changed files with 9725 additions and 5064 deletions
|
|
@ -1,19 +1,37 @@
|
|||
/**
|
||||
* @medieval-kit/glass-phial
|
||||
*
|
||||
* Cork-stoppered, wax-sealed glass bottle. Alchemist's shelf, healer's bag, a
|
||||
* slot in the inventory.
|
||||
* Cork-stoppered glass bottle with a wax seal. Alchemist's shelf, healer's
|
||||
* bag, a slot in the inventory.
|
||||
*
|
||||
* The kit's second model to use the `glass` slot, and where the glass is really
|
||||
* tested: in the lantern it was a panel of a CAGE, here it is the vessel
|
||||
* itself. That has two consequences —
|
||||
* THIRD PROFILE. The first two were curvy decanters: a bulging belly on a
|
||||
* rounded near-pointed base, a long conical shoulder tapering into a stem of
|
||||
* a neck. The blind critique (70/100, worst axis silhouette) reported that
|
||||
* the reference is nothing of the sort: a plain straight-sided apothecary
|
||||
* bottle, vertical walls on a flat foot, a short shoulder, a short neck, and
|
||||
* a flared lip ring that is the one feature naming the object. So:
|
||||
*
|
||||
* - Constant wall radius from the foot to ~60% of total height. The widest
|
||||
* point at mid-belly is gone entirely.
|
||||
* - Shoulder turns over ~12% of height, neck is short, and the lip flares
|
||||
* to ~1.4x the neck radius. Glass above the shoulder is ~10% of height.
|
||||
* - Flat disc base with a slight foot ring, not a rounded bottom.
|
||||
* - The cork is a plain, slightly tapered plug standing clear of the lip at
|
||||
* about the neck's width, not a stub hidden under the wax.
|
||||
* - The wax bridges the cork-to-neck junction: it wraps the lip ring and
|
||||
* climbs onto the cork. The previous seal sat wholly on the cork,
|
||||
* floating above the glass with bare cork showing beneath it.
|
||||
*
|
||||
* The kit's second model to use the `glass` slot, and where the glass is
|
||||
* really tested: in the lantern it was a panel of a CAGE, here it is the
|
||||
* vessel itself. Two consequences survive from the earlier versions —
|
||||
*
|
||||
* - The contents are a separate body INSIDE the glass shell. In the `ember`
|
||||
* slot, so it takes no light and supplies its own colour: a liquid that
|
||||
* went dark at the bottom looked like dirty water, not a potion.
|
||||
* - The liquid surface is a flat disc. Seen through the glass that straight
|
||||
* line is the only mark saying "this is full"; a spherical liquid body has
|
||||
* no surface and the bottle looks empty.
|
||||
* line is the only mark saying "this is full"; a spherical liquid body
|
||||
* has no surface and the bottle looks empty.
|
||||
*
|
||||
* Period note: clear, colourless glass is a very late thing. Glass of the
|
||||
* period was greenish and bubbly from iron impurity; that is why the `glass`
|
||||
|
|
@ -22,7 +40,6 @@
|
|||
import { Color, type BufferGeometry } from 'three'
|
||||
|
||||
import {
|
||||
bandGeometry,
|
||||
createKitModel,
|
||||
createTinter,
|
||||
latheGeometry,
|
||||
|
|
@ -35,11 +52,11 @@ import {
|
|||
export interface GlassPhialConfig {
|
||||
/** Total height, stopper included (metres). */
|
||||
readonly height: number
|
||||
/** Widest radius of the body (metres). */
|
||||
/** Radius of the straight body wall (metres). */
|
||||
readonly radius: number
|
||||
/** Neck length, as a fraction of the height. */
|
||||
/** Height of shoulder, neck and lip together, as a fraction of the height. */
|
||||
readonly neck: number
|
||||
/** Fill level. 0 empty, 1 up to the brim. */
|
||||
/** Fill level. 0 empty, 1 up to the shoulder. */
|
||||
readonly fill: number
|
||||
/**
|
||||
* Colour of the liquid, 0–1 around the colour wheel.
|
||||
|
|
@ -57,8 +74,8 @@ export interface GlassPhialConfig {
|
|||
|
||||
export const glassPhialDefaults: GlassPhialConfig = {
|
||||
height: 0.14,
|
||||
radius: 0.032,
|
||||
neck: 0.34,
|
||||
radius: 0.024,
|
||||
neck: 0.26,
|
||||
fill: 0.62,
|
||||
hue: 0.33,
|
||||
seal: 1,
|
||||
|
|
@ -74,52 +91,67 @@ export function createModel(overrides: Partial<GlassPhialConfig> = {}) {
|
|||
slots: ['glass', 'ember', 'oak', 'char'],
|
||||
build: ({ config, random }) => {
|
||||
const tint = createTinter(random)
|
||||
const half = config.height / 2
|
||||
const neckLength = config.height * config.neck
|
||||
const bodyTop = half - neckLength
|
||||
const bodyBottom = -half
|
||||
const neckRadius = config.radius * 0.36
|
||||
// Floors, because a config slider at zero must degrade the shape, not
|
||||
// divide the vertices into NaN (that mistake once recoloured a whole
|
||||
// model through the occlusion bake).
|
||||
const height = Math.max(0.05, config.height)
|
||||
const radius = Math.max(0.008, config.radius)
|
||||
const half = height / 2
|
||||
const bottom = -half
|
||||
|
||||
// Vertical layout, from the ground up. The cork's crown defines the
|
||||
// total height; glass stops at 86% of it.
|
||||
const glassTop = bottom + height * 0.86
|
||||
const neckLength = height * Math.max(0.12, config.neck)
|
||||
const shoulderStart = glassTop - neckLength
|
||||
const shoulderRun = neckLength * 0.46
|
||||
const lipRun = neckLength * 0.16
|
||||
const neckTop = glassTop - lipRun
|
||||
|
||||
const wallRadius = radius * 0.985
|
||||
const neckRadius = radius * 0.62
|
||||
const lipRadius = neckRadius * 1.42
|
||||
|
||||
// --- Bottle ---------------------------------------------------------------
|
||||
// Blown glass: slightly pushed in at the base (pontil mark), round body,
|
||||
// shoulder narrowing fast into the neck, a lip flared out at the mouth.
|
||||
// Straight-sided apothecary bottle: flat foot with a slight foot ring,
|
||||
// vertical wall, short rounded shoulder, short neck, flared lip ring.
|
||||
// capTop is TRUE: the mouth reads as the flat top face of the lip, and
|
||||
// the cork rises through it. Leaving it open showed the interior
|
||||
// backfaces around the cork whenever the seal was off.
|
||||
const profile: Level[] = [
|
||||
{ y: bodyBottom + config.height * 0.02, radius: config.radius * 0.5 },
|
||||
{ y: bodyBottom + config.height * 0.06, radius: config.radius * 0.86 },
|
||||
{ y: bodyBottom + config.height * 0.19, radius: config.radius },
|
||||
{ y: bodyBottom + (bodyTop - bodyBottom) * 0.72, radius: config.radius * 0.88 },
|
||||
{ y: bodyTop, radius: config.radius * 0.5 },
|
||||
{ y: bodyTop + neckLength * 0.34, radius: neckRadius },
|
||||
{ y: half - config.height * 0.05, radius: neckRadius * 0.96 },
|
||||
{ y: half - config.height * 0.02, radius: neckRadius * 1.28 }, // lip
|
||||
{ y: bottom, radius: radius * 0.95 },
|
||||
{ y: bottom + height * 0.012, radius }, // foot ring
|
||||
{ y: bottom + height * 0.045, radius: wallRadius },
|
||||
{ y: shoulderStart, radius: wallRadius }, // straight wall
|
||||
{ y: shoulderStart + shoulderRun * 0.55, radius: radius * 0.88 },
|
||||
{ y: shoulderStart + shoulderRun, radius: neckRadius }, // shoulder done
|
||||
{ y: neckTop, radius: neckRadius }, // neck
|
||||
{ y: neckTop + lipRun * 0.45, radius: lipRadius }, // lip flare
|
||||
{ y: glassTop, radius: lipRadius * 0.97 },
|
||||
]
|
||||
const bottle = latheGeometry(profile, 9, [0, 0, 0], tint('glass', -0.02, 0.4), {
|
||||
const bottle = latheGeometry(profile, 10, [0, 0, 0], tint('glass', -0.02, 0.4), {
|
||||
colourTop: tint('glass', 0.06, 0.4),
|
||||
capTop: false, // mouth OPEN: the stopper sits there
|
||||
})
|
||||
// Blown glass is never perfectly symmetric; the deviation is kept tiny
|
||||
// because on a transparent surface big irregularity reads as frosted glass.
|
||||
roughenGeometry(bottle, config.radius * 0.02, { salt: 41 })
|
||||
// because on a straight wall big irregularity reads as a dented bottle.
|
||||
roughenGeometry(bottle, radius * 0.015, { salt: 41 })
|
||||
|
||||
// --- Contents -------------------------------------------------------------
|
||||
// The liquid level is computed from the fill and sits on the body's radius
|
||||
// AT THAT HEIGHT — using a fixed radius either spilled the liquid out
|
||||
// through the glass or left it hanging in mid-air.
|
||||
// AT THAT HEIGHT. Its floor starts slightly ABOVE the bottle's own base
|
||||
// cap: two same-facing discs sharing the base plane are forbidden.
|
||||
const fill = Math.max(0, Math.min(1, config.fill))
|
||||
let liquid: BufferGeometry | undefined
|
||||
if (fill > 0.02) {
|
||||
const surfaceY = bodyBottom + config.height * 0.04
|
||||
+ (bodyTop + neckLength * 0.4 - bodyBottom - config.height * 0.04) * fill
|
||||
const floorY = bottom + height * 0.012
|
||||
const surfaceY = floorY
|
||||
+ (shoulderStart + shoulderRun * 0.4 - floorY) * fill
|
||||
// A FIXED number of levels, sampled between the bottle's floor and the
|
||||
// liquid surface. Filtering the bottle profile by height was the
|
||||
// obvious way to do it and it made the vertex count depend on `fill` —
|
||||
// which meant the showcase could not morph this model at all and fell
|
||||
// back to rebuilding it in visible steps.
|
||||
const steps = 5
|
||||
const inner = Array.from({ length: steps }, (_, i) => {
|
||||
const y = profile[0]!.y + (surfaceY - profile[0]!.y) * (i / steps)
|
||||
return { y, radius: radiusAt(y) }
|
||||
})
|
||||
function radiusAt(y: number): number {
|
||||
for (let i = 1; i < profile.length; i += 1) {
|
||||
const a = profile[i - 1]!
|
||||
|
|
@ -131,6 +163,10 @@ export function createModel(overrides: Partial<GlassPhialConfig> = {}) {
|
|||
}
|
||||
return profile.at(-1)!.radius * 0.88
|
||||
}
|
||||
const inner = Array.from({ length: steps }, (_, i) => {
|
||||
const y = floorY + (surfaceY - floorY) * (i / steps)
|
||||
return { y, radius: radiusAt(y) }
|
||||
})
|
||||
inner.push({ y: surfaceY, radius: radiusAt(surfaceY) })
|
||||
// The liquid is in the `ember` slot, so it TAKES NO LIGHT: the vertex
|
||||
// colour is the final colour that goes to the screen. A choice — a
|
||||
|
|
@ -140,22 +176,47 @@ export function createModel(overrides: Partial<GlassPhialConfig> = {}) {
|
|||
const deep = new Color().setHSL(hue, 0.78, 0.36)
|
||||
const bright = new Color().setHSL((hue + 0.03) % 1, 0.72, 0.58)
|
||||
liquid = mergeColoured([latheGeometry(
|
||||
inner, 9, [0, 0, 0], deep,
|
||||
inner, 10, [0, 0, 0], deep,
|
||||
{ colourTop: bright, capTop: true },
|
||||
)])
|
||||
}
|
||||
|
||||
// --- Stopper -------------------------------------------------------------
|
||||
const stopper: BufferGeometry[] = [prismGeometry(
|
||||
neckRadius * 1.02, neckRadius * 1.24, config.height * 0.11, 8,
|
||||
[0, half - config.height * 0.035, 0], tint('oak', 0.08),
|
||||
)]
|
||||
// A plain tapered plug: buried in the neck below, standing clear above
|
||||
// the lip by about the neck's width. Its lower end terminates INSIDE the
|
||||
// neck, never level with the mouth.
|
||||
const corkBottom = neckTop - neckLength * 0.24
|
||||
const corkTop = bottom + height
|
||||
const corkHeight = corkTop - corkBottom
|
||||
const corkRadiusAt = (y: number): number => {
|
||||
const t = (y - corkBottom) / Math.max(1e-6, corkHeight)
|
||||
return neckRadius * (0.84 + 0.22 * t)
|
||||
}
|
||||
const stopper = prismGeometry(
|
||||
corkRadiusAt(corkBottom), corkRadiusAt(corkTop), corkHeight, 8,
|
||||
[0, (corkBottom + corkTop) / 2, 0], tint('oak', 0.08),
|
||||
{ colourTop: tint('oak', 0.13) },
|
||||
)
|
||||
|
||||
// --- Wax seal ------------------------------------------------------------
|
||||
// The wax bridges the junction: a skirt wrapping the glass lip ring, a
|
||||
// slope over its corner, and a collar hugging the cork above it, capped
|
||||
// so it reads as poured wax the cork rises out of.
|
||||
let seal: BufferGeometry | undefined
|
||||
if (config.seal >= 0.5) {
|
||||
// Wax seal: the hoop wrapping stopper and bottle mouth together.
|
||||
stopper.push(bandGeometry(
|
||||
neckRadius * 1.36, half - config.height * 0.035, config.height * 0.055,
|
||||
neckRadius * 0.24, 8, tint('charHot', -0.1, 0.6), { inner: true },
|
||||
))
|
||||
// The skirt stops halfway down the lip: it must bridge onto the glass,
|
||||
// but a skirt that swallowed the whole lip ring erased the one feature
|
||||
// that names the object.
|
||||
const waxTop = glassTop + height * 0.038
|
||||
const waxProfile: Level[] = [
|
||||
{ y: glassTop - lipRun * 0.55, radius: lipRadius * 1.07 },
|
||||
{ y: glassTop + height * 0.012, radius: lipRadius * 0.97 },
|
||||
{ y: waxTop, radius: corkRadiusAt(waxTop) * 1.08 },
|
||||
]
|
||||
seal = mergeColoured([latheGeometry(
|
||||
waxProfile, 10, [0, 0, 0], tint('charHot', -0.1, 0.6),
|
||||
{ colourTop: tint('charHot', -0.04, 0.6) },
|
||||
)])
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -163,9 +224,9 @@ export function createModel(overrides: Partial<GlassPhialConfig> = {}) {
|
|||
liquid: liquid ? { slot: 'ember' as const, geometry: liquid } : undefined,
|
||||
stopper: {
|
||||
slot: 'oak' as const,
|
||||
geometry: mergeColoured([stopper[0]!]),
|
||||
...(stopper.length > 1
|
||||
? { extras: [{ slot: 'char' as const, geometry: mergeColoured(stopper.slice(1)) }] }
|
||||
geometry: mergeColoured([stopper]),
|
||||
...(seal
|
||||
? { extras: [{ slot: 'char' as const, geometry: seal }] }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue