Three defects, two of them mine. The weave had gaps you could see the contents through. The band height was a flat 0.11 of the model height while the spacing between rows is 1/rows -- 0.167 at the default six -- leaving a 12 mm hole between every course. It was a slatted crate, not a weave. The height now derives from the row count so rows meet at any setting. The fruit was invisible, and that was a regression I introduced. An earlier pass fixed produce that hung level with the rim over a gap by resting it on the base. That was right; what did not follow was that a basket deeper than its contents then needs enough contents to fill it. Nine apples in two layers topped out 12 cm below a 10 cm rim -- correctly supported, and completely out of sight. The basket is shallower, holds fifteen, and the layers nest at 0.95 of a diameter rather than 1.5, so the heap now mounds over the rim, which is the only state a fruit basket is ever drawn in. Closing the weave then exposed two collisions. Overlapping rows meet in coplanar faces at the nodes where the undulation crosses zero -- on a cylindrical basket every row shares one radius -- so alternate rows now sit slightly proud of and behind each other, which is how a weaver's rod really passes the stakes. And the rim's inner surface sat at 0.16055 against the liner's 0.16065: a tenth of a millimetre apart, by coincidence, and the same 22-sided prism. The rim is now the heavy rod it should be and clears the liner. Colour: the basket drew from the `straw` palette and rendered at hue 41, saturation 0.59 against a reference at 29 and 0.50 -- bright yellow plastic. Willow is a wood, so both the colour and the material slot move to oak, which measures 7 and 0.03 away instead of 12 and 0.09. It still does not look like the photograph, and the measurement cannot say why. Filtering to pixels above 0.18 saturation discards precisely the grey, weathered pixels that give the reference its character, so the statistic reports agreement that the eye does not see. Setting the lift to 0.12 to make willow "paler than oak" turned it salmon -- raising HSL lightness holds saturation, so a saturated brown climbs towards peach. Sweeping the lift over 0.00 / 0.04 / 0.08 moved the measurement by 0.01, which is the clearest evidence that this axis is not where the remaining gap lives. 27 models, 17853 triangles, all checks pass, 0 floating cases, build clean.
292 lines
14 KiB
TypeScript
292 lines
14 KiB
TypeScript
/**
|
|
* @medieval-kit/wicker-basket
|
|
*
|
|
* A basket woven from willow rods, 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.
|
|
*
|
|
* 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 {
|
|
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 = {
|
|
// 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,
|
|
stakes: 11,
|
|
rows: 6,
|
|
produce: 15,
|
|
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)
|
|
const half = config.height / 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 radiusAt = (t: number): number => bottomRadius + (config.radius - bottomRadius) * t
|
|
|
|
/**
|
|
* 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 -----------------------------------------------------
|
|
// 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.
|
|
const pieces: BufferGeometry[] = []
|
|
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)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// 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
|
|
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
|
|
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),
|
|
)
|
|
// 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.
|
|
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), {
|
|
capTop: false,
|
|
capBottom: false,
|
|
})))
|
|
|
|
// --- Base ----------------------------------------------------------------
|
|
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 }))
|
|
|
|
// --- 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 }),
|
|
])
|
|
|
|
// --- 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)
|
|
// 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(
|
|
(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.
|
|
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)
|
|
- 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: rim },
|
|
contents: contents.length > 0
|
|
? { slot: 'produce' as const, geometry: mergeColoured(contents) }
|
|
: undefined,
|
|
}
|
|
},
|
|
}, overrides)
|
|
}
|