medieval-kit/my-registry/models/core/kit.ts
CRT 50c7fc60b9 Translate the whole repository to English
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.
2026-08-21 14:35:13 +07:00

315 lines
12 KiB
TypeScript

import { Group, Mesh, type BufferGeometry, type Material } from 'three'
import type { ConfigureResult, MaterialBindings, ModelInstance, PartHandle } from '@vibe3d/model.ts'
import { ResourceScope } from '@vibe3d/ownership.ts'
import type { Vec3 } from './geometry.ts'
import { mottleGeometry } from './geometry.ts'
import { bakeOcclusion, type OcclusionOptions } from './occlusion.ts'
import { createPart, type PartSlot } from './parts.ts'
import { createRandom } from './random.ts'
import { createMedievalMaterials, type MedievalSlot, type SlotMaterial } from './materials.ts'
/**
* Kit model scaffold.
*
* Every model has to set up the same contract: resource ownership, material
* resolution and overrides, fixed anchor + replaceable content, a configure()
* that does not break identity, an idempotent dispose(). Writing that by hand
* in every model means ~80 lines of repetition plus a chance to get it wrong
* on each repetition — and indeed I got the anchor/content split wrong in the
* first three models at once.
*
* From here on, writing a model is just producing geometry: `build` returns a
* part name → geometry mapping, this function handles the rest.
*/
/** The body of a part that uses a single material slot. */
export interface PartBody<S extends string> {
readonly slot: S
readonly geometry: BufferGeometry
}
export interface BuiltPart<S extends string> extends PartBody<S> {
/**
* Extra bodies belonging to the same part that use a DIFFERENT slot.
*
* Parts are sibling children of the root, so when one moves the others
* cannot follow it. A chest's lid is oak boards and iron bands and the
* lock's hasp all at once, and the three MUST rotate together — if they were
* separate parts, the bands would hang in mid-air as the lid opened.
*
* So this field does not preserve the assumption "one part = one mesh", it
* preserves the principle "one part = one meaning". The meaning is not
* split, only the material is.
*/
readonly extras?: readonly PartBody<S>[]
/**
* The part's own centre of rotation (in model space).
*
* When given, the anchor is positioned here and the geometry is assumed to
* have been written RELATIVE TO THIS POINT. This is the only thing needed
* for a chest lid to rotate around its hinge: the lid geometry is produced
* at the hinge origin, the anchor is moved to the hinge, and
* `anchor.rotation.x` now opens the lid.
*
* Objects the consumer attached to the anchor join this rotation too — a
* candle placed on top of the lid rises with the lid. That is the correct
* behaviour.
*/
readonly origin?: Vec3
}
export interface BuildContext<C, S extends MedievalSlot> {
readonly config: Readonly<C>
/** Seed-bound deterministic randomness. Starts over on every rebuild. */
readonly random: () => number
/** The slot's current material (the override, if there is one). */
resolve(slot: S): Material
/** The slot's default material — even if it has been overridden. */
readonly defaults: Pick<SlotMaterial, S>
}
/**
* The runtime context handed to actions and to `update`.
*
* It gives access to the PARTS, not to the geometry: an action must not
* trigger a rebuild, it should only move something in the scene graph. Opening
* the lid does not change the model's identity, so it is not `configure()`'s
* job.
*/
export interface RuntimeContext<C, P extends string> {
readonly parts: Record<P, PartHandle<Group>>
getConfig(): Readonly<C>
}
export interface KitModelOptions<
C extends { seed: number },
S extends MedievalSlot,
P extends string,
A extends object = Record<string, never>,
> {
/** Registry item name; used in mesh names and in userData. */
readonly id: string
readonly defaults: C
readonly slots: readonly S[]
/** Part name → geometry. `undefined` means absent in that configuration. */
build(context: BuildContext<C, S>): Record<P, BuiltPart<S> | undefined>
/**
* Ambient occlusion to bake into the vertex colours. `false` turns it off.
*
* All parts are evaluated TOGETHER: the place where a board darkens is the
* surface of the neighbouring post, not its own surface.
*/
readonly occlusion?: OcclusionOptions | false
/**
* Surface mottle written into the vertex colours. `false` turns it off.
*
* The amount is scaled BY SLOT (see the table below): burnished steel is not
* mottled, straw is mottled badly. The model does not have to do anything
* else.
*/
readonly mottle?: { readonly amount?: number; readonly cell?: number } | false
/**
* The model's typed actions. Set up once, after the first build — so state
* held in the closure (is the lid open, which flame phase) survives a
* rebuild.
*/
actions?(context: RuntimeContext<C, P>): A
/** Called each frame. If the consumer never calls it, the model stays fully static. */
update?(deltaSeconds: number, context: RuntimeContext<C, P>): void
}
/**
* Mottle multiplier per slot.
*
* The rule comes from the physics of the material: the more polished a surface
* is, the more it becomes a SINGLE COLOUR, because the light reaching the eye
* comes from the reflection rather than from the surface's own pigment. For
* straw and cloth the exact opposite holds.
*
* Tying this to the slot is better than tuning it separately in every model:
* within the kit the same material looks the same everywhere.
*/
const MOTTLE_BY_SLOT: Readonly<Record<MedievalSlot, number>> = {
straw: 1.35,
cloth: 1.15,
oak: 1,
char: 0.85,
leather: 0.7,
produce: 0.6,
iron: 0.5,
brass: 0.35,
steel: 0.22,
glass: 0.15,
ember: 0, // a flame is not mottled: its colour is already the final colour
}
export function createKitModel<
C extends { seed: number },
S extends MedievalSlot,
P extends string,
A extends object = Record<string, never>,
>(
options: KitModelOptions<C, S, P, A>,
overrides: Partial<C> = {},
): ModelInstance<C, Record<P, PartHandle<Group>>, A> {
let config: C = { ...options.defaults, ...overrides }
// The resources the model owns. Materials handed in by the consumer never
// enter here, so dispose() does not touch them.
const scope = new ResourceScope()
const defaults = createMedievalMaterials(scope, options.slots)
const overridesBySlot = new Map<S, Material>()
const resolve = (slot: S): Material => overridesBySlot.get(slot) ?? defaults[slot]
const root = new Group()
root.name = options.id
const parts = {} as Record<P, PartSlot>
let owned: BufferGeometry[] = []
function build(): void {
for (const geometry of owned) geometry.dispose()
owned = []
const produced = options.build({
config,
random: createRandom(config.seed),
resolve,
defaults,
})
const built = Object.values(produced)
.filter((part): part is BuiltPart<S> => part !== undefined)
const geometriesOf = (part: BuiltPart<S>): BufferGeometry[] =>
[part, ...(part.extras ?? [])]
// A flame neither TAKES shadow nor CASTS one. `ember` is an unlit
// material, so its vertex colour is the final colour going straight to
// the screen — if occlusion darkened it, the flame would go out. The
// rule is tied to the slot itself, not to a per-model flag: it must
// not be possible to forget it.
.filter((body) => body.slot !== 'ember')
.map((body) => body.geometry)
if (options.occlusion !== false) {
// Occlusion has to be computed in ASSEMBLY space: a lid written at its
// own origin looks as though it sits inside the body rather than beside
// it, and darkens the wrong places. So everything is first moved into
// place, baked, then moved back — the resulting geometries stay
// hinge-local.
const moved = built.filter((part) => part.origin !== undefined)
for (const part of moved) {
const [x, y, z] = part.origin!
for (const geometry of geometriesOf(part)) geometry.translate(x, y, z)
}
bakeOcclusion(built.flatMap(geometriesOf), options.occlusion ?? {})
for (const part of moved) {
const [x, y, z] = part.origin!
for (const geometry of geometriesOf(part)) geometry.translate(-x, -y, -z)
}
}
if (options.mottle !== false) {
const amount = options.mottle?.amount ?? 0.125
// The mottle size is derived from the model's scale, but WITH ABSOLUTE
// BOUNDS.
//
// The bounds were added later and closed a real bug: with scale used on
// its own, a 4.89 m fence got a 0.27 m cell. The fence's post is 0.09 m
// — so the entire post fell into ONE cell and came out completely flat.
// The model paid the cost of the texture system and got nothing back.
//
// The truth is this: wood grain mottle is 2–8 cm and has nothing to do
// with the size of the object. A fence post and a tankard stave come out
// of the same tree.
const extent = built.reduce((largest, part) => {
part.geometry.computeBoundingBox()
const box = part.geometry.boundingBox
if (!box) return largest
return Math.max(largest, box.max.x - box.min.x, box.max.y - box.min.y, box.max.z - box.min.z)
}, 0)
const cell = options.mottle?.cell ?? Math.min(0.08, Math.max(0.015, extent * 0.055))
for (const part of built) {
for (const body of [part, ...(part.extras ?? [])]) {
mottleGeometry(body.geometry, amount * MOTTLE_BY_SLOT[body.slot], { cell, salt: 3 })
}
}
}
for (const [name, part] of Object.entries(produced) as Array<[P, BuiltPart<S> | undefined]>) {
// The anchor is created on first setup and NEVER changes again; only its
// content is renewed. That is why objects the consumer attached to the
// anchor survive a rebuild.
let slot = parts[name]
if (!slot) {
slot = createPart(`${options.id}/${name}`)
parts[name] = slot
root.add(slot.anchor)
}
const target = slot.reset()
if (!part) continue
// The position is updated but the ROTATION is not: the angle an action
// moved has to survive a rebuild, otherwise the chest's lid slams shut
// every time `configure()` is called.
if (part.origin) slot.anchor.position.set(...part.origin)
for (const body of [part, ...(part.extras ?? [])]) {
owned.push(body.geometry)
const mesh = new Mesh(body.geometry, resolve(body.slot))
mesh.name = `${options.id}/${name}`
mesh.userData.vibe3d = {
model: `@medieval-kit/${options.id}`,
part: name,
materialSlot: body.slot,
}
target.add(mesh)
}
}
}
build()
const runtime: RuntimeContext<C, P> = {
parts: parts as unknown as Record<P, PartHandle<Group>>,
getConfig: () => config,
}
const actions = (options.actions?.(runtime) ?? {}) as A
const materials: MaterialBindings = {
get: (slot) => (options.slots.includes(slot as S) ? resolve(slot as S) : undefined),
override: (slot, material) => {
if (!options.slots.includes(slot as S)) return
overridesBySlot.set(slot as S, material)
build()
},
reset: (slot) => {
if (!overridesBySlot.delete(slot as S)) return
build()
},
}
return {
root,
parts: parts as unknown as Record<P, PartHandle<Group>>,
actions,
materials,
getConfig: () => config,
configure: (patch): ConfigureResult => {
const next = { ...config, ...patch }
const changed = (Object.keys(next) as Array<keyof C>).some((key) => next[key] !== config[key])
if (!changed) return { rebuilt: false }
config = next
build()
return { rebuilt: true }
},
update: (deltaSeconds) => options.update?.(deltaSeconds, runtime),
dispose: () => {
for (const geometry of owned) geometry.dispose()
owned = []
for (const part of Object.values(parts) as PartSlot[]) part.reset()
scope.dispose()
},
}
}