@medieval-kit/registry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3551 @@
1
+ {
2
+ "$schema": "https://vibe3d.dev/schema/registry.json",
3
+ "schemaVersion": 1,
4
+ "namespace": "@medieval-kit",
5
+ "name": "Medieval Kit",
6
+ "description": "Lowpoly medieval procedural model library for Three.js.",
7
+ "license": "MIT",
8
+ "defaultItem": "kit",
9
+ "compatibility": {
10
+ "vibe3d": "^0.0.1",
11
+ "engine": "three",
12
+ "three": ">=0.185.0",
13
+ "capabilities": []
14
+ },
15
+ "items": [
16
+ {
17
+ "name": "core",
18
+ "type": "vibe3d:lib",
19
+ "title": "Medieval Kit Core",
20
+ "description": "The shared foundation of the kit: deterministic randomness, vertex-colored materials and stave/band/head geometry generators.",
21
+ "dependencies": [
22
+ "three@>=0.185.0"
23
+ ],
24
+ "registryDependencies": [],
25
+ "files": [
26
+ {
27
+ "path": "models/core/geometry.ts",
28
+ "target": "{models}/medieval-kit/core/geometry.ts",
29
+ "content": "import { BufferAttribute, BufferGeometry, Color } from 'three'\r\nimport { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'\r\n\r\n/**\r\n * The kit's geometry vocabulary.\r\n *\r\n * Everything is generated NON-INDEXED. The reason: on non-indexed geometry\r\n * computeVertexNormals() gives every triangle its own normal, so flat shading\r\n * becomes a natural consequence of the geometry — no material flag is needed.\r\n * In lowpoly that is exactly what we want.\r\n *\r\n * Position frame: point(a, r, y) = (sin a · r, y, cos a · r)\r\n * So a = 0 → the +Z direction; as a grows it turns toward +X.\r\n *\r\n * The windings were worked out by hand and the audit inside\r\n * `scripts/verify-model.ts` tests them by mutation: radial faces on the outer\r\n * shell must point away from the axis.\r\n */\r\n\r\nexport type Vec3 = readonly [number, number, number]\r\n\r\nexport interface Level {\r\n /** Vertical position (metres). */\r\n readonly y: number\r\n /** OUTER radius at that height (metres). */\r\n readonly radius: number\r\n}\r\n\r\nfunction point(angle: number, radius: number, y: number): Vec3 {\r\n return [Math.sin(angle) * radius, y, Math.cos(angle) * radius]\r\n}\r\n\r\ninterface Sink {\r\n readonly position: number[]\r\n readonly color: number[]\r\n}\r\n\r\nfunction tri(sink: Sink, a: Vec3, b: Vec3, c: Vec3, colour: Color): void {\r\n sink.position.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2])\r\n for (let i = 0; i < 3; i += 1) sink.color.push(colour.r, colour.g, colour.b)\r\n}\r\n\r\n/** Triangle with a separate colour per corner — for vertical colour ramps. */\r\nfunction triShaded(sink: Sink, a: Vec3, b: Vec3, c: Vec3, ca: Color, cb: Color, cc: Color): void {\r\n sink.position.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2])\r\n sink.color.push(ca.r, ca.g, ca.b, cb.r, cb.g, cb.b, cc.r, cc.g, cc.b)\r\n}\r\n\r\n/** Splits the quad (a,b,c,d) in order into two triangles. The normal follows from the a→b→c winding. */\r\nfunction quad(sink: Sink, a: Vec3, b: Vec3, c: Vec3, d: Vec3, colour: Color): void {\r\n tri(sink, a, b, c, colour)\r\n tri(sink, a, c, d, colour)\r\n}\r\n\r\nfunction finish(sink: Sink): BufferGeometry {\r\n const geometry = new BufferGeometry()\r\n geometry.setAttribute('position', new BufferAttribute(new Float32Array(sink.position), 3))\r\n geometry.setAttribute('color', new BufferAttribute(new Float32Array(sink.color), 3))\r\n return geometry\r\n}\r\n\r\n/**\r\n * Axis-aligned box. The kit's most used part: boards, rails, iron straps,\r\n * feet — all of them are this.\r\n */\r\nexport function boxGeometry(\r\n size: Vec3,\r\n centre: Vec3,\r\n colour: Color,\r\n): BufferGeometry {\r\n const sink: Sink = { position: [], color: [] }\r\n const [hx, hy, hz] = [size[0] / 2, size[1] / 2, size[2] / 2]\r\n const [cx, cy, cz] = centre\r\n const v = (sx: number, sy: number, sz: number): Vec3 => [cx + sx * hx, cy + sy * hy, cz + sz * hz]\r\n\r\n quad(sink, v(-1, -1, 1), v(1, -1, 1), v(1, 1, 1), v(-1, 1, 1), colour) // +Z\r\n quad(sink, v(1, -1, -1), v(-1, -1, -1), v(-1, 1, -1), v(1, 1, -1), colour) // -Z\r\n quad(sink, v(1, -1, 1), v(1, -1, -1), v(1, 1, -1), v(1, 1, 1), colour) // +X\r\n quad(sink, v(-1, -1, -1), v(-1, -1, 1), v(-1, 1, 1), v(-1, 1, -1), colour) // -X\r\n quad(sink, v(-1, 1, 1), v(1, 1, 1), v(1, 1, -1), v(-1, 1, -1), colour) // +Y\r\n quad(sink, v(-1, -1, -1), v(1, -1, -1), v(1, -1, 1), v(-1, -1, 1), colour) // -Y\r\n\r\n return finish(sink)\r\n}\r\n\r\n/**\r\n * Chamfered box — the kit's most important primitive. It can taper.\r\n *\r\n * A sharp 90° corner does not exist in nature. The edge of a hand-planed board\r\n * chips, the corner of forged iron rounds over. The chamfer band catches light\r\n * at a different angle than its neighbouring faces, and the object stops being\r\n * a \"box\" and turns into a physical part. The first version of the kit was\r\n * chamferless boxes from end to end and all of it looked like toys.\r\n *\r\n * Because the bottom and top cross sections can be given separately, it stands\r\n * in for both the straight box and the tapering box — keeping the two as\r\n * separate primitives would have permanently left open the risk of chamfering\r\n * one and leaving the other sharp.\r\n *\r\n * Single-facet chamfer (vibe3d modelling rule 2: one facet by default, a second\r\n * only on masses that carry the silhouette). The cost is 44 triangles instead of 12.\r\n *\r\n * Winding: the 6 faces were worked out by hand; the 12 edges and 8 corners\r\n * CORRECT THEMSELVES against the expected outward direction. Deriving the\r\n * winding of twenty pieces by hand invites mistakes, while the expected normal\r\n * is already known — flipping whatever comes out reversed is both shorter and certain.\r\n */\r\nexport function chamferedBoxGeometry(\r\n bottom: readonly [number, number],\r\n top: readonly [number, number],\r\n height: number,\r\n chamfer: number,\r\n centre: Vec3,\r\n colour: Color,\r\n colourTop?: Color,\r\n): BufferGeometry {\r\n const sink: Sink = { position: [], color: [] }\r\n const [cx, cy, cz] = centre\r\n const hy = height / 2\r\n const minHalf = Math.min(bottom[0], bottom[1], top[0], top[1]) / 2\r\n const c = Math.max(1e-6, Math.min(chamfer, minHalf * 0.5, hy * 0.5))\r\n const upper = colourTop ?? colour\r\n\r\n /**\r\n * `full` sits exactly at the end on its axis, the others a chamfer inside.\r\n * The horizontal half-measures are interpolated between the bottom and top\r\n * cross section according to height.\r\n */\r\n const p = (sx: number, sy: number, sz: number, full: 0 | 1 | 2): Vec3 => {\r\n const y = cy + sy * (full === 1 ? hy : hy - c)\r\n const t = height <= 1e-9 ? 0 : (y - (cy - hy)) / height\r\n const hx = (bottom[0] + (top[0] - bottom[0]) * t) / 2\r\n const hz = (bottom[1] + (top[1] - bottom[1]) * t) / 2\r\n return [\r\n cx + sx * (full === 0 ? hx : hx - c),\r\n y,\r\n cz + sz * (full === 2 ? hz : hz - c),\r\n ]\r\n }\r\n const shade = (v: Vec3): Color =>\r\n colourTop ? new Color().copy(colour).lerp(upper, (v[1] - (cy - hy)) / Math.max(1e-9, height)) : colour\r\n\r\n const face = (a: Vec3, b: Vec3, d: Vec3, e: Vec3): void => {\r\n tri(sink, a, b, d, shade(a)); tri(sink, a, d, e, shade(a))\r\n }\r\n face(p(-1, -1, 1, 2), p(1, -1, 1, 2), p(1, 1, 1, 2), p(-1, 1, 1, 2)) // +Z\r\n face(p(1, -1, -1, 2), p(-1, -1, -1, 2), p(-1, 1, -1, 2), p(1, 1, -1, 2)) // -Z\r\n face(p(1, -1, 1, 0), p(1, -1, -1, 0), p(1, 1, -1, 0), p(1, 1, 1, 0)) // +X\r\n face(p(-1, -1, -1, 0), p(-1, -1, 1, 0), p(-1, 1, 1, 0), p(-1, 1, -1, 0)) // -X\r\n face(p(-1, 1, 1, 1), p(1, 1, 1, 1), p(1, 1, -1, 1), p(-1, 1, -1, 1)) // +Y\r\n face(p(-1, -1, -1, 1), p(1, -1, -1, 1), p(1, -1, 1, 1), p(-1, -1, 1, 1)) // -Y\r\n\r\n /** Writes the triangle against the expected outward direction, flipping it if needed. */\r\n const oriented = (a: Vec3, b: Vec3, d: Vec3, outward: readonly number[]): void => {\r\n const e1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]\r\n const e2 = [d[0] - a[0], d[1] - a[1], d[2] - a[2]]\r\n const n = [\r\n e1[1]! * e2[2]! - e1[2]! * e2[1]!,\r\n e1[2]! * e2[0]! - e1[0]! * e2[2]!,\r\n e1[0]! * e2[1]! - e1[1]! * e2[0]!,\r\n ]\r\n const dot = n[0]! * outward[0]! + n[1]! * outward[1]! + n[2]! * outward[2]!\r\n if (dot >= 0) tri(sink, a, b, d, shade(a))\r\n else tri(sink, a, d, b, shade(a))\r\n }\r\n\r\n const signs = [-1, 1] as const\r\n for (const axis of [0, 1, 2] as const) {\r\n const [u, v] = [0, 1, 2].filter((i) => i !== axis) as [0 | 1 | 2, 0 | 1 | 2]\r\n for (const su of signs) for (const sv of signs) {\r\n const at = (along: number, full: 0 | 1 | 2): Vec3 => {\r\n const sign: [number, number, number] = [0, 0, 0]\r\n sign[axis] = along; sign[u] = su; sign[v] = sv\r\n return p(sign[0], sign[1], sign[2], full)\r\n }\r\n const outward = [0, 1, 2].map((i) => (i === u ? su : i === v ? sv : 0))\r\n oriented(at(-1, u), at(-1, v), at(1, v), outward)\r\n oriented(at(-1, u), at(1, v), at(1, u), outward)\r\n }\r\n }\r\n for (const sx of signs) for (const sy of signs) for (const sz of signs) {\r\n oriented(p(sx, sy, sz, 0), p(sx, sy, sz, 1), p(sx, sy, sz, 2), [sx, sy, sz])\r\n }\r\n\r\n return finish(sink)\r\n}\r\n\r\n/**\r\n * Truncated cone / prism: feet, shafts, bowls, flame tongues.\r\n *\r\n * If `colourTop` is given the colour ramps with height — needed for the flame,\r\n * because the base of a flame is not the same colour as its tip.\r\n */\r\nexport function prismGeometry(\r\n radiusBottom: number,\r\n radiusTop: number,\r\n height: number,\r\n segments: number,\r\n centre: Vec3,\r\n colour: Color,\r\n options: { readonly capTop?: boolean; readonly capBottom?: boolean; readonly colourTop?: Color } = {},\r\n): BufferGeometry {\r\n const sink: Sink = { position: [], color: [] }\r\n const { capTop = true, capBottom = true } = options\r\n const top = options.colourTop ?? colour\r\n const [cx, cy, cz] = centre\r\n const low = cy - height / 2\r\n const high = cy + height / 2\r\n const stepAngle = (Math.PI * 2) / segments\r\n const shift = (p: Vec3): Vec3 => [p[0] + cx, p[1], p[2] + cz]\r\n\r\n for (let i = 0; i < segments; i += 1) {\r\n const a0 = i * stepAngle\r\n const a1 = (i + 1) * stepAngle\r\n const l0 = shift(point(a0, radiusBottom, low))\r\n const l1 = shift(point(a1, radiusBottom, low))\r\n const h0 = shift(point(a0, radiusTop, high))\r\n const h1 = shift(point(a1, radiusTop, high))\r\n\r\n // Side face: points outward.\r\n triShaded(sink, l0, l1, h1, colour, colour, top)\r\n triShaded(sink, l0, h1, h0, colour, top, top)\r\n\r\n if (capTop && radiusTop > 0) tri(sink, [cx, high, cz], h0, h1, top)\r\n if (capBottom && radiusBottom > 0) tri(sink, [cx, low, cz], l1, l0, colour)\r\n }\r\n\r\n return finish(sink)\r\n}\r\n\r\n/**\r\n * Turned surface: revolves a profile around the Y axis.\r\n *\r\n * `prismGeometry` is the two-level version of this. When more levels are needed\r\n * this one is used and NO INTERIOR SURFACE FORMS in between — that is the real\r\n * reason to use this instead of stacking prisms on top of each other: stacked\r\n * prisms leave a pair of coincident faces where they touch, a single lathe\r\n * does not.\r\n *\r\n * Everything round in the kit comes out of this: tool shafts and grip bulges,\r\n * conical sockets, pitchfork tines, and later jugs/candlesticks/wheel hubs.\r\n */\r\nexport function latheGeometry(\r\n levels: readonly Level[],\r\n segments: number,\r\n centre: Vec3,\r\n colour: Color,\r\n options: { readonly capTop?: boolean; readonly capBottom?: boolean; readonly colourTop?: Color } = {},\r\n): BufferGeometry {\r\n if (levels.length < 2) throw new Error('latheGeometry needs at least two levels')\r\n const sink: Sink = { position: [], color: [] }\r\n const { capTop = true, capBottom = true } = options\r\n const top = options.colourTop ?? colour\r\n const [cx, cy, cz] = centre\r\n const stepAngle = (Math.PI * 2) / segments\r\n const shift = (p: Vec3): Vec3 => [p[0] + cx, p[1] + cy, p[2] + cz]\r\n const lerp = (t: number): Color => new Color().copy(colour).lerp(top, t)\r\n\r\n for (let i = 0; i < levels.length - 1; i += 1) {\r\n const low = levels[i]!\r\n const high = levels[i + 1]!\r\n const tLow = i / (levels.length - 1)\r\n const tHigh = (i + 1) / (levels.length - 1)\r\n const cLow = lerp(tLow)\r\n const cHigh = lerp(tHigh)\r\n\r\n for (let j = 0; j < segments; j += 1) {\r\n const a0 = j * stepAngle\r\n const a1 = (j + 1) * stepAngle\r\n const l0 = shift(point(a0, low.radius, low.y))\r\n const l1 = shift(point(a1, low.radius, low.y))\r\n const h0 = shift(point(a0, high.radius, high.y))\r\n const h1 = shift(point(a1, high.radius, high.y))\r\n // At a level whose radius is zero that edge collapses to a point; it is\r\n // closed with a single triangle so no degenerate triangle is produced.\r\n if (low.radius <= 1e-6) { triShaded(sink, l0, h1, h0, cLow, cHigh, cHigh); continue }\r\n if (high.radius <= 1e-6) { triShaded(sink, l0, l1, h0, cLow, cLow, cHigh); continue }\r\n triShaded(sink, l0, l1, h1, cLow, cLow, cHigh)\r\n triShaded(sink, l0, h1, h0, cLow, cHigh, cHigh)\r\n }\r\n }\r\n\r\n const first = levels[0]!\r\n const last = levels.at(-1)!\r\n for (let j = 0; j < segments; j += 1) {\r\n const a0 = j * stepAngle\r\n const a1 = (j + 1) * stepAngle\r\n if (capBottom && first.radius > 1e-6) {\r\n tri(sink, shift([0, first.y, 0]),\r\n shift(point(a1, first.radius, first.y)), shift(point(a0, first.radius, first.y)), colour)\r\n }\r\n if (capTop && last.radius > 1e-6) {\r\n tri(sink, shift([0, last.y, 0]),\r\n shift(point(a0, last.radius, last.y)), shift(point(a1, last.radius, last.y)), top)\r\n }\r\n }\r\n return finish(sink)\r\n}\r\n\r\n/**\r\n * A single barrel stave: one slice of the ring, a closed solid with thickness.\r\n *\r\n * Every level has four corners:\r\n * A = outer/start angle B = outer/end angle\r\n * C = inner/end angle D = inner/start angle\r\n */\r\nexport function staveGeometry(\r\n levels: readonly Level[],\r\n angleStart: number,\r\n angleEnd: number,\r\n thickness: number,\r\n colour: Color,\r\n): BufferGeometry {\r\n if (levels.length < 2) throw new Error('staveGeometry needs at least two levels')\r\n\r\n const sink: Sink = { position: [], color: [] }\r\n\r\n const corners = levels.map((level) => {\r\n // Thickness cannot exceed half the thinnest radius; otherwise the inner surface breaks out.\r\n const inner = Math.max(level.radius * 0.5, level.radius - thickness)\r\n return {\r\n a: point(angleStart, level.radius, level.y),\r\n b: point(angleEnd, level.radius, level.y),\r\n c: point(angleEnd, inner, level.y),\r\n d: point(angleStart, inner, level.y),\r\n }\r\n })\r\n\r\n for (let i = 0; i < corners.length - 1; i += 1) {\r\n const low = corners[i]!\r\n const high = corners[i + 1]!\r\n quad(sink, low.a, low.b, high.b, high.a, colour) // outer face → points outward\r\n quad(sink, low.c, low.d, high.d, high.c, colour) // inner face → points at the axis\r\n quad(sink, low.d, low.a, high.a, high.d, colour) // start edge\r\n quad(sink, low.b, low.c, high.c, high.b, colour) // end edge\r\n }\r\n\r\n const top = corners.at(-1)!\r\n const bottom = corners[0]!\r\n quad(sink, top.a, top.b, top.c, top.d, colour) // top cap → +Y\r\n quad(sink, bottom.d, bottom.c, bottom.b, bottom.a, colour) // bottom cap → -Y\r\n\r\n return finish(sink)\r\n}\r\n\r\n/**\r\n * Iron hoop: a ring with a rectangular cross section.\r\n *\r\n * The inner surface is deliberately not generated — the body it leans against\r\n * hides it from every camera. A quarter of the triangle budget is won here.\r\n */\r\nexport function bandGeometry(\r\n radius: number,\r\n y: number,\r\n height: number,\r\n thickness: number,\r\n segments: number,\r\n colour: Color,\r\n options: {\r\n /**\r\n * Generate the inner face as well.\r\n *\r\n * Not generated by default, because a hoop always wraps a body and the\r\n * inner face is not visible — not generating it is a free triangle saving.\r\n * But a free-standing ring (the cord of a sack, the tie of a bale) becomes\r\n * a solid that is NOT CLOSED this way, and the \"no reversed faces\" check in\r\n * the validation rightly fails. This flag is for that case.\r\n */\r\n readonly inner?: boolean\r\n } = {},\r\n): BufferGeometry {\r\n const sink: Sink = { position: [], color: [] }\r\n const half = height / 2\r\n const inner = radius - thickness\r\n const stepAngle = (Math.PI * 2) / segments\r\n\r\n for (let i = 0; i < segments; i += 1) {\r\n const a0 = i * stepAngle\r\n const a1 = (i + 1) * stepAngle\r\n\r\n const outerLow0 = point(a0, radius, y - half)\r\n const outerLow1 = point(a1, radius, y - half)\r\n const outerHigh0 = point(a0, radius, y + half)\r\n const outerHigh1 = point(a1, radius, y + half)\r\n const innerHigh0 = point(a0, inner, y + half)\r\n const innerHigh1 = point(a1, inner, y + half)\r\n const innerLow0 = point(a0, inner, y - half)\r\n const innerLow1 = point(a1, inner, y - half)\r\n\r\n quad(sink, outerLow0, outerLow1, outerHigh1, outerHigh0, colour) // outer → outward\r\n quad(sink, outerHigh0, outerHigh1, innerHigh1, innerHigh0, colour) // top → +Y\r\n quad(sink, innerLow0, innerLow1, outerLow1, outerLow0, colour) // bottom → -Y\r\n // Inner face: the corner order is the reverse of the outer one so the normal points AT THE AXIS.\r\n if (options.inner) {\r\n quad(sink, innerHigh0, innerHigh1, innerLow1, innerLow0, colour)\r\n }\r\n }\r\n\r\n return finish(sink)\r\n}\r\n\r\n/**\r\n * Fan disc: barrel head, bowl bottom.\r\n *\r\n * So that it reads as built from several boards rather than one piece of wood,\r\n * every triangle falls into a \"board band\" according to the X position of its\r\n * centre, and the tone of that band is written into the vertex colour. The\r\n * geometry cost is zero.\r\n */\r\nexport function headGeometry(\r\n radius: number,\r\n y: number,\r\n segments: number,\r\n facing: 'up' | 'down',\r\n colour: Color,\r\n plankCount: number,\r\n plankShade: number,\r\n): BufferGeometry {\r\n const sink: Sink = { position: [], color: [] }\r\n const centre: Vec3 = [0, y, 0]\r\n const stepAngle = (Math.PI * 2) / segments\r\n const plankWidth = (radius * 2) / Math.max(1, plankCount)\r\n const tint = new Color()\r\n\r\n for (let i = 0; i < segments; i += 1) {\r\n const p0 = point(i * stepAngle, radius, y)\r\n const p1 = point((i + 1) * stepAngle, radius, y)\r\n\r\n // The boards are strips sliced along the X axis; whichever strip the X of\r\n // the triangle's centroid falls into gives it its tone.\r\n const centroidX = (p0[0] + p1[0]) / 3\r\n const band = Math.floor((centroidX + radius) / plankWidth)\r\n tint.copy(colour).multiplyScalar(1 + (band % 2 === 0 ? plankShade : -plankShade))\r\n\r\n if (facing === 'up') tri(sink, centre, p0, p1, tint)\r\n else tri(sink, centre, p1, p0, tint)\r\n }\r\n\r\n return finish(sink)\r\n}\r\n\r\n/**\r\n * Tapering box: a body whose bottom and top rectangles may differ.\r\n *\r\n * Anvil horn, stool leg, fence post tip, tool handle — every \"box but carved\"\r\n * part of the kit is this. The winding follows the same logic as boxGeometry.\r\n */\r\nexport function taperedBoxGeometry(\r\n bottom: readonly [number, number],\r\n top: readonly [number, number],\r\n height: number,\r\n centre: Vec3,\r\n colour: Color,\r\n colourTop?: Color,\r\n): BufferGeometry {\r\n const sink: Sink = { position: [], color: [] }\r\n const [cx, cy, cz] = centre\r\n const low = cy - height / 2\r\n const high = cy + height / 2\r\n const upper = colourTop ?? colour\r\n\r\n // b = bottom corners, t = top corners; both in the same order:\r\n // (-x,+z) (+x,+z) (+x,-z) (-x,-z)\r\n const corner = (size: readonly [number, number], y: number, sx: number, sz: number): Vec3 =>\r\n [cx + (sx * size[0]) / 2, y, cz + (sz * size[1]) / 2]\r\n\r\n const b0 = corner(bottom, low, -1, 1), b1 = corner(bottom, low, 1, 1)\r\n const b2 = corner(bottom, low, 1, -1), b3 = corner(bottom, low, -1, -1)\r\n const t0 = corner(top, high, -1, 1), t1 = corner(top, high, 1, 1)\r\n const t2 = corner(top, high, 1, -1), t3 = corner(top, high, -1, -1)\r\n\r\n const side = (bl: Vec3, br: Vec3, tr: Vec3, tl: Vec3): void => {\r\n triShaded(sink, bl, br, tr, colour, colour, upper)\r\n triShaded(sink, bl, tr, tl, colour, upper, upper)\r\n }\r\n side(b0, b1, t1, t0) // +Z\r\n side(b2, b3, t3, t2) // -Z\r\n side(b1, b2, t2, t1) // +X\r\n side(b3, b0, t0, t3) // -X\r\n\r\n quad(sink, t0, t1, t2, t3, upper) // top → +Y\r\n quad(sink, b3, b2, b1, b0, colour) // bottom → -Y\r\n return finish(sink)\r\n}\r\n\r\n/**\r\n * Square-section bar swept along an arc — bucket handle, ring, hook.\r\n *\r\n * The cross section is laid out counter-clockwise relative to the sweep\r\n * direction (the tangent); that is what makes the outer faces point outward.\r\n * The arc is generated in the XY plane, the model rotates it as it likes.\r\n */\r\nexport function arcBarGeometry(\r\n radius: number,\r\n thickness: number,\r\n fromAngle: number,\r\n toAngle: number,\r\n segments: number,\r\n centre: Vec3,\r\n colour: Color,\r\n): BufferGeometry {\r\n const sink: Sink = { position: [], color: [] }\r\n const h = thickness / 2\r\n const [cx, cy, cz] = centre\r\n const rings: Vec3[][] = []\r\n\r\n for (let i = 0; i <= segments; i += 1) {\r\n const a = fromAngle + ((toAngle - fromAngle) * i) / segments\r\n // p: the point on the arc. r: the radial direction. z: the plane normal.\r\n const px = cx + Math.cos(a) * radius\r\n const py = cy + Math.sin(a) * radius\r\n const rx = Math.cos(a), ry = Math.sin(a)\r\n const at = (su: number, sv: number): Vec3 =>\r\n [px + sv * h * rx, py + sv * h * ry, cz + su * h]\r\n // u = the plane normal, v = the radial direction. Since u×v equals the\r\n // tangent, this ordering is counter-clockwise relative to the sweep direction.\r\n rings.push([at(1, 1), at(-1, 1), at(-1, -1), at(1, -1)])\r\n }\r\n\r\n for (let i = 0; i < segments; i += 1) {\r\n const a = rings[i]!, b = rings[i + 1]!\r\n for (let j = 0; j < 4; j += 1) {\r\n const k = (j + 1) % 4\r\n quad(sink, a[j]!, a[k]!, b[k]!, b[j]!, colour)\r\n }\r\n }\r\n\r\n const first = rings[0]!, last = rings[segments]!\r\n quad(sink, first[0]!, first[3]!, first[2]!, first[1]!, colour) // start cap\r\n quad(sink, last[0]!, last[1]!, last[2]!, last[3]!, colour) // end cap\r\n return finish(sink)\r\n}\r\n\r\nexport interface SheetLevel {\r\n /** Vertical position. */\r\n readonly y: number\r\n /** Half width at that height. */\r\n readonly halfWidth: number\r\n /** Sheet thickness. */\r\n readonly thickness: number\r\n /** Curve height: how far the edges rise relative to the middle. 0 = flat. */\r\n readonly curve: number\r\n}\r\n\r\n/**\r\n * Dished sheet — a single piece, a seamless concave surface.\r\n *\r\n * I tried twice to build the shovel blade from three separate flat panels and\r\n * both times the result was \"three boards side by side\": because each panel\r\n * rotates around its own centre, a step is left between them and the eye did\r\n * not read it as one surface.\r\n *\r\n * The right way is to produce ONE sheet whose cross section is curved. The\r\n * cross section is an arc bent by `curve` at every level; width and thickness\r\n * can change from level to level. The hollow faces the +Z direction.\r\n *\r\n * Besides the shovel, the shield, the trough, the roof covering and the mill\r\n * sail all want this.\r\n */\r\nexport function dishedSheetGeometry(\r\n levels: readonly SheetLevel[],\r\n segments: number,\r\n colour: Color,\r\n colourTop?: Color,\r\n): BufferGeometry {\r\n if (levels.length < 2) throw new Error('dishedSheetGeometry needs at least two levels')\r\n const sink: Sink = { position: [], color: [] }\r\n const top = colourTop ?? colour\r\n const shade = (i: number): Color =>\r\n colourTop ? new Color().copy(colour).lerp(top, i / (levels.length - 1)) : colour\r\n\r\n // front[i][j] / back[i][j]: level i, position j along the cross section\r\n const front: Vec3[][] = []\r\n const back: Vec3[][] = []\r\n for (const level of levels) {\r\n const f: Vec3[] = []\r\n const b: Vec3[] = []\r\n for (let j = 0; j <= segments; j += 1) {\r\n const u = (j / segments) * 2 - 1 // -1 .. +1\r\n const x = u * level.halfWidth\r\n // Parabolic cross section: 0 in the middle, `curve` at the edges. The\r\n // rise of the edges is what forms the hollow.\r\n const z = level.curve * u * u\r\n f.push([x, level.y, z + level.thickness / 2])\r\n b.push([x, level.y, z - level.thickness / 2])\r\n }\r\n front.push(f)\r\n back.push(b)\r\n }\r\n\r\n const last = levels.length - 1\r\n for (let i = 0; i < last; i += 1) {\r\n for (let j = 0; j < segments; j += 1) {\r\n // Front face: points at +Z. (a→b = +X, a→c = +X+Y, cross product +Z.)\r\n triShaded(sink, front[i]![j]!, front[i]![j + 1]!, front[i + 1]![j + 1]!,\r\n shade(i), shade(i), shade(i + 1))\r\n triShaded(sink, front[i]![j]!, front[i + 1]![j + 1]!, front[i + 1]![j]!,\r\n shade(i), shade(i + 1), shade(i + 1))\r\n // Back face: reversed winding.\r\n triShaded(sink, back[i]![j]!, back[i + 1]![j + 1]!, back[i]![j + 1]!,\r\n shade(i), shade(i + 1), shade(i))\r\n triShaded(sink, back[i]![j]!, back[i + 1]![j]!, back[i + 1]![j + 1]!,\r\n shade(i), shade(i + 1), shade(i + 1))\r\n }\r\n\r\n // Side edges: the right one points at +X, the left one at -X.\r\n quad(sink, front[i]![segments]!, back[i]![segments]!,\r\n back[i + 1]![segments]!, front[i + 1]![segments]!, shade(i))\r\n quad(sink, back[i]![0]!, front[i]![0]!,\r\n front[i + 1]![0]!, back[i + 1]![0]!, shade(i))\r\n }\r\n\r\n // Top and bottom edge strips.\r\n for (let j = 0; j < segments; j += 1) {\r\n quad(sink, front[last]![j]!, front[last]![j + 1]!,\r\n back[last]![j + 1]!, back[last]![j]!, shade(last))\r\n quad(sink, back[0]![j]!, back[0]![j + 1]!,\r\n front[0]![j + 1]!, front[0]![j]!, shade(0))\r\n }\r\n\r\n return finish(sink)\r\n}\r\n\r\n/**\r\n * Reverses the winding of every triangle, i.e. turns all the normals around.\r\n *\r\n * Needed for vessels whose inside is visible: the outer surface of a bowl must\r\n * point outward, the inner surface inward. Instead of writing the two\r\n * separately, we generate the same cone and flip one of them.\r\n */\r\nexport function flipGeometry(geometry: BufferGeometry): BufferGeometry {\r\n for (const name of ['position', 'color'] as const) {\r\n const attribute = geometry.getAttribute(name)\r\n if (!attribute) continue\r\n const array = attribute.array as Float32Array\r\n const stride = attribute.itemSize\r\n // Swapping the 2nd and 3rd corner of a triangle reverses the winding.\r\n for (let i = 0; i < attribute.count; i += 3) {\r\n for (let k = 0; k < stride; k += 1) {\r\n const b = (i + 1) * stride + k\r\n const c = (i + 2) * stride + k\r\n const swap = array[b]!\r\n array[b] = array[c]!\r\n array[c] = swap\r\n }\r\n }\r\n attribute.needsUpdate = true\r\n }\r\n return geometry\r\n}\r\n\r\n/**\r\n * Reduces parts that share the same material into a single geometry — one draw\r\n * call per material. The normals are computed AFTER merging: on non-indexed\r\n * geometry that gives every triangle its own normal, i.e. flat shading.\r\n */\r\nexport function mergeColoured(geometries: readonly BufferGeometry[]): BufferGeometry {\r\n // The normals of the inputs are dropped. The reason is a trap: because this\r\n // function computes normals AFTER merging, its output has a `normal`\r\n // attribute while the raw geometries do not. Trying to merge the two\r\n // together made mergeGeometries fail with \"attribute counts do not match\".\r\n // Since they are recomputed below anyway, the input normal data is worthless.\r\n for (const geometry of geometries) geometry.deleteAttribute('normal')\r\n\r\n const merged = mergeGeometries(geometries as BufferGeometry[], false)\r\n if (!merged) throw new Error('Could not merge geometries: attribute sets do not match')\r\n for (const geometry of geometries) geometry.dispose()\r\n merged.computeVertexNormals()\r\n return merged\r\n}\r\n\r\n/**\r\n * Bends a straight body into an arc (along the Y axis, in the YZ plane).\r\n *\r\n * Written for pitchfork tines. A straight tine, however thick it is, looks\r\n * like a technical drawing; a slight curve turns it into a forged tool. Hooks,\r\n * horns and scythes have the same need.\r\n *\r\n * The simple \"rotate every point in proportion to its own height\" approach\r\n * stretched the body and thinned it. The transform here is a real arc mapping:\r\n * the body is WRAPPED onto a circle of radius 1/curvature, so the length of the\r\n * centre line is preserved.\r\n *\r\n * TWO TRAPS, both found by measuring:\r\n *\r\n * 1. The arc is wrapped around y=0, so THE RESULT DEPENDS ON WHERE THE GEOMETRY\r\n * SITS IN Y. A bar whose base is at the origin really does curve; a body\r\n * CENTRED on y=0 bends symmetrically — its two ends go the same way, its\r\n * middle stays put, and the silhouette barely changes at all. That is exactly\r\n * what happened with the hoe blade: on a 0.235 m blade the bend produced\r\n * 14 mm of displacement but DROPPED the Z range from 0.0337 to 0.0327. Build\r\n * the same blade with its base at the origin and bend it, and the offset is\r\n * 44 mm and the range 0.078. START whatever you want to bend at the origin.\r\n *\r\n * 2. The arc is only as smooth as the number of CROSS SECTIONS the geometry has\r\n * along the Y axis. A two-level box (chamferedBoxGeometry) bent gives not an\r\n * arc but a skewed box. A real arc needs a body with intermediate levels,\r\n * like `latheGeometry`.\r\n *\r\n * @param curvature 1/radius. A positive value bends toward +Z. 0 does nothing.\r\n */\r\nexport function bendGeometry(geometry: BufferGeometry, curvature: number): BufferGeometry {\r\n if (Math.abs(curvature) < 1e-9) return geometry\r\n const position = geometry.getAttribute('position')\r\n\r\n // A bend needs something to bend.\r\n //\r\n // Every vertex is mapped by its own Y, so the curve only exists where there\r\n // are Y levels to sample it at. Hand this a `boxGeometry` -- two levels, top\r\n // and bottom -- and there is no midpoint to displace: the top face swings to\r\n // one angle, the bottom to another, and the result is a sheared wedge that\r\n // looks nothing like an arc. It fails silently, which is the expensive part.\r\n //\r\n // This session found the same mistake in three separate models: the\r\n // tankard's handle, and both the brace and the curl of the tavern sign. In\r\n // each the author had written a curve, the geometry had never curved, and\r\n // the renders had been shipping the wedge. Three is a pattern, so the\r\n // helper refuses rather than waiting for a fourth.\r\n //\r\n // The fix is never to nudge the curvature -- it is to give the piece levels\r\n // (a lathe or a stack of them), or to use `arcBarGeometry`, which sweeps a\r\n // real arc and takes the two things that actually matter: where the ends go.\r\n const seen = new Set<number>()\r\n for (let i = 0; i < position.count && seen.size < 3; i += 1) {\r\n seen.add(Math.round(position.getY(i) * 1e5))\r\n }\r\n if (seen.size < 3) {\r\n throw new Error(\r\n `bendGeometry: geometry has ${seen.size} distinct Y level(s); a bend needs at least 3. ` +\r\n 'Two levels shear into a wedge instead of curving. Build the piece with ' +\r\n 'levels (latheGeometry / staveGeometry) or use arcBarGeometry.',\r\n )\r\n }\r\n\r\n const radius = 1 / curvature\r\n for (let i = 0; i < position.count; i += 1) {\r\n const y = position.getY(i)\r\n const z = position.getZ(i)\r\n const angle = y * curvature\r\n const sin = Math.sin(angle)\r\n const cos = Math.cos(angle)\r\n // Centre line + the cross section's offset perpendicular to the tangent.\r\n position.setY(i, radius * sin - z * sin)\r\n position.setZ(i, radius * (1 - cos) + z * cos)\r\n }\r\n position.needsUpdate = true\r\n geometry.computeVertexNormals()\r\n return geometry\r\n}\r\n\r\n/** Deterministic hash derived from position. The same point always gives the same value. */\r\nfunction positionHash(x: number, y: number, z: number, salt: number): number {\r\n // The point is rounded to a 0.1 mm grid: \"identical\" corners getting\r\n // different hashes because of floating point noise was the one bug that tore\r\n // the surface open.\r\n let h = Math.imul(Math.round(x * 1e4) | 0, 0x27d4eb2d)\r\n h ^= Math.imul(Math.round(y * 1e4) | 0, 0x165667b1)\r\n h ^= Math.imul(Math.round(z * 1e4) | 0, 0x9e3779b1)\r\n h = Math.imul(h ^ salt, 0x85ebca6b)\r\n h ^= h >>> 13\r\n return ((h >>> 0) / 0xffffffff) * 2 - 1\r\n}\r\n\r\nexport interface RoughenOptions {\r\n /** For distorting the same geometry in a different way. */\r\n readonly salt?: number\r\n /** Deviation multiplier on the Y axis. Kept low on the straw bale. */\r\n readonly scaleY?: number\r\n}\r\n\r\n/**\r\n * Makes the surface irregular: every corner shifts by a fixed amount derived\r\n * from its own position.\r\n *\r\n * Why it is derived from position: these geometries are NON-INDEXED, i.e. every\r\n * triangle carries its own corners and three or four copies sit at one point.\r\n * Moving the corners independently tears the surface — on the first attempt the\r\n * straw bale ended up riddled with holes. Because the position hash gives all\r\n * the copies at the same point the SAME shift, the surface stays closed.\r\n *\r\n * This is the only thing that makes straw look like straw: a tidy box looks\r\n * like a sponge whatever colour you give it.\r\n */\r\nexport function roughenGeometry(\r\n geometry: BufferGeometry,\r\n amount: number,\r\n options: RoughenOptions = {},\r\n): BufferGeometry {\r\n if (amount <= 0) return geometry\r\n const salt = options.salt ?? 0\r\n const scaleY = options.scaleY ?? 1\r\n const position = geometry.getAttribute('position')\r\n for (let i = 0; i < position.count; i += 1) {\r\n const x = position.getX(i)\r\n const y = position.getY(i)\r\n const z = position.getZ(i)\r\n position.setXYZ(\r\n i,\r\n x + positionHash(x, y, z, salt + 1) * amount,\r\n y + positionHash(x, y, z, salt + 2) * amount * scaleY,\r\n z + positionHash(x, y, z, salt + 3) * amount,\r\n )\r\n }\r\n position.needsUpdate = true\r\n geometry.computeVertexNormals()\r\n return geometry\r\n}\r\n\r\nexport interface MottleOptions {\r\n /** For giving the same geometry a different pattern. */\r\n readonly salt?: number\r\n /**\r\n * The size of the specks (metres). Because positions are rounded to this\r\n * grid and then hashed, corners in the same cell take the same tone — so what\r\n * forms is not noise but MOTTLE. A small value gives sand, a large one a\r\n * mottled surface.\r\n */\r\n readonly cell?: number\r\n /** The ratio of the hue shift to the brightness shift. A high value colours the mottle. */\r\n readonly hue?: number\r\n}\r\n\r\n/**\r\n * Works surface mottle into the vertex colours.\r\n *\r\n * This is this kit's answer to the question \"what are we going to do about\r\n * texture?\". A bitmap texture would have wanted three things: UV coordinates\r\n * (our geometry has none), image files the registry would have to carry, and a\r\n * change to the kit's identity. All three cost more than they give back.\r\n *\r\n * Instead, a mottle pattern derived from the surface's OWN position is used.\r\n * `bakeOcclusion` produced shadow from the SHAPE of the surface; this gives the\r\n * surface a material texture. Together, with no texture file at all, the two\r\n * make a flat-coloured lowpoly surface look like material.\r\n *\r\n * The limit should be stated honestly: the specks are sampled at the corners of\r\n * the geometry, so triangle density sets their resolution. On a wide, sparsely\r\n * subdivided surface shrinking `cell` does not help — there the cure is to\r\n * subdivide the triangle, which eats into the lowpoly budget.\r\n */\r\nexport function mottleGeometry(\r\n geometry: BufferGeometry,\r\n amount: number,\r\n options: MottleOptions = {},\r\n): BufferGeometry {\r\n const colour = geometry.getAttribute('color')\r\n if (!colour || amount <= 0) return geometry\r\n const position = geometry.getAttribute('position')\r\n const salt = options.salt ?? 0\r\n const cell = options.cell ?? 0.05\r\n const hue = options.hue ?? 0.35\r\n\r\n for (let i = 0; i < colour.count; i += 1) {\r\n const x = Math.round(position.getX(i) / cell) * cell\r\n const y = Math.round(position.getY(i) / cell) * cell\r\n const z = Math.round(position.getZ(i) / cell) * cell\r\n const shade = 1 + positionHash(x, y, z, salt + 7) * amount\r\n // A small difference between the channels: on a real material a lightened\r\n // spot does not just get brighter, it also loses a little saturation.\r\n const warm = positionHash(x, y, z, salt + 8) * amount * hue\r\n colour.setXYZ(\r\n i,\r\n Math.max(0, colour.getX(i) * (shade + warm)),\r\n Math.max(0, colour.getY(i) * shade),\r\n Math.max(0, colour.getZ(i) * (shade - warm)),\r\n )\r\n }\r\n colour.needsUpdate = true\r\n return geometry\r\n}\r\n",
30
+ "hash": "b47141c115c48d48e3a4c643fe9634343458288688ea586f044e6a7e7cdbaad5"
31
+ },
32
+ {
33
+ "path": "models/core/index.ts",
34
+ "target": "{models}/medieval-kit/core/index.ts",
35
+ "content": "export { createRandom, jitter } from './random.ts'\r\nexport {\r\n arcBarGeometry,\r\n bandGeometry,\r\n bendGeometry,\r\n boxGeometry,\r\n chamferedBoxGeometry,\r\n dishedSheetGeometry,\r\n flipGeometry,\r\n headGeometry,\r\n latheGeometry,\r\n mergeColoured,\r\n mottleGeometry,\r\n prismGeometry,\r\n roughenGeometry,\r\n staveGeometry,\r\n taperedBoxGeometry,\r\n} from './geometry.ts'\r\nexport type { Level, MottleOptions, RoughenOptions, SheetLevel, Vec3 } from './geometry.ts'\r\nexport { bakeOcclusion } from './occlusion.ts'\r\nexport type { OcclusionOptions } from './occlusion.ts'\r\nexport { createKitModel } from './kit.ts'\r\nexport type { BuildContext, BuiltPart, KitModelOptions, PartBody, RuntimeContext } from './kit.ts'\r\nexport { createPart } from './parts.ts'\r\nexport type { PartSlot } from './parts.ts'\r\nexport { ironTint, steelTint, toolShaft, toolSocket } from './tool.ts'\r\nexport type { ShaftOptions, SocketOptions, ToolShaft } from './tool.ts'\r\nexport { MEDIEVAL_PALETTE, createMedievalMaterials } from './materials.ts'\r\nexport { createTinter } from './tint.ts'\r\nexport type { Tinter } from './tint.ts'\r\nexport type { MedievalPalette, MedievalSlot } from './materials.ts'\r\n",
36
+ "hash": "d7a32eff9c665260013556f95650f827ea8abb7fbe3f65676f53a9a7b4e8c183"
37
+ },
38
+ {
39
+ "path": "models/core/kit.ts",
40
+ "target": "{models}/medieval-kit/core/kit.ts",
41
+ "content": "import { Group, Mesh, type BufferGeometry, type Material } from 'three'\r\n\r\nimport type { ConfigureResult, MaterialBindings, ModelInstance, PartHandle } from '@vibe3d/model.ts'\r\nimport { ResourceScope } from '@vibe3d/ownership.ts'\r\n\r\nimport type { Vec3 } from './geometry.ts'\r\nimport { mottleGeometry } from './geometry.ts'\r\nimport { bakeOcclusion, type OcclusionOptions } from './occlusion.ts'\r\nimport { createPart, type PartSlot } from './parts.ts'\r\nimport { createRandom } from './random.ts'\r\nimport { createMedievalMaterials, type MedievalSlot, type SlotMaterial } from './materials.ts'\r\n\r\n/**\r\n * Kit model scaffold.\r\n *\r\n * Every model has to set up the same contract: resource ownership, material\r\n * resolution and overrides, fixed anchor + replaceable content, a configure()\r\n * that does not break identity, an idempotent dispose(). Writing that by hand\r\n * in every model means ~80 lines of repetition plus a chance to get it wrong\r\n * on each repetition — and indeed I got the anchor/content split wrong in the\r\n * first three models at once.\r\n *\r\n * From here on, writing a model is just producing geometry: `build` returns a\r\n * part name → geometry mapping, this function handles the rest.\r\n */\r\n\r\n/** The body of a part that uses a single material slot. */\r\nexport interface PartBody<S extends string> {\r\n readonly slot: S\r\n readonly geometry: BufferGeometry\r\n}\r\n\r\nexport interface BuiltPart<S extends string> extends PartBody<S> {\r\n /**\r\n * Extra bodies belonging to the same part that use a DIFFERENT slot.\r\n *\r\n * Parts are sibling children of the root, so when one moves the others\r\n * cannot follow it. A chest's lid is oak boards and iron bands and the\r\n * lock's hasp all at once, and the three MUST rotate together — if they were\r\n * separate parts, the bands would hang in mid-air as the lid opened.\r\n *\r\n * So this field does not preserve the assumption \"one part = one mesh\", it\r\n * preserves the principle \"one part = one meaning\". The meaning is not\r\n * split, only the material is.\r\n */\r\n readonly extras?: readonly PartBody<S>[]\r\n /**\r\n * The part's own centre of rotation (in model space).\r\n *\r\n * When given, the anchor is positioned here and the geometry is assumed to\r\n * have been written RELATIVE TO THIS POINT. This is the only thing needed\r\n * for a chest lid to rotate around its hinge: the lid geometry is produced\r\n * at the hinge origin, the anchor is moved to the hinge, and\r\n * `anchor.rotation.x` now opens the lid.\r\n *\r\n * Objects the consumer attached to the anchor join this rotation too — a\r\n * candle placed on top of the lid rises with the lid. That is the correct\r\n * behaviour.\r\n */\r\n readonly origin?: Vec3\r\n}\r\n\r\nexport interface BuildContext<C, S extends MedievalSlot> {\r\n readonly config: Readonly<C>\r\n /** Seed-bound deterministic randomness. Starts over on every rebuild. */\r\n readonly random: () => number\r\n /** The slot's current material (the override, if there is one). */\r\n resolve(slot: S): Material\r\n /** The slot's default material — even if it has been overridden. */\r\n readonly defaults: Pick<SlotMaterial, S>\r\n}\r\n\r\n/**\r\n * The runtime context handed to actions and to `update`.\r\n *\r\n * It gives access to the PARTS, not to the geometry: an action must not\r\n * trigger a rebuild, it should only move something in the scene graph. Opening\r\n * the lid does not change the model's identity, so it is not `configure()`'s\r\n * job.\r\n */\r\nexport interface RuntimeContext<C, P extends string> {\r\n readonly parts: Record<P, PartHandle<Group>>\r\n getConfig(): Readonly<C>\r\n}\r\n\r\nexport interface KitModelOptions<\r\n C extends { seed: number },\r\n S extends MedievalSlot,\r\n P extends string,\r\n A extends object = Record<string, never>,\r\n> {\r\n /** Registry item name; used in mesh names and in userData. */\r\n readonly id: string\r\n readonly defaults: C\r\n readonly slots: readonly S[]\r\n /** Part name → geometry. `undefined` means absent in that configuration. */\r\n build(context: BuildContext<C, S>): Record<P, BuiltPart<S> | undefined>\r\n /**\r\n * Ambient occlusion to bake into the vertex colours. `false` turns it off.\r\n *\r\n * All parts are evaluated TOGETHER: the place where a board darkens is the\r\n * surface of the neighbouring post, not its own surface.\r\n */\r\n readonly occlusion?: OcclusionOptions | false\r\n /**\r\n * Surface mottle written into the vertex colours. `false` turns it off.\r\n *\r\n * The amount is scaled BY SLOT (see the table below): burnished steel is not\r\n * mottled, straw is mottled badly. The model does not have to do anything\r\n * else.\r\n */\r\n readonly mottle?: { readonly amount?: number; readonly cell?: number } | false\r\n /**\r\n * The model's typed actions. Set up once, after the first build — so state\r\n * held in the closure (is the lid open, which flame phase) survives a\r\n * rebuild.\r\n */\r\n actions?(context: RuntimeContext<C, P>): A\r\n /** Called each frame. If the consumer never calls it, the model stays fully static. */\r\n update?(deltaSeconds: number, context: RuntimeContext<C, P>): void\r\n}\r\n\r\n/**\r\n * Mottle multiplier per slot.\r\n *\r\n * The rule comes from the physics of the material: the more polished a surface\r\n * is, the more it becomes a SINGLE COLOUR, because the light reaching the eye\r\n * comes from the reflection rather than from the surface's own pigment. For\r\n * straw and cloth the exact opposite holds.\r\n *\r\n * Tying this to the slot is better than tuning it separately in every model:\r\n * within the kit the same material looks the same everywhere.\r\n */\r\nconst MOTTLE_BY_SLOT: Readonly<Record<MedievalSlot, number>> = {\r\n straw: 1.35,\r\n cloth: 1.15,\r\n oak: 1,\r\n char: 0.85,\r\n leather: 0.7,\r\n produce: 0.6,\r\n iron: 0.5,\r\n brass: 0.35,\r\n steel: 0.22,\r\n glass: 0.15,\r\n // Between timber and iron. Rubble masonry varies block to block, but each\r\n // block is fairly even in itself, so the mottle wants to read at the scale\r\n // of a stone rather than of a grain.\r\n stone: 0.75,\r\n ember: 0, // a flame is not mottled: its colour is already the final colour\r\n water: 0.2,\r\n}\r\n\r\nexport function createKitModel<\r\n C extends { seed: number },\r\n S extends MedievalSlot,\r\n P extends string,\r\n A extends object = Record<string, never>,\r\n>(\r\n options: KitModelOptions<C, S, P, A>,\r\n overrides: Partial<C> = {},\r\n): ModelInstance<C, Record<P, PartHandle<Group>>, A> {\r\n let config: C = { ...options.defaults, ...overrides }\r\n\r\n // The resources the model owns. Materials handed in by the consumer never\r\n // enter here, so dispose() does not touch them.\r\n const scope = new ResourceScope()\r\n const defaults = createMedievalMaterials(scope, options.slots)\r\n const overridesBySlot = new Map<S, Material>()\r\n const resolve = (slot: S): Material => overridesBySlot.get(slot) ?? defaults[slot]\r\n\r\n const root = new Group()\r\n root.name = options.id\r\n\r\n const parts = {} as Record<P, PartSlot>\r\n let owned: BufferGeometry[] = []\r\n\r\n function build(): void {\r\n for (const geometry of owned) geometry.dispose()\r\n owned = []\r\n\r\n const produced = options.build({\r\n config,\r\n random: createRandom(config.seed),\r\n resolve,\r\n defaults,\r\n })\r\n\r\n const built = Object.values(produced)\r\n .filter((part): part is BuiltPart<S> => part !== undefined)\r\n const geometriesOf = (part: BuiltPart<S>): BufferGeometry[] =>\r\n [part, ...(part.extras ?? [])]\r\n // A flame neither TAKES shadow nor CASTS one. `ember` is an unlit\r\n // material, so its vertex colour is the final colour going straight to\r\n // the screen — if occlusion darkened it, the flame would go out. The\r\n // rule is tied to the slot itself, not to a per-model flag: it must\r\n // not be possible to forget it.\r\n .filter((body) => body.slot !== 'ember')\r\n .map((body) => body.geometry)\r\n\r\n if (options.occlusion !== false) {\r\n // Occlusion has to be computed in ASSEMBLY space: a lid written at its\r\n // own origin looks as though it sits inside the body rather than beside\r\n // it, and darkens the wrong places. So everything is first moved into\r\n // place, baked, then moved back — the resulting geometries stay\r\n // hinge-local.\r\n const moved = built.filter((part) => part.origin !== undefined)\r\n for (const part of moved) {\r\n const [x, y, z] = part.origin!\r\n for (const geometry of geometriesOf(part)) geometry.translate(x, y, z)\r\n }\r\n bakeOcclusion(built.flatMap(geometriesOf), options.occlusion ?? {})\r\n for (const part of moved) {\r\n const [x, y, z] = part.origin!\r\n for (const geometry of geometriesOf(part)) geometry.translate(-x, -y, -z)\r\n }\r\n }\r\n\r\n if (options.mottle !== false) {\r\n const amount = options.mottle?.amount ?? 0.125\r\n // The mottle size is derived from the model's scale, but WITH ABSOLUTE\r\n // BOUNDS.\r\n //\r\n // The bounds were added later and closed a real bug: with scale used on\r\n // its own, a 4.89 m fence got a 0.27 m cell. The fence's post is 0.09 m\r\n // — so the entire post fell into ONE cell and came out completely flat.\r\n // The model paid the cost of the texture system and got nothing back.\r\n //\r\n // The truth is this: wood grain mottle is 2–8 cm and has nothing to do\r\n // with the size of the object. A fence post and a tankard stave come out\r\n // of the same tree.\r\n const extent = built.reduce((largest, part) => {\r\n part.geometry.computeBoundingBox()\r\n const box = part.geometry.boundingBox\r\n if (!box) return largest\r\n return Math.max(largest, box.max.x - box.min.x, box.max.y - box.min.y, box.max.z - box.min.z)\r\n }, 0)\r\n const cell = options.mottle?.cell ?? Math.min(0.08, Math.max(0.015, extent * 0.055))\r\n for (const part of built) {\r\n for (const body of [part, ...(part.extras ?? [])]) {\r\n mottleGeometry(body.geometry, amount * MOTTLE_BY_SLOT[body.slot], { cell, salt: 3 })\r\n }\r\n }\r\n }\r\n\r\n for (const [name, part] of Object.entries(produced) as Array<[P, BuiltPart<S> | undefined]>) {\r\n // The anchor is created on first setup and NEVER changes again; only its\r\n // content is renewed. That is why objects the consumer attached to the\r\n // anchor survive a rebuild.\r\n let slot = parts[name]\r\n if (!slot) {\r\n slot = createPart(`${options.id}/${name}`)\r\n parts[name] = slot\r\n root.add(slot.anchor)\r\n }\r\n const target = slot.reset()\r\n if (!part) continue\r\n\r\n // The position is updated but the ROTATION is not: the angle an action\r\n // moved has to survive a rebuild, otherwise the chest's lid slams shut\r\n // every time `configure()` is called.\r\n if (part.origin) slot.anchor.position.set(...part.origin)\r\n\r\n for (const body of [part, ...(part.extras ?? [])]) {\r\n owned.push(body.geometry)\r\n const mesh = new Mesh(body.geometry, resolve(body.slot))\r\n mesh.name = `${options.id}/${name}`\r\n mesh.userData.vibe3d = {\r\n model: `@medieval-kit/${options.id}`,\r\n part: name,\r\n materialSlot: body.slot,\r\n }\r\n target.add(mesh)\r\n }\r\n }\r\n }\r\n\r\n build()\r\n\r\n const runtime: RuntimeContext<C, P> = {\r\n parts: parts as unknown as Record<P, PartHandle<Group>>,\r\n getConfig: () => config,\r\n }\r\n const actions = (options.actions?.(runtime) ?? {}) as A\r\n\r\n const materials: MaterialBindings = {\r\n get: (slot) => (options.slots.includes(slot as S) ? resolve(slot as S) : undefined),\r\n override: (slot, material) => {\r\n if (!options.slots.includes(slot as S)) return\r\n overridesBySlot.set(slot as S, material)\r\n build()\r\n },\r\n reset: (slot) => {\r\n if (!overridesBySlot.delete(slot as S)) return\r\n build()\r\n },\r\n }\r\n\r\n return {\r\n root,\r\n parts: parts as unknown as Record<P, PartHandle<Group>>,\r\n actions,\r\n materials,\r\n getConfig: () => config,\r\n configure: (patch): ConfigureResult => {\r\n const next = { ...config, ...patch }\r\n const changed = (Object.keys(next) as Array<keyof C>).some((key) => next[key] !== config[key])\r\n if (!changed) return { rebuilt: false }\r\n config = next\r\n build()\r\n return { rebuilt: true }\r\n },\r\n update: (deltaSeconds) => options.update?.(deltaSeconds, runtime),\r\n dispose: () => {\r\n for (const geometry of owned) geometry.dispose()\r\n owned = []\r\n for (const part of Object.values(parts) as PartSlot[]) part.reset()\r\n scope.dispose()\r\n },\r\n }\r\n}\r\n",
42
+ "hash": "46bf2f88295dd8eca7e549cb0ad1d67c902d74b591d25add8698630efcd12c82"
43
+ },
44
+ {
45
+ "path": "models/core/materials.ts",
46
+ "target": "{models}/medieval-kit/core/materials.ts",
47
+ "content": "import { Color, DoubleSide, MeshBasicMaterial, MeshStandardMaterial } from 'three'\r\n\r\nimport type { ResourceScope } from '@vibe3d/ownership.ts'\r\n\r\n/**\r\n * The kit's shared material set.\r\n *\r\n * The critical choice: every material is `vertexColors: true`. Colour\r\n * variation is carried in the GEOMETRY, not in the material. That way 13\r\n * boards can have 13 separate tones while all of them share a single\r\n * material, and therefore a single draw call.\r\n *\r\n * This is the small version of the idea behind scifi-kit's wear pipeline:\r\n * write the surface identity into a vertex attribute, then merge.\r\n */\r\nexport type MedievalSlot =\r\n | 'oak' // timber\r\n | 'iron' // wrought iron\r\n | 'steel' // steel burnished by use\r\n | 'brass' // bronze and copper: bells, coins\r\n | 'straw' // straw, wicker, besom bristles\r\n | 'cloth' // linen, sackcloth, parchment\r\n | 'leather' // leather: book covers, pouches\r\n | 'glass' // blown glass\r\n | 'produce' // fruit and vegetable skin\r\n | 'ember' // flame — does not take light, it emits it\r\n | 'char' // charcoal, pitch\r\n | 'stone' // dressed and rubble masonry\r\n | 'water' // standing water\r\n\r\n/**\r\n * The material TYPE can differ per slot. `ember` is an unlit MeshBasicMaterial;\r\n * the rest are PBR. Thanks to this mapping the model code knows at compile\r\n * time which type it is getting.\r\n */\r\nexport interface SlotMaterial {\r\n readonly oak: MeshStandardMaterial\r\n readonly iron: MeshStandardMaterial\r\n readonly steel: MeshStandardMaterial\r\n readonly brass: MeshStandardMaterial\r\n readonly straw: MeshStandardMaterial\r\n readonly cloth: MeshStandardMaterial\r\n readonly leather: MeshStandardMaterial\r\n readonly glass: MeshStandardMaterial\r\n readonly produce: MeshStandardMaterial\r\n readonly ember: MeshBasicMaterial\r\n readonly char: MeshStandardMaterial\r\n readonly stone: MeshStandardMaterial\r\n readonly water: MeshStandardMaterial\r\n}\r\n\r\nexport interface MedievalPalette {\r\n /** Oak body tone. */\r\n readonly oak: Color\r\n /** Lid board — a little cooler and lighter than the body (end grain). */\r\n readonly oakEnd: Color\r\n /** Wrought iron: fresh off the anvil, the oxide layer still on it. */\r\n readonly iron: Color\r\n /** Steel burnished by use: the anvil's face, the shovel's blade, the fork's tines. */\r\n readonly steel: Color\r\n /** Bronze — bells, mortars, coins. */\r\n readonly brass: Color\r\n /**\r\n * Bell bronze that has stood outside.\r\n *\r\n * Separate from `brass` because they are not the same colour and the models\r\n * that use each are not the same models. Fresh bronze on a coin or a book's\r\n * corner boss is the yellow `brass` is; a bell that has hung in weather for a\r\n * century is dark and grey. Measured against a reference photograph the bell\r\n * sits at hue 33, saturation 0.34, value 0.33, against 39 / 0.56 / 0.56 for\r\n * the brass we were painting it with.\r\n *\r\n * This is a PALETTE entry, not a slot: the mesh still resolves through the\r\n * `brass` material. The palette carries colour, the slot carries material,\r\n * and keeping them separate is what lets one model recolour without dragging\r\n * the coin pouch and the book's fittings along with it.\r\n */\r\n readonly bronze: Color\r\n /** Dry straw. */\r\n readonly straw: Color\r\n /** Straw tips bleached by the sun. */\r\n readonly strawPale: Color\r\n /** Raw linen / sackcloth. */\r\n readonly cloth: Color\r\n /** Tanned leather. */\r\n readonly leather: Color\r\n /** Blown glass — slightly greenish, glass of the period was not clear. */\r\n readonly glass: Color\r\n /** Base tone of fruit skin. The actual colour comes from the model's `hue` field. */\r\n readonly produce: Color\r\n /** Base of the flame — hot and bright. */\r\n readonly ember: Color\r\n /** Tip of the flame — more saturated, more red. */\r\n readonly emberTip: Color\r\n /** Burnt-out charcoal. */\r\n readonly char: Color\r\n /** Charcoal that is still glowing. */\r\n readonly charHot: Color\r\n /**\r\n * Masonry.\r\n *\r\n * The kit had no stone at all, which for a medieval catalogue is a hole\r\n * rather than an omission: a well, a trough, a millstone, a boundary wall\r\n * and a hearth are all stone before they are anything else. Its colour is\r\n * close to weathered oak on purpose -- what separates the two at a glance is\r\n * not hue but that stone scatters light completely flat, which is carried by\r\n * the material's roughness rather than by the palette.\r\n */\r\n readonly stone: Color\r\n\r\n /**\r\n * Living bark, which is NOT the timber colour and is not close to it.\r\n *\r\n * Measured off a photograph of a standing oak: hue 70 degrees, saturation\r\n * 0.17. Sawn oak in this palette sits at hue 26 and saturation 0.36 -- the\r\n * bark is 44 degrees cooler and half as saturated, because it is grey-green\r\n * with lichen and algae on it rather than brown. Reaching for the `oak` key\r\n * on a trunk gives a plank standing on end.\r\n *\r\n * Measured off the WINTER photograph, after the first attempt took it off\r\n * the summer one and came out 28 degrees too green. A trunk under a crown in\r\n * full leaf is lit through the leaves, and the green it bounces down is in\r\n * every pixel of it; the same tree bare reads hue 42 and saturation 0.27\r\n * against 70 and 0.17. Two photographs of the same species disagreeing by\r\n * that much is not noise, it is one of them measuring the light instead of\r\n * the bark.\r\n *\r\n * The low saturation of the first attempt made it worse than the hue alone:\r\n * a nearly neutral colour takes its hue from whatever is lighting it, and in\r\n * this kit's renderer that is a blue-grey sky, which dragged the rendered\r\n * bole all the way to hue 119 -- properly green, on a tree whose bark is\r\n * brown. Saturation here is what holds the hue still.\r\n *\r\n * Lightness is the one number not taken straight: the photograph averages\r\n * 0.21, but a good part of that is shadow inside bark fissures millimetres\r\n * deep which nine flat facets cannot reproduce. The straw note below is the\r\n * same trap.\r\n */\r\n readonly bark: Color\r\n\r\n\r\n /**\r\n * Weathered limestone, which is NOT the same rock as `stone`.\r\n *\r\n * `stone` was measured off dressed and rubble masonry: hue 44, saturation\r\n * 0.05 — a wall stone, grey, and grey because a wall is quarried and laid\r\n * face out. A trough is one block left in the open for a century, and the\r\n * same measurement on one reads hue 36, saturation 0.16, lightness 0.48:\r\n * warmer, THREE TIMES more saturated, and lighter. Reaching for `stone` on\r\n * it gives a concrete planter.\r\n *\r\n * Both are correct and neither replaces the other, which is why this is a\r\n * palette key rather than an edit to `stone` — the well and the forge were\r\n * each measured against their own references and are not this rock.\r\n */\r\n readonly limestone: Color\r\n\r\n /**\r\n * Standing water, and it is far less green than it feels.\r\n *\r\n * Sampled inside the reference trough's basin the film reads hue 39.5 —\r\n * which is the hue of the stone underneath it, not a colour of its own. It\r\n * is shallow and it is clear; what it does is DARKEN and slightly saturate\r\n * whatever it lies on (lightness 0.48 down to 0.36, saturation 0.16 up to\r\n * 0.23). So this is nearly neutral, barely cool, and it does its work\r\n * through the material's transparency rather than through its hue. Painted\r\n * the pond green a first-instinct would have reached for, the composite\r\n * would have missed the reference by 100 degrees of hue.\r\n */\r\n readonly water: Color\r\n}\r\n\r\n/**\r\n * The colours are written as sRGB; with ColorManagement enabled three stores\r\n * them internally in linear space. Since the vertex colour attribute expects\r\n * linear values, `color.r/g/b` can be written straight through — no extra\r\n * conversion needed.\r\n */\r\n/**\r\n * Base colours, measured against reference photographs rather than chosen.\r\n *\r\n * The hue was already right -- our oak sits at 26 degrees and real oak in a\r\n * photograph sits at 27 -- but the saturation was consistently too high, by a\r\n * median of 0.08 across ten wooden models, and worst on the ones made almost\r\n * entirely of bare timber: the ladder and the fence were each 0.16 over. That\r\n * is the difference between weathered oak and new pine, and it was making the\r\n * whole kit read as plastic.\r\n *\r\n * Straw and linen are deliberately NOT adjusted, although the same measurement\r\n * said they were too yellow and too washed out. Following it made them visibly\r\n * worse. A photograph of a bale reads as desaturated because it is thousands of\r\n * individual straws each casting a shadow on its neighbour; pushing a flat\r\n * lowpoly surface up to that number does not reproduce the texture, it just\r\n * makes the surface garish. The statistic was real and the inference from it\r\n * was wrong -- which is worth leaving written down, because the same trap is\r\n * there for any material whose reference gets its character from fine detail.\r\n *\r\n * Two follow-up measurements ruled out the other colour explanations: our\r\n * saturation spread is 93% of the references', so it is not a lack of\r\n * variation, and the hue was never off. What remains between these models and\r\n * their references is geometry, not colour.\r\n */\r\nexport const MEDIEVAL_PALETTE: MedievalPalette = {\r\n oak: new Color(0x8a6141),\r\n oakEnd: new Color(0x9a7a5e),\r\n iron: new Color(0x40464d),\r\n steel: new Color(0x8d979f),\r\n brass: new Color(0xa9843f),\r\n bronze: new Color(0x63523e),\r\n straw: new Color(0xc2a049),\r\n strawPale: new Color(0xdcc182),\r\n cloth: new Color(0xb9a888),\r\n leather: new Color(0x6b452c),\r\n glass: new Color(0xbcd4cb),\r\n produce: new Color(0xa8452f),\r\n ember: new Color(0xffd27a),\r\n emberTip: new Color(0xd8571b),\r\n char: new Color(0x241f1c),\r\n stone: new Color(0x6e6b63),\r\n charHot: new Color(0xc4441a),\r\n bark: new Color(0x585032),\r\n limestone: new Color(0x94846b),\r\n water: new Color(0x3b4e45),\r\n}\r\n\r\n/**\r\n * Creates materials for the requested slots.\r\n *\r\n * The slot list is required on purpose: the brazier does not need oak, the\r\n * barrel does not need an emissive material. Creating unused materials is both\r\n * a wasted GPU resource and a lie that contradicts the model's `materialSlots`\r\n * declaration.\r\n *\r\n * The return type is narrowed to the requested slots, so `materials.ember`\r\n * only compiles in a model that asked for it.\r\n */\r\nexport function createMedievalMaterials<S extends MedievalSlot>(\r\n scope: ResourceScope,\r\n slots: readonly S[],\r\n): Pick<SlotMaterial, S> {\r\n const build: { [K in MedievalSlot]: () => SlotMaterial[K] } = {\r\n oak: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / oak',\r\n // White base: all colour information comes from the vertex colour, so\r\n // the material must not multiply it away.\r\n color: 0xffffff,\r\n vertexColors: true,\r\n roughness: 0.82,\r\n metalness: 0,\r\n }),\r\n // Iron occurs in two states and they cannot be told with ONE material.\r\n // The anvil's body is forged, oxidised, matte; its face is like a mirror\r\n // because it has been worked on for years. The difference between them is\r\n // not one of colour but of ROUGHNESS — vertex colour cannot carry that,\r\n // because roughness is not an attribute.\r\n //\r\n // Hence two slots: `iron` is the forged surface, `steel` is the surface\r\n // the work has touched. It is normal for a model to want both; it is worth\r\n // a separate draw call, because a bright cutting edge sells the model on\r\n // its own.\r\n iron: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / wrought iron',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n roughness: 0.62,\r\n metalness: 0.78,\r\n }),\r\n steel: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / burnished steel',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n // Low roughness + high metalness, i.e. almost fully reflective. This\r\n // only works if there is an environment map; in a scene without an\r\n // environment it looks pitch black. The viewer supplies a PMREM sky.\r\n roughness: 0.19,\r\n metalness: 0.95,\r\n }),\r\n brass: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / bronze',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n roughness: 0.38,\r\n metalness: 0.85,\r\n }),\r\n // Straw and cloth are fully matte: metalness 0, roughness almost 1. The\r\n // difference between them is not in the numbers but in the vertex colours\r\n // — both follow the same lighting model, but keeping them in separate\r\n // slots matters, because a model's `materialSlots` declaration is a\r\n // CONTRACT: a straw bale declaring an \"oak\" slot would be a lie told to\r\n // the consumer.\r\n straw: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / straw',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n roughness: 0.94,\r\n metalness: 0,\r\n }),\r\n cloth: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / linen',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n roughness: 0.97,\r\n metalness: 0,\r\n }),\r\n leather: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / leather',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n // Leather is not fully matte; being oiled, it has a slight sheen.\r\n roughness: 0.66,\r\n metalness: 0,\r\n }),\r\n // Glass is a thin SHELL, not a solid block. That has two consequences:\r\n // `side` has to be DoubleSide (otherwise it disappears when seen from\r\n // inside) and `depthWrite` has to be off (otherwise it hides the wick\r\n // behind it).\r\n glass: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / blown glass',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n roughness: 0.08,\r\n metalness: 0,\r\n transparent: true,\r\n opacity: 0.34,\r\n depthWrite: false,\r\n side: DoubleSide,\r\n }),\r\n // Fruit skin is waxy: not fully matte, but not metallic either. The reason\r\n // it is a separate slot is not the name but the BEHAVIOUR — had I given it\r\n // the same roughness as straw, an apple would look like dry hay.\r\n produce: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / produce',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n roughness: 0.52,\r\n metalness: 0,\r\n }),\r\n // A flame does not TAKE light, it emits it. MeshStandardMaterial would be\r\n // the wrong tool here: `emissive` is a single Color, it is not fed from\r\n // vertex colours — so no colour gradient from the flame's base to its tip\r\n // is possible. MeshBasicMaterial skips lighting entirely and shows the\r\n // vertex colour as it is; toneMapped is off so the scene exposure cannot\r\n // put the flame out.\r\n ember: () => new MeshBasicMaterial({\r\n name: 'medieval-kit / ember',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n toneMapped: false,\r\n }),\r\n stone: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / stone',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n // Rougher than anything else here. Dressed stone is not polished and\r\n // rubble certainly is not; any sheen at all reads as ceramic.\r\n roughness: 0.97,\r\n metalness: 0,\r\n }),\r\n // Water is a THIN TRANSPARENT FILM, not a body of liquid. Everything in\r\n // this kit that holds water holds a few centimetres of it over a floor you\r\n // can see, so the colour that reaches the eye is mostly what is underneath\r\n // — which is why the opacity is the important number here and the hue is\r\n // nearly not a number at all. Smooth, because a trough out of the wind is\r\n // the one surface in this kit that reflects.\r\n water: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / standing water',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n roughness: 0.11,\r\n metalness: 0,\r\n transparent: true,\r\n opacity: 0.72,\r\n }),\r\n char: () => new MeshStandardMaterial({\r\n name: 'medieval-kit / charcoal',\r\n color: 0xffffff,\r\n vertexColors: true,\r\n roughness: 0.95,\r\n metalness: 0,\r\n }),\r\n }\r\n\r\n const materials = {} as { [K in S]: SlotMaterial[K] }\r\n for (const slot of slots) {\r\n // TypeScript cannot follow correlated unions: `build[slot]` widens to a\r\n // union of functions, so it cannot know that the return is exactly\r\n // SlotMaterial[slot]. The cast is safe because the mapping is built by\r\n // hand above.\r\n materials[slot] = scope.ownMaterial(build[slot]()) as SlotMaterial[S]\r\n }\r\n return materials\r\n}\r\n",
48
+ "hash": "654669b394efcc0e8efb08f829fa2024f6f74bbcc1a2db9373cba9342b7e7290"
49
+ },
50
+ {
51
+ "path": "models/core/occlusion.ts",
52
+ "target": "{models}/medieval-kit/core/occlusion.ts",
53
+ "content": "import type { BufferGeometry } from 'three'\r\n\r\n/**\r\n * Ambient occlusion baked into vertex colours.\r\n *\r\n * Why this and not a texture: a bitmap texture would bring three things — UV\r\n * coordinates (our geometry has none), image files the registry would have to\r\n * carry, and a change to the kit's identity. Lowpoly + flat shading + vertex\r\n * colour is a coherent style; a half-hearted texture breaks it.\r\n *\r\n * Instead we derive the darkening from the surface's OWN shape: the more\r\n * neighbouring surfaces surround a point, the less sky it sees. The result is\r\n * darkening in cavities and at contact points — between the boards, under the\r\n * hoop, where the logs touch. The models suddenly look \"used\" and it costs\r\n * zero memory, zero textures.\r\n *\r\n * Method: a grid is built from the triangle centroids, then for each vertex\r\n * the neighbour density within its own normal hemisphere is measured. No ray\r\n * tracing — at this scale (hundreds of triangles) it would be needlessly\r\n * expensive and the difference is negligible.\r\n */\r\n\r\nexport interface OcclusionOptions {\r\n /** Radius searched for neighbours. Derived from the model's size if omitted. */\r\n readonly radius?: number\r\n /** How much the darkest point is darkened. 0 = off. */\r\n readonly strength?: number\r\n /** Neighbour weight where saturation is reached. Raising it softens the darkening. */\r\n readonly saturation?: number\r\n}\r\n\r\ninterface Sample {\r\n readonly x: number\r\n readonly y: number\r\n readonly z: number\r\n readonly area: number\r\n}\r\n\r\n/** Simple spatial grid: for quickly finding the samples within a radius. */\r\nclass Grid {\r\n readonly #cells = new Map<string, Sample[]>()\r\n readonly #size: number\r\n\r\n constructor(samples: readonly Sample[], cellSize: number) {\r\n this.#size = cellSize\r\n for (const sample of samples) {\r\n const key = this.#key(sample.x, sample.y, sample.z)\r\n const bucket = this.#cells.get(key)\r\n if (bucket) bucket.push(sample)\r\n else this.#cells.set(key, [sample])\r\n }\r\n }\r\n\r\n #key(x: number, y: number, z: number): string {\r\n return `${Math.floor(x / this.#size)},${Math.floor(y / this.#size)},${Math.floor(z / this.#size)}`\r\n }\r\n\r\n /** The samples of the 27 cells surrounding the given point. */\r\n near(x: number, y: number, z: number): Sample[] {\r\n const cx = Math.floor(x / this.#size)\r\n const cy = Math.floor(y / this.#size)\r\n const cz = Math.floor(z / this.#size)\r\n const found: Sample[] = []\r\n for (let i = -1; i <= 1; i += 1) {\r\n for (let j = -1; j <= 1; j += 1) {\r\n for (let k = -1; k <= 1; k += 1) {\r\n const bucket = this.#cells.get(`${cx + i},${cy + j},${cz + k}`)\r\n if (bucket) found.push(...bucket)\r\n }\r\n }\r\n }\r\n return found\r\n }\r\n}\r\n\r\n/**\r\n * Computes the occlusion of the given geometries TOGETHER and writes it into\r\n * their colours.\r\n *\r\n * They all have to be evaluated as one: the place where a board darkens is the\r\n * surface of the neighbouring post, not its own surface. Processing them one\r\n * by one would miss the contact points entirely.\r\n */\r\nexport function bakeOcclusion(\r\n geometries: readonly BufferGeometry[],\r\n options: OcclusionOptions = {},\r\n): void {\r\n const strength = options.strength ?? 0.42\r\n if (strength <= 0 || geometries.length === 0) return\r\n\r\n // --- 1. Samples: each triangle's centroid, weighted by its area ---\r\n const samples: Sample[] = []\r\n let minX = Infinity, minY = Infinity, minZ = Infinity\r\n let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity\r\n\r\n for (const geometry of geometries) {\r\n const position = geometry.getAttribute('position')\r\n if (!position) continue\r\n const index = geometry.getIndex()\r\n const count = index ? index.count : position.count\r\n for (let i = 0; i < count; i += 3) {\r\n const at = (k: number): [number, number, number] => {\r\n const v = index ? index.getX(i + k) : i + k\r\n return [position.getX(v), position.getY(v), position.getZ(v)]\r\n }\r\n const [ax, ay, az] = at(0)\r\n const [bx, by, bz] = at(1)\r\n const [cx, cy, cz] = at(2)\r\n const ux = bx - ax, uy = by - ay, uz = bz - az\r\n const vx = cx - ax, vy = cy - ay, vz = cz - az\r\n const nx = uy * vz - uz * vy\r\n const ny = uz * vx - ux * vz\r\n const nz = ux * vy - uy * vx\r\n const area = Math.hypot(nx, ny, nz) / 2\r\n if (area <= 0) continue\r\n const x = (ax + bx + cx) / 3\r\n const y = (ay + by + cy) / 3\r\n const z = (az + bz + cz) / 3\r\n samples.push({ x, y, z, area })\r\n minX = Math.min(minX, x); maxX = Math.max(maxX, x)\r\n minY = Math.min(minY, y); maxY = Math.max(maxY, y)\r\n minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z)\r\n }\r\n }\r\n if (samples.length === 0) return\r\n\r\n const extent = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-6)\r\n // The radius is derived from the scale but has an ABSOLUTE ceiling. Without\r\n // the ceiling a 4.89 m fence gets a 0.69 m radius and the definition of\r\n // \"neighbour\" loses its meaning: everything is everything's neighbour, and\r\n // instead of showing the contact points the occlusion smears a flat\r\n // darkening over the whole model.\r\n //\r\n // The physical meaning says the same thing: what shades a surface is what is\r\n // NEAR it. A post half a metre away is not making the shadow between the\r\n // boards.\r\n const radius = options.radius ?? Math.min(0.16, Math.max(0.015, extent * 0.14))\r\n const grid = new Grid(samples, radius)\r\n // Saturation has to be proportional to the model's scale: as the radius\r\n // grows so does the area it covers, and a fixed threshold would turn small\r\n // models pitch black.\r\n const saturation = options.saturation ?? radius * radius * 1.9\r\n\r\n // --- 2. Neighbour density for each vertex ---\r\n for (const geometry of geometries) {\r\n const position = geometry.getAttribute('position')\r\n const colour = geometry.getAttribute('color')\r\n if (!position || !colour) continue\r\n if (!geometry.getAttribute('normal')) geometry.computeVertexNormals()\r\n const normal = geometry.getAttribute('normal')!\r\n\r\n for (let i = 0; i < position.count; i += 1) {\r\n const px = position.getX(i), py = position.getY(i), pz = position.getZ(i)\r\n const nx = normal.getX(i), ny = normal.getY(i), nz = normal.getZ(i)\r\n\r\n let weight = 0\r\n for (const sample of grid.near(px, py, pz)) {\r\n const dx = sample.x - px, dy = sample.y - py, dz = sample.z - pz\r\n const distance = Math.hypot(dx, dy, dz)\r\n if (distance < 1e-6 || distance > radius) continue\r\n // Only neighbours in the hemisphere the vertex FACES occlude it.\r\n const facing = (dx * nx + dy * ny + dz * nz) / distance\r\n if (facing <= 0) continue\r\n // The effect fades with distance; a soft falloff instead of the\r\n // inverse-square law, because the goal is not physical accuracy but a\r\n // readable cavity shadow.\r\n weight += sample.area * facing * (1 - distance / radius)\r\n }\r\n\r\n const occlusion = Math.min(1, weight / saturation)\r\n const factor = 1 - strength * occlusion\r\n colour.setXYZ(i, colour.getX(i) * factor, colour.getY(i) * factor, colour.getZ(i) * factor)\r\n }\r\n colour.needsUpdate = true\r\n }\r\n}\r\n",
54
+ "hash": "6abb37ba737d80279f8af5b3dce94367028af99340baf495c8727e7e5a6dcd7c"
55
+ },
56
+ {
57
+ "path": "models/core/parts.ts",
58
+ "target": "{models}/medieval-kit/core/parts.ts",
59
+ "content": "import { Group } from 'three'\n\nimport type { PartHandle } from '@vibe3d/model.ts'\n\n/**\n * Semantic part slot: fixed anchor + replaceable content.\n *\n * This is the most easily misunderstood contract in vibe3d. `PartHandle`\n * declares two separate objects and they CANNOT BE THE SAME:\n *\n * anchor the same object for the lifetime of the model. The consumer\n * attaches its light, its label, its collision body, its gameplay\n * object here.\n * content the geometry that is thrown away and rebuilt every time\n * configure() is called.\n *\n * Making both the same Group and calling `clear()` on rebuild silently deletes\n * everything the consumer attached too — the model keeps working, but the\n * protocol's actual promise is broken. `scripts/verify-model.ts` tests this\n * separately.\n */\nexport interface PartSlot extends PartHandle<Group> {\n readonly anchor: Group\n readonly content: Group\n /** Replaces the content with a fresh Group and returns it. Leaves the anchor alone. */\n reset(): Group\n}\n\nexport function createPart(name: string): PartSlot {\n const anchor = new Group()\n anchor.name = name\n let content = new Group()\n content.name = `${name}/content`\n anchor.add(content)\n\n return {\n anchor,\n get content(): Group {\n return content\n },\n reset(): Group {\n anchor.remove(content)\n content = new Group()\n content.name = `${name}/content`\n anchor.add(content)\n return content\n },\n }\n}\n",
60
+ "hash": "9b1b5771703d5747476eafb20c0eff72c09c32cba6eb321ec982712f9fa0d045"
61
+ },
62
+ {
63
+ "path": "models/core/random.ts",
64
+ "target": "{models}/medieval-kit/core/random.ts",
65
+ "content": "/**\n * Deterministic randomness.\n *\n * A procedural kit needs variation — perfect symmetry reads as \"generated\".\n * But the variation has to be repeatable: the same seed must always give the\n * same model, otherwise neither previews, nor tests, nor art direction hold\n * up. That is why Math.random() is not used.\n *\n * mulberry32: 32-bit state, fast, not cryptographic but far more than enough\n * for visual variation.\n */\nexport function createRandom(seed: number): () => number {\n let state = seed >>> 0\n return () => {\n state = (state + 0x6d2b79f5) >>> 0\n let t = state\n t = Math.imul(t ^ (t >>> 15), t | 1)\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61)\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\n/** Symmetric deviation in the range -amount .. +amount. */\nexport function jitter(random: () => number, amount: number): number {\n return (random() * 2 - 1) * amount\n}\n",
66
+ "hash": "8294a81f783f53333dbedcae5ee2511b94d333f957ee51d553c439ee6884c510"
67
+ },
68
+ {
69
+ "path": "models/core/tint.ts",
70
+ "target": "{models}/medieval-kit/core/tint.ts",
71
+ "content": "import { Color } from 'three'\r\n\r\nimport { MEDIEVAL_PALETTE, type MedievalPalette } from './materials.ts'\r\nimport { jitter } from './random.ts'\r\n\r\n/**\r\n * Factory that produces colours deviated from the palette.\r\n *\r\n * Every model used to rewrite this inline — the same five lines thirteen\r\n * times. The cost of the repetition was not just line count: the deviation\r\n * amounts had drifted from model to model, so when two models were put side\r\n * by side one of them visibly had more variation than the other.\r\n *\r\n * Every call returns A NEW Color.\r\n *\r\n * It used to return one shared object and mutate it, on the reasoning that the\r\n * geometry helpers read the colour immediately and an allocation per call is\r\n * waste. The reasoning holds right up until two tints appear as arguments of\r\n * the SAME call:\r\n *\r\n * latheGeometry(profile, 7, origin, tint('char', 0.06),\r\n * { colourTop: tint('charHot', -0.12) })\r\n *\r\n * Both arguments are the same object, so both carry whatever the second call\r\n * computed. That is not a hypothetical: it is why the torch's pitch head\r\n * rendered hot orange instead of black, and the same mistake was made twice\r\n * more in one session -- once in the anvil's stump, which came out painted in\r\n * end grain on every face, and once nearly in the shovel. Three times means\r\n * the hazard is in the helper, not in the callers.\r\n *\r\n * A few hundred Color allocations at build time is not a cost worth one class\r\n * of silent, invisible bug.\r\n */\r\nexport function createTinter(random: () => number) {\r\n return (\r\n key: keyof MedievalPalette,\r\n /** Lightness shift. Negative darkens. */\r\n lift = 0,\r\n /** Deviation multiplier. 0 gives a completely flat colour. */\r\n spread = 1,\r\n ): Color => {\r\n const scratch = new Color(MEDIEVAL_PALETTE[key])\r\n scratch.offsetHSL(\r\n jitter(random, 0.012 * spread),\r\n jitter(random, 0.05 * spread),\r\n lift + jitter(random, 0.05 * spread),\r\n )\r\n return scratch\r\n }\r\n}\r\n\r\nexport type Tinter = ReturnType<typeof createTinter>\r\n",
72
+ "hash": "381f4b64574ebc80bd150c50cd8faadd778cbb31775e412f52f2d500075f8350"
73
+ },
74
+ {
75
+ "path": "models/core/tool.ts",
76
+ "target": "{models}/medieval-kit/core/tool.ts",
77
+ "content": "import { Color, type BufferGeometry } from 'three'\n\nimport { latheGeometry, mergeColoured, type Level } from './geometry.ts'\nimport { jitter } from './random.ts'\nimport { MEDIEVAL_PALETTE } from './materials.ts'\n\n/**\n * The shared language of hand tools.\n *\n * In the first attempt all three tools were just a straight prismatic shaft +\n * a boxy head, and they looked like toys. The reasons were small, individual\n * things:\n *\n * - The shaft was the same thickness end to end. A real shaft has a swell\n * at its base (the grip) — so the hand does not slip off. That is the one\n * detail that makes the silhouette readable.\n * - The head was stuck directly onto the shaft. A real tool has a conical\n * socket; the shaft goes inside it.\n * - There was no geometric variation anywhere, only the colour changed. A\n * forged tool is not perfectly symmetric.\n *\n * This module supplies all three from one place, so adding a new tool to the\n * kit is now just a matter of answering \"what is the head\".\n */\n\nexport interface ShaftOptions {\n readonly length: number\n readonly radius: number\n /** How many sides. 6 is enough: on a hand-held stick 8 adds nothing to the silhouette. */\n readonly segments?: number\n readonly random: () => number\n}\n\nexport interface ToolShaft {\n readonly geometry: BufferGeometry\n /** Y position of the shaft's top end — the head sits here. */\n readonly top: number\n /** The shaft's radius at the top end. */\n readonly topRadius: number\n}\n\n/**\n * Tool shaft: grip swell at the bottom, a long straight body in the middle, a\n * slight taper towards the top. Produced as a single lathe — stacking prisms\n * on top of each other would leave coplanar face pairs between them.\n */\nexport function toolShaft(options: ShaftOptions): ToolShaft {\n const { length, radius, random } = options\n const segments = options.segments ?? 6\n const bottom = -length / 2\n const top = length / 2\n const r = (scale: number): number => radius * scale * (1 + jitter(random, 0.02))\n\n const profile: Level[] = [\n { y: bottom, radius: r(0.78) }, // bottom: rounded end\n { y: bottom + length * 0.012, radius: r(1.18) }, // underside of the grip swell\n { y: bottom + length * 0.075, radius: r(1.1) }, // top of the grip\n { y: bottom + length * 0.14, radius: r(0.94) }, // waist between grip and body\n { y: bottom + length * 0.55, radius: r(1) }, // body\n { y: top, radius: r(0.9) }, // taper towards the head\n ]\n\n const tint = new Color(MEDIEVAL_PALETTE.oak)\n tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), 0.05 + jitter(random, 0.05))\n const tintTop = new Color(MEDIEVAL_PALETTE.oak)\n // The head end passes through the hand less often, so it stays a bit darker.\n tintTop.offsetHSL(jitter(random, 0.01), jitter(random, 0.04), -0.02 + jitter(random, 0.04))\n\n return {\n geometry: latheGeometry(profile, segments, [0, 0, 0], tint, { colourTop: tintTop }),\n top,\n topRadius: profile.at(-1)!.radius,\n }\n}\n\nexport interface SocketOptions {\n /** Y position where the socket sits (the shaft's top end). */\n readonly y: number\n /** The shaft's radius at that point. */\n readonly shaftRadius: number\n /** Socket length. */\n readonly length: number\n readonly segments?: number\n readonly random: () => number\n}\n\n/**\n * Forged socket: a cone that wraps the shaft and widens upwards, with a collar\n * on top.\n *\n * It is extended DOWN INTO the shaft — because its lower end stays inside the\n * shaft's body, no surface ends up coplanar with the shaft's surface.\n */\nexport function toolSocket(options: SocketOptions): BufferGeometry {\n const { y, shaftRadius, length, random } = options\n const segments = options.segments ?? 6\n const tint = new Color(MEDIEVAL_PALETTE.iron)\n tint.offsetHSL(0, jitter(random, 0.02), jitter(random, 0.05))\n const collar = new Color(MEDIEVAL_PALETTE.iron)\n collar.offsetHSL(0, jitter(random, 0.02), 0.04 + jitter(random, 0.04))\n\n const profile: Level[] = [\n { y: y - length * 0.9, radius: shaftRadius * 1.12 },\n { y: y - length * 0.45, radius: shaftRadius * 1.34 },\n { y: y + length * 0.1, radius: shaftRadius * 1.5 },\n { y: y + length * 0.22, radius: shaftRadius * 1.72 }, // collar\n { y: y + length * 0.34, radius: shaftRadius * 1.46 },\n ]\n return mergeColoured([latheGeometry(profile, segments, [0, 0, 0], tint, { colourTop: collar })])\n}\n\n/** Iron tone with a small deviation. So all three tools look like one hand made them. */\nexport function ironTint(random: () => number, lift = 0): Color {\n const tint = new Color(MEDIEVAL_PALETTE.iron)\n tint.offsetHSL(0, jitter(random, 0.02), lift + jitter(random, 0.05))\n return tint\n}\n\n/**\n * Burnished steel tone — for the `steel` slot.\n *\n * The deviation is kept narrower than in `ironTint`: what determines the\n * colour of a polished surface is not its own pigment but the environment it\n * reflects. Shifting hue in the vertex colour looks dirty here, so there is\n * only a tiny wobble in lightness.\n */\nexport function steelTint(random: () => number, lift = 0): Color {\n const tint = new Color(MEDIEVAL_PALETTE.steel)\n tint.offsetHSL(0, jitter(random, 0.008), lift + jitter(random, 0.025))\n return tint\n}\n",
78
+ "hash": "b044ee611c5b6d9f1771392aeb520011e7a082e70dc4dd09f03cd38a2665a5d9"
79
+ }
80
+ ]
81
+ },
82
+ {
83
+ "name": "bronze-bell",
84
+ "type": "vibe3d:model",
85
+ "title": "Bronze Bell",
86
+ "description": "Bronze bell hung from a yoke. The clapper swings with a lag and strikes the rim; strikes are counted.",
87
+ "dependencies": [
88
+ "three@>=0.185.0"
89
+ ],
90
+ "registryDependencies": [
91
+ "@medieval-kit/core"
92
+ ],
93
+ "files": [
94
+ {
95
+ "path": "models/bronze-bell/model.ts",
96
+ "target": "{models}/medieval-kit/bronze-bell/model.ts",
97
+ "content": "/**\r\n * @medieval-kit/bronze-bell\r\n *\r\n * Bronze bell hung from a yoke. Church tower, village square, ship's deck.\r\n *\r\n * The most \"machine\" like piece in the kit: the bell itself is easy, the real\r\n * problem is making it WORK. Two bodies swing independently of each other —\r\n *\r\n * - The bell travels back and forth on the yoke axis like a damped pendulum.\r\n * - The CLAPPER is on the same axis but LAGGING. That lag is exactly what\r\n * rings the bell: as the bell moves one way the clapper falls behind, then\r\n * catches up and hits the rim. If the two moved together the bell would be\r\n * silent — and that is exactly what happened in my first attempt, where I\r\n * made the clapper an `extras` body of the bell: it swung, nothing rang.\r\n *\r\n * NO sound. A model cannot make assumptions about the scene's audio system;\r\n * whoever needs it reads the `actions.strikes()` counter and plays its own.\r\n */\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n bandGeometry,\r\n boxGeometry,\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n createTinter,\r\n flipGeometry,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n prismGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface BronzeBellConfig {\r\n /** Mouth diameter of the bell (metres). */\r\n readonly diameter: number\r\n /** Height of the bell itself (metres). */\r\n readonly height: number\r\n /** Length of the yoke rail, as a ratio of the diameter. */\r\n readonly yoke: number\r\n /** Angle at full swing (degrees). */\r\n readonly swing: number\r\n /** Damping rate. A larger value comes to rest sooner. */\r\n readonly damping: number\r\n readonly seed: number\r\n}\r\n\r\nexport const bronzeBellDefaults: BronzeBellConfig = {\r\n diameter: 0.36,\r\n height: 0.4,\r\n yoke: 1.35,\r\n swing: 34,\r\n damping: 0.55,\r\n seed: 67,\r\n}\r\n\r\nexport type BronzeBellParts = 'bell' | 'clapper' | 'yoke' | 'frame'\r\n\r\nexport interface BronzeBellActions {\r\n /** Rings the bell: starts the pendulum off at a full swing. */\r\n ring(): void\r\n /** Stops the motion instantly. */\r\n still(): void\r\n /** Whether the bell is still swinging. */\r\n isRinging(): boolean\r\n /**\r\n * How many times the clapper has hit the rim. A consumer that wants sound\r\n * watches this counter: if it went up after `update()`, a strike happened.\r\n */\r\n strikes(): number\r\n}\r\n\r\nexport function createModel(overrides: Partial<BronzeBellConfig> = {}) {\r\n // Pendulum state lives OUTSIDE the build: `configure()` must not silence it.\r\n let angle = 0\r\n let velocity = 0\r\n let clapper = 0\r\n let clapperVelocity = 0\r\n let strikes = 0\r\n let lastSide = 0\r\n\r\n return createKitModel<BronzeBellConfig, 'brass' | 'iron' | 'oak', BronzeBellParts, BronzeBellActions>({\r\n id: 'bronze-bell',\r\n defaults: bronzeBellDefaults,\r\n slots: ['brass', 'iron', 'oak'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const radius = config.diameter / 2\r\n // Axis of rotation: the yoke rail. Everything is placed relative to it.\r\n const pivotY = config.height * 0.62\r\n const half = config.height / 2\r\n\r\n // --- Bell body ----------------------------------------------------------\r\n // The bell curve is not arbitrary: an upright shoulder up top, a concave\r\n // waist in the middle, a skirt flaring toward the mouth and a thickening\r\n // lip at the bottom (the sound bow). Without that lip the silhouette\r\n // looks like a plastic funnel.\r\n const bellProfile: Level[] = [\r\n { y: -half + config.height * 0.02, radius: radius },\r\n { y: -half + config.height * 0.07, radius: radius * 0.855 }, // sharp flare of the lip\r\n { y: -half + config.height * 0.15, radius: radius * 0.75 },\r\n { y: -half + config.height * 0.36, radius: radius * 0.665 }, // waist: almost straight\r\n { y: -half + config.height * 0.6, radius: radius * 0.615 },\r\n { y: -half + config.height * 0.79, radius: radius * 0.57 },\r\n { y: -half + config.height * 0.92, radius: radius * 0.49 }, // shoulder turn\r\n { y: half, radius: radius * 0.38 },\r\n ]\r\n // The bell is a SHELL: an outside, an inside, and a lip joining the two\r\n // at the mouth. Closed on top (the crown is solid), open at the mouth.\r\n const skirt = latheGeometry(bellProfile, 12, [0, 0, 0], tint('bronze', -0.06, 0.7), {\r\n colourTop: tint('bronze', 0.05, 0.7),\r\n capBottom: false, // mouth OPEN: the inside of the bell must show\r\n capTop: true,\r\n })\r\n // Inner surface. Its winding is reversed with `flipGeometry` — mirroring\r\n // with `scale(-1, 1, 1)` flips the normals too, but it mirrors the\r\n // geometry as well and left the cap facing the wrong way: that is why a\r\n // hole showed at the top of the bell.\r\n const innerProfile = bellProfile.map((level) => ({\r\n y: level.y + config.height * 0.02,\r\n radius: level.radius * 0.88,\r\n }))\r\n const inner = flipGeometry(latheGeometry(\r\n innerProfile, 12, [0, 0, 0], tint('bronze', -0.24, 0.5),\r\n { capBottom: false, capTop: false },\r\n ))\r\n // Mouth lip: the hoop joining the inner and outer shell. Without it a gap\r\n // as wide as the shell is left at the rim and the bell's inside is void.\r\n const lipY = -half + config.height * 0.02\r\n const lip = bandGeometry(\r\n radius * 0.995, lipY, config.height * 0.035,\r\n radius * 0.115, 12, tint('bronze', -0.12, 0.6),\r\n )\r\n\r\n // Crown: the ears that fasten the bell to the yoke.\r\n const crown: BufferGeometry[] = [skirt, inner, lip]\r\n for (let i = 0; i < 3; i += 1) {\r\n const a = (i / 3) * Math.PI * 2\r\n const ear = chamferedBoxGeometry(\r\n [radius * 0.13, radius * 0.1],\r\n [radius * 0.1, radius * 0.08],\r\n config.height * 0.16,\r\n radius * 0.03,\r\n [0, 0, 0],\r\n tint('bronze', 0.08, 0.6),\r\n )\r\n ear.translate(Math.sin(a) * radius * 0.17, half + config.height * 0.05, Math.cos(a) * radius * 0.17)\r\n crown.push(ear)\r\n }\r\n\r\n const bell = mergeColoured(crown)\r\n bell.translate(0, -pivotY, 0) // bring the hinge to the origin\r\n\r\n // --- Clapper -----------------------------------------------------------\r\n // Hanging shaft + ball. The shaft drops down from the axis, the ball sits\r\n // just above the line of the mouth: a real clapper strikes the bell's lip.\r\n const drop = pivotY + half * 0.55\r\n const stem = prismGeometry(radius * 0.035, radius * 0.028, drop, 5,\r\n [0, -drop / 2, 0], tint('iron', -0.02, 0.7))\r\n const ball = latheGeometry([\r\n { y: -radius * 0.12, radius: radius * 0.04 },\r\n { y: -radius * 0.06, radius: radius * 0.11 },\r\n { y: radius * 0.04, radius: radius * 0.115 },\r\n { y: radius * 0.1, radius: radius * 0.05 },\r\n ], 7, [0, -drop, 0], tint('iron', 0.05, 0.7))\r\n const clapperGeometry = mergeColoured([stem, ball])\r\n\r\n // --- Yoke ---------------------------------------------------------------\r\n // DOES NOT SWING: the fixed piece the bell hangs from. It has no origin\r\n // of its own, so it just stands in model space.\r\n const beamLength = config.diameter * config.yoke\r\n const beamY = pivotY + radius * 0.2\r\n // Where the uprights stand. Computed here, before the beam, because the\r\n // beam has to REACH them: decoupling the two (so the frame could not\r\n // close onto the bell at a short yoke) left the rail shorter than the\r\n // gap it spans at the bottom of the slider, and the two A-frames came\r\n // apart into separate objects with nothing joining them.\r\n const standX = Math.max(beamLength * 0.4, radius * 1.3)\r\n const beamSpan = Math.max(beamLength, standX * 2.2)\r\n // The rail is ALREADY built horizontal: the first two arguments of\r\n // `chamferedBoxGeometry` are the X–Z footprint, the third the Y height.\r\n // I had put a `rotateZ` here and it stood the rail upright, sticking out\r\n // of the top of the bell like a post — the price of misremembering which\r\n // axis the helper counts as \"height\".\r\n const beam = chamferedBoxGeometry(\r\n [beamSpan, radius * 0.24],\r\n [beamSpan * 0.98, radius * 0.21],\r\n radius * 0.3,\r\n radius * 0.04,\r\n [0, beamY, 0],\r\n tint('oak', 0.02),\r\n )\r\n const yokePieces: BufferGeometry[] = [beam]\r\n\r\n // --- Frame -------------------------------------------------------------\r\n // Two uprights and a pair of ground sills. Without them the whole\r\n // assembly — bell, clapper, yoke — hangs in mid-air with nothing holding\r\n // it, which is exactly the failure the support check exists to catch.\r\n // A bell also does not come without its frame: the frame is what lets it\r\n // swing, so modelling one without the other is modelling half an object.\r\n //\r\n // Consumers who want the bare bell for their own tower hide\r\n // `parts.frame` — that is what semantic parts are for.\r\n // The frame stands CLEAR of the bell, in both axes.\r\n //\r\n // The uprights were at beamLength * 0.34, which at the default yoke is\r\n // 0.165 against a bell radius of 0.18: half-width included, they spanned\r\n // 0.147 to 0.183 and ran straight through the bell's mouth. Nothing in\r\n // the check suite objects -- the support test sees one connected mass,\r\n // which is exactly what a post driven through a bell looks like -- but a\r\n // bell cannot swing through its own frame. The width is now the larger of\r\n // the yoke's reach and the bell's radius plus a margin, so shortening the\r\n // yoke slider cannot close the frame onto the bell.\r\n //\r\n // And the foot sat at height * 0.16 below the mouth, leaving 24 mm\r\n // between the sill and the lip. A bell hangs clear of its sill; at that\r\n // gap it read as resting on it.\r\n const standFoot = -half - config.height * 0.36\r\n const legY = (beamY + standFoot) / 2\r\n const legHeight = beamY - standFoot\r\n const framePieces: BufferGeometry[] = []\r\n const braceRise = legHeight * 0.44\r\n const braceRun = radius * 0.62\r\n const braceLength = Math.hypot(braceRun, braceRise)\r\n\r\n for (const side of [-1, 1]) {\r\n // The upright reaches PAST the rail it carries, so the joint is a real\r\n // overlap rather than two faces meeting.\r\n framePieces.push(chamferedBoxGeometry(\r\n [radius * 0.2, radius * 0.34],\r\n [radius * 0.15, radius * 0.26],\r\n // Starts INSIDE the sill, not level with it: sharing the sill's\r\n // bottom plane put two downward faces in the same place and they\r\n // flickered against each other.\r\n legHeight + radius * 0.22,\r\n radius * 0.03,\r\n [side * standX, legY + radius * 0.19, 0],\r\n tint('oak', -0.04),\r\n tint('oak', 0.03),\r\n ))\r\n // Sill: the foot that spreads the load along the ground. It runs across\r\n // the swing, because that is the direction a swinging bell tries to\r\n // rock its frame.\r\n framePieces.push(chamferedBoxGeometry(\r\n [radius * 0.26, radius * 1.5],\r\n [radius * 0.22, radius * 1.34],\r\n radius * 0.22,\r\n radius * 0.03,\r\n [side * standX, standFoot + radius * 0.11, 0],\r\n tint('oak', -0.1),\r\n ))\r\n\r\n // Braces: a pair per upright, in the plane of that upright's own\r\n // A-frame, running fore and aft to the ends of its sill.\r\n //\r\n // They used to be single diagonals tilted about Z -- across the model,\r\n // in the same plane as the bell -- which meant they had to be shoved\r\n // either in front of the bell (a diagonal drawn straight over the one\r\n // silhouette that has to read cleanly) or outboard of the uprights,\r\n // where they connect to nothing and stick out like broken sticks.\r\n // Neither is what a bell frame does. Real frames are two A-frames, one\r\n // at each end of the beam, braced fore-and-aft in their own planes,\r\n // with the bell swinging in the gap between them. Tilting about X\r\n // instead of Z puts them there, and the bell is never in the way.\r\n for (const dir of [-1, 1]) {\r\n const brace = chamferedBoxGeometry(\r\n [radius * 0.13, radius * 0.2],\r\n [radius * 0.1, radius * 0.16],\r\n braceLength,\r\n radius * 0.025,\r\n [0, 0, 0],\r\n tint('oak', -0.07),\r\n )\r\n brace.rotateX(-dir * Math.atan2(braceRun, braceRise))\r\n brace.translate(\r\n side * standX,\r\n standFoot + radius * 0.11 + braceRise / 2,\r\n dir * braceRun / 2,\r\n )\r\n framePieces.push(brace)\r\n }\r\n }\r\n\r\n // Bearings: the two iron ears carrying the rail. They carry it from ABOVE\r\n // — that is how a real bearing works, and when their top faces were level\r\n // with the rail's the two ended up coplanar and flickered.\r\n for (const side of [-1, 1]) {\r\n yokePieces.push(boxGeometry(\r\n [radius * 0.09, radius * 0.56, radius * 0.28],\r\n [side * beamLength * 0.34, beamY - radius * 0.02, 0],\r\n tint('iron', jitter(random, 0.04), 0.7),\r\n ))\r\n }\r\n\r\n return {\r\n bell: { slot: 'brass' as const, geometry: bell, origin: [0, pivotY, 0] as const },\r\n clapper: {\r\n slot: 'iron' as const,\r\n geometry: clapperGeometry,\r\n origin: [0, pivotY, 0] as const,\r\n },\r\n yoke: {\r\n slot: 'oak' as const,\r\n geometry: mergeColoured([yokePieces[0]!]),\r\n extras: [{ slot: 'iron' as const, geometry: mergeColoured(yokePieces.slice(1)) }],\r\n },\r\n frame: { slot: 'oak' as const, geometry: mergeColoured(framePieces) },\r\n }\r\n },\r\n\r\n actions: ({ parts }) => {\r\n const apply = (): void => {\r\n parts.bell.anchor.rotation.z = angle\r\n parts.clapper.anchor.rotation.z = clapper\r\n }\r\n apply()\r\n return {\r\n ring: () => {\r\n // It does not always start from the SAME side: a bell rung over and\r\n // over looked like a machine. The direction is chosen from the\r\n // current velocity, so striking an ongoing swing reinforces it.\r\n const direction = velocity >= 0 ? 1 : -1\r\n velocity += direction * 3.4\r\n },\r\n still: () => {\r\n angle = 0; velocity = 0; clapper = 0; clapperVelocity = 0\r\n apply()\r\n },\r\n isRinging: () => Math.abs(angle) > 1e-4 || Math.abs(velocity) > 1e-4,\r\n strikes: () => strikes,\r\n }\r\n },\r\n\r\n update: (dt, { parts, getConfig }) => {\r\n const step = Math.min(0.05, Math.max(0, dt))\r\n if (step === 0) return\r\n const config = getConfig()\r\n const limit = (config.swing * Math.PI) / 180\r\n if (Math.abs(angle) < 1e-5 && Math.abs(velocity) < 1e-5\r\n && Math.abs(clapper) < 1e-5 && Math.abs(clapperVelocity) < 1e-5) return\r\n\r\n // Bell: a damped pendulum. The restoring force is proportional to the\r\n // angle (small-angle approximation), the friction to the velocity.\r\n velocity += -angle * 26 * step - velocity * config.damping * step\r\n angle += velocity * step\r\n if (Math.abs(angle) > limit) {\r\n angle = Math.sign(angle) * limit\r\n velocity *= -0.35 // hitting the yoke's limit\r\n }\r\n\r\n // Clapper: a pendulum of its own, but its mount is carried WITH the bell.\r\n // The drag term (angle - clapper) is exactly what produces the lag.\r\n clapperVelocity += (angle - clapper) * 34 * step - clapperVelocity * 0.9 * step\r\n clapper += clapperVelocity * step\r\n\r\n // Strike: when the clapper reaches the bell's inner wall. Since the wall\r\n // turns with the bell, the limit is not ABSOLUTE but RELATIVE to it.\r\n const reach = 0.26\r\n const relative = clapper - angle\r\n if (Math.abs(relative) > reach) {\r\n const side = Math.sign(relative)\r\n clapper = angle + side * reach\r\n clapperVelocity *= -0.55\r\n // No counting twice on the same side: one strike, one change of side.\r\n if (side !== lastSide) { strikes += 1; lastSide = side }\r\n } else if (Math.abs(relative) < reach * 0.4) {\r\n lastSide = 0\r\n }\r\n\r\n parts.bell.anchor.rotation.z = angle\r\n parts.clapper.anchor.rotation.z = clapper\r\n },\r\n }, overrides)\r\n}\r\n",
98
+ "hash": "6ea3b9369617738a64b4da3c5176b8ffee0bab1f21477094c57e5e56f2f4b8ae"
99
+ }
100
+ ],
101
+ "meta": {
102
+ "title": "Bronze Bell",
103
+ "description": "Bronze bell hung from a yoke. The clapper swings with a lag and strikes the rim; strikes are counted.",
104
+ "category": "Props",
105
+ "tags": [
106
+ "medieval",
107
+ "lowpoly",
108
+ "props",
109
+ "animated",
110
+ "interactive"
111
+ ],
112
+ "controls": {
113
+ "diameter": {
114
+ "type": "number",
115
+ "label": "Mouth diameter",
116
+ "min": 0.15,
117
+ "max": 1,
118
+ "step": 0.01,
119
+ "unit": "m"
120
+ },
121
+ "height": {
122
+ "type": "number",
123
+ "label": "Height",
124
+ "min": 0.15,
125
+ "max": 1.1,
126
+ "step": 0.01,
127
+ "unit": "m"
128
+ },
129
+ "yoke": {
130
+ "type": "number",
131
+ "label": "Yoke length",
132
+ "min": 1,
133
+ "max": 2.2,
134
+ "step": 0.05
135
+ },
136
+ "swing": {
137
+ "type": "number",
138
+ "label": "Swing",
139
+ "min": 5,
140
+ "max": 60,
141
+ "step": 1,
142
+ "unit": "°"
143
+ },
144
+ "damping": {
145
+ "type": "number",
146
+ "label": "Damping",
147
+ "min": 0.1,
148
+ "max": 3,
149
+ "step": 0.05
150
+ },
151
+ "seed": {
152
+ "type": "number",
153
+ "label": "Variation seed",
154
+ "min": 1,
155
+ "max": 64,
156
+ "step": 1
157
+ }
158
+ },
159
+ "materialSlots": [
160
+ "brass",
161
+ "iron",
162
+ "oak"
163
+ ],
164
+ "parts": [
165
+ "bell",
166
+ "clapper",
167
+ "yoke",
168
+ "frame"
169
+ ],
170
+ "sockets": []
171
+ }
172
+ },
173
+ {
174
+ "name": "cart-wheel",
175
+ "type": "vibe3d:model",
176
+ "title": "Cart Wheel",
177
+ "description": "Four layers from the outside in: iron tyre, wooden felloe, spokes, hub.",
178
+ "dependencies": [
179
+ "three@>=0.185.0"
180
+ ],
181
+ "registryDependencies": [
182
+ "@medieval-kit/core"
183
+ ],
184
+ "files": [
185
+ {
186
+ "path": "models/cart-wheel/model.ts",
187
+ "target": "{models}/medieval-kit/cart-wheel/model.ts",
188
+ "content": "/**\r\n * @medieval-kit/cart-wheel\r\n *\r\n * The most iconic medieval object after the barrel. It works alone too — a\r\n * wheel leaning against a wall turns a scene into a village instantly.\r\n *\r\n * The real thing is four layers from the outside in: iron tyre, wooden felloe,\r\n * spokes, hub. The felloe is not one piece, it is built from straight pieces —\r\n * so a lowpoly polygonal rim is not a simplification here, it is the correct\r\n * construction.\r\n *\r\n * The wheel stands in the XY plane; its axis is Z. Whoever wants it lying down\r\n * turns it with `root.rotation.x`.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n MEDIEVAL_PALETTE,\r\n bandGeometry,\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface CartWheelConfig {\r\n /** Outer radius, iron tyre included (metres). */\r\n readonly radius: number\r\n /** Number of spokes. The felloe piece count follows from it. */\r\n readonly spokeCount: number\r\n /** Wheel thickness (metres). */\r\n readonly width: number\r\n /** Hub length, as a multiple of the thickness. */\r\n readonly hubLength: number\r\n /** Iron tyre thickness, as a fraction of the radius. */\r\n readonly tyre: number\r\n readonly seed: number\r\n}\r\n\r\nexport const cartWheelDefaults: CartWheelConfig = {\r\n radius: 0.52,\r\n spokeCount: 10,\r\n width: 0.09,\r\n hubLength: 2.1,\r\n // An iron tyre is a HOOP: 8 to 12 mm of bar shrunk onto the felloe. At\r\n // 0.045 of a 0.52 m radius it was 23 mm of iron, and with an axial width\r\n // greater than the wheel's own it read as a pneumatic tyre rather than as\r\n // the band that holds a wooden rim together.\r\n tyre: 0.022,\r\n seed: 27,\r\n}\r\n\r\nexport type CartWheelParts = 'hub' | 'spokes' | 'felloe' | 'tyre'\r\n\r\nexport function createModel(overrides: Partial<CartWheelConfig> = {}) {\r\n return createKitModel<CartWheelConfig, 'oak' | 'iron', CartWheelParts>({\r\n id: 'cart-wheel',\r\n defaults: cartWheelDefaults,\r\n slots: ['oak', 'iron'],\r\n build: ({ config, random }) => {\r\n const tint = new Color()\r\n const oak = (lift = 0): Color => {\r\n tint.copy(MEDIEVAL_PALETTE.oak)\r\n tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), lift + jitter(random, 0.05))\r\n return tint\r\n }\r\n const iron = (lift = 0): Color => {\r\n tint.copy(MEDIEVAL_PALETTE.iron)\r\n tint.offsetHSL(0, jitter(random, 0.02), lift + jitter(random, 0.05))\r\n return tint\r\n }\r\n\r\n const spokes = Math.max(4, config.spokeCount)\r\n const tyreThickness = config.radius * config.tyre\r\n const felloeOuter = config.radius - tyreThickness\r\n const felloeInner = felloeOuter * 0.82\r\n const hubRadius = config.radius * 0.17\r\n const hubLength = config.width * config.hubLength\r\n\r\n // --- hub: turned log, chamfered at both ends, swollen in the middle ---\r\n const hubProfile: Level[] = [\r\n { y: -hubLength / 2, radius: hubRadius * 0.68 },\r\n { y: -hubLength / 2 + hubLength * 0.1, radius: hubRadius * 0.92 },\r\n { y: -hubLength * 0.16, radius: hubRadius },\r\n { y: hubLength * 0.16, radius: hubRadius },\r\n { y: hubLength / 2 - hubLength * 0.1, radius: hubRadius * 0.92 },\r\n { y: hubLength / 2, radius: hubRadius * 0.68 },\r\n ]\r\n const hub = latheGeometry(hubProfile, 8, [0, 0, 0], oak(-0.05), { colourTop: oak(-0.03) })\r\n // The hub's axis has to be Z: the wheel stands upright.\r\n hub.rotateX(Math.PI / 2)\r\n\r\n // --- spokes: from hub to felloe, tapering outward ---\r\n const spokePieces: BufferGeometry[] = []\r\n // The spoke reaches PAST both ends it joins: it starts inside the hub and\r\n // finishes inside the felloe. Sizing it to the exact gap left it merely\r\n // touching, and at the extreme ends of the sliders — small radius, thick\r\n // tyre — not even that, so the hub and spokes came away from the rim as a\r\n // separate floating island.\r\n const spokeInner = hubRadius * 0.45\r\n const spokeLength = (felloeInner - spokeInner) + config.width * 0.35\r\n for (let i = 0; i < spokes; i += 1) {\r\n const angle = (i / spokes) * Math.PI * 2\r\n const spoke = chamferedBoxGeometry(\r\n [config.width * 0.42, config.width * 0.5],\r\n [config.width * 0.3, config.width * 0.38],\r\n spokeLength,\r\n config.width * 0.07,\r\n [0, spokeLength / 2 + spokeInner, 0],\r\n oak(),\r\n )\r\n // A hand-carved spoke is never exactly centred; we add a small offset.\r\n spoke.rotateZ(angle + jitter(random, 0.012))\r\n spokePieces.push(spoke)\r\n }\r\n\r\n // --- felloe: polygonal rim built from straight wooden pieces ---\r\n const felloePieces: BufferGeometry[] = []\r\n const segments = spokes\r\n const step = (Math.PI * 2) / segments\r\n // Chord length: the distance between two neighbouring corners. A little\r\n // too long so the pieces BITE into one another, not just meet end to end.\r\n const chord = 2 * felloeOuter * Math.sin(step / 2) * 1.03\r\n const midRadius = (felloeOuter + felloeInner) / 2\r\n for (let i = 0; i < segments; i += 1) {\r\n const angle = (i + 0.5) * step\r\n // Every piece has its own thickness. That is both correct (hand-cut\r\n // felloe pieces are never equal) and required: if they were all exactly\r\n // the same thickness their side faces would be coplanar and would\r\n // z-fight wherever they overlap.\r\n const thickness = config.width * (1 + jitter(random, 0.07))\r\n const piece = chamferedBoxGeometry(\r\n [chord, thickness],\r\n [chord, thickness],\r\n felloeOuter - felloeInner,\r\n config.width * 0.09,\r\n [0, 0, 0],\r\n oak(0.03),\r\n )\r\n // The piece is built at the origin: X = tangent (chord length), Y =\r\n // radial thickness, Z = wheel thickness. So for angle=0 it already\r\n // faces the right way. Moving it out to the radius and taking it into\r\n // place with a single rotation is enough — an extra rotation in between\r\n // made some of the pieces parallel and their end faces overlapped.\r\n piece.translate(0, midRadius, 0)\r\n piece.rotateZ(angle)\r\n felloePieces.push(piece)\r\n }\r\n\r\n // --- iron tyre: single-piece hoop wrapping the felloe ---\r\n //\r\n // A real BAND, not a surface. It used to be a lathe sitting at\r\n // `config.radius` with no inner wall, while the felloe stopped a whole\r\n // `tyreThickness` further in — so the two never touched. At the default\r\n // thickness the gap was small enough that nothing noticed; at the top of\r\n // the slider it was 6 cm and an arc of the tyre came away from the wheel\r\n // entirely and sat on the ground as its own object.\r\n //\r\n // `bandGeometry` spans the radius properly: outer face at `config.radius`,\r\n // inner face at `config.radius - tyreThickness`, which is exactly where\r\n // the felloe's outer face is. It also gets `inner: true`, because unlike\r\n // a barrel hoop this band is not wrapped tight around a body — the felloe\r\n // is a POLYGON inside a circle, so the gap at the middle of each facet is\r\n // visible and needs a surface.\r\n const tyre = bandGeometry(\r\n config.radius,\r\n 0,\r\n // NARROWER than the narrowest felloe piece can jitter to.\r\n //\r\n // The felloe's axial thickness jitters by ±7%, so anything between\r\n // 0.93 and 1.07 of the width lands inside that range and will sooner\r\n // or later share a plane with some piece; a tyre at exactly the\r\n // felloe's width flickered against whichever piece came out near\r\n // zero jitter. The first answer was to go wider than the whole range,\r\n // at 1.1 -- which stopped the flicker and made the wheel look shod in\r\n // a pneumatic tyre, a black band standing proud of its own rim.\r\n //\r\n // Going under the range instead solves it the same way and is the\r\n // truer shape: the hoop is set into the tread, and on a worn wheel a\r\n // sliver of felloe shows at each edge where the iron has bedded in.\r\n config.width * 0.9,\r\n tyreThickness * 1.06,\r\n segments * 2,\r\n iron(),\r\n { inner: true },\r\n )\r\n // A single closed band now, so the separate inner shell that used to\r\n // sit against the felloe is gone: the band's own inner face is already\r\n // inside `felloeOuter`.\r\n const tyreRings = mergeColoured([tyre])\r\n tyreRings.rotateX(Math.PI / 2)\r\n\r\n return {\r\n hub: { slot: 'oak', geometry: hub },\r\n spokes: { slot: 'oak', geometry: mergeColoured(spokePieces) },\r\n felloe: { slot: 'oak', geometry: mergeColoured(felloePieces) },\r\n tyre: { slot: 'iron', geometry: tyreRings },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
189
+ "hash": "08fb82af124cfaab7e7b3682eb04d912b552e7f2310e3ec290fe03159642384a"
190
+ }
191
+ ],
192
+ "meta": {
193
+ "title": "Cart Wheel",
194
+ "description": "Four layers from the outside in: iron tyre, wooden felloe, spokes, hub.",
195
+ "category": "Structure",
196
+ "tags": [
197
+ "medieval",
198
+ "lowpoly",
199
+ "cart",
200
+ "structure",
201
+ "procedural"
202
+ ],
203
+ "controls": {
204
+ "radius": {
205
+ "type": "number",
206
+ "label": "Radius",
207
+ "min": 0.25,
208
+ "max": 0.9,
209
+ "step": 0.01,
210
+ "unit": "m"
211
+ },
212
+ "spokeCount": {
213
+ "type": "number",
214
+ "label": "Spokes",
215
+ "min": 6,
216
+ "max": 16,
217
+ "step": 1
218
+ },
219
+ "width": {
220
+ "type": "number",
221
+ "label": "Thickness",
222
+ "min": 0.04,
223
+ "max": 0.18,
224
+ "step": 0.005,
225
+ "unit": "m"
226
+ },
227
+ "hubLength": {
228
+ "type": "number",
229
+ "label": "Hub length",
230
+ "min": 1.2,
231
+ "max": 3.2,
232
+ "step": 0.1
233
+ },
234
+ "tyre": {
235
+ "type": "number",
236
+ "label": "Tyre thickness",
237
+ "min": 0.02,
238
+ "max": 0.09,
239
+ "step": 0.005
240
+ },
241
+ "seed": {
242
+ "type": "number",
243
+ "label": "Variation seed",
244
+ "min": 1,
245
+ "max": 64,
246
+ "step": 1
247
+ }
248
+ },
249
+ "materialSlots": [
250
+ "oak",
251
+ "iron"
252
+ ],
253
+ "parts": [
254
+ "hub",
255
+ "spokes",
256
+ "felloe",
257
+ "tyre"
258
+ ],
259
+ "sockets": []
260
+ }
261
+ },
262
+ {
263
+ "name": "coin-pouch",
264
+ "type": "vibe3d:model",
265
+ "title": "Coin Pouch",
266
+ "description": "Drawstring leather pouch with silver pennies spilled to one side out of its mouth.",
267
+ "dependencies": [
268
+ "three@>=0.185.0"
269
+ ],
270
+ "registryDependencies": [
271
+ "@medieval-kit/core"
272
+ ],
273
+ "files": [
274
+ {
275
+ "path": "models/coin-pouch/model.ts",
276
+ "target": "{models}/medieval-kit/coin-pouch/model.ts",
277
+ "content": "/**\r\n * @medieval-kit/coin-pouch\r\n *\r\n * A drawstring leather pouch with coins spilled beside it.\r\n *\r\n * On its own the pouch has a problem: a closed leather bag cannot be told apart\r\n * from a stone at any distance. What shows what is inside are the SPILLED\r\n * coins; without them the model is a \"lump\", not a \"pouch\". So the coins are\r\n * not an optional garnish, they are how the model gets read at all.\r\n *\r\n * The coin layout is thought through too: money spilled on the ground does not\r\n * form a neat circle, it scatters one way out of the pouch's mouth and part of\r\n * it lands on top of itself. An evenly spaced ring always looked \"placed\".\r\n *\r\n * Period note: a silver penny is thin and SMALL — a little over a centimetre.\r\n * The thick, golden, oversized coin is a fantasy image; here the proportion is\r\n * kept close to the real one, and that makes the pouch's scale read right too.\r\n */\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n bandGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n prismGeometry,\r\n roughenGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface CoinPouchConfig {\r\n /** Height of the pouch (metres). */\r\n readonly height: number\r\n /** Widest radius of the pouch (metres). */\r\n readonly radius: number\r\n /** How full it is. 0.3 is half empty and slumped, 1 is stuffed. */\r\n readonly fill: number\r\n /** Number of coins spilled outside. */\r\n readonly coins: number\r\n /** Coin radius (metres). */\r\n readonly coinRadius: number\r\n readonly seed: number\r\n}\r\n\r\nexport const coinPouchDefaults: CoinPouchConfig = {\r\n height: 0.1,\r\n radius: 0.042,\r\n fill: 0.85,\r\n coins: 9,\r\n coinRadius: 0.011,\r\n seed: 89,\r\n}\r\n\r\nexport type CoinPouchParts = 'pouch' | 'cord' | 'coins'\r\n\r\nexport function createModel(overrides: Partial<CoinPouchConfig> = {}) {\r\n return createKitModel<CoinPouchConfig, 'leather' | 'cloth' | 'brass', CoinPouchParts>({\r\n id: 'coin-pouch',\r\n defaults: coinPouchDefaults,\r\n slots: ['leather', 'cloth', 'brass'],\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const fill = Math.max(0.15, Math.min(1, config.fill))\r\n const floor = 0 // the pouch sits on the ground\r\n const wide = config.radius * (0.68 + fill * 0.4)\r\n const neckY = config.height * (0.62 + fill * 0.14)\r\n\r\n // --- Pouch --------------------------------------------------------------\r\n // A smaller sack, but a different profile: the pouch gathers ABOVE the\r\n // cord and pulls upward, it does not flop outward the way the sack does.\r\n // That gap is the gap between a pouch and a plain bag.\r\n const profile: Level[] = [\r\n { y: floor, radius: wide * 0.62 },\r\n { y: config.height * 0.1, radius: wide * 0.94 },\r\n { y: config.height * 0.3 * fill + config.height * 0.12, radius: wide },\r\n { y: neckY - config.height * 0.14, radius: wide * 0.78 },\r\n { y: neckY, radius: config.radius * 0.3 },\r\n { y: neckY + config.height * 0.16, radius: config.radius * 0.26 },\r\n { y: neckY + config.height * 0.28, radius: config.radius * 0.34 },\r\n ]\r\n // Thirteen sides, not nine. Leather has no facets: at nine the pouch was\r\n // a hard-edged lump where the reference is a soft bag, and the body is\r\n // the whole silhouette of this model.\r\n const pouch = latheGeometry(profile, 13, [0, 0, 0], tint('leather', -0.05, 0.9), {\r\n colourTop: tint('leather', 0.06, 0.9),\r\n capTop: true,\r\n })\r\n roughenGeometry(pouch, config.radius * 0.055, { salt: 51, scaleY: 0.6 })\r\n\r\n // --- Drawstring ------------------------------------------------------------\r\n const cordPieces: BufferGeometry[] = [bandGeometry(\r\n config.radius * 0.31, neckY, config.height * 0.05, config.radius * 0.055, 8,\r\n tint('cloth', -0.18, 0.8), { inner: true },\r\n )]\r\n // The two hanging ends. Orient first, then translate.\r\n for (const side of [-1, 1]) {\r\n const tail = prismGeometry(\r\n config.radius * 0.04, config.radius * 0.028, config.height * 0.3, 4,\r\n [0, -config.height * 0.15, 0], tint('cloth', -0.12, 0.8),\r\n )\r\n tail.rotateZ(side * 0.7 + jitter(random, 0.15))\r\n tail.rotateY(random() * Math.PI * 2)\r\n tail.translate(side * config.radius * 0.28, neckY, 0)\r\n cordPieces.push(tail)\r\n }\r\n\r\n // --- Spilled coins -----------------------------------------------------------\r\n const count = Math.max(0, Math.round(config.coins))\r\n const coinPieces: BufferGeometry[] = []\r\n // Scatter DIRECTION: all one way, because spilled money flows to one side.\r\n const spillAngle = random() * Math.PI * 2\r\n let previous: { x: number; z: number; top: number } | undefined\r\n for (let i = 0; i < count; i += 1) {\r\n const t = (i + 0.6) / count\r\n // Distance grows with a square root: piled at the pouch, thin further out.\r\n const distance = wide * (1.15 + Math.sqrt(t) * 2.4)\r\n const spread = jitter(random, 0.75) * (0.35 + t * 0.65)\r\n const angle = spillAngle + spread\r\n const thickness = config.coinRadius * (0.13 + random() * 0.06)\r\n // Every third coin lands ON the one before it -- which means taking\r\n // that coin's PLACE, not just its height.\r\n //\r\n // The lift was applied while the position stayed independent, so the\r\n // stacking coin was almost never above anything and simply hovered\r\n // about 3 mm off the ground. The support check missed it at its default\r\n // resolution: the voxel is the model's extent over 64, which on a 0.19 m\r\n // pouch is 3 mm -- exactly the size of the gap it had to see.\r\n const stacking = i % 3 === 2 && previous !== undefined\r\n\r\n const lower = config.coinRadius * (0.92 + random() * 0.16)\r\n const coin = prismGeometry(\r\n lower,\r\n config.coinRadius * (0.9 + random() * 0.16),\r\n thickness, 9, [0, 0, 0], tint('brass', jitter(random, 0.06), 0.5),\r\n )\r\n // Some do not land flat: the ones leaning on edge give the pile depth.\r\n const tilt = random() < 0.22 ? 0.5 + random() * 0.7 : jitter(random, 0.12)\r\n coin.rotateX(tilt)\r\n coin.rotateY(random() * Math.PI * 2)\r\n // A tilted disc has to rise by half of (its diameter's projection plus\r\n // its own thickness) for its lowest edge to reach the ground, not by\r\n // half its thickness alone.\r\n // The APOTHEM, not the circumradius, and this coin's own radius rather\r\n // than the nominal one. A coin is a nine-sided prism; rolled onto its\r\n // edge it can come to rest on a face rather than a corner, so lifting\r\n // it by the circumradius leaves it standing on nothing. Erring towards\r\n // the smaller radius beds it into the ground instead, which is the\r\n // right way to be wrong.\r\n const rest = (Math.sin(tilt) * lower * 2 * Math.cos(Math.PI / 9)\r\n + thickness * Math.cos(tilt)) / 2\r\n const x = stacking\r\n ? previous!.x + jitter(random, config.coinRadius * 0.45)\r\n : Math.sin(angle) * distance\r\n const z = stacking\r\n ? previous!.z + jitter(random, config.coinRadius * 0.45)\r\n : Math.cos(angle) * distance\r\n const y = stacking ? previous!.top + rest : floor + rest\r\n coin.translate(x, y, z)\r\n previous = { x, z, top: y + rest * 0.55 }\r\n coinPieces.push(coin)\r\n }\r\n\r\n return {\r\n pouch: { slot: 'leather' as const, geometry: mergeColoured([pouch]) },\r\n cord: { slot: 'cloth' as const, geometry: mergeColoured(cordPieces) },\r\n coins: coinPieces.length > 0\r\n ? { slot: 'brass' as const, geometry: mergeColoured(coinPieces) }\r\n : undefined,\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
278
+ "hash": "53798f5add1ae70354be451840cd34c59f0370b93358ee75710395e2288fabcc"
279
+ }
280
+ ],
281
+ "meta": {
282
+ "title": "Coin Pouch",
283
+ "description": "Drawstring leather pouch with silver pennies spilled to one side out of its mouth.",
284
+ "category": "Props",
285
+ "tags": [
286
+ "medieval",
287
+ "lowpoly",
288
+ "props",
289
+ "tabletop",
290
+ "procedural"
291
+ ],
292
+ "controls": {
293
+ "height": {
294
+ "type": "number",
295
+ "label": "Height",
296
+ "min": 0.05,
297
+ "max": 0.24,
298
+ "step": 0.005,
299
+ "unit": "m"
300
+ },
301
+ "radius": {
302
+ "type": "number",
303
+ "label": "Radius",
304
+ "min": 0.02,
305
+ "max": 0.1,
306
+ "step": 0.002,
307
+ "unit": "m"
308
+ },
309
+ "fill": {
310
+ "type": "number",
311
+ "label": "Fill",
312
+ "min": 0.15,
313
+ "max": 1,
314
+ "step": 0.02
315
+ },
316
+ "coins": {
317
+ "type": "number",
318
+ "label": "Spilled coins",
319
+ "min": 0,
320
+ "max": 30,
321
+ "step": 1
322
+ },
323
+ "coinRadius": {
324
+ "type": "number",
325
+ "label": "Coin radius",
326
+ "min": 0.005,
327
+ "max": 0.025,
328
+ "step": 0.001,
329
+ "unit": "m"
330
+ },
331
+ "seed": {
332
+ "type": "number",
333
+ "label": "Variation seed",
334
+ "min": 1,
335
+ "max": 64,
336
+ "step": 1
337
+ }
338
+ },
339
+ "materialSlots": [
340
+ "leather",
341
+ "cloth",
342
+ "brass"
343
+ ],
344
+ "parts": [
345
+ "pouch",
346
+ "cord",
347
+ "coins"
348
+ ],
349
+ "sockets": []
350
+ }
351
+ },
352
+ {
353
+ "name": "forge-hearth",
354
+ "type": "vibe3d:model",
355
+ "title": "Forge Hearth",
356
+ "description": "Smith's forge: a raised stone hearth with a chimney back, a bed of embers that can be put out, and a leather bellows.",
357
+ "dependencies": [
358
+ "three@>=0.185.0"
359
+ ],
360
+ "registryDependencies": [
361
+ "@medieval-kit/core"
362
+ ],
363
+ "files": [
364
+ {
365
+ "path": "models/forge-hearth/model.ts",
366
+ "target": "{models}/medieval-kit/forge-hearth/model.ts",
367
+ "content": "/**\r\n * @medieval-kit/forge-hearth\r\n *\r\n * A smith's forge: a raised stone hearth with a chimney back and a bellows\r\n * beside it.\r\n *\r\n * The kit has had an anvil since early on and nothing to heat anything in. An\r\n * anvil alone is a lump of iron; what makes a corner a smithy is the fire, and\r\n * what makes the fire a forge rather than a hearth is the bellows blowing\r\n * through the side of it.\r\n *\r\n * The parts follow the way it is used:\r\n *\r\n * - `hearth` — the stone block, waist high, with a kerb round its top. The\r\n * height is the whole point: a smith works standing, and a fire\r\n * on the floor is a cooking fire.\r\n * - `fire` — charcoal and embers in the bed. `setLit` puts it out without\r\n * taking the charcoal with it, the same split the cauldron uses.\r\n * - `chimney` — the tapering stack over the back. It draws the smoke off the\r\n * work, which is the only reason a smith can stand at the fire\r\n * at all.\r\n * - `bellows` — two boards with leather between them, a nozzle into the side\r\n * of the hearth, and a lever above to work it by.\r\n *\r\n * The masonry is a compromise the well taught. Laying every course as separate\r\n * blocks reads far better than a smooth box, and it also costs 12 triangles a\r\n * block — at four faces and four courses that is most of the budget. So the\r\n * blocks go where they are read, which is the kerb around the fire and the top\r\n * course of the walls, and the body below is a roughened slab.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n boxGeometry,\r\n createKitModel,\r\n dishedSheetGeometry,\r\n createTinter,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n prismGeometry,\r\n roughenGeometry,\r\n taperedBoxGeometry,\r\n type Level,\r\n type SheetLevel,\r\n} from '../core/index.ts'\r\n\r\nexport interface ForgeHearthConfig {\r\n /** Length of the hearth block along the smith's side (metres). */\r\n readonly length: number\r\n /** Depth of the hearth block (metres). */\r\n readonly depth: number\r\n /** Height of the block to the top of its kerb (metres). */\r\n readonly height: number\r\n /** Height of the chimney above the hearth's top (metres). */\r\n readonly chimney: number\r\n /** Blocks around the kerb of the fire. */\r\n readonly kerbBlocks: number\r\n /** Whether the fire is lit (0/1). */\r\n readonly lit: number\r\n readonly seed: number\r\n}\r\n\r\nexport const forgeHearthDefaults: ForgeHearthConfig = {\r\n length: 1.15,\r\n depth: 0.82,\r\n height: 0.78,\r\n chimney: 1.1,\r\n kerbBlocks: 16,\r\n lit: 1,\r\n seed: 37,\r\n}\r\n\r\nexport type ForgeHearthParts = 'hearth' | 'fire' | 'chimney' | 'bellows'\r\n\r\nexport interface ForgeHearthActions {\r\n setLit(on: boolean): void\r\n isLit(): boolean\r\n}\r\n\r\nexport function createModel(overrides: Partial<ForgeHearthConfig> = {}) {\r\n let lit = true\r\n\r\n return createKitModel<ForgeHearthConfig, 'stone' | 'char' | 'ember' | 'oak' | 'leather' | 'iron', ForgeHearthParts, ForgeHearthActions>({\r\n id: 'forge-hearth',\r\n defaults: forgeHearthDefaults,\r\n slots: ['stone', 'char', 'ember', 'oak', 'leather', 'iron'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const L = config.length\r\n const D = config.depth\r\n const H = config.height\r\n // The forge stands on the ground rather than about its own centre: it has\r\n // one face that matters and that face is the top, so measuring from the\r\n // floor is what keeps the working height honest.\r\n const floor = 0\r\n const wall = Math.min(L, D) * 0.17\r\n const kerbH = H * 0.14\r\n const bodyTop = H - kerbH\r\n\r\n // --- Hearth block --------------------------------------------------\r\n const masonry: BufferGeometry[] = []\r\n // Four wall slabs rather than four walls of blocks. The saving is what\r\n // pays for the kerb, which is the course anyone actually looks at.\r\n for (const [dx, dz, sx, sz] of [\r\n [0, -(D - wall) / 2, L, wall],\r\n [0, (D - wall) / 2, L, wall],\r\n [-(L - wall) / 2, 0, wall, D - wall * 2],\r\n [(L - wall) / 2, 0, wall, D - wall * 2],\r\n ] as const) {\r\n masonry.push(taperedBoxGeometry(\r\n [sx, sz],\r\n [sx * 0.99, sz * 0.99],\r\n bodyTop,\r\n [dx, floor + bodyTop / 2, dz],\r\n tint('stone', jitter(random, 0.07)),\r\n ))\r\n }\r\n // The floor of the fire bed, so the hearth is not open to the ground.\r\n masonry.push(taperedBoxGeometry(\r\n [L - wall * 1.6, D - wall * 1.6],\r\n [L - wall * 1.8, D - wall * 1.8],\r\n wall * 0.7,\r\n [0, floor + bodyTop - wall * 0.3, 0],\r\n tint('stone', -0.05),\r\n ))\r\n\r\n // Kerb: individual blocks all the way round the top, staggered where the\r\n // corners meet so the ring does not read as four sticks.\r\n const kerbCount = Math.max(8, Math.round(config.kerbBlocks))\r\n const halfL = L / 2 - wall / 2\r\n const halfD = D / 2 - wall / 2\r\n const perimeter = (halfL + halfD) * 4\r\n for (let i = 0; i < kerbCount; i += 1) {\r\n const t = ((i + 0.5) / kerbCount) * perimeter\r\n // Walk the rectangle rather than a circle: a forge is square and the\r\n // blocks have to turn its corners.\r\n let x = 0\r\n let z = 0\r\n let along: 'x' | 'z' = 'x'\r\n let s = t\r\n if (s < halfL * 2) { x = -halfL + s; z = -halfD; along = 'x' }\r\n else if ((s -= halfL * 2) < halfD * 2) { x = halfL; z = -halfD + s; along = 'z' }\r\n else if ((s -= halfD * 2) < halfL * 2) { x = halfL - s; z = halfD; along = 'x' }\r\n else { s -= halfL * 2; x = -halfL; z = halfD - s; along = 'z' }\r\n const run = perimeter / kerbCount\r\n const size: [number, number] = along === 'x'\r\n ? [run * (1.05 + jitter(random, 0.06)), wall * (1.02 + jitter(random, 0.05))]\r\n : [wall * (1.02 + jitter(random, 0.05)), run * (1.05 + jitter(random, 0.06))]\r\n masonry.push(taperedBoxGeometry(\r\n size,\r\n [size[0] * 0.94, size[1] * 0.94],\r\n kerbH * (1.04 + jitter(random, 0.05)),\r\n [x, floor + bodyTop + kerbH * 0.46, z],\r\n tint('stone', 0.03 + jitter(random, 0.08)),\r\n ))\r\n }\r\n // String courses: thin bands standing proud of each wall.\r\n //\r\n // The walls are slabs, which is what pays for the kerb, and a slab of one\r\n // colour is a slab however much it is mottled -- the body of the forge\r\n // came out as a smooth grey box under a course of real stones. Three\r\n // ribs to a face, twelve boxes in all, is enough to say COURSED without\r\n // laying two hundred blocks. They stand proud rather than sitting flush,\r\n // because a dry wall's stones never line up on one plane and a rib that\r\n // is level with its wall is invisible.\r\n for (const at of [0.22, 0.5, 0.78]) {\r\n for (const [dx, dz, sx, sz] of [\r\n [0, -(D + wall * 0.12) / 2, L * 0.99, wall * 0.12],\r\n [0, (D + wall * 0.12) / 2, L * 0.99, wall * 0.12],\r\n [-(L + wall * 0.12) / 2, 0, wall * 0.12, D * 0.99],\r\n [(L + wall * 0.12) / 2, 0, wall * 0.12, D * 0.99],\r\n ] as const) {\r\n masonry.push(taperedBoxGeometry(\r\n [sx, sz],\r\n [sx * 0.99, sz * 0.9],\r\n bodyTop * 0.055,\r\n [dx, floor + bodyTop * at, dz],\r\n tint('stone', -0.07 + jitter(random, 0.05)),\r\n ))\r\n }\r\n }\r\n\r\n const hearth = mergeColoured(masonry)\r\n roughenGeometry(hearth, wall * 0.05, { salt: 23 })\r\n\r\n // --- Fire ------------------------------------------------------------\r\n // The bed is LUMPS, not a plate.\r\n //\r\n // A smooth disc of charcoal colour is the darkest value in the palette\r\n // spread flat, and it reads as a hole cut in the hearth with a few sparks\r\n // in it. A fire bed is broken: irregular lumps of charcoal with the glow\r\n // coming up BETWEEN them, and the only way to get that from vertex colour\r\n // is to build the lumps. They are cheap -- five-sided prisms, eight\r\n // triangles each -- and they are the whole difference between a forge and\r\n // a stone box with a black lid.\r\n const bedX = L * 0.5 - wall * 1.1\r\n const bedZ = D * 0.5 - wall * 1.1\r\n const bedY = floor + bodyTop\r\n const coals: BufferGeometry[] = []\r\n const embers: BufferGeometry[] = []\r\n const lumps = 26\r\n\r\n for (let i = 0; i < lumps; i += 1) {\r\n // Golden-angle spiral over an ellipse, so the bed fills evenly rather\r\n // than ringing.\r\n const a = i * 2.399963\r\n const ring = Math.sqrt((i + 0.35) / lumps)\r\n const size = Math.min(bedX, bedZ) * (0.16 + random() * 0.13)\r\n // The middle of a working fire is where the air arrives, so that is\r\n // where it glows; the edges are burnt out.\r\n const hot = ring < 0.52 && i % 2 === 0\r\n const lump = prismGeometry(\r\n size, size * 0.5, size * 0.8, 5, [0, 0, 0],\r\n hot\r\n ? tint(i % 3 === 0 ? 'emberTip' : 'ember', jitter(random, 0.05), 0.4)\r\n : tint('char', 0.05 + jitter(random, 0.08)),\r\n { capBottom: false },\r\n )\r\n lump.rotateX(jitter(random, 0.5))\r\n lump.rotateZ(jitter(random, 0.5))\r\n lump.rotateY(random() * Math.PI * 2)\r\n lump.translate(\r\n Math.sin(a) * bedX * 0.78 * ring,\r\n bedY + kerbH * (hot ? 0.34 : 0.26),\r\n Math.cos(a) * bedZ * 0.78 * ring,\r\n )\r\n ;(hot ? embers : coals).push(lump)\r\n }\r\n\r\n // A dark floor under the lumps so the bed is not see-through where they\r\n // do not quite meet.\r\n coals.push(taperedBoxGeometry(\r\n [bedX * 1.9, bedZ * 1.9],\r\n [bedX * 1.8, bedZ * 1.8],\r\n kerbH * 0.4,\r\n [0, bedY + kerbH * 0.16, 0],\r\n tint('char', 0.02),\r\n ))\r\n const coal = mergeColoured(coals)\r\n\r\n // --- Chimney ----------------------------------------------------------\r\n // Rises from the BACK of the hearth and tapers. Its foot reaches down\r\n // inside the kerb rather than standing on it, so the joint is an overlap.\r\n // A stack, not a post. At 1.5 wall thicknesses deep it was a slab seen\r\n // edge-on from most angles; a forge chimney is a chest of masonry that\r\n // gathers the whole back of the hearth.\r\n const stackW = L * 0.66\r\n const stackD = D * 0.42\r\n const stack = taperedBoxGeometry(\r\n [stackW, stackD],\r\n [stackW * 0.55, stackD * 0.62],\r\n config.chimney,\r\n [0, floor + bodyTop + config.chimney / 2 - kerbH * 0.3, -(D / 2 - stackD * 0.46)],\r\n tint('stone', -0.03),\r\n )\r\n roughenGeometry(stack, wall * 0.05, { salt: 29 })\r\n const cap = taperedBoxGeometry(\r\n [stackW * 0.68, stackD * 0.72],\r\n [stackW * 0.66, stackD * 0.7],\r\n wall * 0.5,\r\n [0, floor + bodyTop + config.chimney - kerbH * 0.3, -(D / 2 - stackD * 0.46)],\r\n tint('stone', 0.06),\r\n )\r\n\r\n // --- Bellows ----------------------------------------------------------\r\n // On the +X side, blowing in through the wall. Two boards with the\r\n // leather between them, a nozzle, a post and the lever that works it.\r\n const bx = L / 2\r\n const bellowsL = D * 0.86\r\n const bellowsW = D * 0.55\r\n const bellowsY = floor + bodyTop - H * 0.14\r\n const timber: BufferGeometry[] = []\r\n const hide: BufferGeometry[] = []\r\n const metal: BufferGeometry[] = []\r\n\r\n // The boards and the leather are SHAPED PLATES, laid flat.\r\n //\r\n // They were built as tapered boxes and turned into place with two\r\n // rotations, and the pair of them put the thickness along Z and the\r\n // length along Y -- the boards ended up standing on edge, both in nearly\r\n // the same plane, with every face coplanar with its neighbour. The\r\n // z-fight check found them immediately and kept finding them while I\r\n // rearranged the leather, because the leather was never what was wrong.\r\n //\r\n // `dishedSheetGeometry` is the right tool and its own docstring says so:\r\n // a plate whose width varies along its length, which is what a bellows\r\n // board is. It builds in the XY plane with its thickness along Z, so one\r\n // tilt lays it flat and one turn points it out from the hearth.\r\n const lay = (g: BufferGeometry): BufferGeometry => {\r\n g.rotateX(-Math.PI / 2)\r\n g.rotateY(-Math.PI / 2)\r\n return g\r\n }\r\n // Width and LENGTH scale separately, because they mean opposite things.\r\n //\r\n // One `w` drove both, which was how the five plates got their end caps\r\n // out of a common plane -- five coplanar faces is five z-fighting faces.\r\n // But it also meant the fix for the leather being too NARROW made it\r\n // longer in the same breath, and the bag ended up running a third of its\r\n // length past the boards it is nailed to. Leather cannot outrun its\r\n // boards: they are what it is fastened to.\r\n //\r\n // So the leather is wider (1.18 to 1.33) and SHORTER (0.82 to 0.9) than\r\n // the boards, which is the real thing -- the hide is tacked inboard of\r\n // the board edges -- and it staggers the caps further apart than the old\r\n // scheme managed, because now they are spread on purpose rather than as\r\n // a by-product of the width.\r\n const teardrop = (w: number, t: number, len: number): SheetLevel[] => [\r\n { y: 0, halfWidth: bellowsW * 0.15 * w, thickness: t, curve: 0 },\r\n { y: bellowsL * 0.28 * len, halfWidth: bellowsW * 0.4 * w, thickness: t, curve: 0 },\r\n { y: bellowsL * 0.72 * len, halfWidth: bellowsW * 0.5 * w, thickness: t * 0.95, curve: 0 },\r\n { y: bellowsL * len, halfWidth: bellowsW * 0.38 * w, thickness: t * 0.9, curve: 0 },\r\n ]\r\n\r\n for (const [board, at] of [[0, -1], [1, 1]] as const) {\r\n timber.push(lay(dishedSheetGeometry(\r\n teardrop(board === 0 ? 1 : 0.97, D * 0.035, board === 0 ? 1 : 0.97), 5,\r\n tint('oak', (board === 0 ? 0.03 : -0.04) + jitter(random, 0.04)),\r\n // The nose is staggered as well as the tail. Scaling the plates only\r\n // spread their BACK edges; they all still began at y = 0, so after\r\n // being laid down every one of them started at the same x and the five\r\n // nose caps were coplanar in their turn. A few millimetres apart is\r\n // invisible and is the difference between five faces in a plane and\r\n // none.\r\n )).translate(bx + board * D * 0.014, bellowsY + at * D * 0.11, 0))\r\n }\r\n\r\n // One gusset of leather spanning board to board, and two ribs on it at\r\n // different sizes so no two of their faces can line up.\r\n // The leather BULGES past the boards. Cut narrower than them it sat\r\n // hidden in the gap and the bellows read as a stack of loose planks with\r\n // daylight between; a bellows is a bag under pressure and its widest\r\n // point is the leather, not the wood.\r\n hide.push(lay(dishedSheetGeometry(\r\n teardrop(1.18, D * 0.235, 0.9), 5, tint('leather', -0.06, 0.9),\r\n )).translate(bx + D * 0.032, bellowsY, 0))\r\n for (const [i, at] of [-0.34, 0.34].entries()) {\r\n hide.push(lay(dishedSheetGeometry(\r\n teardrop(1.26 + i * 0.07, D * 0.03, 0.82 + i * 0.04), 5,\r\n tint('leather', -0.01 + i * 0.04, 0.9),\r\n )).translate(bx + D * (0.022 + i * 0.019), bellowsY + at * D * 0.11, 0))\r\n }\r\n\r\n // Nozzle, through the hearth wall.\r\n metal.push(latheGeometry(\r\n [\r\n { y: -bellowsL * 0.16, radius: D * 0.055 },\r\n { y: bellowsL * 0.06, radius: D * 0.038 },\r\n { y: bellowsL * 0.2, radius: D * 0.028 },\r\n ] as Level[],\r\n 8, [0, 0, 0], tint('iron', 0.02, 0.7),\r\n ).rotateZ(Math.PI / 2).translate(bx - wall * 0.4, bellowsY, 0))\r\n\r\n // --- The bench it rests on -------------------------------------------\r\n //\r\n // The bellows was cantilevered off one post driven through its middle: a\r\n // leather bag the size of a door hanging in the air with four fifths of\r\n // it past its only prop. The support check passed it, because the post\r\n // reaches the floor and the nose touches the wall, so the thing IS\r\n // connected to the ground -- which is not the same as looking like it\r\n // could hold itself up, and looking like it can is the whole rule.\r\n //\r\n // A great bellows sat on a low timber bench: two bearers let into the\r\n // forge wall at one end and carried on legs at the other, with a rail\r\n // across their tail. Six plain boxes, no rotations, and the bag now lies\r\n // on something along its whole length.\r\n const beamZ = bellowsW * 0.34\r\n const beamW = D * 0.075\r\n // Measured off the underside of the LOWER board rather than guessed: the\r\n // boards, the gusset and the ribs all sit at different heights and which\r\n // of them hangs lowest is not worth deriving twice.\r\n const beamTop = bellowsY - D * 0.11 - D * 0.0175 + D * 0.008\r\n const beamY = beamTop - beamW / 2\r\n const frameTail = bx + bellowsL * 1.06\r\n const beamNose = bx - wall * 0.35\r\n\r\n for (const side of [-1, 1] as const) {\r\n // Let INTO the masonry by a third of the wall, not butted against its\r\n // face. A bearer that stops at the wall is a bearer resting on nothing.\r\n timber.push(boxGeometry(\r\n [frameTail + D * 0.06 - beamNose, beamW, beamW],\r\n [(beamNose + frameTail + D * 0.06) / 2, beamY, side * beamZ],\r\n new Color(tint('oak', -0.05 + jitter(random, 0.04))),\r\n ))\r\n }\r\n // The tail rail, run PAST the bearers on both sides. Ended flush with\r\n // them, its end caps would have been coplanar with their outer faces.\r\n timber.push(boxGeometry(\r\n [D * 0.05, D * 0.07, (beamZ + beamW) * 2],\r\n [frameTail, beamY, 0],\r\n new Color(tint('oak', -0.09)),\r\n ))\r\n const legTop = beamY + beamW * 0.3\r\n for (const side of [-1, 1] as const) {\r\n timber.push(boxGeometry(\r\n [D * 0.055, legTop - floor, D * 0.045],\r\n [bx + bellowsL * 1.03, (floor + legTop) / 2, side * beamZ],\r\n new Color(tint('oak', -0.12 + jitter(random, 0.03))),\r\n ))\r\n }\r\n\r\n // --- Post and lever ---------------------------------------------------\r\n // The post stands BEYOND the tail, clear of everything. At 0.16 of the\r\n // bellows' length it was driven straight through the bag.\r\n const postX = bx + bellowsL * 1.16\r\n const postTop = floor + bodyTop + H * 0.42\r\n timber.push(taperedBoxGeometry(\r\n [D * 0.07, D * 0.07],\r\n [D * 0.06, D * 0.06],\r\n postTop - floor,\r\n [postX, (floor + postTop) / 2, 0],\r\n tint('oak', -0.06),\r\n ))\r\n // The lever, laid along X with ONE rotation.\r\n //\r\n // Chaining rotateZ and rotateY to get it there put its length along Z --\r\n // running across the bellows instead of over it, reaching the post at\r\n // neither end, which the support check reported as a floating bar. This\r\n // is the third time in this one model that a two-rotation chain has put\r\n // a piece on the wrong axis, so it is built the plain way: the helper\r\n // gives a bar along Y, and a single turn about Z lays it along X with\r\n // its tilt folded into the same angle.\r\n const leverLength = bellowsL * 1.12\r\n const leverTilt = 0.24\r\n const lever = taperedBoxGeometry(\r\n [D * 0.055, D * 0.05],\r\n [D * 0.045, D * 0.045],\r\n leverLength,\r\n [0, 0, 0],\r\n tint('oak', 0.05),\r\n )\r\n lever.rotateZ(Math.PI / 2 + leverTilt)\r\n // Pivots on the post and reaches back over the bellows TOWARDS the fire,\r\n // falling as it goes, because the end the smith pulls is the end he can\r\n // reach standing at the hearth. It used to run the other way, out into\r\n // open air away from everything.\r\n //\r\n // 0.42 of the length rather than 0.46: the short arm has to poke a good\r\n // way past the post, or its end cap sits a couple of millimetres off the\r\n // post's own face and the two fight.\r\n const pivotY = postTop - D * 0.06\r\n lever.translate(\r\n postX - Math.cos(leverTilt) * leverLength * 0.42,\r\n pivotY - Math.sin(leverTilt) * leverLength * 0.42,\r\n 0,\r\n )\r\n timber.push(lever)\r\n\r\n // The link, which is what makes it a lever rather than a stick.\r\n //\r\n // A rocker with no connection to the top board does not work the bellows,\r\n // it just hangs over it. This is the rod through the board, and it is\r\n // also the only thing tying the two halves of the assembly together to\r\n // the eye.\r\n const linkX = bx + bellowsL * 0.8\r\n const leverY = pivotY - (postX - linkX) * Math.tan(leverTilt)\r\n const linkFoot = bellowsY + D * 0.1\r\n const linkHead = leverY - D * 0.013\r\n metal.push(boxGeometry(\r\n [D * 0.022, linkHead - linkFoot, D * 0.022],\r\n [linkX, (linkFoot + linkHead) / 2, 0],\r\n new Color(tint('iron', -0.02, 0.7)),\r\n ))\r\n\r\n return {\r\n hearth: { slot: 'stone' as const, geometry: hearth },\r\n fire: {\r\n slot: 'char' as const,\r\n geometry: coal,\r\n extras: [{ slot: 'ember' as const, geometry: mergeColoured(embers) }],\r\n },\r\n chimney: { slot: 'stone' as const, geometry: mergeColoured([stack, cap]) },\r\n bellows: {\r\n slot: 'oak' as const,\r\n geometry: mergeColoured(timber),\r\n extras: [\r\n { slot: 'leather' as const, geometry: mergeColoured(hide) },\r\n { slot: 'iron' as const, geometry: mergeColoured(metal) },\r\n ],\r\n },\r\n }\r\n },\r\n\r\n actions: ({ parts, getConfig }) => {\r\n lit = getConfig().lit >= 0.5\r\n // The embers are the fire part's SECOND body; hiding the part itself\r\n // would take the charcoal with it and leave an empty stone box.\r\n const glow = parts.fire.anchor.children[1]\r\n if (glow) glow.visible = lit\r\n return {\r\n setLit: (on) => {\r\n lit = on\r\n const body = parts.fire.anchor.children[1]\r\n if (body) body.visible = on\r\n },\r\n isLit: () => lit,\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
368
+ "hash": "f504416897d439bd6069fbb796dcfff64df40b01465481880230027d03dabaf7"
369
+ }
370
+ ],
371
+ "meta": {
372
+ "title": "Forge Hearth",
373
+ "description": "Smith's forge: a raised stone hearth with a chimney back, a bed of embers that can be put out, and a leather bellows.",
374
+ "category": "Smithy",
375
+ "tags": [
376
+ "medieval",
377
+ "lowpoly",
378
+ "smithy",
379
+ "procedural"
380
+ ],
381
+ "controls": {
382
+ "length": {
383
+ "type": "number",
384
+ "label": "Hearth length",
385
+ "min": 0.7,
386
+ "max": 1.8,
387
+ "step": 0.02,
388
+ "unit": "m"
389
+ },
390
+ "depth": {
391
+ "type": "number",
392
+ "label": "Hearth depth",
393
+ "min": 0.5,
394
+ "max": 1.3,
395
+ "step": 0.02,
396
+ "unit": "m"
397
+ },
398
+ "height": {
399
+ "type": "number",
400
+ "label": "Working height",
401
+ "min": 0.5,
402
+ "max": 1.1,
403
+ "step": 0.02,
404
+ "unit": "m"
405
+ },
406
+ "chimney": {
407
+ "type": "number",
408
+ "label": "Chimney height",
409
+ "min": 0.3,
410
+ "max": 2,
411
+ "step": 0.05,
412
+ "unit": "m"
413
+ },
414
+ "kerbBlocks": {
415
+ "type": "number",
416
+ "label": "Kerb blocks",
417
+ "min": 8,
418
+ "max": 28,
419
+ "step": 1
420
+ },
421
+ "lit": {
422
+ "type": "number",
423
+ "label": "Lit",
424
+ "min": 0,
425
+ "max": 1,
426
+ "step": 1
427
+ },
428
+ "seed": {
429
+ "type": "number",
430
+ "label": "Variation seed",
431
+ "min": 1,
432
+ "max": 64,
433
+ "step": 1
434
+ }
435
+ },
436
+ "materialSlots": [
437
+ "stone",
438
+ "char",
439
+ "ember",
440
+ "oak",
441
+ "leather",
442
+ "iron"
443
+ ],
444
+ "parts": [
445
+ "hearth",
446
+ "fire",
447
+ "chimney",
448
+ "bellows"
449
+ ],
450
+ "sockets": []
451
+ }
452
+ },
453
+ {
454
+ "name": "glass-phial",
455
+ "type": "vibe3d:model",
456
+ "title": "Glass Phial",
457
+ "description": "Blown glass phial with a cork stopper, sealed with wax. Liquid level is computed from the fill.",
458
+ "dependencies": [
459
+ "three@>=0.185.0"
460
+ ],
461
+ "registryDependencies": [
462
+ "@medieval-kit/core"
463
+ ],
464
+ "files": [
465
+ {
466
+ "path": "models/glass-phial/model.ts",
467
+ "target": "{models}/medieval-kit/glass-phial/model.ts",
468
+ "content": "/**\r\n * @medieval-kit/glass-phial\r\n *\r\n * Cork-stoppered, wax-sealed glass bottle. Alchemist's shelf, healer's bag, a\r\n * slot in the inventory.\r\n *\r\n * The kit's second model to use the `glass` slot, and where the glass is really\r\n * tested: in the lantern it was a panel of a CAGE, here it is the vessel\r\n * itself. That has two consequences —\r\n *\r\n * - The contents are a separate body INSIDE the glass shell. In the `ember`\r\n * slot, so it takes no light and supplies its own colour: a liquid that\r\n * went dark at the bottom looked like dirty water, not a potion.\r\n * - The liquid surface is a flat disc. Seen through the glass that straight\r\n * line is the only mark saying \"this is full\"; a spherical liquid body has\r\n * no surface and the bottle looks empty.\r\n *\r\n * Period note: clear, colourless glass is a very late thing. Glass of the\r\n * period was greenish and bubbly from iron impurity; that is why the `glass`\r\n * colour in the palette leans green.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n bandGeometry,\r\n createKitModel,\r\n createTinter,\r\n latheGeometry,\r\n mergeColoured,\r\n prismGeometry,\r\n roughenGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface GlassPhialConfig {\r\n /** Total height, stopper included (metres). */\r\n readonly height: number\r\n /** Widest radius of the body (metres). */\r\n readonly radius: number\r\n /** Neck length, as a fraction of the height. */\r\n readonly neck: number\r\n /** Fill level. 0 empty, 1 up to the brim. */\r\n readonly fill: number\r\n /**\r\n * Colour of the liquid, 0–1 around the colour wheel.\r\n *\r\n * The reason it is a parameter and not a fixed colour is the same as in the\r\n * rest of the kit: one model has to yield red healing, green poison and blue\r\n * mana potions. Adding three separate colours to the palette would do the\r\n * same thing in a more rigid way.\r\n */\r\n readonly hue: number\r\n /** Whether there is a wax seal (0/1). */\r\n readonly seal: number\r\n readonly seed: number\r\n}\r\n\r\nexport const glassPhialDefaults: GlassPhialConfig = {\r\n height: 0.14,\r\n radius: 0.032,\r\n neck: 0.34,\r\n fill: 0.62,\r\n hue: 0.33,\r\n seal: 1,\r\n seed: 83,\r\n}\r\n\r\nexport type GlassPhialParts = 'bottle' | 'liquid' | 'stopper'\r\n\r\nexport function createModel(overrides: Partial<GlassPhialConfig> = {}) {\r\n return createKitModel<GlassPhialConfig, 'glass' | 'ember' | 'oak' | 'char', GlassPhialParts>({\r\n id: 'glass-phial',\r\n defaults: glassPhialDefaults,\r\n slots: ['glass', 'ember', 'oak', 'char'],\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const half = config.height / 2\r\n const neckLength = config.height * config.neck\r\n const bodyTop = half - neckLength\r\n const bodyBottom = -half\r\n const neckRadius = config.radius * 0.36\r\n\r\n // --- Bottle ---------------------------------------------------------------\r\n // Blown glass: slightly pushed in at the base (pontil mark), round body,\r\n // shoulder narrowing fast into the neck, a lip flared out at the mouth.\r\n const profile: Level[] = [\r\n { y: bodyBottom + config.height * 0.02, radius: config.radius * 0.5 },\r\n { y: bodyBottom + config.height * 0.06, radius: config.radius * 0.86 },\r\n { y: bodyBottom + config.height * 0.19, radius: config.radius },\r\n { y: bodyBottom + (bodyTop - bodyBottom) * 0.72, radius: config.radius * 0.88 },\r\n { y: bodyTop, radius: config.radius * 0.5 },\r\n { y: bodyTop + neckLength * 0.34, radius: neckRadius },\r\n { y: half - config.height * 0.05, radius: neckRadius * 0.96 },\r\n { y: half - config.height * 0.02, radius: neckRadius * 1.28 }, // lip\r\n ]\r\n const bottle = latheGeometry(profile, 9, [0, 0, 0], tint('glass', -0.02, 0.4), {\r\n colourTop: tint('glass', 0.06, 0.4),\r\n capTop: false, // mouth OPEN: the stopper sits there\r\n })\r\n // Blown glass is never perfectly symmetric; the deviation is kept tiny\r\n // because on a transparent surface big irregularity reads as frosted glass.\r\n roughenGeometry(bottle, config.radius * 0.02, { salt: 41 })\r\n\r\n // --- Contents -------------------------------------------------------------\r\n // The liquid level is computed from the fill and sits on the body's radius\r\n // AT THAT HEIGHT — using a fixed radius either spilled the liquid out\r\n // through the glass or left it hanging in mid-air.\r\n const fill = Math.max(0, Math.min(1, config.fill))\r\n let liquid: BufferGeometry | undefined\r\n if (fill > 0.02) {\r\n const surfaceY = bodyBottom + config.height * 0.04\r\n + (bodyTop + neckLength * 0.4 - bodyBottom - config.height * 0.04) * fill\r\n // A FIXED number of levels, sampled between the bottle's floor and the\r\n // liquid surface. Filtering the bottle profile by height was the\r\n // obvious way to do it and it made the vertex count depend on `fill` —\r\n // which meant the showcase could not morph this model at all and fell\r\n // back to rebuilding it in visible steps.\r\n const steps = 5\r\n const inner = Array.from({ length: steps }, (_, i) => {\r\n const y = profile[0]!.y + (surfaceY - profile[0]!.y) * (i / steps)\r\n return { y, radius: radiusAt(y) }\r\n })\r\n function radiusAt(y: number): number {\r\n for (let i = 1; i < profile.length; i += 1) {\r\n const a = profile[i - 1]!\r\n const b = profile[i]!\r\n if (y <= b.y) {\r\n const t = (y - a.y) / Math.max(1e-6, b.y - a.y)\r\n return (a.radius + (b.radius - a.radius) * t) * 0.88\r\n }\r\n }\r\n return profile.at(-1)!.radius * 0.88\r\n }\r\n inner.push({ y: surfaceY, radius: radiusAt(surfaceY) })\r\n // The liquid is in the `ember` slot, so it TAKES NO LIGHT: the vertex\r\n // colour is the final colour that goes to the screen. A choice — a\r\n // liquid behind glass darkened by the scene's light looked like dirty\r\n // water, not a potion.\r\n const hue = ((config.hue % 1) + 1) % 1\r\n const deep = new Color().setHSL(hue, 0.78, 0.36)\r\n const bright = new Color().setHSL((hue + 0.03) % 1, 0.72, 0.58)\r\n liquid = mergeColoured([latheGeometry(\r\n inner, 9, [0, 0, 0], deep,\r\n { colourTop: bright, capTop: true },\r\n )])\r\n }\r\n\r\n // --- Stopper -------------------------------------------------------------\r\n const stopper: BufferGeometry[] = [prismGeometry(\r\n neckRadius * 1.02, neckRadius * 1.24, config.height * 0.11, 8,\r\n [0, half - config.height * 0.035, 0], tint('oak', 0.08),\r\n )]\r\n if (config.seal >= 0.5) {\r\n // Wax seal: the hoop wrapping stopper and bottle mouth together.\r\n stopper.push(bandGeometry(\r\n neckRadius * 1.36, half - config.height * 0.035, config.height * 0.055,\r\n neckRadius * 0.24, 8, tint('charHot', -0.1, 0.6), { inner: true },\r\n ))\r\n }\r\n\r\n return {\r\n bottle: { slot: 'glass' as const, geometry: mergeColoured([bottle]) },\r\n liquid: liquid ? { slot: 'ember' as const, geometry: liquid } : undefined,\r\n stopper: {\r\n slot: 'oak' as const,\r\n geometry: mergeColoured([stopper[0]!]),\r\n ...(stopper.length > 1\r\n ? { extras: [{ slot: 'char' as const, geometry: mergeColoured(stopper.slice(1)) }] }\r\n : {}),\r\n },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
469
+ "hash": "3a78ae31d2bfbf999fe0c173733d22bfb899af6859f932d1af5f8592bdfe4385"
470
+ }
471
+ ],
472
+ "meta": {
473
+ "title": "Glass Phial",
474
+ "description": "Blown glass phial with a cork stopper, sealed with wax. Liquid level is computed from the fill.",
475
+ "category": "Props",
476
+ "tags": [
477
+ "medieval",
478
+ "lowpoly",
479
+ "props",
480
+ "alchemy",
481
+ "tabletop"
482
+ ],
483
+ "controls": {
484
+ "height": {
485
+ "type": "number",
486
+ "label": "Height",
487
+ "min": 0.06,
488
+ "max": 0.32,
489
+ "step": 0.005,
490
+ "unit": "m"
491
+ },
492
+ "radius": {
493
+ "type": "number",
494
+ "label": "Body radius",
495
+ "min": 0.015,
496
+ "max": 0.08,
497
+ "step": 0.002,
498
+ "unit": "m"
499
+ },
500
+ "neck": {
501
+ "type": "number",
502
+ "label": "Neck ratio",
503
+ "min": 0.15,
504
+ "max": 0.55,
505
+ "step": 0.01
506
+ },
507
+ "fill": {
508
+ "type": "number",
509
+ "label": "Fill",
510
+ "min": 0,
511
+ "max": 1,
512
+ "step": 0.02
513
+ },
514
+ "hue": {
515
+ "type": "number",
516
+ "label": "Liquid hue",
517
+ "min": 0,
518
+ "max": 1,
519
+ "step": 0.01
520
+ },
521
+ "seal": {
522
+ "type": "number",
523
+ "label": "Wax seal",
524
+ "min": 0,
525
+ "max": 1,
526
+ "step": 1
527
+ },
528
+ "seed": {
529
+ "type": "number",
530
+ "label": "Variation seed",
531
+ "min": 1,
532
+ "max": 64,
533
+ "step": 1
534
+ }
535
+ },
536
+ "materialSlots": [
537
+ "glass",
538
+ "ember",
539
+ "oak",
540
+ "char"
541
+ ],
542
+ "parts": [
543
+ "bottle",
544
+ "liquid",
545
+ "stopper"
546
+ ],
547
+ "sockets": []
548
+ }
549
+ },
550
+ {
551
+ "name": "grindstone",
552
+ "type": "vibe3d:model",
553
+ "title": "Grindstone",
554
+ "description": "Treadle grindstone: a sandstone wheel in a splayed timber trestle, with an iron crank and a trough of water it runs through. Turns.",
555
+ "dependencies": [
556
+ "three@>=0.185.0"
557
+ ],
558
+ "registryDependencies": [
559
+ "@medieval-kit/core"
560
+ ],
561
+ "files": [
562
+ {
563
+ "path": "models/grindstone/model.ts",
564
+ "target": "{models}/medieval-kit/grindstone/model.ts",
565
+ "content": "/**\r\n * @medieval-kit/grindstone\r\n *\r\n * A treadle grindstone: a sandstone wheel turning in a timber trestle, with a\r\n * crank to work it and a trough of water underneath.\r\n *\r\n * The kit has an anvil, a forge and four edged tools, and nothing that put an\r\n * edge on any of them. This is the machine that did — the one piece of village\r\n * equipment with a moving part in it, which is why it turns.\r\n *\r\n * The wheel is `limestone` rather than `stone`, and that was measured rather\r\n * than assumed: the reference wheel reads hue 32.5, saturation 0.196,\r\n * lightness 0.511 against the trough limestone's 36.6, 0.161, 0.500. Close\r\n * enough that a sandstone wheel and a weathered trough are the same rock as\r\n * far as this palette is concerned, and nowhere near the grey `stone` of a\r\n * dressed wall.\r\n *\r\n * The frame is oak taken well down in value. The reference's timber reads\r\n * lightness 0.25 against the palette's 0.40 — it is a machine left in a yard,\r\n * not sawn boards — but it stays `oak`, because a third wood key for one model\r\n * would be describing the weather rather than the material.\r\n *\r\n * What is NOT here, said plainly: the reference has a treadle and a linkage\r\n * down to a foot board, and this has a hand crank instead. Both are period.\r\n * The treadle is three more moving parts to serve an animation that already\r\n * reads from the crank, and the crank is the half of the reference that says\r\n * \"this turns\" without being run.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n boxGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n prismGeometry,\r\n roughenGeometry,\r\n taperedBoxGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface GrindstoneConfig {\r\n /** Diameter of the wheel (metres). */\r\n readonly diameter: number\r\n /** Thickness of the wheel (metres). */\r\n readonly thickness: number\r\n /** Height of the axle above the ground (metres). */\r\n readonly height: number\r\n /** Length of the frame (metres). */\r\n readonly length: number\r\n /** How far the legs splay out on their way down. */\r\n readonly splay: number\r\n /** How full the trough stands, as a fraction of its depth. 0 is dry. */\r\n readonly water: number\r\n readonly seed: number\r\n}\r\n\r\nexport const grindstoneDefaults: GrindstoneConfig = {\r\n diameter: 0.62,\r\n // 0.16 of the diameter, off the reference. A grindstone is a MILLSTONE's\r\n // proportions, not a saw blade's: thin enough to read as a disc, thick\r\n // enough that its rim is a working surface you could hold a blade against.\r\n thickness: 0.1,\r\n height: 0.63,\r\n length: 1,\r\n splay: 0.26,\r\n water: 0.6,\r\n seed: 23,\r\n}\r\n\r\nexport type GrindstoneParts = 'wheel' | 'frame' | 'crank' | 'water'\r\n\r\nexport interface GrindstoneActions {\r\n /** Give the wheel a turn. Repeated cranking builds speed. */\r\n readonly crank: () => void\r\n /** Stop it dead. */\r\n readonly still: () => void\r\n readonly isTurning: () => boolean\r\n /** Turns completed since the model was built. */\r\n readonly turns: () => number\r\n}\r\n\r\nexport function createModel(overrides: Partial<GrindstoneConfig> = {}) {\r\n let angle = 0\r\n let speed = 0\r\n let turned = 0\r\n\r\n return createKitModel<GrindstoneConfig, 'stone' | 'oak' | 'iron' | 'water', GrindstoneParts, GrindstoneActions>({\r\n id: 'grindstone',\r\n defaults: grindstoneDefaults,\r\n slots: ['stone', 'oak', 'iron', 'water'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const R = Math.max(0.08, config.diameter) / 2\r\n const T = Math.max(0.02, config.thickness)\r\n const axleY = Math.max(R * 0.9, config.height)\r\n const L = Math.max(0.4, config.length)\r\n const splay = Math.max(0, config.splay)\r\n\r\n /** Weathered rather than sawn: the reference's timber is 0.15 down on the palette. */\r\n const timber = (lift = 0): Color => {\r\n const c = tint('oak', -0.11 + lift, 0.85)\r\n c.offsetHSL(0, -0.11, 0)\r\n return c\r\n }\r\n\r\n // --- Wheel --------------------------------------------------------\r\n /**\r\n * A disc with a barrelled rim, built about Y and stood up with ONE turn.\r\n *\r\n * `latheGeometry` revolves about the Y axis, so it comes out lying flat\r\n * like a millstone on the floor. `rotateX(90)` sends +Y to +Z, which\r\n * puts the axle along Z — and Z is across the frame, which is where an\r\n * axle goes. The wheel then turns about Z, so the action is a single\r\n * `rotation.z` and there is no chain of rotations to get wrong.\r\n */\r\n const wheel = latheGeometry(\r\n [\r\n { y: -T / 2, radius: R * 0.985 },\r\n { y: -T * 0.3, radius: R },\r\n { y: T * 0.3, radius: R },\r\n { y: T / 2, radius: R * 0.985 },\r\n ] as Level[],\r\n 16, [0, 0, 0], tint('limestone', -0.02, 0.7),\r\n )\r\n wheel.rotateX(Math.PI / 2)\r\n // A worn wheel is not a machined one. Small, because a grinding face\r\n // that is visibly lumpy is a wheel nobody would put a blade near.\r\n roughenGeometry(wheel, R * 0.012, { salt: 7 })\r\n\r\n // --- Frame --------------------------------------------------------\r\n const frame: BufferGeometry[] = []\r\n // 0.2 of the wheel's radius, not 0.15. At 0.15 the frame came out\r\n // spindly next to a reference whose timber is heavy enough to stand\r\n // being leant on while the wheel is worked — and a machine that looks\r\n // like it would rack is a machine nobody would put a blade to.\r\n const post = R * 0.2\r\n // The axle's radius is needed up here to size the bearing blocks and\r\n // again below to build the axle itself. One number, named once.\r\n const axleGuess = post * 0.3\r\n const railZ = T * 0.9 + post\r\n const railY = axleY - post * 1.4\r\n\r\n /**\r\n * A strut from one point to another.\r\n *\r\n * Built along +Y with its foot at the ORIGIN and turned into place, which\r\n * is the one construction in this kit that has never gone wrong: a piece\r\n * built where it is going to be rotated gets flung away by the rotation,\r\n * a piece built at the origin does not. The two angles are the ordinary\r\n * spherical pair and they are derived from the two ends rather than\r\n * chosen, so a leg cannot end up pointing somewhere its endpoints do not.\r\n */\r\n const strut = (\r\n from: readonly [number, number, number],\r\n to: readonly [number, number, number],\r\n thick: number,\r\n taper: number,\r\n colour: Color,\r\n ): BufferGeometry => {\r\n const dx = to[0] - from[0]\r\n const dy = to[1] - from[1]\r\n const dz = to[2] - from[2]\r\n const len = Math.hypot(dx, dy, dz)\r\n const bar = taperedBoxGeometry(\r\n [thick, thick],\r\n [thick * taper, thick * taper],\r\n len,\r\n [0, len / 2, 0],\r\n colour,\r\n )\r\n const tilt = Math.acos(Math.max(-1, Math.min(1, dy / len)))\r\n // rotateZ then rotateY sends +Y to (-sin t cos a, cos t, sin t sin a),\r\n // so the bearing that lands on (dx, dz) is atan2(dz, -dx).\r\n bar.rotateZ(tilt)\r\n bar.rotateY(Math.atan2(dz, -dx))\r\n bar.translate(from[0], from[1], from[2])\r\n return bar\r\n }\r\n\r\n // Four legs, one to a corner, splaying out in both directions on the way\r\n // down so the thing cannot be pushed over while it is being worked.\r\n const topX = L / 2 - post\r\n const footX = topX + splay * railY\r\n const footZ = railZ + splay * railY * 0.55\r\n for (const sx of [-1, 1] as const) {\r\n for (const sz of [-1, 1] as const) {\r\n frame.push(strut(\r\n [sx * footX, 0, sz * footZ],\r\n // Past the rail rather than up to it: the leg's head shows above\r\n // the rail as a through-tenon, which is the joint the reference\r\n // makes a feature of at every corner.\r\n [sx * topX, railY + post * 1.5, sz * railZ],\r\n post, 0.86, timber(jitter(random, 0.04)),\r\n ))\r\n }\r\n }\r\n\r\n // The two long rails the wheel hangs between, and a cross rail at each\r\n // end. They run PAST the legs, because a rail stopping flush at a post\r\n // is a rail with nothing holding it.\r\n for (const sz of [-1, 1] as const) {\r\n frame.push(boxGeometry(\r\n [L, post * 0.85, post * 0.9],\r\n [0, railY, sz * railZ],\r\n timber(0.03),\r\n ))\r\n }\r\n for (const sx of [-1, 1] as const) {\r\n frame.push(boxGeometry(\r\n [post * 0.8, post * 0.8, (railZ + post) * 2],\r\n [sx * topX, railY - post * 0.15, 0],\r\n timber(-0.02),\r\n ))\r\n }\r\n /**\r\n * The bearing blocks, which are what the wheel actually rests on.\r\n *\r\n * The axle sat 48 mm ABOVE the rails and touched nothing: the wheel was\r\n * hanging in the air and the support check said so — one component on\r\n * the ground, another containing the wheel, the crank and the water. It\r\n * is the reference's own answer, too, and I had left it out: there are\r\n * blocks bolted on top of each rail with the axle running through them.\r\n */\r\n const railTop = railY + post * 0.425\r\n const bearingTop = axleY + axleGuess * 1.8\r\n for (const sz of [-1, 1] as const) {\r\n frame.push(boxGeometry(\r\n [axleGuess * 5, bearingTop - railTop, post * 1.05],\r\n [0, (railTop + bearingTop) / 2, sz * railZ],\r\n timber(0.05),\r\n ))\r\n }\r\n\r\n /**\r\n * The low rails, one down EACH SIDE rather than one down the middle.\r\n *\r\n * A single stretcher on the centre line reached from end to end and\r\n * touched nothing on the way: at that height the legs have splayed out\r\n * to z = 0.21 and the stretcher was at z = 0. It tied the two trestles\r\n * together in the drawing and not in the geometry — the trough hung off\r\n * it, and the whole lot came away as one floating piece.\r\n *\r\n * Where the legs ARE at that height is worked out from the two ends of\r\n * the strut rather than guessed, which is the only way it stays true\r\n * when the splay is changed.\r\n */\r\n const tieY = railY * 0.3\r\n const legTopY = railY + post * 1.5\r\n const along = tieY / legTopY\r\n const tieX = footX + along * (topX - footX)\r\n const tieZ = footZ + along * (railZ - footZ)\r\n for (const sz of [-1, 1] as const) {\r\n frame.push(boxGeometry(\r\n [tieX * 2 + post, post * 0.8, post * 0.7],\r\n [0, tieY, sz * tieZ],\r\n timber(-0.05),\r\n ))\r\n }\r\n // And one across, which is what the trough hangs from.\r\n frame.push(boxGeometry(\r\n [post * 0.8, post * 0.7, tieZ * 2],\r\n [0, tieY - post * 0.05, 0],\r\n timber(-0.07),\r\n ))\r\n\r\n // --- Trough -------------------------------------------------------\r\n // Slung under the wheel so the rim runs through the water. Its rim\r\n // stands ABOVE the bottom of the wheel, which is the whole point of it\r\n // and the one measurement here that has to be right.\r\n const troughTop = axleY - R * 0.72\r\n const troughDepth = R * 0.32\r\n const troughHalfL = R * 0.78\r\n const troughHalfZ = T * 0.85 + post * 0.5\r\n const board = post * 0.38\r\n // Inside the four walls, not flush with them. Cut to the trough's full\r\n // outer size, the floor's own side faces landed in the same planes as\r\n // the walls' outer faces — four coplanar pairs, which the checker\r\n // reported at `plane 0,0,1 | 0.108` before anything had been rendered.\r\n frame.push(boxGeometry(\r\n [(troughHalfL - board) * 2, board, (troughHalfZ - board) * 2],\r\n [0, troughTop - troughDepth + board / 2, 0],\r\n timber(-0.08),\r\n ))\r\n for (const sz of [-1, 1] as const) {\r\n frame.push(boxGeometry(\r\n [troughHalfL * 2, troughDepth, board],\r\n [0, troughTop - troughDepth / 2, sz * (troughHalfZ - board / 2)],\r\n timber(-0.04),\r\n ))\r\n }\r\n for (const sx of [-1, 1] as const) {\r\n frame.push(boxGeometry(\r\n [board, troughDepth, (troughHalfZ - board) * 2],\r\n [sx * (troughHalfL - board / 2), troughTop - troughDepth / 2, 0],\r\n timber(-0.06),\r\n ))\r\n }\r\n // Two hangers from the stretcher up to the trough, so it is carried\r\n // rather than floating between the legs.\r\n for (const sx of [-1, 1] as const) {\r\n frame.push(boxGeometry(\r\n [post * 0.5, troughTop - troughDepth - tieY + post * 0.6, post * 0.5],\r\n [sx * troughHalfL * 0.7, (tieY + troughTop - troughDepth) / 2, 0],\r\n timber(-0.07),\r\n ))\r\n }\r\n\r\n // --- Axle, hub and crank ------------------------------------------\r\n const iron: BufferGeometry[] = []\r\n const axleR = axleGuess\r\n const axleEnd = railZ + post * 0.9\r\n // Built about the ORIGIN, not at the axle's height, because these ride\r\n // with the wheel: they go in as extras on the wheel part, whose anchor\r\n // already sits at `axleY`. Placing them in world coordinates as well\r\n // would have put them at twice that.\r\n iron.push(prismGeometry(\r\n axleR, axleR, axleEnd * 2, 8, [0, 0, 0], tint('iron', 0.02, 0.7),\r\n ).rotateX(Math.PI / 2))\r\n // The washers that hold the wheel on its axle, one to a face.\r\n for (const sz of [-1, 1] as const) {\r\n iron.push(prismGeometry(\r\n axleR * 2.4, axleR * 2.1, T * 0.2, 10, [0, 0, 0], tint('iron', -0.03, 0.7),\r\n ).rotateX(Math.PI / 2).translate(0, 0, sz * (T / 2 + T * 0.06)))\r\n }\r\n // The crank: out along the axle, across, and back for the handle.\r\n // A crank you could actually get a hand round. At 0.34 of the radius\r\n // with a 0.1 m handle it read as a hook on the hub rather than as the\r\n // thing that drives the wheel, which is half of what says \"this turns\"\r\n // when the model is standing still.\r\n const throwR = R * 0.44\r\n // Back along the axle rather than past its end. Set beyond it, the crank\r\n // had nothing to hold it and the support check said so: a part on its own\r\n // at 0.60 m with clear air between it and the machine.\r\n const crankZ = axleEnd - post * 0.3\r\n const crank: BufferGeometry[] = [\r\n boxGeometry(\r\n [axleR * 1.5, throwR + axleR * 2, axleR * 1.5],\r\n [0, throwR / 2 - axleR, 0],\r\n new Color(tint('iron', 0.04, 0.6)),\r\n ),\r\n prismGeometry(\r\n axleR * 0.85, axleR * 0.85, post * 2.6, 8, [0, 0, 0],\r\n tint('iron', 0.06, 0.6),\r\n ).rotateX(Math.PI / 2).translate(0, throwR, post * 1.3),\r\n // The grip is oak, because on every one of these it is: iron in the\r\n // hand is cold and it slips.\r\n prismGeometry(\r\n axleR * 1.7, axleR * 1.5, post * 1.5, 8, [0, 0, 0],\r\n tint('oak', -0.04),\r\n ).rotateX(Math.PI / 2).translate(0, throwR, post * 3.1),\r\n ]\r\n\r\n const crankBody = mergeColoured(crank)\r\n\r\n return {\r\n // The axle and its washers ride WITH the wheel, so they are extras on\r\n // it rather than a part of their own: the anchor turns, they turn, and\r\n // a cylinder on the axis of its own rotation does not move.\r\n //\r\n // Leaving them out of the returned parts entirely was the first\r\n // version. They were built, merged into a local array, and then never\r\n // referenced — so the wheel hung on nothing and the crank floated\r\n // beside the machine. Nothing in the type system minds an array you\r\n // forget to use.\r\n wheel: {\r\n slot: 'stone' as const,\r\n geometry: wheel,\r\n origin: [0, axleY, 0] as const,\r\n extras: [{ slot: 'iron' as const, geometry: mergeColoured(iron) }],\r\n },\r\n frame: { slot: 'oak' as const, geometry: mergeColoured(frame) },\r\n // Its own part with its own origin ON THE AXLE, so the action turns it\r\n // about the axle rather than about the model's floor.\r\n crank: {\r\n slot: 'iron' as const,\r\n geometry: crankBody,\r\n origin: [0, axleY, crankZ] as const,\r\n },\r\n water: (() => {\r\n const level = Math.max(0, Math.min(1, config.water))\r\n const depth = (troughDepth - board) * level\r\n if (depth <= troughDepth * 0.02) return undefined\r\n const sink = board * 0.5\r\n return {\r\n slot: 'water' as const,\r\n // Cut a shade WIDER than the basin, so its sides are buried in the\r\n // boards rather than laid against them. Sized to the inner\r\n // dimensions exactly, the water's faces sat in the same planes as\r\n // the trough's inner faces — the checker found all four at\r\n // `plane 0,0,1 | 0.091`. Water meets wood at a join you cannot see\r\n // anyway; the only question is which side of the wood it ends on.\r\n geometry: boxGeometry(\r\n [(troughHalfL - board * 0.55) * 2, depth + sink, (troughHalfZ - board * 0.55) * 2],\r\n [0, troughTop - troughDepth + board - sink + (depth + sink) / 2, 0],\r\n new Color(tint('water', jitter(random, 0.03), 0.6)),\r\n ),\r\n }\r\n })(),\r\n }\r\n },\r\n\r\n actions: ({ parts }) => {\r\n const apply = (): void => {\r\n parts.wheel.anchor.rotation.z = angle\r\n parts.crank.anchor.rotation.z = angle\r\n }\r\n apply()\r\n return {\r\n crank: () => {\r\n // 9.4 rad/s a pull, which measures out at about two and a half turns\r\n // before it stops. One crank giving less than a single revolution --\r\n // which 5.2 did -- reads as a stiff wheel rather than a heavy one.\r\n speed += 9.4\r\n },\r\n still: () => { angle = 0; speed = 0; apply() },\r\n isTurning: () => Math.abs(speed) > 1e-4,\r\n turns: () => turned,\r\n }\r\n },\r\n\r\n /**\r\n * Friction, not a fixed spin-down.\r\n *\r\n * A stone wheel on a wooden bearing is heavy and badly lubricated: it\r\n * carries for a while and then stops fairly suddenly, which is a constant\r\n * drag rather than a proportional one. A purely proportional decay never\r\n * reaches zero and the wheel creeps for ever, which is both wrong and the\r\n * kind of thing that leaves a model dirtying frames after the tour has\r\n * moved on.\r\n */\r\n update: (dt, { parts }) => {\r\n const step = Math.min(0.05, Math.max(0, dt))\r\n if (step === 0 || Math.abs(speed) < 1e-4) return\r\n const before = angle\r\n angle += speed * step\r\n speed *= Math.exp(-0.32 * step)\r\n const drag = 0.62 * step\r\n speed = Math.abs(speed) <= drag ? 0 : speed - Math.sign(speed) * drag\r\n turned += Math.abs(angle - before) / (Math.PI * 2)\r\n parts.wheel.anchor.rotation.z = angle\r\n parts.crank.anchor.rotation.z = angle\r\n },\r\n }, overrides)\r\n}\r\n",
566
+ "hash": "1ceb242f9e053d7ce3f8810e5167ef80ca118880fd6a486b063abd3268400e00"
567
+ }
568
+ ],
569
+ "meta": {
570
+ "title": "Grindstone",
571
+ "description": "Treadle grindstone: a sandstone wheel in a splayed timber trestle, with an iron crank and a trough of water it runs through. Turns.",
572
+ "category": "Smithy",
573
+ "tags": [
574
+ "medieval",
575
+ "lowpoly",
576
+ "smithy",
577
+ "procedural"
578
+ ],
579
+ "controls": {
580
+ "diameter": {
581
+ "type": "number",
582
+ "label": "Wheel diameter",
583
+ "min": 0.35,
584
+ "max": 0.95,
585
+ "step": 0.02,
586
+ "unit": "m"
587
+ },
588
+ "thickness": {
589
+ "type": "number",
590
+ "label": "Wheel thickness",
591
+ "min": 0.05,
592
+ "max": 0.18,
593
+ "step": 0.005,
594
+ "unit": "m"
595
+ },
596
+ "height": {
597
+ "type": "number",
598
+ "label": "Axle height",
599
+ "min": 0.4,
600
+ "max": 0.85,
601
+ "step": 0.02,
602
+ "unit": "m"
603
+ },
604
+ "length": {
605
+ "type": "number",
606
+ "label": "Frame length",
607
+ "min": 0.7,
608
+ "max": 1.5,
609
+ "step": 0.05,
610
+ "unit": "m"
611
+ },
612
+ "splay": {
613
+ "type": "number",
614
+ "label": "Leg splay",
615
+ "min": 0,
616
+ "max": 0.5,
617
+ "step": 0.02
618
+ },
619
+ "water": {
620
+ "type": "number",
621
+ "label": "Water level",
622
+ "min": 0,
623
+ "max": 1,
624
+ "step": 0.05
625
+ },
626
+ "seed": {
627
+ "type": "number",
628
+ "label": "Variation seed",
629
+ "min": 1,
630
+ "max": 64,
631
+ "step": 1
632
+ }
633
+ },
634
+ "materialSlots": [
635
+ "stone",
636
+ "oak",
637
+ "iron",
638
+ "water"
639
+ ],
640
+ "parts": [
641
+ "wheel",
642
+ "frame",
643
+ "crank",
644
+ "water"
645
+ ],
646
+ "sockets": []
647
+ }
648
+ },
649
+ {
650
+ "name": "hand-cart",
651
+ "type": "vibe3d:model",
652
+ "title": "Hand Cart",
653
+ "description": "Two-wheeled handcart: planked body on an axle set aft of centre, with shafts that rest on the ground.",
654
+ "dependencies": [
655
+ "three@>=0.185.0"
656
+ ],
657
+ "registryDependencies": [
658
+ "@medieval-kit/core"
659
+ ],
660
+ "files": [
661
+ {
662
+ "path": "models/hand-cart/model.ts",
663
+ "target": "{models}/medieval-kit/hand-cart/model.ts",
664
+ "content": "/**\n * @medieval-kit/hand-cart\n *\n * A two-wheeled handcart: a planked body on an axle, with two shafts to pull\n * it by.\n *\n * The kit already had `cart-wheel` and nothing to put it on. That is the gap\n * this fills, and it fills a second one at the same time: every container in\n * the catalogue — crates, sacks, barrels, baskets — is a thing you would\n * MOVE, and there was no way to move any of it.\n *\n * Two details carry the whole design, and both are about where the axle is:\n *\n * - It sits BEHIND the middle of the bed, not under it. A handcart is\n * balanced so that a loaded body rests a little weight on the puller's\n * hands rather than trying to tip backwards out of them, and the wheels go\n * aft of centre to do it.\n * - The shafts slope DOWN from the bed's front and rest on the ground when\n * the cart is parked. That is not the cart tipping: the bed stays level\n * and the shafts leave it at an angle, which is what lets one person pick\n * them up to waist height without the load shifting.\n *\n * Parts: `wheels` (the pair and their axle, with its own origin at the axle so\n * `setRoll` turns them in place), `bed`, `sides` and `shafts`.\n */\nimport { Color, type BufferGeometry } from 'three'\n\nimport {\n bandGeometry,\n boxGeometry,\n chamferedBoxGeometry,\n createKitModel,\n createTinter,\n jitter,\n latheGeometry,\n mergeColoured,\n taperedBoxGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface HandCartConfig {\n /** Length of the cart's bed (metres). */\n readonly bedLength: number\n /** Width of the bed (metres). */\n readonly bedWidth: number\n /** Height of the side boards above the bed (metres). */\n readonly sideHeight: number\n /** Wheel radius (metres). */\n readonly wheelRadius: number\n /** Spokes in each wheel. */\n readonly spokes: number\n /** Length of the shafts beyond the bed (metres). */\n readonly shaftLength: number\n /** Wheel rotation (radians). */\n readonly roll: number\n readonly seed: number\n}\n\nexport const handCartDefaults: HandCartConfig = {\n bedLength: 1.34,\n bedWidth: 0.66,\n sideHeight: 0.28,\n wheelRadius: 0.33,\n spokes: 10,\n shaftLength: 1.15,\n roll: 0,\n seed: 61,\n}\n\nexport type HandCartParts = 'wheels' | 'bed' | 'sides' | 'shafts'\n\nexport interface HandCartActions {\n /** Turns the wheels. */\n setRoll(radians: number): void\n roll(): number\n}\n\nexport function createModel(overrides: Partial<HandCartConfig> = {}) {\n let roll = 0\n\n return createKitModel<HandCartConfig, 'oak' | 'iron', HandCartParts, HandCartActions>({\n id: 'hand-cart',\n defaults: handCartDefaults,\n slots: ['oak', 'iron'],\n\n build: ({ config, random }) => {\n const tint = createTinter(random)\n const L = config.bedLength\n const W = config.bedWidth\n const wheelR = config.wheelRadius\n const plank = W * 0.035\n\n // Ground at y = 0: this model rests on its wheels and its shaft tips, and\n // building from the floor up keeps all three of those at one datum.\n const axleY = wheelR\n const bedY = axleY + plank * 2.6\n // Aft of centre, so a load leans on the puller rather than away from him.\n const axleZ = L * 0.16\n\n // --- Wheels and axle ------------------------------------------------------\n const spokes = Math.max(6, Math.round(config.spokes))\n const felloeOuter = wheelR * 0.9\n const felloeInner = felloeOuter * 0.8\n const hubR = wheelR * 0.19\n const wheelX = W / 2 + plank * 2.2\n const wheelBody: BufferGeometry[] = []\n const wheelIron: BufferGeometry[] = []\n\n for (const side of [-1, 1]) {\n const parts: BufferGeometry[] = []\n // Hub, turned and belled at both ends the way a nave is.\n //\n // A lathe is built around Y and this wheel is drawn in the XY plane, so\n // its axis is Z: without laying the hub over it stands upright inside\n // the wheel like a little drum, mostly hidden behind the spokes, which\n // is exactly why it survived so long.\n parts.push(latheGeometry(\n [\n { y: -wheelR * 0.16, radius: hubR * 0.72 },\n { y: -wheelR * 0.1, radius: hubR },\n { y: wheelR * 0.1, radius: hubR },\n { y: wheelR * 0.16, radius: hubR * 0.72 },\n ] as Level[],\n 9, [0, 0, 0], tint('oak', -0.02, 1.1),\n ).rotateX(Math.PI / 2))\n // Spokes. Each reaches INTO the hub at one end and INTO the felloe at\n // the other, so both joints are overlaps rather than faces meeting.\n const spokeInner = hubR * 0.5\n const spokeLength = felloeInner - spokeInner + wheelR * 0.06\n for (let i = 0; i < spokes; i += 1) {\n const a = (i / spokes) * Math.PI * 2\n const spoke = taperedBoxGeometry(\n [wheelR * 0.075, wheelR * 0.055],\n [wheelR * 0.058, wheelR * 0.045],\n spokeLength,\n [0, spokeLength / 2 + spokeInner, 0],\n tint('oak', jitter(random, 0.05)),\n )\n spoke.rotateZ(a + jitter(random, 0.012))\n parts.push(spoke)\n }\n // Felloe: separate segments, because a wheel rim is built from arcs of\n // timber and a turned ring reads as a hoop.\n for (let i = 0; i < spokes; i += 1) {\n const a = ((i + 0.5) / spokes) * Math.PI * 2\n const mid = (felloeOuter + felloeInner) / 2\n const arc = (Math.PI * 2 * mid) / spokes\n const seg = taperedBoxGeometry(\n [arc * 1.06, felloeOuter - felloeInner],\n [arc * 1.02, (felloeOuter - felloeInner) * 0.96],\n wheelR * 0.14 * (1 + jitter(random, 0.05)),\n [0, 0, 0],\n tint('oak', -0.05 + jitter(random, 0.05)),\n )\n // Built lying flat with its height along Y; standing it on edge puts\n // X along the arc, Y radial and Z across the wheel.\n //\n // Then out along +Y and only THEN round, which is the order the\n // cart-wheel model uses and the order that is correct. Turning first\n // and placing afterwards looks equivalent and is not: rotateZ sends\n // local +X to (cos a, sin a) and local +Y to (-sin a, cos a), so a\n // piece placed at (cos a, sin a) ends up with its ARC pointing\n // radially and its 59 mm thickness wrapped round the rim — a wheel\n // built from twelve stubby blocks sticking 89 mm past their own\n // felloe, straight through the iron tyre that is supposed to bind it.\n seg.rotateX(Math.PI / 2)\n seg.translate(0, mid, 0)\n seg.rotateZ(a)\n parts.push(seg)\n }\n const wheel = mergeColoured(parts)\n wheel.rotateY(Math.PI / 2)\n // Only the sideways offset. This part declares `origin` at the axle so\n // that `setRoll` turns it in place, and a part with an origin has its\n // geometry written RELATIVE to that point — the anchor supplies the\n // height and the position along the cart. Writing axleY and axleZ in\n // here as well applied them twice and hung both wheels 30 cm off the\n // ground, with the cart standing on its shaft tips.\n wheel.translate(side * wheelX, 0, 0)\n wheelBody.push(wheel)\n\n // Iron tyre, set INTO the tread and narrower than the felloe, the same\n // way the cart wheel's is: anything within the felloe's own thickness\n // will sooner or later share a plane with one of its segments.\n const tyre = bandGeometry(\n wheelR, 0, wheelR * 0.115, wheelR * 0.1, spokes * 2,\n tint('iron', jitter(random, 0.04), 0.7), { inner: true },\n )\n tyre.rotateZ(Math.PI / 2)\n tyre.translate(side * wheelX, 0, 0)\n wheelIron.push(tyre)\n }\n\n // Axle: through both hubs, so the pair is one object.\n const axle = taperedBoxGeometry(\n [hubR * 0.8, hubR * 0.8],\n [hubR * 0.8, hubR * 0.8],\n wheelX * 2 + hubR,\n [0, 0, 0],\n tint('oak', -0.1),\n )\n axle.rotateZ(Math.PI / 2)\n // Already at the axle: the anchor puts it there. See the wheel above.\n wheelBody.push(axle)\n\n // --- Bed ------------------------------------------------------------------\n const floorBoards: BufferGeometry[] = []\n const boards = 5\n for (let i = 0; i < boards; i += 1) {\n const w = W / boards\n floorBoards.push(chamferedBoxGeometry(\n [w * 0.97, L],\n [w * 0.95, L * 0.998],\n plank,\n plank * 0.14,\n [(i + 0.5) * w - W / 2, bedY, 0],\n tint('oak', 0.03 + jitter(random, 0.05)),\n ))\n }\n // Cross bearers under the boards, and the axle bed they sit on.\n for (const at of [-0.36, 0, 0.36]) {\n floorBoards.push(chamferedBoxGeometry(\n [W * 1.02, L * 0.07],\n [W * 0.99, L * 0.06],\n plank * 1.5,\n plank * 0.12,\n [0, bedY - plank * 1.2, at * L],\n tint('oak', -0.08),\n ))\n }\n floorBoards.push(chamferedBoxGeometry(\n [wheelX * 2 - hubR, L * 0.1],\n [wheelX * 2 - hubR * 1.6, L * 0.09],\n plank * 1.8,\n plank * 0.12,\n [0, bedY - plank * 2.3, axleZ],\n tint('oak', -0.12),\n ))\n\n // --- Sides ----------------------------------------------------------------\n const walls: BufferGeometry[] = []\n const iron: BufferGeometry[] = []\n const sideH = config.sideHeight\n\n for (const side of [-1, 1]) {\n // Two boards to a side, with a gap between them: a cart side is\n // planked, not panelled, and the gap is what says so.\n for (const [row, at] of [[0, 0.27], [1, 0.74]] as const) {\n walls.push(chamferedBoxGeometry(\n [plank, L * 0.99],\n [plank * 0.94, L * 0.985],\n sideH * 0.42,\n plank * 0.16,\n [side * (W / 2 + plank * 0.5), bedY + sideH * at, 0],\n tint('oak', (row === 0 ? -0.02 : 0.04) + jitter(random, 0.05)),\n ))\n }\n // Corner and middle stakes: the uprights the boards are nailed to.\n for (const at of [-0.46, 0, 0.46]) {\n walls.push(chamferedBoxGeometry(\n [plank * 1.4, L * 0.055],\n [plank * 1.2, L * 0.05],\n sideH * 1.08,\n plank * 0.16,\n [side * (W / 2 + plank * 1.1), bedY + sideH * 0.5, at * L],\n tint('oak', -0.05),\n ))\n }\n }\n // End boards, front and back.\n for (const end of [-1, 1]) {\n walls.push(chamferedBoxGeometry(\n [W + plank * 2.2, plank],\n [W + plank * 1.9, plank * 0.94],\n sideH * 0.92,\n plank * 0.16,\n [0, bedY + sideH * 0.5, end * (L / 2 - plank * 0.5)],\n tint('oak', -0.01 + jitter(random, 0.04)),\n ))\n }\n\n // --- Shafts ---------------------------------------------------------------\n // They leave the bed's front and slope down to the ground. The angle is\n // DERIVED from where they have to end, not chosen: a shaft that stops\n // short hangs in the air and one that goes long pushes the cart over.\n const shafts: BufferGeometry[] = []\n const rootY = bedY - plank * 1.2\n const rootZ = -L / 2\n const reach = config.shaftLength\n // The shaft is a pole, so the ground meets its SURFACE, not its axis.\n // Running the axis down to y = 0 buried the tip: the end face is cut\n // square across an inclined pole, so its low corner sits a radius further\n // down, and the cart was resting on two buried shaft tips with its wheels\n // 13.7 mm clear of the floor. Small, but the same defect as the big one\n // above and the same lie about what is holding the cart up. The axis\n // stops one tip-radius short instead, and wheels and shafts share a floor.\n const tipRadius = plank * 0.62\n const shaftDrop = rootY - tipRadius * Math.cos(Math.atan2(rootY, reach))\n const lean = Math.atan2(shaftDrop, reach)\n const shaftLength = Math.hypot(shaftDrop, reach)\n const shaftX = W / 2 - plank * 1.6\n\n for (const side of [-1, 1]) {\n const shaft = latheGeometry(\n [\n { y: -shaftLength / 2, radius: tipRadius },\n { y: -shaftLength * 0.42, radius: plank * 0.72 },\n { y: shaftLength * 0.36, radius: plank * 0.86 },\n { y: shaftLength / 2, radius: plank * 0.95 },\n ] as Level[],\n 7, [0, 0, 0], tint('oak', 0.02 + jitter(random, 0.05), 1.1),\n )\n // Built upright; leaning it about X drops its lower end forward, and\n // the 4% overrun at the top buries its head in the cart's frame.\n shaft.rotateX(Math.PI / 2 - lean)\n shaft.translate(\n side * shaftX,\n rootY - shaftDrop / 2,\n rootZ - reach / 2,\n )\n shafts.push(shaft)\n\n // The strap that holds the shaft to the frame. It is the only reason\n // the two are joined at all.\n iron.push(boxGeometry(\n [plank * 2.4, plank * 0.5, L * 0.09],\n [side * shaftX, rootY + plank * 0.5, rootZ + L * 0.03],\n new Color(tint('iron', 0.02, 0.7)),\n ))\n }\n // Cross handle between the shaft tips, so the cart can be pulled by one\n // person with both hands.\n shafts.push(latheGeometry(\n [\n { y: -shaftX - plank, radius: plank * 0.6 },\n { y: shaftX + plank, radius: plank * 0.6 },\n ] as Level[],\n 7, [0, 0, 0], tint('oak', 0.06, 1.1),\n ).rotateZ(Math.PI / 2).translate(0, plank * 0.62, rootZ - reach * 0.88))\n\n return {\n wheels: {\n slot: 'oak' as const,\n geometry: mergeColoured(wheelBody),\n extras: [{ slot: 'iron' as const, geometry: mergeColoured(wheelIron) }],\n origin: [0, axleY, axleZ] as const,\n },\n bed: { slot: 'oak' as const, geometry: mergeColoured(floorBoards) },\n sides: {\n slot: 'oak' as const,\n geometry: mergeColoured(walls),\n extras: [{ slot: 'iron' as const, geometry: mergeColoured(iron) }],\n },\n shafts: { slot: 'oak' as const, geometry: mergeColoured(shafts) },\n }\n },\n\n actions: ({ parts, getConfig }) => {\n roll = getConfig().roll\n parts.wheels.anchor.rotation.x = roll\n return {\n setRoll: (radians) => {\n roll = radians\n parts.wheels.anchor.rotation.x = roll\n },\n roll: () => roll,\n }\n },\n }, overrides)\n}\n",
665
+ "hash": "f01cc0c1540dd988b6acb00bf3e07e2705bcd45bcadee6e77886096e516c2d1d"
666
+ }
667
+ ],
668
+ "meta": {
669
+ "title": "Hand Cart",
670
+ "description": "Two-wheeled handcart: planked body on an axle set aft of centre, with shafts that rest on the ground.",
671
+ "category": "Structure",
672
+ "tags": [
673
+ "medieval",
674
+ "lowpoly",
675
+ "structure",
676
+ "procedural"
677
+ ],
678
+ "controls": {
679
+ "bedLength": {
680
+ "type": "number",
681
+ "label": "Bed length",
682
+ "min": 0.8,
683
+ "max": 2.1,
684
+ "step": 0.02,
685
+ "unit": "m"
686
+ },
687
+ "bedWidth": {
688
+ "type": "number",
689
+ "label": "Bed width",
690
+ "min": 0.4,
691
+ "max": 1.1,
692
+ "step": 0.02,
693
+ "unit": "m"
694
+ },
695
+ "sideHeight": {
696
+ "type": "number",
697
+ "label": "Side height",
698
+ "min": 0.1,
699
+ "max": 0.5,
700
+ "step": 0.01,
701
+ "unit": "m"
702
+ },
703
+ "wheelRadius": {
704
+ "type": "number",
705
+ "label": "Wheel radius",
706
+ "min": 0.18,
707
+ "max": 0.5,
708
+ "step": 0.01,
709
+ "unit": "m"
710
+ },
711
+ "spokes": {
712
+ "type": "number",
713
+ "label": "Spokes",
714
+ "min": 6,
715
+ "max": 14,
716
+ "step": 1
717
+ },
718
+ "shaftLength": {
719
+ "type": "number",
720
+ "label": "Shaft length",
721
+ "min": 0.6,
722
+ "max": 1.8,
723
+ "step": 0.02,
724
+ "unit": "m"
725
+ },
726
+ "roll": {
727
+ "type": "number",
728
+ "label": "Wheel rotation",
729
+ "min": 0,
730
+ "max": 6.28,
731
+ "step": 0.02,
732
+ "unit": "rad"
733
+ },
734
+ "seed": {
735
+ "type": "number",
736
+ "label": "Variation seed",
737
+ "min": 1,
738
+ "max": 64,
739
+ "step": 1
740
+ }
741
+ },
742
+ "materialSlots": [
743
+ "oak",
744
+ "iron"
745
+ ],
746
+ "parts": [
747
+ "wheels",
748
+ "bed",
749
+ "sides",
750
+ "shafts"
751
+ ],
752
+ "sockets": []
753
+ }
754
+ },
755
+ {
756
+ "name": "hay-bale",
757
+ "type": "vibe3d:model",
758
+ "title": "Hay Bale",
759
+ "description": "Hand-tied straw bale — pinched where the ropes bite, with wisps sticking out on every side.",
760
+ "dependencies": [
761
+ "three@>=0.185.0"
762
+ ],
763
+ "registryDependencies": [
764
+ "@medieval-kit/core"
765
+ ],
766
+ "files": [
767
+ {
768
+ "path": "models/hay-bale/model.ts",
769
+ "target": "{models}/medieval-kit/hay-bale/model.ts",
770
+ "content": "/**\r\n * @medieval-kit/hay-bale\r\n *\r\n * A bound bundle of straw.\r\n *\r\n * A warning is in order: the rectangular block that comes to mind today when\r\n * you say \"bale\" is machine work and belongs to the 19th century. In the Middle\r\n * Ages straw was either heaped loose or bound by hand into a bundle. This model\r\n * is the second.\r\n *\r\n * The SECOND attempt. The first was built from slice after slice of boxes and\r\n * one look at the render made the call obvious: it looked like A PALE WOODEN\r\n * CHEST. There were two separate mistakes, both about form, not colour:\r\n *\r\n * - The cross-section was rectangular. Sharp corner + flat face = joinery. A\r\n * bound bundle's section is round, because the cord that pulls it rounds it.\r\n * - The surfaces were perfectly flat. Straw is nowhere flat.\r\n *\r\n * So the body is now a lathe body — a cylinder that narrows where the cord\r\n * cinches it and is then broken up with `roughenGeometry`. Those two changes\r\n * made the model recognisable without the colour changing at all.\r\n */\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n bandGeometry,\r\n boxGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n roughenGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface HayBaleConfig {\r\n /** Length (metres). */\r\n readonly length: number\r\n /** Height (metres). */\r\n readonly height: number\r\n /** Depth (metres). */\r\n readonly depth: number\r\n /** How many cord ties. */\r\n readonly ropeCount: number\r\n /** Number of loose stalks sticking out of the surface. */\r\n readonly wisps: number\r\n /** Surface irregularity. 0 = smooth body. */\r\n readonly rough: number\r\n readonly seed: number\r\n}\r\n\r\nexport const hayBaleDefaults: HayBaleConfig = {\r\n length: 0.88,\r\n height: 0.42,\r\n depth: 0.46,\r\n ropeCount: 3,\r\n wisps: 34,\r\n rough: 1,\r\n seed: 47,\r\n}\r\n\r\nexport type HayBaleParts = 'bale' | 'wisps' | 'ropes'\r\n\r\nexport function createModel(overrides: Partial<HayBaleConfig> = {}) {\r\n return createKitModel<HayBaleConfig, 'straw' | 'cloth', HayBaleParts>({\r\n id: 'hay-bale',\r\n defaults: hayBaleDefaults,\r\n slots: ['straw', 'cloth'],\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const ropes = Math.max(0, Math.round(config.ropeCount))\r\n const halfLength = config.length / 2\r\n\r\n // The body is built first as a lathe body of RADIUS 1, because it is then\r\n // squashed into the real width/depth ratio. That way the amount the cord\r\n // pulls in is a single number — no need to work it out per axis.\r\n const ropeXs = Array.from({ length: ropes }, (_, i) =>\r\n ropes === 1 ? 0 : (i / (ropes - 1) - 0.5) * config.length * 0.54)\r\n const cinch = (x: number): number => {\r\n let tightest = 1\r\n for (const rx of ropeXs) {\r\n const distance = Math.abs(x - rx) / (config.length * 0.19)\r\n if (distance < 1) tightest = Math.min(tightest, 1 - 0.13 * (1 - distance * distance))\r\n }\r\n return tightest\r\n }\r\n\r\n // --- Body -----------------------------------------------------------\r\n const rings = 11\r\n const levels: Level[] = Array.from({ length: rings }, (_, i) => {\r\n const t = i / (rings - 1)\r\n const x = -halfLength + config.length * t\r\n // The ends round off: the first and last hoop are clearly narrower, or\r\n // else the bundle looks like a pipe cut off at both ends.\r\n // 0.12, not 0.34. A bound bale has broad, nearly flat ends -- the\r\n // cord holds the straw square against them. Pulling them in by a third\r\n // turned the silhouette into a cinched sack, and combined with the\r\n // roughening below there was no cylinder left to recognise.\r\n const endFade = 1 - Math.pow(Math.abs(t - 0.5) * 2, 6) * 0.12\r\n return { y: x, radius: 0.5 * cinch(x) * endFade * (1 + jitter(random, 0.05)) }\r\n })\r\n\r\n // Thirteen segments, not seven. The body carries the only strong shape\r\n // in the model and a seven-sided prism does not read as round.\r\n const body = latheGeometry(levels, 13, [0, 0, 0], tint('straw', -0.06, 1.6), {\r\n colourTop: tint('strawPale', 0.02, 1.6),\r\n })\r\n // Built upright and then laid down: the lathe helper works around the Y\r\n // axis, whereas the bundle runs along X.\r\n body.rotateZ(Math.PI / 2)\r\n body.scale(1, config.height, config.depth)\r\n // The displacement was 0.045 of the height -- 19 mm against a radius of\r\n // 210 mm, nearly a tenth. Straw is not flat, but a tenth of the radius is\r\n // not texture, it is deformation: the bale came out as an amorphous lump\r\n // with no axis. The stray stalks are what should carry the impression of\r\n // loose straw, and they can only do that against a body that holds its\r\n // shape.\r\n roughenGeometry(body, config.height * 0.016 * config.rough, { salt: 11 })\r\n\r\n // --- Stray stalks ----------------------------------------------------\r\n // The loose stalks on the surface and the stem ends spraying out of the\r\n // two ends. The second matters: in a bound bundle the CUT ends of the\r\n // stalks are always at the two ends, and that is the only sign that makes\r\n // the bundle read as \"cut plant\".\r\n const wispPieces: BufferGeometry[] = []\r\n const wispCount = Math.max(0, Math.round(config.wisps))\r\n const thickness = config.height * 0.016\r\n\r\n for (let i = 0; i < wispCount; i += 1) {\r\n const fromEnd = i % 3 === 0\r\n // Shorter on the sides. At up to 0.30 of the height these read as\r\n // spikes driven into the bale rather than as stalks working loose.\r\n const length = config.height * (fromEnd ? 0.16 + random() * 0.2 : 0.06 + random() * 0.09)\r\n const wisp = boxGeometry(\r\n [length, thickness * (0.6 + random() * 0.9), thickness],\r\n [length * 0.3, 0, 0], // root behind the origin: buried in the body\r\n tint('strawPale', 0.05, 1.4),\r\n )\r\n\r\n if (fromEnd) {\r\n // End stalks: outward along the X axis, scattering a little.\r\n const side = i % 6 === 0 ? 1 : -1\r\n const angle = random() * Math.PI * 2\r\n const radius = 0.5 * (0.15 + random() * 0.8)\r\n wisp.rotateZ(jitter(random, 0.4))\r\n wisp.rotateX(jitter(random, 0.4))\r\n if (side < 0) wisp.rotateY(Math.PI)\r\n wisp.translate(\r\n side * halfLength * (0.86 + random() * 0.1),\r\n Math.sin(angle) * radius * config.height,\r\n Math.cos(angle) * radius * config.depth,\r\n )\r\n } else {\r\n // Surface stalks: outward from the side surface of the body.\r\n const x = (random() - 0.5) * config.length * 0.88\r\n const angle = random() * Math.PI * 2\r\n const shrink = cinch(x)\r\n wisp.rotateZ(jitter(random, 0.9))\r\n wisp.rotateY(angle + Math.PI / 2)\r\n wisp.translate(\r\n x,\r\n Math.sin(angle) * 0.47 * config.height * shrink,\r\n Math.cos(angle) * 0.47 * config.depth * shrink,\r\n )\r\n }\r\n wispPieces.push(wisp)\r\n }\r\n\r\n // --- Cords -----------------------------------------------------------\r\n // Since the body is round, the cord is now built from a real HOOP rather\r\n // than from four bars. It goes through the same squashing transform, so\r\n // it sits exactly on the bundle's cross-section.\r\n const ropePieces: BufferGeometry[] = []\r\n const cord = config.height * 0.045\r\n for (const x of ropeXs) {\r\n // Free-standing hoop: it needs its inner face too, or it is not solid.\r\n // Radius and thickness in NORMALISED units, width in metres.\r\n //\r\n // The ring is built in the same radius-1 space as the body and squashed\r\n // by the same transform, so anything radial has to be expressed the way\r\n // the body is. The old line added `cord * 0.35` -- 4 mm in metres -- to\r\n // a normalised 0.5, which left the hoop standing 0.0044 proud before\r\n // the squash and 1.85 mm after it. The cords were modelled, merged,\r\n // exported and invisible in every render.\r\n //\r\n // The cord sits in the groove it pulls: its outer face comes back out\r\n // to just under where the bale's surface would be without the cinch,\r\n // which is what a tie does.\r\n const outer = 0.5 * (cinch(x) + 0.09)\r\n const ring = bandGeometry(outer, 0, cord * 1.8, 0.5 * 0.06, 9,\r\n tint('cloth', -0.07), { inner: true })\r\n ring.rotateZ(Math.PI / 2)\r\n ring.scale(1, config.height, config.depth)\r\n ring.translate(x, 0, 0)\r\n ropePieces.push(ring)\r\n }\r\n\r\n return {\r\n bale: { slot: 'straw' as const, geometry: mergeColoured([body]) },\r\n wisps: wispPieces.length > 0\r\n ? { slot: 'straw' as const, geometry: mergeColoured(wispPieces) }\r\n : undefined,\r\n ropes: ropePieces.length > 0\r\n ? { slot: 'cloth' as const, geometry: mergeColoured(ropePieces) }\r\n : undefined,\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
771
+ "hash": "f3bd9bebb394f74cb0b7b04e24f15280c35bbd9faf6cbab476bafa222f89e88c"
772
+ }
773
+ ],
774
+ "meta": {
775
+ "title": "Hay Bale",
776
+ "description": "Hand-tied straw bale — pinched where the ropes bite, with wisps sticking out on every side.",
777
+ "category": "Props",
778
+ "tags": [
779
+ "medieval",
780
+ "lowpoly",
781
+ "farm",
782
+ "props",
783
+ "procedural"
784
+ ],
785
+ "controls": {
786
+ "length": {
787
+ "type": "number",
788
+ "label": "Length",
789
+ "min": 0.4,
790
+ "max": 1.6,
791
+ "step": 0.02,
792
+ "unit": "m"
793
+ },
794
+ "height": {
795
+ "type": "number",
796
+ "label": "Height",
797
+ "min": 0.2,
798
+ "max": 0.9,
799
+ "step": 0.02,
800
+ "unit": "m"
801
+ },
802
+ "depth": {
803
+ "type": "number",
804
+ "label": "Depth",
805
+ "min": 0.2,
806
+ "max": 0.9,
807
+ "step": 0.02,
808
+ "unit": "m"
809
+ },
810
+ "ropeCount": {
811
+ "type": "number",
812
+ "label": "Rope count",
813
+ "min": 0,
814
+ "max": 4,
815
+ "step": 1
816
+ },
817
+ "wisps": {
818
+ "type": "number",
819
+ "label": "Stray wisps",
820
+ "min": 0,
821
+ "max": 60,
822
+ "step": 2
823
+ },
824
+ "seed": {
825
+ "type": "number",
826
+ "label": "Variation seed",
827
+ "min": 1,
828
+ "max": 64,
829
+ "step": 1
830
+ }
831
+ },
832
+ "materialSlots": [
833
+ "straw",
834
+ "cloth"
835
+ ],
836
+ "parts": [
837
+ "bale",
838
+ "wisps",
839
+ "ropes"
840
+ ],
841
+ "sockets": []
842
+ }
843
+ },
844
+ {
845
+ "name": "iron-anvil",
846
+ "type": "vibe3d:model",
847
+ "title": "Iron Anvil",
848
+ "description": "Wide base, narrow waist, tapering horn; on top a steel face plate polished by use.",
849
+ "dependencies": [
850
+ "three@>=0.185.0"
851
+ ],
852
+ "registryDependencies": [
853
+ "@medieval-kit/core"
854
+ ],
855
+ "files": [
856
+ {
857
+ "path": "models/iron-anvil/model.ts",
858
+ "target": "{models}/medieval-kit/iron-anvil/model.ts",
859
+ "content": "/**\r\n * @medieval-kit/iron-anvil\r\n *\r\n * An anvil's silhouette is the forging process itself: a wide base, a narrow\r\n * waist, a broad face on top, a horn tapering off to one side. The geometry is\r\n * almost entirely boxes — what gives it character is the proportions.\r\n *\r\n * The kit's first \"place-making\" piece: on its own it suggests a smith's corner.\r\n */\r\nimport { Color } from 'three'\r\n\r\nimport {\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n createTinter,\r\n prismGeometry,\r\n roughenGeometry,\r\n jitter,\r\n MEDIEVAL_PALETTE,\r\n mergeColoured,\r\n steelTint,\r\n} from '../core/index.ts'\r\n\r\nexport interface IronAnvilConfig {\r\n /** Total height (metres). With a real anvil stump it comes to ~0.75 m. */\r\n readonly height: number\r\n /** Length of the top face (metres). */\r\n readonly faceLength: number\r\n /** Width of the top face (metres). */\r\n readonly faceWidth: number\r\n /** How far the horn reaches past the face, as a ratio of the face length. */\r\n readonly hornReach: number\r\n readonly seed: number\r\n}\r\n\r\nexport const ironAnvilDefaults: IronAnvilConfig = {\r\n height: 0.34,\r\n faceLength: 0.46,\r\n faceWidth: 0.13,\r\n hornReach: 0.52,\r\n seed: 9,\r\n}\r\n\r\nexport type IronAnvilParts = 'base' | 'waist' | 'body' | 'face' | 'horn' | 'stump'\r\n\r\nexport function createModel(overrides: Partial<IronAnvilConfig> = {}) {\r\n return createKitModel<IronAnvilConfig, 'iron' | 'steel' | 'oak', IronAnvilParts>({\r\n id: 'iron-anvil',\r\n defaults: ironAnvilDefaults,\r\n slots: ['iron', 'steel', 'oak'],\r\n build: ({ config, random }) => {\r\n const tint = new Color()\r\n const shade = (amount = 0.05): Color => {\r\n tint.copy(MEDIEVAL_PALETTE.iron)\r\n tint.offsetHSL(0, jitter(random, 0.02), jitter(random, amount))\r\n return tint\r\n }\r\n\r\n const half = config.height / 2\r\n const baseHeight = config.height * 0.2\r\n const bodyHeight = config.height * 0.3\r\n const waistHeight = config.height - baseHeight - bodyHeight\r\n\r\n // Base: the widest piece, splaying out slightly toward the bottom.\r\n const baseLength = config.faceLength * 0.58\r\n const baseWidth = config.faceWidth * 1.5\r\n const base = mergeColoured([\r\n chamferedBoxGeometry(\r\n [baseLength * 1.08, baseWidth * 1.08],\r\n [baseLength, baseWidth],\r\n baseHeight,\r\n config.faceWidth * 0.06,\r\n [0, -half + baseHeight / 2, 0],\r\n shade(),\r\n ),\r\n ])\r\n\r\n // Waist: the narrow throat that makes an anvil an anvil.\r\n const waist = chamferedBoxGeometry(\r\n [baseLength * 0.5, baseWidth * 0.46],\r\n [baseLength * 0.5, baseWidth * 0.46],\r\n waistHeight,\r\n config.faceWidth * 0.06,\r\n [0, -half + baseHeight + waistHeight / 2, 0],\r\n shade(),\r\n )\r\n\r\n // Body: widens upward from the waist and carries the steel plate on top.\r\n //\r\n // An anvil really is made of two metals: a hard steel plate is welded on\r\n // top of the wrought iron body. The hammer always lands on that plate, so\r\n // over the years it polishes like a mirror; the body stays oxidised and\r\n // matte. In the model we give this as a separate part + separate material\r\n // slot, because the difference is not in colour but in ROUGHNESS, and\r\n // vertex colour cannot carry roughness.\r\n const plateHeight = config.height * 0.055\r\n const bodyY = half - bodyHeight / 2\r\n const body = mergeColoured([\r\n chamferedBoxGeometry(\r\n [baseLength * 0.62, baseWidth * 0.52],\r\n [config.faceLength * 0.62, config.faceWidth],\r\n bodyHeight,\r\n config.faceWidth * 0.06,\r\n [0, bodyY, 0],\r\n shade(0.06),\r\n ),\r\n ])\r\n\r\n // The plate is SUNK into the body: it goes in by half its own thickness\r\n // and also overhangs a touch on all four sides. Together those guarantee\r\n // that no pair of faces ends up coplanar (the z-fighting rule).\r\n const face = mergeColoured([\r\n chamferedBoxGeometry(\r\n [config.faceLength * 0.628, config.faceWidth * 1.012],\r\n [config.faceLength * 0.622, config.faceWidth * 1.006],\r\n plateHeight,\r\n config.faceWidth * 0.035,\r\n [0, half - plateHeight * 0.32, 0],\r\n steelTint(random),\r\n ),\r\n ])\r\n\r\n // Horn: a cone leaving the body horizontally, tapering to a point. Making\r\n // a vertical tapering box and turning it a quarter turn about the Z axis\r\n // is less code than writing a separate primitive, and the same result.\r\n const reach = config.faceLength * config.hornReach\r\n const horn = chamferedBoxGeometry(\r\n [config.faceWidth * 0.92, config.faceWidth * 0.86],\r\n [config.faceWidth * 0.1, config.faceWidth * 0.1],\r\n reach,\r\n config.faceWidth * 0.06,\r\n [0, 0, 0],\r\n shade(0.04),\r\n )\r\n horn.rotateZ(-Math.PI / 2)\r\n // Reach INTO the body: keep the end face inside the solid piece so that no\r\n // surface ends up coplanar with the body (the z-fighting rule).\r\n horn.translate(config.faceLength * 0.31 + reach / 2 - config.faceWidth * 0.35, bodyY + bodyHeight * 0.12, 0)\r\n\r\n // --- Stump ---------------------------------------------------------\r\n // An anvil is used ON something. It is bedded into the end grain of a\r\n // log so the block takes the ring out of it and puts the face at the\r\n // smith's knuckle height; the file header has said the model \"comes to\r\n // ~0.75 m with a real anvil stump\" for as long as it has existed, while\r\n // shipping the 0.34 m anvil on its own, lying on the floor.\r\n //\r\n // It is its own part, so anyone who wants the bare anvil for their own\r\n // bench hides `parts.stump` -- the same arrangement as the bell's frame.\r\n const stumpTop = -half + config.height * 0.06\r\n const stumpHeight = config.height * 1.2\r\n // Nearly as wide as the anvil is long. A block the anvil overhangs looks\r\n // like it would tip the first time anyone swung at it; a chopping block\r\n // is a section of trunk, chosen fat.\r\n const stumpRadius = config.faceLength * 0.42\r\n const woodTint = createTinter(random)\r\n // BOTH colours copied out before either is read.\r\n //\r\n // `createTinter` returns the same Color object on every call -- its own\r\n // docstring says so -- so passing two tints as arguments to one call\r\n // hands the callee the same object twice, holding whichever value was\r\n // computed last. The block came out painted in end-grain on every face,\r\n // which is why it rendered pale.\r\n const side = new Color(woodTint('oak', -0.26))\r\n const endGrain = new Color(woodTint('oakEnd', -0.16))\r\n const stump = prismGeometry(\r\n stumpRadius * 1.1,\r\n stumpRadius,\r\n stumpHeight,\r\n 9,\r\n [0, stumpTop - stumpHeight / 2, 0],\r\n side,\r\n { colourTop: endGrain },\r\n )\r\n // Split and weathered, the way a chopping block always is. Kept small:\r\n // the block reads by its mass, and the anvil sitting square on it is the\r\n // detail that matters.\r\n roughenGeometry(stump, stumpRadius * 0.035, { salt: 5, scaleY: 0.3 })\r\n\r\n return {\r\n stump: { slot: 'oak', geometry: stump },\r\n base: { slot: 'iron', geometry: base },\r\n waist: { slot: 'iron', geometry: waist },\r\n body: { slot: 'iron', geometry: body },\r\n face: { slot: 'steel', geometry: face },\r\n horn: { slot: 'iron', geometry: horn },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
860
+ "hash": "ba54f7501a2b60047327567f22c2b206dd4471d25e69b52fee23f10905d9bb05"
861
+ }
862
+ ],
863
+ "meta": {
864
+ "title": "Iron Anvil",
865
+ "description": "Wide base, narrow waist, tapering horn; on top a steel face plate polished by use.",
866
+ "category": "Smithy",
867
+ "tags": [
868
+ "medieval",
869
+ "lowpoly",
870
+ "smithy",
871
+ "procedural"
872
+ ],
873
+ "controls": {
874
+ "height": {
875
+ "type": "number",
876
+ "label": "Height",
877
+ "min": 0.2,
878
+ "max": 0.6,
879
+ "step": 0.01,
880
+ "unit": "m"
881
+ },
882
+ "faceLength": {
883
+ "type": "number",
884
+ "label": "Face length",
885
+ "min": 0.25,
886
+ "max": 0.8,
887
+ "step": 0.01,
888
+ "unit": "m"
889
+ },
890
+ "faceWidth": {
891
+ "type": "number",
892
+ "label": "Face width",
893
+ "min": 0.07,
894
+ "max": 0.25,
895
+ "step": 0.005,
896
+ "unit": "m"
897
+ },
898
+ "hornReach": {
899
+ "type": "number",
900
+ "label": "Horn reach",
901
+ "min": 0.2,
902
+ "max": 0.8,
903
+ "step": 0.02
904
+ },
905
+ "seed": {
906
+ "type": "number",
907
+ "label": "Variation seed",
908
+ "min": 1,
909
+ "max": 64,
910
+ "step": 1
911
+ }
912
+ },
913
+ "materialSlots": [
914
+ "iron",
915
+ "steel",
916
+ "oak"
917
+ ],
918
+ "parts": [
919
+ "base",
920
+ "waist",
921
+ "body",
922
+ "face",
923
+ "horn",
924
+ "stump"
925
+ ],
926
+ "sockets": []
927
+ }
928
+ },
929
+ {
930
+ "name": "iron-cauldron",
931
+ "type": "vibe3d:model",
932
+ "title": "Iron Cauldron",
933
+ "description": "Cauldron slung from an iron tripod over a ring of hearth stones, with embers that can be lit or put out.",
934
+ "dependencies": [
935
+ "three@>=0.185.0"
936
+ ],
937
+ "registryDependencies": [
938
+ "@medieval-kit/core"
939
+ ],
940
+ "files": [
941
+ {
942
+ "path": "models/iron-cauldron/model.ts",
943
+ "target": "{models}/medieval-kit/iron-cauldron/model.ts",
944
+ "content": "/**\r\n * @medieval-kit/iron-cauldron\r\n *\r\n * A cauldron slung from a tripod over a ring of hearth stones.\r\n *\r\n * The kit had two light sources — a torch and a lantern — and no HEARTH, which\r\n * is the wrong way round for a medieval catalogue. A cooking fire is where\r\n * everyone in the scene is facing, and it is the only light in most of them.\r\n *\r\n * Three things about the shape are worth stating because they are what make it\r\n * read as a cauldron rather than as a pot:\r\n *\r\n * - The belly is WIDER than the mouth. A cauldron is bulbous, so that its\r\n * contents sit over the fire rather than beside it, and so that the walls\r\n * take the heat evenly. Straight-sided, it is a bucket.\r\n * - It has three short feet. It spends as much time standing in the ashes as\r\n * hanging over them, and a round-bottomed pot cannot stand at all.\r\n * - It hangs by a BAIL — a semicircular loop pinned to two lugs — and not by\r\n * the rim. The bail is what the chain grips, and it swings, which is how a\r\n * cauldron is taken off the fire without touching it.\r\n *\r\n * Parts: `hearth` (the stone ring and its bed of embers), `tripod`, and `pot`\r\n * (the cauldron with its bail and the chain it hangs on). The chain belongs to\r\n * the pot rather than to the tripod because it moves with what it carries.\r\n */\r\nimport { type BufferGeometry } from 'three'\r\n\r\nimport {\r\n arcBarGeometry,\r\n bandGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n prismGeometry,\r\n roughenGeometry,\r\n taperedBoxGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface IronCauldronConfig {\r\n /** Height from the ground to the tripod's apex (metres). */\r\n readonly height: number\r\n /** Outer radius of the ring of hearth stones (metres). */\r\n readonly hearthRadius: number\r\n /** Stones in the ring. */\r\n readonly stones: number\r\n /** Radius of the cauldron at its belly (metres). */\r\n readonly potRadius: number\r\n /** Links in the chain. */\r\n readonly links: number\r\n /** Whether the fire is lit (0/1). */\r\n readonly lit: number\r\n readonly seed: number\r\n}\r\n\r\nexport const ironCauldronDefaults: IronCauldronConfig = {\r\n height: 1.28,\r\n hearthRadius: 0.46,\r\n stones: 12,\r\n potRadius: 0.22,\r\n links: 4,\r\n lit: 1,\r\n seed: 29,\r\n}\r\n\r\nexport type IronCauldronParts = 'hearth' | 'tripod' | 'pot'\r\n\r\nexport interface IronCauldronActions {\r\n /** Lights or puts out the fire. When out, the embers are hidden. */\r\n setLit(on: boolean): void\r\n isLit(): boolean\r\n}\r\n\r\nexport function createModel(overrides: Partial<IronCauldronConfig> = {}) {\r\n let lit = true\r\n\r\n return createKitModel<IronCauldronConfig, 'stone' | 'iron' | 'char' | 'ember', IronCauldronParts, IronCauldronActions>({\r\n id: 'iron-cauldron',\r\n defaults: ironCauldronDefaults,\r\n slots: ['stone', 'iron', 'char', 'ember'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const H = config.height\r\n const floor = -H / 2\r\n const hearthR = config.hearthRadius\r\n\r\n // --- Hearth -------------------------------------------------------------\r\n const stones: BufferGeometry[] = []\r\n const count = Math.max(5, Math.round(config.stones))\r\n const stoneH = hearthR * 0.32\r\n const ringR = hearthR - stoneH * 0.5\r\n\r\n for (let i = 0; i < count; i += 1) {\r\n const a = (i / count) * Math.PI * 2 + jitter(random, 0.05)\r\n const arc = (Math.PI * 2 * ringR) / count\r\n // Cut long, like the well's kerb: a flat-faced block on a curve cannot\r\n // close its joint otherwise, and a fire ring with daylight through it\r\n // is not a fire ring.\r\n const stone = taperedBoxGeometry(\r\n [arc * (1.06 + jitter(random, 0.08)), stoneH * (1 + jitter(random, 0.1))],\r\n [arc * (0.78 + jitter(random, 0.1)), stoneH * (0.72 + jitter(random, 0.12))],\r\n stoneH * (0.86 + jitter(random, 0.14)),\r\n [0, 0, 0],\r\n tint('stone', jitter(random, 0.1)),\r\n )\r\n stone.rotateY(a)\r\n stone.translate(\r\n Math.sin(a) * ringR,\r\n floor + stoneH * 0.4,\r\n Math.cos(a) * ringR,\r\n )\r\n stones.push(stone)\r\n }\r\n const hearth = mergeColoured(stones)\r\n roughenGeometry(hearth, stoneH * 0.08, { salt: 19 })\r\n\r\n // Ash bed inside the ring, and the embers on it. The bed is char and the\r\n // embers sit slightly proud of it; separating them is what lets the fire\r\n // be put out without the ashes going with it.\r\n const ashR = ringR - stoneH * 0.42\r\n const ash = latheGeometry(\r\n [\r\n { y: floor + hearthR * 0.005, radius: ashR },\r\n { y: floor + hearthR * 0.055, radius: ashR * 0.9 },\r\n ] as Level[],\r\n 11, [0, 0, 0], tint('char', 0.04),\r\n )\r\n\r\n const emberPieces: BufferGeometry[] = []\r\n const emberCount = Math.max(4, Math.round(count * 0.7))\r\n for (let i = 0; i < emberCount; i += 1) {\r\n const a = i * 2.399963\r\n const r = ashR * 0.72 * Math.sqrt((i + 0.4) / emberCount)\r\n const size = hearthR * (0.05 + random() * 0.045)\r\n const coal = prismGeometry(\r\n size, size * 0.6, size * 0.7, 5, [0, 0, 0],\r\n tint(i % 3 === 0 ? 'emberTip' : 'ember', jitter(random, 0.05), 0.4),\r\n )\r\n coal.rotateY(random() * Math.PI * 2)\r\n coal.translate(\r\n Math.sin(a) * r,\r\n floor + hearthR * 0.06,\r\n Math.cos(a) * r,\r\n )\r\n emberPieces.push(coal)\r\n }\r\n\r\n // --- Tripod -------------------------------------------------------------\r\n const apexY = floor + H\r\n const iron: BufferGeometry[] = []\r\n const legFoot = hearthR * 1.34\r\n const legRise = apexY - floor\r\n const legLength = Math.hypot(legFoot, legRise)\r\n const barT = hearthR * 0.045\r\n\r\n for (let i = 0; i < 3; i += 1) {\r\n // Three legs, not four. A tripod cannot rock: whatever the ground does,\r\n // three feet are always on it, which is why every cooking fire in\r\n // Europe stood on one.\r\n const a = (i / 3) * Math.PI * 2 + 0.4\r\n const leg = taperedBoxGeometry(\r\n [barT * 1.15, barT * 1.15],\r\n [barT * 0.8, barT * 0.8],\r\n legLength * 1.02,\r\n [0, 0, 0],\r\n tint('iron', -0.02 + jitter(random, 0.04), 0.7),\r\n )\r\n // NEGATIVE. `rotateX(+t)` carries the bar's TOP towards +Z, and the\r\n // translate that follows also moves it +Z, so the two add: the head\r\n // ends up at the full outboard radius and the foot at the axis. The\r\n // brace stands on its head, splayed at the top and gathered at the\r\n // bottom, which is the opposite of what carries a load. The bounding\r\n // box is identical either way, which is why it survives inspection.\r\n leg.rotateX(-Math.atan2(legFoot, legRise))\r\n leg.rotateY(a)\r\n leg.translate(\r\n Math.sin(a) * legFoot * 0.5,\r\n (floor + apexY) / 2,\r\n Math.cos(a) * legFoot * 0.5,\r\n )\r\n iron.push(leg)\r\n }\r\n // Apex ring: what gathers the three heads and carries the chain.\r\n iron.push(bandGeometry(\r\n barT * 2.3, apexY - barT * 0.9, barT * 1.8, barT * 0.7, 8,\r\n tint('iron', 0.05, 0.7), { inner: true },\r\n ))\r\n\r\n // --- Pot ----------------------------------------------------------------\r\n // Authored hanging from the APEX, which is where the part's origin goes,\r\n // so the whole assembly can be raised and lowered on its chain later\r\n // without any of it being rebuilt.\r\n const R = config.potRadius\r\n // Low over the fire, which is the only place a cooking pot is any use.\r\n // At 0.36 of the tripod's height the cauldron hung half a metre clear of\r\n // the embers -- close enough to look like a cauldron and far enough to\r\n // cook nothing.\r\n const potHang = -H * 0.62\r\n const pot: BufferGeometry[] = []\r\n const potIron: BufferGeometry[] = []\r\n\r\n // Chain, from the apex ring down to the bail.\r\n const linkCount = Math.max(2, Math.round(config.links))\r\n const chainDrop = -potHang - R * 1.5\r\n // The link radius comes from the SPACING the links are actually laid out\r\n // at, which is over (count - 1) gaps, not over count. Dividing by the\r\n // count made each link a little smaller than half its own gap, so the\r\n // chain came apart the moment the drop grew -- the same arithmetic slip\r\n // the tavern sign's chain had, and the support check caught this one for\r\n // the same reason it caught that one.\r\n const linkGap = (chainDrop - barT * 1.2) / Math.max(1, linkCount - 1)\r\n const linkR = Math.max(barT * 0.9, linkGap * 0.62)\r\n for (let i = 0; i < linkCount; i += 1) {\r\n const y = -barT * 1.2 - linkGap * i\r\n const ring = bandGeometry(\r\n linkR, 0, barT * 0.7, barT * 0.42, 6,\r\n tint('iron', jitter(random, 0.05), 0.7), { inner: true },\r\n )\r\n // Successive links pass through each other at right angles.\r\n ring.rotateY(i * 0.41)\r\n ring.rotateX(i % 2 === 0 ? Math.PI / 2 : 0)\r\n ring.rotateZ(i % 2 === 0 ? 0 : Math.PI / 2)\r\n ring.translate(0, y, 0)\r\n potIron.push(ring)\r\n }\r\n\r\n // The cauldron itself. The widest point is BELOW the mouth.\r\n const belly = potHang\r\n const profile: Level[] = [\r\n { y: belly - R * 0.82, radius: R * 0.34 },\r\n { y: belly - R * 0.62, radius: R * 0.66 },\r\n { y: belly - R * 0.2, radius: R * 0.97 },\r\n { y: belly + R * 0.1, radius: R },\r\n { y: belly + R * 0.5, radius: R * 0.86 },\r\n { y: belly + R * 0.72, radius: R * 0.82 },\r\n ]\r\n pot.push(latheGeometry(profile, 11, [0, 0, 0], tint('char', 0.05, 0.5), {\r\n colourTop: tint('char', 0.11, 0.5),\r\n capTop: false,\r\n }))\r\n // The inside, so the mouth is not a hole through to nothing.\r\n pot.push(latheGeometry(\r\n [\r\n { y: belly - R * 0.55, radius: R * 0.6 },\r\n { y: belly + R * 0.72, radius: R * 0.76 },\r\n ] as Level[],\r\n 11, [0, 0, 0], tint('char', -0.06, 0.4), { capTop: false },\r\n ))\r\n // Rim, and the girth band round the belly: cast ridges, and the two lines\r\n // that stop the body reading as a smooth blob.\r\n potIron.push(bandGeometry(\r\n R * 0.86, belly + R * 0.7, R * 0.1, R * 0.06, 11,\r\n tint('iron', -0.04, 0.6),\r\n ))\r\n potIron.push(bandGeometry(\r\n R * 1.02, belly + R * 0.06, R * 0.07, R * 0.05, 11,\r\n tint('iron', -0.08, 0.6),\r\n ))\r\n\r\n // Three feet. Short, splayed, and the reason it can stand in the ashes.\r\n for (let i = 0; i < 3; i += 1) {\r\n const a = (i / 3) * Math.PI * 2 + 0.9\r\n const foot = taperedBoxGeometry(\r\n [R * 0.14, R * 0.14],\r\n [R * 0.1, R * 0.1],\r\n R * 0.38,\r\n [0, 0, 0],\r\n tint('iron', -0.05, 0.6),\r\n )\r\n foot.rotateX(0.22)\r\n foot.rotateY(a)\r\n foot.translate(\r\n Math.sin(a) * R * 0.52,\r\n belly - R * 0.86,\r\n Math.cos(a) * R * 0.52,\r\n )\r\n potIron.push(foot)\r\n }\r\n\r\n // Bail: a real arc, pinned to two lugs. `arcBarGeometry` sweeps from 0 to\r\n // PI in the XY plane, which is already the shape of a handle standing\r\n // over the pot, so no rotation is involved.\r\n const bailR = R * 0.92\r\n potIron.push(arcBarGeometry(\r\n bailR, barT * 0.75, -0.22, Math.PI + 0.22, 9,\r\n [0, belly + R * 0.42, 0],\r\n tint('iron', 0.06, 0.7),\r\n ))\r\n for (const side of [-1, 1]) {\r\n potIron.push(bandGeometry(\r\n R * 0.13, belly + R * 0.5, R * 0.13, R * 0.06, 6,\r\n tint('iron', 0.02, 0.6), { inner: true },\r\n ).translate(side * bailR * 0.94, 0, 0))\r\n }\r\n\r\n return {\r\n hearth: {\r\n slot: 'stone' as const,\r\n geometry: hearth,\r\n extras: [\r\n { slot: 'char' as const, geometry: ash },\r\n { slot: 'ember' as const, geometry: mergeColoured(emberPieces) },\r\n ],\r\n },\r\n tripod: { slot: 'iron' as const, geometry: mergeColoured(iron) },\r\n pot: {\r\n slot: 'char' as const,\r\n geometry: mergeColoured(pot),\r\n extras: [{ slot: 'iron' as const, geometry: mergeColoured(potIron) }],\r\n origin: [0, apexY, 0] as const,\r\n },\r\n }\r\n },\r\n\r\n actions: ({ parts, getConfig }) => {\r\n lit = getConfig().lit >= 0.5\r\n // The embers are the SECOND body of the hearth part. Hiding the part\r\n // itself would take the stones with it.\r\n const embers = parts.hearth.anchor.children[2]\r\n if (embers) embers.visible = lit\r\n return {\r\n setLit: (on) => {\r\n lit = on\r\n const body = parts.hearth.anchor.children[2]\r\n if (body) body.visible = on\r\n },\r\n isLit: () => lit,\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
945
+ "hash": "0311aa3190cd1be0b1d7e7a3a337e04dcb10081558054580cb85d83e800ffb0c"
946
+ }
947
+ ],
948
+ "meta": {
949
+ "title": "Iron Cauldron",
950
+ "description": "Cauldron slung from an iron tripod over a ring of hearth stones, with embers that can be lit or put out.",
951
+ "category": "Lighting",
952
+ "tags": [
953
+ "medieval",
954
+ "lowpoly",
955
+ "hearth",
956
+ "procedural"
957
+ ],
958
+ "controls": {
959
+ "height": {
960
+ "type": "number",
961
+ "label": "Tripod height",
962
+ "min": 0.8,
963
+ "max": 2,
964
+ "step": 0.02,
965
+ "unit": "m"
966
+ },
967
+ "hearthRadius": {
968
+ "type": "number",
969
+ "label": "Hearth radius",
970
+ "min": 0.28,
971
+ "max": 0.8,
972
+ "step": 0.01,
973
+ "unit": "m"
974
+ },
975
+ "stones": {
976
+ "type": "number",
977
+ "label": "Hearth stones",
978
+ "min": 6,
979
+ "max": 20,
980
+ "step": 1
981
+ },
982
+ "potRadius": {
983
+ "type": "number",
984
+ "label": "Cauldron radius",
985
+ "min": 0.12,
986
+ "max": 0.36,
987
+ "step": 0.01,
988
+ "unit": "m"
989
+ },
990
+ "links": {
991
+ "type": "number",
992
+ "label": "Chain links",
993
+ "min": 2,
994
+ "max": 8,
995
+ "step": 1
996
+ },
997
+ "lit": {
998
+ "type": "number",
999
+ "label": "Lit",
1000
+ "min": 0,
1001
+ "max": 1,
1002
+ "step": 1
1003
+ },
1004
+ "seed": {
1005
+ "type": "number",
1006
+ "label": "Variation seed",
1007
+ "min": 1,
1008
+ "max": 64,
1009
+ "step": 1
1010
+ }
1011
+ },
1012
+ "materialSlots": [
1013
+ "stone",
1014
+ "iron",
1015
+ "char",
1016
+ "ember"
1017
+ ],
1018
+ "parts": [
1019
+ "hearth",
1020
+ "tripod",
1021
+ "pot"
1022
+ ],
1023
+ "sockets": []
1024
+ }
1025
+ },
1026
+ {
1027
+ "name": "iron-lantern",
1028
+ "type": "vibe3d:model",
1029
+ "title": "Iron Lantern",
1030
+ "description": "Hexagonal iron cage, glass panels, an oil lamp inside. An enclosed flame flickers more calmly.",
1031
+ "dependencies": [
1032
+ "three@>=0.185.0"
1033
+ ],
1034
+ "registryDependencies": [
1035
+ "@medieval-kit/core"
1036
+ ],
1037
+ "files": [
1038
+ {
1039
+ "path": "models/iron-lantern/model.ts",
1040
+ "target": "{models}/medieval-kit/iron-lantern/model.ts",
1041
+ "content": "/**\r\n * @medieval-kit/iron-lantern\r\n *\r\n * An iron lantern carried in the hand or hung from a hook: a hexagonal cage,\r\n * glass panels, an oil lamp inside.\r\n *\r\n * What separates it from the torch is not only shape. A torch is a consumable,\r\n * a lantern is a TOOL — expensive, kept, handed down. So the geometry looks\r\n * more \"made\" as well: forged corner posts, a vent flue, a carrying hoop.\r\n *\r\n * The glass has two consequences and both are visible here:\r\n *\r\n * - The `glass` slot is TRANSPARENT and `depthWrite` is off. Without that the\r\n * glass hid the wick behind it; a transparent surface must not write depth.\r\n * - The glass panels are an `extras` body of the cage. Making them a separate\r\n * part had seemed sensible but was wrong: cage and glass are ONE MEANING —\r\n * if one moves, so does the other. Only the material is split.\r\n */\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n arcBarGeometry,\r\n bandGeometry,\r\n boxGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n prismGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface IronLanternConfig {\r\n /** Body height, hoop excluded (metres). */\r\n readonly height: number\r\n /** Corner-to-corner radius of the cage (metres). */\r\n readonly radius: number\r\n /** How many corners. 4 is a lantern, 6 is richer. */\r\n readonly sides: number\r\n /** Flame height, as a fraction of the body height. */\r\n readonly flameHeight: number\r\n /** Amplitude of the flicker. 0 = steady flame. */\r\n readonly flicker: number\r\n readonly seed: number\r\n}\r\n\r\nexport const ironLanternDefaults: IronLanternConfig = {\r\n height: 0.26,\r\n radius: 0.075,\r\n sides: 6,\r\n flameHeight: 0.22,\r\n flicker: 1,\r\n seed: 71,\r\n}\r\n\r\nexport type IronLanternParts = 'frame' | 'font' | 'flame' | 'handle'\r\n\r\nexport interface IronLanternActions {\r\n setLit(lit: boolean): void\r\n isLit(): boolean\r\n}\r\n\r\nexport function createModel(overrides: Partial<IronLanternConfig> = {}) {\r\n let lit = true\r\n let elapsed = 0\r\n\r\n return createKitModel<IronLanternConfig, 'iron' | 'glass' | 'char' | 'ember', IronLanternParts, IronLanternActions>({\r\n id: 'iron-lantern',\r\n defaults: ironLanternDefaults,\r\n slots: ['iron', 'glass', 'char', 'ember'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const half = config.height / 2\r\n const sides = Math.max(3, Math.round(config.sides))\r\n const bar = config.radius * 0.13\r\n // The glazed section is the middle: oil font below, room for the flue above.\r\n const glassBottom = -half + config.height * 0.2\r\n const glassTop = half - config.height * 0.26\r\n\r\n // --- Cage --------------------------------------------------------------\r\n const iron: BufferGeometry[] = []\r\n\r\n // Base dish and top cap: both hexagonal, one flat and one conical.\r\n iron.push(prismGeometry(\r\n config.radius * 1.08, config.radius, config.height * 0.11, sides,\r\n [0, -half + config.height * 0.055, 0], tint('iron', -0.04, 0.7),\r\n ))\r\n iron.push(prismGeometry(\r\n config.radius * 1.12, config.radius * 0.42, config.height * 0.19, sides,\r\n [0, half - config.height * 0.16, 0], tint('iron', 0.03, 0.7),\r\n ))\r\n // Flue: if the hot air cannot get out the flame dies. A functional detail,\r\n // and what makes the top of the silhouette read as a lantern.\r\n iron.push(prismGeometry(\r\n config.radius * 0.4, config.radius * 0.34, config.height * 0.09, sides,\r\n [0, half - config.height * 0.025, 0], tint('iron', 0.07, 0.7),\r\n ))\r\n\r\n // Corner posts: the forged iron bars left between the glass panels.\r\n const step = (Math.PI * 2) / sides\r\n for (let i = 0; i < sides; i += 1) {\r\n const a = i * step\r\n // The post runs down INTO the base plate. It used to stop 4% of the\r\n // height short of it, which left the whole cage — and the handle on top\r\n // of it — as an island hovering over its own base.\r\n const post = boxGeometry(\r\n [bar, glassTop - glassBottom + config.height * 0.34, bar * 1.15],\r\n [0, 0, 0],\r\n tint('iron', jitter(random, 0.05), 0.7),\r\n )\r\n // Orient first, then translate — reversed, the post is flung into orbit.\r\n post.rotateY(a)\r\n post.translate(\r\n Math.sin(a) * config.radius * 0.97,\r\n (glassBottom + glassTop) / 2 - config.height * 0.09,\r\n Math.cos(a) * config.radius * 0.97,\r\n )\r\n iron.push(post)\r\n }\r\n // The bottom and top frames holding the glass panels.\r\n for (const y of [glassBottom, glassTop]) {\r\n iron.push(bandGeometry(config.radius * 0.99, y, bar * 1.1, bar * 0.8, sides,\r\n tint('iron', -0.02, 0.7)))\r\n }\r\n\r\n // --- Glass ---------------------------------------------------------------\r\n // The panels sit slightly INSIDE the posts: at the same radius their side\r\n // faces would be coplanar with the posts' faces and would flicker.\r\n const glass = prismGeometry(\r\n config.radius * 0.9, config.radius * 0.9, glassTop - glassBottom, sides,\r\n [0, (glassBottom + glassTop) / 2, 0], tint('glass', 0.04, 0.4),\r\n { capTop: false, capBottom: false },\r\n )\r\n\r\n // --- Oil font and wick ------------------------------------------------------\r\n const fontTop = glassBottom + config.height * 0.16\r\n const font = latheGeometry([\r\n // Seated in the base plate rather than floating above it: the font and\r\n // the wick it carries were a second detached island.\r\n { y: -half + config.height * 0.06, radius: config.radius * 0.44 },\r\n { y: glassBottom - config.height * 0.02, radius: config.radius * 0.5 },\r\n { y: glassBottom + config.height * 0.06, radius: config.radius * 0.62 },\r\n { y: fontTop, radius: config.radius * 0.44 },\r\n ], sides * 2, [0, 0, 0], tint('glass', -0.05, 0.4), { capTop: true })\r\n\r\n const wick = prismGeometry(\r\n config.radius * 0.075, config.radius * 0.05, config.height * 0.09, 4,\r\n [0, fontTop + config.height * 0.035, 0], tint('char', 0.05),\r\n )\r\n\r\n // --- Flame -------------------------------------------------------------\r\n // A smaller, calmer version of the torch's flame: in a closed lantern the\r\n // flame gets no wind, so it flickers less as well.\r\n const flameHeight = config.height * config.flameHeight\r\n const flameBase = fontTop + config.height * 0.07\r\n const flameProfile: Level[] = [\r\n { y: 0, radius: flameHeight * 0.2 },\r\n { y: flameHeight * 0.22, radius: flameHeight * 0.3 },\r\n { y: flameHeight * 0.58, radius: flameHeight * 0.19 },\r\n { y: flameHeight, radius: flameHeight * 0.03 },\r\n ]\r\n const flame = mergeColoured([\r\n latheGeometry(flameProfile, 6, [0, 0, 0], tint('ember', 0.06, 0.35),\r\n { colourTop: tint('emberTip', 0.02, 0.35) }),\r\n ])\r\n\r\n // --- Carrying bail ----------------------------------------------------------\r\n // A vertical arch, not a horizontal ring.\r\n //\r\n // What was here was a `bandGeometry` -- a flat hoop lying in the XZ\r\n // plane on top of the flue. That is a suspension eye, and against a\r\n // photograph of a real lantern it reads as a collar rather than as\r\n // anything you could pick the lantern up by. A lantern's defining\r\n // feature is that it is CARRIED: the bail is the first thing the eye\r\n // looks for and the reason the object exists in a scene at all.\r\n //\r\n // `arcBarGeometry` builds in the XY plane, which for a half-circle from\r\n // 0 to PI is already the shape wanted -- ends level, apex overhead --\r\n // so no rotation is involved.\r\n // The sweep runs PAST half a circle at both ends, by about 20 degrees.\r\n // A clean half-circle put the two ends level with the roof's surface and\r\n // merely tangent to it, which the support check rightly called floating.\r\n // Carrying the arc further round turns each end downwards and outwards,\r\n // so it crosses the roof cone from inside to outside and is genuinely\r\n // embedded -- the same \"real carpentry rather than a nudge\" rule the\r\n // rest of the kit follows for joints.\r\n const bailRadius = config.radius * 0.78\r\n const handle: BufferGeometry[] = [arcBarGeometry(\r\n bailRadius,\r\n bar * 1.05,\r\n -0.35,\r\n Math.PI + 0.35,\r\n 11,\r\n [0, half - config.height * 0.145, 0],\r\n tint('iron', 0.06, 0.7),\r\n )]\r\n // The tongue that used to join the old horizontal ring down to the flue\r\n // is gone with the ring. Left in place it z-fought the new bail: its\r\n // sides sat at z = +-0.00488 against the bail's +-0.00512, a quarter of\r\n // a millimetre apart over the whole apex. A bail that reaches into the\r\n // roof needs no strap to hold it on.\r\n\r\n return {\r\n frame: {\r\n slot: 'iron' as const,\r\n geometry: mergeColoured(iron),\r\n extras: [{ slot: 'glass' as const, geometry: mergeColoured([glass]) }],\r\n },\r\n font: {\r\n slot: 'glass' as const,\r\n geometry: mergeColoured([font]),\r\n extras: [{ slot: 'char' as const, geometry: mergeColoured([wick]) }],\r\n },\r\n flame: {\r\n slot: 'ember' as const,\r\n geometry: flame,\r\n origin: [0, flameBase, 0] as const,\r\n },\r\n handle: { slot: 'iron' as const, geometry: mergeColoured(handle) },\r\n }\r\n },\r\n\r\n actions: ({ parts }) => {\r\n parts.flame.anchor.visible = lit\r\n return {\r\n setLit: (next) => { lit = next; parts.flame.anchor.visible = next },\r\n isLit: () => lit,\r\n }\r\n },\r\n\r\n update: (dt, { parts, getConfig }) => {\r\n if (!lit) return\r\n const amount = getConfig().flicker\r\n if (amount === 0) return\r\n elapsed += Math.max(0, dt)\r\n // Slower and smaller than the torch's: the glass shields the flame from wind.\r\n const pulse = Math.sin(elapsed * 6.4) * 0.06 + Math.sin(elapsed * 11.1 + 0.9) * 0.035\r\n const anchor = parts.flame.anchor\r\n anchor.scale.set(1 - pulse * 0.4 * amount, 1 + pulse * amount, 1 - pulse * 0.4 * amount)\r\n anchor.rotation.z = Math.sin(elapsed * 4.7 + 1.3) * 0.03 * amount\r\n },\r\n }, overrides)\r\n}\r\n",
1042
+ "hash": "8e6f2101e85362a7073bbd0612f28ddd12477679b56310cddc48801f0539db96"
1043
+ }
1044
+ ],
1045
+ "meta": {
1046
+ "title": "Iron Lantern",
1047
+ "description": "Hexagonal iron cage, glass panels, an oil lamp inside. An enclosed flame flickers more calmly.",
1048
+ "category": "Lighting",
1049
+ "tags": [
1050
+ "medieval",
1051
+ "lowpoly",
1052
+ "lighting",
1053
+ "animated",
1054
+ "interactive"
1055
+ ],
1056
+ "controls": {
1057
+ "height": {
1058
+ "type": "number",
1059
+ "label": "Height",
1060
+ "min": 0.14,
1061
+ "max": 0.5,
1062
+ "step": 0.01,
1063
+ "unit": "m"
1064
+ },
1065
+ "radius": {
1066
+ "type": "number",
1067
+ "label": "Radius",
1068
+ "min": 0.04,
1069
+ "max": 0.15,
1070
+ "step": 0.005,
1071
+ "unit": "m"
1072
+ },
1073
+ "sides": {
1074
+ "type": "number",
1075
+ "label": "Side count",
1076
+ "min": 3,
1077
+ "max": 8,
1078
+ "step": 1
1079
+ },
1080
+ "flameHeight": {
1081
+ "type": "number",
1082
+ "label": "Flame height",
1083
+ "min": 0.08,
1084
+ "max": 0.45,
1085
+ "step": 0.01
1086
+ },
1087
+ "flicker": {
1088
+ "type": "number",
1089
+ "label": "Flicker",
1090
+ "min": 0,
1091
+ "max": 2.5,
1092
+ "step": 0.05
1093
+ },
1094
+ "seed": {
1095
+ "type": "number",
1096
+ "label": "Variation seed",
1097
+ "min": 1,
1098
+ "max": 64,
1099
+ "step": 1
1100
+ }
1101
+ },
1102
+ "materialSlots": [
1103
+ "iron",
1104
+ "glass",
1105
+ "char",
1106
+ "ember"
1107
+ ],
1108
+ "parts": [
1109
+ "frame",
1110
+ "font",
1111
+ "flame",
1112
+ "handle"
1113
+ ],
1114
+ "sockets": []
1115
+ }
1116
+ },
1117
+ {
1118
+ "name": "leather-book",
1119
+ "type": "vibe3d:model",
1120
+ "title": "Leather Book",
1121
+ "description": "Leather-bound manuscript with spine bands and clasps. The page block overhangs the cover, wavy.",
1122
+ "dependencies": [
1123
+ "three@>=0.185.0"
1124
+ ],
1125
+ "registryDependencies": [
1126
+ "@medieval-kit/core"
1127
+ ],
1128
+ "files": [
1129
+ {
1130
+ "path": "models/leather-book/model.ts",
1131
+ "target": "{models}/medieval-kit/leather-book/model.ts",
1132
+ "content": "/**\r\n * @medieval-kit/leather-book\r\n *\r\n * Leather-bound manuscript with a clasp. Table, shelf, inside a chest, lectern.\r\n *\r\n * The whole point of the model is the PAGE BLOCK. What makes a book a book is\r\n * not its cover but the uneven mass of paper spilling past that cover. Give it\r\n * as a flat box and it reads like a brick no matter how good the cover is.\r\n *\r\n * Two period details drove the geometry:\r\n *\r\n * - The pages are NOT PAPER but vellum (parchment), i.e. animal skin. That\r\n * makes them thick, yellowed and wavy. `roughenGeometry` puts that\r\n * waviness on the edge of the block.\r\n * - The book DOES NOT stay shut: vellum draws in moisture and swells. That\r\n * is why real manuscripts have a clasp — function, not ornament. Without\r\n * the clasp the model loses its period.\r\n */\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n boxGeometry,\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n mergeColoured,\r\n flipGeometry,\r\n prismGeometry,\r\n roughenGeometry,\r\n} from '../core/index.ts'\r\n\r\nexport interface LeatherBookConfig {\r\n /** Cover width (metres). */\r\n readonly width: number\r\n /** Cover length (metres). */\r\n readonly length: number\r\n /** Total thickness of the closed book (metres). */\r\n readonly thickness: number\r\n /** Number of raised bands on the spine. */\r\n readonly bands: number\r\n /** Number of clasps. */\r\n readonly clasps: number\r\n readonly seed: number\r\n}\r\n\r\nexport const leatherBookDefaults: LeatherBookConfig = {\r\n width: 0.19,\r\n length: 0.27,\r\n thickness: 0.062,\r\n bands: 3,\r\n clasps: 1,\r\n seed: 79,\r\n}\r\n\r\nexport type LeatherBookParts = 'cover' | 'pages' | 'clasps'\r\n\r\nexport function createModel(overrides: Partial<LeatherBookConfig> = {}) {\r\n return createKitModel<LeatherBookConfig, 'leather' | 'cloth' | 'brass', LeatherBookParts>({\r\n id: 'leather-book',\r\n defaults: leatherBookDefaults,\r\n slots: ['leather', 'cloth', 'brass'],\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const half = config.thickness / 2\r\n // 0.16, not 0.11. At a ninth of the book each way the two boards came\r\n // to 14 mm against 48 mm of paper, and the thing read as a ream with\r\n // leather stuck to it. A medieval binding is oak boards first, covered\r\n // in leather second, and they are substantial.\r\n const boardThickness = config.thickness * 0.16\r\n const halfWidth = config.width / 2\r\n const spineX = -halfWidth\r\n\r\n // --- Covers ------------------------------------------------------------\r\n // Not just two boards: the spine is the single piece of leather wrapping\r\n // them. So the spine is NOT coplanar with the side faces of the boards,\r\n // it overshoots them slightly — both real bookbinding and coplanar-face\r\n // avoidance.\r\n const cover: BufferGeometry[] = []\r\n for (const side of [-1, 1]) {\r\n cover.push(chamferedBoxGeometry(\r\n [config.width, config.length],\r\n [config.width * 0.995, config.length * 0.997],\r\n boardThickness,\r\n config.thickness * 0.035,\r\n [0, side * (half - boardThickness / 2), 0],\r\n tint('leather', side > 0 ? 0.03 : -0.03, 0.9),\r\n ))\r\n }\r\n\r\n // Spine: the leather wrapping the boards, curved outwards.\r\n const spine = chamferedBoxGeometry(\r\n [config.thickness * 1.06, config.length * 1.01],\r\n [config.thickness * 1.06, config.length * 1.01],\r\n config.width * 0.1,\r\n config.thickness * 0.05,\r\n [0, 0, 0],\r\n tint('leather', -0.06, 0.9),\r\n )\r\n // Built as an upright box and laid down: ORDER matters, rotate at the\r\n // origin, then translate.\r\n spine.rotateZ(Math.PI / 2)\r\n spine.translate(spineX + config.width * 0.03, 0, 0)\r\n cover.push(spine)\r\n\r\n // Spine bands: the ridges raised by the cords sitting under the binding\r\n // stitch. The most readable sign separating manuscript from printed book.\r\n const bandCount = Math.max(0, Math.round(config.bands))\r\n for (let i = 0; i < bandCount; i += 1) {\r\n const t = (i + 1) / (bandCount + 1)\r\n const band = boxGeometry(\r\n [config.width * 0.09, config.thickness * 1.12, config.length * 0.055],\r\n [spineX + config.width * 0.03, 0, (t - 0.5) * config.length * 0.86],\r\n tint('leather', 0.06, 0.9),\r\n )\r\n cover.push(band)\r\n }\r\n\r\n // --- Page block ---------------------------------------------------------\r\n // The BOARDS overhang the pages, not the other way round.\r\n //\r\n // This was written the wrong way round -- pages at 1.035 of the cover,\r\n // spilling past it on three sides -- and it is why the model read as a\r\n // ream of paper with a leather lid rather than as a book. The overhang\r\n // is called the square and it is the whole point of a board binding:\r\n // the boards take the knocks so the leaves do not, which is also what\r\n // the corner bosses are protecting. Flush at the spine, inset on the\r\n // three free edges.\r\n const square = config.width * 0.055\r\n const pages = chamferedBoxGeometry(\r\n [config.width - square, config.length - square * 1.1],\r\n [config.width - square * 1.15, config.length - square * 1.25],\r\n config.thickness - boardThickness * 2.4,\r\n config.thickness * 0.02,\r\n [-square / 2, 0, 0],\r\n tint('cloth', 0.09, 0.7),\r\n )\r\n // Vellum is not flat: the waviness at the edge is what makes the block\r\n // read as a stack of leaves.\r\n roughenGeometry(pages, config.thickness * 0.035, { salt: 31, scaleY: 0.35 })\r\n\r\n // --- Clasps ---------------------------------------------------------------\r\n const claspCount = Math.max(0, Math.round(config.clasps))\r\n const claspPieces: BufferGeometry[] = []\r\n for (let i = 0; i < claspCount; i += 1) {\r\n const t = claspCount === 1 ? 0.5 : 0.25 + (i / (claspCount - 1)) * 0.5\r\n const z = (t - 0.5) * config.length * 0.62\r\n // A strap that curls from the front face round to the back: built from\r\n // three pieces, because a single box cannot wrap the book's edge.\r\n claspPieces.push(boxGeometry(\r\n [config.width * 0.2, config.thickness * 0.035, config.length * 0.075],\r\n [halfWidth * 0.86, half - boardThickness * 0.35, z + jitter(random, 0.002)],\r\n tint('brass', 0.04, 0.5),\r\n ))\r\n claspPieces.push(boxGeometry(\r\n [config.width * 0.045, config.thickness * 0.9, config.length * 0.07],\r\n [halfWidth * 1.012, 0, z],\r\n tint('brass', -0.02, 0.5),\r\n ))\r\n claspPieces.push(boxGeometry(\r\n [config.width * 0.12, config.thickness * 0.035, config.length * 0.07],\r\n [halfWidth * 0.92, -half + boardThickness * 0.35, z],\r\n tint('brass', 0.07, 0.5),\r\n ))\r\n }\r\n\r\n // --- Corner bosses ------------------------------------------------------\r\n // Brass studs at the four corners of both boards.\r\n //\r\n // These are the single most recognisable thing about a medieval book and\r\n // the model had none. They are not ornament: a book of this weight was\r\n // stored FLAT, often on its side, and the bosses hold the leather clear\r\n // of the shelf so it does not abrade. Without them the silhouette is a\r\n // modern hardback.\r\n const bossRadius = config.width * 0.055\r\n for (const side of [-1, 1]) {\r\n for (const sx of [-1, 1]) {\r\n for (const sz of [-1, 1]) {\r\n const boss = prismGeometry(\r\n bossRadius,\r\n bossRadius * 0.55,\r\n config.thickness * 0.2,\r\n 6,\r\n [\r\n sx * halfWidth * 0.78,\r\n side * (half + config.thickness * 0.04),\r\n sz * (config.length / 2) * 0.84,\r\n ],\r\n tint('brass', side > 0 ? 0.04 : -0.02, 0.6),\r\n )\r\n // Domes point away from the book on each face.\r\n if (side < 0) flipGeometry(boss)\r\n claspPieces.push(boss)\r\n }\r\n }\r\n }\r\n\r\n return {\r\n cover: { slot: 'leather' as const, geometry: mergeColoured(cover) },\r\n pages: { slot: 'cloth' as const, geometry: mergeColoured([pages]) },\r\n clasps: claspPieces.length > 0\r\n ? { slot: 'brass' as const, geometry: mergeColoured(claspPieces) }\r\n : undefined,\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
1133
+ "hash": "60857ef9cbab7e83c0e52f89370591e4988f06a82264bf475a14a60551914fe0"
1134
+ }
1135
+ ],
1136
+ "meta": {
1137
+ "title": "Leather Book",
1138
+ "description": "Leather-bound manuscript with spine bands and clasps. The page block overhangs the cover, wavy.",
1139
+ "category": "Props",
1140
+ "tags": [
1141
+ "medieval",
1142
+ "lowpoly",
1143
+ "props",
1144
+ "tabletop",
1145
+ "procedural"
1146
+ ],
1147
+ "controls": {
1148
+ "width": {
1149
+ "type": "number",
1150
+ "label": "Width",
1151
+ "min": 0.08,
1152
+ "max": 0.4,
1153
+ "step": 0.005,
1154
+ "unit": "m"
1155
+ },
1156
+ "length": {
1157
+ "type": "number",
1158
+ "label": "Length",
1159
+ "min": 0.1,
1160
+ "max": 0.55,
1161
+ "step": 0.005,
1162
+ "unit": "m"
1163
+ },
1164
+ "thickness": {
1165
+ "type": "number",
1166
+ "label": "Thickness",
1167
+ "min": 0.02,
1168
+ "max": 0.16,
1169
+ "step": 0.002,
1170
+ "unit": "m"
1171
+ },
1172
+ "bands": {
1173
+ "type": "number",
1174
+ "label": "Spine bands",
1175
+ "min": 0,
1176
+ "max": 6,
1177
+ "step": 1
1178
+ },
1179
+ "clasps": {
1180
+ "type": "number",
1181
+ "label": "Clasps",
1182
+ "min": 0,
1183
+ "max": 3,
1184
+ "step": 1
1185
+ },
1186
+ "seed": {
1187
+ "type": "number",
1188
+ "label": "Variation seed",
1189
+ "min": 1,
1190
+ "max": 64,
1191
+ "step": 1
1192
+ }
1193
+ },
1194
+ "materialSlots": [
1195
+ "leather",
1196
+ "cloth",
1197
+ "brass"
1198
+ ],
1199
+ "parts": [
1200
+ "cover",
1201
+ "pages",
1202
+ "clasps"
1203
+ ],
1204
+ "sockets": []
1205
+ }
1206
+ },
1207
+ {
1208
+ "name": "linen-sack",
1209
+ "type": "vibe3d:model",
1210
+ "title": "Linen Sack",
1211
+ "description": "Grain sack tied shut at the mouth with cord. The fill ratio changes the whole silhouette.",
1212
+ "dependencies": [
1213
+ "three@>=0.185.0"
1214
+ ],
1215
+ "registryDependencies": [
1216
+ "@medieval-kit/core"
1217
+ ],
1218
+ "files": [
1219
+ {
1220
+ "path": "models/linen-sack/model.ts",
1221
+ "target": "{models}/medieval-kit/linen-sack/model.ts",
1222
+ "content": "/**\r\n * @medieval-kit/linen-sack\r\n *\r\n * Grain sack with its mouth tied off by a cord. Storeroom, mill, market stall,\r\n * cart — one of the kit's most widely placeable pieces.\r\n *\r\n * What makes a sack a sack is that it TAKES THE SHAPE of what is inside it. So\r\n * the body is not a cylinder: it spreads out at the bottom under the weight of\r\n * the grain, bulges in the middle, and gathers towards the mouth. Then\r\n * `roughenGeometry` breaks up the surface, because nothing about a full sack\r\n * is flat.\r\n *\r\n * The bottom corners are a separate matter: a real sack pulls in at its four\r\n * corners and those corners stick out like ears. Without them the model looked\r\n * like a vase.\r\n */\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n bandGeometry,\r\n boxGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n roughenGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface LinenSackConfig {\r\n /** Total height (metres). */\r\n readonly height: number\r\n /** Radius at the widest point (metres). */\r\n readonly radius: number\r\n /** How full it is. 1 = packed solid, 0.4 = half empty and slumped. */\r\n readonly fill: number\r\n /** Cloth left above the mouth, as a fraction of the height. */\r\n readonly collar: number\r\n /** Gathered ears at the bottom. */\r\n readonly ears: number\r\n readonly seed: number\r\n}\r\n\r\nexport const linenSackDefaults: LinenSackConfig = {\r\n height: 0.52,\r\n radius: 0.16,\r\n fill: 0.85,\r\n // A hand's width of gathered cloth above the cord, not a fifth of the sack.\r\n collar: 0.1,\r\n ears: 4,\r\n seed: 53,\r\n}\r\n\r\nexport type LinenSackParts = 'body' | 'collar' | 'cord'\r\n\r\nexport function createModel(overrides: Partial<LinenSackConfig> = {}) {\r\n return createKitModel<LinenSackConfig, 'cloth', LinenSackParts>({\r\n id: 'linen-sack',\r\n defaults: linenSackDefaults,\r\n slots: ['cloth'],\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const half = config.height / 2\r\n const fill = Math.max(0.15, Math.min(1, config.fill))\r\n // Where the neck sits: high on a full sack, low on an empty one.\r\n const neckY = half - config.height * config.collar\r\n const bodyTop = neckY - config.height * 0.06\r\n\r\n // --- Body -----------------------------------------------------------\r\n // The profile depends on the fill: an under-filled sack both drops and\r\n // spreads sideways. Letting a single `fill` number change the whole\r\n // silhouette means one model can produce both a full and a half-empty\r\n // sack.\r\n const wide = config.radius * (0.72 + fill * 0.36)\r\n // A sack, not an amphora.\r\n //\r\n // The old profile put its widest point a quarter of the way up and then\r\n // narrowed all the way to the neck, which is the silhouette of a vase.\r\n // A filled sack does the opposite: it SPREADS where it meets the ground,\r\n // because the grain settles and the cloth has no stiffness, then runs\r\n // close to parallel up most of its height, and only gathers in sharply\r\n // where the cord is tied. Nearly the whole difference between the two\r\n // shapes is in the bottom level and in where the taper begins.\r\n const profile: Level[] = [\r\n { y: -half, radius: wide * 0.93 },\r\n { y: -half + config.height * 0.07, radius: wide },\r\n { y: -half + config.height * 0.44 * fill, radius: wide },\r\n { y: -half + config.height * 0.72 * fill, radius: wide * 0.9 },\r\n { y: bodyTop - config.height * 0.09, radius: wide * 0.52 },\r\n { y: bodyTop, radius: config.radius * 0.25 },\r\n ]\r\n const body = latheGeometry(profile, 11, [0, 0, 0], tint('cloth', -0.06, 1.3), {\r\n colourTop: tint('cloth', 0.05, 1.3),\r\n })\r\n // Cloth is not rigid: here the surface break-up is the texture itself.\r\n roughenGeometry(body, config.radius * 0.05, { salt: 21, scaleY: 0.7 })\r\n\r\n const pieces: BufferGeometry[] = [body]\r\n\r\n // --- Bottom ears ------------------------------------------------------\r\n // A sack gathers at the seam and its corners jut outwards. Without them\r\n // the cylinder comes out as a vase.\r\n const ears = Math.max(0, Math.round(config.ears))\r\n for (let i = 0; i < ears; i += 1) {\r\n const angle = (i / ears) * Math.PI * 2 + jitter(random, 0.12)\r\n const reach = wide * (0.3 + random() * 0.16)\r\n const ear = boxGeometry(\r\n [reach, config.height * 0.05, config.radius * 0.2],\r\n [reach * 0.36, 0, 0], // keep the root INSIDE the body\r\n tint('cloth', -0.1, 1.2),\r\n )\r\n // Orient first, then translate — the reverse order flings the ear off\r\n // into orbit.\r\n ear.rotateZ(-0.22 + jitter(random, 0.1))\r\n ear.rotateY(-angle)\r\n ear.translate(\r\n Math.sin(angle) * wide * 0.6,\r\n -half + config.height * 0.035,\r\n Math.cos(angle) * wide * 0.6,\r\n )\r\n pieces.push(ear)\r\n }\r\n\r\n // --- Mouth allowance ---------------------------------------------------\r\n // The cloth left ABOVE the tie, flopping outwards. This is what separates\r\n // a sack from a sealed bag: a tied mouth always has some cloth to spare.\r\n const collarPieces: BufferGeometry[] = []\r\n const flare: Level[] = [\r\n // Starts BELOW the body's top, not above it. It used to begin at\r\n // neckY − 0.02·h while the body ended at neckY − 0.06·h, so the collar\r\n // and the cord were a separate island hanging over the sack.\r\n { y: bodyTop - config.height * 0.03, radius: config.radius * 0.36 },\r\n { y: neckY - config.height * 0.02, radius: config.radius * 0.3 },\r\n { y: neckY + config.height * 0.03, radius: config.radius * 0.27 },\r\n // The tuft above the tie opens only a little. Flaring to 0.46 of the\r\n // radius turned the top of a sack into the mouth of a bottle, which is\r\n // the last thing left reading as a vessel once the body was fixed.\r\n { y: half - config.height * 0.02, radius: config.radius * 0.33 },\r\n { y: half, radius: config.radius * 0.27 },\r\n ]\r\n const collar = latheGeometry(flare, 9, [0, 0, 0], tint('cloth', 0.02, 1.3), {\r\n colourTop: tint('cloth', 0.1, 1.3),\r\n capTop: true,\r\n })\r\n roughenGeometry(collar, config.radius * 0.035, { salt: 22, scaleY: 0.6 })\r\n collarPieces.push(collar)\r\n\r\n // --- Cord ---------------------------------------------------------------\r\n const cord = bandGeometry(config.radius * 0.29, neckY, config.height * 0.035,\r\n config.radius * 0.045, 9, tint('cloth', -0.24, 0.8), { inner: true })\r\n\r\n return {\r\n body: { slot: 'cloth' as const, geometry: mergeColoured(pieces) },\r\n collar: { slot: 'cloth' as const, geometry: mergeColoured(collarPieces) },\r\n cord: { slot: 'cloth' as const, geometry: mergeColoured([cord]) },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
1223
+ "hash": "b92a9b075c2b702598a6c8cb5eff76941356cd5f38c7ffd01de621aea10c368a"
1224
+ }
1225
+ ],
1226
+ "meta": {
1227
+ "title": "Linen Sack",
1228
+ "description": "Grain sack tied shut at the mouth with cord. The fill ratio changes the whole silhouette.",
1229
+ "category": "Props",
1230
+ "tags": [
1231
+ "medieval",
1232
+ "lowpoly",
1233
+ "props",
1234
+ "farm",
1235
+ "procedural"
1236
+ ],
1237
+ "controls": {
1238
+ "height": {
1239
+ "type": "number",
1240
+ "label": "Height",
1241
+ "min": 0.2,
1242
+ "max": 1,
1243
+ "step": 0.01,
1244
+ "unit": "m"
1245
+ },
1246
+ "radius": {
1247
+ "type": "number",
1248
+ "label": "Radius",
1249
+ "min": 0.07,
1250
+ "max": 0.35,
1251
+ "step": 0.005,
1252
+ "unit": "m"
1253
+ },
1254
+ "fill": {
1255
+ "type": "number",
1256
+ "label": "Fill",
1257
+ "min": 0.15,
1258
+ "max": 1,
1259
+ "step": 0.02
1260
+ },
1261
+ "collar": {
1262
+ "type": "number",
1263
+ "label": "Collar allowance",
1264
+ "min": 0.05,
1265
+ "max": 0.3,
1266
+ "step": 0.01
1267
+ },
1268
+ "ears": {
1269
+ "type": "number",
1270
+ "label": "Bottom ears",
1271
+ "min": 0,
1272
+ "max": 6,
1273
+ "step": 1
1274
+ },
1275
+ "seed": {
1276
+ "type": "number",
1277
+ "label": "Variation seed",
1278
+ "min": 1,
1279
+ "max": 64,
1280
+ "step": 1
1281
+ }
1282
+ },
1283
+ "materialSlots": [
1284
+ "cloth"
1285
+ ],
1286
+ "parts": [
1287
+ "body",
1288
+ "collar",
1289
+ "cord"
1290
+ ],
1291
+ "sockets": []
1292
+ }
1293
+ },
1294
+ {
1295
+ "name": "log-pile",
1296
+ "type": "vibe3d:model",
1297
+ "title": "Log Pile",
1298
+ "description": "Pile of cut firewood. The end grain is far lighter than the bark; it is what you see first.",
1299
+ "dependencies": [
1300
+ "three@>=0.185.0"
1301
+ ],
1302
+ "registryDependencies": [
1303
+ "@medieval-kit/core"
1304
+ ],
1305
+ "files": [
1306
+ {
1307
+ "path": "models/log-pile/model.ts",
1308
+ "target": "{models}/medieval-kit/log-pile/model.ts",
1309
+ "content": "/**\r\n * @medieval-kit/log-pile\r\n *\r\n * A pile of cut firewood. One of the cheapest models in the kit and one of the\r\n * highest in scene value: put it at the foot of a wall and that spot instantly\r\n * becomes \"somewhere people live\".\r\n *\r\n * Three things make a pile a pile:\r\n * - The log end (the grain cross-section) is far lighter than the bark. Those\r\n * pale circles are the first thing you see when you look at the pile.\r\n * - No log matches its neighbour in diameter, length, angle or roll.\r\n * - And most importantly: every log RESTS ON THE ONES BELOW IT.\r\n *\r\n * That last one was wrong in two separate attempts. First I used a fixed row\r\n * height, then I computed it \"from the thickest log\" — in both cases the thick\r\n * logs sank into the ones underneath and the thin ones hung in mid-air. The\r\n * right answer is to solve the real contact height for each log: the point\r\n * where two circles are tangent. `restingHeight` below does exactly that, and\r\n * the pile now settles by itself — whatever radii come along.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n MEDIEVAL_PALETTE,\r\n createKitModel,\r\n headGeometry,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface LogPileConfig {\r\n /** How many rows. */\r\n readonly rows: number\r\n /** Number of logs in the bottom row. */\r\n readonly perRow: number\r\n /** Log length (metres). */\r\n readonly logLength: number\r\n /** Average log radius (metres). */\r\n readonly logRadius: number\r\n /** Thickness variety. 0 = all the same diameter. */\r\n readonly variation: number\r\n /** Pyramid-shaped (1) or a straight stack (0). */\r\n readonly taperRows: number\r\n readonly seed: number\r\n}\r\n\r\nexport const logPileDefaults: LogPileConfig = {\r\n // Denser than it was. Twelve logs read as \"some firewood\"; a stack is the\r\n // thing that makes a wall look lived-against, and that needs enough logs\r\n // for the ends to form a pattern rather than a row.\r\n rows: 4,\r\n perRow: 7,\r\n logLength: 0.62,\r\n logRadius: 0.065,\r\n variation: 0.22,\r\n taperRows: 1,\r\n seed: 41,\r\n}\r\n\r\nexport type LogPileParts = 'bark' | 'ends'\r\n\r\ninterface Placed {\r\n readonly x: number\r\n readonly y: number\r\n readonly r: number\r\n}\r\n\r\n/**\r\n * The height at which a log of radius `r` at position `x` will come to rest.\r\n *\r\n * Computes the height at which it is tangent to each log below it and takes the\r\n * highest; if it touches none of them it rests on the ground. Two circles being\r\n * tangent means the distance between their centres equals the sum of the radii,\r\n * so the vertical distance is √((r₁+r₂)² − Δx²).\r\n */\r\nfunction restingHeight(x: number, r: number, below: readonly Placed[], ground: number): number {\r\n let y = ground + r\r\n for (const other of below) {\r\n const dx = Math.abs(x - other.x)\r\n const reach = r + other.r\r\n if (dx >= reach) continue\r\n y = Math.max(y, other.y + Math.sqrt(reach * reach - dx * dx))\r\n }\r\n return y\r\n}\r\n\r\nexport function createModel(overrides: Partial<LogPileConfig> = {}) {\r\n return createKitModel<LogPileConfig, 'oak', LogPileParts>({\r\n id: 'log-pile',\r\n defaults: logPileDefaults,\r\n slots: ['oak'],\r\n build: ({ config, random }) => {\r\n const tint = new Color()\r\n const barkTint = (): Color => {\r\n tint.copy(MEDIEVAL_PALETTE.oak)\r\n tint.offsetHSL(jitter(random, 0.016), jitter(random, 0.07), -0.06 + jitter(random, 0.07))\r\n return tint\r\n }\r\n const endTint = (): Color => {\r\n tint.copy(MEDIEVAL_PALETTE.oakEnd)\r\n // +0.07 did not deliver what the file header promises. A sawn end is\r\n // dramatically paler than bark -- in a reference photograph the pale\r\n // discs are the first thing the eye lands on -- and at this offset the\r\n // ends were merely a slightly different brown.\r\n tint.offsetHSL(jitter(random, 0.012), -0.06 + jitter(random, 0.04), 0.19 + jitter(random, 0.05))\r\n return tint\r\n }\r\n\r\n const bark: BufferGeometry[] = []\r\n const ends: BufferGeometry[] = []\r\n const rows = Math.max(1, config.rows)\r\n const spread = Math.max(0, Math.min(0.6, config.variation))\r\n let below: Placed[] = []\r\n\r\n for (let row = 0; row < rows; row += 1) {\r\n const inRow = Math.max(1, config.perRow - (config.taperRows >= 0.5 ? row : 0))\r\n const radii = Array.from({ length: inRow },\r\n () => config.logRadius * (1 - spread + random() * spread * 2))\r\n\r\n // Horizontally, neighbouring logs are tangent: the gap is the sum of\r\n // the two radii. A fixed gap drove the thick ones into each other.\r\n const gaps = radii.slice(0, -1).map((r, i) => r + radii[i + 1]!)\r\n const rowWidth = gaps.reduce((sum, w) => sum + w, 0)\r\n // Upper rows are offset so they drop into the GROOVE of the row below;\r\n // the offset direction alternates, otherwise the pile leans one way.\r\n const shift = (row % 2 === 1 ? 1 : -1) * config.logRadius * 0.5\r\n + jitter(random, config.logRadius * 0.1)\r\n\r\n const placed: Placed[] = []\r\n let cursor = -rowWidth / 2 + shift\r\n for (let i = 0; i < inRow; i += 1) {\r\n const radius = radii[i]!\r\n const x = cursor\r\n if (i < gaps.length) cursor += gaps[i]!\r\n placed.push({ x, y: restingHeight(x, radius, below, 0), r: radius })\r\n }\r\n\r\n for (const log of placed) {\r\n const length = config.logLength * (0.86 + random() * 0.28)\r\n\r\n // Body: the ends are left uncapped, the grain cross-section is a\r\n // separate pair of discs. That lets bark and end take very different\r\n // colours.\r\n //\r\n // CRITICAL: it must NEVER EXCEED `log.r` anywhere. Previously the\r\n // ends were `log.r * (1 ± 0.05)`, i.e. they fattened by up to 5%;\r\n // since the layout is computed from `log.r`, neighbours intersected\r\n // by up to 10% at their ends. A log now only ever TAPERS IN — which\r\n // closes the bug and matches a real log, which narrows towards the\r\n // end anyway.\r\n const taperA = 1 - random() * 0.1\r\n const taperB = 1 - random() * 0.1\r\n const profile: Level[] = [\r\n { y: -length / 2, radius: log.r * taperA },\r\n { y: 0, radius: log.r },\r\n { y: length / 2, radius: log.r * taperB },\r\n ]\r\n const body = latheGeometry(profile, 7, [0, 0, 0], barkTint(), {\r\n capTop: false,\r\n capBottom: false,\r\n })\r\n const grain = endTint()\r\n // The sawn end stands a shade PROUD of the bark, and it has to.\r\n //\r\n // Cut to exactly the body's end radius, the disc and the lathe's open\r\n // rim meet along a seam of no width at all: they touch and never\r\n // overlap. Raising the support check's resolution showed that up at\r\n // once -- the ends came away from the logs they belong to, because\r\n // there was never anything joining them, only two edges in the same\r\n // place. Three percent of the radius is two millimetres here, which\r\n // nobody will see, and it is true of a real log anyway: bark wears\r\n // back from a cut face.\r\n // Set a little INSIDE the end as well as wider than it, so the rim\r\n // passes through the wall rather than sitting level with its edge.\r\n // Widening alone left one cap in five still meeting the body edge to\r\n // edge, which is a joint only in the sense that two things are in the\r\n // same place.\r\n const seat = length * 0.02\r\n const capA = headGeometry(profile.at(-1)!.radius * 1.03, length / 2 - seat, 7, 'up', grain, 3, 0.07)\r\n const capB = headGeometry(profile[0]!.radius * 1.03, -length / 2 + seat, 7, 'down', grain, 3, 0.07)\r\n\r\n // Each log is rolled randomly about its own axis: with every facet at\r\n // the same angle the pile looks mechanical, and on top of that\r\n // adjacent logs kept parallel faces that could z-fight.\r\n const roll = random() * Math.PI * 2\r\n const tilt = jitter(random, 0.04)\r\n for (const [target, geometry] of [[bark, body], [ends, capA], [ends, capB]] as const) {\r\n geometry.rotateY(roll)\r\n // About X, NOT about Z.\r\n //\r\n // `latheGeometry` builds along Y. Rotating +90 degrees about Z\r\n // sends +Y to -X, so every log lay along the X axis -- which is\r\n // the very axis the layout spaces them along. Each log was 0.62 m\r\n // long and its neighbour's centre was 0.13 m away, so every log\r\n // ran straight through about five of its neighbours. The tangent\r\n // solve above was correct the whole time; it was solving for an\r\n // orientation the geometry did not have. Rotating about X sends\r\n // +Y to +Z, which is the axis `restingHeight` assumes.\r\n geometry.rotateX(Math.PI / 2)\r\n geometry.rotateY(tilt)\r\n geometry.translate(log.x, log.y, jitter(random, config.logLength * 0.015))\r\n target.push(geometry)\r\n }\r\n }\r\n\r\n below = placed\r\n }\r\n\r\n return {\r\n bark: { slot: 'oak', geometry: mergeColoured(bark) },\r\n ends: { slot: 'oak', geometry: mergeColoured(ends) },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
1310
+ "hash": "d0a68191cd5e464c7f8d699277a1d9c132947ae5fcf4d4e54636baf1a5665b5d"
1311
+ }
1312
+ ],
1313
+ "meta": {
1314
+ "title": "Log Pile",
1315
+ "description": "Pile of cut firewood. The end grain is far lighter than the bark; it is what you see first.",
1316
+ "category": "Props",
1317
+ "tags": [
1318
+ "medieval",
1319
+ "lowpoly",
1320
+ "props",
1321
+ "procedural"
1322
+ ],
1323
+ "controls": {
1324
+ "rows": {
1325
+ "type": "number",
1326
+ "label": "Rows",
1327
+ "min": 1,
1328
+ "max": 6,
1329
+ "step": 1
1330
+ },
1331
+ "perRow": {
1332
+ "type": "number",
1333
+ "label": "Logs per row",
1334
+ "min": 1,
1335
+ "max": 9,
1336
+ "step": 1
1337
+ },
1338
+ "logLength": {
1339
+ "type": "number",
1340
+ "label": "Log length",
1341
+ "min": 0.3,
1342
+ "max": 1.4,
1343
+ "step": 0.02,
1344
+ "unit": "m"
1345
+ },
1346
+ "logRadius": {
1347
+ "type": "number",
1348
+ "label": "Log radius",
1349
+ "min": 0.03,
1350
+ "max": 0.14,
1351
+ "step": 0.002,
1352
+ "unit": "m"
1353
+ },
1354
+ "variation": {
1355
+ "type": "number",
1356
+ "label": "Thickness variation",
1357
+ "min": 0,
1358
+ "max": 0.5,
1359
+ "step": 0.02
1360
+ },
1361
+ "taperRows": {
1362
+ "type": "number",
1363
+ "label": "Pyramid stack",
1364
+ "min": 0,
1365
+ "max": 1,
1366
+ "step": 1
1367
+ },
1368
+ "seed": {
1369
+ "type": "number",
1370
+ "label": "Variation seed",
1371
+ "min": 1,
1372
+ "max": 64,
1373
+ "step": 1
1374
+ }
1375
+ },
1376
+ "materialSlots": [
1377
+ "oak"
1378
+ ],
1379
+ "parts": [
1380
+ "bark",
1381
+ "ends"
1382
+ ],
1383
+ "sockets": []
1384
+ }
1385
+ },
1386
+ {
1387
+ "name": "market-stall",
1388
+ "type": "vibe3d:model",
1389
+ "title": "Market Stall",
1390
+ "description": "Plank counter on a braced trestle under a linen awning slung between four posts, sagging the way cloth does.",
1391
+ "dependencies": [
1392
+ "three@>=0.185.0"
1393
+ ],
1394
+ "registryDependencies": [
1395
+ "@medieval-kit/core"
1396
+ ],
1397
+ "files": [
1398
+ {
1399
+ "path": "models/market-stall/model.ts",
1400
+ "target": "{models}/medieval-kit/market-stall/model.ts",
1401
+ "content": "/**\r\n * @medieval-kit/market-stall\r\n *\r\n * A plank table on a braced trestle, four posts, and a linen awning over it.\r\n *\r\n * This is the piece the kit was assembled around without having: it is where\r\n * the basket, the vegetables, the sack, the crate and the coin pouch all\r\n * belong, and a market with none of it is a row of props on the floor. It is\r\n * also the only thing here with a roof.\r\n *\r\n * The awning SAGS, and that is the whole model. A flat sheet on four posts is\r\n * a table with a lid; cloth slung between four points hangs, and the dip is\r\n * what says it is cloth before the colour does. It costs nothing here:\r\n * `dishedSheetGeometry` bends its cross section by `curve`, and one turn about\r\n * X maps that bend onto the world's vertical — so the sag is the helper's own\r\n * parameter rather than anything hand-built.\r\n *\r\n * The front edge dips further than the back, which is also the reference and\r\n * is the reason the sag is per-level rather than one number: cloth pulled over\r\n * a ridge and left loose at the eaves does not hang symmetrically.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n boxGeometry,\r\n createKitModel,\r\n createTinter,\r\n dishedSheetGeometry,\r\n jitter,\r\n mergeColoured,\r\n taperedBoxGeometry,\r\n type SheetLevel,\r\n} from '../core/index.ts'\r\n\r\nexport interface MarketStallConfig {\r\n /** Along the counter (metres). */\r\n readonly length: number\r\n /** Front to back (metres). */\r\n readonly depth: number\r\n /** Height of the counter top (metres). */\r\n readonly height: number\r\n /** Height of the awning above the ground (metres). */\r\n readonly awning: number\r\n /** How far the cloth hangs between the posts, as a fraction of the depth. */\r\n readonly sag: number\r\n /** Boards in the counter top. */\r\n readonly planks: number\r\n readonly seed: number\r\n}\r\n\r\nexport const marketStallDefaults: MarketStallConfig = {\r\n length: 1.62,\r\n depth: 0.76,\r\n height: 0.78,\r\n awning: 1.94,\r\n // 0.10 of the depth, not 0.17. At 0.17 the cloth dropped a tenth of its own\r\n // span between the posts and read as a hammock; the reference hangs about\r\n // half that. Cloth stretched over a stall is pulled tight and then sags a\r\n // little, which is a different shape from cloth thrown over it.\r\n sag: 0.1,\r\n planks: 6,\r\n seed: 29,\r\n}\r\n\r\nexport type MarketStallParts = 'top' | 'trestle' | 'posts' | 'awning'\r\n\r\nexport function createModel(overrides: Partial<MarketStallConfig> = {}) {\r\n return createKitModel<MarketStallConfig, 'oak' | 'cloth', MarketStallParts>({\r\n id: 'market-stall',\r\n defaults: marketStallDefaults,\r\n slots: ['oak', 'cloth'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const L = Math.max(0.5, config.length)\r\n const D = Math.max(0.3, config.depth)\r\n const H = Math.max(0.3, config.height)\r\n const roofY = Math.max(H + 0.25, config.awning)\r\n const planks = Math.max(2, Math.round(config.planks))\r\n const timberSize = D * 0.075\r\n\r\n /** Weathered, like the grindstone's frame: a stall lives outdoors. */\r\n const timber = (lift = 0): Color => {\r\n const c = tint('oak', -0.08 + lift, 0.85)\r\n c.offsetHSL(0, -0.09, 0)\r\n return c\r\n }\r\n\r\n // --- Counter top ----------------------------------------------------\r\n // Boards with a hair of daylight between them. The gap is what makes it\r\n // a counter rather than a slab, and it is why the planks are separate\r\n // boxes rather than one.\r\n const boardT = timberSize * 0.62\r\n const topY = H - boardT / 2\r\n const pitch = D / planks\r\n // The counter stops SHORT of the posts, which stand at the two ends.\r\n // Run to the full length the boards had posts driven through them a\r\n // hand's breadth from each end, which is not how a stall is built and\r\n // not what the reference shows: the top lies between the posts.\r\n const postSize = timberSize * 0.72\r\n const topL = L - postSize * 2.6\r\n const top: BufferGeometry[] = []\r\n for (let i = 0; i < planks; i += 1) {\r\n top.push(boxGeometry(\r\n [topL, boardT, pitch * 0.93],\r\n [0, topY + jitter(random, boardT * 0.06), -D / 2 + pitch * (i + 0.5)],\r\n timber(jitter(random, 0.05)),\r\n ))\r\n }\r\n\r\n // --- Trestle --------------------------------------------------------\r\n const trestle: BufferGeometry[] = []\r\n const legX = L / 2 - timberSize * 1.6\r\n const legZ = D / 2 - timberSize * 1.3\r\n const railY = H - boardT - timberSize * 0.5\r\n // Two rails under the top, running the length, which is what the legs\r\n // and the posts both fasten to.\r\n for (const sz of [-1, 1] as const) {\r\n // Full length, so they reach the posts at the ends.\r\n trestle.push(boxGeometry(\r\n [L, timberSize, timberSize * 0.85],\r\n [0, railY, sz * legZ],\r\n timber(-0.02),\r\n ))\r\n }\r\n // Legs, splayed a little on the way down so the thing is not a card\r\n // table. Splayed by WIDENING rather than by rotating, which is what lets\r\n // the foot sit flat.\r\n const splay = timberSize * 1.5\r\n for (const sx of [-1, 1] as const) {\r\n for (const sz of [-1, 1] as const) {\r\n trestle.push(taperedBoxGeometry(\r\n [timberSize * 1.15, timberSize * 1.15],\r\n [timberSize, timberSize],\r\n // Up INTO the rail and stopping there. Run to the rail's own top\r\n // face the leg ends level with it — two upward faces in the plane\r\n // y = 0.745, which the checker found at all four corners before\r\n // anything was rendered. A leg is housed in a rail, not flush\r\n // with it.\r\n railY + timberSize * 0.18,\r\n [\r\n sx * (legX + splay * 0.5),\r\n (railY + timberSize * 0.18) / 2,\r\n sz * (legZ + splay * 0.35),\r\n ],\r\n timber(-0.05 + jitter(random, 0.04)),\r\n ))\r\n }\r\n }\r\n // The braces from the leg up to the rail. They are the reference's most\r\n // recognisable joint and they are also what stops the top racking.\r\n for (const sx of [-1, 1] as const) {\r\n for (const sz of [-1, 1] as const) {\r\n const foot: readonly [number, number, number] = [\r\n sx * (legX + splay * 0.42), railY * 0.42, sz * (legZ + splay * 0.2),\r\n ]\r\n const head: readonly [number, number, number] = [\r\n sx * (legX - L * 0.16), railY, sz * legZ,\r\n ]\r\n const dx = head[0] - foot[0]\r\n const dy = head[1] - foot[1]\r\n const dz = head[2] - foot[2]\r\n const len = Math.hypot(dx, dy, dz)\r\n const brace = taperedBoxGeometry(\r\n [timberSize * 0.7, timberSize * 0.7],\r\n [timberSize * 0.62, timberSize * 0.62],\r\n len,\r\n [0, len / 2, 0],\r\n timber(-0.03),\r\n )\r\n // Built at the origin and turned, the construction that has never\r\n // gone wrong here. The bearing comes off the two ends, not out of a\r\n // guess: rotateZ then rotateY sends +Y to\r\n // (-sin t cos a, cos t, sin t sin a), so a = atan2(dz, -dx).\r\n brace.rotateZ(Math.acos(Math.max(-1, Math.min(1, dy / len))))\r\n brace.rotateY(Math.atan2(dz, -dx))\r\n trestle.push(brace.translate(foot[0], foot[1], foot[2]))\r\n }\r\n }\r\n // The low stretcher and its cross piece.\r\n const tieY = railY * 0.26\r\n trestle.push(boxGeometry(\r\n [(legX + splay * 0.46) * 2, timberSize * 0.8, timberSize * 0.7],\r\n [0, tieY, 0],\r\n timber(-0.07),\r\n ))\r\n for (const sx of [-1, 1] as const) {\r\n trestle.push(boxGeometry(\r\n [timberSize * 0.7, timberSize * 0.7, (legZ + splay * 0.3) * 2],\r\n [sx * (legX + splay * 0.46), tieY, 0],\r\n timber(-0.06),\r\n ))\r\n }\r\n\r\n // --- Posts ----------------------------------------------------------\r\n // They run from the RAIL, not from the top: bolted to the frame is how\r\n // the reference carries them, and it is also what puts them beyond doubt\r\n // for the support check — a post standing on a plank top would be held up\r\n // by six millimetres of board.\r\n const posts: BufferGeometry[] = []\r\n const postX = L / 2 - postSize * 0.5\r\n\r\n // The awning's shape, worked out here because the POSTS need it.\r\n const overhang = timberSize * 1.8\r\n const sag = Math.max(0, config.sag) * D\r\n const halfLength = L / 2 + overhang\r\n const halfDepth = D / 2 + overhang\r\n const tilt = 0.055\r\n\r\n /**\r\n * How high the cloth hangs over a point on the ground.\r\n *\r\n * Every post has to reach it, and each of the four reaches a different\r\n * height: the sheet is a parabola across its length and it is deeper at\r\n * the front than the back, so a single post height leaves some of them\r\n * short. Built with one, the cloth came away as its own floating piece\r\n * 67 mm above the post heads — held up in the drawing and by nothing in\r\n * the geometry.\r\n *\r\n * This is the sheet's own arithmetic rather than a second version of it:\r\n * the same parabola, the same per-level sag, the same quarter turn that\r\n * maps the helper's `curve` onto the world's vertical, and the same\r\n * small tilt after it.\r\n */\r\n const clothY = (x: number, z: number): number => {\r\n const f = (-z + halfDepth) / (2 * halfDepth)\r\n const u = x / halfLength\r\n const curve = sag * (0.72 + f * 0.55)\r\n return roofY + Math.cos(tilt) * curve * u * u + Math.sin(tilt) * z\r\n }\r\n\r\n for (const sx of [-1, 1] as const) {\r\n for (const sz of [-1, 1] as const) {\r\n // ON the cloth's mid surface, so half its thickness covers the head.\r\n //\r\n // A shade PAST it was the first version, and four dark post heads\r\n // came through the awning like tacks — the same fault as the bench's\r\n // tenons and made for the same reason: pushed proud to keep two\r\n // faces out of one plane. Buried does that too, and cannot be seen\r\n // doing it.\r\n const head = clothY(sx * postX, sz * legZ)\r\n // Down PAST the rail and finishing as a stub below it, which is both\r\n // the reference's detail and the fix for a real fault: stopped at the\r\n // rail's underside, the post's foot lay in the same plane as it,\r\n // facing the same way — `plane 0,-1,0 | -0.688` at all four corners.\r\n const foot = railY - timberSize * 1.8\r\n posts.push(taperedBoxGeometry(\r\n [postSize, postSize],\r\n [postSize * 0.88, postSize * 0.88],\r\n head - foot,\r\n [sx * postX, (foot + head) / 2, sz * legZ],\r\n timber(0.02 + jitter(random, 0.03)),\r\n ))\r\n }\r\n }\r\n\r\n // --- Awning ---------------------------------------------------------\r\n /**\r\n * Cloth slung between the four post heads.\r\n *\r\n * `dishedSheetGeometry` builds in the XY plane and bends its cross\r\n * section along Z by `curve`. Turning it a quarter about X maps that\r\n * bend onto the world's vertical, so the parabola the helper already\r\n * draws IS the hang of the cloth: levels run front to back, the half\r\n * width is half the stall's length, and the sag is a parameter rather\r\n * than something modelled.\r\n *\r\n * `curve` lifts the EDGES relative to the middle, which is the right way\r\n * round — the cloth is held at the posts and falls between them.\r\n */\r\n const levels: SheetLevel[] = []\r\n const steps = 4\r\n for (let i = 0; i < steps; i += 1) {\r\n const f = i / (steps - 1)\r\n levels.push({\r\n y: halfDepth * (f * 2 - 1),\r\n halfWidth: halfLength,\r\n // Thick enough to hide a post head in. At 0.2 of a board there was\r\n // barely three millimetres of cloth either side of the mid surface,\r\n // which is not enough to bury anything in with confidence.\r\n thickness: boardT * 0.42,\r\n // Deeper at the front than the back. Cloth pulled over a ridge and\r\n // left loose at the eaves does not hang symmetrically.\r\n curve: sag * (0.72 + f * 0.55),\r\n })\r\n }\r\n const awning = dishedSheetGeometry(levels, 6, tint('cloth', 0.04, 0.9))\r\n awning.rotateX(-Math.PI / 2)\r\n // A shade of fall front to back, on top of the sag. Same axis as the\r\n // turn above, so it is one angle rather than a second rotation.\r\n awning.rotateX(-tilt)\r\n awning.translate(0, roofY, 0)\r\n\r\n return {\r\n top: { slot: 'oak' as const, geometry: mergeColoured(top) },\r\n trestle: { slot: 'oak' as const, geometry: mergeColoured(trestle) },\r\n posts: { slot: 'oak' as const, geometry: mergeColoured(posts) },\r\n awning: { slot: 'cloth' as const, geometry: awning },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
1402
+ "hash": "1068f90351fae381f337fb2fc9e424ac1b96cb8d96c659e32219fe0e383a5adb"
1403
+ }
1404
+ ],
1405
+ "meta": {
1406
+ "title": "Market Stall",
1407
+ "description": "Plank counter on a braced trestle under a linen awning slung between four posts, sagging the way cloth does.",
1408
+ "category": "Structure",
1409
+ "tags": [
1410
+ "medieval",
1411
+ "lowpoly",
1412
+ "structure",
1413
+ "procedural"
1414
+ ],
1415
+ "controls": {
1416
+ "length": {
1417
+ "type": "number",
1418
+ "label": "Length",
1419
+ "min": 1,
1420
+ "max": 2.6,
1421
+ "step": 0.05,
1422
+ "unit": "m"
1423
+ },
1424
+ "depth": {
1425
+ "type": "number",
1426
+ "label": "Depth",
1427
+ "min": 0.45,
1428
+ "max": 1.1,
1429
+ "step": 0.02,
1430
+ "unit": "m"
1431
+ },
1432
+ "height": {
1433
+ "type": "number",
1434
+ "label": "Counter height",
1435
+ "min": 0.55,
1436
+ "max": 1,
1437
+ "step": 0.02,
1438
+ "unit": "m"
1439
+ },
1440
+ "awning": {
1441
+ "type": "number",
1442
+ "label": "Awning height",
1443
+ "min": 1.4,
1444
+ "max": 2.4,
1445
+ "step": 0.05,
1446
+ "unit": "m"
1447
+ },
1448
+ "sag": {
1449
+ "type": "number",
1450
+ "label": "Cloth sag",
1451
+ "min": 0,
1452
+ "max": 0.4,
1453
+ "step": 0.02
1454
+ },
1455
+ "planks": {
1456
+ "type": "number",
1457
+ "label": "Counter boards",
1458
+ "min": 2,
1459
+ "max": 10,
1460
+ "step": 1
1461
+ },
1462
+ "seed": {
1463
+ "type": "number",
1464
+ "label": "Variation seed",
1465
+ "min": 1,
1466
+ "max": 64,
1467
+ "step": 1
1468
+ }
1469
+ },
1470
+ "materialSlots": [
1471
+ "oak",
1472
+ "cloth"
1473
+ ],
1474
+ "parts": [
1475
+ "top",
1476
+ "trestle",
1477
+ "posts",
1478
+ "awning"
1479
+ ],
1480
+ "sockets": []
1481
+ }
1482
+ },
1483
+ {
1484
+ "name": "oak-tankard",
1485
+ "type": "vibe3d:model",
1486
+ "title": "Oak Tankard",
1487
+ "description": "Oak tankard: the barrel at palm scale, in the same stave-and-hoop language.",
1488
+ "dependencies": [
1489
+ "three@>=0.185.0"
1490
+ ],
1491
+ "registryDependencies": [
1492
+ "@medieval-kit/core"
1493
+ ],
1494
+ "files": [
1495
+ {
1496
+ "path": "models/oak-tankard/model.ts",
1497
+ "target": "{models}/medieval-kit/oak-tankard/model.ts",
1498
+ "content": "/**\r\n * @medieval-kit/oak-tankard\r\n *\r\n * Oak tankard: the barrel at palm size. Same stave language, same iron hoop,\r\n * only the scale changes — the example that shows how far the kit carries its\r\n * own vocabulary.\r\n *\r\n * Do NOT look for a glass mug: a medieval drinking vessel was wood, leather or\r\n * pewter. A clear glass would be a period error, and it would also look foreign\r\n * next to the rest of the kit.\r\n *\r\n * The handle turned into a problem. A round rod handle looked like a modern\r\n * mug; a real tankard's handle is a FLAT strap of wood or iron, fixed to the\r\n * body at two points. The curve comes from `bendGeometry`.\r\n */\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n arcBarGeometry,\r\n bandGeometry,\r\n boxGeometry,\r\n createKitModel,\r\n createTinter,\r\n headGeometry,\r\n jitter,\r\n mergeColoured,\r\n staveGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface OakTankardConfig {\r\n /** Height (metres). */\r\n readonly height: number\r\n /** Rim radius (metres). */\r\n readonly radius: number\r\n /** Narrowing towards the base. 0 = cylinder. */\r\n readonly taper: number\r\n /** Number of staves. */\r\n readonly staveCount: number\r\n /** Number of iron hoops. */\r\n readonly hoopCount: number\r\n /** Whether there is a handle (0/1). */\r\n readonly handle: number\r\n readonly seed: number\r\n}\r\n\r\nexport const oakTankardDefaults: OakTankardConfig = {\r\n height: 0.162,\r\n radius: 0.056,\r\n taper: 0.05,\r\n staveCount: 10,\r\n hoopCount: 2,\r\n handle: 1,\r\n seed: 61,\r\n}\r\n\r\nexport type OakTankardParts = 'staves' | 'base' | 'hoops' | 'handle'\r\n\r\nexport function createModel(overrides: Partial<OakTankardConfig> = {}) {\r\n return createKitModel<OakTankardConfig, 'oak' | 'iron', OakTankardParts>({\r\n id: 'oak-tankard',\r\n defaults: oakTankardDefaults,\r\n slots: ['oak', 'iron'],\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const half = config.height / 2\r\n const staves = Math.max(5, Math.round(config.staveCount))\r\n const thickness = config.radius * 0.13\r\n const bottomRadius = config.radius * (1 - config.taper)\r\n\r\n // --- Staves -----------------------------------------------------------\r\n const stavePieces: BufferGeometry[] = []\r\n const step = (Math.PI * 2) / staves\r\n for (let i = 0; i < staves; i += 1) {\r\n // A thin gap between staves: adjacent staves were coplanar along their\r\n // side faces, and on the barrel that caused z-fighting.\r\n const gap = step * 0.035\r\n const levels: Level[] = [\r\n { y: -half, radius: bottomRadius * (1 + jitter(random, 0.012)) },\r\n { y: -half + config.height * 0.5, radius: config.radius * (0.985 + jitter(random, 0.012)) },\r\n { y: half, radius: config.radius * (1 + jitter(random, 0.012)) },\r\n ]\r\n stavePieces.push(staveGeometry(\r\n levels, i * step + gap, (i + 1) * step - gap, thickness,\r\n tint('oak', jitter(random, 0.06)),\r\n ))\r\n }\r\n\r\n // --- Base --------------------------------------------------------------\r\n // A disc seated inside the staves; its edge stays within them.\r\n const base = headGeometry(\r\n bottomRadius - thickness * 0.55, -half + config.height * 0.055,\r\n staves, 'up', tint('oakEnd', 0.02), 3, 0.06,\r\n )\r\n\r\n // --- Hoops --------------------------------------------------------------\r\n const hoops = Math.max(0, Math.round(config.hoopCount))\r\n const hoopPieces: BufferGeometry[] = []\r\n for (let i = 0; i < hoops; i += 1) {\r\n const t = hoops === 1 ? 0.5 : 0.13 + (i / (hoops - 1)) * 0.74\r\n const y = -half + config.height * t\r\n const radius = bottomRadius + (config.radius - bottomRadius) * t\r\n hoopPieces.push(bandGeometry(\r\n radius + thickness * 0.42, y, config.height * 0.055,\r\n thickness * 0.32, staves, tint('iron', jitter(random, 0.05), 0.6),\r\n ))\r\n }\r\n\r\n // --- Handle --------------------------------------------------------------\r\n let handle: BufferGeometry | undefined\r\n if (config.handle >= 0.5) {\r\n // Built as a real arc, not as a bent box.\r\n //\r\n // The previous version made a flat `boxGeometry` strap and put it\r\n // through `bendGeometry`, then pushed it clear of the body by a\r\n // hand-derived offset: `(1 - cos(span/2 * k)) / k`. Measured, the\r\n // result was a handle whose outermost point stood 7 mm proud of a\r\n // body 47 mm in radius, with the rest of it buried in the stave wall.\r\n // There was no finger gap at all -- it read as a plank glued to the\r\n // side, which is exactly what it was. The offset formula did not\r\n // describe what `bendGeometry` actually does to the geometry.\r\n //\r\n // So this does not compute a correction for a curve it cannot see.\r\n // `arcBarGeometry` produces the arc directly, and the arc is specified\r\n // by the two things that actually matter: where its ends land, and how\r\n // far its belly stands off the body.\r\n const span = config.height * 0.72\r\n // Ends land INSIDE the stave wall, so the joint is an overlap.\r\n const endDepth = config.radius - thickness * 1.1\r\n const arcRadius = Math.hypot(span / 2, endDepth)\r\n const halfAngle = Math.atan2(span / 2, endDepth)\r\n const strap = arcBarGeometry(\r\n arcRadius,\r\n thickness * 1.5,\r\n -halfAngle,\r\n halfAngle,\r\n 7,\r\n [0, 0, 0],\r\n tint('oak', -0.05),\r\n )\r\n // The arc is built in the XY plane; -90 degrees about Y carries its\r\n // +X into +Z, standing it up beside the body with its span along Y.\r\n strap.rotateY(-Math.PI / 2)\r\n handle = mergeColoured([strap])\r\n }\r\n\r\n return {\r\n staves: { slot: 'oak' as const, geometry: mergeColoured(stavePieces) },\r\n base: { slot: 'oak' as const, geometry: mergeColoured([base]) },\r\n hoops: hoopPieces.length > 0\r\n ? { slot: 'iron' as const, geometry: mergeColoured(hoopPieces) }\r\n : undefined,\r\n handle: handle ? { slot: 'oak' as const, geometry: handle } : undefined,\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
1499
+ "hash": "a69881fd29e3dafdedb7786da13892ee69e1be058994eeef9ff27bca6ae4033d"
1500
+ }
1501
+ ],
1502
+ "meta": {
1503
+ "title": "Oak Tankard",
1504
+ "description": "Oak tankard: the barrel at palm scale, in the same stave-and-hoop language.",
1505
+ "category": "Props",
1506
+ "tags": [
1507
+ "medieval",
1508
+ "lowpoly",
1509
+ "props",
1510
+ "tavern",
1511
+ "procedural"
1512
+ ],
1513
+ "controls": {
1514
+ "height": {
1515
+ "type": "number",
1516
+ "label": "Height",
1517
+ "min": 0.08,
1518
+ "max": 0.28,
1519
+ "step": 0.005,
1520
+ "unit": "m"
1521
+ },
1522
+ "radius": {
1523
+ "type": "number",
1524
+ "label": "Rim radius",
1525
+ "min": 0.03,
1526
+ "max": 0.1,
1527
+ "step": 0.002,
1528
+ "unit": "m"
1529
+ },
1530
+ "taper": {
1531
+ "type": "number",
1532
+ "label": "Base taper",
1533
+ "min": 0,
1534
+ "max": 0.3,
1535
+ "step": 0.01
1536
+ },
1537
+ "staveCount": {
1538
+ "type": "number",
1539
+ "label": "Stave count",
1540
+ "min": 6,
1541
+ "max": 18,
1542
+ "step": 1
1543
+ },
1544
+ "hoopCount": {
1545
+ "type": "number",
1546
+ "label": "Hoop count",
1547
+ "min": 0,
1548
+ "max": 4,
1549
+ "step": 1
1550
+ },
1551
+ "handle": {
1552
+ "type": "number",
1553
+ "label": "Handle",
1554
+ "min": 0,
1555
+ "max": 1,
1556
+ "step": 1
1557
+ },
1558
+ "seed": {
1559
+ "type": "number",
1560
+ "label": "Variation seed",
1561
+ "min": 1,
1562
+ "max": 64,
1563
+ "step": 1
1564
+ }
1565
+ },
1566
+ "materialSlots": [
1567
+ "oak",
1568
+ "iron"
1569
+ ],
1570
+ "parts": [
1571
+ "staves",
1572
+ "base",
1573
+ "hoops",
1574
+ "handle"
1575
+ ],
1576
+ "sockets": []
1577
+ }
1578
+ },
1579
+ {
1580
+ "name": "pitch-torch",
1581
+ "type": "vibe3d:model",
1582
+ "title": "Pitch Torch",
1583
+ "description": "Torch wrapped in pitch-soaked cloth. The flame flickers in update(), goes out via actions.setLit.",
1584
+ "dependencies": [
1585
+ "three@>=0.185.0"
1586
+ ],
1587
+ "registryDependencies": [
1588
+ "@medieval-kit/core"
1589
+ ],
1590
+ "files": [
1591
+ {
1592
+ "path": "models/pitch-torch/model.ts",
1593
+ "target": "{models}/medieval-kit/pitch-torch/model.ts",
1594
+ "content": "/**\n * @medieval-kit/pitch-torch\n *\n * Pitch torch: a knotty stick, pitch-soaked cloth wound onto its end, a flame\n * above. This was exactly the period's lighting — candles were expensive,\n * torches were free.\n *\n * The kit's first ANIMATED model. The flame flickers via `update()` and the\n * source of that flicker is NOT randomness but a sum of sines of elapsed time.\n * There are three reasons for that:\n *\n * - Randomness breaks determinism. `Math.random()` is banned everywhere in\n * the kit; the flame can be no exception, otherwise two torches with the\n * same seed diverge.\n * - Two sines at incommensurate frequencies give an oscillation that reads as\n * \"non-repeating\" to the eye. A single sine would tick like a metronome.\n * - If the consumer never calls `update()`, the model stops completely.\n * Setting up a self-driving timer would violate the protocol's principle\n * that \"the consumer owns the loop\".\n *\n * The flame also EMITS NO LIGHT. If the torch is meant to light the scene, the\n * consumer attaches a PointLight to `parts.flame.anchor` — the model has no\n * right to make assumptions about the scene's light budget.\n */\nimport {\n createKitModel,\n createTinter,\n jitter,\n latheGeometry,\n mergeColoured,\n prismGeometry,\n roughenGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface PitchTorchConfig {\n /** Total shaft length (metres). */\n readonly length: number\n /** Shaft radius (metres). */\n readonly radius: number\n /** Length of the cloth wrap, as a fraction of the shaft. */\n readonly wrapLength: number\n /** Flame height, as a fraction of the wrap length. */\n readonly flameHeight: number\n /** Amplitude of the flicker. 0 = steady flame. */\n readonly flicker: number\n readonly seed: number\n}\n\nexport const pitchTorchDefaults: PitchTorchConfig = {\n length: 0.58,\n radius: 0.019,\n wrapLength: 0.3,\n // The flame was taller than the head that feeds it. A pitch torch burns\n // with a low, fat, smoky flame, not a candle's spire.\n flameHeight: 0.78,\n flicker: 1,\n seed: 37,\n}\n\nexport type PitchTorchParts = 'shaft' | 'wrap' | 'flame'\n\nexport interface PitchTorchActions {\n /** Lights/extinguishes the flame. When out, the `flame` part is fully hidden. */\n setLit(lit: boolean): void\n isLit(): boolean\n}\n\nexport function createModel(overrides: Partial<PitchTorchConfig> = {}) {\n // State lives OUTSIDE the build: `configure()` must not put the torch out.\n let lit = true\n let elapsed = 0\n\n return createKitModel<PitchTorchConfig, 'oak' | 'char' | 'ember', PitchTorchParts, PitchTorchActions>({\n id: 'pitch-torch',\n defaults: pitchTorchDefaults,\n slots: ['oak', 'char', 'ember'],\n\n build: ({ config, random }) => {\n const tint = createTinter(random)\n const half = config.length / 2\n const wrapLength = config.length * config.wrapLength\n const wrapBase = half - wrapLength\n\n // --- Shaft -----------------------------------------------------------\n // A knotty branch: its radius wavers along its length. A straight\n // cylinder would look manufactured, whereas a torch is a stick cut in\n // the forest.\n const knots = 6\n const shaftProfile: Level[] = Array.from({ length: knots + 1 }, (_, i) => {\n const t = i / knots\n return {\n y: -half + config.length * (1 - config.wrapLength) * t,\n radius: config.radius * (1 + jitter(random, 0.16)) * (i === 0 ? 0.82 : 1),\n }\n })\n const shaft = mergeColoured([latheGeometry(\n shaftProfile, 6, [0, 0, 0], tint('oak', -0.08),\n { colourTop: tint('oak', -0.02) },\n )])\n\n // --- Wrap ------------------------------------------------------------\n // Pitch-soaked cloth: thick relative to the shaft, swollen towards the\n // end, flat on top. The char slot is used because pitch gets coated in\n // soot — an oak colour would be a lie here.\n const wrapProfile: Level[] = [\n { y: wrapBase - wrapLength * 0.12, radius: config.radius * 1.15 },\n // Fatter. A torch head is a fist of tow and rags soaked in pitch,\n // bound on; at 2.5 shaft radii it was a spindle, and the head is the\n // whole reason the object is not a stick.\n { y: wrapBase + wrapLength * 0.22, radius: config.radius * 3.3 },\n { y: wrapBase + wrapLength * 0.62, radius: config.radius * 3.5 },\n { y: half, radius: config.radius * 2.9 },\n ]\n const wrap = mergeColoured([latheGeometry(\n wrapProfile, 7, [0, 0, 0], tint('char', 0.06),\n { colourTop: tint('charHot', -0.32) },\n )])\n\n // --- Flame -----------------------------------------------------------\n // The flame geometry is built at ITS OWN origin and the anchor is moved\n // to the end of the wrap. This is required because the flicker drives the\n // anchor's scale: a flame whose origin is not at its base would sink into\n // the wrap when scaled.\n const flameHeight = wrapLength * config.flameHeight\n // This is the flame profile's second version. The first tapered from base\n // to tip on a single curve and in render it looked like a ROCKET NOSE —\n // smooth, symmetric, pointed. A flame is not like that: its base is wide\n // and swollen, it has a waist in the middle, and its tip is not pointed\n // but TORN. The profile below gives that waist, and `roughen` breaks the\n // symmetry.\n const flameProfile: Level[] = [\n { y: 0, radius: config.radius * 2.05 },\n { y: flameHeight * 0.14, radius: config.radius * 2.75 },\n { y: flameHeight * 0.34, radius: config.radius * 2.15 },\n { y: flameHeight * 0.5, radius: config.radius * 2.4 },\n { y: flameHeight * 0.72, radius: config.radius * 1.35 },\n { y: flameHeight * 0.9, radius: config.radius * 0.8 },\n { y: flameHeight, radius: config.radius * 0.22 },\n ]\n const outer = latheGeometry(flameProfile, 6, [0, 0, 0], tint('ember', 0.04, 0.4),\n { colourTop: tint('emberTip', 0, 0.4), capBottom: true })\n roughenGeometry(outer, config.radius * 0.3, { salt: 5, scaleY: 1.6 })\n\n const flame = mergeColoured([\n outer,\n // Inner core: smaller and whiter than the outer one. Two layers give\n // the impression that the flame has depth — a single cone reads flat.\n //\n // Its base is NOT CAPPED: it was coplanar with the base of the outer\n // cone and z-fought with it. It is invisible anyway, being inside the\n // outer shell, so the cap was both unnecessary and harmful.\n prismGeometry(\n config.radius * 1.25, config.radius * 0.1, flameHeight * 0.48, 5,\n [0, flameHeight * 0.3, 0], tint('ember', 0.22, 0.3),\n { capBottom: false },\n ),\n ])\n\n return {\n shaft: { slot: 'oak' as const, geometry: shaft },\n wrap: { slot: 'char' as const, geometry: wrap },\n flame: {\n slot: 'ember' as const,\n geometry: flame,\n origin: [0, half - wrapLength * 0.12, 0] as const,\n },\n }\n },\n\n actions: ({ parts }) => {\n parts.flame.anchor.visible = lit\n return {\n setLit: (next) => { lit = next; parts.flame.anchor.visible = next },\n isLit: () => lit,\n }\n },\n\n update: (dt, { parts, getConfig }) => {\n // An extinguished torch does not advance: so that when it is relit the\n // flame resumes from the same phase point rather than wherever it drifted\n // to. Otherwise a torch left out for a long time would start at an\n // arbitrary size when lit.\n if (!lit) return\n const config = getConfig()\n const amount = config.flicker\n if (amount === 0) return\n elapsed += Math.max(0, dt)\n\n // Incommensurate frequencies: 11.3 and 19.7 are not multiples of each\n // other, so the period of the sum is too long for the eye to catch.\n const pulse = Math.sin(elapsed * 11.3) * 0.09 + Math.sin(elapsed * 19.7 + 1.4) * 0.055\n const sway = Math.sin(elapsed * 7.1 + 0.6) * 0.05 + Math.sin(elapsed * 13.9) * 0.028\n\n const anchor = parts.flame.anchor\n // Height and width move in OPPOSITE directions: a flame narrows as it\n // stretches. Scaling them the same way made the flame look like a\n // breathing balloon.\n anchor.scale.set(1 - pulse * 0.55 * amount, 1 + pulse * amount, 1 - pulse * 0.55 * amount)\n anchor.rotation.z = sway * amount\n anchor.rotation.x = Math.sin(elapsed * 9.4 + 2.1) * 0.038 * amount\n },\n }, overrides)\n}\n",
1595
+ "hash": "afc3b92ad659f102aa141de59ba8d1d5d6ba36cace3f10d4b51639098acbd510"
1596
+ }
1597
+ ],
1598
+ "meta": {
1599
+ "title": "Pitch Torch",
1600
+ "description": "Torch wrapped in pitch-soaked cloth. The flame flickers in update(), goes out via actions.setLit.",
1601
+ "category": "Lighting",
1602
+ "tags": [
1603
+ "medieval",
1604
+ "lowpoly",
1605
+ "lighting",
1606
+ "animated",
1607
+ "interactive"
1608
+ ],
1609
+ "controls": {
1610
+ "length": {
1611
+ "type": "number",
1612
+ "label": "Shaft length",
1613
+ "min": 0.3,
1614
+ "max": 1.4,
1615
+ "step": 0.02,
1616
+ "unit": "m"
1617
+ },
1618
+ "radius": {
1619
+ "type": "number",
1620
+ "label": "Shaft radius",
1621
+ "min": 0.008,
1622
+ "max": 0.05,
1623
+ "step": 0.001,
1624
+ "unit": "m"
1625
+ },
1626
+ "wrapLength": {
1627
+ "type": "number",
1628
+ "label": "Wrap ratio",
1629
+ "min": 0.12,
1630
+ "max": 0.5,
1631
+ "step": 0.01
1632
+ },
1633
+ "flameHeight": {
1634
+ "type": "number",
1635
+ "label": "Flame height",
1636
+ "min": 0.4,
1637
+ "max": 3,
1638
+ "step": 0.05
1639
+ },
1640
+ "flicker": {
1641
+ "type": "number",
1642
+ "label": "Flicker",
1643
+ "min": 0,
1644
+ "max": 2.5,
1645
+ "step": 0.05
1646
+ },
1647
+ "seed": {
1648
+ "type": "number",
1649
+ "label": "Variation seed",
1650
+ "min": 1,
1651
+ "max": 64,
1652
+ "step": 1
1653
+ }
1654
+ },
1655
+ "materialSlots": [
1656
+ "oak",
1657
+ "char",
1658
+ "ember"
1659
+ ],
1660
+ "parts": [
1661
+ "shaft",
1662
+ "wrap",
1663
+ "flame"
1664
+ ],
1665
+ "sockets": []
1666
+ }
1667
+ },
1668
+ {
1669
+ "name": "post-mill",
1670
+ "type": "vibe3d:model",
1671
+ "title": "Post Mill",
1672
+ "description": "Medieval post mill: the whole timber body turns on one massive post, with four lattice sails and the tail ladder it is turned by.",
1673
+ "dependencies": [
1674
+ "three@>=0.185.0"
1675
+ ],
1676
+ "registryDependencies": [
1677
+ "@medieval-kit/core"
1678
+ ],
1679
+ "files": [
1680
+ {
1681
+ "path": "models/post-mill/model.ts",
1682
+ "target": "{models}/medieval-kit/post-mill/model.ts",
1683
+ "content": "/**\r\n * @medieval-kit/post-mill\r\n *\r\n * A post mill: the medieval windmill, and the largest thing in this kit.\r\n *\r\n * The name is the whole design. The entire body — millstones, gearing, miller\r\n * and all — is balanced on ONE massive vertical post and turns bodily on it, so\r\n * that the sails can be pointed into whatever wind there is. The miller does\r\n * that by walking the tail ladder round, which is why the ladder is not a way\r\n * up but a lever, and why it reaches so far behind the mill.\r\n *\r\n * That gives the model its four parts and the order they have to be built in:\r\n *\r\n * - `trestle` — two cross-trees laid on the ground, the post standing on\r\n * them, and four quarter-bars bracing post to cross-tree. Nothing is\r\n * fastened to the ground: a post mill stands on its own weight, and the\r\n * cross-trees are what stop it walking off.\r\n * - `body` — the buck, a boarded timber box with a pitched roof, sitting on\r\n * the post's crown.\r\n * - `sails` — four lattice sails on a windshaft that leaves the front gable\r\n * tilted a little upwards. This part has its own origin at the shaft, so\r\n * `setTurning` can spin it without moving anything else.\r\n * - `ladder` — the tail ladder, from the back of the buck down to the ground\r\n * well behind it.\r\n *\r\n * The sails are the reason the triangle count is where it is. A sail is not a\r\n * blade, it is a LATTICE: a whip with a frame beside it and a run of bars\r\n * across, which the miller dresses with canvas according to the wind. Drawn as\r\n * a solid blade the silhouette is wrong in the one place everybody looks, so\r\n * the bars are modelled and the budget is spent there rather than on the\r\n * boarding of the buck, which reads perfectly well as vertex colour.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n boxGeometry,\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n mergeColoured,\r\n taperedBoxGeometry,\r\n} from '../core/index.ts'\r\n\r\nexport interface PostMillConfig {\r\n /** Height to the ridge of the roof (metres). Sails reach past it. */\r\n readonly height: number\r\n /** Sail span, tip to tip (metres). */\r\n readonly sailSpan: number\r\n /** Bars across each sail. */\r\n readonly sailBars: number\r\n /** Width of the lattice beside each whip, as a fraction of the sail's length. */\r\n readonly sailWidth: number\r\n /** Rungs in the tail ladder. */\r\n readonly ladderRungs: number\r\n /** Current sail angle (radians). */\r\n readonly spin: number\r\n readonly seed: number\r\n}\r\n\r\nexport const postMillDefaults: PostMillConfig = {\r\n height: 6.2,\r\n // The lowest sail tip has to clear the ground. At 7.4 against a 6.2 m mill\r\n // it came down to within 0.27 m of it, which reads as a mill about to plough\r\n // its own field; the reference leaves roughly a tenth of the height.\r\n sailSpan: 6.6,\r\n // The lattice is what says \"windmill\" at any distance, and nine bars over a\r\n // 3.3 m sail spaces them like ladder rungs. The reference reads as a fine\r\n // grid.\r\n sailBars: 13,\r\n sailWidth: 0.17,\r\n ladderRungs: 13,\r\n spin: 0,\r\n seed: 43,\r\n}\r\n\r\nexport type PostMillParts = 'trestle' | 'body' | 'sails' | 'ladder'\r\n\r\nexport interface PostMillActions {\r\n /** Starts or stops the sails. */\r\n setTurning(on: boolean): void\r\n isTurning(): boolean\r\n /** Puts the sails at a given angle and stops them there. */\r\n setAngle(radians: number): void\r\n}\r\n\r\nexport function createModel(overrides: Partial<PostMillConfig> = {}) {\r\n let turning = false\r\n let angle = 0\r\n\r\n return createKitModel<PostMillConfig, 'oak' | 'iron', PostMillParts, PostMillActions>({\r\n id: 'post-mill',\r\n defaults: postMillDefaults,\r\n slots: ['oak', 'iron'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const H = config.height\r\n const half = H / 2\r\n const floor = -half\r\n\r\n // Heights are fractions of the mill's own height, so every slider keeps\r\n // the proportions of the reference rather than stretching one piece.\r\n const postTop = floor + H * 0.42\r\n const bodyHeight = H * 0.30\r\n const bodyBottom = postTop\r\n const bodyTop = bodyBottom + bodyHeight\r\n const bodyWidth = H * 0.29 // across the sails' axis\r\n const bodyDepth = H * 0.34 // along it\r\n const ridge = bodyTop + H * 0.13\r\n\r\n // --- Trestle -------------------------------------------------------------\r\n const timber: BufferGeometry[] = []\r\n const postSide = H * 0.075\r\n const treeSpan = H * 0.62\r\n const treeSide = H * 0.055\r\n\r\n // Two cross-trees, laid one over the other. They are NOT coplanar: the\r\n // upper one rides on top of the lower, which is how they actually sit and\r\n // which keeps their faces out of each other.\r\n for (const [index, along] of ([0, 1] as const).entries()) {\r\n const lower = index === 0\r\n const size: [number, number] = along === 0\r\n ? [treeSpan, treeSide]\r\n : [treeSide, treeSpan]\r\n timber.push(chamferedBoxGeometry(\r\n size,\r\n [size[0] * 0.97, size[1] * 0.97],\r\n treeSide,\r\n treeSide * 0.12,\r\n [0, floor + (lower ? treeSide * 0.5 : treeSide * 1.4), 0],\r\n tint('oak', lower ? -0.1 : -0.05),\r\n ))\r\n }\r\n\r\n // The post. Slightly tapered, because it is a whole tree and a tree is.\r\n const postBase = floor + treeSide * 1.1\r\n const postHeight = postTop - postBase\r\n timber.push(taperedBoxGeometry(\r\n [postSide * 1.15, postSide * 1.15],\r\n [postSide * 0.92, postSide * 0.92],\r\n postHeight,\r\n [0, postBase + postHeight / 2, 0],\r\n tint('oak', -0.02),\r\n ))\r\n\r\n // Four quarter-bars, one to each arm of the cross-trees. Their feet sit\r\n // ON the cross-tree and their heads run INTO the post, so both joints are\r\n // overlaps rather than faces meeting.\r\n const barFoot = treeSpan * 0.36\r\n const barHead = postTop - H * 0.075\r\n const barRise = barHead - (floor + treeSide * 1.6)\r\n const barLength = Math.hypot(barFoot, barRise)\r\n for (let i = 0; i < 4; i += 1) {\r\n const a = (i / 4) * Math.PI * 2\r\n const bar = chamferedBoxGeometry(\r\n [H * 0.036, H * 0.05],\r\n [H * 0.03, H * 0.042],\r\n barLength * 1.04,\r\n H * 0.006,\r\n [0, 0, 0],\r\n tint('oak', -0.07 + jitter(random, 0.03)),\r\n )\r\n // Built upright, leaned outwards, then swung to its arm. Rotating about\r\n // X leans it in the YZ plane; rotating about Y carries that lean round.\r\n //\r\n // The sign is NEGATIVE, and it was not. `rotateX(+t)` carries the bar's\r\n // top towards +Z and the translate below moves it +Z as well, so the\r\n // two add and the quarter-bars stood on their heads -- feet gathered at\r\n // the post, heads splayed out over the cross-trees, which is precisely\r\n // backwards for a brace whose whole job is to carry the post's load out\r\n // to the ground. Nothing caught it: the bar joins post to cross-tree\r\n // either way round, so the mass stays connected and the bounding box is\r\n // identical. It surfaced in the cauldron, where the same expression\r\n // built a tripod standing on its apex and the support check finally had\r\n // something to say.\r\n bar.rotateX(-Math.atan2(barFoot, barRise))\r\n bar.rotateY(a)\r\n bar.translate(\r\n Math.sin(a) * barFoot * 0.5,\r\n (floor + treeSide * 1.6 + barHead) / 2,\r\n Math.cos(a) * barFoot * 0.5,\r\n )\r\n timber.push(bar)\r\n }\r\n\r\n // Crown: the beam the whole body turns on, laid across the post's head.\r\n timber.push(chamferedBoxGeometry(\r\n [bodyWidth * 1.05, H * 0.07],\r\n [bodyWidth * 0.98, H * 0.06],\r\n H * 0.05,\r\n H * 0.006,\r\n [0, postTop - H * 0.012, 0],\r\n tint('oak', 0.02),\r\n ))\r\n\r\n // --- Body ----------------------------------------------------------------\r\n const shell: BufferGeometry[] = []\r\n shell.push(chamferedBoxGeometry(\r\n [bodyWidth, bodyDepth],\r\n [bodyWidth * 0.99, bodyDepth * 0.99],\r\n bodyHeight,\r\n H * 0.008,\r\n [0, bodyBottom + bodyHeight / 2, 0],\r\n tint('oak', 0.04),\r\n tint('oak', -0.03),\r\n ))\r\n\r\n // Roof: two pitched sheets meeting at a ridge. Built as tapered boxes\r\n // leaned in rather than as a prism, so the gable ends stay flat and the\r\n // eaves can overhang the walls, which is what keeps rain off boarding.\r\n const eave = bodyWidth * 0.62\r\n const roofRise = ridge - bodyTop\r\n const slope = Math.hypot(eave, roofRise)\r\n for (const side of [-1, 1]) {\r\n // `slope`, not `slope * 2`. Each pitch covers HALF the roof: the slope\r\n // is already the hypotenuse from ridge to eave. Doubled, the two came\r\n // out 2.79 m across a building 1.80 m wide, and the roof stopped being\r\n // a roof and became the largest thing in the silhouette.\r\n const pitch = chamferedBoxGeometry(\r\n [slope * 1.03, bodyDepth * 1.08],\r\n [slope * 1.03, bodyDepth * 1.08],\r\n H * 0.016,\r\n H * 0.004,\r\n [0, 0, 0],\r\n tint('oak', -0.12),\r\n )\r\n // The pitch starts as a slab lying FLAT -- `chamferedBoxGeometry` takes\r\n // an X-Z footprint and a Y height -- so the tilt is the roof's angle\r\n // measured from the horizontal, and it is negative on the +X side so\r\n // the eave drops rather than rises. Both earlier attempts used the\r\n // complement of this angle, which stood the pitches up like the covers\r\n // of an open book.\r\n pitch.rotateZ(-side * Math.atan2(roofRise, eave))\r\n pitch.translate(side * eave * 0.5, bodyTop + roofRise * 0.5, 0)\r\n shell.push(pitch)\r\n }\r\n // Ridge board, capping the join so the two pitches do not meet in a seam.\r\n shell.push(chamferedBoxGeometry(\r\n [H * 0.03, bodyDepth * 1.1],\r\n [H * 0.024, bodyDepth * 1.1],\r\n H * 0.022,\r\n H * 0.004,\r\n [0, ridge - H * 0.008, 0],\r\n tint('oak', -0.16),\r\n ))\r\n\r\n // The door, in the tail wall above the gallery.\r\n //\r\n // The ladder has to arrive somewhere. A mill is entered from its tail,\r\n // through the one wall the sails never sweep past, and the miller comes\r\n // up the same ladder he turns the mill with -- so a flight of steps\r\n // ending at blank boarding is the one thing about this model that could\r\n // not be true.\r\n //\r\n // Boarded proud of the wall rather than cut into it: there are no\r\n // booleans here, and a door of applied planks with a frame round it is\r\n // how a plank building is actually closed.\r\n const doorWidth = bodyWidth * 0.42\r\n const doorHeight = bodyHeight * 0.66\r\n const doorY = bodyBottom + H * 0.035 + doorHeight / 2\r\n const doorZ = -bodyDepth / 2\r\n shell.push(chamferedBoxGeometry(\r\n [doorWidth, H * 0.018],\r\n [doorWidth * 0.99, H * 0.016],\r\n doorHeight,\r\n H * 0.004,\r\n [0, doorY, doorZ - H * 0.004],\r\n tint('oak', -0.14),\r\n ))\r\n // Two iron-dark ledges across it, and the frame: what stops a plank door\r\n // being a rectangle drawn on a wall.\r\n for (const at of [-0.28, 0.3]) {\r\n shell.push(chamferedBoxGeometry(\r\n [doorWidth * 1.04, H * 0.012],\r\n [doorWidth * 1.02, H * 0.01],\r\n H * 0.022,\r\n H * 0.003,\r\n [0, doorY + doorHeight * at, doorZ - H * 0.012],\r\n tint('oak', -0.22),\r\n ))\r\n }\r\n\r\n // The tail gallery: the little platform the ladder arrives at.\r\n shell.push(chamferedBoxGeometry(\r\n [bodyWidth * 0.92, H * 0.05],\r\n [bodyWidth * 0.88, H * 0.045],\r\n H * 0.018,\r\n H * 0.004,\r\n [0, bodyBottom + H * 0.02, -bodyDepth * 0.52],\r\n tint('oak', -0.06),\r\n ))\r\n\r\n // --- Sails ---------------------------------------------------------------\r\n // The windshaft leaves the front gable pointing +Z and tilted up a little,\r\n // which is what stops the sails striking the body as they come round.\r\n // The sail disc has to clear the front of the buck.\r\n //\r\n // A tilted shaft spreads its sails through Z: at 0.14 rad a 3.5 m sail\r\n // swings +-0.49 m fore and aft, and with the hub only 0.08 m in front of\r\n // the gable the whole lower half of the disc passed through the building.\r\n // Two changes, and both are what a real mill does. The shaft tilts only a\r\n // few degrees -- just enough to keep the sails off the body and to take\r\n // some of their weight onto the thrust bearing -- and it projects a good\r\n // way out, so the sweep happens clear of the wall.\r\n const shaftTilt = 0.075\r\n // Far enough forward and no further. The clearance the disc needs is\r\n // sailLength * sin(tilt), about 0.26 m here; pushing the hub out to 0.86\r\n // of the body's depth left the sails hanging most of a metre off the\r\n // gable with nothing but the shaft between them, where the reference has\r\n // them sweeping close past the boarding.\r\n const hubZ = bodyDepth * 0.72\r\n const hubY = bodyBottom + bodyHeight * 0.72\r\n const sailLength = config.sailSpan / 2\r\n const hubRadius = H * 0.035\r\n const barT = H * 0.014\r\n const latticeWidth = sailLength * config.sailWidth\r\n const bars = Math.max(2, Math.round(config.sailBars))\r\n\r\n const rig: BufferGeometry[] = []\r\n const ironWork: BufferGeometry[] = []\r\n // Windshaft: iron-banded oak, poking out of the gable ALONG Z.\r\n //\r\n // `taperedBoxGeometry` takes its third argument as a Y height, so written\r\n // without a rotation this was a short vertical post standing inside the\r\n // buck rather than a shaft projecting from the front of it. Hidden inside\r\n // the body it went unseen through every render; it only surfaced when the\r\n // iron bands were added and turned out to be coaxial with it, which the\r\n // z-fight check reported at once.\r\n // Long enough to reach back into the gable it comes out of.\r\n const shaftLength = H * 0.20\r\n const shaft = taperedBoxGeometry(\r\n [hubRadius * 2.1, hubRadius * 2.1],\r\n [hubRadius * 1.5, hubRadius * 1.5],\r\n shaftLength,\r\n [0, 0, 0],\r\n tint('oak', -0.04),\r\n )\r\n shaft.rotateX(Math.PI / 2)\r\n shaft.translate(0, 0, -shaftLength * 0.32)\r\n rig.push(shaft)\r\n // The bands themselves, and they are not decoration. Four sails pull on\r\n // one shaft from four directions; what holds the whips to it is iron\r\n // strapping, and this model declared an `iron` slot while using it\r\n // nowhere -- the shaft was described in the comment above as iron-banded\r\n // and then painted oak like everything else.\r\n for (const at of [-0.02, 0.03]) {\r\n const band = taperedBoxGeometry(\r\n [hubRadius * 2.4, hubRadius * 2.4],\r\n [hubRadius * 2.3, hubRadius * 2.3],\r\n H * 0.014,\r\n [0, 0, 0],\r\n tint('iron', 0.02, 0.7),\r\n )\r\n band.rotateX(Math.PI / 2)\r\n band.translate(0, 0, H * at)\r\n ironWork.push(band)\r\n }\r\n\r\n for (let i = 0; i < 4; i += 1) {\r\n const a = (i / 4) * Math.PI * 2\r\n const sail: BufferGeometry[] = []\r\n const tone = tint('oak', -0.05 + jitter(random, 0.04))\r\n\r\n // Whip: the spar the whole sail hangs on, running out from the hub.\r\n sail.push(boxGeometry(\r\n [barT * 1.3, sailLength - hubRadius, barT * 1.6],\r\n [0, hubRadius + (sailLength - hubRadius) / 2, 0],\r\n new Color(tone),\r\n ))\r\n // The outer rail of the frame, parallel to the whip.\r\n sail.push(boxGeometry(\r\n [barT * 0.9, (sailLength - hubRadius) * 0.9, barT * 1.1],\r\n [latticeWidth, hubRadius + (sailLength - hubRadius) * 0.5, 0],\r\n new Color(tint('oak', -0.1)),\r\n ))\r\n // And the bars across. These are the sail: a solid blade would read as\r\n // a propeller, and the lattice is what says \"windmill\" at any distance.\r\n for (let b = 0; b < bars; b += 1) {\r\n const t = (b + 0.6) / bars\r\n sail.push(boxGeometry(\r\n [latticeWidth * 1.12, barT * 0.7, barT * 0.9],\r\n [latticeWidth * 0.5, hubRadius + (sailLength - hubRadius) * t, 0],\r\n new Color(tint('oak', -0.02 + jitter(random, 0.05))),\r\n ))\r\n }\r\n\r\n const merged = mergeColoured(sail)\r\n // Cant: each sail is twisted a few degrees about its own length so it\r\n // presents a face to the wind. Without it the four read as a flat cross.\r\n merged.rotateY(0.18)\r\n merged.rotateZ(a)\r\n // Opposite pairs sit at different depths along the shaft.\r\n //\r\n // This is how the sails are actually carried: TWO stocks pass through\r\n // the windshaft at right angles to each other, one behind the other,\r\n // and each stock carries a sail at both of its ends. Built all in one\r\n // plane the four converge on the hub in perfect symmetry, and the\r\n // z-fight check found their faces meeting there -- correctly, because\r\n // four timbers cannot occupy one crossing.\r\n merged.translate(0, 0, (i % 2 === 0 ? -1 : 1) * barT * 2.1)\r\n rig.push(merged)\r\n }\r\n\r\n const sails = mergeColoured(rig)\r\n const shaftBands = mergeColoured(ironWork)\r\n shaftBands.rotateX(-shaftTilt)\r\n // The rig is authored around the hub and the part's origin goes there, so\r\n // `setTurning` spins it about the shaft instead of about the model.\r\n sails.rotateX(-shaftTilt)\r\n\r\n // --- Ladder --------------------------------------------------------------\r\n // Long and shallow: it is the lever the mill is turned with, not a stair.\r\n const steps: BufferGeometry[] = []\r\n // Long and shallow. The tail ladder is the lever the mill is turned by,\r\n // so it reaches well behind: at 0.42 of the height it came down at 47\r\n // degrees, which is a staircase. The reference lands much further back.\r\n const footZ = -bodyDepth * 0.5 - H * 0.62\r\n const headZ = -bodyDepth * 0.52\r\n const headY = bodyBottom + H * 0.02\r\n const run = headZ - footZ\r\n const rise = headY - floor\r\n const railLength = Math.hypot(run, rise)\r\n const lean = Math.atan2(run, rise)\r\n const railGap = bodyWidth * 0.34\r\n\r\n // The whole ladder is lifted by one offset, rails and rungs together.\r\n //\r\n // The foot has to land ON the ground, level with the cross-trees, and two\r\n // things push it under. The rail is built 3% long so it can bury its head\r\n // in the gallery, and half of that overrun hangs off the bottom; and once\r\n // tilted, the lowest CORNER of its end face drops below that face's\r\n // centre by half the rail's depth times sin(lean). Left uncorrected the\r\n // ladder sat 0.10 m under the trestle, so the mill's floor was its stair\r\n // and the whole thing hovered above whatever it was placed on.\r\n //\r\n // Correcting only the rails is worse than not correcting at all. The\r\n // second term displaces them PERPENDICULAR to their own axis by very\r\n // nearly a rung's half-depth, so the rungs -- placed on the original\r\n // centre line -- came away from the rails entirely. The support audit\r\n // caught that at the small end of the height slider as loose rungs\r\n // floating beside the ladder.\r\n const railDepth = H * 0.038\r\n const liftY = railLength * 0.015 * Math.cos(lean) + (railDepth / 2) * Math.sin(lean)\r\n const liftZ = -railLength * 0.015 * Math.sin(lean)\r\n\r\n for (const side of [-1, 1]) {\r\n const rail = chamferedBoxGeometry(\r\n [H * 0.022, railDepth],\r\n [H * 0.018, H * 0.032],\r\n railLength * 1.03,\r\n H * 0.004,\r\n [0, 0, 0],\r\n tint('oak', -0.08),\r\n )\r\n // +lean, not -lean. `run` is positive -- the ladder's head sits at a\r\n // LARGER z than its foot -- and rotateX(+t) carries +Y towards +Z, so\r\n // the negative sign laid the rails along the opposite diagonal to the\r\n // one the rungs are placed on. The two crossed near the middle and\r\n // diverged towards the ends, which is exactly what the support audit\r\n // reported: the middle rungs held, the outer ones floated. The bounding\r\n // box looked right the whole time, because the translation put the\r\n // ladder where it belonged and only its internals were wrong.\r\n rail.rotateX(lean)\r\n rail.translate(\r\n side * railGap,\r\n (floor + headY) / 2 + liftY,\r\n (footZ + headZ) / 2 + liftZ,\r\n )\r\n steps.push(rail)\r\n }\r\n\r\n const rungs = Math.max(2, Math.round(config.ladderRungs))\r\n for (let i = 0; i < rungs; i += 1) {\r\n const t = (i + 0.5) / rungs\r\n // Spanning PAST both rails, so each rung is housed in them rather than\r\n // ending at their inner faces.\r\n // The rung is DEEPER than the rail it passes through (0.048 against\r\n // 0.038), so its faces stand proud on both sides.\r\n //\r\n // Made thinner than the rail, a rung that \"passes through\" it has its\r\n // whole surface buried inside the rail's hollow interior, touching\r\n // nothing: these are surfaces, not solids. At the default height the\r\n // voxel grid was coarse enough to bridge the gap and the support check\r\n // passed; shrink the mill to the bottom of the height slider and the\r\n // finer grid separated three of the five rungs from the ladder. The\r\n // check was right both times -- the joint was never real.\r\n const rung = boxGeometry(\r\n [railGap * 2 + H * 0.03, H * 0.02, H * 0.048],\r\n [0, 0, 0],\r\n new Color(tint('oak', -0.03 + jitter(random, 0.04))),\r\n )\r\n rung.rotateX(lean)\r\n rung.translate(0, floor + rise * t + liftY, footZ + run * t + liftZ)\r\n steps.push(rung)\r\n }\r\n\r\n /**\r\n * Then MEASURE the foot and put it on the ground, rather than reasoning\r\n * about where it ended up.\r\n *\r\n * `liftY` above is two analytic corrections to the same corner, and both\r\n * are right about the thing they describe. What neither accounts for is\r\n * the chamfer: the rail is a chamfered box, so the corner they compute\r\n * the drop of has been cut off, and the ladder came to rest 14.5 mm high\r\n * — very nearly the chamfer itself. That is the third arithmetic mistake\r\n * at this one corner; the comments above are the first two.\r\n *\r\n * A bounding box cannot be wrong about this the way a derivation can, and\r\n * it stays right when the chamfer, the taper or the rail section change.\r\n * The head is buried in the gallery by a 3% overrun that is several times\r\n * this shift, so moving the assembly does not pull it out.\r\n */\r\n const ladder = mergeColoured(steps)\r\n ladder.computeBoundingBox()\r\n ladder.translate(0, floor - ladder.boundingBox!.min.y, 0)\r\n\r\n return {\r\n trestle: { slot: 'oak' as const, geometry: mergeColoured(timber) },\r\n body: { slot: 'oak' as const, geometry: mergeColoured(shell) },\r\n sails: {\r\n slot: 'oak' as const,\r\n geometry: sails,\r\n // The bands belong here, not with the trestle. They are authored\r\n // around the hub, so in any other part's frame they land at the\r\n // model's origin and hang in the air -- which is where the support\r\n // check found them. They also turn with the shaft, because in a real\r\n // mill the shaft is what the sails are keyed to.\r\n extras: [{ slot: 'iron' as const, geometry: shaftBands }],\r\n origin: [0, hubY, hubZ] as const,\r\n },\r\n ladder: { slot: 'oak' as const, geometry: ladder },\r\n }\r\n },\r\n\r\n actions: ({ parts, getConfig }) => {\r\n angle = getConfig().spin\r\n parts.sails.anchor.rotation.z = angle\r\n return {\r\n setTurning: (on) => { turning = on },\r\n isTurning: () => turning,\r\n setAngle: (radians) => {\r\n turning = false\r\n angle = radians\r\n parts.sails.anchor.rotation.z = angle\r\n },\r\n }\r\n },\r\n\r\n update: (deltaSeconds, { parts }) => {\r\n if (!turning) return\r\n // A working post mill turns slowly — on the order of ten revolutions a\r\n // minute at the sail tips, not the blur people draw.\r\n angle += deltaSeconds * 1.05\r\n parts.sails.anchor.rotation.z = angle\r\n },\r\n }, overrides)\r\n}\r\n",
1684
+ "hash": "e12c2276d57a4451c2beff28edf7243173bc76a1b837dbe9fc3f0b9355f88b7b"
1685
+ }
1686
+ ],
1687
+ "meta": {
1688
+ "title": "Post Mill",
1689
+ "description": "Medieval post mill: the whole timber body turns on one massive post, with four lattice sails and the tail ladder it is turned by.",
1690
+ "category": "Structure",
1691
+ "tags": [
1692
+ "medieval",
1693
+ "lowpoly",
1694
+ "structure",
1695
+ "procedural",
1696
+ "landmark"
1697
+ ],
1698
+ "controls": {
1699
+ "height": {
1700
+ "type": "number",
1701
+ "label": "Height to ridge",
1702
+ "min": 3.5,
1703
+ "max": 9,
1704
+ "step": 0.1,
1705
+ "unit": "m"
1706
+ },
1707
+ "sailSpan": {
1708
+ "type": "number",
1709
+ "label": "Sail span",
1710
+ "min": 4,
1711
+ "max": 11,
1712
+ "step": 0.1,
1713
+ "unit": "m"
1714
+ },
1715
+ "sailBars": {
1716
+ "type": "number",
1717
+ "label": "Bars per sail",
1718
+ "min": 3,
1719
+ "max": 14,
1720
+ "step": 1
1721
+ },
1722
+ "sailWidth": {
1723
+ "type": "number",
1724
+ "label": "Sail width",
1725
+ "min": 0.08,
1726
+ "max": 0.28,
1727
+ "step": 0.005
1728
+ },
1729
+ "ladderRungs": {
1730
+ "type": "number",
1731
+ "label": "Ladder rungs",
1732
+ "min": 5,
1733
+ "max": 20,
1734
+ "step": 1
1735
+ },
1736
+ "spin": {
1737
+ "type": "number",
1738
+ "label": "Sail angle",
1739
+ "min": 0,
1740
+ "max": 6.28,
1741
+ "step": 0.02,
1742
+ "unit": "rad"
1743
+ },
1744
+ "seed": {
1745
+ "type": "number",
1746
+ "label": "Variation seed",
1747
+ "min": 1,
1748
+ "max": 64,
1749
+ "step": 1
1750
+ }
1751
+ },
1752
+ "materialSlots": [
1753
+ "oak",
1754
+ "iron"
1755
+ ],
1756
+ "parts": [
1757
+ "trestle",
1758
+ "body",
1759
+ "sails",
1760
+ "ladder"
1761
+ ],
1762
+ "sockets": []
1763
+ }
1764
+ },
1765
+ {
1766
+ "name": "round-shield",
1767
+ "type": "vibe3d:model",
1768
+ "title": "Round Shield",
1769
+ "description": "Planked round shield with a rawhide rim and an iron boss, quartered in paint and leaning on its edge.",
1770
+ "dependencies": [
1771
+ "three@>=0.185.0"
1772
+ ],
1773
+ "registryDependencies": [
1774
+ "@medieval-kit/core"
1775
+ ],
1776
+ "files": [
1777
+ {
1778
+ "path": "models/round-shield/model.ts",
1779
+ "target": "{models}/medieval-kit/round-shield/model.ts",
1780
+ "content": "/**\r\n * @medieval-kit/round-shield\r\n *\r\n * A planked round shield with a rawhide rim and an iron boss.\r\n *\r\n * The kit had no arms or armour at all, which for a medieval catalogue is a\r\n * whole category missing rather than a gap. This is the cheapest way in: a\r\n * shield is the one piece of war gear that stands on its own in a scene —\r\n * leaning on a wall, stacked by a door — without needing a person to hold it.\r\n *\r\n * Three things make it a shield rather than a disc, and all three are\r\n * structural rather than decorative:\r\n *\r\n * - It is BOARDS, not a plate. A round shield is planks butted edge to edge\r\n * and held by the rim and the boss; the plank lines are the first thing\r\n * the eye finds and a smooth face reads as a lid.\r\n * - The rim binds the ENDS of those planks. Without it the boards would\r\n * split off one by one at the first blow, and without it in the model the\r\n * silhouette is a wafer.\r\n * - The boss is a dome over a HOLE. The hand goes behind it, gripping a bar\r\n * across the back, so the boss is not ornament — it is the knuckle guard,\r\n * and it is why the centre of the face is iron.\r\n *\r\n * The face is quartered because that is what a painted shield looks like and\r\n * because it costs nothing: each plank is built in two halves anyway, so the\r\n * upper and lower halves simply take different colours. `hue` turns both\r\n * quarters together and keeps them opposite, so the slider always produces a\r\n * pair that belongs together.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n bandGeometry,\r\n boxGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n prismGeometry,\r\n taperedBoxGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface RoundShieldConfig {\r\n /** Radius of the shield (metres). */\r\n readonly radius: number\r\n /** Planks across the face. */\r\n readonly planks: number\r\n /** Thickness of the boards (metres). */\r\n readonly thickness: number\r\n /** How far it leans back from upright (radians). */\r\n readonly lean: number\r\n /** Paint colour, 0–1 around the wheel. The second quarter sits opposite. */\r\n readonly hue: number\r\n /** Rivets around the boss. */\r\n readonly rivets: number\r\n readonly seed: number\r\n}\r\n\r\nexport const roundShieldDefaults: RoundShieldConfig = {\r\n radius: 0.36,\r\n planks: 9,\r\n thickness: 0.016,\r\n // Barely off vertical. A shield propped against something stands almost\r\n // upright; at 0.22 it read as lying back against nothing.\r\n lean: 0.12,\r\n hue: 0.02,\r\n rivets: 10,\r\n seed: 83,\r\n}\r\n\r\nexport type RoundShieldParts = 'boards' | 'rim' | 'boss'\r\n\r\nexport function createModel(overrides: Partial<RoundShieldConfig> = {}) {\r\n return createKitModel<RoundShieldConfig, 'oak' | 'leather' | 'iron', RoundShieldParts>({\r\n id: 'round-shield',\r\n defaults: roundShieldDefaults,\r\n slots: ['oak', 'leather', 'iron'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const R = config.radius\r\n const T = config.thickness\r\n const planks = Math.max(3, Math.round(config.planks))\r\n const hue = ((config.hue % 1) + 1) % 1\r\n\r\n // Saturated and dark. At 0.42 saturation and 0.34 lightness the face\r\n // came out pastel against a reference whose red and blue are deep enough\r\n // to read as pigment ground in oil -- which is what they were. Shield\r\n // paint is not a wash.\r\n const paint = (h: number, dark: number): Color =>\r\n new Color().setHSL(\r\n (h + jitter(random, 0.01) + 1) % 1,\r\n 0.66 + jitter(random, 0.05),\r\n dark + jitter(random, 0.03),\r\n )\r\n\r\n // --- Boards ---------------------------------------------------------\r\n // Each plank is built in two halves so the face can be quartered without\r\n // any extra geometry: the split the paint needs is a split the boards\r\n // already have to make anyway.\r\n const boards: BufferGeometry[] = []\r\n const step = (R * 2) / planks\r\n const upper = paint(hue, 0.26)\r\n const lower = paint((hue + 0.55) % 1, 0.23)\r\n\r\n for (let i = 0; i < planks; i += 1) {\r\n const x = -R + step * (i + 0.5)\r\n // The plank is a chord of the circle, so its length is set by how far\r\n // out it sits. Squaring off every plank at full width would give a\r\n // square with a rim drawn on it.\r\n const halfSpan = Math.sqrt(Math.max(0, R * R - Math.pow(Math.abs(x) + step * 0.5, 2)))\r\n if (halfSpan <= step * 0.2) continue\r\n for (const half of [-1, 1]) {\r\n // The two halves of one plank are quartered on the DIAGONAL: the\r\n // left-hand planks take one colour above and the other below, and\r\n // the right-hand ones the reverse. That is what makes a quartered\r\n // shield read as quartered rather than as banded.\r\n const left = x < 0\r\n const colour = (half < 0) === left ? upper : lower\r\n boards.push(taperedBoxGeometry(\r\n [step * 0.97, T],\r\n [step * 0.94, T * 0.92],\r\n halfSpan,\r\n [x, half * halfSpan * 0.5, 0],\r\n new Color(colour),\r\n ))\r\n }\r\n }\r\n\r\n // --- Rim ------------------------------------------------------------\r\n // A rawhide binding folded over the edge. It is a band whose thickness\r\n // reaches INSIDE the boards' own radius, so it grips them rather than\r\n // sitting against their ends.\r\n // Thick enough to SWALLOW the plank ends.\r\n //\r\n // The boards are rectangles cut to the chord of the circle, so their ends\r\n // step in and out around the edge -- unavoidable, since a plank is\r\n // straight and the outline is not. A real rim hides exactly that: it is a\r\n // wide band of hide folded over the ends and stitched, and it is what\r\n // turns a stack of boards into a disc. At 0.055 of the radius it was too\r\n // narrow to cover the steps and the silhouette came out serrated.\r\n const rim = bandGeometry(\r\n R * 1.02, 0, T * 3.2, R * 0.105, planks * 3,\r\n tint('leather', -0.04, 0.9), { inner: true },\r\n )\r\n rim.rotateX(Math.PI / 2)\r\n\r\n // The cross between the quarters, laid ON the face.\r\n //\r\n // Two bands of pale paint dividing the four quarters. It is the detail\r\n // that makes a quartered shield read at a distance -- without it the\r\n // quarters meet edge to edge and the eye sees two colours rather than a\r\n // device. Proud of the boards rather than inlaid, because paint is.\r\n const trim: BufferGeometry[] = []\r\n const band = R * 0.1\r\n const cream = new Color().setHSL(0.11, 0.24, 0.74)\r\n for (const vertical of [false, true]) {\r\n const arm = taperedBoxGeometry(\r\n vertical ? [band, T * 0.5] : [R * 1.94, T * 0.5],\r\n vertical ? [band * 0.97, T * 0.4] : [R * 1.9, T * 0.4],\r\n vertical ? R * 1.94 : band,\r\n [0, 0, T * 0.62],\r\n new Color(cream),\r\n )\r\n trim.push(arm)\r\n }\r\n\r\n // --- Boss -----------------------------------------------------------\r\n const bossR = R * 0.26\r\n const iron: BufferGeometry[] = []\r\n // The dome. Its back is open, because behind it is the hole the hand\r\n // goes into, and a cap there would be a face nobody can see.\r\n iron.push(latheGeometry(\r\n [\r\n { y: 0, radius: bossR * 0.98 },\r\n { y: bossR * 0.42, radius: bossR * 0.86 },\r\n { y: bossR * 0.78, radius: bossR * 0.54 },\r\n { y: bossR * 0.94, radius: bossR * 0.16 },\r\n ] as Level[],\r\n 11, [0, 0, 0], tint('iron', 0.05, 0.7), { capBottom: false },\r\n // +90, not -90. The dome is turned about Y, and -90 sends its point\r\n // to -Z -- into the back of the shield, where the only thing visible\r\n // from the front was the open rim of it. That is why the boss read as a\r\n // flat black ring.\r\n ).rotateX(Math.PI / 2).translate(0, 0, T * 0.5))\r\n // The flange it is riveted through.\r\n iron.push(bandGeometry(\r\n bossR * 1.34, 0, T * 1.1, bossR * 0.36, 11,\r\n tint('iron', -0.02, 0.7), { inner: true },\r\n ).rotateX(Math.PI / 2).translate(0, 0, T * 0.55))\r\n\r\n const rivets = Math.max(0, Math.round(config.rivets))\r\n for (let i = 0; i < rivets; i += 1) {\r\n const a = (i / rivets) * Math.PI * 2 + 0.2\r\n // Flared outwards, like the barrel's: a stud that tapers away from the\r\n // surface tilts its side normals back towards it, which the radial\r\n // check reads as reversed winding, and a hammered rivet flares anyway.\r\n const stud = prismGeometry(\r\n R * 0.016, R * 0.022, T * 1.4, 4, [0, 0, 0],\r\n tint('iron', 0.09, 0.6), { capBottom: false },\r\n )\r\n stud.rotateX(-Math.PI / 2)\r\n iron.push(stud.translate(\r\n Math.sin(a) * bossR * 1.16,\r\n Math.cos(a) * bossR * 1.16,\r\n T * 0.8,\r\n ))\r\n }\r\n\r\n // --- Back ------------------------------------------------------------\r\n // The grip bar across the hole, and two battens holding the planks. Cheap\r\n // and invisible from the front, but a shield seen from behind with\r\n // nothing to hold is worse than one with no back at all.\r\n const back: BufferGeometry[] = []\r\n back.push(boxGeometry(\r\n [R * 1.2, R * 0.09, T * 1.6],\r\n [0, 0, -T * 1.1],\r\n new Color(tint('oak', -0.1)),\r\n ))\r\n for (const at of [-0.46, 0.46]) {\r\n back.push(boxGeometry(\r\n [R * 1.5, R * 0.07, T * 1.2],\r\n [0, at * R, -T * 0.9],\r\n new Color(tint('oak', -0.14)),\r\n ))\r\n }\r\n\r\n const face = mergeColoured([...boards, ...trim, ...back])\r\n const boss = mergeColoured(iron)\r\n\r\n // Leaned back from UPRIGHT, not tipped up from flat.\r\n //\r\n // Everything is authored in the XY plane facing +Z, which is already a\r\n // shield standing on its edge. Rotating by -90 degrees and then adding\r\n // the lean laid it face-up on the floor with a slight tilt, which is a\r\n // shield somebody dropped. The lean is the whole rotation.\r\n for (const g of [face, rim, boss]) g.rotateX(config.lean)\r\n // Set down by measuring, not by trigonometry: the boss stands proud of\r\n // the face and the rim wraps past it, so which piece is lowest depends on\r\n // the lean and is not worth deriving.\r\n const all = mergeColoured([face, rim, boss])\r\n all.computeBoundingBox()\r\n const drop = all.boundingBox?.min.y ?? 0\r\n for (const g of [face, rim, boss]) g.translate(0, -drop, 0)\r\n\r\n return {\r\n boards: { slot: 'oak' as const, geometry: face },\r\n rim: { slot: 'leather' as const, geometry: rim },\r\n boss: { slot: 'iron' as const, geometry: boss },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
1781
+ "hash": "2c4d955d61db499fd5bdb5a4247a98809dc2aefe0b0802650da1aac050fd8fff"
1782
+ }
1783
+ ],
1784
+ "meta": {
1785
+ "title": "Round Shield",
1786
+ "description": "Planked round shield with a rawhide rim and an iron boss, quartered in paint and leaning on its edge.",
1787
+ "category": "Arms",
1788
+ "tags": [
1789
+ "medieval",
1790
+ "lowpoly",
1791
+ "arms",
1792
+ "procedural"
1793
+ ],
1794
+ "controls": {
1795
+ "radius": {
1796
+ "type": "number",
1797
+ "label": "Radius",
1798
+ "min": 0.2,
1799
+ "max": 0.6,
1800
+ "step": 0.01,
1801
+ "unit": "m"
1802
+ },
1803
+ "planks": {
1804
+ "type": "number",
1805
+ "label": "Planks",
1806
+ "min": 3,
1807
+ "max": 15,
1808
+ "step": 1
1809
+ },
1810
+ "thickness": {
1811
+ "type": "number",
1812
+ "label": "Board thickness",
1813
+ "min": 0.008,
1814
+ "max": 0.04,
1815
+ "step": 0.002,
1816
+ "unit": "m"
1817
+ },
1818
+ "lean": {
1819
+ "type": "number",
1820
+ "label": "Lean back",
1821
+ "min": 0,
1822
+ "max": 0.6,
1823
+ "step": 0.02,
1824
+ "unit": "rad"
1825
+ },
1826
+ "hue": {
1827
+ "type": "number",
1828
+ "label": "Paint hue",
1829
+ "min": 0,
1830
+ "max": 1,
1831
+ "step": 0.01
1832
+ },
1833
+ "rivets": {
1834
+ "type": "number",
1835
+ "label": "Boss rivets",
1836
+ "min": 0,
1837
+ "max": 16,
1838
+ "step": 1
1839
+ },
1840
+ "seed": {
1841
+ "type": "number",
1842
+ "label": "Variation seed",
1843
+ "min": 1,
1844
+ "max": 64,
1845
+ "step": 1
1846
+ }
1847
+ },
1848
+ "materialSlots": [
1849
+ "oak",
1850
+ "leather",
1851
+ "iron"
1852
+ ],
1853
+ "parts": [
1854
+ "boards",
1855
+ "rim",
1856
+ "boss"
1857
+ ],
1858
+ "sockets": []
1859
+ }
1860
+ },
1861
+ {
1862
+ "name": "stone-trough",
1863
+ "type": "vibe3d:model",
1864
+ "title": "Stone Trough",
1865
+ "description": "Watering trough cut from one block of weathered limestone, with a heavy rim, a worn basin and a film of standing water.",
1866
+ "dependencies": [
1867
+ "three@>=0.185.0"
1868
+ ],
1869
+ "registryDependencies": [
1870
+ "@medieval-kit/core"
1871
+ ],
1872
+ "files": [
1873
+ {
1874
+ "path": "models/stone-trough/model.ts",
1875
+ "target": "{models}/medieval-kit/stone-trough/model.ts",
1876
+ "content": "/**\r\n * @medieval-kit/stone-trough\r\n *\r\n * A watering trough cut from one block of limestone.\r\n *\r\n * The kit could water nobody. It has a well, a bucket and a cauldron, and all\r\n * three are things you carry water IN; a trough is the thing a village keeps\r\n * water in, standing in the street outside the smithy where the horses are.\r\n *\r\n * It is not the same ROCK as the well. `stone` in this palette was measured\r\n * off dressed and rubble masonry and is grey, because a wall is quarried and\r\n * laid face out. One block left in the open for a century reads hue 36 and\r\n * saturation 0.16 against the wall's 44 and 0.05 — warmer, three times more\r\n * saturated, lighter. Hence `limestone`, and the rendered block now matches\r\n * the reference to within a fifth of a degree of hue and 0.004 of lightness.\r\n *\r\n * What took three attempts was making it read as CARVED rather than sawn, and\r\n * the two failures are worth keeping because they are opposite errors:\r\n *\r\n * 1. Each wall in two tiers, to give `roughenGeometry` more corners to move.\r\n * The tiers' corners sit at different positions, so the hash moved them\r\n * by different amounts and left a hard seam right round the block — a lid\r\n * on a base.\r\n * 2. One box per wall, roughened harder to compensate. A box has eight\r\n * corners and every face is two triangles, so moving those corners folded\r\n * each wall along its own diagonal. Not stone: creased cardboard.\r\n *\r\n * Both tried to get subdivision out of the wrong thing. The walls are\r\n * `dishedSheetGeometry` — one continuous body with levels along its length,\r\n * which is what that helper's own docstring says the trough wants. The rim\r\n * undulates because each level carries its own height, the wall thickens and\r\n * thins because each carries its own thickness, and there is no join anywhere\r\n * for a seam to open in.\r\n *\r\n * The basin is made the way everything hollow in this kit is made: by not\r\n * filling it. Four walls and a floor, no boolean, corners overlapping.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n boxGeometry,\r\n createKitModel,\r\n createTinter,\r\n dishedSheetGeometry,\r\n jitter,\r\n mergeColoured,\r\n roughenGeometry,\r\n type SheetLevel,\r\n} from '../core/index.ts'\r\n\r\nexport interface StoneTroughConfig {\r\n /** Along the trough (metres). */\r\n readonly length: number\r\n /** Across it (metres). */\r\n readonly width: number\r\n /** Height of the block (metres). */\r\n readonly height: number\r\n /** Wall thickness as a fraction of the width. */\r\n readonly wall: number\r\n /** How full it stands, as a fraction of the basin's depth. 0 is dry. */\r\n readonly water: number\r\n readonly seed: number\r\n}\r\n\r\nexport const stoneTroughDefaults: StoneTroughConfig = {\r\n length: 1.5,\r\n width: 0.58,\r\n height: 0.44,\r\n // Heavy, but not as heavy as I first made it. A trough is a block with a\r\n // dish taken out of it rather than a vessel, and at 0.27 the two walls came\r\n // to more than half the width and the basin shut so far that a three-quarter\r\n // view showed only its inner face. The reference's opening is about three\r\n // fifths of the block across, which is this.\r\n wall: 0.21,\r\n // Nearly full, and that is the reference rather than a preference: its water\r\n // stands a couple of centimetres under the rim. It is also the only level at\r\n // which the water is VISIBLE from anywhere but straight above — at 0.55 the\r\n // surface sits below the near rim and the trough reads as dry from every\r\n // angle anyone will look at it from.\r\n water: 0.85,\r\n seed: 19,\r\n}\r\n\r\nexport type StoneTroughParts = 'block' | 'water'\r\n\r\nexport function createModel(overrides: Partial<StoneTroughConfig> = {}) {\r\n return createKitModel<StoneTroughConfig, 'stone' | 'water', StoneTroughParts>({\r\n id: 'stone-trough',\r\n defaults: stoneTroughDefaults,\r\n slots: ['stone', 'water'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const L = Math.max(0.3, config.length)\r\n const W = Math.max(0.2, config.width)\r\n const H = Math.max(0.15, config.height)\r\n const wall = Math.max(0.02, Math.min(0.4, config.wall)) * W\r\n // The floor is thicker than the walls. A trough wears out from the\r\n // inside and the bottom is what is left when it does.\r\n const floor = H * 0.3\r\n const basin = H - floor\r\n\r\n /**\r\n * One wall as a single sheet, laid along +X and centred on the origin.\r\n *\r\n * `dishedSheetGeometry` builds in the XY plane: levels run along Y,\r\n * `halfWidth` spans X, `thickness` spans Z. One turn about Z lays that\r\n * down so the levels run along the trough and the half-width becomes the\r\n * wall's HEIGHT — which is the whole trick, because every level can then\r\n * carry a different height and the rim undulates on its own, with no\r\n * roughening involved and no seam to tear open.\r\n */\r\n const wallSheet = (len: number, thick: number, tall: number, lift: number): BufferGeometry => {\r\n const steps = 7\r\n const levels: SheetLevel[] = []\r\n for (let i = 0; i < steps; i += 1) {\r\n levels.push({\r\n y: len * (i / (steps - 1)),\r\n halfWidth: (tall / 2) * (1 + jitter(random, 0.035)),\r\n thickness: thick * (0.88 + random() * 0.24),\r\n curve: 0,\r\n })\r\n }\r\n const sheet = dishedSheetGeometry(levels, 2, tint('limestone', lift, 0.85))\r\n sheet.rotateZ(-Math.PI / 2)\r\n sheet.translate(-len / 2, 0, 0)\r\n return sheet\r\n }\r\n\r\n const stone: BufferGeometry[] = []\r\n\r\n /**\r\n * The solid bottom, and the ONLY thing that touches the ground.\r\n *\r\n * Built as four walls standing on the floor, every piece had a face on\r\n * the ground plane, and where the corners overlapped — which is exactly\r\n * where they are meant to overlap — two downward faces shared the plane\r\n * y = 0. The checker found it twice, and the second time was after I had\r\n * \"fixed\" it by lifting the basin floor, which only moved the pairing\r\n * from floor-against-wall to wall-against-wall.\r\n *\r\n * The answer is not to separate the overlaps but to stop there being\r\n * four things down there. A trough IS solid below the water line: one\r\n * block from the ground to the basin, and the walls stand on top of it.\r\n * One piece on the ground, one bottom face, nothing to pair with.\r\n */\r\n const foot = wall * 0.5\r\n stone.push(boxGeometry(\r\n [L - foot, floor, W - foot],\r\n [0, floor / 2, 0],\r\n new Color(tint('limestone', -0.05, 0.7)),\r\n ))\r\n\r\n /**\r\n * The four walls, sunk into that block by DIFFERENT amounts.\r\n *\r\n * Sunk, so their undersides are buried in solid stone rather than\r\n * meeting its top face in a plane. By different amounts, because the\r\n * long pair and the end pair overlap each other at the corners, and two\r\n * buried faces in one plane are still two faces in one plane whether\r\n * anybody can see them or not.\r\n *\r\n * The WALLS are flush with the block's nominal outline and the base is\r\n * tucked in behind them, not the other way round. Standing the walls\r\n * inset on a full-width base put a sharp ledge right round the bottom and\r\n * the thing read as a planter on a concrete plinth. Tucked under, the\r\n * base is invisible from above and the block is one face from rim to\r\n * ground.\r\n *\r\n * Their outer faces cannot land in the base's plane by accident either:\r\n * each level of a sheet carries its own thickness, so a wall's outer\r\n * surface is a run of slightly different planes rather than one.\r\n */\r\n const inset = 0\r\n const longSink = H * 0.06\r\n const endSink = H * 0.095\r\n for (const side of [-1, 1] as const) {\r\n const w = wallSheet(L - inset * 2, wall, basin + longSink, jitter(random, 0.04))\r\n stone.push(w.translate(\r\n 0,\r\n floor - longSink + (basin + longSink) / 2,\r\n (side * (W - wall - inset * 2)) / 2,\r\n ))\r\n }\r\n for (const side of [-1, 1] as const) {\r\n const end = wallSheet(W - wall * 0.5, wall, basin + endSink, 0.03 + jitter(random, 0.04))\r\n // Built along +X like the others, then turned a quarter about Y, which\r\n // sends +X to -Z and leaves the height alone.\r\n end.rotateY(Math.PI / 2)\r\n stone.push(end.translate(\r\n (side * (L - wall - inset * 2)) / 2,\r\n floor - endSink + (basin + endSink) / 2,\r\n 0,\r\n ))\r\n }\r\n\r\n const block = mergeColoured(stone)\r\n // Light, now that the sheets carry the shape themselves. This is grain\r\n // on a face, not the shape of the block.\r\n roughenGeometry(block, wall * 0.09, { salt: 41, scaleY: 0.5 })\r\n\r\n /**\r\n * Put the block back on the floor.\r\n *\r\n * Roughening moves every corner including the ones on the ground, and so\r\n * does the per-level height jitter, so the block ends up balanced on\r\n * three corners with daylight under the rest of it. The same treatment\r\n * the oak's bole gets: flatten everything below the bottom band back to\r\n * exactly zero, leaving all of the irregularity above that line.\r\n */\r\n const position = block.getAttribute('position')\r\n for (let i = 0; i < position.count; i += 1) {\r\n if (position.getY(i) < H * 0.04) position.setY(i, 0)\r\n }\r\n position.needsUpdate = true\r\n block.computeVertexNormals()\r\n\r\n // --- Water ------------------------------------------------------------\r\n // A film, not a body. It is a slab rather than a plane because a plane\r\n // seen edge-on from a low angle vanishes, and a trough is looked into\r\n // from above and along. Sunk into the floor rather than resting on it,\r\n // because a surface laid exactly ON another leaves two faces in a plane.\r\n const level = Math.max(0, Math.min(1, config.water))\r\n const depth = basin * level\r\n const sink = H * 0.035\r\n const water = depth > H * 0.01\r\n ? boxGeometry(\r\n [L - wall * 1.6, depth + sink, W - wall * 1.6],\r\n [0, floor - sink + (depth + sink) / 2, 0],\r\n new Color(tint('water', jitter(random, 0.03), 0.6)),\r\n )\r\n : undefined\r\n\r\n return {\r\n block: { slot: 'stone' as const, geometry: block },\r\n // Present and undefined when it is dry, never missing: the kit clears a\r\n // part's anchor before it checks whether there is anything to put in\r\n // it, so a key that is absent leaves last build's water hanging there.\r\n water: water ? { slot: 'water' as const, geometry: water } : undefined,\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
1877
+ "hash": "b582bcde5ea5ceae090bc16f3879619c1c10819333b571b11f5e1807d45a009a"
1878
+ }
1879
+ ],
1880
+ "meta": {
1881
+ "title": "Stone Trough",
1882
+ "description": "Watering trough cut from one block of weathered limestone, with a heavy rim, a worn basin and a film of standing water.",
1883
+ "category": "Structure",
1884
+ "tags": [
1885
+ "medieval",
1886
+ "lowpoly",
1887
+ "structure",
1888
+ "procedural"
1889
+ ],
1890
+ "controls": {
1891
+ "length": {
1892
+ "type": "number",
1893
+ "label": "Length",
1894
+ "min": 0.8,
1895
+ "max": 2.6,
1896
+ "step": 0.05,
1897
+ "unit": "m"
1898
+ },
1899
+ "width": {
1900
+ "type": "number",
1901
+ "label": "Width",
1902
+ "min": 0.35,
1903
+ "max": 0.95,
1904
+ "step": 0.02,
1905
+ "unit": "m"
1906
+ },
1907
+ "height": {
1908
+ "type": "number",
1909
+ "label": "Height",
1910
+ "min": 0.25,
1911
+ "max": 0.7,
1912
+ "step": 0.02,
1913
+ "unit": "m"
1914
+ },
1915
+ "wall": {
1916
+ "type": "number",
1917
+ "label": "Wall thickness",
1918
+ "min": 0.12,
1919
+ "max": 0.34,
1920
+ "step": 0.01
1921
+ },
1922
+ "water": {
1923
+ "type": "number",
1924
+ "label": "Water level",
1925
+ "min": 0,
1926
+ "max": 1,
1927
+ "step": 0.05
1928
+ },
1929
+ "seed": {
1930
+ "type": "number",
1931
+ "label": "Variation seed",
1932
+ "min": 1,
1933
+ "max": 64,
1934
+ "step": 1
1935
+ }
1936
+ },
1937
+ "materialSlots": [
1938
+ "stone",
1939
+ "water"
1940
+ ],
1941
+ "parts": [
1942
+ "block",
1943
+ "water"
1944
+ ],
1945
+ "sockets": []
1946
+ }
1947
+ },
1948
+ {
1949
+ "name": "stone-well",
1950
+ "type": "vibe3d:model",
1951
+ "title": "Stone Well",
1952
+ "description": "Village well: a drystone kerb, a timber frame over it, and a windlass with rope and bucket.",
1953
+ "dependencies": [
1954
+ "three@>=0.185.0"
1955
+ ],
1956
+ "registryDependencies": [
1957
+ "@medieval-kit/core"
1958
+ ],
1959
+ "files": [
1960
+ {
1961
+ "path": "models/stone-well/model.ts",
1962
+ "target": "{models}/medieval-kit/stone-well/model.ts",
1963
+ "content": "/**\r\n * @medieval-kit/stone-well\r\n *\r\n * A village well: a drystone kerb, a timber frame over it, and a windlass to\r\n * wind the bucket up on.\r\n *\r\n * The kerb is the point of the thing and it is worth saying why. A well is a\r\n * hole; what makes it an OBJECT is the ring of stone built round its mouth, and\r\n * that ring is there for a reason — it keeps the surface water, and whatever the\r\n * surface water is carrying, from running back down into the drinking water. So\r\n * the courses are laid dry, staggered, and stand well proud of the ground.\r\n *\r\n * Four parts:\r\n *\r\n * - `kerb` — the stone ring. Every block is placed individually, because a\r\n * turned cylinder with lines drawn on it reads as a pot.\r\n * - `frame` — two uprights standing OUTSIDE the kerb, and the head beam\r\n * across them with their tenons showing through it.\r\n * - `windlass`— the roller between the uprights, its end collars, and the\r\n * crank. The rope is wound on the middle of the roller.\r\n * - `bucket` — rope and bucket, hung from the roller. Its origin is the\r\n * roller's axis, so `setDepth` lowers it down the shaft without\r\n * moving anything else.\r\n *\r\n * This is the model that brought `stone` into the palette. The kit had no\r\n * masonry at all, which for a medieval catalogue is a hole rather than an\r\n * omission.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n bandGeometry,\r\n boxGeometry,\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n roughenGeometry,\r\n staveGeometry,\r\n taperedBoxGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface StoneWellConfig {\r\n /** Outer radius of the stone kerb (metres). */\r\n readonly radius: number\r\n /** Height of the kerb above the ground (metres). */\r\n readonly wallHeight: number\r\n /** Courses of stone. */\r\n readonly courses: number\r\n /** Blocks in each course. */\r\n readonly blocks: number\r\n /** Height from the ground to the top of the head beam (metres). */\r\n readonly frameHeight: number\r\n /** How far down the bucket hangs. 0 = at the roller, 1 = at the water. */\r\n readonly depth: number\r\n readonly seed: number\r\n}\r\n\r\nexport const stoneWellDefaults: StoneWellConfig = {\r\n radius: 0.62,\r\n wallHeight: 0.66,\r\n courses: 4,\r\n blocks: 11,\r\n frameHeight: 1.95,\r\n depth: 0.34,\r\n seed: 17,\r\n}\r\n\r\nexport type StoneWellParts = 'kerb' | 'frame' | 'windlass' | 'bucket'\r\n\r\nexport interface StoneWellActions {\r\n /** Sends the bucket down (1) or brings it up (0). */\r\n setDepth(t: number): void\r\n depth(): number\r\n /** Winds continuously until stopped. Positive winds up. */\r\n setWinding(speed: number): void\r\n}\r\n\r\nexport function createModel(overrides: Partial<StoneWellConfig> = {}) {\r\n let depth = 0\r\n let winding = 0\r\n let travel = 0\r\n /**\r\n * Where the part's own origin put the anchor.\r\n *\r\n * `origin` places the anchor at the roller's axis at build time, and the\r\n * geometry is authored around it. Writing an absolute `position.y` here\r\n * therefore DESTROYS that placement -- which is what the first version did,\r\n * setting it to 0 and dropping the whole rope and bucket by the height of the\r\n * roller, so the rope started a hand's width below the barrel it hangs from.\r\n * Everything the actions do is now an offset from this.\r\n */\r\n let restY = 0\r\n\r\n return createKitModel<StoneWellConfig, 'stone' | 'oak' | 'iron' | 'cloth', StoneWellParts, StoneWellActions>({\r\n id: 'stone-well',\r\n defaults: stoneWellDefaults,\r\n slots: ['stone', 'oak', 'iron', 'cloth'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const H = config.frameHeight\r\n const floor = -H / 2\r\n const R = config.radius\r\n const wall = R * 0.34\r\n\r\n // --- Kerb ---------------------------------------------------------------\r\n const masonry: BufferGeometry[] = []\r\n const courses = Math.max(1, Math.round(config.courses))\r\n const blocks = Math.max(5, Math.round(config.blocks))\r\n const courseH = config.wallHeight / courses\r\n const midR = R - wall / 2\r\n\r\n for (let c = 0; c < courses; c += 1) {\r\n // Every other course is offset by half a block: a joint running\r\n // straight up through four courses is a crack waiting to happen, and a\r\n // waller knows it. It is also what stops the ring reading as a stack of\r\n // identical rings.\r\n const phase = (c % 2) * 0.5\r\n for (let b = 0; b < blocks; b += 1) {\r\n const a = ((b + phase) / blocks) * Math.PI * 2\r\n const arc = (Math.PI * 2 * midR) / blocks\r\n // Plain tapered boxes, not chamfered ones. A chamfer is 44 triangles\r\n // against 12, and at forty-four blocks that is 1 900 of them spent on\r\n // edges the `roughenGeometry` pass below softens anyway. Stone is the\r\n // one material in this kit where a sharp arris is not wrong.\r\n // ARC first, WALL second. `rotateY(a)` carries the footprint's +Z out\r\n // along the radius and its +X round the circumference, so writing the\r\n // wall thickness first put every block across the ring instead of\r\n // along it: each one stuck a block's length out of a wall a block's\r\n // width thick, and the kerb came out as rubble thrown at a circle.\r\n // The variation is small on purpose. At +-10% on the arc and +-9% on\r\n // the thickness, every block sat at its own radius and its own width:\r\n // the ring came out as rubble rather than as courses, and a waller\r\n // who laid it like that would be looking for other work. A dry wall\r\n // is irregular in the way a hand-cut stone is irregular, which is a\r\n // few percent, not a tenth.\r\n // Blocks are cut a little LONGER than their share of the arc, and\r\n // courses a little taller than their share of the height, so\r\n // neighbours bite into each other.\r\n //\r\n // A flat-faced box on a curve cannot close the joint at its outer\r\n // corner: the chord is shorter than the arc, so anything sized to its\r\n // exact share leaves a wedge of daylight at every joint and the wall\r\n // reads as a ring of separate stones. Letting them overlap is the\r\n // same rule the rest of the kit follows for joints, and it is safe\r\n // here because each block is rotated to its own angle, so no two\r\n // adjoining faces are parallel.\r\n const block = taperedBoxGeometry(\r\n [arc * (1.04 + jitter(random, 0.03)), wall * (0.99 + jitter(random, 0.04))],\r\n [arc * (1.02 + jitter(random, 0.03)), wall * (0.95 + jitter(random, 0.04))],\r\n courseH * (1.03 + jitter(random, 0.025)),\r\n [0, 0, 0],\r\n tint('stone', jitter(random, 0.09)),\r\n )\r\n // Built facing +Z and swung round: rotating first and placing second\r\n // is what keeps each block square to the ring rather than to the axes.\r\n block.rotateY(a)\r\n block.translate(\r\n Math.sin(a) * midR,\r\n floor + courseH * (c + 0.5),\r\n Math.cos(a) * midR,\r\n )\r\n masonry.push(block)\r\n }\r\n }\r\n // Rough dressing. Small: the blocks are already irregular in size, and\r\n // displacing them further only opens the dry joints between them.\r\n const kerb = mergeColoured(masonry)\r\n roughenGeometry(kerb, wall * 0.03, { salt: 13 })\r\n\r\n // --- Frame --------------------------------------------------------------\r\n const timber: BufferGeometry[] = []\r\n const postSide = R * 0.24\r\n const postX = R + postSide * 0.55\r\n const beamH = R * 0.2\r\n const beamY = floor + H - beamH / 2\r\n // The uprights run PAST the beam and show as tenons on top of it.\r\n const postTop = floor + H + beamH * 0.34\r\n\r\n for (const side of [-1, 1]) {\r\n timber.push(chamferedBoxGeometry(\r\n [postSide * 1.06, postSide * 1.06],\r\n [postSide * 0.94, postSide * 0.94],\r\n postTop - floor,\r\n postSide * 0.09,\r\n [side * postX, (floor + postTop) / 2, 0],\r\n tint('oak', -0.03 + jitter(random, 0.04)),\r\n ))\r\n }\r\n timber.push(chamferedBoxGeometry(\r\n [postX * 2 + postSide * 2.2, beamH * 0.86],\r\n [postX * 2 + postSide * 1.9, beamH * 0.78],\r\n beamH,\r\n beamH * 0.09,\r\n [0, beamY, 0],\r\n tint('oak', 0.03),\r\n ))\r\n\r\n // --- Windlass -----------------------------------------------------------\r\n // The roller sits well below the beam: the rope has to come off it and\r\n // fall clear down the middle of the well, and a roller tucked under the\r\n // beam would foul it.\r\n const rollerY = floor + H * 0.62\r\n const rollerR = R * 0.15\r\n const rollerHalf = postX - postSide * 0.25\r\n const gear: BufferGeometry[] = []\r\n const iron: BufferGeometry[] = []\r\n\r\n const rollerProfile: Level[] = [\r\n { y: -rollerHalf, radius: rollerR * 0.86 },\r\n { y: -rollerHalf * 0.88, radius: rollerR },\r\n { y: rollerHalf * 0.88, radius: rollerR },\r\n { y: rollerHalf, radius: rollerR * 0.86 },\r\n ]\r\n const roller = latheGeometry(rollerProfile, 9, [0, 0, 0], tint('oak', 0.02, 1.2))\r\n // Built along Y like every lathe here; +90 about Z lays it along X,\r\n // spanning the gap between the uprights.\r\n roller.rotateZ(Math.PI / 2)\r\n roller.translate(0, rollerY, 0)\r\n gear.push(roller)\r\n\r\n // End collars: the raised shoulders that keep the rope from wandering off\r\n // the barrel and into the bearing.\r\n for (const side of [-1, 1]) {\r\n const collar = bandGeometry(\r\n rollerR * 1.5, 0, rollerR * 0.55, rollerR * 0.5, 9,\r\n tint('oak', -0.06, 1.2), { inner: true },\r\n )\r\n collar.rotateZ(Math.PI / 2)\r\n collar.translate(side * rollerHalf * 0.62, rollerY, 0)\r\n gear.push(collar)\r\n }\r\n\r\n // The rope wound on the barrel, between the collars.\r\n const coil = bandGeometry(\r\n rollerR * 1.34, 0, rollerR * 1.05, rollerR * 0.34, 9,\r\n tint('cloth', -0.04, 1.3), { inner: true },\r\n )\r\n coil.rotateZ(Math.PI / 2)\r\n coil.translate(0, rollerY, 0)\r\n gear.push(coil)\r\n\r\n // Crank: an iron elbow through the upright with a wooden grip on the end.\r\n const crankOut = postX + postSide * 0.9\r\n iron.push(taperedBoxGeometry(\r\n [rollerR * 0.5, rollerR * 0.5],\r\n [rollerR * 0.42, rollerR * 0.42],\r\n postSide * 2.4,\r\n [0, 0, 0],\r\n tint('iron', 0.02, 0.7),\r\n ).rotateZ(Math.PI / 2).translate(postX + postSide * 0.5, rollerY, 0))\r\n iron.push(boxGeometry(\r\n [rollerR * 0.46, rollerR * 2.6, rollerR * 0.46],\r\n [crankOut, rollerY - rollerR * 1.2, 0],\r\n new Color(tint('iron', -0.02, 0.7)),\r\n ))\r\n gear.push(taperedBoxGeometry(\r\n [rollerR * 0.72, rollerR * 0.72],\r\n [rollerR * 0.58, rollerR * 0.58],\r\n rollerR * 2.1,\r\n [0, 0, 0],\r\n tint('oak', 0.05),\r\n ).rotateZ(Math.PI / 2).translate(crankOut + rollerR * 0.9, rollerY - rollerR * 2.2, 0))\r\n\r\n // --- Rope and bucket ----------------------------------------------------\r\n // Authored hanging from the ROLLER'S AXIS, which is where the part's\r\n // origin goes, so lowering the bucket is one rotation-free translation.\r\n const bucketR = R * 0.3\r\n const bucketH = bucketR * 1.5\r\n const fall = H * 0.34 * Math.min(1, Math.max(0, config.depth))\r\n const hanging: BufferGeometry[] = []\r\n\r\n // The rope from barrel to bail. It has to stretch with the drop, so its\r\n // length is derived rather than fixed.\r\n // The rope leaves the COIL, not the axis.\r\n //\r\n // Starting it at y = 0 put its whole surface inside the roller's hollow\r\n // interior, touching nothing -- these are surfaces, not solids, so a rope\r\n // threaded up the middle of a barrel is joined to it only in the sense\r\n // that it is inside it. The support check called the bucket floating and\r\n // it was right. Beginning just inside the coil's inner face means the\r\n // rope crosses the coil's outer face on its way down.\r\n // Inside the coil's MATERIAL, which is the band from 1.0 to 1.34 of the\r\n // roller radius -- not at 0.95, which is the hole through the middle of\r\n // it. Starting there the rope hung in the ring's eye touching nothing;\r\n // starting at 1.15 it begins within the wound rope and crosses its outer\r\n // face on the way down.\r\n const ropeTop = -rollerR * 1.15\r\n const ropeLength = Math.max(rollerR * 1.6, fall) - ropeTop\r\n hanging.push(latheGeometry(\r\n [\r\n { y: ropeTop - ropeLength, radius: rollerR * 0.11 },\r\n { y: ropeTop, radius: rollerR * 0.12 },\r\n ] as Level[],\r\n 6, [0, 0, 0], tint('cloth', -0.08, 1.3),\r\n { capTop: false, capBottom: false },\r\n ))\r\n\r\n // Bail: the iron loop the bucket hangs by.\r\n const bail = bandGeometry(\r\n bucketR * 0.94, 0, rollerR * 0.16, rollerR * 0.13, 9,\r\n tint('iron', 0.04, 0.7), { inner: true },\r\n )\r\n bail.rotateX(Math.PI / 2)\r\n bail.translate(0, ropeTop - ropeLength - bucketR * 0.5, 0)\r\n hanging.push(bail)\r\n\r\n // A small stave bucket, built the way the kit's bucket is: separate\r\n // boards, not a turned cup.\r\n const staves = 11\r\n const step = (Math.PI * 2) / staves\r\n const bucketY = ropeTop - ropeLength - bucketR * 0.5 - bucketH * 0.52\r\n for (let i = 0; i < staves; i += 1) {\r\n hanging.push(staveGeometry(\r\n [\r\n { y: bucketY - bucketH / 2, radius: bucketR * 0.82 },\r\n { y: bucketY + bucketH / 2, radius: bucketR },\r\n ] as Level[],\r\n i * step + step * 0.06,\r\n (i + 1) * step - step * 0.06,\r\n bucketR * 0.14,\r\n tint('oak', jitter(random, 0.06)),\r\n ))\r\n }\r\n for (const at of [-0.34, 0.32]) {\r\n const hoop = bandGeometry(\r\n bucketR * (at < 0 ? 0.88 : 1.01), bucketY + bucketH * at,\r\n bucketH * 0.1, bucketR * 0.05, staves,\r\n tint('iron', 0.03, 0.7),\r\n )\r\n hanging.push(hoop)\r\n }\r\n hanging.push(latheGeometry(\r\n [\r\n { y: bucketY - bucketH * 0.44, radius: bucketR * 0.78 },\r\n { y: bucketY - bucketH * 0.38, radius: bucketR * 0.8 },\r\n ] as Level[],\r\n staves, [0, 0, 0], tint('oak', -0.1),\r\n ))\r\n\r\n return {\r\n kerb: { slot: 'stone' as const, geometry: kerb },\r\n frame: { slot: 'oak' as const, geometry: mergeColoured(timber) },\r\n windlass: {\r\n slot: 'oak' as const,\r\n geometry: mergeColoured(gear),\r\n extras: [{ slot: 'iron' as const, geometry: mergeColoured(iron) }],\r\n },\r\n bucket: {\r\n slot: 'oak' as const,\r\n geometry: mergeColoured(hanging),\r\n origin: [0, rollerY, 0] as const,\r\n },\r\n }\r\n },\r\n\r\n actions: ({ parts, getConfig }) => {\r\n depth = getConfig().depth\r\n travel = 0\r\n restY = parts.bucket.anchor.position.y\r\n return {\r\n setDepth: (t) => {\r\n winding = 0\r\n depth = Math.min(1, Math.max(0, t))\r\n travel = 0\r\n },\r\n depth: () => depth,\r\n setWinding: (speed) => { winding = speed },\r\n }\r\n },\r\n\r\n update: (deltaSeconds, { parts, getConfig }) => {\r\n if (winding === 0) return\r\n // Winding moves the bucket by TRANSLATION rather than by rebuilding the\r\n // rope, so the action costs nothing per frame. The rope's own length is\r\n // fixed by `depth` at build time; between rebuilds the bucket slides on\r\n // it, which is exactly what a bucket on a rope does.\r\n const H = getConfig().frameHeight\r\n travel = Math.min(H * 0.34, Math.max(-H * 0.2, travel + winding * deltaSeconds * H * 0.18))\r\n parts.bucket.anchor.position.y = restY + travel\r\n },\r\n }, overrides)\r\n}\r\n",
1964
+ "hash": "9ebb0b37eacf8258944f044bde2a4e5de0ef8d5f54d78f50676fbb70a01d1490"
1965
+ }
1966
+ ],
1967
+ "meta": {
1968
+ "title": "Stone Well",
1969
+ "description": "Village well: a drystone kerb, a timber frame over it, and a windlass with rope and bucket.",
1970
+ "category": "Structure",
1971
+ "tags": [
1972
+ "medieval",
1973
+ "lowpoly",
1974
+ "structure",
1975
+ "procedural"
1976
+ ],
1977
+ "controls": {
1978
+ "radius": {
1979
+ "type": "number",
1980
+ "label": "Kerb radius",
1981
+ "min": 0.35,
1982
+ "max": 1.1,
1983
+ "step": 0.01,
1984
+ "unit": "m"
1985
+ },
1986
+ "wallHeight": {
1987
+ "type": "number",
1988
+ "label": "Kerb height",
1989
+ "min": 0.3,
1990
+ "max": 1.1,
1991
+ "step": 0.02,
1992
+ "unit": "m"
1993
+ },
1994
+ "courses": {
1995
+ "type": "number",
1996
+ "label": "Stone courses",
1997
+ "min": 2,
1998
+ "max": 7,
1999
+ "step": 1
2000
+ },
2001
+ "blocks": {
2002
+ "type": "number",
2003
+ "label": "Blocks per course",
2004
+ "min": 6,
2005
+ "max": 18,
2006
+ "step": 1
2007
+ },
2008
+ "frameHeight": {
2009
+ "type": "number",
2010
+ "label": "Frame height",
2011
+ "min": 1.2,
2012
+ "max": 2.8,
2013
+ "step": 0.05,
2014
+ "unit": "m"
2015
+ },
2016
+ "depth": {
2017
+ "type": "number",
2018
+ "label": "Bucket drop",
2019
+ "min": 0,
2020
+ "max": 1,
2021
+ "step": 0.02
2022
+ },
2023
+ "seed": {
2024
+ "type": "number",
2025
+ "label": "Variation seed",
2026
+ "min": 1,
2027
+ "max": 64,
2028
+ "step": 1
2029
+ }
2030
+ },
2031
+ "materialSlots": [
2032
+ "stone",
2033
+ "oak",
2034
+ "iron",
2035
+ "cloth"
2036
+ ],
2037
+ "parts": [
2038
+ "kerb",
2039
+ "frame",
2040
+ "windlass",
2041
+ "bucket"
2042
+ ],
2043
+ "sockets": []
2044
+ }
2045
+ },
2046
+ {
2047
+ "name": "straw-broom",
2048
+ "type": "vibe3d:model",
2049
+ "title": "Straw Broom",
2050
+ "description": "Besom: hazel shaft, a brush bundle built from three concentric rings, willow bindings.",
2051
+ "dependencies": [
2052
+ "three@>=0.185.0"
2053
+ ],
2054
+ "registryDependencies": [
2055
+ "@medieval-kit/core"
2056
+ ],
2057
+ "files": [
2058
+ {
2059
+ "path": "models/straw-broom/model.ts",
2060
+ "target": "{models}/medieval-kit/straw-broom/model.ts",
2061
+ "content": "/**\r\n * @medieval-kit/straw-broom\r\n *\r\n * Besom: a hazel-rod handle with a bundle of birch twigs bound to its end by a\r\n * withy tie. The period's broom really was this simple, and that is exactly why\r\n * it turns up in every interior scene.\r\n *\r\n * THIRD attempt. In the first, the bristles were individual rods and the bundle\r\n * looked like a whisk; in the second I moved to flat sheaves, which brought\r\n * mass, but what came out in render still was not a broom: \"a lampshade pushed\r\n * onto a handle\", \"a closed umbrella\". The cause was single and structural —\r\n * all the sheaves sat on ONE SINGLE RING, i.e. the bundle was a hollow cone\r\n * SHELL. On top of that, since it gathered at a single point up top and opened\r\n * downwards, its silhouette was a cone, whereas a besom is a slightly flared\r\n * CYLINDER.\r\n *\r\n * Three changes fix this:\r\n *\r\n * - The bundle is built from three CONCENTRIC rings (6 / 10 / 16). The inner\r\n * rings have less slope, so the middle fills in. The shell becomes mass.\r\n * - The flare is no longer a hand-tuned angle: the tie radius and the tip\r\n * radius are given, and the slope is DERIVED from the two. What determines\r\n * the silhouette is two directly measurable numbers.\r\n * - The radius of the bindings comes from the same source as the bundle's\r\n * radius AT THAT HEIGHT. There used to be a separate guessed formula, and\r\n * the binding hung in the air around the bundle.\r\n */\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n arcBarGeometry,\r\n bandGeometry,\r\n bendGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n roughenGeometry,\r\n taperedBoxGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface StrawBroomConfig {\r\n /** Total length (metres). */\r\n readonly length: number\r\n /** Shaft radius (metres). */\r\n readonly shaftRadius: number\r\n /** Length of the bundle, as a fraction of the total length. */\r\n readonly headLength: number\r\n /** Bundle radius at the tie (metres). */\r\n readonly tieRadius: number\r\n /** Bundle radius at the sweeping end (metres). The flare derives from these two. */\r\n readonly tipRadius: number\r\n /** Total number of twigs. */\r\n readonly bristles: number\r\n /** How many turns of binding. */\r\n readonly bindings: number\r\n readonly seed: number\r\n}\r\n\r\nexport const strawBroomDefaults: StrawBroomConfig = {\r\n length: 1.2,\r\n shaftRadius: 0.018,\r\n // Measured from the binding down, the old default left only 36% of the\r\n // broom as bundle against roughly half in the reference -- the head looked\r\n // stuck on the end of an over-long stick.\r\n headLength: 0.52,\r\n tieRadius: 0.058,\r\n // A 20-degree spray, not a 5-degree one. Derived rather than guessed:\r\n // atan((tip - tie) / span), which at the old 0.102 came to five degrees and\r\n // gave a narrow cone. The reference fans out until the spread at the sweeping\r\n // end is comparable to the length of the bundle itself.\r\n tipRadius: 0.17,\r\n bristles: 46,\r\n bindings: 3,\r\n seed: 59,\r\n}\r\n\r\nexport type StrawBroomParts = 'shaft' | 'bristles' | 'bindings'\r\n\r\nexport function createModel(overrides: Partial<StrawBroomConfig> = {}) {\r\n return createKitModel<StrawBroomConfig, 'oak' | 'straw' | 'cloth', StrawBroomParts>({\r\n id: 'straw-broom',\r\n // The auto-derived values stay too coarse for a 1.2 m object: the ambient\r\n // occlusion darkens the bundle like a blanket, and the mottle cell drops to\r\n // a couple of samples per sheaf. Both are tied to the scale of a twig.\r\n occlusion: { radius: 0.055 },\r\n mottle: { cell: 0.022 },\r\n defaults: strawBroomDefaults,\r\n slots: ['oak', 'straw', 'cloth'],\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const half = config.length / 2\r\n const headLength = config.length * config.headLength\r\n const headTop = -half + headLength\r\n\r\n // --- The bundle's shape, from a single source ----------------------------\r\n const tieY = headTop - config.length * 0.017\r\n const sweepY = -half + config.length * 0.025\r\n const span = Math.max(0.02, tieY - sweepY)\r\n const tieRadius = config.tieRadius\r\n const tipRadius = Math.max(tieRadius * 1.05, config.tipRadius)\r\n // `tieRadius` and `tipRadius` are the bundle's OUTER radius — what the\r\n // user would measure looking at a broom. The rings are back-computed from\r\n // it: outer surface = ring radius + half the sheaf's width.\r\n const halfWidth = config.shaftRadius * 0.95\r\n /** The bundle's OUTER radius at height `y`. The bindings use this too. */\r\n const bundleRadius = (y: number): number =>\r\n tieRadius + (tipRadius - tieRadius) * Math.min(1, Math.max(0, (tieY - y) / span))\r\n\r\n // A broom has a FACE: held in the hand, the same side always meets the\r\n // floor and that side wears down more. The direction is chosen once,\r\n // driven by the seed.\r\n const faceAngle = random() * Math.PI * 2\r\n\r\n // --- Twigs: three concentric rings -----------------------------------------\r\n // The inner rings have little slope, the outer one has the full slope.\r\n // That is why the inside of the bundle fills in; had I left a single ring\r\n // it would be a shell again.\r\n const total = Math.max(6, Math.round(config.bristles))\r\n const core = Math.max(config.shaftRadius, tieRadius - halfWidth)\r\n const rings = [\r\n { share: 0.19, radius: core * 0.34, slope: 0.3, offset: 0 },\r\n { share: 0.31, radius: core * 0.62, slope: 0.68, offset: Math.PI / 7 },\r\n { share: 0.5, radius: core * 0.92, slope: 1, offset: Math.PI / 14 },\r\n ]\r\n const fullFlare = Math.atan((tipRadius - tieRadius) / span)\r\n\r\n const bristles: BufferGeometry[] = []\r\n for (const ring of rings) {\r\n const n = Math.max(3, Math.round(total * ring.share))\r\n for (let i = 0; i < n; i += 1) {\r\n const angle = (i / n) * Math.PI * 2 + ring.offset + jitter(random, 0.1)\r\n const flare = fullFlare * ring.slope * (0.92 + random() * 0.16)\r\n // The worn face is shorter: the asymmetry comes from here, not from\r\n // flattening the bundle. (The flat fan broom is a 19th-century Shaker\r\n // invention, an anachronism here.)\r\n const wear = 1 + 0.1 * Math.cos(angle - faceAngle)\r\n // Lengths vary by nearly a third, not by three percent.\r\n //\r\n // At +-0.03 every twig finished within a few millimetres of its\r\n // neighbours and the bundle ended in a straight cut across the\r\n // bottom. That single edge is what made the head read as a lampshade\r\n // pushed onto a stick: no bundle of cut birch ends level, and the\r\n // ragged, feathered tip is most of what identifies a besom in a\r\n // photograph. Nothing is left floating by this -- every twig is held\r\n // at the tie, and it is only the free end that varies.\r\n // The spread runs DOWN from 1.0, never above it. Centring it on 1.0\r\n // let the longest twigs overshoot the sweeping end and the model came\r\n // out 1.32 m for a declared length of 1.2 -- caught by the size\r\n // guard, and a real inconsistency, since `length` is supposed to mean\r\n // the whole broom. The span is the longest twig; the rest fall short\r\n // of it by up to a third.\r\n const length = (span / Math.cos(flare)) * wear * (0.68 + random() * 0.32)\r\n\r\n // Narrower, and more of them. At 1.7 shaft radii each sheaf was a\r\n // 31 mm slat, and thirty-two slats read as a fan of laths rather\r\n // than as a bundle of twigs. Trading width for count keeps roughly\r\n // the same mass in the bundle while making the individual pieces\r\n // read at the right scale.\r\n const width = config.shaftRadius * (1.05 + random() * 0.24)\r\n const depth = config.shaftRadius * (0.6 + random() * 0.14)\r\n // The cross-section TAPERS DOWNWARDS. Previously it was exactly the\r\n // reverse — the lower end was both wider and thicker, i.e. the bundle\r\n // swelled towards the bottom; whereas the sweeping end wears thin\r\n // over the years.\r\n const sheaf = taperedBoxGeometry(\r\n [width * 1.12, depth * 0.62],\r\n [width, depth],\r\n length,\r\n [0, -length / 2, 0], // top end AT THE ORIGIN: the bundle hangs from the tie\r\n tint('straw', 0.02, 1.5),\r\n tint('strawPale', 0.12, 1.5),\r\n )\r\n roughenGeometry(sheaf, config.shaftRadius * 0.09, { salt: i, scaleY: 0.4 })\r\n\r\n // THE SIGN: the sheaf extends along -Y. `rotateX(+f)` throws its end\r\n // towards -Z, and the following `rotateY(angle)` turns -Z TOWARDS THE\r\n // AXIS — so a positive sign flares the bundle INWARDS, not outwards.\r\n // This was the case in the previous two versions too: the sheaves\r\n // crossed the axis and were flung to the far side, which is why the\r\n // bundle came out a hollow shell. Caught by measurement, not by eye —\r\n // the bundle's width came out 0.13 m where 0.23 m was expected.\r\n sheaf.rotateZ(jitter(random, 0.09))\r\n sheaf.rotateX(-flare)\r\n sheaf.rotateY(angle)\r\n sheaf.translate(\r\n Math.sin(angle) * ring.radius,\r\n tieY + jitter(random, config.length * 0.005),\r\n Math.cos(angle) * ring.radius,\r\n )\r\n bristles.push(sheaf)\r\n }\r\n }\r\n\r\n // --- Collar: the cut butts left above the tie ------------------------------\r\n // It both covers where the shaft enters the bundle and is the cheapest way\r\n // of saying \"this is a bundle\": short stubs tipped upwards.\r\n for (let i = 0; i < 9; i += 1) {\r\n const angle = i * 2.399963 // golden angle: no rows form anywhere\r\n const stub = config.shaftRadius * (1.6 + random() * 1.2)\r\n const piece = taperedBoxGeometry(\r\n [config.shaftRadius * 0.62, config.shaftRadius * 0.4],\r\n [config.shaftRadius * 0.5, config.shaftRadius * 0.34],\r\n stub,\r\n [0, stub / 2, 0], // centre at the LOWER end: this piece juts upwards\r\n tint('strawPale', 0.16, 1.4),\r\n tint('straw', 0.04, 1.4),\r\n )\r\n // This piece extends along +Y, so the sign is the OPPOSITE of the\r\n // sheaves': a positive value throws its end towards +Z and `rotateY`\r\n // turns it outwards.\r\n piece.rotateZ(jitter(random, 0.08))\r\n piece.rotateX(0.28 + random() * 0.24)\r\n piece.rotateY(angle)\r\n piece.translate(\r\n Math.sin(angle) * tieRadius * 0.8,\r\n tieY + config.length * 0.008,\r\n Math.cos(angle) * tieRadius * 0.8,\r\n )\r\n bristles.push(piece)\r\n }\r\n\r\n // --- Bindings --------------------------------------------------------------\r\n const turns = Math.max(0, Math.round(config.bindings))\r\n const bindings: BufferGeometry[] = []\r\n for (let i = 0; i < turns; i += 1) {\r\n const y = tieY - config.length * (0.01 + i * 0.042)\r\n // The binding sits on the bundle's OUTER surface and bites into it a\r\n // little. Originally the radius was computed from the ring radius, so\r\n // the binding stayed INSIDE the sheaves and was never visible at all.\r\n const radius = bundleRadius(y) - config.shaftRadius * 0.16\r\n bindings.push(bandGeometry(\r\n radius, y, config.shaftRadius * 1.15, config.shaftRadius * 0.42, 12,\r\n tint('cloth', -0.28, 0.9), { inner: true },\r\n ))\r\n }\r\n if (turns > 0) {\r\n // The tucked withy end: the one piece that shows how the tie itself is\r\n // fastened. Offset so it does not stay parallel to the band's 30° facet.\r\n const withy = arcBarGeometry(\r\n bundleRadius(tieY) + config.shaftRadius * 0.24, config.shaftRadius * 0.32,\r\n -0.5, 0.9, 3, [0, 0, 0], tint('cloth', -0.34, 0.7),\r\n )\r\n withy.rotateX(Math.PI / 2)\r\n withy.rotateY(0.26 + Math.PI / 12)\r\n withy.translate(0, tieY - config.length * 0.006, 0)\r\n bindings.push(withy)\r\n }\r\n\r\n // --- Shaft -------------------------------------------------------------------\r\n // Not a turned spindle but a hazel rod cut in the forest: the radius\r\n // wavers along its length, there is a grip swell where the hand holds it,\r\n // and the top end is whittled. And it is slightly bent — a straight rod\r\n // always reads as manufactured.\r\n //\r\n // Built AT THE ORIGIN and bent, THEN translated: bending it at its final\r\n // coordinates would fling the whole stick away.\r\n const shaftBottom = headTop - config.length * 0.1\r\n const shaftLength = half - shaftBottom\r\n const r = config.shaftRadius\r\n const shaftLevels: Level[] = [\r\n { y: -shaftLength / 2, radius: r * 0.26 },\r\n { y: -shaftLength / 2 + shaftLength * 0.07, radius: r * 0.82 },\r\n { y: -shaftLength / 2 + shaftLength * 0.16, radius: r * 1.02 },\r\n { y: shaftLength * 0.06, radius: r * 0.93 },\r\n { y: shaftLength / 2 - shaftLength * 0.07, radius: r * 1.14 }, // grip\r\n { y: shaftLength / 2, radius: r * 0.84 },\r\n ].map((level) => ({ y: level.y, radius: level.radius * (1 + jitter(random, 0.05)) }))\r\n\r\n const shaft = latheGeometry(shaftLevels, 6, [0, 0, 0], tint('oak', -0.05), {\r\n colourTop: tint('oak', 0.05),\r\n })\r\n bendGeometry(shaft, jitter(random, 0.22) / shaftLength)\r\n shaft.rotateY(random() * Math.PI * 2)\r\n shaft.translate(0, shaftBottom + shaftLength / 2, 0)\r\n\r\n return {\r\n shaft: { slot: 'oak' as const, geometry: mergeColoured([shaft]) },\r\n bristles: { slot: 'straw' as const, geometry: mergeColoured(bristles) },\r\n bindings: bindings.length > 0\r\n ? { slot: 'cloth' as const, geometry: mergeColoured(bindings) }\r\n : undefined,\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
2062
+ "hash": "24e819d136d3e04a39885a07ce0314a79146b88a926ae2c28414bfcd4ab11928"
2063
+ }
2064
+ ],
2065
+ "meta": {
2066
+ "title": "Straw Broom",
2067
+ "description": "Besom: hazel shaft, a brush bundle built from three concentric rings, willow bindings.",
2068
+ "category": "Tools",
2069
+ "tags": [
2070
+ "medieval",
2071
+ "lowpoly",
2072
+ "tools",
2073
+ "household",
2074
+ "procedural"
2075
+ ],
2076
+ "controls": {
2077
+ "length": {
2078
+ "type": "number",
2079
+ "label": "Length",
2080
+ "min": 0.7,
2081
+ "max": 1.8,
2082
+ "step": 0.02,
2083
+ "unit": "m"
2084
+ },
2085
+ "shaftRadius": {
2086
+ "type": "number",
2087
+ "label": "Shaft thickness",
2088
+ "min": 0.01,
2089
+ "max": 0.03,
2090
+ "step": 0.001,
2091
+ "unit": "m"
2092
+ },
2093
+ "headLength": {
2094
+ "type": "number",
2095
+ "label": "Bundle length",
2096
+ "min": 0.2,
2097
+ "max": 0.55,
2098
+ "step": 0.01
2099
+ },
2100
+ "tieRadius": {
2101
+ "type": "number",
2102
+ "label": "Binding radius",
2103
+ "min": 0.03,
2104
+ "max": 0.09,
2105
+ "step": 0.002,
2106
+ "unit": "m"
2107
+ },
2108
+ "tipRadius": {
2109
+ "type": "number",
2110
+ "label": "Tip radius",
2111
+ "min": 0.04,
2112
+ "max": 0.26,
2113
+ "step": 0.005,
2114
+ "unit": "m"
2115
+ },
2116
+ "bristles": {
2117
+ "type": "number",
2118
+ "label": "Bristle count",
2119
+ "min": 8,
2120
+ "max": 60,
2121
+ "step": 2
2122
+ },
2123
+ "bindings": {
2124
+ "type": "number",
2125
+ "label": "Binding count",
2126
+ "min": 0,
2127
+ "max": 5,
2128
+ "step": 1
2129
+ },
2130
+ "seed": {
2131
+ "type": "number",
2132
+ "label": "Variation seed",
2133
+ "min": 1,
2134
+ "max": 64,
2135
+ "step": 1
2136
+ }
2137
+ },
2138
+ "materialSlots": [
2139
+ "oak",
2140
+ "straw",
2141
+ "cloth"
2142
+ ],
2143
+ "parts": [
2144
+ "shaft",
2145
+ "bristles",
2146
+ "bindings"
2147
+ ],
2148
+ "sockets": []
2149
+ }
2150
+ },
2151
+ {
2152
+ "name": "tavern-sign",
2153
+ "type": "vibe3d:model",
2154
+ "title": "Tavern Sign",
2155
+ "description": "Wooden board hung by chain from a forged iron bracket. Pushed, it swings long and lazily.",
2156
+ "dependencies": [
2157
+ "three@>=0.185.0"
2158
+ ],
2159
+ "registryDependencies": [
2160
+ "@medieval-kit/core"
2161
+ ],
2162
+ "files": [
2163
+ {
2164
+ "path": "models/tavern-sign/model.ts",
2165
+ "target": "{models}/medieval-kit/tavern-sign/model.ts",
2166
+ "content": "/**\r\n * @medieval-kit/tavern-sign\r\n *\r\n * A wooden board swinging from the end of a forged iron bracket fixed to a wall.\r\n *\r\n * Since literacy was rare, a period sign carried a PICTURE, not TEXT: a garland\r\n * meant the vintner, a boot the cobbler, a mortar the apothecary. So the model\r\n * gives the board itself, not the device on it — the consumer attaches whatever\r\n * they want to `parts.board.anchor`. This is exactly what the protocol's idea\r\n * of semantic parts is good for.\r\n *\r\n * The swing is a different pendulum from the bell's: here the restoring force\r\n * is not gravity but the friction of two rings. So a sign at rest always hangs\r\n * STRAIGHT, but once pushed it oscillates for a long time. Put next to the\r\n * bell's hard, fast damping, the difference between the two reads immediately.\r\n */\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n arcBarGeometry,\r\n bandGeometry,\r\n boxGeometry,\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n mergeColoured,\r\n taperedBoxGeometry,\r\n} from '../core/index.ts'\r\n\r\nexport interface TavernSignConfig {\r\n /** Board width (metres). */\r\n readonly width: number\r\n /** Board height (metres). */\r\n readonly height: number\r\n /** How far the bracket projects from the wall (metres). */\r\n readonly reach: number\r\n /** Height of the post the arm is bolted to (metres). */\r\n readonly postHeight: number\r\n /** Length of the hanging chain (metres). */\r\n readonly drop: number\r\n /** Number of planks. */\r\n readonly plankCount: number\r\n /** How fast the swing damps out. */\r\n readonly damping: number\r\n readonly seed: number\r\n}\r\n\r\nexport const tavernSignDefaults: TavernSignConfig = {\r\n width: 0.72,\r\n height: 0.52,\r\n reach: 0.62,\r\n postHeight: 2.15,\r\n // Long enough to see the chain. At 0.12 the board hung almost against the\r\n // bracket and the five links that connect them were a smudge; the chain is\r\n // half of what makes a hanging sign read as hanging.\r\n drop: 0.28,\r\n plankCount: 3,\r\n damping: 0.42,\r\n seed: 73,\r\n}\r\n\r\n// The chains are NOT a separate part: they have to swing together with the\r\n// board, so they live as its `extras` body.\r\nexport type TavernSignParts = 'bracket' | 'board' | 'post'\r\n\r\nexport interface TavernSignActions {\r\n /** Pushes the sign: wind, or someone coming out of the door. */\r\n push(strength?: number): void\r\n still(): void\r\n /** Current swing angle (radians). */\r\n lean(): number\r\n}\r\n\r\nexport function createModel(overrides: Partial<TavernSignConfig> = {}) {\r\n let angle = 0\r\n let velocity = 0\r\n\r\n return createKitModel<TavernSignConfig, 'oak' | 'iron', TavernSignParts, TavernSignActions>({\r\n id: 'tavern-sign',\r\n defaults: tavernSignDefaults,\r\n slots: ['oak', 'iron'],\r\n\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const bar = config.reach * 0.035\r\n // Axis of rotation: the line where the chains leave the bracket. The\r\n // board and the chains are written RELATIVE to this point.\r\n const pivotY = config.height * 0.5 + config.drop\r\n const armY = pivotY\r\n // Where the two chains meet the arm, measured ALONG it.\r\n const hangA = config.reach * 0.34\r\n const hangB = config.reach * 0.92\r\n\r\n // --- Bracket -----------------------------------------------------------\r\n // Sits against the wall at Z zero and reaches out along +Z.\r\n // --- Post ------------------------------------------------------------\r\n // The bracket used to be bolted to a wall the model did not contain, so\r\n // the whole sign floated in the air. A standing signpost is\r\n // period-correct — plenty of inn signs were on posts rather than walls —\r\n // and it is the version that can actually be dropped into a scene,\r\n // because it holds itself up.\r\n // A signpost is a structural timber, not a stake. At reach*0.1 it came\r\n // out 62 mm square carrying a 0.72 m board two metres up -- thinner than\r\n // the board is thick, and part of the same across-the-kit habit of\r\n // under-sizing timber that the bench, the table and the fence all share.\r\n const postWidth = config.reach * 0.19\r\n const postTop = pivotY + config.reach * 0.1\r\n const postBase = postTop - config.postHeight\r\n const timber: BufferGeometry[] = [chamferedBoxGeometry(\r\n [postWidth, postWidth],\r\n [postWidth * 0.82, postWidth * 0.82],\r\n config.postHeight,\r\n postWidth * 0.12,\r\n [0, (postTop + postBase) / 2, 0],\r\n tint('oak', -0.06),\r\n tint('oak', 0.04),\r\n )]\r\n // Soil mound at the foot, the same device the fence uses: it explains how\r\n // the post stays upright and hides where it meets the ground.\r\n const soil = tint('oak', -0.2)\r\n soil.offsetHSL(0, -0.28, 0)\r\n timber.push(taperedBoxGeometry(\r\n [postWidth * 3.2, postWidth * 3.2],\r\n [postWidth * 1.6, postWidth * 1.6],\r\n postWidth * 1.1,\r\n [0, postBase + postWidth * 0.4, 0],\r\n soil,\r\n ))\r\n\r\n const iron: BufferGeometry[] = []\r\n iron.push(boxGeometry(\r\n [bar * 4.4, config.height * 0.9, bar * 1.6],\r\n [0, armY - config.height * 0.1, bar * 0.4],\r\n tint('iron', -0.05, 0.7),\r\n ))\r\n // The horizontal arm.\r\n iron.push(boxGeometry(\r\n [bar * 1.5, bar * 1.7, config.reach],\r\n [0, armY + bar * 0.5, config.reach / 2],\r\n tint('iron', 0.02, 0.7),\r\n ))\r\n // Brace: the curved support tying the arm back to the wall. Without it\r\n // the arm looks like it is hanging in the air and the eye's question of\r\n // \"what is holding this up\" goes unanswered.\r\n // A real quarter-arc, not a sheared box.\r\n //\r\n // This and the curl below were both `boxGeometry` put through\r\n // `bendGeometry`, and a box has two levels in Y. `bendGeometry` cannot\r\n // bend two levels -- it shears them -- so neither piece was ever a\r\n // curve. The sheared curl is what pushed the bracket's top to 0.579\r\n // while the post it is bolted to ends at 0.442: an arm standing above\r\n // its own post, which is why the sign read as broken rather than as\r\n // ironwork.\r\n //\r\n // `arcBarGeometry` sweeps a real arc. Its ends are placed rather than\r\n // corrected: one inside the post, one inside the arm.\r\n const braceRadius = config.reach * 0.45\r\n const brace = arcBarGeometry(\r\n braceRadius, bar * 1.2, -Math.PI / 2, 0, 7, [0, 0, 0],\r\n tint('iron', -0.02, 0.7),\r\n )\r\n // Built in XY; -90 degrees about Y carries +X into +Z, standing the arc\r\n // in the plane the bracket occupies.\r\n brace.rotateY(-Math.PI / 2)\r\n brace.translate(0, armY + bar * 0.5, bar)\r\n iron.push(brace)\r\n // The curl at the end of the arm: the signature of forged iron.\r\n const curlRadius = config.reach * 0.11\r\n const curl = arcBarGeometry(\r\n curlRadius, bar * 0.9, -2.6, 1.9, 9, [0, 0, 0],\r\n tint('iron', 0.06, 0.7),\r\n )\r\n curl.rotateY(-Math.PI / 2)\r\n // Hung under the end of the arm, where a smith finishes the bar.\r\n curl.translate(0, armY + bar * 0.5 - curlRadius, config.reach - bar)\r\n iron.push(curl)\r\n\r\n // No cross-bar. The chains hang from the ARM.\r\n //\r\n // The board used to hang across the arm rather than along it -- its width\r\n // ran in X while the arm projected in Z -- so the sign stuck out sideways\r\n // from the end of the bracket. That is not how an inn sign hangs: the arm\r\n // comes out from the post and the board hangs UNDER it, in line with it,\r\n // so it faces the road on both sides and can be read walking past.\r\n //\r\n // Once the board is turned the right way its two chains hang from two\r\n // points along the arm, and the arm is the cross-bar. The extra piece\r\n // that used to be needed to reach chains set either side of a\r\n // centre-line bar is not needed at all.\r\n\r\n // --- Chains ----------------------------------------------------------------\r\n // They have to swing TOGETHER with the board, hence the board's `extras`\r\n // body. Were they a separate part, the chain would stay bolt upright\r\n // while the board swung.\r\n //\r\n // The link COUNT is derived from the drop, not fixed. It used to be three\r\n // links whose radius scaled with `config.drop`, which is scale-invariant\r\n // in the worst way: the spacing between links was always 0.5·drop while\r\n // their diameter was always 0.32·drop, so the chain never actually\r\n // interlocked at any setting. At the default drop the gap was small\r\n // enough that nothing caught it; at the top of the slider the board came\r\n // off the bracket completely.\r\n //\r\n // Now the link is sized from the iron stock and enough of them are made\r\n // to overlap across whatever drop is asked for.\r\n // The COUNT is fixed and the RADIUS is derived, not the other way round.\r\n // Deriving the count from `drop` guaranteed the links overlap, but it also\r\n // made the triangle count depend on a continuous slider — which meant the\r\n // showcase could not morph this model and had to fall back to visibly\r\n // stepped rebuilds. Fixing the count and growing the links instead keeps\r\n // both properties: always interlocked, always the same topology.\r\n const linkCount = 5\r\n const linkRadius = Math.max(bar * 0.9, (config.drop / (linkCount - 1)) * 0.62)\r\n const links: BufferGeometry[] = []\r\n for (const side of [-1, 1]) {\r\n const count = linkCount\r\n for (let i = 0; i < count; i += 1) {\r\n // The first link sits INSIDE the arm and the last inside the board's\r\n // ear, so the chain is a real connection rather than two things at\r\n // roughly the same height. Spacing the links evenly across the drop\r\n // (the old `(i + 0.5) / count`) left the top link short of the arm by\r\n // drop/6, which detached the entire board.\r\n const y = -config.drop * (i / Math.max(1, count - 1))\r\n const ring = bandGeometry(linkRadius, 0, bar * 0.6, bar * 0.35, 6,\r\n tint('iron', jitter(random, 0.05), 0.7), { inner: true })\r\n // Each link is indexed a little differently about its own axis.\r\n //\r\n // The links are six-sided and they interlock, so consecutive ones\r\n // share space; with every link built at the same phase their facets\r\n // came out parallel and, where they overlapped, coplanar. Lengthening\r\n // the drop made this visible because the links grew with it -- radius\r\n // 0.0195 to 0.0434 -- and began to overlap in earnest. The offset is\r\n // deterministic rather than random so the seeded stream is untouched,\r\n // and a real chain does not index its links either.\r\n ring.rotateY(i * 0.37 + side * 0.19)\r\n // Successive links must pass through at right angles — that is what a\r\n // chain is.\r\n ring.rotateX(i % 2 === 0 ? Math.PI / 2 : 0)\r\n ring.rotateZ(i % 2 === 0 ? 0 : Math.PI / 2)\r\n ring.translate(0, y, side < 0 ? hangA - config.reach * 0.62 : hangB - config.reach * 0.62)\r\n links.push(ring)\r\n }\r\n }\r\n\r\n // --- Board -------------------------------------------------------------------\r\n const planks = Math.max(1, Math.round(config.plankCount))\r\n const plankHeight = config.height / planks\r\n const board: BufferGeometry[] = []\r\n for (let i = 0; i < planks; i += 1) {\r\n const y = -config.drop - config.height + plankHeight * (i + 0.5)\r\n board.push(chamferedBoxGeometry(\r\n [config.height * 0.055, config.width],\r\n [config.height * 0.05, config.width * 0.997],\r\n plankHeight * 0.94,\r\n config.height * 0.012,\r\n [0, y, 0],\r\n tint('oak', jitter(random, 0.05)),\r\n ))\r\n }\r\n // The two battens on the back: what holds the planks together. They go\r\n // INTO the planks so that no two faces end up coplanar.\r\n for (const side of [-1, 1]) {\r\n board.push(boxGeometry(\r\n [config.height * 0.045, config.height * 0.94, config.width * 0.07],\r\n [-config.height * 0.045, -config.drop - config.height / 2, side * config.width * 0.36],\r\n tint('oak', -0.09),\r\n ))\r\n }\r\n // The two iron lugs joining the board to the chain.\r\n for (const side of [-1, 1]) {\r\n links.push(boxGeometry(\r\n [bar * 1.4, config.drop * 0.4, bar * 1.2],\r\n [0, -config.drop - config.drop * 0.06, side < 0 ? hangA - config.reach * 0.62 : hangB - config.reach * 0.62],\r\n tint('iron', 0.04, 0.7),\r\n ))\r\n }\r\n\r\n return {\r\n post: { slot: 'oak' as const, geometry: mergeColoured(timber) },\r\n bracket: { slot: 'iron' as const, geometry: mergeColoured(iron) },\r\n board: {\r\n slot: 'oak' as const,\r\n geometry: mergeColoured(board),\r\n // The origin is the CROSS-BAR, not the post.\r\n //\r\n // Everything in this part is authored hanging from the bar at\r\n // z = reach * 0.86, but the origin sat at z = 0 on the post's centre\r\n // line -- half a metre away. At rest that is invisible; the moment\r\n // the swing action runs, the whole assembly rotates about a point it\r\n // does not hang from, and the tops of the chains sweep off the bar\r\n // they are supposed to be looped through. The support audit caught it\r\n // as a detached board the instant the sheared curl stopped\r\n // accidentally filling the gap.\r\n //\r\n // Moving the origin means the geometry above is written relative to\r\n // the bar, which is why every `config.reach * 0.86` in this part is\r\n // now zero. Same place in the world, correct axis to turn about.\r\n origin: [0, pivotY, config.reach * 0.62] as const,\r\n extras: [{ slot: 'iron' as const, geometry: mergeColoured(links) }],\r\n },\r\n }\r\n },\r\n\r\n actions: ({ parts }) => {\r\n parts.board.anchor.rotation.z = angle\r\n return {\r\n push: (strength = 1) => {\r\n // Reinforces the existing motion instead of resetting it: successive\r\n // pushes should accumulate the way a real wind does.\r\n velocity += (velocity >= 0 ? 1 : -1) * 1.6 * strength\r\n },\r\n still: () => { angle = 0; velocity = 0; parts.board.anchor.rotation.z = 0 },\r\n lean: () => angle,\r\n }\r\n },\r\n\r\n update: (dt, { parts, getConfig }) => {\r\n const step = Math.min(0.05, Math.max(0, dt))\r\n if (step === 0) return\r\n if (Math.abs(angle) < 1e-5 && Math.abs(velocity) < 1e-5) return\r\n // A SOFTER pendulum than the bell's: weak restoring force, little damping.\r\n // This is what the long, lazy swing of a heavy board looks like.\r\n velocity += -angle * 11 * step - velocity * getConfig().damping * step\r\n angle += velocity * step\r\n // Limit: the board must stop before it hits the arm.\r\n const limit = 0.55\r\n if (Math.abs(angle) > limit) {\r\n angle = Math.sign(angle) * limit\r\n velocity *= -0.4\r\n }\r\n parts.board.anchor.rotation.z = angle\r\n },\r\n }, overrides)\r\n}\r\n",
2167
+ "hash": "31b8a14033543246a72ae0a42099bca0b5cc8666d4c0ac6d87acce5c1f109340"
2168
+ }
2169
+ ],
2170
+ "meta": {
2171
+ "title": "Tavern Sign",
2172
+ "description": "Wooden board hung by chain from a forged iron bracket. Pushed, it swings long and lazily.",
2173
+ "category": "Props",
2174
+ "tags": [
2175
+ "medieval",
2176
+ "lowpoly",
2177
+ "props",
2178
+ "animated",
2179
+ "interactive"
2180
+ ],
2181
+ "controls": {
2182
+ "width": {
2183
+ "type": "number",
2184
+ "label": "Board width",
2185
+ "min": 0.25,
2186
+ "max": 1.1,
2187
+ "step": 0.02,
2188
+ "unit": "m"
2189
+ },
2190
+ "height": {
2191
+ "type": "number",
2192
+ "label": "Board height",
2193
+ "min": 0.2,
2194
+ "max": 0.8,
2195
+ "step": 0.02,
2196
+ "unit": "m"
2197
+ },
2198
+ "reach": {
2199
+ "type": "number",
2200
+ "label": "Bracket reach",
2201
+ "min": 0.3,
2202
+ "max": 1.2,
2203
+ "step": 0.02,
2204
+ "unit": "m"
2205
+ },
2206
+ "drop": {
2207
+ "type": "number",
2208
+ "label": "Chain drop",
2209
+ "min": 0.04,
2210
+ "max": 0.35,
2211
+ "step": 0.01,
2212
+ "unit": "m"
2213
+ },
2214
+ "plankCount": {
2215
+ "type": "number",
2216
+ "label": "Board count",
2217
+ "min": 1,
2218
+ "max": 6,
2219
+ "step": 1
2220
+ },
2221
+ "damping": {
2222
+ "type": "number",
2223
+ "label": "Damping",
2224
+ "min": 0.05,
2225
+ "max": 2,
2226
+ "step": 0.05
2227
+ },
2228
+ "seed": {
2229
+ "type": "number",
2230
+ "label": "Variation seed",
2231
+ "min": 1,
2232
+ "max": 64,
2233
+ "step": 1
2234
+ }
2235
+ },
2236
+ "materialSlots": [
2237
+ "oak",
2238
+ "iron"
2239
+ ],
2240
+ "parts": [
2241
+ "post",
2242
+ "bracket",
2243
+ "board"
2244
+ ],
2245
+ "sockets": []
2246
+ }
2247
+ },
2248
+ {
2249
+ "name": "trestle-table",
2250
+ "type": "vibe3d:model",
2251
+ "title": "Trestle Table",
2252
+ "description": "Trestle table: the top is not nailed to the legs, it rests on them — so the hall can be cleared.",
2253
+ "dependencies": [
2254
+ "three@>=0.185.0"
2255
+ ],
2256
+ "registryDependencies": [
2257
+ "@medieval-kit/core"
2258
+ ],
2259
+ "files": [
2260
+ {
2261
+ "path": "models/trestle-table/model.ts",
2262
+ "target": "{models}/medieval-kit/trestle-table/model.ts",
2263
+ "content": "/**\r\n * @medieval-kit/trestle-table\r\n *\r\n * Trestle table: the standard table of the middle ages. The top is NOT nailed\r\n * to the trestles, it merely rests on them — so that once the meal is over it\r\n * can be lifted away and the hall cleared. That is why the top boards stand\r\n * independent of the trestles and have gaps between them.\r\n *\r\n * A trestle: a horizontal cap, two legs splaying down from it, a foot at the\r\n * bottom. Two trestles are tied together by a stretcher.\r\n */\r\nimport { Color } from 'three'\r\n\r\nimport {\r\n MEDIEVAL_PALETTE,\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n jitter,\r\n mergeColoured,\r\n} from '../core/index.ts'\r\n\r\nexport interface TrestleTableConfig {\r\n /** Top length (metres). */\r\n readonly length: number\r\n /** Top width (metres). */\r\n readonly width: number\r\n readonly height: number\r\n /** Number of top boards. */\r\n readonly plankCount: number\r\n /** Outward splay of the legs. */\r\n readonly splay: number\r\n readonly seed: number\r\n}\r\n\r\nexport const trestleTableDefaults: TrestleTableConfig = {\r\n length: 1.9,\r\n width: 0.78,\r\n height: 0.74,\r\n plankCount: 4,\r\n splay: 0.22,\r\n seed: 19,\r\n}\r\n\r\nexport type TrestleTableParts = 'top' | 'trestles' | 'stretcher'\r\n\r\nexport function createModel(overrides: Partial<TrestleTableConfig> = {}) {\r\n return createKitModel<TrestleTableConfig, 'oak', TrestleTableParts>({\r\n id: 'trestle-table',\r\n defaults: trestleTableDefaults,\r\n slots: ['oak'],\r\n build: ({ config, random }) => {\r\n const tint = new Color()\r\n const oak = (lift = 0): Color => {\r\n tint.copy(MEDIEVAL_PALETTE.oak)\r\n tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), lift + jitter(random, 0.055))\r\n return tint\r\n }\r\n const half = config.height / 2\r\n // 0.07 of the height, not 0.045: a 52 mm plank rather than a 33 mm one\r\n // across 1.9 m. Same reasoning as the bench -- a trestle top is a slab\r\n // laid on frames and taken off again, and it has to be stiff enough to\r\n // carry itself between two supports.\r\n const board = config.height * 0.07\r\n const timber = config.height * 0.075\r\n\r\n // --- top: separate boards with a thin gap between them ---\r\n const planks = []\r\n const count = Math.max(1, config.plankCount)\r\n const gap = config.width * 0.008\r\n const plankWidth = (config.width - gap * (count - 1)) / count\r\n for (let i = 0; i < count; i += 1) {\r\n const z = -config.width / 2 + plankWidth / 2 + i * (plankWidth + gap)\r\n // Every board gets its own thickness and tone: a top that was sawn,\r\n // planed and used for years is never uniform.\r\n const thickness = board * (1 + jitter(random, 0.08))\r\n planks.push(chamferedBoxGeometry(\r\n [config.length, plankWidth],\r\n [config.length, plankWidth],\r\n thickness,\r\n board * 0.22,\r\n [jitter(random, config.length * 0.004), half - thickness / 2, z],\r\n oak(0.04),\r\n ))\r\n }\r\n\r\n // --- trestles ---\r\n const trestles = []\r\n const trestleX = config.length * 0.31\r\n const legSpan = config.height - board\r\n for (const side of [-1, 1] as const) {\r\n const x = side * trestleX\r\n // Cap: the horizontal rail that carries the top.\r\n trestles.push(chamferedBoxGeometry(\r\n [timber * 1.1, config.width * 0.72],\r\n [timber * 1.1, config.width * 0.72],\r\n timber * 0.9,\r\n timber * 0.16,\r\n [x, half - board - timber * 0.45, 0],\r\n oak(-0.02),\r\n ))\r\n // Two legs: they splay outwards on their way down from the cap.\r\n for (const dir of [-1, 1] as const) {\r\n const leg = chamferedBoxGeometry(\r\n [timber * 0.9, timber * 0.8],\r\n [timber * 1.05, timber * 0.95],\r\n legSpan,\r\n timber * 0.15,\r\n [0, -legSpan / 2, 0],\r\n oak(),\r\n )\r\n leg.rotateX(dir * config.splay)\r\n // The legs pass through the cap at SEPARATE points; two timbers\r\n // cannot share one mortise. Without this offset the two sit on top\r\n // of each other when splay=0.\r\n leg.translate(x, half - board - timber * 0.3, dir * timber * 0.62)\r\n trestles.push(leg)\r\n }\r\n // Centre post: the vertical timber running from the cap down to the\r\n // foot. The stretcher passes through it — without it the stretcher hung\r\n // in mid-air BETWEEN the two trestles, because as the legs splayed they\r\n // moved apart along z and no longer touched the stretcher.\r\n trestles.push(chamferedBoxGeometry(\r\n [timber * 0.8, timber * 0.85],\r\n [timber * 0.9, timber * 0.9],\r\n config.height - board - timber * 0.5,\r\n timber * 0.14,\r\n [x, -half + (config.height - board - timber * 0.5) / 2 + timber * 0.2, 0],\r\n oak(-0.01),\r\n ))\r\n\r\n // Foot: the crosswise member resting on the ground. Required, because\r\n // the floor is not flat.\r\n const spread = Math.sin(config.splay) * legSpan\r\n trestles.push(chamferedBoxGeometry(\r\n [timber * 1.2, config.width * 0.62 + spread * 2],\r\n [timber * 1.05, config.width * 0.58 + spread * 2],\r\n timber * 0.62,\r\n timber * 0.14,\r\n [x, -half + timber * 0.31, 0],\r\n oak(-0.04),\r\n ))\r\n }\r\n\r\n // --- stretcher: the long rail tying the two trestles, it enters the legs ---\r\n const stretcher = chamferedBoxGeometry(\r\n [trestleX * 2 + timber * 1.6, timber * 0.7],\r\n [trestleX * 2 + timber * 1.6, timber * 0.7],\r\n timber * 0.85,\r\n timber * 0.15,\r\n [0, -half + config.height * 0.24, 0],\r\n oak(-0.03),\r\n )\r\n\r\n return {\r\n top: { slot: 'oak', geometry: mergeColoured(planks) },\r\n trestles: { slot: 'oak', geometry: mergeColoured(trestles) },\r\n stretcher: { slot: 'oak', geometry: stretcher },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
2264
+ "hash": "5cb5c80b9994d3a3094773b4acfe73a4aef2a5fcabb3a95979ce36f529fe187c"
2265
+ }
2266
+ ],
2267
+ "meta": {
2268
+ "title": "Trestle Table",
2269
+ "description": "Trestle table: the top is not nailed to the legs, it rests on them — so the hall can be cleared.",
2270
+ "category": "Furniture",
2271
+ "tags": [
2272
+ "medieval",
2273
+ "lowpoly",
2274
+ "furniture",
2275
+ "procedural"
2276
+ ],
2277
+ "controls": {
2278
+ "length": {
2279
+ "type": "number",
2280
+ "label": "Length",
2281
+ "min": 1,
2282
+ "max": 3.2,
2283
+ "step": 0.05,
2284
+ "unit": "m"
2285
+ },
2286
+ "width": {
2287
+ "type": "number",
2288
+ "label": "Width",
2289
+ "min": 0.5,
2290
+ "max": 1.2,
2291
+ "step": 0.02,
2292
+ "unit": "m"
2293
+ },
2294
+ "height": {
2295
+ "type": "number",
2296
+ "label": "Height",
2297
+ "min": 0.5,
2298
+ "max": 1,
2299
+ "step": 0.01,
2300
+ "unit": "m"
2301
+ },
2302
+ "plankCount": {
2303
+ "type": "number",
2304
+ "label": "Top boards",
2305
+ "min": 2,
2306
+ "max": 7,
2307
+ "step": 1
2308
+ },
2309
+ "splay": {
2310
+ "type": "number",
2311
+ "label": "Leg splay",
2312
+ "min": 0,
2313
+ "max": 0.45,
2314
+ "step": 0.01
2315
+ },
2316
+ "seed": {
2317
+ "type": "number",
2318
+ "label": "Variation seed",
2319
+ "min": 1,
2320
+ "max": 64,
2321
+ "step": 1
2322
+ }
2323
+ },
2324
+ "materialSlots": [
2325
+ "oak"
2326
+ ],
2327
+ "parts": [
2328
+ "top",
2329
+ "trestles",
2330
+ "stretcher"
2331
+ ],
2332
+ "sockets": []
2333
+ }
2334
+ },
2335
+ {
2336
+ "name": "vegetables",
2337
+ "type": "vibe3d:model",
2338
+ "title": "Vegetables",
2339
+ "description": "Loose heap mixed from eight kinds: turnip, cabbage, onion, leek, parsnip, carrot, tomato and potato. The first six are the period-correct set.",
2340
+ "dependencies": [
2341
+ "three@>=0.185.0"
2342
+ ],
2343
+ "registryDependencies": [
2344
+ "@medieval-kit/core"
2345
+ ],
2346
+ "files": [
2347
+ {
2348
+ "path": "models/vegetables/model.ts",
2349
+ "target": "{models}/medieval-kit/vegetables/model.ts",
2350
+ "content": "/**\r\n * @medieval-kit/vegetables\r\n *\r\n * A loose heap of vegetables, mixed from six kinds.\r\n *\r\n * Eight kinds, and the mix is a slider rather than a fixed recipe.\r\n *\r\n * Six of them are what a medieval European kitchen had -- turnip, cabbage,\r\n * onion, leek, parsnip and carrot -- and the last two, tomato and potato, are\r\n * New World crops that only reach Europe after 1492. They are here because a\r\n * kit is more useful than it is a museum, and the carrot is orange rather than\r\n * the purple a medieval one would have been. Anyone who wants the strictly\r\n * period set can have it without thinking about it: the kinds are ordered so\r\n * that `lead: 0` with `kinds: 6` gives exactly those six and never reaches the\r\n * two after them.\r\n *\r\n * The heap is two parts — `bulbs` for the things that sit on their base and\r\n * `roots` for the things that lie on their side — because those are the two\r\n * ways a vegetable rests, and a consumer wanting only one of them should not\r\n * have to take both.\r\n *\r\n * Every vegetable is authored with its LOWEST POINT AT THE ORIGIN. Placing a\r\n * heap then costs one translate per item and nothing can end up hovering,\r\n * which is not a small thing: the pile is the only model in the kit made\r\n * entirely of loose objects, so it is the one with the most ways to float.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n createKitModel,\r\n createRandom,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n taperedBoxGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface VegetablesConfig {\r\n /** How many vegetables in the heap. */\r\n readonly count: number\r\n /** How many of the eight kinds appear in the mix. */\r\n readonly kinds: number\r\n /**\r\n * Which kind leads the mix, and where it starts counting:\r\n * 0 turnip, 1 cabbage, 2 onion, 3 leek, 4 parsnip, 5 carrot, 6 tomato,\r\n * 7 potato.\r\n */\r\n readonly lead: number\r\n /** Overall size of one vegetable (metres). */\r\n readonly size: number\r\n /** How far the heap spreads, as a multiple of its own size. */\r\n readonly spread: number\r\n readonly seed: number\r\n}\r\n\r\nexport const vegetablesDefaults: VegetablesConfig = {\r\n count: 15,\r\n kinds: 8,\r\n lead: 0,\r\n size: 0.1,\r\n // Tight enough to be a heap. At 2.6 the vegetables were laid out in a ring\r\n // with daylight between every one of them, which is a display, not a pile.\r\n spread: 1.5,\r\n seed: 71,\r\n}\r\n\r\nexport type VegetablesParts = 'bulbs' | 'roots'\r\n\r\n/** Kinds in the order the `lead` slider indexes them. */\r\n// Ordered so the first six are the period-correct set: `lead: 0` with\r\n// `kinds: 6` yields exactly those and never reaches the two after them.\r\nconst KINDS = [\r\n 'turnip', 'cabbage', 'onion', 'leek', 'parsnip', 'carrot', 'tomato', 'potato',\r\n] as const\r\ntype Kind = (typeof KINDS)[number]\r\n\r\n/** Which of them sit on a base and which lie on their side. */\r\nconst LIES_DOWN: ReadonlySet<Kind> = new Set(['leek', 'parsnip', 'carrot'])\r\n\r\nexport function createModel(overrides: Partial<VegetablesConfig> = {}) {\r\n return createKitModel<VegetablesConfig, 'produce', VegetablesParts>({\r\n id: 'vegetables',\r\n defaults: vegetablesDefaults,\r\n slots: ['produce'],\r\n\r\n build: ({ config, random }) => {\r\n const S = config.size\r\n const hsl = (h: number, s: number, l: number, r: () => number): Color =>\r\n new Color().setHSL(\r\n (h + jitter(r, 0.012) + 1) % 1,\r\n Math.min(1, Math.max(0, s + jitter(r, 0.06))),\r\n Math.min(1, Math.max(0, l + jitter(r, 0.05))),\r\n )\r\n\r\n /**\r\n * One vegetable, built with its lowest point at y = 0 and its own axis\r\n * up. Whatever lies down is laid down here, so the caller never has to\r\n * know which is which.\r\n */\r\n function grow(kind: Kind, r: () => number): { geometry: BufferGeometry; rest: number } {\r\n const pieces: BufferGeometry[] = []\r\n const scale = S * (0.86 + r() * 0.3)\r\n let widest = scale * 0.5 // body radius, used while shaping\r\n\r\n if (kind === 'turnip') {\r\n // A squat globe, flat on top, drawn down to a root at the bottom. The\r\n // purple is only on the shoulder: it is where the sun reached it\r\n // above the soil, which is why it stops in a line.\r\n const R = scale * 0.5\r\n widest = R\r\n const pale = hsl(0.11, 0.16, 0.79, r)\r\n const purple = hsl(0.83, 0.3, 0.5, r)\r\n // Two lathes, not one gradient. `colourTop` interpolates across the\r\n // WHOLE profile, so a purple crown on a cream body bled all the way\r\n // down and the turnip came out lavender to its root. On a real one\r\n // the purple stops in a LINE -- it is the part that stood above the\r\n // soil and caught the sun -- and a line is a second piece, overlapped\r\n // into the first so there is no seam between them.\r\n pieces.push(latheGeometry(\r\n [\r\n { y: 0, radius: R * 0.1 },\r\n { y: R * 0.22, radius: R * 0.72 },\r\n { y: R * 0.62, radius: R },\r\n { y: R * 0.92, radius: R * 0.97 },\r\n ] as Level[],\r\n // No top cap: it is buried under the shoulder. Two turnips\r\n // touching in a tight heap met on exactly these hidden discs,\r\n // which is the same saving `bandGeometry` makes on the inside of\r\n // a hoop -- free, and here it removes a whole class of collision.\r\n 9, [0, 0, 0], pale, { colourTop: pale, capTop: false },\r\n ))\r\n // The shoulder is PROUD of the body it caps. Started at the same\r\n // radius, the two lathes ran parallel through their overlap and met\r\n // in coplanar faces -- visible the moment a heap was made of nothing\r\n // but turnips. A few percent wider makes the surfaces cross instead,\r\n // and the widest point of a turnip really is its shoulder.\r\n pieces.push(latheGeometry(\r\n [\r\n { y: R * 0.78, radius: R * 1.04 },\r\n { y: R * 1.18, radius: R * 0.9 },\r\n { y: R * 1.42, radius: R * 0.44 },\r\n ] as Level[],\r\n 9, [0, 0, 0], purple, { colourTop: purple, capBottom: false },\r\n ))\r\n // The cut stalk, a pale nub on the crown.\r\n pieces.push(latheGeometry(\r\n [\r\n { y: R * 1.34, radius: R * 0.2 },\r\n { y: R * 1.52, radius: R * 0.12 },\r\n ] as Level[],\r\n 6, [0, 0, 0], hsl(0.16, 0.22, 0.66, r), { capBottom: false },\r\n ))\r\n } else if (kind === 'cabbage') {\r\n const R = scale * 0.62\r\n widest = R\r\n const inner = hsl(0.23, 0.38, 0.62, r)\r\n const outer = hsl(0.27, 0.44, 0.36, r)\r\n pieces.push(latheGeometry(\r\n [\r\n { y: 0, radius: R * 0.42 },\r\n { y: R * 0.34, radius: R * 0.92 },\r\n { y: R * 0.86, radius: R },\r\n { y: R * 1.4, radius: R * 0.86 },\r\n { y: R * 1.7, radius: R * 0.3 },\r\n ] as Level[],\r\n 9, [0, 0, 0], inner, { colourTop: inner },\r\n ))\r\n // Three loose outer leaves. A cabbage that is a smooth ball is a\r\n // melon; what says cabbage is the leaves that have not closed.\r\n for (let i = 0; i < 3; i += 1) {\r\n const a = (i / 3) * Math.PI * 2 + r() * 1.4\r\n // Built AT THE ORIGIN, rotated, and only then carried to where it\r\n // grows. Rotation turns a body about the origin, so a leaf built\r\n // already in place is flung away from the cabbage by its own\r\n // distance times the sine of the angle. That is exactly what\r\n // happened, and why the support check found loose leaves lying\r\n // beside the heap with nothing holding them.\r\n const leaf = taperedBoxGeometry(\r\n [R * 0.95, R * 0.1],\r\n [R * 0.5, R * 0.07],\r\n R * 1.05,\r\n [0, 0, 0],\r\n outer,\r\n )\r\n leaf.rotateZ(0.95 + r() * 0.4)\r\n leaf.rotateY(a)\r\n leaf.translate(Math.sin(a) * R * 0.42, R * 0.62, Math.cos(a) * R * 0.42)\r\n pieces.push(leaf)\r\n }\r\n } else if (kind === 'onion') {\r\n const R = scale * 0.44\r\n widest = R\r\n const skin = hsl(0.09, 0.52, 0.52, r)\r\n pieces.push(latheGeometry(\r\n [\r\n { y: 0, radius: R * 0.16 },\r\n { y: R * 0.3, radius: R * 0.84 },\r\n { y: R * 0.78, radius: R },\r\n { y: R * 1.3, radius: R * 0.72 },\r\n { y: R * 1.62, radius: R * 0.2 },\r\n { y: R * 1.95, radius: R * 0.07 },\r\n ] as Level[],\r\n 9, [0, 0, 0], skin, { colourTop: hsl(0.11, 0.35, 0.66, r) },\r\n ))\r\n } else if (kind === 'leek') {\r\n // White at the root, pale green through the middle, dark flat leaves\r\n // at the top. Built standing and laid down at the end.\r\n const len = scale * 3.4\r\n const R = scale * 0.19\r\n widest = R\r\n pieces.push(latheGeometry(\r\n [\r\n { y: 0, radius: R * 0.5 },\r\n { y: len * 0.06, radius: R },\r\n { y: len * 0.4, radius: R * 0.96 },\r\n { y: len * 0.62, radius: R * 0.88 },\r\n ] as Level[],\r\n 7, [0, 0, 0], hsl(0.13, 0.1, 0.86, r),\r\n { colourTop: hsl(0.28, 0.38, 0.56, r) },\r\n ))\r\n for (let i = 0; i < 3; i += 1) {\r\n const a = (i / 3) * Math.PI * 2 + r() * 1.2\r\n // Same rule as the cabbage's leaves: origin, rotate, place.\r\n const blade = taperedBoxGeometry(\r\n [R * 1.5, R * 0.16],\r\n [R * 0.5, R * 0.1],\r\n len * 0.46,\r\n [0, 0, 0],\r\n hsl(0.3, 0.44, 0.3, r),\r\n )\r\n blade.rotateX(0.2 + r() * 0.35)\r\n blade.rotateY(a)\r\n blade.translate(\r\n Math.sin(a) * R * 0.4,\r\n len * 0.62 + len * 0.19,\r\n Math.cos(a) * R * 0.4,\r\n )\r\n pieces.push(blade)\r\n }\r\n // Root whiskers.\r\n pieces.push(latheGeometry(\r\n [\r\n { y: -len * 0.05, radius: R * 0.06 },\r\n { y: len * 0.04, radius: R * 0.5 },\r\n ] as Level[],\r\n 6, [0, 0, 0], hsl(0.11, 0.14, 0.7, r), { capTop: false },\r\n ))\r\n } else if (kind === 'tomato') {\r\n // Squat, flattened top and bottom, with the calyx still on it. The\r\n // dimple where the stem was is what stops it reading as an apple.\r\n const R = scale * 0.42\r\n widest = R\r\n pieces.push(latheGeometry(\r\n [\r\n { y: 0, radius: R * 0.34 },\r\n { y: R * 0.24, radius: R * 0.86 },\r\n { y: R * 0.7, radius: R },\r\n { y: R * 1.18, radius: R * 0.84 },\r\n { y: R * 1.36, radius: R * 0.3 },\r\n ] as Level[],\r\n 9, [0, 0, 0], hsl(0.02, 0.76, 0.42, r),\r\n { colourTop: hsl(0.03, 0.7, 0.34, r) },\r\n ))\r\n const green = hsl(0.28, 0.5, 0.3, r)\r\n for (let i = 0; i < 5; i += 1) {\r\n const a = (i / 5) * Math.PI * 2\r\n const sepal = taperedBoxGeometry(\r\n [R * 0.3, R * 0.07],\r\n [R * 0.1, R * 0.05],\r\n R * 0.6,\r\n [0, 0, 0],\r\n green,\r\n )\r\n sepal.rotateZ(1.15)\r\n sepal.rotateY(a)\r\n sepal.translate(Math.sin(a) * R * 0.22, R * 1.24, Math.cos(a) * R * 0.22)\r\n pieces.push(sepal)\r\n }\r\n } else if (kind === 'potato') {\r\n // Lumpy and asymmetric on purpose. A smooth ellipsoid is an egg; a\r\n // potato is a body of revolution that has been knocked about, so its\r\n // levels wander instead of describing a curve.\r\n const R = scale * 0.4\r\n widest = R * 1.25\r\n pieces.push(latheGeometry(\r\n [\r\n { y: 0, radius: R * 0.4 },\r\n { y: R * 0.34, radius: R * (0.88 + jitter(r, 0.12)) },\r\n { y: R * 0.82, radius: R * (1.02 + jitter(r, 0.14)) },\r\n { y: R * 1.3, radius: R * (0.94 + jitter(r, 0.14)) },\r\n { y: R * 1.72, radius: R * (0.7 + jitter(r, 0.1)) },\r\n { y: R * 2, radius: R * 0.3 },\r\n ] as Level[],\r\n 8, [0, 0, 0], hsl(0.09, 0.3, 0.56, r),\r\n { colourTop: hsl(0.08, 0.26, 0.5, r) },\r\n ))\r\n } else {\r\n // Parsnip and carrot are the same shape at different proportions: a\r\n // long cone with a broad shoulder. The carrot is ORANGE, which is a\r\n // seventeenth-century Dutch selection rather than a medieval one,\r\n // and is also the only carrot anybody recognises.\r\n const carrot = kind === 'carrot'\r\n const len = scale * (carrot ? 3.1 : 2.7)\r\n const R = scale * (carrot ? 0.24 : 0.32)\r\n widest = R\r\n const body = carrot ? hsl(0.07, 0.82, 0.47, r) : hsl(0.1, 0.2, 0.78, r)\r\n pieces.push(latheGeometry(\r\n [\r\n { y: 0, radius: R * 0.05 },\r\n { y: len * 0.3, radius: R * 0.55 },\r\n { y: len * 0.72, radius: R * 0.9 },\r\n { y: len * 0.94, radius: R },\r\n { y: len, radius: R * 0.88 },\r\n ] as Level[],\r\n 7, [0, 0, 0], body, { colourTop: carrot ? hsl(0.08, 0.7, 0.55, r) : hsl(0.12, 0.18, 0.84, r) },\r\n ))\r\n // The cut crown, always paler than the root.\r\n pieces.push(latheGeometry(\r\n [\r\n { y: len * 0.98, radius: R * 0.7 },\r\n { y: len * 1.1, radius: R * 0.4 },\r\n ] as Level[],\r\n 6, [0, 0, 0], hsl(0.14, 0.24, 0.62, r), { capBottom: false },\r\n ))\r\n }\r\n\r\n const geometry = mergeColoured(pieces)\r\n if (LIES_DOWN.has(kind)) {\r\n geometry.rotateZ(Math.PI / 2)\r\n }\r\n // EVERY vegetable is turned, not only the ones that lie down.\r\n //\r\n // These are faceted bodies of revolution, and two of them built at the\r\n // same phase have parallel faces all the way round. Left unturned they\r\n // were fine while the heap was loose and z-fought the moment it was\r\n // tightened -- worst with the mix set to a single kind, where every\r\n // neighbour is the same shape as well as the same phase. Nothing in a\r\n // pile of vegetables shares an orientation anyway.\r\n geometry.rotateY(r() * Math.PI * 2)\r\n\r\n // The vegetable is set down by MEASURING it, not by working out where\r\n // its lowest point ought to be.\r\n //\r\n // Lifting a lying root by its body radius is right for the root and\r\n // wrong for everything attached to it: a leek's blades lean away from\r\n // the axis by four times that radius, and a cabbage's loose leaves hang\r\n // below its base. Both were left under the ground, and since the floor\r\n // is the whole model's lowest point, whichever vegetable dug in\r\n // deepest made every other one look as though it were hovering. Reading\r\n // the bounding box makes the claim in this file's header -- that every\r\n // vegetable's lowest point is at the origin -- true by construction\r\n // rather than by an argument that has to be right about six shapes.\r\n geometry.computeBoundingBox()\r\n const low = geometry.boundingBox?.min.y ?? 0\r\n geometry.translate(0, -low, 0)\r\n return { geometry, rest: -low }\r\n }\r\n\r\n // --- The heap -------------------------------------------------------\r\n const count = Math.max(1, Math.round(config.count))\r\n const kindCount = Math.min(KINDS.length, Math.max(1, Math.round(config.kinds)))\r\n const lead = ((Math.round(config.lead) % KINDS.length) + KINDS.length) % KINDS.length\r\n\r\n // The mix starts at `lead` and takes the next `kinds` of them, so the\r\n // slider reads as \"which one, and how mixed\" rather than as an opaque\r\n // index into a list.\r\n const mix = Array.from({ length: kindCount }, (_, i) => KINDS[(lead + i) % KINDS.length]!)\r\n\r\n const bulbs: BufferGeometry[] = []\r\n const roots: BufferGeometry[] = []\r\n\r\n for (let i = 0; i < count; i += 1) {\r\n // The lead kind gets roughly twice the share of the others, which is\r\n // what \"a heap of turnips with some onions in\" actually looks like.\r\n const pick = i % 3 === 0 ? mix[0]! : mix[1 + (i % Math.max(1, kindCount - 1))] ?? mix[0]!\r\n // A separate stream per item, so adding one vegetable does not reshuffle\r\n // every vegetable after it.\r\n const r = createRandom(config.seed * 31 + i * 977)\r\n const { geometry } = grow(pick, r)\r\n\r\n const angle = i * 2.399963\r\n const ring = Math.sqrt((i + 0.5) / count)\r\n const at = S * config.spread * ring\r\n geometry.translate(\r\n Math.sin(angle) * at + jitter(r, S * 0.12),\r\n 0,\r\n Math.cos(angle) * at + jitter(r, S * 0.12),\r\n )\r\n ;(LIES_DOWN.has(pick) ? roots : bulbs).push(geometry)\r\n }\r\n\r\n // `random` is the model's own stream and every vegetable uses its own, so\r\n // draw from it once to keep the seed meaningful at this level too.\r\n void random()\r\n\r\n return {\r\n bulbs: { slot: 'produce' as const, geometry: mergeColoured(bulbs.length ? bulbs : roots) },\r\n roots: roots.length > 0 && bulbs.length > 0\r\n ? { slot: 'produce' as const, geometry: mergeColoured(roots) }\r\n : undefined,\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
2351
+ "hash": "a94f797493cabe85818d16bf5a9ae15b5c907dfd4c8fe04e3e57a54b706655a5"
2352
+ }
2353
+ ],
2354
+ "meta": {
2355
+ "title": "Vegetables",
2356
+ "description": "Loose heap mixed from eight kinds: turnip, cabbage, onion, leek, parsnip, carrot, tomato and potato. The first six are the period-correct set.",
2357
+ "category": "Props",
2358
+ "tags": [
2359
+ "medieval",
2360
+ "lowpoly",
2361
+ "props",
2362
+ "procedural",
2363
+ "food"
2364
+ ],
2365
+ "controls": {
2366
+ "count": {
2367
+ "type": "number",
2368
+ "label": "How many",
2369
+ "min": 1,
2370
+ "max": 30,
2371
+ "step": 1
2372
+ },
2373
+ "kinds": {
2374
+ "type": "number",
2375
+ "label": "Kinds in the mix",
2376
+ "min": 1,
2377
+ "max": 8,
2378
+ "step": 1
2379
+ },
2380
+ "lead": {
2381
+ "type": "number",
2382
+ "label": "Leading kind",
2383
+ "min": 0,
2384
+ "max": 7,
2385
+ "step": 1
2386
+ },
2387
+ "size": {
2388
+ "type": "number",
2389
+ "label": "Vegetable size",
2390
+ "min": 0.05,
2391
+ "max": 0.2,
2392
+ "step": 0.005,
2393
+ "unit": "m"
2394
+ },
2395
+ "spread": {
2396
+ "type": "number",
2397
+ "label": "Heap spread",
2398
+ "min": 1,
2399
+ "max": 5,
2400
+ "step": 0.1
2401
+ },
2402
+ "seed": {
2403
+ "type": "number",
2404
+ "label": "Variation seed",
2405
+ "min": 1,
2406
+ "max": 64,
2407
+ "step": 1
2408
+ }
2409
+ },
2410
+ "materialSlots": [
2411
+ "produce"
2412
+ ],
2413
+ "parts": [
2414
+ "bulbs",
2415
+ "roots"
2416
+ ],
2417
+ "sockets": []
2418
+ }
2419
+ },
2420
+ {
2421
+ "name": "wicker-basket",
2422
+ "type": "vibe3d:model",
2423
+ "title": "Wicker Basket",
2424
+ "description": "Basket woven from willow rods. Horizontal rods pass in front of one upright, behind the next.",
2425
+ "dependencies": [
2426
+ "three@>=0.185.0"
2427
+ ],
2428
+ "registryDependencies": [
2429
+ "@medieval-kit/core"
2430
+ ],
2431
+ "files": [
2432
+ {
2433
+ "path": "models/wicker-basket/model.ts",
2434
+ "target": "{models}/medieval-kit/wicker-basket/model.ts",
2435
+ "content": "/**\n * @medieval-kit/wicker-basket\n *\n * A basket woven from willow rods, optionally filled with produce.\n *\n * The model in the kit that imitates \"how it was made\" the most, because in\n * wickerwork the form and the making are the same thing: a basket is the\n * horizontal rods (withies) winding IN FRONT OF ONE and BEHIND THE NEXT of the\n * vertical rods (stakes). Without that winding what you get is a bucket with\n * lines drawn on it.\n *\n * The weave trick is short: every horizontal hoop is first produced as a flat\n * band, then its vertices are pushed in and out according to THEIR ANGLE —\n *\n * radius × (1 + amplitude · cos(stakes · angle + phase))\n *\n * On consecutive rows the phase is shifted by π, so where one row comes out the\n * next one goes in. That is exactly what a real weave is, and it costs not one\n * single extra triangle.\n *\n * The produce sits in its own slot and takes its colour from the `hue` field:\n * the same model can give you a basket of apples, turnips or cabbages. Do NOT\n * look for tomatoes — they come from the Americas and do not enter European\n * cooking before the 16th century.\n */\nimport { Color, type BufferGeometry } from 'three'\n\nimport {\n bandGeometry,\n createKitModel,\n createTinter,\n flipGeometry,\n jitter,\n latheGeometry,\n mergeColoured,\n prismGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface WickerBasketConfig {\n /** Basket height, handle excluded (metres). */\n readonly height: number\n /** Mouth radius (metres). */\n readonly radius: number\n /** Taper towards the base. 0 = cylinder. */\n readonly taper: number\n /** Number of vertical stakes. Also the \"wave count\" of the weave. */\n readonly stakes: number\n /** Horizontal weave rows. */\n readonly rows: number\n /** Number of fruits inside. 0 = empty basket. */\n readonly produce: number\n /** Fruit colour, 0–1 around the colour wheel. */\n readonly hue: number\n readonly seed: number\n}\n\nexport const wickerBasketDefaults: WickerBasketConfig = {\n // Shallower and fuller. The reference is a bowl wider than it is deep,\n // heaped until the fruit mounds over the rim -- which is the only state a\n // fruit basket is ever drawn in.\n height: 0.16,\n radius: 0.17,\n taper: 0.26,\n stakes: 11,\n rows: 6,\n produce: 15,\n hue: 0.02,\n seed: 97,\n}\n\nexport type WickerBasketParts = 'weave' | 'rim' | 'contents'\n\nexport function createModel(overrides: Partial<WickerBasketConfig> = {}) {\n return createKitModel<WickerBasketConfig, 'oak' | 'produce', WickerBasketParts>({\n id: 'wicker-basket',\n defaults: wickerBasketDefaults,\n slots: ['oak', 'produce'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n const half = config.height / 2\n const stakes = Math.max(5, Math.round(config.stakes))\n const rows = Math.max(1, Math.round(config.rows))\n const bottomRadius = config.radius * (1 - config.taper)\n const withy = config.height * 0.05 // rod thickness\n const amplitude = 0.055 // in-out travel of the weave\n\n const radiusAt = (t: number): number => bottomRadius + (config.radius - bottomRadius) * t\n\n /**\n * The transform that turns a band into a weave: every vertex moves\n * towards and away from the centre according to ITS OWN angle. Y is left\n * alone, so the hoop stays in its plane and never collides with the\n * neighbouring rows.\n */\n const undulate = (geometry: BufferGeometry, phase: number): BufferGeometry => {\n const position = geometry.getAttribute('position')\n for (let i = 0; i < position.count; i += 1) {\n const x = position.getX(i)\n const z = position.getZ(i)\n const distance = Math.hypot(x, z)\n if (distance < 1e-6) continue\n const scale = 1 + amplitude * Math.cos(stakes * Math.atan2(x, z) + phase)\n position.setX(i, x * scale)\n position.setZ(i, z * scale)\n }\n position.needsUpdate = true\n geometry.computeVertexNormals()\n return geometry\n }\n\n // --- Vertical stakes -----------------------------------------------------\n // They run THROUGH the weave: because the horizontal hoops wind in front\n // of one and behind the next, the stakes hide here and show up there.\n const pieces: BufferGeometry[] = []\n for (let i = 0; i < stakes; i += 1) {\n const angle = (i / stakes) * Math.PI * 2\n const stake = prismGeometry(\n withy * 0.42, withy * 0.36, config.height * 1.02, 4,\n [0, 0, 0], tint('oak', -0.09 + 0.04, 1.2),\n )\n // Bend first, move second: in a tapering basket the stakes lean too.\n stake.rotateX(Math.atan2(config.radius - bottomRadius, config.height))\n stake.rotateY(angle)\n const mid = (bottomRadius + config.radius) / 2\n stake.translate(Math.sin(angle) * mid, 0, Math.cos(angle) * mid)\n pieces.push(stake)\n }\n\n // Willow, not straw.\n //\n // The basket drew its colour from the `straw` palette entry and rendered\n // at hue 41 with saturation 0.59; a photograph of a wicker basket sits at\n // hue 29, saturation 0.50. It read as bright yellow plastic. The tuned\n // `oak` entry is hue 26 / saturation 0.53 -- almost exactly the\n // measurement -- and willow IS a wood, so the material slot moves with\n // the colour. The lift keeps it at the pale, peeled end of oak rather\n // than the dark structural end.\n //\n // The lift is 0.04 and that is nearly arbitrary: sweeping it over 0.00,\n // 0.04 and 0.08 moved the measured hue and saturation by at most 0.01.\n // It was first set to 0.12 on the reasoning that willow is paler than\n // structural oak, which is true and which made the basket salmon pink --\n // raising HSL lightness holds saturation, so a saturated brown climbs\n // towards peach rather than towards weathered willow. Against the\n // reference the straw entry was off by 12 degrees of hue and 0.09\n // saturation; oak is off by 7 and 0.03.\n\n // --- Horizontal weave ----------------------------------------------------\n // TWO segments per vertical stake: `cos(stakes·θ)` is sampled exactly once\n // positive and once negative on every stake, so the wave is fully resolved\n // with the fewest possible triangles. Four segments gave a smoother wave\n // but doubled the triangles per hoop and pushed the basket past the\n // lowpoly budget.\n //\n // The INNER FACE of the hoops is not generated. In its place there is a\n // single-piece inner liner (below): six separate inner surfaces for six\n // hoops cost ~800 triangles, the liner costs 44, and from the inside the\n // difference is invisible.\n // The weave stops BELOW the rim. Once the rows were made tall enough to\n // meet each other they grew into the rim's band, and the outermost point\n // of the undulation met the rim's inner facets in the same plane. The\n // rim is a separate, thicker rod laid over the finished weave, so the\n // weave ending under it is how the object is actually made.\n const weaveSpan = config.height * 0.9\n for (let r = 0; r < rows; r += 1) {\n const t = (r + 0.5) / rows\n const y = -half + weaveSpan * t\n // The band height is derived from the row count so that rows always\n // MEET. It used to be a flat 0.11 of the height while the spacing\n // between rows is 1/rows -- 0.167 at the default six -- which left a\n // 12 mm gap you could see straight through to the contents. That is a\n // slatted crate, not a weave. Rows now overlap slightly; they do not\n // z-fight because consecutive rows are undulated half a wave apart, so\n // where one bulges out its neighbour is tucked in.\n // Alternate rows sit slightly proud of and slightly behind each other.\n // This is how a weave really goes together -- the weaver's rod passes\n // outside one stake and inside the next, so no two rows lie on the\n // same cylinder -- and it is also what makes the overlap above safe.\n // Undulating consecutive rows in antiphase is not enough on its own:\n // a sine crosses zero, and at those nodes both rows returned to\n // exactly `radiusAt(t)`. With a cylindrical basket (taper 0) every row\n // shares that radius, so the overlapping bands met in coplanar faces\n // at every node.\n const lean = r % 2 === 0 ? withy * 0.2 : -withy * 0.2\n const ring = bandGeometry(\n radiusAt(t) + lean, y, (weaveSpan / rows) * 1.06, withy * 0.8, stakes * 2,\n tint('oak', 0.04 + jitter(random, 0.07), 1.2),\n )\n // The phase shifts by half a wave on every row: the next row going in\n // where the previous one came out is what makes a weave a weave.\n pieces.push(undulate(ring, r % 2 === 0 ? 0 : Math.PI))\n }\n\n // Inner liner: the single surface that closes off the back of the weave.\n // Wound in reverse so the normals face the axis.\n pieces.push(flipGeometry(latheGeometry([\n { y: -half + config.height * 0.03, radius: bottomRadius * (1 - amplitude) },\n { y: half - config.height * 0.02, radius: config.radius * (1 - amplitude) },\n ], stakes * 2, [0, 0, 0], tint('oak', 0.06, 1.1), {\n capTop: false,\n capBottom: false,\n })))\n\n // --- Base ----------------------------------------------------------------\n pieces.push(latheGeometry([\n { y: -half - config.height * 0.01, radius: bottomRadius * 0.94 },\n { y: -half + config.height * 0.05, radius: bottomRadius * 0.99 },\n ], stakes * 2, [0, 0, 0], tint('oak', -0.14 + 0.04, 1.2), { capTop: true }))\n\n // --- Rim -----------------------------------------------------------------\n // The thick bend that finishes the weave. It is the most visible detail on\n // the basket, and without it the edge looks \"cut off\".\n const rim = mergeColoured([\n // Thickness withy*2.1, not 1.5, and the reason is arithmetic rather\n // than taste. At 1.5 the rim's inner surface landed at\n // radius*1.015 - withy*1.5 = 0.16055, and the inner liner sits at\n // radius*(1 - amplitude) = 0.16065 -- a tenth of a millimetre apart,\n // by coincidence. On a cylindrical basket (taper 0) those are the same\n // 22-sided prism and every facet of it z-fought. The thicker rod\n // carries the inner face clearly past the liner, and it is the truer\n // shape anyway: the rim is the heaviest rod in a basket, bent over the\n // finished weave, and it stands proud on both sides.\n bandGeometry(config.radius * 1.015, half - config.height * 0.03,\n config.height * 0.1, withy * 2.1, stakes * 2,\n tint('oak', 0.07 + 0.04, 1.2), { inner: true }),\n ])\n\n // --- Contents ------------------------------------------------------------\n const count = Math.max(0, Math.round(config.produce))\n const contents: BufferGeometry[] = []\n const hue = ((config.hue % 1) + 1) % 1\n for (let i = 0; i < count; i += 1) {\n const size = config.radius * (0.2 + random() * 0.07)\n // Apple profile: dimpled top and bottom, wide in the middle.\n const fruit = latheGeometry([\n { y: -size * 0.86, radius: size * 0.3 },\n { y: -size * 0.6, radius: size * 0.78 },\n { y: 0, radius: size },\n { y: size * 0.58, radius: size * 0.82 },\n { y: size * 0.84, radius: size * 0.34 },\n ] as Level[], 7, [0, 0, 0], new Color().setHSL(\n (hue + jitter(random, 0.03) + 1) % 1,\n 0.52 + random() * 0.2,\n 0.3 + random() * 0.12,\n ))\n\n // Placement: golden-angle spiral plus a distance growing with the square\n // root, and — critically — the heap RESTS ON THE BASE.\n //\n // It used to be positioned relative to the rim, which is wrong for any\n // basket deeper than a fruit: the produce hung near the mouth with a\n // gap underneath it. Fruit sits at the bottom and piles up from there;\n // if there is more of it than the basket holds, the heap rises past\n // the rim, which is also what really happens.\n const angle = i * 2.399963\n const ring = Math.sqrt((i + 0.4) / count)\n const inner = Math.max(size, bottomRadius * 0.92 - size * 0.6)\n const spread = inner * ring\n // Fewer fruit per layer means MORE layers, and it is the layer count\n // that decides whether the heap ever reaches the mouth. At 0.55 the\n // default nine apples formed two layers topping out 12 cm below a rim\n // 10 cm up: correctly resting on the base, and completely invisible.\n // Resting on the base was the fix for an earlier bug where the produce\n // hung level with the rim over a gap; the fix was right and the\n // consequence -- that a deep basket then needs enough fruit to fill it\n // -- was not followed through.\n const layer = Math.floor(i / Math.max(3, Math.round(count * 0.38)))\n fruit.rotateX(jitter(random, 0.6))\n fruit.rotateZ(jitter(random, 0.6))\n fruit.translate(\n Math.sin(angle) * spread,\n // Base top + one radius = resting on the floor of the basket.\n // Layers nest at 0.95 of a diameter, not 1.5: stacked fruit settles\n // into the gaps of the layer below rather than sitting on top of it.\n -half + config.height * 0.05 + size * (0.92 + layer * 0.95)\n - ring * size * 0.28 + jitter(random, size * 0.08),\n Math.cos(angle) * spread,\n )\n contents.push(fruit)\n }\n\n return {\n weave: { slot: 'oak' as const, geometry: mergeColoured(pieces) },\n rim: { slot: 'oak' as const, geometry: rim },\n contents: contents.length > 0\n ? { slot: 'produce' as const, geometry: mergeColoured(contents) }\n : undefined,\n }\n },\n }, overrides)\n}\n",
2436
+ "hash": "596b709436ce9ab344fd43143f6ba954dac400f38c2ec0389cafcdfc3896199a"
2437
+ }
2438
+ ],
2439
+ "meta": {
2440
+ "title": "Wicker Basket",
2441
+ "description": "Basket woven from willow rods. Horizontal rods pass in front of one upright, behind the next.",
2442
+ "category": "Props",
2443
+ "tags": [
2444
+ "medieval",
2445
+ "lowpoly",
2446
+ "props",
2447
+ "farm",
2448
+ "procedural"
2449
+ ],
2450
+ "controls": {
2451
+ "height": {
2452
+ "type": "number",
2453
+ "label": "Height",
2454
+ "min": 0.1,
2455
+ "max": 0.5,
2456
+ "step": 0.01,
2457
+ "unit": "m"
2458
+ },
2459
+ "radius": {
2460
+ "type": "number",
2461
+ "label": "Rim radius",
2462
+ "min": 0.08,
2463
+ "max": 0.35,
2464
+ "step": 0.005,
2465
+ "unit": "m"
2466
+ },
2467
+ "taper": {
2468
+ "type": "number",
2469
+ "label": "Base taper",
2470
+ "min": 0,
2471
+ "max": 0.45,
2472
+ "step": 0.01
2473
+ },
2474
+ "stakes": {
2475
+ "type": "number",
2476
+ "label": "Upright rods",
2477
+ "min": 6,
2478
+ "max": 18,
2479
+ "step": 1
2480
+ },
2481
+ "rows": {
2482
+ "type": "number",
2483
+ "label": "Weave rows",
2484
+ "min": 2,
2485
+ "max": 14,
2486
+ "step": 1
2487
+ },
2488
+ "produce": {
2489
+ "type": "number",
2490
+ "label": "Produce count",
2491
+ "min": 0,
2492
+ "max": 24,
2493
+ "step": 1
2494
+ },
2495
+ "hue": {
2496
+ "type": "number",
2497
+ "label": "Produce hue",
2498
+ "min": 0,
2499
+ "max": 1,
2500
+ "step": 0.01
2501
+ },
2502
+ "seed": {
2503
+ "type": "number",
2504
+ "label": "Variation seed",
2505
+ "min": 1,
2506
+ "max": 64,
2507
+ "step": 1
2508
+ }
2509
+ },
2510
+ "materialSlots": [
2511
+ "oak",
2512
+ "produce"
2513
+ ],
2514
+ "parts": [
2515
+ "weave",
2516
+ "rim",
2517
+ "contents"
2518
+ ],
2519
+ "sockets": []
2520
+ }
2521
+ },
2522
+ {
2523
+ "name": "wooden-barrel",
2524
+ "type": "vibe3d:model",
2525
+ "title": "Wooden Barrel",
2526
+ "description": "Lowpoly barrel built from separate oak staves, with iron hoops and a recessed head.",
2527
+ "dependencies": [
2528
+ "three@>=0.185.0"
2529
+ ],
2530
+ "registryDependencies": [
2531
+ "@medieval-kit/core"
2532
+ ],
2533
+ "files": [
2534
+ {
2535
+ "path": "models/wooden-barrel/model.ts",
2536
+ "target": "{models}/medieval-kit/wooden-barrel/model.ts",
2537
+ "content": "/**\r\n* @medieval-kit/wooden-barrel\r\n*\r\n* A real barrel is not a single piece: it is built from separate boards\r\n* (staves) that narrow towards the ends, squeezed together by iron hoops, its\r\n* head sunk into the body, and the staves leave a collar (the chime) above the\r\n* head. This model builds it that way — not as an inflated cylinder.\r\n*\r\n* Dependencies: plain `three` and `@medieval-kit/core`. It never touches\r\n* scifi-kit's primitive/wear pipeline; WebGL is enough.\r\n*/\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n MEDIEVAL_PALETTE,\r\n bandGeometry,\r\n createKitModel,\r\n createRandom,\r\n headGeometry,\r\n jitter,\r\n mergeColoured,\r\n prismGeometry,\r\n staveGeometry,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface WoodenBarrelConfig {\r\n /** Total height (metres). */\r\n readonly height: number\r\n /** Outer radius at the belly (bilge), in metres. */\r\n readonly radius: number\r\n /** How far the ends narrow against the belly. 0.16 = ends at 84% width. */\r\n readonly taper: number\r\n /** Stave count. Odd by default, because it breaks perfect symmetry. */\r\n readonly staveCount: number\r\n /** Iron hoop count. */\r\n readonly hoopCount: number\r\n /**\r\n * Rivets per hoop.\r\n *\r\n * A hoop is a strip of iron bent into a circle and riveted where its ends\r\n * overlap; a cooper then adds more along its length to stop it springing.\r\n * They are small, but they are the detail that makes a dark band read as\r\n * FORGED IRON rather than as a painted stripe, so they earn their triangles.\r\n */\r\n readonly rivets: number\r\n /** Variation seed. The same seed always gives the same barrel. */\r\n readonly seed: number\r\n}\r\n\r\nexport const woodenBarrelDefaults: WoodenBarrelConfig = {\r\n height: 1.04,\r\n radius: 0.41,\r\n taper: 0.17,\r\n // Stave and hoop counts come from a reference photograph of a real cask,\r\n // not from taste. Thirteen staves left a visibly polygonal silhouette and\r\n // four hoops read as too few for the height.\r\n staveCount: 17,\r\n hoopCount: 6,\r\n rivets: 6,\r\n seed: 7,\r\n}\r\n\r\nexport type WoodenBarrelParts = 'staves' | 'heads' | 'hoops'\r\n\r\nconst SLOTS = ['oak', 'iron'] as const\r\ntype Slot = (typeof SLOTS)[number]\r\n\r\n/** Barrel profile: t ∈ [-1,1], narrow at the ends, wide at the belly. */\r\nfunction profileAt(t: number, taper: number): number {\r\n return 1 - taper * t * t\r\n}\r\n\r\n/**\r\n* The hoops sit symmetrically from the ends inwards: the outermost ones are the\r\n* \"chime\" (end) hoops, the inner ones the \"bilge\" (belly) hoops.\r\n*/\r\nfunction hoopPositions(count: number): number[] {\r\n if (count <= 0) return []\r\n if (count === 1) return [0]\r\n const outer = 0.87\r\n const inner = count <= 2 ? 0.87 : 0.33\r\n const pairs = Math.floor(count / 2)\r\n const positions: number[] = []\r\n for (let i = 0; i < pairs; i += 1) {\r\n const t = pairs === 1 ? outer : outer - (outer - inner) * (i / (pairs - 1))\r\n positions.push(t, -t)\r\n }\r\n if (count % 2 === 1) positions.push(0)\r\n return positions\r\n}\r\n\r\nexport function createModel(overrides: Partial<WoodenBarrelConfig> = {}) {\r\n return createKitModel<WoodenBarrelConfig, 'oak' | 'iron', WoodenBarrelParts>({\r\n id: 'wooden-barrel',\r\n defaults: woodenBarrelDefaults,\r\n slots: SLOTS,\r\n build: ({ config, random }) => {\r\n const half = config.height / 2\r\n // Five levels: ends, quarters and belly. Enough for a lowpoly barrel\r\n // curve; a sixth level adds nothing measurable to the silhouette.\r\n const levels = [-1, -0.58, 0, 0.58, 1]\r\n\r\n function buildStaves(random: () => number, half: number, levels: number[]): BufferGeometry {\r\n const wallThickness = config.radius * 0.13\r\n const step = (Math.PI * 2) / config.staveCount\r\n // A thin gap between the staves — the one detail that makes this read\r\n // as \"assembled\" instead of \"a single piece\".\r\n const gap = step * 0.055\r\n const tint = new Color()\r\n const pieces: BufferGeometry[] = []\r\n\r\n for (let index = 0; index < config.staveCount; index += 1) {\r\n // Every stave carries its own small deviations: radius, end height,\r\n // tone. Perfect repetition reads \"manufactured\"; rule: break mirrors.\r\n const radiusBias = 1 + jitter(random, 0.014)\r\n const topBias = jitter(random, 0.006)\r\n const bottomBias = jitter(random, 0.006)\r\n\r\n const shaped: Level[] = levels.map((t, level) => {\r\n const edge = level === 0 ? bottomBias : level === levels.length - 1 ? topBias : 0\r\n return {\r\n y: t * half + edge,\r\n radius: config.radius * profileAt(t, config.taper) * radiusBias,\r\n }\r\n })\r\n\r\n tint.copy(MEDIEVAL_PALETTE.oak)\r\n tint.offsetHSL(jitter(random, 0.014), jitter(random, 0.05), jitter(random, 0.055))\r\n\r\n pieces.push(staveGeometry(\r\n shaped,\r\n index * step + gap / 2,\r\n (index + 1) * step - gap / 2,\r\n wallThickness,\r\n tint,\r\n ))\r\n }\r\n\r\n return mergeColoured(pieces)\r\n }\r\n\r\n function buildHeads(random: () => number, half: number): BufferGeometry {\r\n const wallThickness = config.radius * 0.13\r\n const endRadius = config.radius * profileAt(1, config.taper)\r\n // The head seats INSIDE the body, a little back from the end; the\r\n // collar (chime) the staves leave above it makes a barrel a barrel.\r\n const seatRadius = endRadius - wallThickness * 0.9\r\n // Deep enough to SEE. At 0.055 the collar existed in the geometry\r\n // but was too shallow to read at any normal viewing distance, so the\r\n // head looked flush with the stave ends — the one silhouette detail\r\n // that distinguishes a cask from a bucket.\r\n const inset = config.height * 0.07\r\n const tint = new Color(MEDIEVAL_PALETTE.oakEnd)\r\n tint.offsetHSL(0, jitter(random, 0.03), jitter(random, 0.03))\r\n\r\n return mergeColoured([\r\n headGeometry(seatRadius, half - inset, config.staveCount, 'up', tint, 3, 0.06),\r\n headGeometry(seatRadius, -half + inset, config.staveCount, 'down', tint, 3, 0.06),\r\n ])\r\n }\r\n\r\n function buildHoops(random: () => number, half: number): BufferGeometry | undefined {\r\n const positions = hoopPositions(config.hoopCount)\r\n if (positions.length === 0) return undefined\r\n\r\n const tint = new Color()\r\n const pieces: BufferGeometry[] = []\r\n\r\n for (const t of positions) {\r\n const seat = config.radius * profileAt(t, config.taper)\r\n // The end hoops are wider: that is where the strain is greatest.\r\n const bandHeight = config.height * (0.045 + 0.03 * Math.abs(t))\r\n tint.copy(MEDIEVAL_PALETTE.iron)\r\n tint.offsetHSL(0, jitter(random, 0.02), jitter(random, 0.05))\r\n\r\n const bandRadius = seat + config.radius * 0.022\r\n pieces.push(bandGeometry(\r\n bandRadius,\r\n t * half,\r\n bandHeight,\r\n config.radius * 0.05,\r\n config.staveCount,\r\n tint,\r\n ))\r\n\r\n // Rivets. Centred ON the band's outer face, so the inner half is\r\n // buried in the iron rather than resting against it — the same\r\n // deliberate interpenetration the rest of the kit uses instead of\r\n // coplanar faces.\r\n // Wide and shallow. The first attempt was long and thin and read\r\n // as a row of nails driven through the hoop; a rivet is a domed\r\n // button that barely stands off the iron. Only a quarter of the\r\n // length shows — the rest is buried in the band.\r\n const studLength = config.radius * 0.05\r\n const studRadius = config.radius * 0.042\r\n // Each hoop's rivets start at their own angle. Aligning them into\r\n // vertical columns is the giveaway that a thing was generated.\r\n const phase = random() * Math.PI * 2\r\n // Rivets are struck iron: their domes are polished by the hammer\r\n // and by every hand that has ever moved the cask, so they catch\r\n // light the flat band does not. Without this they were the right\r\n // shape in the right place and still invisible against the hoop.\r\n const studTint = tint.clone()\r\n studTint.offsetHSL(0, 0, 0.09)\r\n for (let i = 0; i < config.rivets; i += 1) {\r\n const angle = phase + (i / config.rivets) * Math.PI * 2\r\n // The head FLARES outward, it does not taper. That is what a\r\n // hammered rivet actually looks like — the smith spreads the end\r\n // over the iron — and it is also the only version that keeps the\r\n // radial winding check honest: a stud narrowing outwards tilts\r\n // its side normals ~9.5 degrees back towards the barrel axis,\r\n // which the check correctly reports as faces pointing inward.\r\n const stud = prismGeometry(\r\n studRadius * 0.72,\r\n studRadius,\r\n studLength,\r\n 4,\r\n [0, 0, 0],\r\n studTint,\r\n // No back face. The rivet's inner end is buried in the band, so\r\n // that cap is never visible — the same saving `bandGeometry`\r\n // makes on the inside of a hoop. It is not only free: once the\r\n // stud is seated deep enough to look right, the buried cap\r\n // falls inside the 94% shell the radial check inspects, and a\r\n // cap pointing at the axis is exactly what that check hunts.\r\n { capBottom: false },\r\n )\r\n stud.rotateX(Math.PI / 2)\r\n stud.rotateY(angle)\r\n const seatDepth = bandRadius - studLength * 0.25\r\n stud.translate(\r\n Math.sin(angle) * seatDepth,\r\n t * half,\r\n Math.cos(angle) * seatDepth,\r\n )\r\n pieces.push(stud)\r\n }\r\n }\r\n\r\n return mergeColoured(pieces)\r\n }\r\n\r\n // The call ORDER must be kept: the seeded randomness advances as a\r\n // stream, and if the order changes so does the geometry.\r\n const stavesPart = buildStaves(random, half, levels)\r\n const headsPart = buildHeads(random, half)\r\n const hoopsPart = buildHoops(random, half)\r\n\r\n return {\r\n staves: { slot: 'oak' as const, geometry: stavesPart },\r\n heads: { slot: 'oak' as const, geometry: headsPart },\r\n hoops: hoopsPart ? { slot: 'iron' as const, geometry: hoopsPart } : undefined,\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
2538
+ "hash": "1a226a0796b317fb9fd4af8618de27bd336c08d330774d7b0b6a97eee446aba3"
2539
+ }
2540
+ ],
2541
+ "meta": {
2542
+ "title": "Wooden Barrel",
2543
+ "description": "Lowpoly barrel built from separate oak staves, with iron hoops and a recessed head.",
2544
+ "category": "Props",
2545
+ "tags": [
2546
+ "medieval",
2547
+ "lowpoly",
2548
+ "props",
2549
+ "procedural"
2550
+ ],
2551
+ "controls": {
2552
+ "height": {
2553
+ "type": "number",
2554
+ "label": "Height",
2555
+ "min": 0.4,
2556
+ "max": 2,
2557
+ "step": 0.02,
2558
+ "unit": "m"
2559
+ },
2560
+ "radius": {
2561
+ "type": "number",
2562
+ "label": "Radius",
2563
+ "min": 0.15,
2564
+ "max": 0.9,
2565
+ "step": 0.01,
2566
+ "unit": "m"
2567
+ },
2568
+ "taper": {
2569
+ "type": "number",
2570
+ "label": "End taper",
2571
+ "min": 0,
2572
+ "max": 0.34,
2573
+ "step": 0.01
2574
+ },
2575
+ "staveCount": {
2576
+ "type": "number",
2577
+ "label": "Stave count",
2578
+ "min": 6,
2579
+ "max": 28,
2580
+ "step": 1
2581
+ },
2582
+ "hoopCount": {
2583
+ "type": "number",
2584
+ "label": "Hoop count",
2585
+ "min": 0,
2586
+ "max": 8,
2587
+ "step": 1
2588
+ },
2589
+ "rivets": {
2590
+ "type": "number",
2591
+ "label": "Rivets per hoop",
2592
+ "min": 0,
2593
+ "max": 8,
2594
+ "step": 1
2595
+ },
2596
+ "seed": {
2597
+ "type": "number",
2598
+ "label": "Variation seed",
2599
+ "min": 1,
2600
+ "max": 64,
2601
+ "step": 1
2602
+ }
2603
+ },
2604
+ "materialSlots": [
2605
+ "oak",
2606
+ "iron"
2607
+ ],
2608
+ "parts": [
2609
+ "staves",
2610
+ "heads",
2611
+ "hoops"
2612
+ ],
2613
+ "sockets": []
2614
+ }
2615
+ },
2616
+ {
2617
+ "name": "wooden-bench",
2618
+ "type": "vibe3d:model",
2619
+ "title": "Wooden Bench",
2620
+ "description": "Medieval bench with splayed legs, its tenons showing through the top of the seat.",
2621
+ "dependencies": [
2622
+ "three@>=0.185.0"
2623
+ ],
2624
+ "registryDependencies": [
2625
+ "@medieval-kit/core"
2626
+ ],
2627
+ "files": [
2628
+ {
2629
+ "path": "models/wooden-bench/model.ts",
2630
+ "target": "{models}/medieval-kit/wooden-bench/model.ts",
2631
+ "content": "/**\r\n * @medieval-kit/wooden-bench\r\n *\r\n * The bench that sits beside the trestle table. In the middle ages a chair was\r\n * a status object; what people actually sat on was a bench, so a hall scene\r\n * needs one even more than it needs the table.\r\n *\r\n * Its structure is a simplified version of the table's: two thick end boards, a\r\n * stretcher between them, the seat on top. But one thing differs from the\r\n * table — the seat IS fixed to the legs. The table's top could be lifted away,\r\n * a bench's seat cannot; so the legs are joined by tenons that run into the\r\n * seat, and those tenons show through the top of it. That is the signature of\r\n * medieval joinery.\r\n */\r\nimport {\r\n boxGeometry,\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n createTinter,\r\n jitter,\r\n mergeColoured,\r\n taperedBoxGeometry,\r\n} from '../core/index.ts'\r\n\r\nexport interface WoodenBenchConfig {\r\n /** Bench length (metres). */\r\n readonly length: number\r\n /** Seat height (metres). */\r\n readonly height: number\r\n /** Seat width (metres). */\r\n readonly width: number\r\n /** Outward splay of the legs. 0 = upright. */\r\n readonly splay: number\r\n /** How far the legs stand in from the ends, as a fraction of the length. */\r\n readonly inset: number\r\n readonly seed: number\r\n}\r\n\r\nexport const woodenBenchDefaults: WoodenBenchConfig = {\r\n length: 1.62,\r\n height: 0.45,\r\n width: 0.3,\r\n splay: 0.24,\r\n inset: 0.13,\r\n seed: 31,\r\n}\r\n\r\nexport type WoodenBenchParts = 'seat' | 'legs' | 'stretcher'\r\n\r\nexport function createModel(overrides: Partial<WoodenBenchConfig> = {}) {\r\n return createKitModel<WoodenBenchConfig, 'oak', WoodenBenchParts>({\r\n id: 'wooden-bench',\r\n defaults: woodenBenchDefaults,\r\n slots: ['oak'],\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const half = config.height / 2\r\n // 0.135 of the height, not 0.09.\r\n //\r\n // At 0.09 the seat came out 40 mm on a bench 1.62 m long -- one\r\n // fortieth of its own span, where the reference reads about one\r\n // twentieth. Medieval furniture is riven, not machined: the board is\r\n // whatever the log gave, and that is thick. A thin slab over a long\r\n // span also looks like it would flex, which is the specific way these\r\n // pieces were reading as flimsy.\r\n const seatThickness = config.height * 0.135\r\n const seatTop = half\r\n const seatBottom = seatTop - seatThickness\r\n const timber = config.width * 0.09\r\n\r\n // --- Seat ----------------------------------------------------------\r\n // A single thick board. Using two boards is not as natural on a bench\r\n // as it is on the table: the seat must not have a gap down the middle.\r\n const seatPieces = [chamferedBoxGeometry(\r\n [config.length, config.width * 0.95],\r\n [config.length * 0.995, config.width],\r\n seatThickness,\r\n timber * 0.4,\r\n [0, seatBottom + seatThickness / 2, 0],\r\n tint('oak', 0.05),\r\n )]\r\n\r\n // --- Legs ----------------------------------------------------------\r\n const legX = config.length * (0.5 - config.inset)\r\n const legHeight = seatBottom - (-half)\r\n const legWidth = config.width * 0.66\r\n const spread = legWidth * config.splay\r\n\r\n const legPieces = []\r\n for (const side of [-1, 1]) {\r\n // Leg board with an arch cut out of its foot.\r\n //\r\n // The board widens on the way down. The splay is in the measure, not\r\n // in the angle — widening the bottom face instead of rotating is both\r\n // cheaper and lets the foot sit FLAT on the ground, whereas a rotated\r\n // leg stands on its edge.\r\n //\r\n // The arch is not decoration. A plank leg standing on its whole edge\r\n // rocks on any floor that is not flat, and no medieval floor was; the\r\n // cut leaves two feet, and two feet on each board give the bench four\r\n // points to find the ground with. It is also the single most\r\n // recognisable thing about the silhouette, and the reference has it.\r\n //\r\n // It is cut by building the board in three pieces rather than\r\n // subtracting: the geometry here is all non-indexed triangle soup with\r\n // no boolean operations, so a notch is made by not filling it.\r\n const archHeight = legHeight * 0.3\r\n const archFrac = archHeight / legHeight\r\n const footWidth = legWidth + spread * 2\r\n const widthAtArch = legWidth + spread * 2 * (1 - archFrac)\r\n\r\n // Upper board: from the top of the arch to the underside of the seat.\r\n legPieces.push(taperedBoxGeometry(\r\n [widthAtArch, timber * 1.35],\r\n [legWidth, timber * 1.35],\r\n legHeight - archHeight,\r\n [side * legX, -half + archHeight + (legHeight - archHeight) / 2, 0],\r\n tint('oak', -0.02),\r\n ))\r\n\r\n // The two feet. They run PAST the top of the arch into the board above,\r\n // so the joint is an overlap rather than two faces meeting.\r\n const footThickness = footWidth * 0.3\r\n const footRise = archHeight + legHeight * 0.07\r\n\r\n /**\r\n * Each foot narrows on the way UP by exactly the board's own taper.\r\n *\r\n * This is the protrusion, and it was reported twice before I found it:\r\n * the first time I planed the through-tenons and answered the wrong\r\n * question. The feet were built at `footWidth`, the board's width at\r\n * the GROUND -- but a foot starts at the top of the arch, a third of\r\n * the way up, where the board has not widened that far yet. So every\r\n * foot stood proud of the board it hangs from, on both sides, as a\r\n * hard shoulder at shin height: 14 mm by default and 32 mm at full\r\n * splay. Setting the splay to zero made it vanish, which is what\r\n * finally named the cause.\r\n *\r\n * The leg's outer edge is a straight line from `footWidth / 2` at the\r\n * floor to `legWidth / 2` under the seat, and the foot's outer face\r\n * has to lie ON that line rather than run vertically up from its foot.\r\n * A tapered box narrows about its own centre, so taking twice the\r\n * taper off the top width moves the outer face in by exactly the taper\r\n * -- and moves the inner face out by the same, which narrows the arch\r\n * as it rises, which is also what a splayed leg does.\r\n */\r\n const footTaper = spread * (footRise / legHeight)\r\n const footTop = Math.max(footThickness * 0.3, footThickness - footTaper * 2)\r\n for (const foot of [-1, 1]) {\r\n legPieces.push(taperedBoxGeometry(\r\n [footThickness, timber * 1.35],\r\n [footTop, timber * 1.35],\r\n footRise,\r\n [\r\n side * legX + foot * (footWidth - footThickness) / 2,\r\n -half + footRise / 2,\r\n 0,\r\n ],\r\n tint('oak', -0.04),\r\n ))\r\n }\r\n\r\n /**\r\n * Tenon: HOUSED in the seat, stopping short of its top face.\r\n *\r\n * It was a through-tenon and it was reported twice. First at 42 mm\r\n * proud, a block of oak the size of a thumb standing out of the\r\n * surface you sit on; I planed it to 3 mm and called it done, and 3 mm\r\n * is still a raised patch with a lit edge on it, still visible in the\r\n * render, and still the thing being complained about. The brief is\r\n * that nothing on the seat shows, and 3 mm is not nothing — it is\r\n * small.\r\n *\r\n * So it ends INSIDE the slab, an eighth of the seat's thickness below\r\n * the top. That is a real joint and not a compromise: a stub tenon\r\n * into the underside of a board is how a bench is made when the top is\r\n * thick enough to take it, which at 0.135 of the height it is. What is\r\n * lost is the panel of end grain on top; what is gained is a seat.\r\n *\r\n * Ending it LEVEL with the top face was never an option, which is what\r\n * sent me to 3 mm proud in the first place: two upward faces in one\r\n * plane is the z-fight the checker exists to find. Buried has neither\r\n * problem — a face inside a solid cannot fight anything and cannot be\r\n * seen.\r\n */\r\n const buried = seatThickness * 0.12\r\n const housed = seatThickness * 0.16\r\n legPieces.push(chamferedBoxGeometry(\r\n [legWidth * 0.34, timber * 0.85],\r\n [legWidth * 0.32, timber * 0.8],\r\n seatThickness - buried + housed,\r\n timber * 0.16,\r\n [\r\n side * legX,\r\n seatBottom + (seatThickness - buried - housed) / 2,\r\n jitter(random, timber * 0.1),\r\n ],\r\n tint('oak', 0.09),\r\n ))\r\n }\r\n\r\n // --- Stretcher -----------------------------------------------------\r\n // The batten tying the two legs together. It runs INTO the legs: its\r\n // ends stay inside solid material so that no face ends up coplanar.\r\n const stretcherY = -half + legHeight * 0.34\r\n const stretcher = mergeColoured([boxGeometry(\r\n [legX * 2 + legWidth * 0.4, timber * 1.5, timber * 0.95],\r\n [0, stretcherY, 0],\r\n tint('oak', -0.06),\r\n )])\r\n\r\n return {\r\n seat: { slot: 'oak' as const, geometry: mergeColoured(seatPieces) },\r\n legs: { slot: 'oak' as const, geometry: mergeColoured(legPieces) },\r\n stretcher: { slot: 'oak' as const, geometry: stretcher },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
2632
+ "hash": "ddba6c89ac114bf2cc31c6e8df1631ec97a0dae79ad2b06de4cbdbdbc78c556e"
2633
+ }
2634
+ ],
2635
+ "meta": {
2636
+ "title": "Wooden Bench",
2637
+ "description": "Medieval bench with splayed legs, its tenons showing through the top of the seat.",
2638
+ "category": "Furniture",
2639
+ "tags": [
2640
+ "medieval",
2641
+ "lowpoly",
2642
+ "furniture",
2643
+ "procedural"
2644
+ ],
2645
+ "controls": {
2646
+ "length": {
2647
+ "type": "number",
2648
+ "label": "Length",
2649
+ "min": 0.6,
2650
+ "max": 3,
2651
+ "step": 0.02,
2652
+ "unit": "m"
2653
+ },
2654
+ "height": {
2655
+ "type": "number",
2656
+ "label": "Height",
2657
+ "min": 0.28,
2658
+ "max": 0.7,
2659
+ "step": 0.01,
2660
+ "unit": "m"
2661
+ },
2662
+ "width": {
2663
+ "type": "number",
2664
+ "label": "Width",
2665
+ "min": 0.18,
2666
+ "max": 0.5,
2667
+ "step": 0.01,
2668
+ "unit": "m"
2669
+ },
2670
+ "splay": {
2671
+ "type": "number",
2672
+ "label": "Leg splay",
2673
+ "min": 0,
2674
+ "max": 0.6,
2675
+ "step": 0.02
2676
+ },
2677
+ "inset": {
2678
+ "type": "number",
2679
+ "label": "Leg inset",
2680
+ "min": 0.02,
2681
+ "max": 0.3,
2682
+ "step": 0.01
2683
+ },
2684
+ "seed": {
2685
+ "type": "number",
2686
+ "label": "Variation seed",
2687
+ "min": 1,
2688
+ "max": 64,
2689
+ "step": 1
2690
+ }
2691
+ },
2692
+ "materialSlots": [
2693
+ "oak"
2694
+ ],
2695
+ "parts": [
2696
+ "seat",
2697
+ "legs",
2698
+ "stretcher"
2699
+ ],
2700
+ "sockets": []
2701
+ }
2702
+ },
2703
+ {
2704
+ "name": "wooden-bucket",
2705
+ "type": "vibe3d:model",
2706
+ "title": "Wooden Bucket",
2707
+ "description": "Tapering oak staves, an iron hoop and a forged handle — a small barrel.",
2708
+ "dependencies": [
2709
+ "three@>=0.185.0"
2710
+ ],
2711
+ "registryDependencies": [
2712
+ "@medieval-kit/core"
2713
+ ],
2714
+ "files": [
2715
+ {
2716
+ "path": "models/wooden-bucket/model.ts",
2717
+ "target": "{models}/medieval-kit/wooden-bucket/model.ts",
2718
+ "content": "/**\n * @medieval-kit/wooden-bucket\n *\n * A bucket is really a small barrel: tapering staves, an iron hoop, a base sunk\n * inside. It using the same `staveGeometry` as the barrel is no accident — so\n * that when the two stand side by side you read at a glance that they come from\n * the same catalogue.\n *\n * Where it differs from the barrel: conical (no belly), open at the top, and it\n * has an iron handle.\n */\nimport { Color, type BufferGeometry } from 'three'\n\nimport {\n MEDIEVAL_PALETTE,\n arcBarGeometry,\n bandGeometry,\n createKitModel,\n headGeometry,\n jitter,\n mergeColoured,\n staveGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface WoodenBucketConfig {\n /** Height (metres). */\n readonly height: number\n /** Mouth radius. The base is always narrower. */\n readonly radius: number\n /** How far the base narrows against the mouth. 0.25 = base at 75% width. */\n readonly taper: number\n /** Stave count. */\n readonly staveCount: number\n /** Iron hoop count. */\n readonly hoopCount: number\n /** Handle present (1) or not (0). */\n readonly handle: number\n readonly seed: number\n}\n\nexport const woodenBucketDefaults: WoodenBucketConfig = {\n height: 0.32,\n radius: 0.15,\n taper: 0.26,\n staveCount: 11,\n hoopCount: 2,\n handle: 1,\n seed: 5,\n}\n\nexport type WoodenBucketParts = 'staves' | 'base' | 'hoops' | 'handle'\n\n/** t ∈ [0,1], 0 = base, 1 = mouth. */\nfunction profileAt(t: number, taper: number): number {\n return 1 - taper * (1 - t)\n}\n\nexport function createModel(overrides: Partial<WoodenBucketConfig> = {}) {\n return createKitModel<WoodenBucketConfig, 'oak' | 'iron', WoodenBucketParts>({\n id: 'wooden-bucket',\n defaults: woodenBucketDefaults,\n slots: ['oak', 'iron'],\n build: ({ config, random }) => {\n const half = config.height / 2\n const wall = config.radius * 0.11\n const tint = new Color()\n\n // --- wall staves ---\n const step = (Math.PI * 2) / config.staveCount\n // NO GAP between the staves. On the barrel a visible seam looked good,\n // but a bucket carries water: the 4 mm slots between the 11 staves turned\n // the bucket into a sieve. The seam now reads out of the per-stave radius\n // deviation — every stave that comes out slightly different from its\n // neighbour casts its own shadow, without leaving a hole.\n const gap = 0\n const levels = [0, 0.5, 1]\n const staves: BufferGeometry[] = []\n\n for (let i = 0; i < config.staveCount; i += 1) {\n const bias = 1 + jitter(random, 0.006)\n const rimBias = jitter(random, 0.004)\n const shaped: Level[] = levels.map((t, index) => ({\n y: -half + t * config.height + (index === levels.length - 1 ? rimBias : 0),\n radius: config.radius * profileAt(t, config.taper) * bias,\n }))\n tint.copy(MEDIEVAL_PALETTE.oak)\n tint.offsetHSL(jitter(random, 0.014), jitter(random, 0.05), jitter(random, 0.06))\n staves.push(staveGeometry(shaped, i * step + gap / 2, (i + 1) * step - gap / 2, wall, tint))\n }\n\n // --- base: seats inside the body ---\n const baseRadius = config.radius * profileAt(0, config.taper) - wall * 0.85\n tint.copy(MEDIEVAL_PALETTE.oakEnd)\n tint.offsetHSL(0, jitter(random, 0.03), jitter(random, 0.04))\n const base = headGeometry(baseRadius, -half + config.height * 0.07, config.staveCount, 'up', tint, 3, 0.05)\n\n // --- iron hoops ---\n const hoops: BufferGeometry[] = []\n for (let i = 0; i < config.hoopCount; i += 1) {\n // From the top and the bottom inwards; a single hoop sits in the middle.\n const t = config.hoopCount === 1 ? 0.5 : 0.14 + (0.72 * i) / (config.hoopCount - 1)\n tint.copy(MEDIEVAL_PALETTE.iron)\n tint.offsetHSL(0, jitter(random, 0.02), jitter(random, 0.05))\n hoops.push(bandGeometry(\n config.radius * profileAt(t, config.taper) + config.radius * 0.02,\n -half + t * config.height,\n config.height * 0.055,\n config.radius * 0.05,\n config.staveCount,\n tint,\n ))\n }\n\n // --- handle (bail): a half arc just above the mouth ---\n let handle: BufferGeometry | undefined\n if (config.handle >= 0.5) {\n tint.copy(MEDIEVAL_PALETTE.iron)\n tint.offsetHSL(0, jitter(random, 0.02), jitter(random, 0.04))\n const span = config.radius * profileAt(1, config.taper) + config.radius * 0.02\n // The arc is produced in the XY plane; since the bucket's axis is Y it\n // stands as it is, and is only shifted up level with the mouth.\n handle = arcBarGeometry(span, config.radius * 0.055, 0, Math.PI, 9, [0, half * 0.92, 0], tint)\n }\n\n return {\n staves: { slot: 'oak', geometry: mergeColoured(staves) },\n base: { slot: 'oak', geometry: base },\n hoops: hoops.length ? { slot: 'iron', geometry: mergeColoured(hoops) } : undefined,\n handle: handle ? { slot: 'iron', geometry: handle } : undefined,\n }\n },\n }, overrides)\n}\n",
2719
+ "hash": "0b87c1305c5d92c37b40967b05fbffa0d8d8bd092a8a17e9adc8a9a0f8e66eaf"
2720
+ }
2721
+ ],
2722
+ "meta": {
2723
+ "title": "Wooden Bucket",
2724
+ "description": "Tapering oak staves, an iron hoop and a forged handle — a small barrel.",
2725
+ "category": "Props",
2726
+ "tags": [
2727
+ "medieval",
2728
+ "lowpoly",
2729
+ "props",
2730
+ "farm",
2731
+ "procedural"
2732
+ ],
2733
+ "controls": {
2734
+ "height": {
2735
+ "type": "number",
2736
+ "label": "Height",
2737
+ "min": 0.15,
2738
+ "max": 0.6,
2739
+ "step": 0.01,
2740
+ "unit": "m"
2741
+ },
2742
+ "radius": {
2743
+ "type": "number",
2744
+ "label": "Rim radius",
2745
+ "min": 0.07,
2746
+ "max": 0.3,
2747
+ "step": 0.005,
2748
+ "unit": "m"
2749
+ },
2750
+ "taper": {
2751
+ "type": "number",
2752
+ "label": "Base taper",
2753
+ "min": 0,
2754
+ "max": 0.45,
2755
+ "step": 0.01
2756
+ },
2757
+ "staveCount": {
2758
+ "type": "number",
2759
+ "label": "Stave count",
2760
+ "min": 6,
2761
+ "max": 20,
2762
+ "step": 1
2763
+ },
2764
+ "hoopCount": {
2765
+ "type": "number",
2766
+ "label": "Hoop count",
2767
+ "min": 0,
2768
+ "max": 4,
2769
+ "step": 1
2770
+ },
2771
+ "handle": {
2772
+ "type": "number",
2773
+ "label": "Handle",
2774
+ "min": 0,
2775
+ "max": 1,
2776
+ "step": 1
2777
+ },
2778
+ "seed": {
2779
+ "type": "number",
2780
+ "label": "Variation seed",
2781
+ "min": 1,
2782
+ "max": 64,
2783
+ "step": 1
2784
+ }
2785
+ },
2786
+ "materialSlots": [
2787
+ "oak",
2788
+ "iron"
2789
+ ],
2790
+ "parts": [
2791
+ "staves",
2792
+ "base",
2793
+ "hoops",
2794
+ "handle"
2795
+ ],
2796
+ "sockets": []
2797
+ }
2798
+ },
2799
+ {
2800
+ "name": "wooden-chest",
2801
+ "type": "vibe3d:model",
2802
+ "title": "Wooden Chest",
2803
+ "description": "Six-board medieval chest with iron bands. The lid is hinged and opens on an action.",
2804
+ "dependencies": [
2805
+ "three@>=0.185.0"
2806
+ ],
2807
+ "registryDependencies": [
2808
+ "@medieval-kit/core"
2809
+ ],
2810
+ "files": [
2811
+ {
2812
+ "path": "models/wooden-chest/model.ts",
2813
+ "target": "{models}/medieval-kit/wooden-chest/model.ts",
2814
+ "content": "/**\n * @medieval-kit/wooden-chest\n *\n * The six-board chest — the dominant chest form of the middle ages. The\n * barrel-lidded coffer of pirate films is actually a far later thing; the chest\n * of the period is a flat-lidded box whose two end boards run down to the floor\n * to form the feet, its front girded with iron straps.\n *\n * The kit's first model with ACTIONS. Opening the lid is not a `configure()`\n * job: the chest's identity does not change, only its state in the scene does.\n * The protocol's `actions` field exists for exactly this.\n *\n * The real issue behind the lid is that the parts must be siblings: when the\n * lid turns, the iron straps on it and the lock hasp have to turn with it. Were\n * they separate parts they would hang in mid-air. That is why they are all\n * `extras` bodies of a single part — the meaning is not split, only the material.\n */\nimport { Color } from 'three'\n\nimport {\n MEDIEVAL_PALETTE,\n boxGeometry,\n chamferedBoxGeometry,\n createKitModel,\n ironTint,\n jitter,\n mergeColoured,\n prismGeometry,\n} from '../core/index.ts'\n\nexport interface WoodenChestConfig {\n /** Width — the long side (metres). */\n readonly width: number\n /** Total height with the lid closed (metres). */\n readonly height: number\n /** Depth (metres). */\n readonly depth: number\n /** Number of vertical iron straps on the front and back faces. */\n readonly bandCount: number\n /** The angle the lid makes when fully open (degrees). */\n readonly openAngle: number\n readonly seed: number\n}\n\nexport const woodenChestDefaults: WoodenChestConfig = {\n width: 0.82,\n height: 0.5,\n depth: 0.44,\n bandCount: 3,\n openAngle: 104,\n seed: 23,\n}\n\nexport type WoodenChestParts = 'body' | 'lid' | 'bands' | 'lock'\n\nexport interface WoodenChestActions {\n /** Sets the target state. The motion advances as `update()` is called. */\n setOpen(open: boolean): void\n /** Open ↔ close. Returns the new TARGET state. */\n toggle(): boolean\n /** The target state — the motion may not have finished. */\n isOpen(): boolean\n /** Instantaneous progress of the motion: 0 closed, 1 fully open. */\n openness(): number\n /** Skips the motion and snaps straight to the target. */\n snap(): void\n}\n\nexport function createModel(overrides: Partial<WoodenChestConfig> = {}) {\n // The lid state is kept OUTSIDE the build. Even when `configure()` rebuilds\n // the chest the lid has to stay open — otherwise changing the width would\n // slam it shut.\n let target = 0\n let progress = 0\n\n return createKitModel<WoodenChestConfig, 'oak' | 'iron', WoodenChestParts, WoodenChestActions>({\n id: 'wooden-chest',\n defaults: woodenChestDefaults,\n slots: ['oak', 'iron'],\n\n build: ({ config, random }) => {\n const tint = new Color()\n const oak = (lift = 0): Color => {\n tint.copy(MEDIEVAL_PALETTE.oak)\n tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), lift + jitter(random, 0.05))\n return tint\n }\n\n const half = config.height / 2\n // Board thickness follows the SMALLEST dimension. Tying it to the depth\n // alone gave a deep chest thick boards — a real chest board is ~2 cm\n // regardless of how big the chest is.\n const board = Math.min(config.width * 0.5, config.height, config.depth) * 0.055\n const lidThickness = config.height * 0.075\n const footHeight = config.height * 0.14\n const bodyTop = half - lidThickness\n const bodyFloor = -half + footHeight\n const wallHeight = bodyTop - bodyFloor\n const strap = board * 0.3 // iron strap thickness\n\n // --- BODY ----------------------------------------------------------\n const bodyPieces = []\n\n // End boards: the chest's load bearers. Down below they split in two and\n // become the feet — the detail that makes a six-board chest recognisable.\n for (const side of [-1, 1]) {\n const x = side * (config.width / 2 - board / 2)\n bodyPieces.push(chamferedBoxGeometry(\n [board, config.depth],\n [board, config.depth],\n wallHeight,\n board * 0.22,\n [x, bodyFloor + wallHeight / 2, 0],\n oak(0.02),\n ))\n\n // The feet. They are extended INTO the end board: their top faces stay\n // inside solid material so that no pair of faces ends up coplanar.\n const footDepth = config.depth * 0.3\n for (const end of [-1, 1]) {\n bodyPieces.push(boxGeometry(\n [board * 1.16, footHeight + board * 0.6, footDepth],\n [x, -half + (footHeight + board * 0.6) / 2, end * (config.depth / 2 - footDepth / 2)],\n oak(-0.05),\n ))\n }\n }\n\n // Front and back boards: they fit between the end boards and sink a\n // little way into them at their ends (the z-fighting rule).\n for (const face of [1, -1]) {\n bodyPieces.push(chamferedBoxGeometry(\n [config.width - board * 1.3, board],\n [config.width - board * 1.3, board],\n wallHeight - board * 0.36,\n board * 0.2,\n [0, bodyFloor + wallHeight / 2, face * (config.depth / 2 - board / 2)],\n oak(face > 0 ? 0.03 : -0.02),\n ))\n }\n\n // Floor: it sinks into all four walls.\n bodyPieces.push(boxGeometry(\n [config.width - board * 1.3, board, config.depth - board * 1.3],\n [0, bodyFloor + board * 0.6, 0],\n oak(-0.08),\n ))\n\n // --- LID -----------------------------------------------------------\n // Hinge: the top rear edge. The lid geometry is written RELATIVE TO THAT\n // POINT, the anchor is moved there, and `rotation.x` now opens the lid.\n const overhang = board * 0.85\n const lidDepth = config.depth + overhang\n const lid = mergeColoured([chamferedBoxGeometry(\n [config.width + overhang * 2, lidDepth],\n [config.width + overhang * 1.7, lidDepth - overhang * 0.15],\n lidThickness,\n board * 0.18,\n [0, lidThickness / 2 - board * 0.3, lidDepth / 2],\n oak(0.06),\n )])\n\n // --- IRON ----------------------------------------------------------\n // The straps end on the body and continue on the lid. Closed they read as\n // a single piece, open they part along the hinge line — that is exactly\n // what a real strap hinge is.\n const count = Math.max(0, Math.round(config.bandCount))\n const strapWidth = config.width * 0.055\n const bandXs = Array.from({ length: count }, (_, i) =>\n count === 1 ? 0 : (i / (count - 1) - 0.5) * config.width * 0.66)\n\n const bandPieces = []\n const lidIron = []\n\n // The middle of the front face is the LOCK's place. On a real chest no\n // strap goes there — the escutcheon already does that job. The rule is\n // both correct and closes off a whole class of bug at the root: with the\n // strap and the lock bridge in the same place, at certain dimensions\n // their top faces became coplanar and shimmered.\n const lockSpan = config.width * 0.075 + strapWidth * 0.7\n const clearsLock = (x: number): boolean => Math.abs(x) > lockSpan\n\n for (const x of bandXs) {\n const front = clearsLock(x)\n for (const face of [1, -1]) {\n if (face > 0 && !front) continue\n const z = face * (config.depth / 2 + strap * 0.2)\n // Body strap: it stays entirely WITHIN the board's Y range, otherwise\n // its bottom and top faces end up coplanar with the board's.\n bandPieces.push(boxGeometry(\n [strapWidth, wallHeight - board * 0.9, strap],\n [x, bodyFloor + wallHeight / 2, z],\n ironTint(random, -0.02),\n ))\n }\n\n if (front) {\n // Lid strap: it reaches from the top towards the back and ends on the hinge line.\n lidIron.push(boxGeometry(\n [strapWidth, strap, lidDepth * 0.92],\n [x, lidThickness + strap * 0.3, lidDepth * 0.46],\n ironTint(random, 0.02),\n ))\n // The end that curls down over the front edge.\n lidIron.push(boxGeometry(\n [strapWidth, lidThickness * 1.5, strap],\n [x, lidThickness * 0.35, lidDepth - strap * 0.2],\n ironTint(random),\n ))\n }\n }\n\n // The hinge cylinders must sit EXACTLY at the local origin: that is the\n // axis of rotation. One nudge off was enough for the hinge to sweep an arc\n // of its own as the lid opened — a real hinge turns on its own axis, it\n // does not travel.\n //\n // The hexagonal prism's flat faces are tilted too: a face lying horizontal\n // became coplanar with the lid's underside and shimmered.\n const barrel = strap * 1.45\n for (const x of bandXs) {\n const pin = prismGeometry(barrel, barrel, strapWidth * 1.2, 6, [0, 0, 0],\n ironTint(random, 0.04))\n pin.rotateZ(Math.PI / 2)\n pin.rotateX(Math.PI / 12)\n pin.translate(x, 0, 0)\n lidIron.push(pin)\n }\n\n // A vertical strap on each end board as well — the chest should be ironed\n // from every side.\n for (const side of [-1, 1]) {\n bandPieces.push(boxGeometry(\n [strap, wallHeight * 0.78, config.depth * 0.16],\n [side * (config.width / 2 + strap * 0.2), bodyFloor + wallHeight / 2, 0],\n ironTint(random, -0.04),\n ))\n }\n\n // --- LOCK ----------------------------------------------------------\n // The escutcheon on the body, the hasp on the lid. The two have to be in\n // separate places: one is fixed, the other rises with the lid.\n const plateHeight = config.height * 0.19\n const lockZ = config.depth / 2 + strap * 0.2\n const lock = mergeColoured([\n chamferedBoxGeometry(\n [config.width * 0.15, strap * 1.6],\n [config.width * 0.115, strap * 1.6],\n plateHeight,\n strap * 0.5,\n [0, bodyTop - plateHeight * 0.62, lockZ],\n ironTint(random, 0.05),\n ),\n // The bridge the hasp passes through.\n boxGeometry(\n [config.width * 0.05, strap * 2.2, strap * 2.6],\n [0, bodyTop - plateHeight * 0.32, lockZ + strap * 0.9],\n ironTint(random, 0.09),\n ),\n ])\n\n // Hasp: it hangs down from the lid's front edge and seats over the bridge.\n lidIron.push(boxGeometry(\n [config.width * 0.075, plateHeight * 0.62, strap * 1.2],\n [0, lidThickness * 0.5 - plateHeight * 0.31, lidDepth + strap * 0.35],\n ironTint(random, 0.07),\n ))\n\n return {\n body: { slot: 'oak' as const, geometry: mergeColoured(bodyPieces) },\n lid: {\n slot: 'oak' as const,\n geometry: lid,\n origin: [0, bodyTop, -config.depth / 2] as const,\n extras: [{ slot: 'iron' as const, geometry: mergeColoured(lidIron) }],\n },\n bands: { slot: 'iron' as const, geometry: mergeColoured(bandPieces) },\n lock: { slot: 'iron' as const, geometry: lock },\n }\n },\n\n actions: ({ parts, getConfig }) => {\n const apply = (): void => {\n parts.lid.anchor.rotation.x = -(getConfig().openAngle * Math.PI / 180) * progress\n }\n apply()\n return {\n setOpen: (open) => { target = open ? 1 : 0 },\n toggle: () => { target = target > 0.5 ? 0 : 1; return target > 0.5 },\n isOpen: () => target > 0.5,\n openness: () => progress,\n snap: () => { progress = target; apply() },\n }\n },\n\n update: (dt, { parts, getConfig }) => {\n if (progress === target) return\n // Exponential approach: INDEPENDENT of frame time. A naive lerp such as\n // `progress += diff * k` would open slower at 30 fps than at 120 fps.\n progress += (target - progress) * (1 - Math.exp(-9 * Math.max(0, dt)))\n // An exponential approach never actually arrives; at the threshold it is\n // snapped so that `openness()` can really return 1.\n if (Math.abs(target - progress) < 0.0015) progress = target\n parts.lid.anchor.rotation.x = -(getConfig().openAngle * Math.PI / 180) * progress\n },\n }, overrides)\n}\n",
2815
+ "hash": "b9dff48b7d6121b1f7a48ce7a88b5a75459a6e9b0b2f26ee705a85042c65ab81"
2816
+ }
2817
+ ],
2818
+ "meta": {
2819
+ "title": "Wooden Chest",
2820
+ "description": "Six-board medieval chest with iron bands. The lid is hinged and opens on an action.",
2821
+ "category": "Furniture",
2822
+ "tags": [
2823
+ "medieval",
2824
+ "lowpoly",
2825
+ "furniture",
2826
+ "interactive",
2827
+ "procedural"
2828
+ ],
2829
+ "controls": {
2830
+ "width": {
2831
+ "type": "number",
2832
+ "label": "Width",
2833
+ "min": 0.4,
2834
+ "max": 1.6,
2835
+ "step": 0.02,
2836
+ "unit": "m"
2837
+ },
2838
+ "height": {
2839
+ "type": "number",
2840
+ "label": "Height",
2841
+ "min": 0.28,
2842
+ "max": 0.9,
2843
+ "step": 0.02,
2844
+ "unit": "m"
2845
+ },
2846
+ "depth": {
2847
+ "type": "number",
2848
+ "label": "Depth",
2849
+ "min": 0.24,
2850
+ "max": 0.8,
2851
+ "step": 0.02,
2852
+ "unit": "m"
2853
+ },
2854
+ "bandCount": {
2855
+ "type": "number",
2856
+ "label": "Band count",
2857
+ "min": 0,
2858
+ "max": 6,
2859
+ "step": 1
2860
+ },
2861
+ "openAngle": {
2862
+ "type": "number",
2863
+ "label": "Open angle",
2864
+ "min": 40,
2865
+ "max": 130,
2866
+ "step": 2,
2867
+ "unit": "°"
2868
+ },
2869
+ "seed": {
2870
+ "type": "number",
2871
+ "label": "Variation seed",
2872
+ "min": 1,
2873
+ "max": 64,
2874
+ "step": 1
2875
+ }
2876
+ },
2877
+ "materialSlots": [
2878
+ "oak",
2879
+ "iron"
2880
+ ],
2881
+ "parts": [
2882
+ "body",
2883
+ "lid",
2884
+ "bands",
2885
+ "lock"
2886
+ ],
2887
+ "sockets": []
2888
+ }
2889
+ },
2890
+ {
2891
+ "name": "wooden-crate",
2892
+ "type": "vibe3d:model",
2893
+ "title": "Wooden Crate",
2894
+ "description": "Rows of horizontal boards nailed to corner posts, with forged iron straps.",
2895
+ "dependencies": [
2896
+ "three@>=0.185.0"
2897
+ ],
2898
+ "registryDependencies": [
2899
+ "@medieval-kit/core"
2900
+ ],
2901
+ "files": [
2902
+ {
2903
+ "path": "models/wooden-crate/model.ts",
2904
+ "target": "{models}/medieval-kit/wooden-crate/model.ts",
2905
+ "content": "/**\n* @medieval-kit/wooden-crate\n*\n* A crate is not a box: it is rows of horizontal boards nailed to four corner\n* posts. The thin gaps between them, and the posts standing proud of them, are\n* what give the silhouette its \"assembled\" reading. This model builds it that\n* way.\n*\n* It shares the same core as the barrel: the same oak tone, the same\n* deterministic randomness, the same vertex-colour technique. That is why they\n* look as if they came from the same catalogue when you stand them side by\n* side.\n*/\nimport { Color, type BufferGeometry } from 'three'\n\nimport {\n chamferedBoxGeometry,\n createKitModel,\n createRandom,\n jitter,\n MEDIEVAL_PALETTE,\n mergeColoured,\n type Vec3,\n} from '../core/index.ts'\n\nexport interface WoodenCrateConfig {\n /** Width (X axis, metres). */\n readonly width: number\n /** Height (metres). */\n readonly height: number\n /** Depth (Z axis, metres). */\n readonly depth: number\n /** Number of horizontal board rows on each face. */\n readonly plankRows: number\n /** Iron strap count. 0 = plain wooden crate. */\n readonly strapCount: number\n /** Variation seed. */\n readonly seed: number\n}\n\nexport const woodenCrateDefaults: WoodenCrateConfig = {\n width: 0.66,\n height: 0.52,\n depth: 0.52,\n plankRows: 3,\n strapCount: 2,\n seed: 3,\n}\n\nexport type WoodenCrateParts = 'posts' | 'planks' | 'straps'\n\nconst SLOTS = ['oak', 'iron'] as const\ntype Slot = (typeof SLOTS)[number]\n\nexport function createModel(overrides: Partial<WoodenCrateConfig> = {}) {\n return createKitModel<WoodenCrateConfig, 'oak' | 'iron', WoodenCrateParts>({\n id: 'wooden-crate',\n defaults: woodenCrateDefaults,\n slots: SLOTS,\n build: ({ config, random }) => {\n /**\n * The dimension contract.\n *\n * Z-FIGHTING RULE: no two surfaces may overlap in the same plane facing the\n * same way. In real joinery the parts interlock, and we do the same here. The\n * posts stand proud of the boards, the lid and the floor overhang the frame a\n * little, the boards are butt-jointed to one another. That way every surface\n * is alone in its own plane.\n */\n const dims = () => {\n const post = Math.min(config.width, config.depth) * 0.11\n const board = post * 0.5\n return {\n post,\n board,\n /** How far the posts stand proud of the board surface. */\n postProud: board * 0.45,\n /** Overhang of the lid and the floor beyond the frame. */\n overhang: board * 0.6,\n half: config.height / 2,\n }\n }\n\n function buildPosts(random: () => number): BufferGeometry {\n const { post, board, half } = dims()\n const tint = new Color()\n const pieces: BufferGeometry[] = []\n const x = config.width / 2 - post / 2\n const z = config.depth / 2 - post / 2\n // The posts run INTO the lid and the floor; because their ends stay\n // inside those solid pieces they are invisible and align with nothing.\n const reach = half - board * 0.3\n\n for (const [sx, sz] of [[-1, -1], [1, -1], [1, 1], [-1, 1]] as const) {\n tint.copy(MEDIEVAL_PALETTE.oak)\n // Posts are a bit darker than the body: a different cut, more wear.\n tint.offsetHSL(jitter(random, 0.01), jitter(random, 0.04), -0.045 + jitter(random, 0.02))\n pieces.push(chamferedBoxGeometry(\n [post, post], [post, post], reach * 2,\n board * 0.16, [sx * x, 0, sz * z], tint,\n ))\n }\n\n return mergeColoured(pieces)\n }\n\n function buildPlanks(random: () => number): BufferGeometry {\n const { board, postProud, overhang, half } = dims()\n const tint = new Color()\n const pieces: BufferGeometry[] = []\n\n // The side boards are pulled BEHIND the posts: their outer faces are not at\n // ±width/2 but at ±(width/2 - postProud). That is why they never share a plane\n // with the posts.\n const faceX = config.width / 2 - postProud - board / 2\n const faceZ = config.depth / 2 - postProud - board / 2\n // Butt joint: the front/back boards bear against the inner face of the side\n // boards. They touch but do not overlap — edge contact produces no z-fighting.\n const spanX = (config.width / 2 - postProud - board) * 2\n const spanZ = (config.depth / 2 - postProud - board) * 2\n\n // The rows reach far enough to enter the lid and the floor, but they end\n // at a DIFFERENT height than the ends of the posts.\n const wallTop = half - board * 0.65\n const rows = Math.max(1, config.plankRows)\n const gap = config.height * 0.012\n const rowHeight = (wallTop * 2 - gap * (rows - 1)) / rows\n\n for (let row = 0; row < rows; row += 1) {\n const y = -wallTop + rowHeight / 2 + row * (rowHeight + gap)\n for (const side of [-1, 1] as const) {\n tint.copy(MEDIEVAL_PALETTE.oak)\n tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), jitter(random, 0.06))\n pieces.push(chamferedBoxGeometry(\n [spanX, board], [spanX, board], rowHeight,\n board * 0.16, [0, y, side * faceZ], tint,\n ))\n\n tint.copy(MEDIEVAL_PALETTE.oak)\n tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), jitter(random, 0.06))\n pieces.push(chamferedBoxGeometry(\n [board, spanZ], [board, spanZ], rowHeight,\n board * 0.16, [side * faceX, y, 0], tint,\n ))\n }\n }\n\n // Lid and floor: board sheets that sit on top of and under the frame and\n // overhang it a little. The overhang keeps their side faces from aligning\n // with the faces of the posts.\n const slabWidth = config.width + overhang * 2\n const slabDepth = config.depth + overhang * 2\n const slabBoards = 3\n const slabGap = slabDepth * 0.014\n const boardDepth = (slabDepth - slabGap * (slabBoards - 1)) / slabBoards\n\n for (const [y, shade] of [[half - board / 2, 0.03], [-half + board / 2, -0.05]] as const) {\n for (let i = 0; i < slabBoards; i += 1) {\n tint.copy(MEDIEVAL_PALETTE.oakEnd)\n tint.offsetHSL(jitter(random, 0.01), jitter(random, 0.04), shade + jitter(random, 0.05))\n pieces.push(chamferedBoxGeometry(\n [slabWidth, boardDepth],\n [slabWidth, boardDepth],\n board,\n board * 0.16,\n [0, y, -slabDepth / 2 + boardDepth / 2 + i * (boardDepth + slabGap)],\n tint,\n ))\n }\n }\n\n return mergeColoured(pieces)\n }\n\n function buildStraps(random: () => number): BufferGeometry | undefined {\n if (config.strapCount <= 0) return undefined\n\n const { post } = dims()\n const tint = new Color()\n const pieces: BufferGeometry[] = []\n const bandHeight = config.height * 0.07\n const proud = post * 0.3\n\n for (let i = 0; i < config.strapCount; i += 1) {\n // The straps sit symmetrically from the top and the bottom inwards.\n const t = config.strapCount === 1\n ? 0\n : 0.6 - (1.2 * i) / (config.strapCount - 1)\n const y = (t * config.height) / 2\n tint.copy(MEDIEVAL_PALETTE.iron)\n tint.offsetHSL(0, jitter(random, 0.02), jitter(random, 0.06))\n\n // The front/back straps stand proud at the corners; the side straps end\n // BEFORE reaching them. That way the top faces of the four pieces do not\n // sit on top of each other at the corner.\n pieces.push(\n chamferedBoxGeometry([config.width + proud * 2, proud], [config.width + proud * 2, proud], bandHeight, proud * 0.22, [0, y, config.depth / 2], tint),\n chamferedBoxGeometry([config.width + proud * 2, proud], [config.width + proud * 2, proud], bandHeight, proud * 0.22, [0, y, -config.depth / 2], tint),\n chamferedBoxGeometry([proud, config.depth - proud], [proud, config.depth - proud], bandHeight, proud * 0.22, [config.width / 2, y, 0], tint),\n chamferedBoxGeometry([proud, config.depth - proud], [proud, config.depth - proud], bandHeight, proud * 0.22, [-config.width / 2, y, 0], tint),\n )\n }\n\n return mergeColoured(pieces)\n }\n\n // The call ORDER must be kept: the seeded randomness advances as a\n // stream, and if the order changes so does the geometry.\n const postsPart = buildPosts(random)\n const planksPart = buildPlanks(random)\n const strapsPart = buildStraps(random)\n\n return {\n posts: { slot: 'oak' as const, geometry: postsPart },\n planks: { slot: 'oak' as const, geometry: planksPart },\n straps: strapsPart ? { slot: 'iron' as const, geometry: strapsPart } : undefined,\n }\n },\n }, overrides)\n}\n",
2906
+ "hash": "756f61c1865dac27f5394fb0bafa5a8e142af90ea40a60780f988d54dbfae566"
2907
+ }
2908
+ ],
2909
+ "meta": {
2910
+ "title": "Wooden Crate",
2911
+ "description": "Rows of horizontal boards nailed to corner posts, with forged iron straps.",
2912
+ "category": "Props",
2913
+ "tags": [
2914
+ "medieval",
2915
+ "lowpoly",
2916
+ "props",
2917
+ "procedural"
2918
+ ],
2919
+ "controls": {
2920
+ "width": {
2921
+ "type": "number",
2922
+ "label": "Width",
2923
+ "min": 0.3,
2924
+ "max": 1.4,
2925
+ "step": 0.02,
2926
+ "unit": "m"
2927
+ },
2928
+ "height": {
2929
+ "type": "number",
2930
+ "label": "Height",
2931
+ "min": 0.25,
2932
+ "max": 1.2,
2933
+ "step": 0.02,
2934
+ "unit": "m"
2935
+ },
2936
+ "depth": {
2937
+ "type": "number",
2938
+ "label": "Depth",
2939
+ "min": 0.3,
2940
+ "max": 1.4,
2941
+ "step": 0.02,
2942
+ "unit": "m"
2943
+ },
2944
+ "plankRows": {
2945
+ "type": "number",
2946
+ "label": "Board rows",
2947
+ "min": 1,
2948
+ "max": 6,
2949
+ "step": 1
2950
+ },
2951
+ "strapCount": {
2952
+ "type": "number",
2953
+ "label": "Iron straps",
2954
+ "min": 0,
2955
+ "max": 4,
2956
+ "step": 1
2957
+ },
2958
+ "seed": {
2959
+ "type": "number",
2960
+ "label": "Variation seed",
2961
+ "min": 1,
2962
+ "max": 64,
2963
+ "step": 1
2964
+ }
2965
+ },
2966
+ "materialSlots": [
2967
+ "oak",
2968
+ "iron"
2969
+ ],
2970
+ "parts": [
2971
+ "posts",
2972
+ "planks",
2973
+ "straps"
2974
+ ],
2975
+ "sockets": []
2976
+ }
2977
+ },
2978
+ {
2979
+ "name": "wooden-fence",
2980
+ "type": "vibe3d:model",
2981
+ "title": "Wooden Fence",
2982
+ "description": "Mortised riven fence: the rail runs through the post and projects from the far face at the ends.",
2983
+ "dependencies": [
2984
+ "three@>=0.185.0"
2985
+ ],
2986
+ "registryDependencies": [
2987
+ "@medieval-kit/core"
2988
+ ],
2989
+ "files": [
2990
+ {
2991
+ "path": "models/wooden-fence/model.ts",
2992
+ "target": "{models}/medieval-kit/wooden-fence/model.ts",
2993
+ "content": "/**\r\n * @medieval-kit/wooden-fence\r\n *\r\n * Mortised riven fence: riven heavy posts, rails passing THROUGH the post.\r\n *\r\n * SECOND attempt, and the reason can be said in one word: JOINERY. The first\r\n * version was four square sticks with two thin battens laid across their\r\n * front; at no point was it visible how two pieces held on to each other, so\r\n * the whole subject of the object was missing. In the render it read not as a\r\n * fence but as \"the technical drawing of a fence\" — 4.89 × 1.10 × 0.09 m,\r\n * i.e. a depth-to-length ratio of 54:1, cardboard.\r\n *\r\n * In a real riven post-and-rail fence a RECTANGULAR HOLE is cut through the\r\n * post and the rail passes through that hole. The entire model was rebuilt\r\n * around this single fact:\r\n *\r\n * - The post is no longer a single box: two CHEEKS with BRIDGE blocks\r\n * between them. The hole therefore exists geometrically, it is not a\r\n * painted notch. `bakeOcclusion` darkens its mouth on its own too.\r\n * - The rail passes through the hole and PROTRUDES from the far face at the\r\n * two ends of the fence. The tenon tongue is the only horizontal\r\n * protrusion that enters the silhouette, and it answers the question\r\n * \"how is this standing up\" all by itself.\r\n * - The rail is narrower than the hole: a few millimetres of gap remain on\r\n * each side, so the hole does not close up. That gap is what shows the\r\n * hole as a hole.\r\n *\r\n * And the spacing: the old rail distribution was `0.28 + 0.5·r/(count−1)`,\r\n * i.e. no matter how many rails there were it ALWAYS filled the 0.28–0.78\r\n * band. Filling the top and the bottom was structurally impossible; that is\r\n * why the upper edge of the silhouette was empty.\r\n */\r\nimport { Color } from 'three'\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n jitter,\r\n MEDIEVAL_PALETTE,\r\n mergeColoured,\r\n taperedBoxGeometry,\r\n} from '../core/index.ts'\r\n\r\nexport interface WoodenFenceConfig {\r\n /** Number of sections. Each section is the span between two posts. */\r\n readonly sections: number\r\n /** Length of one section (metres). 2–3 m, because a rail is riven from one log. */\r\n readonly sectionLength: number\r\n readonly height: number\r\n /** Number of horizontal rails. */\r\n readonly railCount: number\r\n /** Curvature and height deviation of the posts. 0 = factory straightness. */\r\n readonly rough: number\r\n /** Whether a brace is placed at one end (0/1). */\r\n readonly brace: number\r\n readonly seed: number\r\n}\r\n\r\nexport const woodenFenceDefaults: WoodenFenceConfig = {\r\n sections: 2,\r\n sectionLength: 2.4,\r\n height: 1.25,\r\n railCount: 3,\r\n rough: 1,\r\n brace: 1,\r\n seed: 12,\r\n}\r\n\r\nexport type WoodenFenceParts = 'posts' | 'rails'\r\n\r\nexport function createModel(overrides: Partial<WoodenFenceConfig> = {}) {\r\n return createKitModel<WoodenFenceConfig, 'oak', WoodenFenceParts>({\r\n id: 'wooden-fence',\r\n // The mottle cell is given by hand: the fence is 4.8 m long, and when the\r\n // automatic derivation takes it from the model's scale a single post falls\r\n // into a single cell and the texture system does nothing. The grain mottle\r\n // of wood is a few centimetres regardless of the object's size.\r\n mottle: { cell: 0.05 },\r\n defaults: woodenFenceDefaults,\r\n slots: ['oak'],\r\n build: ({ config, random }) => {\r\n const tint = new Color()\r\n const shade = (lift = 0): Color => {\r\n tint.copy(MEDIEVAL_PALETTE.oak)\r\n tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), lift + jitter(random, 0.06))\r\n return tint\r\n }\r\n /** End grain: riven surface and cut ends. The fence never used this. */\r\n const endGrain = (lift = 0): Color => {\r\n tint.copy(MEDIEVAL_PALETTE.oakEnd)\r\n tint.offsetHSL(jitter(random, 0.01), jitter(random, 0.04), lift + jitter(random, 0.05))\r\n return tint\r\n }\r\n const soil = (): Color => {\r\n tint.copy(MEDIEVAL_PALETTE.oak)\r\n tint.offsetHSL(jitter(random, 0.01), -0.3 + jitter(random, 0.04), -0.19 + jitter(random, 0.04))\r\n return tint\r\n }\r\n\r\n const sections = Math.max(1, Math.round(config.sections))\r\n const count = Math.max(1, Math.round(config.railCount))\r\n const total = sections * config.sectionLength\r\n const half = config.height / 2\r\n const rough = Math.max(0, config.rough)\r\n\r\n // --- Dimensions, all derived from the height --------------------------\r\n const postW = config.height * 0.12 // post width along the fence\r\n const mortise = config.height * 0.062 // Z opening of the hole\r\n const cheek = config.height * 0.03 // material on each side of the hole\r\n const postD = mortise + cheek * 2 // total depth of the post\r\n const railH = config.height * 0.098 // vertical height of the rail\r\n const railD = config.height * 0.053 // rail depth — NARROWER than the hole\r\n const tenon = config.height * 0.088 // overhang at the ends\r\n\r\n // Rail heights. Exponent 1.12: the gaps tighten towards the bottom,\r\n // because the animal trying to get under it is the small one.\r\n const railT = Array.from({ length: count }, (_, r) =>\r\n count === 1 ? 0.55 : 0.19 + 0.71 * Math.pow(r / (count - 1), 1.12))\r\n\r\n // --- Posts --------------------------------------------------------------\r\n const postPieces: BufferGeometry[] = []\r\n const slotHalf = railH / 2 + config.height * 0.008\r\n\r\n for (let i = 0; i <= sections; i += 1) {\r\n const x = -total / 2 + i * config.sectionLength\r\n const postH = config.height + jitter(random, 0.075 * rough)\r\n const pieces: BufferGeometry[] = []\r\n\r\n // Two cheeks: the walls of the hole. Full height, base to top.\r\n for (const side of [-1, 1]) {\r\n pieces.push(chamferedBoxGeometry(\r\n [postW, cheek],\r\n [postW * 0.81, cheek * 0.94],\r\n postH,\r\n cheek * 0.2,\r\n [0, postH / 2, side * (mortise + cheek) / 2],\r\n shade(-0.11),\r\n shade(0.02),\r\n ))\r\n }\r\n\r\n // Bridges: blocks that fill the space BETWEEN the slots. The hole is\r\n // exactly the gap they leave. Their cross-sections stay INSIDE the\r\n // cheeks (their ±Z faces are buried in the cheek solid), so no pair\r\n // of faces is coplanar.\r\n //\r\n // The bridges do not run all the way to the two ENDS of the post:\r\n // their ends sat on the same plane as the cheek ends and z-fought.\r\n // The insets are not visible — they stay inside the soil mound below\r\n // and inside the cap above.\r\n const inset = cheek * 0.3\r\n const bounds = [0, ...railT.flatMap((t) => [t * postH - slotHalf, t * postH + slotHalf]), postH]\r\n for (let k = 0; k + 1 < bounds.length; k += 2) {\r\n const lo = Math.max(inset, bounds[k]!)\r\n const hi = Math.min(postH - inset, bounds[k + 1]!)\r\n if (hi - lo < 1e-4) continue\r\n const taper = 1 - 0.19 * (lo / postH)\r\n pieces.push(taperedBoxGeometry(\r\n [postW * taper * 0.96, mortise + cheek * 1.1],\r\n [postW * (taper - 0.03) * 0.96, mortise + cheek * 1.1],\r\n hi - lo,\r\n [0, (lo + hi) / 2, 0],\r\n shade(-0.07),\r\n ))\r\n }\r\n\r\n // Cap: an axe-hewn ridge that sheds water. Its base sits INSIDE the body\r\n // and its section is LARGER than the body's section at that height — the\r\n // same pattern as `toolSocket`; that is why no coplanar face pair forms.\r\n pieces.push(taperedBoxGeometry(\r\n [postW * 0.88, postD * 0.98],\r\n [postW * 0.74, postD * 0.13],\r\n config.height * 0.1,\r\n [0, postH - config.height * 0.016, 0],\r\n endGrain(-0.03),\r\n endGrain(0.07),\r\n ))\r\n\r\n // Build → ROTATE → translate. The old code passed the centre straight\r\n // into the geometry call, so rotating was impossible; that is why they\r\n // lined up like a grid. Rotations are kept small: 0.045 rad means 7 mm\r\n // of lateral drift along the hole, and the hole slack is 8 mm.\r\n const post = mergeColoured(pieces)\r\n post.rotateY(jitter(random, 0.045 * rough))\r\n post.rotateZ(jitter(random, 0.03 * rough))\r\n post.rotateX(jitter(random, 0.018 * rough))\r\n const sink = config.height * (0.012 + Math.abs(jitter(random, 0.012)))\r\n post.translate(x, -half - sink, 0)\r\n postPieces.push(post)\r\n\r\n // Soil mound. DOES NOT ROTATE: the post's lean would lift it off the ground.\r\n postPieces.push(taperedBoxGeometry(\r\n [postW * 2.2, postD * 2],\r\n [postW * 1.25, postD * 1.15],\r\n config.height * 0.11,\r\n [x, -half + config.height * 0.018, 0],\r\n soil(),\r\n ))\r\n }\r\n\r\n // --- Rails ----------------------------------------------------------------\r\n const railPieces: BufferGeometry[] = []\r\n for (let r = 0; r < count; r += 1) {\r\n const y = -half + railT[r]! * config.height + jitter(random, config.height * 0.005)\r\n for (let i = 0; i < sections; i += 1) {\r\n const xc = -total / 2 + (i + 0.5) * config.sectionLength\r\n // The body enters the holes of the two neighbouring posts and meets\r\n // the body of the adjacent bay end to end in there.\r\n const body = chamferedBoxGeometry(\r\n [config.sectionLength + postW * 0.55, railD],\r\n [config.sectionLength + postW * 0.55, railD * 0.88],\r\n railH,\r\n railH * 0.09,\r\n [xc, y, jitter(random, railD * 0.06)],\r\n shade(0.05),\r\n shade(0.1),\r\n )\r\n railPieces.push(body)\r\n }\r\n\r\n // Tenon tongue: ONLY at the two ends. Protruding from the far face, this\r\n // piece is the only horizontal projection entering the silhouette; it\r\n // tells on its own that the rail runs through the post. No overhang at\r\n // the intermediate posts, because there two bodies meet inside the hole.\r\n for (const side of [-1, 1]) {\r\n const px = side * total / 2\r\n railPieces.push(taperedBoxGeometry(\r\n [tenon * 2, railD * 0.9],\r\n [tenon * 1.7, railD * 0.76],\r\n railH * 0.82,\r\n [px + side * tenon * 0.72, y, 0],\r\n shade(0.02),\r\n endGrain(0.05),\r\n ))\r\n }\r\n }\r\n\r\n // --- Brace -----------------------------------------------------------------\r\n // The model's only off-axis line. It props the end post towards the field.\r\n if (config.brace >= 0.5) {\r\n const rise = config.height * 0.72\r\n const run = config.sectionLength * 0.3\r\n const length = Math.hypot(run, rise)\r\n const atStart = random() < 0.5\r\n const brace = chamferedBoxGeometry(\r\n [postW * 0.72, postD * 0.36],\r\n [postW * 0.56, postD * 0.3],\r\n length,\r\n postW * 0.06,\r\n [0, 0, 0],\r\n shade(-0.05),\r\n endGrain(0.02),\r\n )\r\n // The sign looks INVERTED but this is the correct one: the TOP of the\r\n // brace leans against the post, its FOOT stands on the field. Flipped,\r\n // what came out was a stick with its foot at the base of the post and\r\n // its top in the air — a brace that supports nothing.\r\n const angle = Math.atan2(run, rise)\r\n brace.rotateZ(atStart ? angle : -angle)\r\n brace.translate(\r\n (atStart ? -1 : 1) * (total / 2 - run / 2),\r\n -half + rise / 2 + config.height * 0.02,\r\n // The post's back face is SLOPED (tapered), the brace's is upright —\r\n // they never become coplanar at any height.\r\n -postD * 0.62,\r\n )\r\n postPieces.push(brace)\r\n }\r\n\r\n return {\r\n posts: { slot: 'oak', geometry: mergeColoured(postPieces) },\r\n rails: { slot: 'oak', geometry: mergeColoured(railPieces) },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
2994
+ "hash": "1f90d1d545b0cf9989e5da7685b5bab022208bf1d4808152e43a4d741dc2d62c"
2995
+ }
2996
+ ],
2997
+ "meta": {
2998
+ "title": "Wooden Fence",
2999
+ "description": "Mortised riven fence: the rail runs through the post and projects from the far face at the ends.",
3000
+ "category": "Structure",
3001
+ "tags": [
3002
+ "medieval",
3003
+ "lowpoly",
3004
+ "structure",
3005
+ "farm",
3006
+ "procedural"
3007
+ ],
3008
+ "controls": {
3009
+ "sections": {
3010
+ "type": "number",
3011
+ "label": "Sections",
3012
+ "min": 1,
3013
+ "max": 5,
3014
+ "step": 1
3015
+ },
3016
+ "sectionLength": {
3017
+ "type": "number",
3018
+ "label": "Section length",
3019
+ "min": 1.6,
3020
+ "max": 3.4,
3021
+ "step": 0.05,
3022
+ "unit": "m"
3023
+ },
3024
+ "height": {
3025
+ "type": "number",
3026
+ "label": "Height",
3027
+ "min": 0.8,
3028
+ "max": 1.7,
3029
+ "step": 0.02,
3030
+ "unit": "m"
3031
+ },
3032
+ "railCount": {
3033
+ "type": "number",
3034
+ "label": "Rails",
3035
+ "min": 1,
3036
+ "max": 4,
3037
+ "step": 1
3038
+ },
3039
+ "rough": {
3040
+ "type": "number",
3041
+ "label": "Irregularity",
3042
+ "min": 0,
3043
+ "max": 2,
3044
+ "step": 0.05
3045
+ },
3046
+ "brace": {
3047
+ "type": "number",
3048
+ "label": "Brace",
3049
+ "min": 0,
3050
+ "max": 1,
3051
+ "step": 1
3052
+ },
3053
+ "seed": {
3054
+ "type": "number",
3055
+ "label": "Variation seed",
3056
+ "min": 1,
3057
+ "max": 64,
3058
+ "step": 1
3059
+ }
3060
+ },
3061
+ "materialSlots": [
3062
+ "oak"
3063
+ ],
3064
+ "parts": [
3065
+ "posts",
3066
+ "rails"
3067
+ ],
3068
+ "sockets": []
3069
+ }
3070
+ },
3071
+ {
3072
+ "name": "wooden-hoe",
3073
+ "type": "vibe3d:model",
3074
+ "title": "Wooden Hoe",
3075
+ "description": "Gooseneck field hoe: a curved forged neck carrying the blade ahead of the shaft axis, dished blade.",
3076
+ "dependencies": [
3077
+ "three@>=0.185.0"
3078
+ ],
3079
+ "registryDependencies": [
3080
+ "@medieval-kit/core"
3081
+ ],
3082
+ "files": [
3083
+ {
3084
+ "path": "models/wooden-hoe/model.ts",
3085
+ "target": "{models}/medieval-kit/wooden-hoe/model.ts",
3086
+ "content": "/**\r\n * @medieval-kit/wooden-hoe\r\n *\r\n * Gooseneck field hoe: an ash shaft, a forged iron neck curving forward and\r\n * down from the tip of the shaft, and a dished blade at the end of it.\r\n *\r\n * THIRD attempt, and both of the earlier ones missed the same thing: the\r\n * GOOSENECK. What makes a hoe a hoe is not the blade, it is the curved neck\r\n * that carries the blade FORWARD off the axis of the shaft. Without it what\r\n * you get is a flat sheet balanced on top of a post — in the render it read\r\n * exactly as \"lectern\", \"music stand\", \"road sign\". The neck also adds negative\r\n * space to the silhouette: that gap between shaft and blade is what tells the\r\n * object apart from a distance.\r\n *\r\n * In the second attempt I tried to curve the blade with `bendGeometry` and\r\n * wrote in the comment \"this single detail solves the real problem in the\r\n * silhouette\". Measuring showed that was wrong: because the blade was built\r\n * CENTRED on y=0 the bend was symmetric, both ends went the same way and the\r\n * middle stayed where it was. On a 0.235 m blade the Z range DROPPED from\r\n * 0.0337 to 0.0327, i.e. the curve was not visible in the silhouette at all.\r\n * With the same blade built base-at-origin the run-out is 44 mm. Now both the\r\n * neck and the blade are started from the origin.\r\n */\r\nimport type { BufferGeometry } from 'three'\r\n\r\nimport {\r\n bendGeometry,\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n dishedSheetGeometry,\r\n ironTint,\r\n steelTint,\r\n jitter,\r\n latheGeometry,\r\n mergeColoured,\r\n toolShaft,\r\n toolSocket,\r\n type Level,\r\n type SheetLevel,\r\n} from '../core/index.ts'\r\n\r\nexport interface WoodenHoeConfig {\r\n /** Shaft length (metres). */\r\n readonly length: number\r\n readonly shaftRadius: number\r\n /** Width of the blade (metres). */\r\n readonly bladeWidth: number\r\n /** Total sweep of the gooseneck (degrees). 0 = straight neck, no longer a hoe. */\r\n readonly neckSweep: number\r\n /** Dish of the blade. 0 = flat sheet. */\r\n readonly dish: number\r\n readonly seed: number\r\n}\r\n\r\nexport const woodenHoeDefaults: WoodenHoeConfig = {\r\n length: 1.14,\r\n shaftRadius: 0.021,\r\n bladeWidth: 0.23,\r\n // 112 stands. I lowered it to 95 after reading a render in which the blade\r\n // happened to sit edge-on to the camera, and mistook foreshortening for the\r\n // wrong angle.\r\n //\r\n // It is also worth recording that the reference generated for this model is\r\n // a plain strap hoe, where this one is deliberately a GOOSENECK -- a curved\r\n // forged neck carrying the blade ahead of the shaft axis, as the\r\n // description says. Both are period-correct, and bending the model towards\r\n // the photograph on that point would be changing the design rather than\r\n // fixing a fault. The blade's depth and wedge are another matter: a hoe\r\n // blade is the heavy end of the tool whichever neck it hangs from.\r\n neckSweep: 112,\r\n dish: 1,\r\n seed: 23,\r\n}\r\n\r\nexport type WoodenHoeParts = 'shaft' | 'socket' | 'blade'\r\n\r\nexport function createModel(overrides: Partial<WoodenHoeConfig> = {}) {\r\n return createKitModel<WoodenHoeConfig, 'oak' | 'iron' | 'steel', WoodenHoeParts>({\r\n id: 'wooden-hoe',\r\n defaults: woodenHoeDefaults,\r\n slots: ['oak', 'iron', 'steel'],\r\n build: ({ config, random }) => {\r\n const shaft = toolShaft({ length: config.length, radius: config.shaftRadius, random })\r\n const socketLength = config.length * 0.075\r\n const socket = toolSocket({\r\n y: shaft.top - socketLength * 0.42,\r\n shaftRadius: shaft.topRadius,\r\n length: socketLength,\r\n random,\r\n })\r\n\r\n // --- Gooseneck ----------------------------------------------------------\r\n // It is built with its base AT THE ORIGIN and bent from there; centred on\r\n // y=0 it would bend symmetrically and nothing would happen.\r\n //\r\n // `latheGeometry` was chosen because it has intermediate levels: bending\r\n // a two-level box gives you a warped box, not an arc.\r\n const neckLength = config.length * 0.17\r\n const bar = config.shaftRadius * 0.85\r\n const sweep = (config.neckSweep * Math.PI) / 180\r\n const curvature = sweep / neckLength\r\n\r\n const neckLevels: Level[] = Array.from({ length: 7 }, (_, i) => {\r\n const t = i / 6\r\n return { y: neckLength * t, radius: bar * (1.05 - 0.28 * t) }\r\n })\r\n const neck = latheGeometry(neckLevels, 5, [0, 0, 0], ironTint(random, -0.02), {\r\n colourTop: ironTint(random, 0.04),\r\n })\r\n // A forged neck is not round but FLAT: it is hammered out crosswise. The\r\n // scaling is BEFORE the bend and only in X — scaling in Z would ruin the\r\n // plane of the arc.\r\n neck.scale(1.75, 1, 0.62)\r\n bendGeometry(neck, curvature)\r\n neck.translate(0, shaft.top - neckLength * 0.12, 0)\r\n\r\n // The TIP of the neck and the tangent there come out of the arc mapping\r\n // itself — computed instead of placed by eye, so that when `neckSweep`\r\n // changes the blade follows on its own.\r\n const tipY = shaft.top - neckLength * 0.12 + Math.sin(sweep) / curvature\r\n const tipZ = (1 - Math.cos(sweep)) / curvature\r\n\r\n // --- Blade ---------------------------------------------------------------\r\n // Continues from the tip of the neck. Built base-at-origin so that the\r\n // dish is actually visible.\r\n // 0.17 of the tool, not 0.115. A field hoe's blade is the heavy end of\r\n // it -- in the reference it is close to a fifth of the whole length --\r\n // and at 131 mm ours read as a tab riveted to a stick.\r\n const bladeLength = config.length * 0.15\r\n const thick = config.length * 0.028\r\n const thin = config.length * 0.006\r\n // A dished sheet, not a bent box.\r\n //\r\n // The blade was a `chamferedBoxGeometry` put through `bendGeometry`.\r\n // That has exactly four levels in Y, and once the blade was made deep\r\n // enough to look right the bend had to work a 50-degree arc across those\r\n // four -- the cutting edge came out visibly faceted, a row of steps\r\n // instead of a curve. `dishedSheetGeometry` exists for this: it produces\r\n // one seamless concave surface whose cross-section is curved at every\r\n // level, which is what the shovel's blade is built from.\r\n //\r\n // The hoe is a wedge: narrow where the neck carries it, wide where it\r\n // meets the ground. That taper is most of what identifies the\r\n // silhouette, and it was nearly parallel-sided before.\r\n const halfEdge = config.bladeWidth / 2\r\n // `curve` is an absolute rise in metres, not a ratio, so it has to be\r\n // scaled to the blade. Written as `-0.34 * dish` it asked for a 340 mm\r\n // rise across a 230 mm blade -- seven times its own half-width -- and\r\n // the sheet came out as a pair of wings. The shovel gets this right:\r\n // `bladeWidth * dish`, with its own dish slider running 0 to 0.22. This\r\n // one's runs 0 to 2, so the coefficient differs; the quantity does not.\r\n const curve = config.bladeWidth * 0.13 * config.dish\r\n const profile: SheetLevel[] = [\r\n { y: 0, halfWidth: halfEdge * 0.34, thickness: thick, curve: curve * 0.15 },\r\n { y: bladeLength * 0.18, halfWidth: halfEdge * 0.6, thickness: thick * 0.9, curve: curve * 0.5 },\r\n { y: bladeLength * 0.52, halfWidth: halfEdge * 0.86, thickness: thick * 0.6, curve: curve * 0.9 },\r\n { y: bladeLength * 0.86, halfWidth: halfEdge, thickness: thin * 1.6, curve },\r\n { y: bladeLength, halfWidth: halfEdge * 0.99, thickness: thin, curve: curve * 0.95 },\r\n ]\r\n const blade = dishedSheetGeometry(\r\n profile, 7, steelTint(random, -0.04), steelTint(random, 0.05),\r\n )\r\n // A forged blade is not perfectly symmetric.\r\n blade.rotateY(jitter(random, 0.04))\r\n // Align to the tangent at the neck tip, then move it there — order is critical.\r\n blade.rotateX(sweep)\r\n blade.translate(0, tipY, tipZ)\r\n\r\n // Collar: the forged thickening where the neck meets the blade. The only\r\n // detail that answers how the two pieces hold on to each other.\r\n const collar = latheGeometry([\r\n { y: -bar * 0.9, radius: bar * 1.05 },\r\n { y: 0, radius: bar * 1.5 },\r\n { y: bar * 1.1, radius: bar * 1.15 },\r\n ], 6, [0, 0, 0], ironTint(random, 0.06))\r\n collar.scale(1.7, 1, 0.7)\r\n collar.rotateX(sweep)\r\n collar.translate(0, tipY, tipZ)\r\n\r\n const ironwork: BufferGeometry = mergeColoured([socket, neck, collar])\r\n\r\n return {\r\n shaft: { slot: 'oak', geometry: shaft.geometry },\r\n socket: { slot: 'iron', geometry: ironwork },\r\n blade: { slot: 'steel', geometry: mergeColoured([blade]) },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
3087
+ "hash": "460f389c08df5daba8493fec8e4ece7c2056c1edbc29ca9eddabb4a1e9ccb6a6"
3088
+ }
3089
+ ],
3090
+ "meta": {
3091
+ "title": "Wooden Hoe",
3092
+ "description": "Gooseneck field hoe: a curved forged neck carrying the blade ahead of the shaft axis, dished blade.",
3093
+ "category": "Tools",
3094
+ "tags": [
3095
+ "medieval",
3096
+ "lowpoly",
3097
+ "tools",
3098
+ "farm",
3099
+ "procedural"
3100
+ ],
3101
+ "controls": {
3102
+ "length": {
3103
+ "type": "number",
3104
+ "label": "Shaft length",
3105
+ "min": 0.8,
3106
+ "max": 1.7,
3107
+ "step": 0.02,
3108
+ "unit": "m"
3109
+ },
3110
+ "shaftRadius": {
3111
+ "type": "number",
3112
+ "label": "Shaft thickness",
3113
+ "min": 0.012,
3114
+ "max": 0.035,
3115
+ "step": 0.001,
3116
+ "unit": "m"
3117
+ },
3118
+ "bladeWidth": {
3119
+ "type": "number",
3120
+ "label": "Blade width",
3121
+ "min": 0.1,
3122
+ "max": 0.3,
3123
+ "step": 0.005,
3124
+ "unit": "m"
3125
+ },
3126
+ "neckSweep": {
3127
+ "type": "number",
3128
+ "label": "Neck sweep",
3129
+ "min": 40,
3130
+ "max": 150,
3131
+ "step": 2,
3132
+ "unit": "°"
3133
+ },
3134
+ "dish": {
3135
+ "type": "number",
3136
+ "label": "Dish",
3137
+ "min": 0,
3138
+ "max": 2,
3139
+ "step": 0.05
3140
+ },
3141
+ "seed": {
3142
+ "type": "number",
3143
+ "label": "Variation seed",
3144
+ "min": 1,
3145
+ "max": 64,
3146
+ "step": 1
3147
+ }
3148
+ },
3149
+ "materialSlots": [
3150
+ "oak",
3151
+ "iron",
3152
+ "steel"
3153
+ ],
3154
+ "parts": [
3155
+ "shaft",
3156
+ "socket",
3157
+ "blade"
3158
+ ],
3159
+ "sockets": []
3160
+ }
3161
+ },
3162
+ {
3163
+ "name": "wooden-ladder",
3164
+ "type": "vibe3d:model",
3165
+ "title": "Wooden Ladder",
3166
+ "description": "Two rails converging toward the top, with rungs let into them.",
3167
+ "dependencies": [
3168
+ "three@>=0.185.0"
3169
+ ],
3170
+ "registryDependencies": [
3171
+ "@medieval-kit/core"
3172
+ ],
3173
+ "files": [
3174
+ {
3175
+ "path": "models/wooden-ladder/model.ts",
3176
+ "target": "{models}/medieval-kit/wooden-ladder/model.ts",
3177
+ "content": "/**\r\n * @medieval-kit/wooden-ladder\r\n *\r\n * Two rails with rungs between them. The cheapest model in the kit and one of\r\n * the highest in scene value: it suggests vertical movement in a scene.\r\n *\r\n * The rungs go INTO the rails (a housed joint), so no surface sits on the same\r\n * plane as the surface of the rails.\r\n */\r\nimport { Color } from 'three'\r\n\r\nimport {\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n jitter,\r\n MEDIEVAL_PALETTE,\r\n mergeColoured,\r\n} from '../core/index.ts'\r\n\r\nexport interface WoodenLadderConfig {\r\n readonly height: number\r\n /** Distance between the rails (metres). */\r\n readonly width: number\r\n readonly rungCount: number\r\n /** How much the rails narrow towards the top. 0 = parallel. */\r\n readonly taper: number\r\n readonly seed: number\r\n}\r\n\r\nexport const woodenLadderDefaults: WoodenLadderConfig = {\r\n height: 2.2,\r\n width: 0.42,\r\n rungCount: 8,\r\n taper: 0.18,\r\n seed: 4,\r\n}\r\n\r\nexport type WoodenLadderParts = 'rails' | 'rungs'\r\n\r\nexport function createModel(overrides: Partial<WoodenLadderConfig> = {}) {\r\n return createKitModel<WoodenLadderConfig, 'oak', WoodenLadderParts>({\r\n id: 'wooden-ladder',\r\n defaults: woodenLadderDefaults,\r\n slots: ['oak'],\r\n build: ({ config, random }) => {\r\n const tint = new Color()\r\n const shade = (lift = 0): Color => {\r\n tint.copy(MEDIEVAL_PALETTE.oak)\r\n tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), lift + jitter(random, 0.055))\r\n return tint\r\n }\r\n\r\n const railThickness = config.width * 0.1\r\n const half = config.width / 2\r\n\r\n // The rails converge towards the top; this single detail stops the ladder\r\n // from being \"two boards\" and makes it a ladder.\r\n const lean = half * config.taper\r\n /**\r\n * Half-gap between the rails at height fraction `t`.\r\n *\r\n * This exists because the rung length and the rail placement used to be\r\n * two separate expressions that disagreed. The rails are built upright\r\n * and then rotated about Z, so at height y each one sits at\r\n * `side·half − y·sin(θ)`; the rung length assumed `half − lean·t`, which\r\n * narrows where the real rails widen. At the default taper the error was\r\n * small enough to hide. At taper 0.4 on a 5 m ladder the top rungs came\r\n * out half the length of the gap they were supposed to span and floated\r\n * between the rails.\r\n *\r\n * Both now read from here, so they cannot drift apart again.\r\n */\r\n const railHalfAt = (t: number): number => {\r\n const y = -config.height / 2 + t * config.height\r\n return half - y * Math.sin(lean / config.height)\r\n }\r\n const rails = [-1, 1].map((side) => {\r\n const rail = chamferedBoxGeometry(\r\n [railThickness, railThickness * 1.35],\r\n [railThickness * 0.85, railThickness * 1.15],\r\n config.height,\r\n railThickness * 0.16,\r\n [0, 0, 0],\r\n shade(),\r\n )\r\n // Lean it slightly around the Z axis: bottom end out, top end in.\r\n rail.rotateZ((side * -lean) / config.height)\r\n rail.translate(side * half, 0, 0)\r\n return rail\r\n })\r\n\r\n const rungs = []\r\n const count = Math.max(2, config.rungCount)\r\n for (let i = 0; i < count; i += 1) {\r\n const t = (i + 0.5) / count\r\n const y = -config.height / 2 + t * config.height\r\n // The rung is a little LONGER than the rail gap at that height, so that\r\n // its ends stay inside the rails.\r\n const span = railHalfAt(t) * 2 + railThickness * 0.9\r\n rungs.push(chamferedBoxGeometry(\r\n [span, railThickness * 1.05],\r\n [span, railThickness * 1.05],\r\n railThickness * 0.8,\r\n railThickness * 0.16,\r\n [0, y, 0],\r\n shade(0.03),\r\n ))\r\n }\r\n\r\n return {\r\n rails: { slot: 'oak', geometry: mergeColoured(rails) },\r\n rungs: { slot: 'oak', geometry: mergeColoured(rungs) },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
3178
+ "hash": "231fd771117e6f61f3f3d0e6ea9c532086cacba70d1b4af8229b8ea5234c4458"
3179
+ }
3180
+ ],
3181
+ "meta": {
3182
+ "title": "Wooden Ladder",
3183
+ "description": "Two rails converging toward the top, with rungs let into them.",
3184
+ "category": "Structure",
3185
+ "tags": [
3186
+ "medieval",
3187
+ "lowpoly",
3188
+ "structure",
3189
+ "procedural"
3190
+ ],
3191
+ "controls": {
3192
+ "height": {
3193
+ "type": "number",
3194
+ "label": "Height",
3195
+ "min": 1,
3196
+ "max": 5,
3197
+ "step": 0.1,
3198
+ "unit": "m"
3199
+ },
3200
+ "width": {
3201
+ "type": "number",
3202
+ "label": "Width",
3203
+ "min": 0.25,
3204
+ "max": 0.8,
3205
+ "step": 0.01,
3206
+ "unit": "m"
3207
+ },
3208
+ "rungCount": {
3209
+ "type": "number",
3210
+ "label": "Rungs",
3211
+ "min": 3,
3212
+ "max": 18,
3213
+ "step": 1
3214
+ },
3215
+ "taper": {
3216
+ "type": "number",
3217
+ "label": "Taper",
3218
+ "min": 0,
3219
+ "max": 0.4,
3220
+ "step": 0.01
3221
+ },
3222
+ "seed": {
3223
+ "type": "number",
3224
+ "label": "Variation seed",
3225
+ "min": 1,
3226
+ "max": 64,
3227
+ "step": 1
3228
+ }
3229
+ },
3230
+ "materialSlots": [
3231
+ "oak"
3232
+ ],
3233
+ "parts": [
3234
+ "rails",
3235
+ "rungs"
3236
+ ],
3237
+ "sockets": []
3238
+ }
3239
+ },
3240
+ {
3241
+ "name": "wooden-pitchfork",
3242
+ "type": "vibe3d:model",
3243
+ "title": "Wooden Pitchfork",
3244
+ "description": "Steel tines splaying outward; the gap in the silhouette makes it readable from a distance.",
3245
+ "dependencies": [
3246
+ "three@>=0.185.0"
3247
+ ],
3248
+ "registryDependencies": [
3249
+ "@medieval-kit/core"
3250
+ ],
3251
+ "files": [
3252
+ {
3253
+ "path": "models/wooden-pitchfork/model.ts",
3254
+ "target": "{models}/medieval-kit/wooden-pitchfork/model.ts",
3255
+ "content": "/**\r\n * @medieval-kit/wooden-pitchfork\r\n *\r\n * What tells a pitchfork apart from a distance is the gap between the tines.\r\n * That is why the tine count and their spread decide the whole silhouette.\r\n *\r\n * In my first version the tines were boxes of square section and every one of\r\n * them was identical. A real tine is forged and round, it tapers towards the\r\n * point, and none of them sits at exactly the same angle as its neighbour. All\r\n * three were fixed here.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n createTinter,\r\n ironTint,\r\n jitter,\r\n bendGeometry,\r\n latheGeometry,\r\n mergeColoured,\r\n toolShaft,\r\n toolSocket,\r\n type Level,\r\n} from '../core/index.ts'\r\n\r\nexport interface WoodenPitchforkConfig {\r\n readonly length: number\r\n readonly shaftRadius: number\r\n /** Number of tines. */\r\n readonly tineCount: number\r\n /** How far the tines splay outwards (radians). */\r\n readonly spread: number\r\n /** Tine length, as a fraction of the total length. */\r\n readonly tineLength: number\r\n readonly seed: number\r\n}\r\n\r\nexport const woodenPitchforkDefaults: WoodenPitchforkConfig = {\r\n length: 1.5,\r\n shaftRadius: 0.021,\r\n // Four tines and longer ones. Three is a garden fork; a hay fork has four\r\n // or five, and the tines are the working length of the tool -- at 0.24 on a\r\n // 1.5 m shaft they were 16% of it against roughly 22% in the reference, and\r\n // read as stubs rather than as something you could lift a forkful with.\r\n // Tines run close to parallel, splaying only a little. Fanned out like a\r\n // claw they read as a garden cultivator; a hay fork's tines stay together\r\n // so a forkful stays on them.\r\n tineCount: 4,\r\n spread: 0.15,\r\n tineLength: 0.34,\r\n seed: 37,\r\n}\r\n\r\nexport type WoodenPitchforkParts = 'shaft' | 'socket' | 'tines'\r\n\r\nexport function createModel(overrides: Partial<WoodenPitchforkConfig> = {}) {\r\n return createKitModel<WoodenPitchforkConfig, 'oak' | 'iron', WoodenPitchforkParts>({\r\n id: 'wooden-pitchfork',\r\n defaults: woodenPitchforkDefaults,\r\n slots: ['oak', 'iron'],\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const tineSpan = config.length * config.tineLength\r\n const shaftLength = config.length - tineSpan * 0.86\r\n const shaft = toolShaft({ length: shaftLength, radius: config.shaftRadius, random })\r\n\r\n const socketLength = config.length * 0.045\r\n const socket = toolSocket({\r\n y: shaft.top - socketLength * 0.3,\r\n shaftRadius: shaft.topRadius,\r\n length: socketLength,\r\n random,\r\n })\r\n\r\n const count = Math.max(2, config.tineCount)\r\n const base = shaft.top + config.length * 0.006\r\n const pieces: BufferGeometry[] = []\r\n\r\n // Cross forging: flat iron tying the tines to the socket. Thins towards the ends.\r\n const crossWidth = config.shaftRadius * 2.6 * count\r\n pieces.push(chamferedBoxGeometry(\r\n [crossWidth, config.shaftRadius * 2],\r\n [crossWidth * 0.94, config.shaftRadius * 1.3],\r\n config.length * 0.026,\r\n config.shaftRadius * 0.25,\r\n [0, base, 0],\r\n ironTint(random, -0.03),\r\n ))\r\n\r\n for (let i = 0; i < count; i += 1) {\r\n const t = count === 1 ? 0 : (i / (count - 1)) * 2 - 1\r\n // A tine must be THICK. In the first version the radius was half the\r\n // shaft's, i.e. 1 cm, and from a distance the model looked like three\r\n // hairs. A real pitchfork tine is forged iron 2–3 cm across and takes up\r\n // as much of the silhouette as the shaft itself.\r\n const radius = config.shaftRadius * 0.88\r\n const profile: Level[] = [\r\n { y: 0, radius: radius * 1.2 },\r\n { y: tineSpan * 0.2, radius },\r\n { y: tineSpan * 0.66, radius: radius * 0.78 },\r\n { y: tineSpan * 0.9, radius: radius * 0.42 },\r\n { y: tineSpan, radius: radius * 0.12 }, // pointed but not zero\r\n ]\r\n // Oak, not steel. The model is called wooden-pitchfork and the\r\n // reference is exactly that: a hay fork was cut and steamed from wood,\r\n // because iron tines are heavy at the end of a long shaft and hay does\r\n // not need an edge. The socket stays iron -- that is the ferrule.\r\n const tine = latheGeometry(profile, 6, [0, 0, 0], new Color(tint('oak', -0.03)), {\r\n capTop: false,\r\n colourTop: new Color(tint('oakEnd', 0.06)),\r\n })\r\n // Curve: a straight tine looks like a technical drawing. A real pitchfork\r\n // tine is bent forward — so it does not drop the straw it has lifted.\r\n bendGeometry(tine, 0.42 / tineSpan + jitter(random, 0.06 / tineSpan))\r\n // Each tine sits at a slightly different angle from its neighbour: a\r\n // forged pitchfork is never perfectly symmetric, and this single detail\r\n // stops it from looking \"manufactured\".\r\n tine.rotateZ(-t * config.spread + jitter(random, 0.02))\r\n tine.rotateX(jitter(random, 0.025))\r\n tine.translate(t * config.shaftRadius * 2.5, base + config.length * 0.008, 0)\r\n pieces.push(tine)\r\n }\r\n\r\n return {\r\n shaft: { slot: 'oak', geometry: shaft.geometry },\r\n socket: { slot: 'iron', geometry: socket },\r\n tines: { slot: 'oak', geometry: mergeColoured(pieces) },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
3256
+ "hash": "a919c9da1b99d935be854751ba24134411561bcdb344e589fcf2f2e249113eab"
3257
+ }
3258
+ ],
3259
+ "meta": {
3260
+ "title": "Wooden Pitchfork",
3261
+ "description": "Steel tines splaying outward; the gap in the silhouette makes it readable from a distance.",
3262
+ "category": "Tools",
3263
+ "tags": [
3264
+ "medieval",
3265
+ "lowpoly",
3266
+ "tools",
3267
+ "farm",
3268
+ "procedural"
3269
+ ],
3270
+ "controls": {
3271
+ "length": {
3272
+ "type": "number",
3273
+ "label": "Length",
3274
+ "min": 1,
3275
+ "max": 2.4,
3276
+ "step": 0.02,
3277
+ "unit": "m"
3278
+ },
3279
+ "shaftRadius": {
3280
+ "type": "number",
3281
+ "label": "Shaft thickness",
3282
+ "min": 0.012,
3283
+ "max": 0.035,
3284
+ "step": 0.001,
3285
+ "unit": "m"
3286
+ },
3287
+ "tineCount": {
3288
+ "type": "number",
3289
+ "label": "Tine count",
3290
+ "min": 2,
3291
+ "max": 6,
3292
+ "step": 1
3293
+ },
3294
+ "spread": {
3295
+ "type": "number",
3296
+ "label": "Tine spread",
3297
+ "min": 0,
3298
+ "max": 0.4,
3299
+ "step": 0.01
3300
+ },
3301
+ "tineLength": {
3302
+ "type": "number",
3303
+ "label": "Tine length",
3304
+ "min": 0.1,
3305
+ "max": 0.42,
3306
+ "step": 0.01
3307
+ },
3308
+ "seed": {
3309
+ "type": "number",
3310
+ "label": "Variation seed",
3311
+ "min": 1,
3312
+ "max": 64,
3313
+ "step": 1
3314
+ }
3315
+ },
3316
+ "materialSlots": [
3317
+ "oak",
3318
+ "iron"
3319
+ ],
3320
+ "parts": [
3321
+ "shaft",
3322
+ "socket",
3323
+ "tines"
3324
+ ],
3325
+ "sockets": []
3326
+ }
3327
+ },
3328
+ {
3329
+ "name": "wooden-shovel",
3330
+ "type": "vibe3d:model",
3331
+ "title": "Wooden Shovel",
3332
+ "description": "A single-piece dished steel plate that both widens and thins toward the tip.",
3333
+ "dependencies": [
3334
+ "three@>=0.185.0"
3335
+ ],
3336
+ "registryDependencies": [
3337
+ "@medieval-kit/core"
3338
+ ],
3339
+ "files": [
3340
+ {
3341
+ "path": "models/wooden-shovel/model.ts",
3342
+ "target": "{models}/medieval-kit/wooden-shovel/model.ts",
3343
+ "content": "/**\r\n * @medieval-kit/wooden-shovel\r\n *\r\n * Third attempt. The first two failed, and both for the same reason: I tried\r\n * to build the blade out of flat pieces.\r\n *\r\n * attempt 1 — two boxes end to end. It came out \"shovel-shaped\", not a shovel.\r\n * attempt 2 — three flat panels, each rotated slightly, lined up side by\r\n * side. Because the panels rotated about their own centres, steps were\r\n * left between them; the eye read that as \"three boards\", not one surface.\r\n *\r\n * What makes a shovel a shovel is that the blade is ONE CONTINUOUS CONCAVE\r\n * SURFACE: the dish that holds the soil. `dishedSheetGeometry` was written for\r\n * exactly this — a seamless sheet with a curved cross-section whose width and\r\n * thickness change along its length.\r\n *\r\n * Silhouette: a narrow neck at the socket, widest at 45%, a soft taper towards\r\n * the tip. A foot tread at the back — on a real shovel the top edge of the\r\n * blade is folded over, to press down on with the foot.\r\n */\r\nimport { Color, type BufferGeometry } from 'three'\r\n\r\nimport {\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n createTinter,\r\n dishedSheetGeometry,\r\n ironTint,\r\n mergeColoured,\r\n prismGeometry,\r\n toolShaft,\r\n toolSocket,\r\n type SheetLevel,\r\n} from '../core/index.ts'\r\n\r\nexport interface WoodenShovelConfig {\r\n readonly length: number\r\n readonly shaftRadius: number\r\n /** The widest point of the blade (metres). */\r\n readonly bladeWidth: number\r\n /** Blade length, as a fraction of the total length. */\r\n readonly bladeLength: number\r\n /** Depth of the scoop: how far the edges rise above the middle. 0 = flat sheet. */\r\n readonly dish: number\r\n /** Tilt of the blade relative to the shaft (degrees). */\r\n readonly bladeAngle: number\r\n readonly seed: number\r\n}\r\n\r\nexport const woodenShovelDefaults: WoodenShovelConfig = {\r\n length: 1.16,\r\n shaftRadius: 0.022,\r\n bladeWidth: 0.27,\r\n bladeLength: 0.27,\r\n dish: 0.17,\r\n bladeAngle: 9,\r\n seed: 31,\r\n}\r\n\r\nexport type WoodenShovelParts = 'shaft' | 'socket' | 'blade'\r\n\r\nexport function createModel(overrides: Partial<WoodenShovelConfig> = {}) {\r\n return createKitModel<WoodenShovelConfig, 'oak' | 'iron', WoodenShovelParts>({\r\n id: 'wooden-shovel',\r\n defaults: woodenShovelDefaults,\r\n slots: ['oak', 'iron'],\r\n build: ({ config, random }) => {\r\n const tint = createTinter(random)\r\n const span = config.length * config.bladeLength\r\n const shaftLength = config.length - span * 0.82\r\n const shaft = toolShaft({ length: shaftLength, radius: config.shaftRadius, random })\r\n\r\n // --- T-handle --------------------------------------------------------\r\n // The reference has one and this did not: the shaft simply ended in a\r\n // swelled knob. A digging spade is worked with both hands and the lower\r\n // one goes round a crossbar; it is the detail that separates a spade\r\n // from a stick with a plate on the end, and it is right at the end of\r\n // the silhouette where the eye lands.\r\n //\r\n // The bar crosses the grip swell rather than butting onto it, so the\r\n // joint is an overlap like every other in the kit.\r\n const gripSpan = config.shaftRadius * 8.6\r\n const grip = prismGeometry(\r\n config.shaftRadius * 0.95,\r\n config.shaftRadius * 0.82,\r\n gripSpan,\r\n 7,\r\n [0, 0, 0],\r\n new Color(tint('oak', 0.05)),\r\n )\r\n // Built along Y; +90 degrees about Z lays it along X, across the blade.\r\n grip.rotateZ(Math.PI / 2)\r\n grip.translate(0, -shaft.top + config.shaftRadius * 0.9, 0)\r\n\r\n const socketLength = config.length * 0.05\r\n const socket = toolSocket({\r\n y: shaft.top - socketLength * 0.3,\r\n shaftRadius: shaft.topRadius,\r\n length: socketLength,\r\n random,\r\n })\r\n\r\n const half = config.bladeWidth / 2\r\n const t = config.length * 0.011\r\n const curve = config.bladeWidth * config.dish\r\n\r\n // Cross-section profile — the FOURTH attempt, this time over the silhouette.\r\n //\r\n // The third attempt was geometrically correct (one continuous dished\r\n // surface) but it still did not read as a shovel: the edges bulged in the\r\n // middle and closed softly towards the tip, i.e. a SPOON profile. A shovel\r\n // is not a spoon: it runs almost PARALLEL along its sides, then ends in a\r\n // short chamfer at the tip. What holds the soil is that parallel part;\r\n // without it what you get is a spatula.\r\n const profile: SheetLevel[] = [\r\n { y: 0, halfWidth: half * 0.24, thickness: t * 1.5, curve: curve * 0.08 },\r\n { y: span * 0.11, halfWidth: half * 0.82, thickness: t * 1.15, curve: curve * 0.4 },\r\n { y: span * 0.28, halfWidth: half * 0.99, thickness: t * 0.95, curve: curve * 0.85 },\r\n { y: span * 0.62, halfWidth: half, thickness: t * 0.82, curve },\r\n { y: span * 0.85, halfWidth: half * 0.95, thickness: t * 0.6, curve: curve * 0.94 },\r\n { y: span * 0.96, halfWidth: half * 0.74, thickness: t * 0.32, curve: curve * 0.72 },\r\n { y: span, halfWidth: half * 0.44, thickness: t * 0.14, curve: curve * 0.48 },\r\n ]\r\n\r\n // Both colours copied out first: `createTinter` returns one Color and\r\n // mutates it, so two tints inside one call would resolve to the same\r\n // value.\r\n const boardLow = new Color(tint('oak', -0.05))\r\n const boardHigh = new Color(tint('oakEnd', 0.03))\r\n const sheet = dishedSheetGeometry(profile, 8, boardLow, boardHigh)\r\n\r\n // Foot tread: the fold at the top edge of the blade, BEHIND the dish. It\r\n // must sit at shoulder height and be WIDE — this is where you step, and a\r\n // narrow ledge is both useless and invisible in the silhouette.\r\n const tread = chamferedBoxGeometry(\r\n [config.bladeWidth * 0.78, t * 2.4],\r\n [config.bladeWidth * 0.7, t * 1.9],\r\n t * 1.8,\r\n t * 0.34,\r\n [0, 0, 0],\r\n new Color(tint('oak', -0.1)),\r\n )\r\n tread.rotateX(0.42)\r\n tread.translate(0, span * 0.12, -t * 1.7)\r\n\r\n // Back strap: forged iron leaving the socket and running up BEHIND the\r\n // blade. This is the second thing that makes a shovel a shovel. Without it\r\n // the blade looks like a sheet glued to the end of the shaft; on a real\r\n // shovel the strap is what carries the blade, and it is also what answers\r\n // the eye's question of \"how is this held on\".\r\n const strap = chamferedBoxGeometry(\r\n [config.shaftRadius * 2.3, t * 2.2],\r\n [config.shaftRadius * 1.1, t * 1.4],\r\n span * 0.5,\r\n t * 0.3,\r\n [0, span * 0.22, -t * 1.4],\r\n ironTint(random, -0.02),\r\n )\r\n\r\n const blade: BufferGeometry = mergeColoured([sheet, tread])\r\n // Both must take the SAME transform, or the strap won't stay behind the blade.\r\n for (const piece of [blade, strap]) {\r\n // The blade is tilted slightly forward of the shaft line, so it bites the soil.\r\n piece.rotateX(-(config.bladeAngle * Math.PI) / 180)\r\n piece.translate(0, shaft.top - span * 0.06, 0)\r\n }\r\n\r\n return {\r\n shaft: { slot: 'oak', geometry: mergeColoured([shaft.geometry, grip]) },\r\n socket: { slot: 'iron', geometry: socket },\r\n blade: {\r\n // Oak, not steel. A medieval digging shovel is a shaped board with\r\n // an iron shoe along its lip -- the strap below is that shoe, and it\r\n // has been there all along while the board it protects was rendered\r\n // as a sheet of polished steel.\r\n slot: 'oak',\r\n geometry: blade,\r\n extras: [{ slot: 'iron', geometry: strap }],\r\n },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
3344
+ "hash": "b735415c9bb00977628e7681faaab80c89f9d144e737a760c1eed26b21045383"
3345
+ }
3346
+ ],
3347
+ "meta": {
3348
+ "title": "Wooden Shovel",
3349
+ "description": "A single-piece dished steel plate that both widens and thins toward the tip.",
3350
+ "category": "Tools",
3351
+ "tags": [
3352
+ "medieval",
3353
+ "lowpoly",
3354
+ "tools",
3355
+ "farm",
3356
+ "procedural"
3357
+ ],
3358
+ "controls": {
3359
+ "length": {
3360
+ "type": "number",
3361
+ "label": "Length",
3362
+ "min": 0.8,
3363
+ "max": 1.8,
3364
+ "step": 0.02,
3365
+ "unit": "m"
3366
+ },
3367
+ "shaftRadius": {
3368
+ "type": "number",
3369
+ "label": "Shaft thickness",
3370
+ "min": 0.014,
3371
+ "max": 0.035,
3372
+ "step": 0.001,
3373
+ "unit": "m"
3374
+ },
3375
+ "bladeWidth": {
3376
+ "type": "number",
3377
+ "label": "Blade width",
3378
+ "min": 0.12,
3379
+ "max": 0.34,
3380
+ "step": 0.005,
3381
+ "unit": "m"
3382
+ },
3383
+ "bladeLength": {
3384
+ "type": "number",
3385
+ "label": "Blade length",
3386
+ "min": 0.18,
3387
+ "max": 0.4,
3388
+ "step": 0.01
3389
+ },
3390
+ "dish": {
3391
+ "type": "number",
3392
+ "label": "Scoop depth",
3393
+ "min": 0,
3394
+ "max": 0.28,
3395
+ "step": 0.01
3396
+ },
3397
+ "bladeAngle": {
3398
+ "type": "number",
3399
+ "label": "Blade angle",
3400
+ "min": 0,
3401
+ "max": 25,
3402
+ "step": 1,
3403
+ "unit": "degrees"
3404
+ },
3405
+ "seed": {
3406
+ "type": "number",
3407
+ "label": "Variation seed",
3408
+ "min": 1,
3409
+ "max": 64,
3410
+ "step": 1
3411
+ }
3412
+ },
3413
+ "materialSlots": [
3414
+ "oak",
3415
+ "iron"
3416
+ ],
3417
+ "parts": [
3418
+ "shaft",
3419
+ "socket",
3420
+ "blade"
3421
+ ],
3422
+ "sockets": []
3423
+ }
3424
+ },
3425
+ {
3426
+ "name": "wooden-stool",
3427
+ "type": "vibe3d:model",
3428
+ "title": "Wooden Stool",
3429
+ "description": "Three-legged village stool — three is the count that does not rock on uneven ground.",
3430
+ "dependencies": [
3431
+ "three@>=0.185.0"
3432
+ ],
3433
+ "registryDependencies": [
3434
+ "@medieval-kit/core"
3435
+ ],
3436
+ "files": [
3437
+ {
3438
+ "path": "models/wooden-stool/model.ts",
3439
+ "target": "{models}/medieval-kit/wooden-stool/model.ts",
3440
+ "content": "/**\r\n * @medieval-kit/wooden-stool\r\n *\r\n * A three-legged stool. Three legs are no accident: on uneven ground three legs\r\n * always touch, a fourth one rocks — that is why village furniture has three.\r\n *\r\n * Its function in a scene: to say \"somebody sits here\". Not a prop on its own\r\n * but the sign of a human presence.\r\n */\r\nimport { Color } from 'three'\r\n\r\nimport {\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n jitter,\r\n MEDIEVAL_PALETTE,\r\n mergeColoured,\r\n prismGeometry,\r\n} from '../core/index.ts'\r\n\r\nexport interface WoodenStoolConfig {\r\n readonly height: number\r\n /** Radius of the seat (metres). */\r\n readonly seatRadius: number\r\n readonly legCount: number\r\n /** How far the legs splay outwards. 0 = upright. */\r\n readonly splay: number\r\n readonly seed: number\r\n}\r\n\r\nexport const woodenStoolDefaults: WoodenStoolConfig = {\r\n height: 0.46,\r\n seatRadius: 0.17,\r\n legCount: 3,\r\n splay: 0.22,\r\n seed: 17,\r\n}\r\n\r\nexport type WoodenStoolParts = 'seat' | 'legs'\r\n\r\nexport function createModel(overrides: Partial<WoodenStoolConfig> = {}) {\r\n return createKitModel<WoodenStoolConfig, 'oak', WoodenStoolParts>({\r\n id: 'wooden-stool',\r\n defaults: woodenStoolDefaults,\r\n slots: ['oak'],\r\n build: ({ config, random }) => {\r\n const tint = new Color()\r\n const half = config.height / 2\r\n const seatThickness = config.seatRadius * 0.22\r\n\r\n // Seat: a thick wooden disc — that is, a short cylinder. Its edge narrows\r\n // slightly downwards, which gives the impression of being hewn from a log.\r\n tint.copy(MEDIEVAL_PALETTE.oakEnd)\r\n tint.offsetHSL(jitter(random, 0.01), jitter(random, 0.04), jitter(random, 0.04))\r\n const seatTop = half\r\n const seat = prismGeometry(\r\n config.seatRadius * 0.94,\r\n config.seatRadius,\r\n seatThickness,\r\n 12,\r\n [0, seatTop - seatThickness / 2, 0],\r\n tint,\r\n )\r\n\r\n const legs = []\r\n const count = Math.max(3, config.legCount)\r\n const legLength = config.height - seatThickness\r\n for (let i = 0; i < count; i += 1) {\r\n const angle = (i / count) * Math.PI * 2 + jitter(random, 0.06)\r\n tint.copy(MEDIEVAL_PALETTE.oak)\r\n tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), jitter(random, 0.06))\r\n const thick = config.seatRadius * 0.2\r\n\r\n // Leg: a stick hanging below the origin, tapering downwards. Order is\r\n // critical: lean first, then MOVE OUT TO THE RADIUS, then rotate. Without\r\n // the move-to-radius step all three legs stack on the axis when splay=0.\r\n const leg = chamferedBoxGeometry(\r\n [thick * 0.72, thick * 0.72],\r\n [thick, thick],\r\n legLength,\r\n thick * 0.16,\r\n [0, -legLength / 2, 0],\r\n tint,\r\n )\r\n leg.rotateZ(config.splay)\r\n leg.translate(config.seatRadius * 0.6, 0, 0)\r\n leg.rotateY(angle)\r\n // Go INTO the seat disc: the top end is invisible and aligns with no plane.\r\n leg.translate(0, seatTop - seatThickness * 0.35, 0)\r\n legs.push(leg)\r\n }\r\n\r\n return {\r\n seat: { slot: 'oak', geometry: seat },\r\n legs: { slot: 'oak', geometry: mergeColoured(legs) },\r\n }\r\n },\r\n }, overrides)\r\n}\r\n",
3441
+ "hash": "a9930f0245c83b7fe6d0c9759430f63de2ec85c722d4e895bc728a6e18f57af8"
3442
+ }
3443
+ ],
3444
+ "meta": {
3445
+ "title": "Wooden Stool",
3446
+ "description": "Three-legged village stool — three is the count that does not rock on uneven ground.",
3447
+ "category": "Furniture",
3448
+ "tags": [
3449
+ "medieval",
3450
+ "lowpoly",
3451
+ "furniture",
3452
+ "procedural"
3453
+ ],
3454
+ "controls": {
3455
+ "height": {
3456
+ "type": "number",
3457
+ "label": "Height",
3458
+ "min": 0.25,
3459
+ "max": 0.9,
3460
+ "step": 0.01,
3461
+ "unit": "m"
3462
+ },
3463
+ "seatRadius": {
3464
+ "type": "number",
3465
+ "label": "Seat radius",
3466
+ "min": 0.1,
3467
+ "max": 0.3,
3468
+ "step": 0.005,
3469
+ "unit": "m"
3470
+ },
3471
+ "legCount": {
3472
+ "type": "number",
3473
+ "label": "Legs",
3474
+ "min": 3,
3475
+ "max": 5,
3476
+ "step": 1
3477
+ },
3478
+ "splay": {
3479
+ "type": "number",
3480
+ "label": "Leg splay",
3481
+ "min": 0,
3482
+ "max": 0.45,
3483
+ "step": 0.01
3484
+ },
3485
+ "seed": {
3486
+ "type": "number",
3487
+ "label": "Variation seed",
3488
+ "min": 1,
3489
+ "max": 64,
3490
+ "step": 1
3491
+ }
3492
+ },
3493
+ "materialSlots": [
3494
+ "oak"
3495
+ ],
3496
+ "parts": [
3497
+ "seat",
3498
+ "legs"
3499
+ ],
3500
+ "sockets": []
3501
+ }
3502
+ },
3503
+ {
3504
+ "name": "kit",
3505
+ "type": "vibe3d:kit",
3506
+ "title": "Medieval Kit",
3507
+ "description": "The complete lowpoly medieval kit.",
3508
+ "dependencies": [],
3509
+ "registryDependencies": [
3510
+ "@medieval-kit/bronze-bell",
3511
+ "@medieval-kit/cart-wheel",
3512
+ "@medieval-kit/coin-pouch",
3513
+ "@medieval-kit/forge-hearth",
3514
+ "@medieval-kit/glass-phial",
3515
+ "@medieval-kit/grindstone",
3516
+ "@medieval-kit/hand-cart",
3517
+ "@medieval-kit/hay-bale",
3518
+ "@medieval-kit/iron-anvil",
3519
+ "@medieval-kit/iron-cauldron",
3520
+ "@medieval-kit/iron-lantern",
3521
+ "@medieval-kit/leather-book",
3522
+ "@medieval-kit/linen-sack",
3523
+ "@medieval-kit/log-pile",
3524
+ "@medieval-kit/market-stall",
3525
+ "@medieval-kit/oak-tankard",
3526
+ "@medieval-kit/pitch-torch",
3527
+ "@medieval-kit/post-mill",
3528
+ "@medieval-kit/round-shield",
3529
+ "@medieval-kit/stone-trough",
3530
+ "@medieval-kit/stone-well",
3531
+ "@medieval-kit/straw-broom",
3532
+ "@medieval-kit/tavern-sign",
3533
+ "@medieval-kit/trestle-table",
3534
+ "@medieval-kit/vegetables",
3535
+ "@medieval-kit/wicker-basket",
3536
+ "@medieval-kit/wooden-barrel",
3537
+ "@medieval-kit/wooden-bench",
3538
+ "@medieval-kit/wooden-bucket",
3539
+ "@medieval-kit/wooden-chest",
3540
+ "@medieval-kit/wooden-crate",
3541
+ "@medieval-kit/wooden-fence",
3542
+ "@medieval-kit/wooden-hoe",
3543
+ "@medieval-kit/wooden-ladder",
3544
+ "@medieval-kit/wooden-pitchfork",
3545
+ "@medieval-kit/wooden-shovel",
3546
+ "@medieval-kit/wooden-stool"
3547
+ ],
3548
+ "files": []
3549
+ }
3550
+ ]
3551
+ }