medieval-kit/my-registry/models/wicker-basket/model.ts

382 lines
17 KiB
TypeScript

/**
* @medieval-kit/wicker-basket
*
* A basket woven from willow rods, with a bow handle, optionally filled with
* produce.
*
* The model in the kit that imitates "how it was made" the most, because in
* wickerwork the form and the making are the same thing: a basket is the
* horizontal rods (withies) winding IN FRONT OF ONE and BEHIND THE NEXT of the
* vertical rods (stakes). Without that winding what you get is a bucket with
* lines drawn on it.
*
* The weave trick is short: every horizontal hoop is first produced as a flat
* band, then its vertices are pushed in and out according to THEIR ANGLE —
*
* radius × (1 + amplitude · cos(stakes · angle + phase))
*
* On consecutive rows the phase is shifted by π, so where one row comes out the
* next one goes in. That is exactly what a real weave is, and it costs not one
* single extra triangle.
*
* HISTORY, because this model is on its third body:
*
* v2 read as a bowl of fired clay, and the blind critique named the three
* reasons. (1) No handle: the reference's single most identifying feature is a
* bow handle arcing well above the rim, and without it the silhouette is a
* bowl, not a carrying basket. (2) The wall was a cone — base half the rim —
* where the reference is a near-vertical drum whose inward curve lives in the
* bottom fifth. `radiusAt` now has a knee at t=0.2: eased curve below it,
* near-vertical above. Because the wall is no longer a straight line, a
* one-piece leaning stake no longer follows it; each stake is two prisms, a
* steep lower one under the knee and a near-vertical upper one, overlapped at
* the joint. (3) Four fat courses with a deep sawtooth read as stacked blocks;
* there are now more, thinner courses and the undulation amplitude is a third
* of what it was. The colour was salmon because the oak entry is saturated and
* every lift pushed it toward peach; the `willow` wrapper below desaturates
* every tint toward grey-brown instead of lightening it (lifting was tried in
* v2 and documented as the salmon mistake — do not go back to it).
*
* The critique also caught a modelling error: the lowest points of the model
* were the dips of the bottom course's zigzag, so the basket would rock. The
* base is now a foot ring standing 2% of the height below the wall, with a
* flat bottom cap, and its top plate is wide enough to reach under the bottom
* course and the stakes — an earlier draft of this fix left the base plate at
* the nominal base radius, which the flaring wall never touches, and the base
* came out a second floating component.
*
* The produce sits in its own slot and takes its colour from the `hue` field:
* the same model can give you a basket of apples, turnips or cabbages. Do NOT
* look for tomatoes — they come from the Americas and do not enter European
* cooking before the 16th century.
*/
import { Color, type BufferGeometry } from 'three'
import {
arcBarGeometry,
bandGeometry,
createKitModel,
createTinter,
flipGeometry,
jitter,
latheGeometry,
mergeColoured,
prismGeometry,
type Level,
} from '../core/index.ts'
export interface WickerBasketConfig {
/** Basket height, handle excluded (metres). */
readonly height: number
/** Mouth radius (metres). */
readonly radius: number
/** Taper towards the base. 0 = cylinder. */
readonly taper: number
/** Number of vertical stakes. Also the "wave count" of the weave. */
readonly stakes: number
/** Horizontal weave rows. */
readonly rows: number
/** Number of fruits inside. 0 = empty basket. */
readonly produce: number
/** Fruit colour, 0–1 around the colour wheel. */
readonly hue: number
readonly seed: number
}
export const wickerBasketDefaults: WickerBasketConfig = {
height: 0.16,
radius: 0.17,
// Base 0.79 of the rim. The reference's wall is a near-vertical drum; the
// taper is small and, through the knee in `radiusAt`, spent almost entirely
// in the bottom fifth.
taper: 0.21,
stakes: 11,
rows: 9,
produce: 12,
hue: 0.02,
seed: 97,
}
export type WickerBasketParts = 'weave' | 'rim' | 'contents'
export function createModel(overrides: Partial<WickerBasketConfig> = {}) {
return createKitModel<WickerBasketConfig, 'oak' | 'produce', WickerBasketParts>({
id: 'wicker-basket',
defaults: wickerBasketDefaults,
slots: ['oak', 'produce'],
build: ({ config, random }) => {
const tint = createTinter(random)
// Floors, not trust: several of these appear in denominators or in the
// sagitta of the handle arc, and a zero patched in from outside must not
// become a NaN that the occlusion bake then smears over every part.
const H = Math.max(0.05, config.height)
const R = Math.max(0.04, config.radius)
const half = H / 2
const stakes = Math.max(5, Math.round(config.stakes))
const rows = Math.max(1, Math.round(config.rows))
const taper = Math.min(0.7, Math.max(0, config.taper))
const bottomRadius = R * (1 - taper)
const rod = H * 0.026 // horizontal withy thickness (halved from v2)
const stakeThick = H * 0.021 // uprights keep their old, visible section
// A third of v2's 0.055: the courses should read as bent willow, and at
// 0.055 they read as stacked blocks. The zigzag survives at 0.02 because
// consecutive rows still move in antiphase.
const amplitude = 0.02
/**
* Willow is not oak-coloured. The `oak` palette entry has the right hue
* but weathered willow is greyer, and v2 proved that LIFTING the entry
* moves it toward peach, not toward grey (lightness holds saturation).
* So every basket tint passes through here and has its saturation set
* against the reference rather than inherited. The tinter returns a new Color per call, so mutating it in
* place is safe.
*
* The factor was a third, and a third was right when `oak` was a saturated
* orange-brown. That entry has since been measured against its references
* and pulled toward grey itself, so taking another third off it greyed the
* basket 0.20 below its own reference. Two corrections for the same fault,
* applied one on top of the other. Measured back the other way, the
* reference willow is in fact WARMER than the corrected oak, so the factor
* is now above one.
*/
const willow = (lift: number, spread = 1.2): Color => {
const c = tint('oak', lift, spread)
const hsl = { h: 0, s: 0, l: 0 }
c.getHSL(hsl)
c.setHSL(hsl.h, Math.max(0, hsl.s * 1.3), hsl.l)
return c
}
/**
* Wall profile. Near-vertical drum over the top four fifths, the whole
* inward curve eased into the bottom fifth — the knee. `rKnee` is floored
* at `bottomRadius` so a cylinder (taper 0) stays a cylinder instead of
* denting inward at the knee.
*/
const knee = 0.2
const rKnee = Math.max(bottomRadius, R * 0.97)
const radiusAt = (t: number): number => {
if (t >= knee) return rKnee + (R - rKnee) * ((t - knee) / (1 - knee))
const s = t / knee
return bottomRadius + (rKnee - bottomRadius) * s * (2 - s)
}
/**
* The transform that turns a band into a weave: every vertex moves
* towards and away from the centre according to ITS OWN angle. Y is left
* alone, so the hoop stays in its plane and never collides with the
* neighbouring rows.
*/
const undulate = (geometry: BufferGeometry, phase: number): BufferGeometry => {
const position = geometry.getAttribute('position')
for (let i = 0; i < position.count; i += 1) {
const x = position.getX(i)
const z = position.getZ(i)
const distance = Math.hypot(x, z)
if (distance < 1e-6) continue
const scale = 1 + amplitude * Math.cos(stakes * Math.atan2(x, z) + phase)
position.setX(i, x * scale)
position.setZ(i, z * scale)
}
position.needsUpdate = true
geometry.computeVertexNormals()
return geometry
}
// --- Vertical stakes -----------------------------------------------------
// Two prisms per stake, because the wall is no longer a straight line: a
// steep lower segment rounds through the knee, a near-vertical upper one
// runs to the rim. They overlap at the joint; both far ends are buried —
// the bottom behind the lowest course, the top inside the rim ring — so
// only the lower bottom is capped and the rest of the tube ends stay open
// where nothing can see them.
const pieces: BufferGeometry[] = []
const yKnee = -half + knee * H
const yStakeBottom = -half + H * 0.012
const yStakeTop = half - H * 0.02
const overlap = H * 0.02
const rLow = radiusAt(0.012)
const segment = (
y0: number, r0: number, y1: number, r1: number, angle: number,
colour: Color, options: { capTop?: boolean; capBottom?: boolean },
): BufferGeometry => {
const length = Math.hypot(y1 - y0, r1 - r0)
const piece = prismGeometry(
stakeThick * 0.52, stakeThick * 0.46, length, 4, [0, 0, 0], colour, options,
)
piece.rotateX(Math.atan2(r1 - r0, y1 - y0))
piece.rotateY(angle)
const mid = (r0 + r1) / 2
piece.translate(Math.sin(angle) * mid, (y0 + y1) / 2, Math.cos(angle) * mid)
return piece
}
for (let i = 0; i < stakes; i += 1) {
const angle = (i / stakes) * Math.PI * 2
const shade = willow(-0.05, 1.2)
pieces.push(segment(
yStakeBottom, rLow, yKnee + overlap, rKnee, angle, shade,
{ capTop: false, capBottom: true },
))
pieces.push(segment(
yKnee - overlap, rKnee, yStakeTop, R, angle, shade,
{ capTop: false, capBottom: false },
))
}
// --- Horizontal weave ----------------------------------------------------
// TWO segments per vertical stake: `cos(stakes·θ)` is sampled exactly once
// positive and once negative on every stake, so the wave is fully resolved
// with the fewest possible triangles.
//
// The INNER FACE of the hoops is not generated. In its place there is a
// single-piece inner liner (below). The weave stops BELOW the rim: the rim
// is a separate, thicker rod laid over the finished weave, which is how
// the object is actually made.
const weaveSpan = H * 0.92
for (let r = 0; r < rows; r += 1) {
const t = (r + 0.5) / rows
const y = -half + weaveSpan * t
// Rows overlap slightly so they always MEET; they do not z-fight
// because consecutive rows are undulated half a wave apart AND lean
// alternately in and out, so no two rows lie on the same cylinder.
const lean = r % 2 === 0 ? rod * 0.2 : -rod * 0.2
const ring = bandGeometry(
radiusAt(t) + lean, y, (weaveSpan / rows) * 1.06, rod * 0.8, stakes * 2,
willow(0.02 + jitter(random, 0.05)),
)
pieces.push(undulate(ring, r % 2 === 0 ? 0 : Math.PI))
}
// Inner liner: the single surface that closes off the back of the weave.
// Wound in reverse so the normals face the axis. It follows the wall
// profile at 0.955 of it — inside the courses, outside the base plate,
// its top edge buried in the rim ring.
pieces.push(flipGeometry(latheGeometry([
{ y: -half + H * 0.04, radius: radiusAt(0.05) * 0.955 },
{ y: yKnee, radius: rKnee * 0.955 },
{ y: half - H * 0.02, radius: R * 0.955 },
], stakes * 2, [0, 0, 0], willow(-0.02, 1.1), {
capTop: false,
capBottom: false,
})))
// --- Base and foot -------------------------------------------------------
// One lathe: a foot ring the basket actually stands on (flat bottom cap
// 2% of the height below the wall, so the bottom course's zigzag dips
// stay clear of the ground), swelling slightly, then flaring up and OUT
// to a floor plate at `radiusAt(0.03)` — wide enough to pass under the
// bottom course and the stake feet, which is what keeps the base part of
// the same solid as the wall.
const footR = bottomRadius * 0.98
const yGround = -half - H * 0.02
const yFloor = -half + H * 0.06
pieces.push(latheGeometry([
{ y: yGround, radius: footR * 0.95 },
{ y: yGround + H * 0.03, radius: footR * 1.03 },
{ y: yFloor, radius: radiusAt(0.03) * 0.99 },
], stakes * 2, [0, 0, 0], willow(-0.06, 1.2)))
// --- Rim -----------------------------------------------------------------
// The thick bend that finishes the weave: the heaviest rod in a basket,
// bent over the finished weave, standing proud on both sides. Everything
// that must vanish — liner top, stake tops, handle feet — ends inside
// this solid.
const rimY = half - H * 0.03
const rimHalfH = H * 0.05
const rimOuter = R * 1.02
const rimThick = rod * 3.5
const rimPieces: BufferGeometry[] = [
bandGeometry(rimOuter, rimY, rimHalfH * 2, rimThick, stakes * 2,
willow(0.05, 1.2), { inner: true }),
]
// --- Handle --------------------------------------------------------------
// The bow that makes it a carrying basket rather than a bowl. A flat
// band — twice as wide in the plane of the loop as it is deep — arcing
// rim to rim across the diameter and peaking about 0.6 of the body
// height above the rim. The arc is solved from its chord and sagitta, so
// the feet land at mid-wall of the rim ring (inside the solid, not
// butted against the outer face) whatever the proportions are patched to.
const handleWide = Math.min(rimThick * 0.9, R * 0.09)
const handleDeep = handleWide * 0.5
const footY = rimY - rimHalfH * 0.4
const footX = rimOuter - rimThick * 0.5
const peak = rimY + H * 0.62
const sagitta = Math.max(0.01, peak - footY)
const arcR = (footX * footX + sagitta * sagitta) / (2 * sagitta)
const arcCentreY = peak - arcR
const footAngle = Math.atan2(footY - arcCentreY, footX)
const handle = arcBarGeometry(
arcR, handleWide, footAngle, Math.PI - footAngle, 7,
[0, arcCentreY, 0], willow(0.03, 1.1),
)
// The bar comes out square; flattening it in Z (the out-of-loop
// direction) turns it into the band. Scale is safe here because the arc
// is only ever offset in Y.
handle.scale(1, 1, handleDeep / handleWide)
rimPieces.push(handle)
// Lashings: a short sleeve wrapped around each handle foot just above
// the rim, its lower end sunk into the rim ring — the bindings that hold
// a real handle. The sleeve is held by the handle passing through it.
for (const side of [0, 1] as const) {
const a = side === 0 ? footAngle + 0.12 : Math.PI - footAngle - 0.12
const sleeve = prismGeometry(
handleWide * 0.72, handleWide * 0.66, H * 0.2, 4, [0, 0, 0],
willow(-0.03, 1.1),
)
sleeve.rotateZ(a)
sleeve.translate(Math.cos(a) * arcR, arcCentreY + Math.sin(a) * arcR, 0)
rimPieces.push(sleeve)
}
// --- Contents ------------------------------------------------------------
const count = Math.max(0, Math.round(config.produce))
const contents: BufferGeometry[] = []
const hue = ((config.hue % 1) + 1) % 1
for (let i = 0; i < count; i += 1) {
const size = R * (0.2 + random() * 0.07)
// Apple profile: dimpled top and bottom, wide in the middle.
const fruit = latheGeometry([
{ y: -size * 0.82, radius: size * 0.36 },
{ y: -size * 0.45, radius: size * 0.9 },
{ y: size * 0.15, radius: size * 0.97 },
{ y: size * 0.8, radius: size * 0.4 },
] as Level[], 6, [0, 0, 0], new Color().setHSL(
(hue + jitter(random, 0.03) + 1) % 1,
0.52 + random() * 0.2,
0.3 + random() * 0.12,
))
// Placement: golden-angle spiral plus a distance growing with the
// square root, and the heap RESTS ON THE FLOOR PLATE — fruit piles up
// from the bottom, and with enough of it the heap mounds past the rim.
const angle = i * 2.399963
const ring = Math.sqrt((i + 0.4) / count)
const inner = Math.max(size, bottomRadius * 0.92 - size * 0.6)
const spread = inner * ring
const layer = Math.floor(i / Math.max(3, Math.round(count * 0.38)))
fruit.rotateX(jitter(random, 0.6))
fruit.rotateZ(jitter(random, 0.6))
fruit.translate(
Math.sin(angle) * spread,
// Floor plate + one radius = resting on the floor of the basket.
// Layers nest at 0.95 of a diameter: stacked fruit settles into the
// gaps of the layer below rather than sitting on top of it.
yFloor + size * (0.92 + layer * 0.95)
- ring * size * 0.28 + jitter(random, size * 0.08),
Math.cos(angle) * spread,
)
contents.push(fruit)
}
return {
weave: { slot: 'oak' as const, geometry: mergeColoured(pieces) },
rim: { slot: 'oak' as const, geometry: mergeColoured(rimPieces) },
contents: contents.length > 0
? { slot: 'produce' as const, geometry: mergeColoured(contents) }
: undefined,
}
},
}, overrides)
}