317 lines
15 KiB
TypeScript
317 lines
15 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.
|
|
* Five earlier versions failed on exactly this:
|
|
*
|
|
* 1. The first profile put its widest point a quarter of the way up and
|
|
* narrowed to the neck — a vase.
|
|
* 2. The second fixed the body but kept a straight parallel-sided neck with a
|
|
* flat cylindrical cap above the cord: a stoppered jug.
|
|
* 3. The third grew a gathered fan, but its points were matchstick-thin and
|
|
* near vertical (a crown), and the cord recessed into a notch and vanished
|
|
* from the silhouette.
|
|
* 4. The fourth put the widest ring AT THE FLOOR and tapered in a straight
|
|
* line all the way to the tie: a truncated cone. Its tie was a tall dark
|
|
* cylinder, its petals flared to near horizontal over an exposed interior
|
|
* floor disc, and its bottom ears dipped through the ground plane.
|
|
* 5. The fifth ("egg on a pillow") scored 66: still a squat urn. Widest ring
|
|
* at mid-height and nearly as wide as tall, a hard step from the dome into
|
|
* a bottle neck, a low outward skirt ring that read as a turned plinth,
|
|
* tapered-box bottom ears that read as free-floating tabs, a dark sliver
|
|
* at 12 o'clock where the tail showed through the gaps between the three
|
|
* cord wraps, petal bases visibly clear of the tuft, and one flat facet on
|
|
* the left flank from heavy roughen on 14 segments.
|
|
*
|
|
* So, this pass, straight from the critique of v5:
|
|
*
|
|
* - PROPORTION: the body is distinctly taller than wide, about 1:1.5 width to
|
|
* height, with the widest ring at 35% of the body height and the two rings
|
|
* below it pulled in only slightly so the lower body stays heavy.
|
|
* - NO NECK: the same lathe climbs continuously from the shoulder to the tie
|
|
* through a run of gather rings (0.72 → 0.955 of the body, radii falling
|
|
* 0.72W → 0.17W). On top of that, a displacement pass scales vertices
|
|
* radially by cos(8·angle + phase) with the phase held constant up the
|
|
* stack, ramping in above 60% of the body height, so alternate vertices
|
|
* pull in and out and the creases converge on the twine. The amplitude
|
|
* eases off again just under the cord so the pleated cloth (plus roughen)
|
|
* stays INSIDE the twine wraps.
|
|
* - PILLOW BASE: no skirt ring, no rim. The lowest ring is wider than the one
|
|
* above it and the bottom sits flat on the floor. The corner ears are NOT
|
|
* separate boxes: the same displacement pass pushes the bottom 12% of the
|
|
* body radially outward (and a few millimetres down, clamped at the floor)
|
|
* in `ears` sharpened cosine lobes, so the corners grow out of the cloth
|
|
* and share edges with the side faces by construction. `ears` is the lobe
|
|
* count of that displacement, not a mesh-piece count.
|
|
* - The displacement is a pure function of vertex POSITION (like roughen), so
|
|
* the co-located copies in this non-indexed geometry all move together and
|
|
* the surface stays closed.
|
|
* - The three twine wraps are taller than their spacing so they overlap and
|
|
* there is no see-through gap between turns (v5's 12 o'clock sliver was the
|
|
* dark tail visible through those gaps). The tail still roots inside the
|
|
* cinched cloth and drapes down the gather at the gather's own slope.
|
|
* - Petal roots sit lower and closer to the axis (0.35·cinchR at 1.5% of the
|
|
* height above the cord) so they emerge from the tuft, never float over it.
|
|
* - 16 lathe segments and milder roughen, against v5's single flat facet.
|
|
*/
|
|
import type { BufferGeometry } from 'three'
|
|
|
|
import {
|
|
bandGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
latheGeometry,
|
|
mergeColoured,
|
|
roughenGeometry,
|
|
taperedBoxGeometry,
|
|
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,
|
|
// Enough neck below the fan for the tie to actually show.
|
|
collar: 0.12,
|
|
// Two opposing corners, like the reference: the seam gathers at the ends of
|
|
// the flat-sewn bottom, not all the way round.
|
|
ears: 2,
|
|
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)
|
|
// Floored: h and radius are divisors downstream, and a zero here would
|
|
// NaN every vertex (and, through the occlusion bake, every colour).
|
|
const h = Math.max(0.05, config.height)
|
|
const radius = Math.max(0.02, config.radius)
|
|
const half = h / 2
|
|
const fill = Math.max(0.15, Math.min(1, config.fill))
|
|
const collarF = Math.max(0.05, Math.min(0.4, config.collar))
|
|
// Where the cord sits: high on a full sack, low on an empty one.
|
|
const neckY = half - h * collarF
|
|
// Body height, floor to cord, and a helper to place rings on it.
|
|
const bodyH = neckY + half
|
|
const by = (f: number): number => -half + bodyH * f
|
|
// The tie pinches the gathered cloth down to this. Everything near the
|
|
// cord keys off it: the cloth must come in UNDER the wraps or the cord
|
|
// vanishes into a notch.
|
|
const cinchR = radius * 0.18
|
|
// Outer radius of the twine wraps: just proud of the cinched cloth.
|
|
const cordR = cinchR + radius * 0.065
|
|
|
|
// --- Body (floor to cord, one piece) ---------------------------------
|
|
// An under-filled sack both drops and spreads sideways. Kept narrow
|
|
// enough that the whole sack stands about 1.5x taller than wide.
|
|
const wide = radius * (0.8 + fill * 0.26)
|
|
// Widest ring at 35% of the body, the two rings below it pulled in only
|
|
// slightly so the lower body stays heavy, the lowest ring WIDER than
|
|
// the one above it so the base reads as cloth puddling, and a
|
|
// continuous run of gather rings climbing from the shoulder to the
|
|
// cord with no step and no neck.
|
|
const profile: Level[] = [
|
|
{ y: by(0.0), radius: wide * 0.94 },
|
|
{ y: by(0.045), radius: wide * 0.92 },
|
|
{ y: by(0.14), radius: wide * 0.96 },
|
|
{ y: by(0.35), radius: wide }, // widest, 35% up
|
|
{ y: by(0.5), radius: wide * 0.975 },
|
|
{ y: by(0.62), radius: wide * 0.925 },
|
|
{ y: by(0.72), radius: wide * 0.83 },
|
|
{ y: by(0.78), radius: wide * 0.7 },
|
|
{ y: by(0.84), radius: wide * 0.52 },
|
|
{ y: by(0.89), radius: wide * 0.36 },
|
|
{ y: by(0.925), radius: wide * 0.25 },
|
|
{ y: by(0.955), radius: wide * 0.17 },
|
|
{ y: neckY - h * 0.013, radius: cinchR * 1.06 },
|
|
{ y: neckY, radius: cinchR },
|
|
]
|
|
const body = latheGeometry(profile, 16, [0, 0, 0], tint('cloth', -0.04, 1.2), {
|
|
colourTop: tint('cloth', 0.05, 1.2),
|
|
})
|
|
|
|
// --- Displacement pass: drape pleats and base corners -----------------
|
|
// Both are pure functions of vertex position, so every co-located copy
|
|
// of a corner moves identically and the surface stays closed (same
|
|
// trick roughenGeometry relies on).
|
|
const earN = Math.max(0, Math.round(config.ears))
|
|
const earPhase = 0.7 + jitter(random, 0.18)
|
|
const pleatPhase = random() * Math.PI * 2
|
|
// Pleat depth: ramps in above 60% of the body, peaks at 85%, then eases
|
|
// off approaching the cord so pleats + roughen stay inside the wraps.
|
|
const pleatAmp = (f: number): number => {
|
|
if (f < 0.6) return 0
|
|
if (f < 0.85) return 0.13 * ((f - 0.6) / 0.25)
|
|
return 0.13 * (1 - 0.7 * ((f - 0.85) / 0.15))
|
|
}
|
|
{
|
|
const position = body.getAttribute('position')
|
|
for (let i = 0; i < position.count; i += 1) {
|
|
const x = position.getX(i)
|
|
const y = position.getY(i)
|
|
const z = position.getZ(i)
|
|
const r = Math.hypot(x, z)
|
|
if (r < 1e-6) continue
|
|
const f = (y + half) / bodyH
|
|
const angle = Math.atan2(x, z)
|
|
let scale = 1
|
|
let drop = 0
|
|
// Drape creases: alternate vertices in and out (16 segments, 8
|
|
// lobes: exact alternation), phase constant up the stack so the
|
|
// creases run vertically and converge on the twine.
|
|
scale += pleatAmp(f) * Math.cos(8 * angle + pleatPhase)
|
|
// Base corners: sharpened cosine lobes push the bottom of the
|
|
// cloth out and slightly down. Grown from the body, not appended.
|
|
if (f < 0.12 && earN > 0) {
|
|
const t = 1 - f / 0.12
|
|
const lobe = Math.max(0, Math.cos(earN * (angle - earPhase)))
|
|
const sharp = lobe * lobe * lobe
|
|
scale += 0.2 * t * sharp
|
|
drop = h * 0.008 * t * sharp
|
|
}
|
|
position.setXYZ(i, x * scale, y - drop, z * scale)
|
|
}
|
|
position.needsUpdate = true
|
|
}
|
|
|
|
// Cloth is not rigid: the surface break-up IS the texture. Milder than
|
|
// v5 (whose 0.038 on 14 segments flattened a whole facet) and small
|
|
// enough that the cinched cloth cannot spike through the twine.
|
|
roughenGeometry(body, radius * 0.03, { salt: 21, scaleY: 0.7 })
|
|
// The floor verts must not dig through the ground plane: clamp the
|
|
// roughened bottom flat, which is also what a sack of grain does.
|
|
{
|
|
const position = body.getAttribute('position')
|
|
for (let i = 0; i < position.count; i += 1) {
|
|
if (position.getY(i) < -half) position.setY(i, -half)
|
|
}
|
|
position.needsUpdate = true
|
|
body.computeVertexNormals()
|
|
}
|
|
|
|
// --- Gathered crown above the tie ------------------------------------
|
|
// A CLOSED gathered cone rises out of the tie and caps itself, so every
|
|
// gap between petals shows cloth, never the background or an interior
|
|
// floor. The whole crown stays within about 1.2x the cord's diameter.
|
|
const collarPieces: BufferGeometry[] = []
|
|
const core: Level[] = [
|
|
{ y: neckY - h * 0.02, radius: cinchR * 0.8 }, // ends INSIDE the body
|
|
{ y: neckY + h * 0.024, radius: cinchR * 1.05 }, // clears the top wrap
|
|
{ y: neckY + h * 0.052, radius: cinchR * 1.35 },
|
|
{ y: neckY + h * 0.075, radius: cinchR * 1.6 },
|
|
]
|
|
const tuft = latheGeometry(core, 10, [0, 0, 0], tint('cloth', 0.03, 1.2), {
|
|
colourTop: tint('cloth', 0.09, 1.2),
|
|
})
|
|
collarPieces.push(tuft)
|
|
|
|
// Pleats: widest at the TIP and pinched at the tie, leaning about 40
|
|
// degrees. Roots sit LOW and CLOSE to the axis so every flap visibly
|
|
// emerges from the tuft cloth (v5's roots were higher and wider and
|
|
// read as floating over the rim).
|
|
const points = 8
|
|
for (let i = 0; i < points; i += 1) {
|
|
const angle = (i / points) * Math.PI * 2 + jitter(random, 0.12)
|
|
const tall = h * (0.055 + random() * 0.028)
|
|
const lean = 0.55 + random() * 0.2
|
|
const widthTip = cinchR * (1.25 + random() * 0.35)
|
|
const wedge = taperedBoxGeometry(
|
|
[cinchR * 0.6, cinchR * 0.5], // pinched at the tie
|
|
[widthTip, cinchR * 0.3], // widest at the tip
|
|
tall,
|
|
[0, tall / 2, 0], // base at the origin: build, THEN orient
|
|
tint('cloth', 0.02, 1.3),
|
|
tint('cloth', 0.1, 1.3),
|
|
)
|
|
wedge.rotateZ(jitter(random, 0.08)) // slight twist so no two match
|
|
wedge.rotateX(lean) // top leans towards local +Z
|
|
wedge.rotateY(angle) // +Z now points along (sin a, cos a)
|
|
wedge.translate(
|
|
Math.sin(angle) * cinchR * 0.35,
|
|
neckY + h * 0.015,
|
|
Math.cos(angle) * cinchR * 0.35,
|
|
)
|
|
collarPieces.push(wedge)
|
|
}
|
|
const collar = mergeColoured(collarPieces)
|
|
roughenGeometry(collar, radius * 0.024, { salt: 22, scaleY: 0.8 })
|
|
|
|
// --- Cord: thin stacked wraps and a loose tail ------------------------
|
|
// Twine, not a hoop: three thin turns close to the body's own value,
|
|
// standing only slightly proud of the cinched cloth. Each wrap is
|
|
// TALLER than the wrap spacing so the turns overlap: v5 left 0.0055h
|
|
// gaps between them and the dark tail behind showed through as a
|
|
// vertical sliver at 12 o'clock.
|
|
const cordRope = tint('cloth', -0.08, 0.7)
|
|
const cordPieces: BufferGeometry[] = []
|
|
const wrapYs = [neckY - h * 0.011, neckY, neckY + h * 0.011]
|
|
for (const wrapY of wrapYs) {
|
|
cordPieces.push(bandGeometry(
|
|
cordR + jitter(random, radius * 0.006),
|
|
wrapY,
|
|
h * 0.015,
|
|
radius * 0.05,
|
|
10,
|
|
tint('cloth', -0.08, 0.7),
|
|
{ inner: true },
|
|
))
|
|
}
|
|
// The loose tail: emerges from under the bottom wrap and drapes down
|
|
// the gather. The gather is CONCAVE (the flare accelerates downward),
|
|
// so a straight chord leaned at the average slope stands clear of the
|
|
// cloth mid-way and reads as a floating stick — which is exactly what
|
|
// the first render of this pass showed. Instead it leans only slightly
|
|
// (0.35 rad), hugging the narrow column just under the tie, and its far
|
|
// end lands INSIDE the widening cloth lower down, so it reads as a rope
|
|
// end lying on the gather and disappearing into a crease.
|
|
const tailLen = h * 0.1
|
|
const tailLean = 0.35
|
|
const tail = taperedBoxGeometry(
|
|
[radius * 0.055, radius * 0.055],
|
|
[radius * 0.04, radius * 0.04],
|
|
tailLen,
|
|
[0, -tailLen / 2, 0], // hangs DOWN from the origin
|
|
cordRope,
|
|
)
|
|
const tailA = 0.7 + jitter(random, 0.3)
|
|
tail.rotateX(-tailLean) // down and outward along local +Z
|
|
tail.rotateY(tailA)
|
|
tail.translate(
|
|
Math.sin(tailA) * cordR * 0.6,
|
|
neckY - h * 0.01,
|
|
Math.cos(tailA) * cordR * 0.6,
|
|
)
|
|
cordPieces.push(tail)
|
|
|
|
return {
|
|
body: { slot: 'cloth' as const, geometry: mergeColoured([body]) },
|
|
collar: { slot: 'cloth' as const, geometry: collar },
|
|
cord: { slot: 'cloth' as const, geometry: mergeColoured(cordPieces) },
|
|
}
|
|
},
|
|
}, overrides)
|
|
}
|