Both models had something in the place a handle belongs, and against a photograph neither was a handle. The tankard's was a flat box put through `bendGeometry` and then pushed clear of the body by a hand-derived offset. Measured, the result stood 7 mm proud of a body 47 mm in radius, with the rest buried in the stave wall -- no finger gap at all, which is why it read as a plank glued to the side. The offset formula did not describe what `bendGeometry` does to the geometry, and rather than deriving a better correction for a curve that cannot be inspected, the handle is now an actual arc from `arcBarGeometry`, specified by the two things that matter: where the ends land and how far the belly stands off. It now stands 24 mm clear. The proportions go with it -- the reference is a fist-sized vessel, ours was a beaker. The lantern's was a horizontal `bandGeometry` ring sitting on the flue: a suspension eye, not a bail. A lantern is defined by being carried, so the bail is the first thing the eye looks for. It is now a vertical arch whose sweep runs about 20 degrees past a half circle at each end, which turns the ends downwards and outwards so they cross the roof cone instead of sitting tangent to it -- a clean half circle was rejected by the support check as floating, correctly. Removing the ring stranded the tongue that used to join it to the flue, and leaving it in place produced the z-fight: its sides sat at z = +-0.00488 against the bail's +-0.00512, a quarter of a millimetre apart across the whole apex. A bail that reaches into the roof needs no strap to hold it on. Worth recording how that one was found. The first guess was that `arcBarGeometry` self-intersects once the sweep passes PI. Building the bail in isolation across six sweeps and segment counts gave 0 overlaps every time, which killed the theory before any time went into fixing the wrong thing. 27 models, 17433 triangles, all checks pass, 0 floating cases.
245 lines
9.9 KiB
TypeScript
245 lines
9.9 KiB
TypeScript
/**
|
|
* @medieval-kit/iron-lantern
|
|
*
|
|
* An iron lantern carried in the hand or hung from a hook: a hexagonal cage,
|
|
* glass panels, an oil lamp inside.
|
|
*
|
|
* What separates it from the torch is not only shape. A torch is a consumable,
|
|
* a lantern is a TOOL — expensive, kept, handed down. So the geometry looks
|
|
* more "made" as well: forged corner posts, a vent flue, a carrying hoop.
|
|
*
|
|
* The glass has two consequences and both are visible here:
|
|
*
|
|
* - The `glass` slot is TRANSPARENT and `depthWrite` is off. Without that the
|
|
* glass hid the wick behind it; a transparent surface must not write depth.
|
|
* - The glass panels are an `extras` body of the cage. Making them a separate
|
|
* part had seemed sensible but was wrong: cage and glass are ONE MEANING —
|
|
* if one moves, so does the other. Only the material is split.
|
|
*/
|
|
import type { BufferGeometry } from 'three'
|
|
|
|
import {
|
|
arcBarGeometry,
|
|
bandGeometry,
|
|
boxGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
latheGeometry,
|
|
mergeColoured,
|
|
prismGeometry,
|
|
type Level,
|
|
} from '../core/index.ts'
|
|
|
|
export interface IronLanternConfig {
|
|
/** Body height, hoop excluded (metres). */
|
|
readonly height: number
|
|
/** Corner-to-corner radius of the cage (metres). */
|
|
readonly radius: number
|
|
/** How many corners. 4 is a lantern, 6 is richer. */
|
|
readonly sides: number
|
|
/** Flame height, as a fraction of the body height. */
|
|
readonly flameHeight: number
|
|
/** Amplitude of the flicker. 0 = steady flame. */
|
|
readonly flicker: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const ironLanternDefaults: IronLanternConfig = {
|
|
height: 0.26,
|
|
radius: 0.075,
|
|
sides: 6,
|
|
flameHeight: 0.22,
|
|
flicker: 1,
|
|
seed: 71,
|
|
}
|
|
|
|
export type IronLanternParts = 'frame' | 'font' | 'flame' | 'handle'
|
|
|
|
export interface IronLanternActions {
|
|
setLit(lit: boolean): void
|
|
isLit(): boolean
|
|
}
|
|
|
|
export function createModel(overrides: Partial<IronLanternConfig> = {}) {
|
|
let lit = true
|
|
let elapsed = 0
|
|
|
|
return createKitModel<IronLanternConfig, 'iron' | 'glass' | 'char' | 'ember', IronLanternParts, IronLanternActions>({
|
|
id: 'iron-lantern',
|
|
defaults: ironLanternDefaults,
|
|
slots: ['iron', 'glass', 'char', 'ember'],
|
|
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
const half = config.height / 2
|
|
const sides = Math.max(3, Math.round(config.sides))
|
|
const bar = config.radius * 0.13
|
|
// The glazed section is the middle: oil font below, room for the flue above.
|
|
const glassBottom = -half + config.height * 0.2
|
|
const glassTop = half - config.height * 0.26
|
|
|
|
// --- Cage --------------------------------------------------------------
|
|
const iron: BufferGeometry[] = []
|
|
|
|
// Base dish and top cap: both hexagonal, one flat and one conical.
|
|
iron.push(prismGeometry(
|
|
config.radius * 1.08, config.radius, config.height * 0.11, sides,
|
|
[0, -half + config.height * 0.055, 0], tint('iron', -0.04, 0.7),
|
|
))
|
|
iron.push(prismGeometry(
|
|
config.radius * 1.12, config.radius * 0.42, config.height * 0.19, sides,
|
|
[0, half - config.height * 0.16, 0], tint('iron', 0.03, 0.7),
|
|
))
|
|
// Flue: if the hot air cannot get out the flame dies. A functional detail,
|
|
// and what makes the top of the silhouette read as a lantern.
|
|
iron.push(prismGeometry(
|
|
config.radius * 0.4, config.radius * 0.34, config.height * 0.09, sides,
|
|
[0, half - config.height * 0.025, 0], tint('iron', 0.07, 0.7),
|
|
))
|
|
|
|
// Corner posts: the forged iron bars left between the glass panels.
|
|
const step = (Math.PI * 2) / sides
|
|
for (let i = 0; i < sides; i += 1) {
|
|
const a = i * step
|
|
// The post runs down INTO the base plate. It used to stop 4% of the
|
|
// height short of it, which left the whole cage — and the handle on top
|
|
// of it — as an island hovering over its own base.
|
|
const post = boxGeometry(
|
|
[bar, glassTop - glassBottom + config.height * 0.34, bar * 1.15],
|
|
[0, 0, 0],
|
|
tint('iron', jitter(random, 0.05), 0.7),
|
|
)
|
|
// Orient first, then translate — reversed, the post is flung into orbit.
|
|
post.rotateY(a)
|
|
post.translate(
|
|
Math.sin(a) * config.radius * 0.97,
|
|
(glassBottom + glassTop) / 2 - config.height * 0.09,
|
|
Math.cos(a) * config.radius * 0.97,
|
|
)
|
|
iron.push(post)
|
|
}
|
|
// The bottom and top frames holding the glass panels.
|
|
for (const y of [glassBottom, glassTop]) {
|
|
iron.push(bandGeometry(config.radius * 0.99, y, bar * 1.1, bar * 0.8, sides,
|
|
tint('iron', -0.02, 0.7)))
|
|
}
|
|
|
|
// --- Glass ---------------------------------------------------------------
|
|
// The panels sit slightly INSIDE the posts: at the same radius their side
|
|
// faces would be coplanar with the posts' faces and would flicker.
|
|
const glass = prismGeometry(
|
|
config.radius * 0.9, config.radius * 0.9, glassTop - glassBottom, sides,
|
|
[0, (glassBottom + glassTop) / 2, 0], tint('glass', 0.04, 0.4),
|
|
{ capTop: false, capBottom: false },
|
|
)
|
|
|
|
// --- Oil font and wick ------------------------------------------------------
|
|
const fontTop = glassBottom + config.height * 0.16
|
|
const font = latheGeometry([
|
|
// Seated in the base plate rather than floating above it: the font and
|
|
// the wick it carries were a second detached island.
|
|
{ y: -half + config.height * 0.06, radius: config.radius * 0.44 },
|
|
{ y: glassBottom - config.height * 0.02, radius: config.radius * 0.5 },
|
|
{ y: glassBottom + config.height * 0.06, radius: config.radius * 0.62 },
|
|
{ y: fontTop, radius: config.radius * 0.44 },
|
|
], sides * 2, [0, 0, 0], tint('glass', -0.05, 0.4), { capTop: true })
|
|
|
|
const wick = prismGeometry(
|
|
config.radius * 0.075, config.radius * 0.05, config.height * 0.09, 4,
|
|
[0, fontTop + config.height * 0.035, 0], tint('char', 0.05),
|
|
)
|
|
|
|
// --- Flame -------------------------------------------------------------
|
|
// A smaller, calmer version of the torch's flame: in a closed lantern the
|
|
// flame gets no wind, so it flickers less as well.
|
|
const flameHeight = config.height * config.flameHeight
|
|
const flameBase = fontTop + config.height * 0.07
|
|
const flameProfile: Level[] = [
|
|
{ y: 0, radius: flameHeight * 0.2 },
|
|
{ y: flameHeight * 0.22, radius: flameHeight * 0.3 },
|
|
{ y: flameHeight * 0.58, radius: flameHeight * 0.19 },
|
|
{ y: flameHeight, radius: flameHeight * 0.03 },
|
|
]
|
|
const flame = mergeColoured([
|
|
latheGeometry(flameProfile, 6, [0, 0, 0], tint('ember', 0.06, 0.35),
|
|
{ colourTop: tint('emberTip', 0.02, 0.35) }),
|
|
])
|
|
|
|
// --- Carrying bail ----------------------------------------------------------
|
|
// A vertical arch, not a horizontal ring.
|
|
//
|
|
// What was here was a `bandGeometry` -- a flat hoop lying in the XZ
|
|
// plane on top of the flue. That is a suspension eye, and against a
|
|
// photograph of a real lantern it reads as a collar rather than as
|
|
// anything you could pick the lantern up by. A lantern's defining
|
|
// feature is that it is CARRIED: the bail is the first thing the eye
|
|
// looks for and the reason the object exists in a scene at all.
|
|
//
|
|
// `arcBarGeometry` builds in the XY plane, which for a half-circle from
|
|
// 0 to PI is already the shape wanted -- ends level, apex overhead --
|
|
// so no rotation is involved.
|
|
// The sweep runs PAST half a circle at both ends, by about 20 degrees.
|
|
// A clean half-circle put the two ends level with the roof's surface and
|
|
// merely tangent to it, which the support check rightly called floating.
|
|
// Carrying the arc further round turns each end downwards and outwards,
|
|
// so it crosses the roof cone from inside to outside and is genuinely
|
|
// embedded -- the same "real carpentry rather than a nudge" rule the
|
|
// rest of the kit follows for joints.
|
|
const bailRadius = config.radius * 0.78
|
|
const handle: BufferGeometry[] = [arcBarGeometry(
|
|
bailRadius,
|
|
bar * 1.05,
|
|
-0.35,
|
|
Math.PI + 0.35,
|
|
11,
|
|
[0, half - config.height * 0.145, 0],
|
|
tint('iron', 0.06, 0.7),
|
|
)]
|
|
// The tongue that used to join the old horizontal ring down to the flue
|
|
// is gone with the ring. Left in place it z-fought the new bail: its
|
|
// sides sat at z = +-0.00488 against the bail's +-0.00512, a quarter of
|
|
// a millimetre apart over the whole apex. A bail that reaches into the
|
|
// roof needs no strap to hold it on.
|
|
|
|
return {
|
|
frame: {
|
|
slot: 'iron' as const,
|
|
geometry: mergeColoured(iron),
|
|
extras: [{ slot: 'glass' as const, geometry: mergeColoured([glass]) }],
|
|
},
|
|
font: {
|
|
slot: 'glass' as const,
|
|
geometry: mergeColoured([font]),
|
|
extras: [{ slot: 'char' as const, geometry: mergeColoured([wick]) }],
|
|
},
|
|
flame: {
|
|
slot: 'ember' as const,
|
|
geometry: flame,
|
|
origin: [0, flameBase, 0] as const,
|
|
},
|
|
handle: { slot: 'iron' as const, geometry: mergeColoured(handle) },
|
|
}
|
|
},
|
|
|
|
actions: ({ parts }) => {
|
|
parts.flame.anchor.visible = lit
|
|
return {
|
|
setLit: (next) => { lit = next; parts.flame.anchor.visible = next },
|
|
isLit: () => lit,
|
|
}
|
|
},
|
|
|
|
update: (dt, { parts, getConfig }) => {
|
|
if (!lit) return
|
|
const amount = getConfig().flicker
|
|
if (amount === 0) return
|
|
elapsed += Math.max(0, dt)
|
|
// Slower and smaller than the torch's: the glass shields the flame from wind.
|
|
const pulse = Math.sin(elapsed * 6.4) * 0.06 + Math.sin(elapsed * 11.1 + 0.9) * 0.035
|
|
const anchor = parts.flame.anchor
|
|
anchor.scale.set(1 - pulse * 0.4 * amount, 1 + pulse * amount, 1 - pulse * 0.4 * amount)
|
|
anchor.rotation.z = Math.sin(elapsed * 4.7 + 1.3) * 0.03 * amount
|
|
},
|
|
}, overrides)
|
|
}
|