/** * @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 = {}) { return createKitModel({ 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) }