Two rules were missing, and each one had been quietly failing for a while. The first is structural. Every check in this repo asked about surfaces -- winding, coplanarity, triangle counts -- and none of them could see a piece of a model hanging in the air with nothing holding it up. A chest lid separated from its chest passed everything we had. scripts/support.ts voxelises the model, finds the connected components of occupied space and requires each one to reach the floor; scripts/audit.ts runs that across default, min, max, both sweep ends, actions and the midpoint of a morph, so a variation cannot break what the default gets right. That audit started at 22 failing cases: the bell and the sign had no supporting structure at all and now have a frame and a post, the lantern was in four pieces, the sack's collar floated above its body, the basket's fruit hung level with the rim instead of resting on the base, and the ladder's rungs were sized by a formula that disagreed with where the rails actually were. It now reports 0. The second is that we had no target. The feedback loop was an offline render of our own output, which can only answer "is this broken", never "is this right". Given a reference photograph of a cask, five defects were obvious in seconds that three rounds of self-review had missed: four hoops where there should be six, no rivets at all, a chime cut so shallow it did not read, and a stave count low enough to leave a visibly polygonal silhouette. All four are fixed here. Two things the rivets taught us, both kept as comments rather than as scars: a stud that tapers outward tilts its side normals back towards the axis, which the radial check correctly reports as reversed winding -- a real rivet head flares, so the honest shape is also the passing one. And once seated deep enough to look right, the stud's buried inner cap falls inside the shell the check inspects, so it is not generated at all, exactly as bandGeometry already declines to build the inside of a hoop. 27 models, 16973 triangles, all checks pass, 0 floating cases.
147 lines
5.8 KiB
TypeScript
147 lines
5.8 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[] = [
|
|
// Starts BELOW the body's top, not above it. It used to begin at
|
|
// neckY − 0.02·h while the body ended at neckY − 0.06·h, so the collar
|
|
// and the cord were a separate island hanging over the sack.
|
|
{ y: bodyTop - config.height * 0.03, radius: config.radius * 0.36 },
|
|
{ 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)
|
|
}
|