medieval-kit/my-registry/drafts/iron-brazier/model.ts
CRT a3300ff233 Kit'i 26 modele çıkar, doğrulamayı ve inceleyiciyi genişlet
Modeller (13 → 26): chest, bench, torch, lantern, bell, sign, hay bale,
sack, broom, tankard, book, phial, coin pouch. Kürek, çapa ve yaba
render'lara bakılarak yeniden yazıldı.

core:
- createKitModel artık tipli `actions` ve `update(dt)` taşıyor
- BuiltPart.extras: bir parça birden çok materyal yuvası kullanabiliyor
  (sandığın kapağı = meşe tahta + demir kayış, birlikte dönmek zorunda)
- BuiltPart.origin: parça başına dönme merkezi (menteşe, sarkaç)
- bendGeometry / roughenGeometry / mottleGeometry
- 5 yeni materyal yuvası: steel, brass, straw, cloth, leather, glass
- bandGeometry({ inner }) — serbest halkalar kapalı katı olabilsin

my-registry/meta.ts: katalog metadata'sı artık build.ts ve viewer için
tek kaynak. Doğrulama modelle metadata'nın uyuştuğunu denetliyor ve bu
eklendiği gün dört modelde gerçek bir sapma yakaladı.

Doğrulama (~500 kontrol):
- metadata uyumu, eylem semantiği, kare hızı bağımsızlığı
- scripts/render.ts: tarayıcısız yazılım rasterleyici. Bütün doğrulama
  geometrikti; "bu kürek küreğe benzemiyor" diyebilmek için modele
  bakmak gerekiyordu
- scripts/verify-glb.ts: dışa aktar → geri oku → karşılaştır

GLB dışa aktarımı: viewer düğmesi ve toplu CLI aynı kodu kullanıyor.

Viewer:
- parça paneli (solo/izole) ve materyal yuvası paneli (canlı renk override)
- kit görünümü: 26 model tek sahnede, ölçek ve ton tutarlılığı için
- configure() sonrası gölge bayraklarının düşmesi düzeltildi
2026-08-21 09:44:58 +07:00

396 lines
13 KiB
TypeScript

/**
* @medieval-kit/iron-brazier
*
* Kitin ilk HAREKETLİ modeli. Fıçı ve sandık statik: kurulurlar ve öyle
* kalırlar. Mangal, vibe3d protokolünün onların hiç dokunmadığı iki parçasını
* çalıştırıyor:
*
* - tipli `actions` → yak / söndür
* - `update(dt)` → alev titremesi ve ışık dalgalanması
*
* Ayrım önemli: `configure()` topolojiyi yeniden kurar ve pahalıdır, kullanıcı
* ayarı içindir. Kare başına değişen her şey `update()` içinde olmalı.
*
* Model ayrıca bir PointLight taşıyor. Işık `flame` anchor'ına takılı, yani
* configure() geometriyi yeniden kursa bile ışık hayatta kalıyor.
*/
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 {
/** Ayak dâhil toplam yükseklik (metre). */
readonly height: number
/** Kâsenin üst ağız yarıçapı (metre). */
readonly bowlRadius: number
/** Kâse çevresindeki köşe sayısı. Düşük tut — lowpoly silueti bu belirler. */
readonly bowlSegments: number
/** Ayak sayısı. */
readonly legCount: number
/** Alev dili sayısı. 0 = alev yok, sadece kömür. */
readonly flameCount: number
/** Varyasyon tohumu. */
readonly seed: number
}
export const ironBrazierDefaults: IronBrazierConfig = {
height: 0.86,
bowlRadius: 0.26,
bowlSegments: 12,
legCount: 3,
flameCount: 5,
seed: 11,
}
export interface IronBrazierParts {
/** Kâse ve ağız çemberi. */
readonly bowl: PartHandle<Group>
/** Ayaklar ve pabuçlar. */
readonly legs: PartHandle<Group>
/** Sönmüş kömürler. */
readonly coals: PartHandle<Group>
/** Alev dilleri, kor kömürler ve ışık. Söndürülünce gizlenir. */
readonly flame: PartHandle<Group>
}
export interface IronBrazierActions {
/** Ateşi yakar ya da söndürür. */
setLit(lit: boolean): void
/** Şu anda yanıyor mu. */
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)
// Işık ANCHOR'a takılı, üretilen içeriğe değil. Anchor rebuild'de
// değişmediği için configure() ateşi söndürmüyor — tüketicinin kendi
// eklediği nesneler de aynı sebeple hayatta kalıyor.
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
/** Kâsenin üst ağzının dünya yüksekliği. */
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
// Dış kabuk ve iç kabuk aynı koni; içteki ters çevrilmiş, çünkü kâsenin
// içine bakıldığında yüzeyler bize dönük olmalı.
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 },
))
// Ağız çemberi dış ve iç kabuğun arasındaki boşluğu kapatır ve dövme bir
// dudak izlenimi verir.
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))
// Önce origin'in ALTINA sarkan bir çubuk kur, sonra dışa yatır, sonra
// yerine döndür. Sıra önemli: rotate her zaman origin etrafında döner.
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)
// Pabuç: ayağın yere bastığı yassı taban.
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)
}
/** Kâsenin içine dağılmış kömür parçaları. `hot` olanlar ışıldar. */
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
}
/**
* Alev dilleri. Her biri ayrı bir Group içinde, çünkü update() onları tek
* tek ölçekleyip döndürecek — birleştirilmiş tek geometri kıpırdayamazdı.
*/
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))
// Uç yarıçapı sıfır: koni. Renk dipten uca geçiş yapıyor, alevin dibi ile
// ucu aynı renk değildir.
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() sadece üretilmiş içeriği değiştirir; anchor'lar ve onlara
// takılanlar (ışık dâhil) yerinde kalır.
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 {
// Üretilen alev içeriği content'te; ışık anchor'da. İkisi de sönmeli.
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
// Kare atlamalarında alevin fırlamaması için adımı sınırla.
elapsed += Math.min(Math.max(deltaSeconds, 0), 0.05)
let brightness = 0
for (const blade of blades) {
// İki farklı frekansın toplamı: tek sinüs fazla düzenli, "nefes alan"
// bir ritim veriyor; ateş öyle yanmaz.
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()
},
}
}