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

@ -1,7 +1,8 @@
/**
* @medieval-kit/wicker-basket
*
* A basket woven from willow rods, optionally filled with produce.
* 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
@ -18,6 +19,32 @@
* 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
@ -26,6 +53,7 @@
import { Color, type BufferGeometry } from 'three'
import {
arcBarGeometry,
bandGeometry,
createKitModel,
createTinter,
@ -56,15 +84,15 @@ export interface WickerBasketConfig {
}
export const wickerBasketDefaults: WickerBasketConfig = {
// Shallower and fuller. The reference is a bowl wider than it is deep,
// heaped until the fruit mounds over the rim -- which is the only state a
// fruit basket is ever drawn in.
height: 0.16,
radius: 0.17,
taper: 0.26,
// 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: 6,
produce: 15,
rows: 9,
produce: 12,
hue: 0.02,
seed: 97,
}
@ -78,14 +106,60 @@ export function createModel(overrides: Partial<WickerBasketConfig> = {}) {
slots: ['oak', 'produce'],
build: ({ config, random }) => {
const tint = createTinter(random)
const half = config.height / 2
// 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 bottomRadius = config.radius * (1 - config.taper)
const withy = config.height * 0.05 // rod thickness
const amplitude = 0.055 // in-out travel of the weave
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
const radiusAt = (t: number): number => bottomRadius + (config.radius - bottomRadius) * t
/**
* 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
@ -110,170 +184,186 @@ export function createModel(overrides: Partial<WickerBasketConfig> = {}) {
}
// --- Vertical stakes -----------------------------------------------------
// They run THROUGH the weave: because the horizontal hoops wind in front
// of one and behind the next, the stakes hide here and show up there.
// 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 stake = prismGeometry(
withy * 0.42, withy * 0.36, config.height * 1.02, 4,
[0, 0, 0], tint('oak', -0.09 + 0.04, 1.2),
)
// Bend first, move second: in a tapering basket the stakes lean too.
stake.rotateX(Math.atan2(config.radius - bottomRadius, config.height))
stake.rotateY(angle)
const mid = (bottomRadius + config.radius) / 2
stake.translate(Math.sin(angle) * mid, 0, Math.cos(angle) * mid)
pieces.push(stake)
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 },
))
}
// Willow, not straw.
//
// The basket drew its colour from the `straw` palette entry and rendered
// at hue 41 with saturation 0.59; a photograph of a wicker basket sits at
// hue 29, saturation 0.50. It read as bright yellow plastic. The tuned
// `oak` entry is hue 26 / saturation 0.53 -- almost exactly the
// measurement -- and willow IS a wood, so the material slot moves with
// the colour. The lift keeps it at the pale, peeled end of oak rather
// than the dark structural end.
//
// The lift is 0.04 and that is nearly arbitrary: sweeping it over 0.00,
// 0.04 and 0.08 moved the measured hue and saturation by at most 0.01.
// It was first set to 0.12 on the reasoning that willow is paler than
// structural oak, which is true and which made the basket salmon pink --
// raising HSL lightness holds saturation, so a saturated brown climbs
// towards peach rather than towards weathered willow. Against the
// reference the straw entry was off by 12 degrees of hue and 0.09
// saturation; oak is off by 7 and 0.03.
// --- 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. Four segments gave a smoother wave
// but doubled the triangles per hoop and pushed the basket past the
// lowpoly budget.
// 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): six separate inner surfaces for six
// hoops cost ~800 triangles, the liner costs 44, and from the inside the
// difference is invisible.
// The weave stops BELOW the rim. Once the rows were made tall enough to
// meet each other they grew into the rim's band, and the outermost point
// of the undulation met the rim's inner facets in the same plane. The
// rim is a separate, thicker rod laid over the finished weave, so the
// weave ending under it is how the object is actually made.
const weaveSpan = config.height * 0.9
// 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
// The band height is derived from the row count so that rows always
// MEET. It used to be a flat 0.11 of the height while the spacing
// between rows is 1/rows -- 0.167 at the default six -- which left a
// 12 mm gap you could see straight through to the contents. That is a
// slatted crate, not a weave. Rows now overlap slightly; they do not
// z-fight because consecutive rows are undulated half a wave apart, so
// where one bulges out its neighbour is tucked in.
// Alternate rows sit slightly proud of and slightly behind each other.
// This is how a weave really goes together -- the weaver's rod passes
// outside one stake and inside the next, so no two rows lie on the
// same cylinder -- and it is also what makes the overlap above safe.
// Undulating consecutive rows in antiphase is not enough on its own:
// a sine crosses zero, and at those nodes both rows returned to
// exactly `radiusAt(t)`. With a cylindrical basket (taper 0) every row
// shares that radius, so the overlapping bands met in coplanar faces
// at every node.
const lean = r % 2 === 0 ? withy * 0.2 : -withy * 0.2
// 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, withy * 0.8, stakes * 2,
tint('oak', 0.04 + jitter(random, 0.07), 1.2),
radiusAt(t) + lean, y, (weaveSpan / rows) * 1.06, rod * 0.8, stakes * 2,
willow(0.02 + jitter(random, 0.05)),
)
// The phase shifts by half a wave on every row: the next row going in
// where the previous one came out is what makes a weave a weave.
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.
// 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 + config.height * 0.03, radius: bottomRadius * (1 - amplitude) },
{ y: half - config.height * 0.02, radius: config.radius * (1 - amplitude) },
], stakes * 2, [0, 0, 0], tint('oak', 0.06, 1.1), {
{ 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 ----------------------------------------------------------------
// --- 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: -half - config.height * 0.01, radius: bottomRadius * 0.94 },
{ y: -half + config.height * 0.05, radius: bottomRadius * 0.99 },
], stakes * 2, [0, 0, 0], tint('oak', -0.14 + 0.04, 1.2), { capTop: true }))
{ 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. It is the most visible detail on
// the basket, and without it the edge looks "cut off".
const rim = mergeColoured([
// Thickness withy*2.1, not 1.5, and the reason is arithmetic rather
// than taste. At 1.5 the rim's inner surface landed at
// radius*1.015 - withy*1.5 = 0.16055, and the inner liner sits at
// radius*(1 - amplitude) = 0.16065 -- a tenth of a millimetre apart,
// by coincidence. On a cylindrical basket (taper 0) those are the same
// 22-sided prism and every facet of it z-fought. The thicker rod
// carries the inner face clearly past the liner, and it is the truer
// shape anyway: the rim is the heaviest rod in a basket, bent over the
// finished weave, and it stands proud on both sides.
bandGeometry(config.radius * 1.015, half - config.height * 0.03,
config.height * 0.1, withy * 2.1, stakes * 2,
tint('oak', 0.07 + 0.04, 1.2), { inner: true }),
])
// 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 = config.radius * (0.2 + random() * 0.07)
const size = R * (0.2 + random() * 0.07)
// Apple profile: dimpled top and bottom, wide in the middle.
const fruit = latheGeometry([
{ y: -size * 0.86, radius: size * 0.3 },
{ y: -size * 0.6, radius: size * 0.78 },
{ y: 0, radius: size },
{ y: size * 0.58, radius: size * 0.82 },
{ y: size * 0.84, radius: size * 0.34 },
] as Level[], 7, [0, 0, 0], new Color().setHSL(
{ 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 — critically — the heap RESTS ON THE BASE.
//
// It used to be positioned relative to the rim, which is wrong for any
// basket deeper than a fruit: the produce hung near the mouth with a
// gap underneath it. Fruit sits at the bottom and piles up from there;
// if there is more of it than the basket holds, the heap rises past
// the rim, which is also what really happens.
// 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
// Fewer fruit per layer means MORE layers, and it is the layer count
// that decides whether the heap ever reaches the mouth. At 0.55 the
// default nine apples formed two layers topping out 12 cm below a rim
// 10 cm up: correctly resting on the base, and completely invisible.
// Resting on the base was the fix for an earlier bug where the produce
// hung level with the rim over a gap; the fix was right and the
// consequence -- that a deep basket then needs enough fruit to fill it
// -- was not followed through.
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,
// Base top + one radius = resting on the floor of the basket.
// Layers nest at 0.95 of a diameter, not 1.5: stacked fruit settles
// into the gaps of the layer below rather than sitting on top of it.
-half + config.height * 0.05 + size * (0.92 + layer * 0.95)
// 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,
)
@ -282,7 +372,7 @@ export function createModel(overrides: Partial<WickerBasketConfig> = {}) {
return {
weave: { slot: 'oak' as const, geometry: mergeColoured(pieces) },
rim: { slot: 'oak' as const, geometry: rim },
rim: { slot: 'oak' as const, geometry: mergeColoured(rimPieces) },
contents: contents.length > 0
? { slot: 'produce' as const, geometry: mergeColoured(contents) }
: undefined,