medieval-kit/my-registry/models/iron-lantern/model.ts
CRT 01d00cdd26 Give the tankard a handle you could hold, and the lantern one at all
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.
2026-08-22 11:54:54 +07:00

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)
}