The kit is going public, so everything a reader sees is now English:
27 model sources, the core vocabulary, meta.ts (titles, descriptions and
every slider label), the viewer UI, all script output, REFERENCE.md and
the READMEs.
Rules applied: only comments, doc comments and human-facing strings were
touched. Identifiers, numbers, logic, registry item ids, part names and
material slot names are unchanged — those are a published contract and
were already English.
Verified rather than assumed: for all 53 changed .ts files the code
skeleton (source with comments stripped and string contents replaced by a
placeholder) is byte-identical before and after. Full gate is green —
typecheck, registry conformance, ~510 model checks, GLB round trip and a
production build.
Also fixed here:
- index.html and viewer.html were missed by the translation pass because
the file list only covered .ts/.md/.css. Both had lang="tr" and Turkish
titles.
- The viewer formatted triangle counts with toLocaleString('tr-TR'), which
prints 15.425 rather than 15,425.
- REFERENCE.md's model table header did not match what
scripts/catalog-table.ts emits, so the next regeneration would have
silently changed it. Aligned to the generator.
- Removed scripts/probe.ts and scripts/zdiag.ts; they were throwaway
diagnostics that got committed by accident.
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
/**
|
|
* @medieval-kit/iron-brazier
|
|
*
|
|
* The first MOVING model in the kit. The barrel and the chest are static: they
|
|
* are built and they stay that way. The brazier exercises two parts of the
|
|
* vibe3d protocol that those two never touch:
|
|
*
|
|
* - typed `actions` → light / extinguish
|
|
* - `update(dt)` → flame flicker and light fluctuation
|
|
*
|
|
* The distinction matters: `configure()` rebuilds the topology and is
|
|
* expensive, it is meant for user settings. Everything that changes per frame
|
|
* must live inside `update()`.
|
|
*
|
|
* The model also carries a PointLight. The light is attached to the `flame`
|
|
* anchor, so even when configure() rebuilds the geometry the light survives.
|
|
*/
|
|
import {
|
|
Color,
|
|
Group,
|
|
Mesh,
|
|
PointLight,
|
|
type BufferGeometry,
|
|
type Material,
|
|
} from 'three'
|
|
|
|
import type { ConfigureResult, MaterialBindings, ModelInstance, PartHandle } from '@vibe3d/model.ts'
|
|
import { ResourceScope } from '@vibe3d/ownership.ts'
|
|
|
|
import {
|
|
MEDIEVAL_PALETTE,
|
|
bandGeometry,
|
|
boxGeometry,
|
|
createMedievalMaterials,
|
|
createPart,
|
|
createRandom,
|
|
flipGeometry,
|
|
jitter,
|
|
mergeColoured,
|
|
prismGeometry,
|
|
} from '../core/index.ts'
|
|
|
|
export interface IronBrazierConfig {
|
|
/** Total height including the legs (metres). */
|
|
readonly height: number
|
|
/** Radius of the bowl's top rim (metres). */
|
|
readonly bowlRadius: number
|
|
/** Corner count around the bowl. Keep it low — it sets the lowpoly silhouette. */
|
|
readonly bowlSegments: number
|
|
/** Number of legs. */
|
|
readonly legCount: number
|
|
/** Number of flame blades. 0 = no flame, coals only. */
|
|
readonly flameCount: number
|
|
/** Variation seed. */
|
|
readonly seed: number
|
|
}
|
|
|
|
export const ironBrazierDefaults: IronBrazierConfig = {
|
|
height: 0.86,
|
|
bowlRadius: 0.26,
|
|
bowlSegments: 12,
|
|
legCount: 3,
|
|
flameCount: 5,
|
|
seed: 11,
|
|
}
|
|
|
|
export interface IronBrazierParts {
|
|
/** The bowl and its rim hoop. */
|
|
readonly bowl: PartHandle<Group>
|
|
/** The legs and their feet. */
|
|
readonly legs: PartHandle<Group>
|
|
/** Burnt-out coals. */
|
|
readonly coals: PartHandle<Group>
|
|
/** Flame blades, glowing coals and the light. Hidden when extinguished. */
|
|
readonly flame: PartHandle<Group>
|
|
}
|
|
|
|
export interface IronBrazierActions {
|
|
/** Lights or extinguishes the fire. */
|
|
setLit(lit: boolean): void
|
|
/** Whether it is currently lit. */
|
|
isLit(): boolean
|
|
}
|
|
|
|
const SLOTS = ['iron', 'char', 'ember'] as const
|
|
type Slot = (typeof SLOTS)[number]
|
|
|
|
interface Blade {
|
|
readonly mesh: Group
|
|
readonly phase: number
|
|
readonly speed: number
|
|
readonly reach: number
|
|
}
|
|
|
|
export function createModel(
|
|
overrides: Partial<IronBrazierConfig> = {},
|
|
): ModelInstance<IronBrazierConfig, IronBrazierParts, IronBrazierActions> {
|
|
let config: IronBrazierConfig = { ...ironBrazierDefaults, ...overrides }
|
|
|
|
const scope = new ResourceScope()
|
|
const defaults = createMedievalMaterials(scope, SLOTS)
|
|
const overridesBySlot = new Map<Slot, Material>()
|
|
const resolve = (slot: Slot): Material => overridesBySlot.get(slot) ?? defaults[slot]
|
|
|
|
const root = new Group()
|
|
root.name = 'iron-brazier'
|
|
const bowl = createPart('iron-brazier/bowl')
|
|
const legs = createPart('iron-brazier/legs')
|
|
const coals = createPart('iron-brazier/coals')
|
|
const flame = createPart('iron-brazier/flame')
|
|
root.add(bowl.anchor, legs.anchor, coals.anchor, flame.anchor)
|
|
|
|
// The light is attached to the ANCHOR, not to the generated content. Because
|
|
// the anchor does not change on rebuild, configure() does not put the fire
|
|
// out — objects the consumer added itself also survive for the same reason.
|
|
const light = new PointLight(0xffa04d, 0, 6, 2)
|
|
light.name = 'iron-brazier/firelight'
|
|
flame.anchor.add(light)
|
|
|
|
let owned: BufferGeometry[] = []
|
|
let blades: Blade[] = []
|
|
let lit = true
|
|
let elapsed = 0
|
|
|
|
/** World height of the bowl's top rim. */
|
|
const bowlTop = (): number => config.height * 0.5
|
|
const bowlDepth = (): number => config.bowlRadius * 0.78
|
|
|
|
function buildBowl(random: () => number): BufferGeometry {
|
|
const tint = new Color()
|
|
const shade = (): Color => {
|
|
tint.copy(MEDIEVAL_PALETTE.iron)
|
|
tint.offsetHSL(0, jitter(random, 0.02), jitter(random, 0.055))
|
|
return tint
|
|
}
|
|
|
|
const depth = bowlDepth()
|
|
const centreY = bowlTop() - depth / 2
|
|
const outerBottom = config.bowlRadius * 0.56
|
|
const wall = config.bowlRadius * 0.055
|
|
|
|
// The outer shell and the inner shell are the same cone; the inner one is
|
|
// flipped, because when you look into the bowl the faces must face us.
|
|
const outer = prismGeometry(
|
|
outerBottom, config.bowlRadius, depth, config.bowlSegments,
|
|
[0, centreY, 0], shade(), { capTop: false, capBottom: true },
|
|
)
|
|
const inner = flipGeometry(prismGeometry(
|
|
outerBottom - wall, config.bowlRadius - wall, depth, config.bowlSegments,
|
|
[0, centreY + wall, 0], shade(), { capTop: false, capBottom: true },
|
|
))
|
|
// The rim hoop closes the gap between the outer and inner shells and gives
|
|
// the impression of a forged lip.
|
|
const rim = bandGeometry(
|
|
config.bowlRadius + wall * 0.4, bowlTop(), wall * 2.6, wall * 1.6,
|
|
config.bowlSegments, shade(),
|
|
)
|
|
|
|
return mergeColoured([outer, inner, rim])
|
|
}
|
|
|
|
function buildLegs(random: () => number): BufferGeometry {
|
|
const tint = new Color()
|
|
const pieces: BufferGeometry[] = []
|
|
const attachY = bowlTop() - bowlDepth()
|
|
const legLength = attachY * 1.16
|
|
const thickness = config.bowlRadius * 0.1
|
|
const tilt = 0.24
|
|
|
|
for (let i = 0; i < Math.max(1, config.legCount); i += 1) {
|
|
const angle = (i / Math.max(1, config.legCount)) * Math.PI * 2
|
|
tint.copy(MEDIEVAL_PALETTE.iron)
|
|
tint.offsetHSL(0, jitter(random, 0.02), jitter(random, 0.05))
|
|
|
|
// First build a stick hanging BELOW the origin, then tilt it outwards, then
|
|
// rotate it into place. Order matters: rotate always turns about the origin.
|
|
const leg = boxGeometry([thickness, legLength, thickness], [0, -legLength / 2, 0], tint)
|
|
leg.rotateZ(tilt)
|
|
leg.rotateY(angle)
|
|
leg.translate(0, attachY, 0)
|
|
pieces.push(leg)
|
|
|
|
// Foot: the flat base where the leg stands on the ground.
|
|
const spread = Math.sin(tilt) * legLength
|
|
const foot = boxGeometry(
|
|
[thickness * 2.1, thickness * 0.7, thickness * 2.1],
|
|
[0, 0, 0],
|
|
tint,
|
|
)
|
|
foot.rotateY(angle)
|
|
foot.translate(
|
|
Math.sin(angle) * spread,
|
|
attachY - Math.cos(tilt) * legLength + thickness * 0.35,
|
|
Math.cos(angle) * spread,
|
|
)
|
|
pieces.push(foot)
|
|
}
|
|
|
|
return mergeColoured(pieces)
|
|
}
|
|
|
|
/** Coal pieces scattered inside the bowl. The `hot` ones glow. */
|
|
function coalPieces(random: () => number, hot: boolean): BufferGeometry[] {
|
|
const tint = new Color()
|
|
const pieces: BufferGeometry[] = []
|
|
const floor = bowlTop() - bowlDepth() + config.bowlRadius * 0.06
|
|
const count = hot ? 4 : 7
|
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
const angle = random() * Math.PI * 2
|
|
const radius = Math.sqrt(random()) * config.bowlRadius * 0.62
|
|
const size = config.bowlRadius * (0.13 + random() * 0.1)
|
|
tint.copy(hot ? MEDIEVAL_PALETTE.charHot : MEDIEVAL_PALETTE.char)
|
|
tint.offsetHSL(jitter(random, 0.02), jitter(random, 0.08), jitter(random, hot ? 0.09 : 0.05))
|
|
|
|
const coal = boxGeometry(
|
|
[size, size * 0.62, size * 0.86],
|
|
[0, 0, 0],
|
|
tint,
|
|
)
|
|
coal.rotateY(random() * Math.PI)
|
|
coal.rotateX(jitter(random, 0.25))
|
|
coal.translate(
|
|
Math.sin(angle) * radius,
|
|
floor + size * 0.31 + (hot ? size * 0.12 : 0),
|
|
Math.cos(angle) * radius,
|
|
)
|
|
pieces.push(coal)
|
|
}
|
|
|
|
return pieces
|
|
}
|
|
|
|
/**
|
|
* Flame blades. Each one sits in its own Group, because update() will scale
|
|
* and rotate them one by one — a single merged geometry could not move.
|
|
*/
|
|
function buildFlame(random: () => number, target: Group): { geometries: BufferGeometry[]; blades: Blade[] } {
|
|
const geometries: BufferGeometry[] = []
|
|
const madeBlades: Blade[] = []
|
|
const base = bowlTop() - bowlDepth() * 0.35
|
|
const material = resolve('ember')
|
|
const hot = new Color()
|
|
const tip = new Color()
|
|
|
|
for (let i = 0; i < config.flameCount; i += 1) {
|
|
const angle = (i / Math.max(1, config.flameCount)) * Math.PI * 2 + jitter(random, 0.4)
|
|
const radius = config.bowlRadius * (0.1 + random() * 0.42)
|
|
const reach = config.bowlRadius * (0.85 + random() * 1.15)
|
|
const width = config.bowlRadius * (0.16 + random() * 0.13)
|
|
|
|
hot.copy(MEDIEVAL_PALETTE.ember).offsetHSL(0, jitter(random, 0.04), jitter(random, 0.05))
|
|
tip.copy(MEDIEVAL_PALETTE.emberTip).offsetHSL(jitter(random, 0.02), 0, jitter(random, 0.05))
|
|
|
|
// Tip radius zero: a cone. The colour shifts from base to tip, because the
|
|
// bottom of a flame and its tip are not the same colour.
|
|
const blade = prismGeometry(
|
|
width, 0, reach, 5,
|
|
[0, reach / 2, 0], hot,
|
|
{ capBottom: true, capTop: false, colourTop: tip },
|
|
)
|
|
geometries.push(blade)
|
|
|
|
const holder = new Group()
|
|
holder.name = `iron-brazier/flame-${i}`
|
|
holder.position.set(Math.sin(angle) * radius, base, Math.cos(angle) * radius)
|
|
const mesh = new Mesh(blade, material)
|
|
mesh.name = `iron-brazier/flame-${i}/blade`
|
|
mesh.userData.vibe3d = { model: '@medieval-kit/iron-brazier', part: 'flame', materialSlot: 'ember' }
|
|
holder.add(mesh)
|
|
target.add(holder)
|
|
|
|
madeBlades.push({
|
|
mesh: holder,
|
|
phase: random() * Math.PI * 2,
|
|
speed: 2.6 + random() * 2.9,
|
|
reach,
|
|
})
|
|
}
|
|
|
|
return { geometries, blades: madeBlades }
|
|
}
|
|
|
|
function attach(target: Group, geometry: BufferGeometry, part: string, slot: Slot): void {
|
|
owned.push(geometry)
|
|
const mesh = new Mesh(geometry, resolve(slot))
|
|
mesh.name = `iron-brazier/${part}`
|
|
mesh.userData.vibe3d = { model: '@medieval-kit/iron-brazier', part, materialSlot: slot }
|
|
target.add(mesh)
|
|
}
|
|
|
|
function build(): void {
|
|
for (const geometry of owned) geometry.dispose()
|
|
owned = []
|
|
blades = []
|
|
// reset() only replaces the generated content; the anchors and whatever is
|
|
// attached to them (the light included) stay in place.
|
|
const random = createRandom(config.seed)
|
|
attach(bowl.reset(), buildBowl(random), 'bowl', 'iron')
|
|
attach(legs.reset(), buildLegs(random), 'legs', 'iron')
|
|
attach(coals.reset(), mergeColoured(coalPieces(random, false)), 'coals', 'char')
|
|
|
|
const flameTarget = flame.reset()
|
|
const glowing = coalPieces(random, true)
|
|
const built = buildFlame(random, flameTarget)
|
|
owned.push(...built.geometries)
|
|
blades = built.blades
|
|
|
|
const hotCoals = mergeColoured(glowing)
|
|
owned.push(hotCoals)
|
|
const hotMesh = new Mesh(hotCoals, resolve('ember'))
|
|
hotMesh.name = 'iron-brazier/hot-coals'
|
|
hotMesh.userData.vibe3d = { model: '@medieval-kit/iron-brazier', part: 'flame', materialSlot: 'ember' }
|
|
flameTarget.add(hotMesh)
|
|
|
|
applyLit()
|
|
}
|
|
|
|
function applyLit(): void {
|
|
// The generated flame content is in content, the light on the anchor. Both go out.
|
|
flame.content.visible = lit
|
|
light.visible = lit
|
|
light.position.y = bowlTop()
|
|
if (!lit) light.intensity = 0
|
|
}
|
|
|
|
build()
|
|
|
|
const materials: MaterialBindings = {
|
|
get: (slot) => (SLOTS.includes(slot as Slot) ? resolve(slot as Slot) : undefined),
|
|
override: (slot, material) => {
|
|
if (!SLOTS.includes(slot as Slot)) return
|
|
overridesBySlot.set(slot as Slot, material)
|
|
build()
|
|
},
|
|
reset: (slot) => {
|
|
if (!overridesBySlot.delete(slot as Slot)) return
|
|
build()
|
|
},
|
|
}
|
|
|
|
return {
|
|
root,
|
|
parts: { bowl, legs, coals, flame },
|
|
actions: {
|
|
setLit: (next: boolean) => {
|
|
if (next === lit) return
|
|
lit = next
|
|
applyLit()
|
|
},
|
|
isLit: () => lit,
|
|
},
|
|
materials,
|
|
getConfig: () => config,
|
|
configure: (patch): ConfigureResult => {
|
|
const next = { ...config, ...patch }
|
|
const changed = (Object.keys(next) as Array<keyof IronBrazierConfig>)
|
|
.some((keyName) => next[keyName] !== config[keyName])
|
|
if (!changed) return { rebuilt: false }
|
|
config = next
|
|
build()
|
|
return { rebuilt: true }
|
|
},
|
|
update: (deltaSeconds: number) => {
|
|
if (!lit) return
|
|
// Clamp the step so the flame does not shoot up when frames are dropped.
|
|
elapsed += Math.min(Math.max(deltaSeconds, 0), 0.05)
|
|
|
|
let brightness = 0
|
|
for (const blade of blades) {
|
|
// The sum of two different frequencies: a single sine is too regular and
|
|
// gives a "breathing" rhythm; fire does not burn like that.
|
|
const wave =
|
|
Math.sin(elapsed * blade.speed + blade.phase) * 0.6 +
|
|
Math.sin(elapsed * blade.speed * 2.7 + blade.phase * 1.9) * 0.4
|
|
blade.mesh.scale.y = 1 + wave * 0.22
|
|
blade.mesh.scale.x = 1 - wave * 0.07
|
|
blade.mesh.scale.z = blade.mesh.scale.x
|
|
blade.mesh.rotation.z = wave * 0.09
|
|
brightness += wave
|
|
}
|
|
|
|
const average = blades.length > 0 ? brightness / blades.length : 0
|
|
light.intensity = 2.4 + average * 0.9
|
|
},
|
|
dispose: () => {
|
|
for (const geometry of owned) geometry.dispose()
|
|
owned = []
|
|
blades = []
|
|
bowl.reset()
|
|
legs.reset()
|
|
coals.reset()
|
|
flame.reset()
|
|
scope.dispose()
|
|
},
|
|
}
|
|
}
|