Bring the anvil's horn back to its reference proportion, rebuild the book's metal furniture so no two overlapping faces share a plane, and release 0.2.0.

This commit is contained in:
CRTkafa 2026-09-01 03:00:37 +07:00
parent 3f57165d06
commit 9b4ce16c59
82 changed files with 9725 additions and 5064 deletions

View file

@ -4,27 +4,70 @@
* 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.
* What makes a sack a sack is that it TAKES THE SHAPE of what is inside it.
* Five earlier versions failed on exactly this:
*
* 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.
* 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,
boxGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
roughenGeometry,
taperedBoxGeometry,
type Level,
} from '../core/index.ts'
@ -46,9 +89,11 @@ export const linenSackDefaults: LinenSackConfig = {
height: 0.52,
radius: 0.16,
fill: 0.85,
// A hand's width of gathered cloth above the cord, not a fifth of the sack.
collar: 0.1,
ears: 4,
// 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,
}
@ -61,99 +106,211 @@ export function createModel(overrides: Partial<LinenSackConfig> = {}) {
slots: ['cloth'],
build: ({ config, random }) => {
const tint = createTinter(random)
const half = config.height / 2
// 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))
// 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
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 -----------------------------------------------------------
// 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)
// A sack, not an amphora.
//
// The old profile put its widest point a quarter of the way up and then
// narrowed all the way to the neck, which is the silhouette of a vase.
// A filled sack does the opposite: it SPREADS where it meets the ground,
// because the grain settles and the cloth has no stiffness, then runs
// close to parallel up most of its height, and only gathers in sharply
// where the cord is tied. Nearly the whole difference between the two
// shapes is in the bottom level and in where the taper begins.
// --- 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: -half, radius: wide * 0.93 },
{ y: -half + config.height * 0.07, radius: wide },
{ y: -half + config.height * 0.44 * fill, radius: wide },
{ y: -half + config.height * 0.72 * fill, radius: wide * 0.9 },
{ y: bodyTop - config.height * 0.09, radius: wide * 0.52 },
{ y: bodyTop, radius: config.radius * 0.25 },
{ 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, 11, [0, 0, 0], tint('cloth', -0.06, 1.3), {
colourTop: tint('cloth', 0.05, 1.3),
const body = latheGeometry(profile, 16, [0, 0, 0], tint('cloth', -0.04, 1.2), {
colourTop: tint('cloth', 0.05, 1.2),
})
// 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)
// --- 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
}
// --- 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 },
// The tuft above the tie opens only a little. Flaring to 0.46 of the
// radius turned the top of a sack into the mouth of a bottle, which is
// the last thing left reading as a vessel once the body was fixed.
{ y: half - config.height * 0.02, radius: config.radius * 0.33 },
{ y: half, radius: config.radius * 0.27 },
]
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)
// 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()
}
// --- 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 })
// --- 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(pieces) },
collar: { slot: 'cloth' as const, geometry: mergeColoured(collarPieces) },
cord: { slot: 'cloth' as const, geometry: mergeColoured([cord]) },
body: { slot: 'cloth' as const, geometry: mergeColoured([body]) },
collar: { slot: 'cloth' as const, geometry: collar },
cord: { slot: 'cloth' as const, geometry: mergeColoured(cordPieces) },
}
},
}, overrides)