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.
143 lines
5.5 KiB
TypeScript
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)
|
|
}
|