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:
parent
3f57165d06
commit
9b4ce16c59
82 changed files with 9725 additions and 5064 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue