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
This commit is contained in:
CRTkafa 2026-08-21 09:44:58 +07:00
parent 98cfbab180
commit a3300ff233
85 changed files with 14473 additions and 1915 deletions

View file

@ -0,0 +1,191 @@
/**
* @medieval-kit/pitch-torch
*
* Ziftli meşale: budaklı bir sopa, ucuna sarılmış zift emdirilmiş bez, üstünde
* alev. Dönemin aydınlatması tam olarak bu — mum pahalıydı, meşale bedavaydı.
*
* Kitin ilk ANİMASYONLU modeli. Alev `update()` ile titriyor ve titremenin
* kaynağı rastgelelik DEĞİL, geçen sürenin sinüs toplamı. Bunun üç sebebi var:
*
* - Rastgelelik determinizmi bozar. Kitin her yerinde `Math.random()` yasak;
* alev de istisna olamaz, yoksa aynı tohumlu iki meşale ayrışır.
* - Uyumsuz frekanslı iki sinüs gözle "tekrar etmeyen" bir salınım veriyor.
* Tek sinüs metronom gibi atardı.
* - Tüketici `update()` çağırmazsa model tamamen durur. Kendi kendine dönen
* bir zamanlayıcı kurmak protokolün "tüketici döngüye sahiptir" ilkesini
* çiğnerdi.
*
* Alev ayrıca ışık YAYMAZ. Meşalenin sahneyi aydınlatması isteniyorsa tüketici
* `parts.flame.anchor`'a bir PointLight takar — modelin sahnenin ışık bütçesi
* hakkında varsayım yapmaya hakkı yok.
*/
import {
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
prismGeometry,
roughenGeometry,
type Level,
} from '../core/index.ts'
export interface PitchTorchConfig {
/** Toplam sap uzunluğu (metre). */
readonly length: number
/** Sap yarıçapı (metre). */
readonly radius: number
/** Bez sargının uzunluğu, sapın oranı olarak. */
readonly wrapLength: number
/** Alev yüksekliği, sargı uzunluğunun oranı olarak. */
readonly flameHeight: number
/** Titremenin genliği. 0 = sabit alev. */
readonly flicker: number
readonly seed: number
}
export const pitchTorchDefaults: PitchTorchConfig = {
length: 0.58,
radius: 0.019,
wrapLength: 0.3,
flameHeight: 1.15,
flicker: 1,
seed: 37,
}
export type PitchTorchParts = 'shaft' | 'wrap' | 'flame'
export interface PitchTorchActions {
/** Alevi yakar/söndürür. Sönükken `flame` parçası tamamen gizlenir. */
setLit(lit: boolean): void
isLit(): boolean
}
export function createModel(overrides: Partial<PitchTorchConfig> = {}) {
// Durum inşanın DIŞINDA: `configure()` meşaleyi söndürmemeli.
let lit = true
let elapsed = 0
return createKitModel<PitchTorchConfig, 'oak' | 'char' | 'ember', PitchTorchParts, PitchTorchActions>({
id: 'pitch-torch',
defaults: pitchTorchDefaults,
slots: ['oak', 'char', 'ember'],
build: ({ config, random }) => {
const tint = createTinter(random)
const half = config.length / 2
const wrapLength = config.length * config.wrapLength
const wrapBase = half - wrapLength
// --- Sap -----------------------------------------------------------
// Budaklı bir dal: yarıçapı boyunca dalgalanıyor. Düz bir silindir
// fabrikasyon görünürdü, oysa meşale ormandan kesilmiş bir çubuktur.
const knots = 6
const shaftProfile: Level[] = Array.from({ length: knots + 1 }, (_, i) => {
const t = i / knots
return {
y: -half + config.length * (1 - config.wrapLength) * t,
radius: config.radius * (1 + jitter(random, 0.16)) * (i === 0 ? 0.82 : 1),
}
})
const shaft = mergeColoured([latheGeometry(
shaftProfile, 6, [0, 0, 0], tint('oak', -0.08),
{ colourTop: tint('oak', -0.02) },
)])
// --- Sargı ---------------------------------------------------------
// Zift emdirilmiş bez: sapa göre kalın, uca doğru şişkin, tepesi düz.
// Kömür yuvası kullanılıyor çünkü zift kurumla kaplanır — meşe rengi
// burada yalan olurdu.
const wrapProfile: Level[] = [
{ y: wrapBase - wrapLength * 0.12, radius: config.radius * 1.15 },
{ y: wrapBase + wrapLength * 0.22, radius: config.radius * 2.35 },
{ y: wrapBase + wrapLength * 0.62, radius: config.radius * 2.5 },
{ y: half, radius: config.radius * 2.05 },
]
const wrap = mergeColoured([latheGeometry(
wrapProfile, 7, [0, 0, 0], tint('char', 0.06),
{ colourTop: tint('charHot', -0.12) },
)])
// --- Alev ----------------------------------------------------------
// Alev geometrisi KENDİ orijininde üretiliyor ve anchor sargının ucuna
// taşınıyor. Titreme anchor'ın ölçeğini oynattığı için bu şart: orijini
// dipte olmayan bir alev, ölçeklenince sargının içine gömülürdü.
const flameHeight = wrapLength * config.flameHeight
// Alev profili ikinci hâli. İlki tek eğriyle dipten uca inceliyordu ve
// render'da ROKET BURNU gibi duruyordu — pürüzsüz, simetrik, sivri.
// Alev öyle değil: dibi geniş ve şişkin, ortasında bir boğum, ucu ise
// sivri değil YIRTIK. Aşağıdaki profil o boğumu veriyor, `roughen` de
// simetriyi bozuyor.
const flameProfile: Level[] = [
{ y: 0, radius: config.radius * 2.05 },
{ y: flameHeight * 0.14, radius: config.radius * 2.75 },
{ y: flameHeight * 0.34, radius: config.radius * 2.15 },
{ y: flameHeight * 0.5, radius: config.radius * 2.4 },
{ y: flameHeight * 0.72, radius: config.radius * 1.35 },
{ y: flameHeight * 0.9, radius: config.radius * 0.8 },
{ y: flameHeight, radius: config.radius * 0.22 },
]
const outer = latheGeometry(flameProfile, 6, [0, 0, 0], tint('ember', 0.04, 0.4),
{ colourTop: tint('emberTip', 0, 0.4), capBottom: true })
roughenGeometry(outer, config.radius * 0.3, { salt: 5, scaleY: 1.6 })
const flame = mergeColoured([
outer,
// İç çekirdek: dıştakinden daha küçük ve daha beyaz. İki katman
// alevin derinliği olduğu izlenimini veriyor — tek koni yassı durur.
//
// Dibi KAPATILMIYOR: dış koninin tabanıyla aynı düzleme oturup
// titriyordu. Zaten dışın içinde kaldığı için görünmez, dolayısıyla
// kapak hem gereksiz hem zararlıydı.
prismGeometry(
config.radius * 1.25, config.radius * 0.1, flameHeight * 0.48, 5,
[0, flameHeight * 0.3, 0], tint('ember', 0.22, 0.3),
{ capBottom: false },
),
])
return {
shaft: { slot: 'oak' as const, geometry: shaft },
wrap: { slot: 'char' as const, geometry: wrap },
flame: {
slot: 'ember' as const,
geometry: flame,
origin: [0, half - wrapLength * 0.12, 0] as const,
},
}
},
actions: ({ parts }) => {
parts.flame.anchor.visible = lit
return {
setLit: (next) => { lit = next; parts.flame.anchor.visible = next },
isLit: () => lit,
}
},
update: (dt, { parts, getConfig }) => {
// Sönük meşale ilerlemez: yeniden yakıldığında alev kaldığı yerden
// değil, aynı faz noktasından devam etsin diye. Aksi hâlde uzun süre
// sönük kalan bir meşale yanınca rastgele bir boyda başlardı.
if (!lit) return
const config = getConfig()
const amount = config.flicker
if (amount === 0) return
elapsed += Math.max(0, dt)
// Uyumsuz frekanslar: 11.3 ve 19.7 birbirinin katı değil, dolayısıyla
// toplamın periyodu gözle yakalanamayacak kadar uzun.
const pulse = Math.sin(elapsed * 11.3) * 0.09 + Math.sin(elapsed * 19.7 + 1.4) * 0.055
const sway = Math.sin(elapsed * 7.1 + 0.6) * 0.05 + Math.sin(elapsed * 13.9) * 0.028
const anchor = parts.flame.anchor
// Boy ve en TERS yönde oynuyor: alev uzarken incelir. Aynı yönde
// ölçeklemek alevi nefes alan bir balon gibi gösteriyordu.
anchor.scale.set(1 - pulse * 0.55 * amount, 1 + pulse * amount, 1 - pulse * 0.55 * amount)
anchor.rotation.z = sway * amount
anchor.rotation.x = Math.sin(elapsed * 9.4 + 2.1) * 0.038 * amount
},
}, overrides)
}