medieval-kit/my-registry/models/iron-anvil/model.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

146 lines
5.1 KiB
TypeScript

/**
* @medieval-kit/iron-anvil
*
* An anvil's silhouette is the forging process itself: a wide base, a narrow
* waist, a broad face on top, a horn tapering off to one side. The geometry is
* almost entirely boxes — what gives it character is the proportions.
*
* The kit's first "place-making" piece: on its own it suggests a smith's corner.
*/
import { Color } from 'three'
import {
chamferedBoxGeometry,
createKitModel,
jitter,
MEDIEVAL_PALETTE,
mergeColoured,
steelTint,
} from '../core/index.ts'
export interface IronAnvilConfig {
/** Total height (metres). With a real anvil stump it comes to ~0.75 m. */
readonly height: number
/** Length of the top face (metres). */
readonly faceLength: number
/** Width of the top face (metres). */
readonly faceWidth: number
/** How far the horn reaches past the face, as a ratio of the face length. */
readonly hornReach: number
readonly seed: number
}
export const ironAnvilDefaults: IronAnvilConfig = {
height: 0.34,
faceLength: 0.46,
faceWidth: 0.13,
hornReach: 0.52,
seed: 9,
}
export type IronAnvilParts = 'base' | 'waist' | 'body' | 'face' | 'horn'
export function createModel(overrides: Partial<IronAnvilConfig> = {}) {
return createKitModel<IronAnvilConfig, 'iron' | 'steel', IronAnvilParts>({
id: 'iron-anvil',
defaults: ironAnvilDefaults,
slots: ['iron', 'steel'],
build: ({ config, random }) => {
const tint = new Color()
const shade = (amount = 0.05): Color => {
tint.copy(MEDIEVAL_PALETTE.iron)
tint.offsetHSL(0, jitter(random, 0.02), jitter(random, amount))
return tint
}
const half = config.height / 2
const baseHeight = config.height * 0.2
const bodyHeight = config.height * 0.3
const waistHeight = config.height - baseHeight - bodyHeight
// Base: the widest piece, splaying out slightly toward the bottom.
const baseLength = config.faceLength * 0.58
const baseWidth = config.faceWidth * 1.5
const base = mergeColoured([
chamferedBoxGeometry(
[baseLength * 1.08, baseWidth * 1.08],
[baseLength, baseWidth],
baseHeight,
config.faceWidth * 0.06,
[0, -half + baseHeight / 2, 0],
shade(),
),
])
// Waist: the narrow throat that makes an anvil an anvil.
const waist = chamferedBoxGeometry(
[baseLength * 0.5, baseWidth * 0.46],
[baseLength * 0.5, baseWidth * 0.46],
waistHeight,
config.faceWidth * 0.06,
[0, -half + baseHeight + waistHeight / 2, 0],
shade(),
)
// Body: widens upward from the waist and carries the steel plate on top.
//
// An anvil really is made of two metals: a hard steel plate is welded on
// top of the wrought iron body. The hammer always lands on that plate, so
// over the years it polishes like a mirror; the body stays oxidised and
// matte. In the model we give this as a separate part + separate material
// slot, because the difference is not in colour but in ROUGHNESS, and
// vertex colour cannot carry roughness.
const plateHeight = config.height * 0.055
const bodyY = half - bodyHeight / 2
const body = mergeColoured([
chamferedBoxGeometry(
[baseLength * 0.62, baseWidth * 0.52],
[config.faceLength * 0.62, config.faceWidth],
bodyHeight,
config.faceWidth * 0.06,
[0, bodyY, 0],
shade(0.06),
),
])
// The plate is SUNK into the body: it goes in by half its own thickness
// and also overhangs a touch on all four sides. Together those guarantee
// that no pair of faces ends up coplanar (the z-fighting rule).
const face = mergeColoured([
chamferedBoxGeometry(
[config.faceLength * 0.628, config.faceWidth * 1.012],
[config.faceLength * 0.622, config.faceWidth * 1.006],
plateHeight,
config.faceWidth * 0.035,
[0, half - plateHeight * 0.32, 0],
steelTint(random),
),
])
// Horn: a cone leaving the body horizontally, tapering to a point. Making
// a vertical tapering box and turning it a quarter turn about the Z axis
// is less code than writing a separate primitive, and the same result.
const reach = config.faceLength * config.hornReach
const horn = chamferedBoxGeometry(
[config.faceWidth * 0.92, config.faceWidth * 0.86],
[config.faceWidth * 0.1, config.faceWidth * 0.1],
reach,
config.faceWidth * 0.06,
[0, 0, 0],
shade(0.04),
)
horn.rotateZ(-Math.PI / 2)
// Reach INTO the body: keep the end face inside the solid piece so that no
// surface ends up coplanar with the body (the z-fighting rule).
horn.translate(config.faceLength * 0.31 + reach / 2 - config.faceWidth * 0.35, bodyY + bodyHeight * 0.12, 0)
return {
base: { slot: 'iron', geometry: base },
waist: { slot: 'iron', geometry: waist },
body: { slot: 'iron', geometry: body },
face: { slot: 'steel', geometry: face },
horn: { slot: 'iron', geometry: horn },
}
},
}, overrides)
}