medieval-kit/my-registry/models/linen-sack/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

143 lines
5.5 KiB
TypeScript

/**
* @medieval-kit/linen-sack
*
* Grain sack with its mouth tied off by a cord. Storeroom, mill, market stall,
* cart — one of the kit's most widely placeable pieces.
*
* What makes a sack a sack is that it TAKES THE SHAPE of what is inside it. So
* the body is not a cylinder: it spreads out at the bottom under the weight of
* the grain, bulges in the middle, and gathers towards the mouth. Then
* `roughenGeometry` breaks up the surface, because nothing about a full sack
* is flat.
*
* The bottom corners are a separate matter: a real sack pulls in at its four
* corners and those corners stick out like ears. Without them the model looked
* like a vase.
*/
import type { BufferGeometry } from 'three'
import {
bandGeometry,
boxGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
roughenGeometry,
type Level,
} from '../core/index.ts'
export interface LinenSackConfig {
/** Total height (metres). */
readonly height: number
/** Radius at the widest point (metres). */
readonly radius: number
/** How full it is. 1 = packed solid, 0.4 = half empty and slumped. */
readonly fill: number
/** Cloth left above the mouth, as a fraction of the height. */
readonly collar: number
/** Gathered ears at the bottom. */
readonly ears: number
readonly seed: number
}
export const linenSackDefaults: LinenSackConfig = {
height: 0.52,
radius: 0.16,
fill: 0.85,
collar: 0.14,
ears: 4,
seed: 53,
}
export type LinenSackParts = 'body' | 'collar' | 'cord'
export function createModel(overrides: Partial<LinenSackConfig> = {}) {
return createKitModel<LinenSackConfig, 'cloth', LinenSackParts>({
id: 'linen-sack',
defaults: linenSackDefaults,
slots: ['cloth'],
build: ({ config, random }) => {
const tint = createTinter(random)
const half = config.height / 2
const fill = Math.max(0.15, Math.min(1, config.fill))
// Where the neck sits: high on a full sack, low on an empty one.
const neckY = half - config.height * config.collar
const bodyTop = neckY - config.height * 0.06
// --- Body -----------------------------------------------------------
// The profile depends on the fill: an under-filled sack both drops and
// spreads sideways. Letting a single `fill` number change the whole
// silhouette means one model can produce both a full and a half-empty
// sack.
const wide = config.radius * (0.72 + fill * 0.36)
const profile: Level[] = [
{ y: -half, radius: wide * 0.72 },
{ y: -half + config.height * 0.05, radius: wide * 0.95 },
{ y: -half + config.height * 0.26 * fill, radius: wide },
{ y: -half + config.height * 0.52 * fill, radius: wide * 0.93 },
{ y: bodyTop - config.height * 0.16, radius: wide * 0.7 },
{ y: bodyTop, radius: config.radius * 0.34 },
]
const body = latheGeometry(profile, 9, [0, 0, 0], tint('cloth', -0.06, 1.3), {
colourTop: tint('cloth', 0.05, 1.3),
})
// Cloth is not rigid: here the surface break-up is the texture itself.
roughenGeometry(body, config.radius * 0.05, { salt: 21, scaleY: 0.7 })
const pieces: BufferGeometry[] = [body]
// --- Bottom ears ------------------------------------------------------
// A sack gathers at the seam and its corners jut outwards. Without them
// the cylinder comes out as a vase.
const ears = Math.max(0, Math.round(config.ears))
for (let i = 0; i < ears; i += 1) {
const angle = (i / ears) * Math.PI * 2 + jitter(random, 0.12)
const reach = wide * (0.3 + random() * 0.16)
const ear = boxGeometry(
[reach, config.height * 0.05, config.radius * 0.2],
[reach * 0.36, 0, 0], // keep the root INSIDE the body
tint('cloth', -0.1, 1.2),
)
// Orient first, then translate — the reverse order flings the ear off
// into orbit.
ear.rotateZ(-0.22 + jitter(random, 0.1))
ear.rotateY(-angle)
ear.translate(
Math.sin(angle) * wide * 0.6,
-half + config.height * 0.035,
Math.cos(angle) * wide * 0.6,
)
pieces.push(ear)
}
// --- Mouth allowance ---------------------------------------------------
// The cloth left ABOVE the tie, flopping outwards. This is what separates
// a sack from a sealed bag: a tied mouth always has some cloth to spare.
const collarPieces: BufferGeometry[] = []
const flare: Level[] = [
{ y: neckY - config.height * 0.02, radius: config.radius * 0.3 },
{ y: neckY + config.height * 0.03, radius: config.radius * 0.27 },
{ y: half - config.height * 0.02, radius: config.radius * 0.46 },
{ y: half, radius: config.radius * 0.4 },
]
const collar = latheGeometry(flare, 9, [0, 0, 0], tint('cloth', 0.02, 1.3), {
colourTop: tint('cloth', 0.1, 1.3),
capTop: true,
})
roughenGeometry(collar, config.radius * 0.035, { salt: 22, scaleY: 0.6 })
collarPieces.push(collar)
// --- Cord ---------------------------------------------------------------
const cord = bandGeometry(config.radius * 0.29, neckY, config.height * 0.035,
config.radius * 0.045, 9, tint('cloth', -0.24, 0.8), { inner: true })
return {
body: { slot: 'cloth' as const, geometry: mergeColoured(pieces) },
collar: { slot: 'cloth' as const, geometry: mergeColoured(collarPieces) },
cord: { slot: 'cloth' as const, geometry: mergeColoured([cord]) },
}
},
}, overrides)
}