@medieval-kit/registry 0.1.1 → 0.2.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.
- package/README.md +20 -21
- package/dist/registry.json +70 -71
- package/package.json +1 -1
package/dist/registry.json
CHANGED
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
{
|
|
45
45
|
"path": "models/core/materials.ts",
|
|
46
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": "
|
|
47
|
+
"content": "import { Color, DoubleSide, MeshBasicMaterial, MeshStandardMaterial } from 'three'\n\nimport type { ResourceScope } from '@vibe3d/ownership.ts'\n\n/**\n * The kit's shared material set.\n *\n * The critical choice: every material is `vertexColors: true`. Colour\n * variation is carried in the GEOMETRY, not in the material. That way 13\n * boards can have 13 separate tones while all of them share a single\n * material, and therefore a single draw call.\n *\n * This is the small version of the idea behind scifi-kit's wear pipeline:\n * write the surface identity into a vertex attribute, then merge.\n */\nexport type MedievalSlot =\n | 'oak' // timber\n | 'iron' // wrought iron\n | 'steel' // steel burnished by use\n | 'brass' // bronze and copper: bells, coins\n | 'straw' // straw, wicker, besom bristles\n | 'cloth' // linen, sackcloth, parchment\n | 'leather' // leather: book covers, pouches\n | 'glass' // blown glass\n | 'produce' // fruit and vegetable skin\n | 'ember' // flame — does not take light, it emits it\n | 'char' // charcoal, pitch\n | 'stone' // dressed and rubble masonry\n | 'water' // standing water\n\n/**\n * The material TYPE can differ per slot. `ember` is an unlit MeshBasicMaterial;\n * the rest are PBR. Thanks to this mapping the model code knows at compile\n * time which type it is getting.\n */\nexport interface SlotMaterial {\n readonly oak: MeshStandardMaterial\n readonly iron: MeshStandardMaterial\n readonly steel: MeshStandardMaterial\n readonly brass: MeshStandardMaterial\n readonly straw: MeshStandardMaterial\n readonly cloth: MeshStandardMaterial\n readonly leather: MeshStandardMaterial\n readonly glass: MeshStandardMaterial\n readonly produce: MeshStandardMaterial\n readonly ember: MeshBasicMaterial\n readonly char: MeshStandardMaterial\n readonly stone: MeshStandardMaterial\n readonly water: MeshStandardMaterial\n}\n\nexport interface MedievalPalette {\n /** Oak body tone. */\n readonly oak: Color\n /** Lid board — a little cooler and lighter than the body (end grain). */\n readonly oakEnd: Color\n /** Wrought iron: fresh off the anvil, the oxide layer still on it. */\n readonly iron: Color\n /** Steel burnished by use: the anvil's face, the shovel's blade, the fork's tines. */\n readonly steel: Color\n /** Bronze — bells, mortars, coins. */\n readonly brass: Color\n /**\n * Bell bronze that has stood outside.\n *\n * Separate from `brass` because they are not the same colour and the models\n * that use each are not the same models. Fresh bronze on a coin or a book's\n * corner boss is the yellow `brass` is; a bell that has hung in weather for a\n * century is dark and grey. Measured against a reference photograph the bell\n * sits at hue 33, saturation 0.34, value 0.33, against 39 / 0.56 / 0.56 for\n * the brass we were painting it with.\n *\n * This is a PALETTE entry, not a slot: the mesh still resolves through the\n * `brass` material. The palette carries colour, the slot carries material,\n * and keeping them separate is what lets one model recolour without dragging\n * the coin pouch and the book's fittings along with it.\n */\n readonly bronze: Color\n /** Dry straw. */\n readonly straw: Color\n /** Straw tips bleached by the sun. */\n readonly strawPale: Color\n /** Raw linen / sackcloth. */\n readonly cloth: Color\n /** Tanned leather. */\n readonly leather: Color\n /** Blown glass — slightly greenish, glass of the period was not clear. */\n readonly glass: Color\n /** Base tone of fruit skin. The actual colour comes from the model's `hue` field. */\n readonly produce: Color\n /** Base of the flame — hot and bright. */\n readonly ember: Color\n /** Tip of the flame — more saturated, more red. */\n readonly emberTip: Color\n /** Burnt-out charcoal. */\n readonly char: Color\n /** Charcoal that is still glowing. */\n readonly charHot: Color\n /**\n * Masonry.\n *\n * The kit had no stone at all, which for a medieval catalogue is a hole\n * rather than an omission: a well, a trough, a millstone, a boundary wall\n * and a hearth are all stone before they are anything else. Its colour is\n * close to weathered oak on purpose -- what separates the two at a glance is\n * not hue but that stone scatters light completely flat, which is carried by\n * the material's roughness rather than by the palette.\n */\n readonly stone: Color\n\n /**\n * Living bark, which is NOT the timber colour and is not close to it.\n *\n * Measured off a photograph of a standing oak: hue 70 degrees, saturation\n * 0.17. Sawn oak in this palette sits at hue 26 and saturation 0.36 -- the\n * bark is 44 degrees cooler and half as saturated, because it is grey-green\n * with lichen and algae on it rather than brown. Reaching for the `oak` key\n * on a trunk gives a plank standing on end.\n *\n * Measured off the WINTER photograph, after the first attempt took it off\n * the summer one and came out 28 degrees too green. A trunk under a crown in\n * full leaf is lit through the leaves, and the green it bounces down is in\n * every pixel of it; the same tree bare reads hue 42 and saturation 0.27\n * against 70 and 0.17. Two photographs of the same species disagreeing by\n * that much is not noise, it is one of them measuring the light instead of\n * the bark.\n *\n * The low saturation of the first attempt made it worse than the hue alone:\n * a nearly neutral colour takes its hue from whatever is lighting it, and in\n * this kit's renderer that is a blue-grey sky, which dragged the rendered\n * bole all the way to hue 119 -- properly green, on a tree whose bark is\n * brown. Saturation here is what holds the hue still.\n *\n * Lightness is the one number not taken straight: the photograph averages\n * 0.21, but a good part of that is shadow inside bark fissures millimetres\n * deep which nine flat facets cannot reproduce. The straw note below is the\n * same trap.\n */\n readonly bark: Color\n\n\n /**\n * Weathered limestone, which is NOT the same rock as `stone`.\n *\n * `stone` was measured off dressed and rubble masonry: hue 44, saturation\n * 0.05 — a wall stone, grey, and grey because a wall is quarried and laid\n * face out. A trough is one block left in the open for a century, and the\n * same measurement on one reads hue 36, saturation 0.16, lightness 0.48:\n * warmer, THREE TIMES more saturated, and lighter. Reaching for `stone` on\n * it gives a concrete planter.\n *\n * Both are correct and neither replaces the other, which is why this is a\n * palette key rather than an edit to `stone` — the well and the forge were\n * each measured against their own references and are not this rock.\n */\n readonly limestone: Color\n\n /**\n * Standing water, and it is far less green than it feels.\n *\n * Sampled inside the reference trough's basin the film reads hue 39.5 —\n * which is the hue of the stone underneath it, not a colour of its own. It\n * is shallow and it is clear; what it does is DARKEN and slightly saturate\n * whatever it lies on (lightness 0.48 down to 0.36, saturation 0.16 up to\n * 0.23). So this is nearly neutral, barely cool, and it does its work\n * through the material's transparency rather than through its hue. Painted\n * the pond green a first-instinct would have reached for, the composite\n * would have missed the reference by 100 degrees of hue.\n */\n readonly water: Color\n}\n\n/**\n * The colours are written as sRGB; with ColorManagement enabled three stores\n * them internally in linear space. Since the vertex colour attribute expects\n * linear values, `color.r/g/b` can be written straight through — no extra\n * conversion needed.\n */\n/**\n * Base colours, measured against reference photographs rather than chosen.\n *\n * The hue was already right -- our oak sits at 26 degrees and real oak in a\n * photograph sits at 27 -- but the saturation was consistently too high, by a\n * median of 0.08 across ten wooden models, and worst on the ones made almost\n * entirely of bare timber: the ladder and the fence were each 0.16 over. That\n * is the difference between weathered oak and new pine, and it was making the\n * whole kit read as plastic.\n *\n * Straw was left alone for a long time on an argument that was half right, and\n * the half that was wrong cost four models. The original measurement said straw\n * was both too yellow and too washed out. The saturation half was rejected for a\n * good reason, still true: a photograph of a bale reads as desaturated because\n * it is thousands of individual straws each shading its neighbour, and pushing a\n * flat lowpoly surface to that number does not reproduce the texture, it only\n * makes the surface garish. Saturation now measures within 0.03 of the\n * references, so that call was correct.\n *\n * The hue half was thrown out with it, and nothing checked lightness at all.\n * Measured against the references, straw sat 11 degrees too yellow and a median\n * 0.18 too light, worst on the broom and the stall awning at 0.30 and 0.29. A\n * bale came out the colour of butter. Rejecting the inference from one axis is\n * not a reason to discard the others that were measured beside it.\n *\n * That saturation pass was right and it was not the whole story. It closed the\n * gap it measured: the render now sits within 0.03 of the references on\n * saturation. Then it concluded from two axes that colour was finished. The\n * axis it never measured was lightness, and across nine timber models the\n * render is a median 0.13 lighter than its reference, worst on the chest at\n * 0.21 and the trestle table at 0.17. Hue is four degrees short as well,\n * sitting at 25 where the references sit at 29.\n *\n * A light, red-shifted brown is pink, which is the word eleven separate critics\n * reached for. The earlier note that what remained was geometry rather than\n * colour came from a measurement that could not have seen the problem. The\n * lesson is not that the numbers lied. It is that a clean result on the axes\n * you chose says nothing about the axis you did not.\n */\nexport const MEDIEVAL_PALETTE: MedievalPalette = {\n oak: new Color(0x5c442e),\n oakEnd: new Color(0x705942),\n iron: new Color(0x40464d),\n steel: new Color(0x8d979f),\n brass: new Color(0xa9843f),\n bronze: new Color(0x63523e),\n straw: new Color(0x7f592e),\n strawPale: new Color(0xb5834a),\n cloth: new Color(0xa48965),\n leather: new Color(0x6b452c),\n glass: new Color(0xbcd4cb),\n produce: new Color(0xa8452f),\n ember: new Color(0xffd27a),\n emberTip: new Color(0xd8571b),\n char: new Color(0x241f1c),\n stone: new Color(0x6e6b63),\n charHot: new Color(0xc4441a),\n bark: new Color(0x585032),\n limestone: new Color(0x94846b),\n water: new Color(0x3b4e45),\n}\n\n/**\n * Creates materials for the requested slots.\n *\n * The slot list is required on purpose: the brazier does not need oak, the\n * barrel does not need an emissive material. Creating unused materials is both\n * a wasted GPU resource and a lie that contradicts the model's `materialSlots`\n * declaration.\n *\n * The return type is narrowed to the requested slots, so `materials.ember`\n * only compiles in a model that asked for it.\n */\nexport function createMedievalMaterials<S extends MedievalSlot>(\n scope: ResourceScope,\n slots: readonly S[],\n): Pick<SlotMaterial, S> {\n const build: { [K in MedievalSlot]: () => SlotMaterial[K] } = {\n oak: () => new MeshStandardMaterial({\n name: 'medieval-kit / oak',\n // White base: all colour information comes from the vertex colour, so\n // the material must not multiply it away.\n color: 0xffffff,\n vertexColors: true,\n roughness: 0.82,\n metalness: 0,\n }),\n // Iron occurs in two states and they cannot be told with ONE material.\n // The anvil's body is forged, oxidised, matte; its face is like a mirror\n // because it has been worked on for years. The difference between them is\n // not one of colour but of ROUGHNESS — vertex colour cannot carry that,\n // because roughness is not an attribute.\n //\n // Hence two slots: `iron` is the forged surface, `steel` is the surface\n // the work has touched. It is normal for a model to want both; it is worth\n // a separate draw call, because a bright cutting edge sells the model on\n // its own.\n iron: () => new MeshStandardMaterial({\n name: 'medieval-kit / wrought iron',\n color: 0xffffff,\n vertexColors: true,\n roughness: 0.62,\n metalness: 0.78,\n }),\n steel: () => new MeshStandardMaterial({\n name: 'medieval-kit / burnished steel',\n color: 0xffffff,\n vertexColors: true,\n // Low roughness + high metalness, i.e. almost fully reflective. This\n // only works if there is an environment map; in a scene without an\n // environment it looks pitch black. The viewer supplies a PMREM sky.\n roughness: 0.19,\n metalness: 0.95,\n }),\n brass: () => new MeshStandardMaterial({\n name: 'medieval-kit / bronze',\n color: 0xffffff,\n vertexColors: true,\n roughness: 0.38,\n metalness: 0.85,\n }),\n // Straw and cloth are fully matte: metalness 0, roughness almost 1. The\n // difference between them is not in the numbers but in the vertex colours\n // — both follow the same lighting model, but keeping them in separate\n // slots matters, because a model's `materialSlots` declaration is a\n // CONTRACT: a straw bale declaring an \"oak\" slot would be a lie told to\n // the consumer.\n straw: () => new MeshStandardMaterial({\n name: 'medieval-kit / straw',\n color: 0xffffff,\n vertexColors: true,\n roughness: 0.94,\n metalness: 0,\n }),\n cloth: () => new MeshStandardMaterial({\n name: 'medieval-kit / linen',\n color: 0xffffff,\n vertexColors: true,\n roughness: 0.97,\n metalness: 0,\n }),\n leather: () => new MeshStandardMaterial({\n name: 'medieval-kit / leather',\n color: 0xffffff,\n vertexColors: true,\n // Leather is not fully matte; being oiled, it has a slight sheen.\n roughness: 0.66,\n metalness: 0,\n }),\n // Glass is a thin SHELL, not a solid block. That has two consequences:\n // `side` has to be DoubleSide (otherwise it disappears when seen from\n // inside) and `depthWrite` has to be off (otherwise it hides the wick\n // behind it).\n glass: () => new MeshStandardMaterial({\n name: 'medieval-kit / blown glass',\n color: 0xffffff,\n vertexColors: true,\n roughness: 0.08,\n metalness: 0,\n transparent: true,\n opacity: 0.34,\n depthWrite: false,\n side: DoubleSide,\n }),\n // Fruit skin is waxy: not fully matte, but not metallic either. The reason\n // it is a separate slot is not the name but the BEHAVIOUR — had I given it\n // the same roughness as straw, an apple would look like dry hay.\n produce: () => new MeshStandardMaterial({\n name: 'medieval-kit / produce',\n color: 0xffffff,\n vertexColors: true,\n roughness: 0.52,\n metalness: 0,\n }),\n // A flame does not TAKE light, it emits it. MeshStandardMaterial would be\n // the wrong tool here: `emissive` is a single Color, it is not fed from\n // vertex colours — so no colour gradient from the flame's base to its tip\n // is possible. MeshBasicMaterial skips lighting entirely and shows the\n // vertex colour as it is; toneMapped is off so the scene exposure cannot\n // put the flame out.\n ember: () => new MeshBasicMaterial({\n name: 'medieval-kit / ember',\n color: 0xffffff,\n vertexColors: true,\n toneMapped: false,\n }),\n stone: () => new MeshStandardMaterial({\n name: 'medieval-kit / stone',\n color: 0xffffff,\n vertexColors: true,\n // Rougher than anything else here. Dressed stone is not polished and\n // rubble certainly is not; any sheen at all reads as ceramic.\n roughness: 0.97,\n metalness: 0,\n }),\n // Water is a THIN TRANSPARENT FILM, not a body of liquid. Everything in\n // this kit that holds water holds a few centimetres of it over a floor you\n // can see, so the colour that reaches the eye is mostly what is underneath\n // — which is why the opacity is the important number here and the hue is\n // nearly not a number at all. Smooth, because a trough out of the wind is\n // the one surface in this kit that reflects.\n water: () => new MeshStandardMaterial({\n name: 'medieval-kit / standing water',\n color: 0xffffff,\n vertexColors: true,\n roughness: 0.11,\n metalness: 0,\n transparent: true,\n opacity: 0.72,\n }),\n char: () => new MeshStandardMaterial({\n name: 'medieval-kit / charcoal',\n color: 0xffffff,\n vertexColors: true,\n roughness: 0.95,\n metalness: 0,\n }),\n }\n\n const materials = {} as { [K in S]: SlotMaterial[K] }\n for (const slot of slots) {\n // TypeScript cannot follow correlated unions: `build[slot]` widens to a\n // union of functions, so it cannot know that the return is exactly\n // SlotMaterial[slot]. The cast is safe because the mapping is built by\n // hand above.\n materials[slot] = scope.ownMaterial(build[slot]()) as SlotMaterial[S]\n }\n return materials\n}\n",
|
|
48
|
+
"hash": "2848b1a7dcd4c5189854099396a73b8007129c902d94542dee60773037ef27a2"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "models/core/occlusion.ts",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
{
|
|
69
69
|
"path": "models/core/tint.ts",
|
|
70
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.
|
|
72
|
-
"hash": "
|
|
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\n/**\r\n * Lightness a tinted colour is never taken below.\r\n *\r\n * These lightnesses are LINEAR, not sRGB, so the whole palette lives between\r\n * about 0.03 and 0.36 and a lift of -0.28 is an enormous move rather than a\r\n * gentle darkening. That is survivable while the palette sits high and fatal\r\n * once it drops. When `cloth` was measured against its references and taken\r\n * from linear lightness 0.364 to 0.252, every part carrying a -0.28 lift went\r\n * straight through zero: nine parts across six models -- the market stall's\r\n * whole trestle, two stretchers, three cords and a set of bindings -- came out\r\n * pure black. In the render they then read as neutral GREY, because a black\r\n * albedo contributes nothing and the only thing left is the white specular.\r\n *\r\n * The lifts were not wrong. They are relative offsets and the thing they were\r\n * relative to moved. So the floor lives here rather than in nine call sites,\r\n * because otherwise the next palette measurement re-breaks all of them, and\r\n * because a part that keeps its hue at the bottom of its range is always a\r\n * better answer than one that loses it.\r\n */\r\nconst FLOOR = 0.045\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 // `offsetHSL` is get-add-set, and this is the same thing with the lightness\r\n // clamped before the set rather than after. Clamping afterwards is not the\r\n // same: a colour that has already reached zero has no hue or saturation\r\n // left to read back, so lifting it returns grey instead of dark timber.\r\n const hsl = { h: 0, s: 0, l: 0 }\r\n scratch.getHSL(hsl)\r\n scratch.setHSL(\r\n hsl.h + jitter(random, 0.012 * spread),\r\n hsl.s + jitter(random, 0.05 * spread),\r\n Math.max(FLOOR, hsl.l + 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": "eca7c84af61843a0653bb1ff11f36772bf8f2f4d6f3da6a7589e310645ee3bd4"
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"path": "models/core/tool.ts",
|
|
@@ -94,8 +94,8 @@
|
|
|
94
94
|
{
|
|
95
95
|
"path": "models/bronze-bell/model.ts",
|
|
96
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": "
|
|
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 /*\r\n * Straps: the two iron ears that clamp the rail where the bell hangs\r\n * from it. They carry it from ABOVE, which is how a real strap works, and\r\n * when their top faces were level with the rail's the two ended up\r\n * coplanar and flickered.\r\n *\r\n * They used to sit at `beamLength * 0.34`, which at the default yoke puts\r\n * them all but directly over the uprights, at the far ends of the rail.\r\n * The bell meets the rail at its centre. So the ironwork was at one end of\r\n * the beam and the load at the other, and the straps held nothing: a\r\n * critic reading the render said exactly that. They now straddle the\r\n * hanger, which is the only place on a beam where a strap has a job.\r\n */\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 * radius * 0.3, 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": "4c5d347eb979b289d45f313927181a66dacf392189545b52a5717d8aac4785d3"
|
|
99
99
|
}
|
|
100
100
|
],
|
|
101
101
|
"meta": {
|
|
@@ -185,8 +185,8 @@
|
|
|
185
185
|
{
|
|
186
186
|
"path": "models/cart-wheel/model.ts",
|
|
187
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": "
|
|
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 bandGeometry,\r\n chamferedBoxGeometry,\r\n createKitModel,\r\n createTinter,\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 // Through the shared tinter. The two helpers this replaces copied a\r\n // palette entry into ONE shared Color and handed it back, so two tints in\r\n // the same call resolved to the same value -- and the hub was doing\r\n // exactly that, asking for a body colour and a lighter end colour in one\r\n // expression and getting the second one twice. The tinter returns a new\r\n // Color every time, and it floors lightness so a lift cannot take a part\r\n // through black.\r\n const tinter = createTinter(random)\r\n const oak = (lift = 0): Color => tinter('oak', lift)\r\n const iron = (lift = 0): Color => tinter('iron', lift, 0.6)\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 // 0.20 of the wheel RADIUS, so the nave is 0.20 of the wheel diameter\r\n // across. It is the landmark that names the object and at 0.17 it was a\r\n // flat disc lost in the spoke plane.\r\n const hubRadius = config.radius * 0.2\r\n // A nave is bored for the axle, and the bore is the reason it reads as a\r\n // nave rather than as a knob.\r\n const boreRadius = hubRadius * 0.3\r\n const hubLength = config.width * config.hubLength\r\n\r\n // --- nave: a stepped, bored hub standing proud of the spoke plane ---\r\n //\r\n // It used to be a turned log: one lathe, symmetric, sitting flush in the\r\n // plane of the spokes with no hole through it. That is a bobbin, not a\r\n // nave. A nave projects well forward of the wheel so the linchpin clears\r\n // the spokes, carries a shorter step behind, and is bored end to end.\r\n //\r\n // Built from bands rather than lathes because a band with `inner` set is\r\n // already a closed tube: outer wall, two end annuli and a bore. Three of\r\n // them stacked give the steps for nothing.\r\n const bodyLen = hubLength * 0.45\r\n const frontLen = hubLength * 0.33\r\n const rearLen = hubLength * 0.22\r\n const frontRadius = hubRadius * 0.72\r\n const rearRadius = hubRadius * 0.78\r\n // Each step sinks a quarter of its length into the body. Butted end to\r\n // end their annuli would be coplanar, and two faces in one plane are a\r\n // flicker whichever way they point.\r\n const frontY = bodyLen / 2 + frontLen / 2 - frontLen * 0.25\r\n const rearY = -bodyLen / 2 - rearLen / 2 + rearLen * 0.25\r\n // The steps are bored slightly wider than the body, which is true of a\r\n // real nave and, here, keeps their bore walls off the body's own.\r\n const hubPieces: BufferGeometry[] = [\r\n bandGeometry(hubRadius, 0, bodyLen, hubRadius - boreRadius, 10, oak(-0.05), { inner: true }),\r\n bandGeometry(frontRadius, frontY, frontLen, frontRadius - boreRadius * 1.1, 10, oak(-0.01), { inner: true }),\r\n bandGeometry(rearRadius, rearY, rearLen, rearRadius - boreRadius * 1.06, 10, oak(-0.03), { inner: true }),\r\n // Two narrow iron rings girdling the steps, the way a nave is hooped to\r\n // stop the timber splitting along the grain from the bore outward.\r\n bandGeometry(frontRadius * 1.05, frontY + frontLen * 0.18, frontLen * 0.26, frontRadius * 0.16, 10, iron()),\r\n bandGeometry(rearRadius * 1.05, rearY - rearLen * 0.16, rearLen * 0.3, rearRadius * 0.16, 10, iron(-0.03)),\r\n ]\r\n const hub = mergeColoured(hubPieces)\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 // The flat outer face sits at the INSCRIBED radius, not at `felloeOuter`.\r\n //\r\n // A felloe piece is a straight box, so its outer face is a chord. Put\r\n // that chord at `felloeOuter` and its two corners are further out than\r\n // its middle: at ten segments they reached 0.5338 on a wheel whose iron\r\n // tyre finishes at 0.52, so the corners came through the tyre and the rim\r\n // rendered as alternating bands of tan wood and dark iron. Pulling the\r\n // face in by cos(step/2) lands the CORNERS on `felloeOuter`, which is\r\n // where the tyre's inner face is, so every piece bites into the iron and\r\n // none of it breaks the outside.\r\n const felloeFace = felloeOuter * Math.cos(step / 2)\r\n const midRadius = (felloeFace + 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 felloeFace - 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": "0b108053ee74cb57b8075d7d719ea5aa210b890813ba9469b44183b24d39a65a"
|
|
190
190
|
}
|
|
191
191
|
],
|
|
192
192
|
"meta": {
|
|
@@ -274,8 +274,8 @@
|
|
|
274
274
|
{
|
|
275
275
|
"path": "models/coin-pouch/model.ts",
|
|
276
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": "
|
|
277
|
+
"content": "/**\n * @medieval-kit/coin-pouch\n *\n * A drawstring leather pouch with silver pennies spilled beside it.\n *\n * What names this object is the TOP, not the body. A closed leather bag reads\n * as a stone; a lathe that pinches and then flares reads as a BOTTLE; a body\n * widest at mid-height with a shoulder break reads as THROWN POTTERY, which is\n * the death pass four is answering. The pouch lives in three landmarks:\n *\n * 1. the body is a TEARDROP, not a pot (pass four): widest ring ~35% of the\n * height up from the base, base slumping out fast from a flat contact\n * patch about a third of the max width, and above the bulge one\n * continuous concave sweep to the neck, radius = tieR + span*(1-t)^1.6,\n * with NO shoulder ring. Pass three's profile put the widest loop near\n * mid-height with a distinct shoulder crease and scored as a clay pot.\n * 2. the gathered crown: the SAME hide as the body, bunched. Pass four\n * replaced the ring of separate square prisms (background showed through\n * the gaps) with tapered WEDGES that touch at their bases: each fold is\n * wide tangentially at the collar and pinched at the tip, leans out\n * 10-18 degrees with a small twist about its own axis, and a dark plug\n * fills the throat so the mouth is a dark slot, never open sky.\n * 3. the drawstring is a SINGLE TIE (pass four): two wrapped turns, one\n * knot cluster on one side, and BOTH tails leaving from that knot with\n * different lengths, bowing away from the wall. Pass three's two\n * mirrored hangers on opposite sides read as handles. Tail control\n * points clamp to radiusAt(y) plus a cord radius so no strand can enter\n * the body on its way down.\n *\n * The side seam is a continuous overlapping braid welt (pass four): bars 1.5x\n * their spacing so consecutive bars overlap, buried wide*0.035 into the wall\n * with a radial depth that outreaches the roughen amplitude (dropped to\n * wide*0.02) in both directions. Pass three's separate half-sunk stitches sat\n * proud of roughened facets and the lowest one detached entirely.\n *\n * Body colour is near-uniform: pass three's lighter colourTop lerped to a\n * visible horizontal band at mid-height that read as a pot lid seam.\n *\n * Coins: silver pennies, cool grey (steel slot), all ON THE FLOOR as one\n * thinning trail whose first coin touches the base. No coin on the pouch: at\n * this scale a coin bedded into leather cannot be told from a clipping bug.\n * Every third floor coin leans on its neighbour with its rim on the ground,\n * offset DERIVED from the chosen tilt. Flat coins alternate sides of the\n * trail axis so consecutive coins cannot drift into each other.\n */\nimport type { BufferGeometry } from 'three'\n\nimport {\n bandGeometry,\n boxGeometry,\n createKitModel,\n createTinter,\n jitter,\n latheGeometry,\n mergeColoured,\n prismGeometry,\n roughenGeometry,\n taperedBoxGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface CoinPouchConfig {\n /** Height of the pouch (metres). */\n readonly height: number\n /** Widest radius of the pouch (metres). */\n readonly radius: number\n /** How full it is. 0.3 is half empty and slumped, 1 is stuffed. */\n readonly fill: number\n /** Number of coins spilled outside. */\n readonly coins: number\n /** Coin radius (metres). */\n readonly coinRadius: number\n readonly seed: number\n}\n\nexport const coinPouchDefaults: CoinPouchConfig = {\n height: 0.115,\n radius: 0.042,\n fill: 0.85,\n coins: 9,\n coinRadius: 0.011,\n seed: 89,\n}\n\nexport type CoinPouchParts = 'pouch' | 'cord' | 'coins'\n\nexport function createModel(overrides: Partial<CoinPouchConfig> = {}) {\n return createKitModel<CoinPouchConfig, 'leather' | 'cloth' | 'steel', CoinPouchParts>({\n id: 'coin-pouch',\n defaults: coinPouchDefaults,\n slots: ['leather', 'cloth', 'steel'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n const fill = Math.max(0.15, Math.min(1, config.fill))\n const h = config.height\n const wide = config.radius * (0.68 + fill * 0.4)\n // The pinch sits at about 70% of the height; the crown owns the rest.\n const tieY = h * (0.6 + fill * 0.12)\n // The tube at the pinch is 40% of the body width: a fat gathered neck,\n // not a bottleneck. Thinner than this and the vase comes back.\n const tieR = wide * 0.4\n const tubeTop = tieY + h * 0.07\n\n // --- Body -------------------------------------------------------------\n // A teardrop: flat contact patch about a third of the max width, fast\n // slump out to the widest ring at ~35% of the height, then ONE concave\n // sweep to the neck. No shoulder ring, no flat wide base disc.\n const maxY = h * (0.33 + 0.07 * fill)\n const sweep = (t: number): Level => ({\n y: maxY + (tieY - maxY) * t,\n radius: tieR + (wide - tieR) * Math.pow(1 - t, 1.35),\n })\n const profile: Level[] = [\n { y: 0, radius: wide * 0.36 },\n { y: h * 0.035, radius: wide * 0.64 },\n { y: h * 0.12, radius: wide * 0.88 },\n { y: maxY * 0.72, radius: wide * 0.97 },\n { y: maxY, radius: wide },\n sweep(0.22),\n sweep(0.45),\n sweep(0.68),\n sweep(0.88),\n { y: tieY, radius: tieR },\n { y: tubeTop, radius: tieR * 1.08 },\n ]\n const radiusAt = (y: number): number => {\n for (let i = 0; i < profile.length - 1; i += 1) {\n const a = profile[i]!\n const b = profile[i + 1]!\n if (y >= a.y && y <= b.y) {\n const t = (y - a.y) / Math.max(1e-9, b.y - a.y)\n return a.radius + (b.radius - a.radius) * t\n }\n }\n return profile[profile.length - 1]!.radius\n }\n\n const pouchPieces: BufferGeometry[] = []\n // Near-uniform colour: a lighter top lerped across the lathe put a hard\n // horizontal band at mid-height that read as a pot lid seam.\n const body = latheGeometry(profile, 13, [0, 0, 0], tint('leather', -0.1, 0.85), {\n capTop: true,\n })\n roughenGeometry(body, wide * 0.02, { salt: 51, scaleY: 0.6 })\n pouchPieces.push(body)\n\n // Dark plug filling the throat: the mouth is a small dark slot between\n // the fold tips, never open sky through to the far side. The leather\n // palette is already dark: a lift below about -0.12 clamps to black and\n // reads as missing geometry, not shadow, so this stays at -0.11.\n // Its cap stays BELOW the fold tips: a taller plug lifted its brightly\n // lit top cap above the folds, where it read as a pale sliver of\n // background between the tips.\n pouchPieces.push(prismGeometry(\n tieR * 0.95, tieR * 0.55, h * 0.16, 8,\n [0, tubeTop + h * 0.02, 0], tint('leather', -0.11, 0.6),\n ))\n\n // The gathered crown: seven wedge folds of the BODY'S leather (same\n // lift; a lighter lift here turned a previous crown terracotta). Each\n // fold is a tapered wedge, wide tangentially at the collar so the bases\n // TOUCH their neighbours, pinched at the tip, leaning outward 10-18\n // degrees with a small twist about its own axis.\n const foldCount = 7\n const crownBase = tieY + h * 0.005\n const ringR = tieR * 0.7\n const chord = 2 * ringR * Math.sin(Math.PI / foldCount)\n for (let i = 0; i < foldCount; i += 1) {\n const a = (i / foldCount) * Math.PI * 2 + jitter(random, 0.06)\n const foldH = h * (0.16 + random() * 0.05)\n const baseW = chord * 1.25\n const baseD = tieR * 0.6\n const lean = 0.18 + random() * 0.14\n const twist = 0.06 + jitter(random, 0.15)\n const col = tint('leather', -0.1 + jitter(random, 0.02), 0.7)\n const colTip = tint('leather', -0.08 + jitter(random, 0.02), 0.55)\n\n const fold = taperedBoxGeometry(\n [baseW, baseD], [baseW * 0.38, baseD * 0.6], foldH,\n [0, foldH / 2, 0], col, colTip,\n )\n fold.rotateY(twist)\n fold.rotateX(lean)\n fold.rotateY(a)\n fold.translate(Math.sin(a) * ringR, crownBase, Math.cos(a) * ringR)\n pouchPieces.push(fold)\n }\n\n // Side seam: a continuous braid welt, the reference's strongest graphic\n // landmark. Bars 1.5x their spacing overlap into an unbroken run, each\n // buried wide*0.035 into the wall with enough radial depth to outreach\n // the roughen amplitude both ways: no bar can hover proud of a facet or\n // detach from the surface.\n const coinA = Math.PI / 2\n const seamA = coinA - (Math.PI * 2) / 3\n const seamLow = h * 0.13\n const seamHigh = tieY - h * 0.03\n const seamCount = 10\n const seamStep = (seamHigh - seamLow) / (seamCount - 1)\n for (let i = 0; i < seamCount; i += 1) {\n const y = seamLow + seamStep * i\n const dY = h * 0.02\n const slope = Math.atan2(radiusAt(y + dY) - radiusAt(y - dY), 2 * dY)\n // Lighter than the body: the bars sit in their own baked occlusion\n // and a darker lift on top of that read as a CRACK, not a seam.\n const bar = boxGeometry(\n [wide * 0.085, seamStep * 1.3, wide * 0.1],\n [0, 0, 0], tint('leather', -0.03 + jitter(random, 0.015), 0.3),\n )\n bar.rotateZ((i % 2 === 0 ? 1 : -1) * (0.18 + jitter(random, 0.04)))\n bar.rotateX(slope)\n bar.rotateY(seamA)\n const out = radiusAt(y) - wide * 0.035\n bar.translate(Math.sin(seamA) * out, y, Math.cos(seamA) * out)\n pouchPieces.push(bar)\n }\n\n // --- Drawstring: a single tie -----------------------------------------\n const cordA = coinA + (Math.PI * 5) / 6\n const cordR = wide * 0.045\n const cordPieces: BufferGeometry[] = []\n\n // Two turns wrapped around the pinch.\n cordPieces.push(bandGeometry(\n tieR + cordR * 1.2, tieY + cordR * 0.9, cordR * 1.7, cordR * 2.6, 10,\n tint('cloth', -0.3, 0.5), { inner: true },\n ))\n cordPieces.push(bandGeometry(\n tieR + cordR * 1.45, tieY - cordR * 1.05, cordR * 1.7, cordR * 2.6, 10,\n tint('cloth', -0.33, 0.5), { inner: true },\n ))\n\n // Knot cluster on ONE side of the wrap: two overlapping lumps, buried\n // back into the wrap, that both tails spring from.\n const knotOut = tieR + cordR * 1.9\n const knotPos: readonly [number, number, number] = [\n Math.sin(cordA) * knotOut, tieY, Math.cos(cordA) * knotOut,\n ]\n const knotA = prismGeometry(\n cordR * 2.2, cordR * 1.8, cordR * 3, 6, [0, 0, 0], tint('cloth', -0.28, 0.5),\n )\n knotA.rotateX(0.6)\n knotA.rotateY(cordA)\n knotA.translate(knotPos[0], knotPos[1], knotPos[2])\n cordPieces.push(knotA)\n const knotB = prismGeometry(\n cordR * 1.8, cordR * 1.4, cordR * 2.6, 5, [0, 0, 0], tint('cloth', -0.32, 0.5),\n )\n knotB.rotateZ(0.9)\n knotB.rotateY(cordA)\n knotB.translate(knotPos[0], knotPos[1] + cordR * 1.1, knotPos[2])\n cordPieces.push(knotB)\n\n // A lace segment between two points. Overshoots both ends by a cord\n // radius so every joint ends inside the next solid, never in air.\n const lace = (\n from: readonly [number, number, number],\n to: readonly [number, number, number],\n colour: ReturnType<typeof tint>,\n ): BufferGeometry => {\n const dx = to[0] - from[0]\n const dy = to[1] - from[1]\n const dz = to[2] - from[2]\n const len = Math.max(1e-6, Math.hypot(dx, dy, dz))\n const seg = prismGeometry(cordR, cordR * 0.92, len + cordR * 2, 5, [0, 0, 0], colour)\n seg.rotateZ(-Math.acos(Math.max(-1, Math.min(1, dy / len))))\n seg.rotateY(Math.atan2(-dz, dx))\n seg.translate((from[0] + to[0]) / 2, (from[1] + to[1]) / 2, (from[2] + to[2]) / 2)\n return seg\n }\n\n // Both tails leave from the SAME knot, drift the same way around the\n // body by different amounts, and end at different heights: one tie,\n // not two mirrored hangers. Each control point's radial distance is\n // clamped to the wall radius plus a cord clearance so no strand can\n // pass through the body, and the whole chain hangs from the knot.\n const tails: ReadonlyArray<{ drift: number; endY: number; lift: number }> = [\n { drift: 0.62, endY: h * 0.4, lift: -0.31 },\n { drift: 0.28, endY: h * 0.5, lift: -0.34 },\n ]\n for (const tail of tails) {\n const steps = 4\n const pts: Array<readonly [number, number, number]> = [knotPos]\n for (let k = 1; k <= steps; k += 1) {\n const s = k / steps\n const y = tieY + (tail.endY - tieY) * Math.pow(s, 1.15)\n const az = cordA + tail.drift * s + jitter(random, 0.03)\n const bow = knotOut + (wide * 1.06 - knotOut) * Math.pow(s, 0.7)\n const out = Math.max(bow, radiusAt(y) + cordR * 1.5)\n pts.push([Math.sin(az) * out, y, Math.cos(az) * out])\n }\n const colour = tint('cloth', tail.lift, 0.5)\n for (let k = 0; k < pts.length - 1; k += 1) {\n cordPieces.push(lace(pts[k]!, pts[k + 1]!, colour))\n }\n\n // Knotted bead near the tip, then a short flared tail end whose top\n // is buried in the bead.\n const end = pts[pts.length - 1]!\n const bead = prismGeometry(\n cordR * 2.1, cordR * 1.7, cordR * 3, 8, [0, 0, 0], tint('cloth', -0.27, 0.5),\n )\n bead.rotateX(jitter(random, 0.15))\n bead.rotateY(cordA + tail.drift)\n bead.translate(end[0], end[1] - cordR * 1.1, end[2])\n cordPieces.push(bead)\n const tip = prismGeometry(\n cordR, cordR * 0.5, cordR * 2, 4, [0, 0, 0], tint('cloth', -0.3, 0.5),\n )\n tip.rotateY(cordA + tail.drift)\n tip.translate(end[0], end[1] - cordR * 3, end[2])\n cordPieces.push(tip)\n }\n\n // --- Coin trail -------------------------------------------------------\n // One thinning trail across the floor, first coin touching the base.\n // Cool grey silver, not brass. No coins on the pouch itself: at this\n // scale a coin bedded into leather cannot be told from a clipping bug.\n const count = Math.max(0, Math.round(config.coins))\n const coinPieces: BufferGeometry[] = []\n const rimShrink = Math.cos(Math.PI / 9)\n const mint = (lower: number, thickness: number): BufferGeometry => prismGeometry(\n lower, lower * (0.96 + random() * 0.08), thickness, 9,\n [0, 0, 0], tint('steel', -0.05 + jitter(random, 0.04), 0.5),\n )\n const dirX = Math.sin(coinA)\n const dirZ = Math.cos(coinA)\n const perpX = Math.cos(coinA)\n const perpZ = -Math.sin(coinA)\n let trail = 0\n let prevFlat: { x: number; z: number; thickness: number; lower: number } | undefined\n for (let j = 0; j < count; j += 1) {\n const lower = config.coinRadius * (0.9 + random() * 0.16)\n const thickness = config.coinRadius * (0.13 + random() * 0.06)\n const coin = mint(lower, thickness)\n const side = j % 2 === 0 ? 1 : -1\n if (j % 3 === 2 && prevFlat !== undefined) {\n // Leans on the previous coin: tilt chosen first, offset DERIVED\n // from the contact (rim on the ground, underside touching the\n // previous coin's top rim). Guessing the offset is what made the\n // pairs clip through each other.\n const theta = 0.36 + random() * 0.1\n const away = coinA + side * (Math.PI / 2) + jitter(random, 0.3)\n const offset = prevFlat.lower\n + (prevFlat.thickness * 0.9) / Math.tan(theta)\n - lower * Math.cos(theta)\n const px = prevFlat.x + Math.sin(away) * offset\n const pz = prevFlat.z + Math.cos(away) * offset\n coin.rotateX(theta)\n coin.rotateY(away)\n const rest = lower * rimShrink * Math.sin(theta) + (thickness / 2) * Math.cos(theta)\n coin.translate(px, rest, pz)\n } else {\n const t = trail / Math.max(1, count - 1)\n // The trail starts CLEAR of the widest ring: a coin under the\n // bulge's overhang bakes to a near-black speck that reads as a\n // stray fragment, not as a penny.\n const d = wide * 1.15 + config.coinRadius * (0.55 + trail * 2.35)\n + jitter(random, config.coinRadius * 0.25)\n // Alternating sides of the trail axis: consecutive flat coins can\n // never wander onto each other.\n const lat = side * config.coinRadius * (0.4 + t * 0.9)\n + jitter(random, config.coinRadius * 0.2)\n const x = dirX * d + perpX * lat\n const z = dirZ * d + perpZ * lat\n const tilt = jitter(random, 0.1)\n coin.rotateX(tilt)\n coin.rotateY(random() * Math.PI * 2)\n const rest = Math.abs(Math.sin(tilt)) * lower * rimShrink\n + (thickness / 2) * Math.cos(tilt)\n coin.translate(x, rest, z)\n prevFlat = { x, z, thickness, lower }\n trail += 1\n }\n coinPieces.push(coin)\n }\n\n return {\n pouch: { slot: 'leather' as const, geometry: mergeColoured(pouchPieces) },\n cord: { slot: 'cloth' as const, geometry: mergeColoured(cordPieces) },\n coins: coinPieces.length > 0\n ? { slot: 'steel' as const, geometry: mergeColoured(coinPieces) }\n : undefined,\n }\n },\n }, overrides)\n}\n",
|
|
278
|
+
"hash": "3a5bcf2e7bc5c00ccb1a99fb04ff055ae2bce3e17c88e0e0b6a6885644559744"
|
|
279
279
|
}
|
|
280
280
|
],
|
|
281
281
|
"meta": {
|
|
@@ -339,7 +339,7 @@
|
|
|
339
339
|
"materialSlots": [
|
|
340
340
|
"leather",
|
|
341
341
|
"cloth",
|
|
342
|
-
"
|
|
342
|
+
"steel"
|
|
343
343
|
],
|
|
344
344
|
"parts": [
|
|
345
345
|
"pouch",
|
|
@@ -364,8 +364,8 @@
|
|
|
364
364
|
{
|
|
365
365
|
"path": "models/forge-hearth/model.ts",
|
|
366
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": "
|
|
367
|
+
"content": "/**\n * @medieval-kit/forge-hearth\n *\n * A smith's forge: a raised stone hearth with a coursed chimney hood over its\n * back end and a bellows on a ground post blowing through the side wall.\n *\n * The kit has had an anvil since early on and nothing to heat anything in. An\n * anvil alone is a lump of iron; what makes a corner a smithy is the fire, and\n * what makes the fire a forge rather than a hearth is the bellows blowing\n * through the side of it.\n *\n * - `hearth` — the stone trough, waist high, coursed all round, with a\n * single-block coping rim about the fire pan.\n * - `fire` — charcoal and embers filling the pan. `setLit` puts it out\n * without taking the charcoal with it, as the cauldron does.\n * - `chimney` — a HOLLOW hood of block courses over the rear third, with a\n * mouth opening at the bottom of its front face and an open\n * flue through the cap ring.\n * - `bellows` — teardrop boards and a pleated leather bag, nose into the\n * wall through an iron nozzle, tail carried by a brace off a\n * post that stands on its own foot clear of the masonry.\n *\n * Lessons this model has already paid for, in the order they were bought:\n *\n * MASONRY. A slab mottled to stone colour is a slab, and that goes for the\n * chimney too: pass three scored 70 with a solid smooth pyramid standing on\n * obviously blocky walls. Everything mineral here is now the same module: a\n * ring of proud, jittered blocks per course. The walls keep a backing slab\n * behind their blocks (it plays mortar and keeps the box closed); the hood\n * carries no backing at all, its blocks overlap along the ring and course to\n * course, which is what lets it be hollow.\n *\n * THE CHIMNEY IS A HOOD, NOT A MONUMENT. The critic called the closed pyramid\n * a \"filled solid sitting on the rear rim\". The shaft is now open top and\n * bottom: a cap ring with a visible flue hole, a one-course mouth in the\n * front face at hearth-top level (corner blocks kept so the sides reach the\n * ground), and the char bed running back underneath so the throat shows dark\n * with an ember in it.\n *\n * THE BELLOWS IS PART OF THE FORGE, NOT FURNITURE BESIDE IT. Version two\n * stood it on a bench (read as a stool with a bag on it); version three hung\n * it off a post flat against the wall, which read as a stripe painted on the\n * masonry, with the nozzle lost and the tail plank in free air. The machine\n * is now: nose buried in the wall through a fat tapering nozzle, post\n * standing on a plinth foot with daylight between it and the stone, a pivot\n * peg through the post head, and a shallow diagonal brace from the peg that\n * LIES ON the top board near the tail, its end cap finishing inside the\n * board's thickness. Post carries the tail, wall carries the nose.\n *\n * PLEATS GO ALL THE WAY ROUND. The bag is a stack of four alternating\n * wide/narrow teardrop plates between the boards, every plate with its own\n * width, length, thickness and nose offset so no two caps share a plane;\n * pass three's five plates included two WIDER than the boards, which put\n * near-coincident slivers on the silhouette. Wide now means wide among the\n * leather, still inside the board outline.\n *\n * ROTATION. Twice a two-rotation chain put a bar on the wrong axis. Anything\n * diagonal is built along one axis and turned ONCE: the brace lives in the\n * x = postX plane and only rotates about X, which works because the teardrop\n * board is wider than the post offset where the brace lands on it.\n */\nimport { type BufferGeometry } from 'three'\n\nimport {\n createKitModel,\n createTinter,\n dishedSheetGeometry,\n jitter,\n latheGeometry,\n mergeColoured,\n prismGeometry,\n roughenGeometry,\n taperedBoxGeometry,\n type Level,\n type SheetLevel,\n} from '../core/index.ts'\n\nexport interface ForgeHearthConfig {\n /** Length of the hearth block along the smith's side (metres). */\n readonly length: number\n /** Depth of the hearth block (metres). */\n readonly depth: number\n /** Height of the block to the top of its kerb (metres). */\n readonly height: number\n /** Height of the chimney above the hearth's top (metres). */\n readonly chimney: number\n /** Masonry density: blocks around the kerb, wall courses scale with it. */\n readonly kerbBlocks: number\n /** Whether the fire is lit (0/1). */\n readonly lit: number\n readonly seed: number\n}\n\nexport const forgeHearthDefaults: ForgeHearthConfig = {\n length: 1.32,\n depth: 0.82,\n height: 0.78,\n chimney: 1.1,\n kerbBlocks: 16,\n lit: 1,\n seed: 37,\n}\n\nexport type ForgeHearthParts = 'hearth' | 'fire' | 'chimney' | 'bellows'\n\nexport interface ForgeHearthActions {\n setLit(on: boolean): void\n isLit(): boolean\n}\n\nconst lerp = (a: number, b: number, t: number): number => a + (b - a) * t\n\nexport function createModel(overrides: Partial<ForgeHearthConfig> = {}) {\n let lit = true\n\n return createKitModel<ForgeHearthConfig, 'stone' | 'char' | 'ember' | 'oak' | 'leather' | 'iron', ForgeHearthParts, ForgeHearthActions>({\n id: 'forge-hearth',\n defaults: forgeHearthDefaults,\n slots: ['stone', 'char', 'ember', 'oak', 'leather', 'iron'],\n\n build: ({ config, random }) => {\n const tint = createTinter(random)\n const L = config.length\n const D = config.depth\n const H = config.height\n // The forge stands on the ground rather than about its own centre: it\n // has one face that matters and that face is the top, so measuring from\n // the floor is what keeps the working height honest.\n const floor = 0\n const wall = Math.min(L, D) * 0.17\n const kerbH = H * 0.13\n const bodyTop = H - kerbH\n const kerbCount = Math.max(8, Math.round(config.kerbBlocks))\n const density = kerbCount / 16\n\n // One ring of blocks walked around a rectangle. The whole model's\n // masonry is this module: the coping rim uses it once, the hood uses it\n // per course. Blocks straddle the rectangle's edge; `skip` lets a run\n // be left out (the coping's back, the hood's mouth). Runs overlap by a\n // few percent and every block gets its own outward nudge, so a ring is\n // closed without any two faces sharing a plane.\n const ringCourse = (\n into: BufferGeometry[],\n cx: number, halfX: number, halfZ: number, count: number,\n cy: number, blockH: number, blockD: number, lift: number,\n overlap: number, phase: number,\n skip?: (onFront: boolean, onBack: boolean, frac: number) => boolean,\n ): void => {\n const per = (halfX + halfZ) * 4\n const run = per / count\n for (let i = 0; i < count; i += 1) {\n let s = (((i + 0.5 + phase) % count) / count) * per\n let x = 0\n let z = 0\n let along: 'x' | 'z' = 'x'\n let onFront = false\n let onBack = false\n let frac = 0\n if (s < halfX * 2) { x = -halfX + s; z = -halfZ; along = 'x'; frac = s / (halfX * 2) }\n else if ((s -= halfX * 2) < halfZ * 2) { x = halfX; z = -halfZ + s; along = 'z'; onFront = true; frac = s / (halfZ * 2) }\n else if ((s -= halfZ * 2) < halfX * 2) { x = halfX - s; z = halfZ; along = 'x'; frac = s / (halfX * 2) }\n else { s -= halfX * 2; x = -halfX; z = halfZ - s; along = 'z'; onBack = true; frac = s / (halfZ * 2) }\n if (skip && skip(onFront, onBack, frac)) continue\n const d = blockD * (1 + jitter(random, 0.08))\n const r = run * (overlap + jitter(random, 0.04))\n const nudge = jitter(random, blockD * 0.14)\n const size: [number, number] = along === 'x' ? [r, d] : [d, r]\n const centre: [number, number, number] = along === 'x'\n ? [cx + x, cy, z + Math.sign(z) * Math.abs(nudge)]\n : [cx + x + Math.sign(x) * Math.abs(nudge), cy, z]\n into.push(taperedBoxGeometry(\n size,\n [size[0] * 0.92, size[1] * 0.92],\n blockH * (1 + jitter(random, 0.05)),\n centre,\n tint('stone', lift + jitter(random, 0.09)),\n ))\n }\n }\n\n // --- Hearth trough ---------------------------------------------------\n const masonry: BufferGeometry[] = []\n // Four backing slabs: they close the box and play mortar in the joints\n // between the course blocks that carry the whole masonry read.\n for (const [dx, dz, sx, sz] of [\n [0, -(D - wall) / 2, L, wall],\n [0, (D - wall) / 2, L, wall],\n [-(L - wall) / 2, 0, wall, D - wall * 2],\n [(L - wall) / 2, 0, wall, D - wall * 2],\n ] as const) {\n masonry.push(taperedBoxGeometry(\n [sx, sz],\n [sx * 0.99, sz * 0.99],\n bodyTop,\n [dx, floor + bodyTop / 2, dz],\n tint('stone', -0.06 + jitter(random, 0.03)),\n ))\n }\n\n // Wall courses: three chunky bands of blocks proud of the slabs. Joints\n // stagger by alternating the block count per course; a half-block phase\n // shift on an OPEN run would leave its last block past the corner.\n const courses = 3\n const courseH = bodyTop / courses\n const blockD = wall * 0.5\n const proud = wall * 0.12\n const nAlong = Math.max(4, Math.round(kerbCount * 0.26))\n const nAcross = Math.max(3, Math.round(kerbCount * 0.19))\n for (let c = 0; c < courses; c += 1) {\n const cy = floor + (c + 0.5) * courseH\n for (const [along, sign] of [['x', 1], ['x', -1], ['z', 1], ['z', -1]] as const) {\n const faceLen = along === 'x' ? L : D\n const n = (along === 'x' ? nAlong : nAcross) + (c % 2)\n const run = faceLen / n\n const plane = along === 'x'\n ? sign * (D / 2 - blockD / 2 + proud)\n : sign * (L / 2 - blockD / 2 + proud)\n for (let i = 0; i < n; i += 1) {\n const at = -faceLen / 2 + (i + 0.5) * run\n const size: [number, number] = along === 'x'\n ? [run * (0.92 + jitter(random, 0.05)), blockD]\n : [blockD, run * (0.92 + jitter(random, 0.05))]\n masonry.push(taperedBoxGeometry(\n size,\n [size[0] * 0.9, size[1] * 0.9],\n courseH * (0.9 + jitter(random, 0.06)),\n along === 'x' ? [at, cy, plane] : [plane, cy, at],\n tint('stone', 0.02 + jitter(random, 0.09)),\n ))\n }\n }\n }\n\n // The hood's footprint: rear third of the length, full width.\n const xb = -L / 2 + 0.015\n const hoodHalfX0 = L * 0.18\n const hoodFront = xb + hoodHalfX0 * 2\n\n // Coping rim: a single-block ring around the fire pan on three sides;\n // the fourth side of the pan is the hood's mouth, so the rim would only\n // hide the throat there. The side runs end inside the hood's bottom\n // corner blocks.\n const rimD = 0.115\n const copFront = L / 2 - rimD * 0.48\n const copBack = hoodFront + 0.03\n const copHalfX = (copFront - copBack) / 2\n const copHalfZ = D / 2 - rimD * 0.48\n ringCourse(\n masonry,\n (copFront + copBack) / 2, copHalfX, copHalfZ, kerbCount,\n floor + bodyTop + kerbH * 0.45, kerbH * 1.05, rimD, 0.04, 0.97, 0,\n (_f, onBack) => onBack,\n )\n\n const hearth = mergeColoured(masonry)\n roughenGeometry(hearth, wall * 0.05, { salt: 23 })\n\n // --- Fire ------------------------------------------------------------\n // The pan fills the open top inside the rim, sunk about a course below\n // it, and its floor runs back UNDER the hood so the mouth shows char\n // instead of a stone wall. The bed is LUMPS, not a plate: irregular\n // charcoal with the glow coming up between the lumps at the middle.\n const floorTop = floor + bodyTop - 0.025\n const panMaxX = copFront - rimD\n const panHalfZ = copHalfZ - rimD\n const coals: BufferGeometry[] = []\n const embers: BufferGeometry[] = []\n\n // Floor slab: its edges bury themselves inside the wall slabs, which is\n // what keeps it counted as carried rather than floating in the shell.\n coals.push(taperedBoxGeometry(\n [panMaxX + 0.05 - (xb + 0.05), (panHalfZ + 0.06) * 2],\n [panMaxX - xb, panHalfZ * 2 + 0.1],\n 0.1,\n [(panMaxX + 0.05 + xb + 0.05) / 2, floorTop - 0.05, 0],\n tint('char', 0.02),\n ))\n\n const bcx = (hoodFront + panMaxX) / 2 - 0.02\n const radX = (panMaxX - hoodFront) / 2 * 0.92\n const radZ = panHalfZ * 0.85\n const lumps = 21\n for (let i = 0; i < lumps; i += 1) {\n // Golden-angle spiral over an ellipse, so the bed fills evenly rather\n // than ringing. The middle of a working fire is where the air\n // arrives, so that is where it glows; the edges are burnt out.\n const a = i * 2.399963\n const ring = Math.sqrt((i + 0.35) / lumps)\n const size = 0.05 + random() * 0.042\n const hot = ring < 0.55 && i % 2 === 0\n const lump = prismGeometry(\n size, size * 0.5, size * 0.8, 5, [0, 0, 0],\n hot\n ? tint(i % 3 === 0 ? 'emberTip' : 'ember', jitter(random, 0.05), 0.4)\n : tint('char', 0.05 + jitter(random, 0.08)),\n { capBottom: false },\n )\n lump.rotateX(jitter(random, 0.5))\n lump.rotateZ(jitter(random, 0.5))\n lump.rotateY(random() * Math.PI * 2)\n lump.translate(\n bcx + Math.sin(a) * radX * ring,\n floorTop + size * (hot ? 0.42 : 0.33),\n Math.cos(a) * radZ * ring,\n )\n ;(hot ? embers : coals).push(lump)\n }\n // Two lumps in the throat, one of them glowing, so the mouth reads as\n // the fire's back rather than an empty niche.\n for (const [mz, hot] of [[0.09, true], [-0.1, false]] as const) {\n const size = 0.055 + random() * 0.02\n const lump = prismGeometry(\n size, size * 0.5, size * 0.8, 5, [0, 0, 0],\n hot ? tint('ember', jitter(random, 0.05), 0.4) : tint('char', 0.04),\n { capBottom: false },\n )\n lump.rotateY(random() * Math.PI * 2)\n lump.translate(hoodFront - 0.05, floorTop + size * 0.35, mz)\n ;(hot ? embers : coals).push(lump)\n }\n const coal = mergeColoured(coals)\n\n // --- Chimney hood ----------------------------------------------------\n // Six shrinking rings of the same blocks the walls wear, no core at\n // all: the shaft is hollow, the cap is a ring around an open flue, and\n // the bottom course skips its mid-front blocks to leave a mouth over\n // the char (the corner blocks stay so the sides reach the hearth). Ring\n // runs overlap and courses interpenetrate vertically, which is what\n // keeps a coreless stack closed everywhere but the two openings.\n const hood: BufferGeometry[] = []\n const hoodBase = floor + bodyTop - 0.03\n const hoodH = Math.max(0.3, floor + H + config.chimney - hoodBase)\n const hoodCourses = 6\n const hCourseH = hoodH / hoodCourses\n for (let c = 0; c < hoodCourses; c += 1) {\n const t = (c + 0.5) / hoodCourses\n const hx = lerp(hoodHalfX0, L * 0.078, t)\n const hz = lerp(D * 0.49, D * 0.2, t)\n const count = Math.max(6, Math.round(((hx + hz) * 4 / 0.21) * density))\n ringCourse(\n hood,\n xb + hx, hx, hz, count,\n hoodBase + (c + 0.5) * hCourseH, hCourseH * 1.08, 0.085, 0.02,\n 1.08, (c % 2) * 0.5,\n c === 0\n ? (onFront, _b, frac) => onFront && frac > 0.18 && frac < 0.82\n : undefined,\n )\n }\n // Cap ring: four slabs proud of the top course with a hole between\n // them, the reference's cap slab opened into a flue. Heights and spans\n // jitter so no two slabs share a plane where they interpenetrate.\n const capY = hoodBase + hoodH + 0.008\n const capCx = xb + lerp(hoodHalfX0, L * 0.078, 1)\n const capHx = L * 0.115\n const capHz = D * 0.27\n const capW = 0.08\n for (const [dx, dz, sx, sz] of [\n [capHx - capW / 2, 0, capW, capHz * 2],\n [-(capHx - capW / 2), 0, capW, capHz * 2 * 0.98],\n [0, capHz - capW / 2, (capHx - capW) * 2.2, capW],\n [0, -(capHz - capW / 2), (capHx - capW) * 2.2 * 0.97, capW],\n ] as const) {\n hood.push(taperedBoxGeometry(\n [sx, sz],\n [sx * 0.96, sz * 0.96],\n 0.055 * (1 + jitter(random, 0.08)),\n [capCx + dx, capY + jitter(random, 0.004), dz],\n tint('stone', 0.06 + jitter(random, 0.05)),\n ))\n }\n const chimney = mergeColoured(hood)\n roughenGeometry(chimney, wall * 0.045, { salt: 29 })\n\n // --- Bellows ---------------------------------------------------------\n // Nose toward the hearth on -Z, tail hanging out over open ground.\n // Nothing here floats: the nose hangs on the nozzle buried in the wall,\n // the tail hangs on the brace, the brace hangs on the peg through the\n // post head, and the post stands on its own plinth foot with clear\n // daylight between it and the masonry.\n const bx0 = -0.1 * (L / 1.32)\n const zn = D / 2 + 0.09\n const bl = H * 0.8\n const bw = D * 0.54\n const by = floor + H * 0.74\n const timber: BufferGeometry[] = []\n const hide: BufferGeometry[] = []\n const metal: BufferGeometry[] = []\n\n // Teardrop plate: narrow nose, widest past the middle, rounded tail.\n // Width and LENGTH scale separately, because they mean opposite things.\n // `dishedSheetGeometry` builds in the XY plane, thickness along Z; ONE\n // rotation about X lays it flat with its length pointing +Z, away from\n // the hearth (length +Z, width X, thickness Y).\n const teardrop = (w: number, t: number, len: number): SheetLevel[] => [\n { y: 0, halfWidth: bw * 0.125 * w, thickness: t, curve: 0 },\n { y: bl * 0.24 * len, halfWidth: bw * 0.42 * w, thickness: t, curve: 0 },\n { y: bl * 0.68 * len, halfWidth: bw * 0.5 * w, thickness: t * 0.95, curve: 0 },\n { y: bl * len, halfWidth: bw * 0.33 * w, thickness: t * 0.9, curve: 0 },\n ]\n const plate = (w: number, t: number, len: number, off: number, nose: number, colour: Parameters<typeof dishedSheetGeometry>[2]): BufferGeometry => {\n const g = dishedSheetGeometry(teardrop(w, t, len), 5, colour)\n g.rotateX(Math.PI / 2)\n g.translate(bx0, by + off, zn + nose)\n return g\n }\n\n const boardOff = bw * 0.195\n timber.push(plate(1, 0.035, 1, -boardOff, 0, tint('oak', 0.03 + jitter(random, 0.03))))\n timber.push(plate(0.965, 0.035, 0.975, boardOff, 0.014, tint('oak', -0.04 + jitter(random, 0.03))))\n\n // The bag: four stepped teardrop rings, wide fold / narrow valley\n // alternating, every one with its own width, thickness, length and\n // nose offset. Wide stays inside the board outline; two plates wider\n // than the boards is a sliver on the silhouette, already paid for.\n const pleats = [\n { off: -boardOff * 0.66, w: 0.94, t: 0.052, len: 0.9, nose: 0.012 },\n { off: -boardOff * 0.24, w: 0.74, t: 0.056, len: 0.84, nose: 0.028 },\n { off: boardOff * 0.19, w: 0.92, t: 0.05, len: 0.88, nose: 0.006 },\n { off: boardOff * 0.61, w: 0.72, t: 0.054, len: 0.82, nose: 0.022 },\n ] as const\n for (const p of pleats) {\n hide.push(plate(p.w, p.t, p.len, p.off, p.nose, tint('leather', -0.04 + jitter(random, 0.03), 0.9)))\n }\n\n // The nozzle, the joint of the whole model: a fat iron taper built\n // along +Y and turned once about X so it runs along Z. Its narrow tip\n // ends INSIDE the wall slab, its wide base inside the leather nose, and\n // the visible span between wall face and nose is what reads as tuyere.\n const nozzle = latheGeometry(\n [\n { y: D / 2 - wall * 0.62, radius: 0.027 },\n { y: D / 2 - 0.02, radius: 0.045 },\n { y: zn + 0.06, radius: 0.062 },\n ] as Level[],\n 8, [0, 0, 0], tint('iron', 0.02, 0.7),\n )\n nozzle.rotateX(Math.PI / 2)\n nozzle.translate(bx0, by, 0)\n metal.push(nozzle)\n\n // --- Post, foot, peg, brace ------------------------------------------\n const postX = bx0 - bw * 0.3\n const postZ = D / 2 + wall * 0.28 + 0.09\n const postTop = by + H * 0.47\n timber.push(taperedBoxGeometry(\n [0.17, 0.17],\n [0.15, 0.15],\n 0.07,\n [postX, floor + 0.035, postZ],\n tint('oak', -0.09),\n ))\n timber.push(taperedBoxGeometry(\n [0.09, 0.09],\n [0.078, 0.078],\n postTop - floor - 0.02,\n [postX, (floor + 0.02 + postTop) / 2, postZ],\n tint('oak', -0.06),\n ))\n // Pivot peg through the post head; the brace lies over it, so the two\n // read as a hinge rather than a bar glued to a bar.\n const peg = taperedBoxGeometry(\n [0.05, 0.05],\n [0.046, 0.046],\n 0.24,\n [0, 0, 0],\n tint('oak', 0.05),\n )\n peg.rotateZ(Math.PI / 2)\n peg.translate(postX, postTop - 0.055, postZ)\n timber.push(peg)\n\n // The brace: one rotation about X, in the x = postX plane, from inside\n // the post head down at a shallow angle to LIE ON the top board near\n // the tail. Its lower end cap finishes inside the board's thickness;\n // the board is wider than the post offset there, which is what makes\n // the constant-X plane land on it.\n const boardTopY = by + boardOff + 0.0175\n const p1y = postTop - 0.065\n const p1z = postZ\n const p2y = boardTopY + 0.012\n const p2z = zn + bl * 0.78\n const dy = p2y - p1y\n const dz = p2z - p1z\n const dl = Math.hypot(dy, dz)\n const e1 = 0.03 / dl\n const e2 = 0.05 / dl\n const a1y = p1y - dy * e1\n const a1z = p1z - dz * e1\n const a2y = p2y + dy * e2\n const a2z = p2z + dz * e2\n const brace = taperedBoxGeometry(\n [0.055, 0.048],\n [0.05, 0.043],\n Math.hypot(a2y - a1y, a2z - a1z),\n [0, 0, 0],\n tint('oak', 0.02),\n )\n brace.rotateX(Math.atan2(dz, dy))\n brace.translate(postX, (a1y + a2y) / 2, (a1z + a2z) / 2)\n timber.push(brace)\n\n return {\n hearth: { slot: 'stone' as const, geometry: hearth },\n fire: {\n slot: 'char' as const,\n geometry: coal,\n extras: [{ slot: 'ember' as const, geometry: mergeColoured(embers) }],\n },\n chimney: { slot: 'stone' as const, geometry: chimney },\n bellows: {\n slot: 'oak' as const,\n geometry: mergeColoured(timber),\n extras: [\n { slot: 'leather' as const, geometry: mergeColoured(hide) },\n { slot: 'iron' as const, geometry: mergeColoured(metal) },\n ],\n },\n }\n },\n\n actions: ({ parts, getConfig }) => {\n lit = getConfig().lit >= 0.5\n // The embers are the fire part's SECOND body; hiding the part itself\n // would take the charcoal with it and leave an empty stone box.\n const glow = parts.fire.anchor.children[1]\n if (glow) glow.visible = lit\n return {\n setLit: (on) => {\n lit = on\n const body = parts.fire.anchor.children[1]\n if (body) body.visible = on\n },\n isLit: () => lit,\n }\n },\n }, overrides)\n}\n",
|
|
368
|
+
"hash": "8ec954a342653a2ba65d087d8890bc489307cf91161b1cceccd40681217d4bf4"
|
|
369
369
|
}
|
|
370
370
|
],
|
|
371
371
|
"meta": {
|
|
@@ -465,8 +465,8 @@
|
|
|
465
465
|
{
|
|
466
466
|
"path": "models/glass-phial/model.ts",
|
|
467
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": "
|
|
468
|
+
"content": "/**\n * @medieval-kit/glass-phial\n *\n * Cork-stoppered glass bottle with a wax seal. Alchemist's shelf, healer's\n * bag, a slot in the inventory.\n *\n * THIRD PROFILE. The first two were curvy decanters: a bulging belly on a\n * rounded near-pointed base, a long conical shoulder tapering into a stem of\n * a neck. The blind critique (70/100, worst axis silhouette) reported that\n * the reference is nothing of the sort: a plain straight-sided apothecary\n * bottle, vertical walls on a flat foot, a short shoulder, a short neck, and\n * a flared lip ring that is the one feature naming the object. So:\n *\n * - Constant wall radius from the foot to ~60% of total height. The widest\n * point at mid-belly is gone entirely.\n * - Shoulder turns over ~12% of height, neck is short, and the lip flares\n * to ~1.4x the neck radius. Glass above the shoulder is ~10% of height.\n * - Flat disc base with a slight foot ring, not a rounded bottom.\n * - The cork is a plain, slightly tapered plug standing clear of the lip at\n * about the neck's width, not a stub hidden under the wax.\n * - The wax bridges the cork-to-neck junction: it wraps the lip ring and\n * climbs onto the cork. The previous seal sat wholly on the cork,\n * floating above the glass with bare cork showing beneath it.\n *\n * The kit's second model to use the `glass` slot, and where the glass is\n * really tested: in the lantern it was a panel of a CAGE, here it is the\n * vessel itself. Two consequences survive from the earlier versions —\n *\n * - The contents are a separate body INSIDE the glass shell. In the `ember`\n * slot, so it takes no light and supplies its own colour: a liquid that\n * went dark at the bottom looked like dirty water, not a potion.\n * - The liquid surface is a flat disc. Seen through the glass that straight\n * line is the only mark saying \"this is full\"; a spherical liquid body\n * has no surface and the bottle looks empty.\n *\n * Period note: clear, colourless glass is a very late thing. Glass of the\n * period was greenish and bubbly from iron impurity; that is why the `glass`\n * colour in the palette leans green.\n */\nimport { Color, type BufferGeometry } from 'three'\n\nimport {\n createKitModel,\n createTinter,\n latheGeometry,\n mergeColoured,\n prismGeometry,\n roughenGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface GlassPhialConfig {\n /** Total height, stopper included (metres). */\n readonly height: number\n /** Radius of the straight body wall (metres). */\n readonly radius: number\n /** Height of shoulder, neck and lip together, as a fraction of the height. */\n readonly neck: number\n /** Fill level. 0 empty, 1 up to the shoulder. */\n readonly fill: number\n /**\n * Colour of the liquid, 0–1 around the colour wheel.\n *\n * The reason it is a parameter and not a fixed colour is the same as in the\n * rest of the kit: one model has to yield red healing, green poison and blue\n * mana potions. Adding three separate colours to the palette would do the\n * same thing in a more rigid way.\n */\n readonly hue: number\n /** Whether there is a wax seal (0/1). */\n readonly seal: number\n readonly seed: number\n}\n\nexport const glassPhialDefaults: GlassPhialConfig = {\n height: 0.14,\n radius: 0.024,\n neck: 0.26,\n fill: 0.62,\n hue: 0.33,\n seal: 1,\n seed: 83,\n}\n\nexport type GlassPhialParts = 'bottle' | 'liquid' | 'stopper'\n\nexport function createModel(overrides: Partial<GlassPhialConfig> = {}) {\n return createKitModel<GlassPhialConfig, 'glass' | 'ember' | 'oak' | 'char', GlassPhialParts>({\n id: 'glass-phial',\n defaults: glassPhialDefaults,\n slots: ['glass', 'ember', 'oak', 'char'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n // Floors, because a config slider at zero must degrade the shape, not\n // divide the vertices into NaN (that mistake once recoloured a whole\n // model through the occlusion bake).\n const height = Math.max(0.05, config.height)\n const radius = Math.max(0.008, config.radius)\n const half = height / 2\n const bottom = -half\n\n // Vertical layout, from the ground up. The cork's crown defines the\n // total height; glass stops at 86% of it.\n const glassTop = bottom + height * 0.86\n const neckLength = height * Math.max(0.12, config.neck)\n const shoulderStart = glassTop - neckLength\n const shoulderRun = neckLength * 0.46\n const lipRun = neckLength * 0.16\n const neckTop = glassTop - lipRun\n\n const wallRadius = radius * 0.985\n const neckRadius = radius * 0.62\n const lipRadius = neckRadius * 1.42\n\n // --- Bottle ---------------------------------------------------------------\n // Straight-sided apothecary bottle: flat foot with a slight foot ring,\n // vertical wall, short rounded shoulder, short neck, flared lip ring.\n // capTop is TRUE: the mouth reads as the flat top face of the lip, and\n // the cork rises through it. Leaving it open showed the interior\n // backfaces around the cork whenever the seal was off.\n const profile: Level[] = [\n { y: bottom, radius: radius * 0.95 },\n { y: bottom + height * 0.012, radius }, // foot ring\n { y: bottom + height * 0.045, radius: wallRadius },\n { y: shoulderStart, radius: wallRadius }, // straight wall\n { y: shoulderStart + shoulderRun * 0.55, radius: radius * 0.88 },\n { y: shoulderStart + shoulderRun, radius: neckRadius }, // shoulder done\n { y: neckTop, radius: neckRadius }, // neck\n { y: neckTop + lipRun * 0.45, radius: lipRadius }, // lip flare\n { y: glassTop, radius: lipRadius * 0.97 },\n ]\n const bottle = latheGeometry(profile, 10, [0, 0, 0], tint('glass', -0.02, 0.4), {\n colourTop: tint('glass', 0.06, 0.4),\n })\n // Blown glass is never perfectly symmetric; the deviation is kept tiny\n // because on a straight wall big irregularity reads as a dented bottle.\n roughenGeometry(bottle, radius * 0.015, { salt: 41 })\n\n // --- Contents -------------------------------------------------------------\n // The liquid level is computed from the fill and sits on the body's radius\n // AT THAT HEIGHT. Its floor starts slightly ABOVE the bottle's own base\n // cap: two same-facing discs sharing the base plane are forbidden.\n const fill = Math.max(0, Math.min(1, config.fill))\n let liquid: BufferGeometry | undefined\n if (fill > 0.02) {\n const floorY = bottom + height * 0.012\n const surfaceY = floorY\n + (shoulderStart + shoulderRun * 0.4 - floorY) * fill\n // A FIXED number of levels, sampled between the bottle's floor and the\n // liquid surface. Filtering the bottle profile by height was the\n // obvious way to do it and it made the vertex count depend on `fill` —\n // which meant the showcase could not morph this model at all and fell\n // back to rebuilding it in visible steps.\n const steps = 5\n function radiusAt(y: number): number {\n for (let i = 1; i < profile.length; i += 1) {\n const a = profile[i - 1]!\n const b = profile[i]!\n if (y <= b.y) {\n const t = (y - a.y) / Math.max(1e-6, b.y - a.y)\n return (a.radius + (b.radius - a.radius) * t) * 0.88\n }\n }\n return profile.at(-1)!.radius * 0.88\n }\n const inner = Array.from({ length: steps }, (_, i) => {\n const y = floorY + (surfaceY - floorY) * (i / steps)\n return { y, radius: radiusAt(y) }\n })\n inner.push({ y: surfaceY, radius: radiusAt(surfaceY) })\n // The liquid is in the `ember` slot, so it TAKES NO LIGHT: the vertex\n // colour is the final colour that goes to the screen. A choice — a\n // liquid behind glass darkened by the scene's light looked like dirty\n // water, not a potion.\n const hue = ((config.hue % 1) + 1) % 1\n const deep = new Color().setHSL(hue, 0.78, 0.36)\n const bright = new Color().setHSL((hue + 0.03) % 1, 0.72, 0.58)\n liquid = mergeColoured([latheGeometry(\n inner, 10, [0, 0, 0], deep,\n { colourTop: bright, capTop: true },\n )])\n }\n\n // --- Stopper -------------------------------------------------------------\n // A plain tapered plug: buried in the neck below, standing clear above\n // the lip by about the neck's width. Its lower end terminates INSIDE the\n // neck, never level with the mouth.\n const corkBottom = neckTop - neckLength * 0.24\n const corkTop = bottom + height\n const corkHeight = corkTop - corkBottom\n const corkRadiusAt = (y: number): number => {\n const t = (y - corkBottom) / Math.max(1e-6, corkHeight)\n return neckRadius * (0.84 + 0.22 * t)\n }\n const stopper = prismGeometry(\n corkRadiusAt(corkBottom), corkRadiusAt(corkTop), corkHeight, 8,\n [0, (corkBottom + corkTop) / 2, 0], tint('oak', 0.08),\n { colourTop: tint('oak', 0.13) },\n )\n\n // --- Wax seal ------------------------------------------------------------\n // The wax bridges the junction: a skirt wrapping the glass lip ring, a\n // slope over its corner, and a collar hugging the cork above it, capped\n // so it reads as poured wax the cork rises out of.\n let seal: BufferGeometry | undefined\n if (config.seal >= 0.5) {\n // The skirt stops halfway down the lip: it must bridge onto the glass,\n // but a skirt that swallowed the whole lip ring erased the one feature\n // that names the object.\n const waxTop = glassTop + height * 0.038\n const waxProfile: Level[] = [\n { y: glassTop - lipRun * 0.55, radius: lipRadius * 1.07 },\n { y: glassTop + height * 0.012, radius: lipRadius * 0.97 },\n { y: waxTop, radius: corkRadiusAt(waxTop) * 1.08 },\n ]\n seal = mergeColoured([latheGeometry(\n waxProfile, 10, [0, 0, 0], tint('charHot', -0.1, 0.6),\n { colourTop: tint('charHot', -0.04, 0.6) },\n )])\n }\n\n return {\n bottle: { slot: 'glass' as const, geometry: mergeColoured([bottle]) },\n liquid: liquid ? { slot: 'ember' as const, geometry: liquid } : undefined,\n stopper: {\n slot: 'oak' as const,\n geometry: mergeColoured([stopper]),\n ...(seal\n ? { extras: [{ slot: 'char' as const, geometry: seal }] }\n : {}),\n },\n }\n },\n }, overrides)\n}\n",
|
|
469
|
+
"hash": "5da2f3d54554ec425781340535913b0867fe30093d1189d25b7dadb882c724bf"
|
|
470
470
|
}
|
|
471
471
|
],
|
|
472
472
|
"meta": {
|
|
@@ -767,8 +767,8 @@
|
|
|
767
767
|
{
|
|
768
768
|
"path": "models/hay-bale/model.ts",
|
|
769
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": "
|
|
770
|
+
"content": "/**\n * @medieval-kit/hay-bale\n *\n * A bound bundle of straw, standing on its flat end.\n *\n * A warning is in order: the rectangular block that comes to mind today when\n * you say \"bale\" is machine work and belongs to the 19th century. In the Middle\n * Ages straw was either heaped loose or bound by hand into a bundle. This model\n * is that bundle, stood upright the way the reference shows it.\n *\n * The FOURTH attempt. The dead ends so far:\n *\n * - The first was slice after slice of boxes and rendered as A PALE WOODEN\n * CHEST. Rectangular section + flat faces = joinery. A bound bundle's\n * section is round, because the cord that pulls it rounds it.\n * - The second fixed the section but laid the cylinder DOWN, near 3:1 long,\n * with flat grey strips for the ties. The critic read it as a bedroll with\n * steel strapping.\n * - The third stood the drum up and swept real arcBar tori for the ropes,\n * but scored 61 as \"a barrel or a wheel of cheese\", and every reason\n * traced to three choices:\n * 1. colourTop was strawPale and latheGeometry lerps wall colour from\n * base to top ACROSS ALL LEVELS, so the whole upper wall drifted to\n * pale butter and the top disc went nearly white. Reference wall is\n * mid-brown (102, 75, 46). Both tints are now plain `straw`, the\n * gradient is a few percent, and an extra mottle pass breaks the\n * flatness (reference luminance sd 37 vs our 16).\n * 2. the rope tori barely cleared the wall (outer face ~6 mm proud\n * against 12 mm of bump), so with the cinch groove they rendered as\n * RECESSED dark bands = barrel hoops. The cord centre now sits ON the\n * wall radius, outer face ~19 mm proud of the worst bump, in light\n * hemp (cloth) so the cords read raised, not cut in.\n * 3. per-level radius jitter at 1% stepped the silhouette like stacked\n * drums. Now 0.5%, and the profile is one smooth barrel: max\n * diameter in the upper third, slight taper to the base, a real\n * shoulder easing to a top disc of 0.80 diameter, and a low crown\n * instead of a dead-flat full-width cap meeting the wall at 90\n * degrees (that hard rim is what said \"tub\").\n * - Modelling errors called out on the third, all placement: base wisps\n * drooping through the floor (droop is now clamped so the tip stays above\n * y=0), wisps lying flat on or half-sunk in the top disc (top-disc wisps\n * now tilt at least 0.5 rad and root under the crown surface), and wisps\n * with a visible gap to the wall (roots now sit 7-8% of R inside the\n * surface, 28% of the length buried).\n *\n * The `wisps` config field now means BUNDLES of stray stalks, not single\n * stalks: each unit spawns ~2.2 thinner straws so the silhouette breaks\n * continuously at the top rim and the base ring, which is where the reference\n * breaks. The field's default is unchanged.\n */\nimport type { BufferGeometry } from 'three'\n\nimport {\n arcBarGeometry,\n boxGeometry,\n createKitModel,\n createTinter,\n jitter,\n latheGeometry,\n mergeColoured,\n mottleGeometry,\n roughenGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface HayBaleConfig {\n /** Footprint diameter along X (metres). */\n readonly length: number\n /** Height (metres). The bale stands on its flat end. */\n readonly height: number\n /** Footprint diameter along Z (metres). */\n readonly depth: number\n /** How many rope ties around the wall. */\n readonly ropeCount: number\n /** Number of loose stalks sticking out of the surface. */\n readonly wisps: number\n /** Surface irregularity. 0 = smooth body. */\n readonly rough: number\n readonly seed: number\n}\n\nexport const hayBaleDefaults: HayBaleConfig = {\n length: 1.0,\n height: 0.74,\n depth: 1.0,\n ropeCount: 3,\n wisps: 40,\n rough: 1,\n seed: 47,\n}\n\nexport type HayBaleParts = 'bale' | 'wisps' | 'ropes'\n\nexport function createModel(overrides: Partial<HayBaleConfig> = {}) {\n return createKitModel<HayBaleConfig, 'straw' | 'cloth', HayBaleParts>({\n id: 'hay-bale',\n defaults: hayBaleDefaults,\n slots: ['straw', 'cloth'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n const ropes = Math.max(0, Math.round(config.ropeCount))\n // Floored: a zero patch on any of these would put NaN into every vertex\n // via the squash division, and the occlusion bake spreads one NaN to the\n // whole model.\n const H = Math.max(0.1, config.height)\n const R = Math.max(0.05, config.length / 2)\n const squashZ = Math.max(0.1, config.depth) / (R * 2)\n\n // Ropes at 22 / 50 / 78 percent of the height for the default three,\n // which is where the reference has them: the outer pair sit nearer the\n // rims than an even 25/75 split.\n const ropeYs = ropes === 1\n ? [H * 0.5]\n : Array.from({ length: ropes }, (_, i) => H * (0.22 + (0.56 * i) / Math.max(1, ropes - 1)))\n\n // The wall pinches a little where each rope bites. The rope itself\n // stands proud; the pinch only has to hint.\n const cinch = (y: number): number => {\n let tightest = 1\n for (const ry of ropeYs) {\n const distance = Math.abs(y - ry) / (H * 0.12)\n if (distance < 1) tightest = Math.min(tightest, 1 - 0.02 * (1 - distance * distance))\n }\n return tightest\n }\n\n // One smooth barrel: slight taper to the base, maximum diameter at 68%\n // of the height, and a shoulder over the last 12% easing in to a top\n // disc of 0.80 of the diameter. No steps.\n const profile = (t: number): number => {\n const swell = 1 - 0.05 * Math.pow(Math.abs(t - 0.68) / 0.68, 1.7)\n const s = t <= 0.88 ? 0 : (t - 0.88) / 0.12\n return swell * (1 - 0.2 * s * s)\n }\n const wallR = (y: number): number => R * profile(Math.min(1, Math.max(0, y / H))) * cinch(y)\n\n // --- Body -----------------------------------------------------------\n const rings = 16\n const levels: Level[] = Array.from({ length: rings }, (_, i) => {\n const t = i / (rings - 1)\n const y = H * t\n // 0.5% jitter. At 1% every level stood out as its own ridge and the\n // silhouette stepped like stacked drums.\n return { y, radius: wallR(y) * (1 + jitter(random, 0.005)) }\n })\n // A low crown instead of a flat cap: the top disc rises to a blunt apex\n // ~2.5 cm above the rim, closed by the zero-radius level (latheGeometry\n // collapses that edge to a point, so there is no separate flat cap).\n const rimR = levels[rings - 1]!.radius\n levels.push({ y: H + R * 0.03, radius: rimR * 0.62 })\n levels.push({ y: H + R * 0.05, radius: 0 })\n\n // Both tints are STRAW. The third attempt used strawPale as colourTop\n // and the lathe's base-to-top lerp bleached the whole upper wall; the\n // reference wall is mid-brown and its top only a shade lighter.\n const body = latheGeometry(levels, 18, [0, 0, 0], tint('straw', -0.06, 0.8), {\n colourTop: tint('straw', 0.04, 0.8),\n })\n body.scale(1, 1, squashZ)\n // 6 mm of grain: the rope has to clear every bump and reach below every\n // hollow, and the ring maths below assumes this amplitude.\n const grain = R * 0.012 * config.rough\n roughenGeometry(body, grain, { salt: 11, scaleY: 0.45 })\n body.translate(0, -grain * 0.45, 0)\n // Straw texture on top of the kit's own mottle pass: the reference wall\n // has twice our luminance spread, and facet steps alone do not carry it.\n // Measured after the first render of THIS version: wall luminance sd was\n // 16 against the reference's 40, so the amount went from 0.22 to 0.34.\n mottleGeometry(body, 0.34, { cell: 0.05, salt: 5, hue: 0.6 })\n\n // Crown surface height at a given radius, for seating things ON the top.\n const crownY = (r: number): number => {\n if (r >= rimR) return H\n if (r >= rimR * 0.62) return H + R * 0.03 * ((rimR - r) / (rimR * 0.38))\n return H + R * 0.03 + R * 0.02 * (1 - r / (rimR * 0.62))\n }\n\n // --- Ropes -----------------------------------------------------------\n // Raised cords, not grooves. Cord is ~3% of the diameter across; its\n // centre sits ON the wall radius, so the outer face stands ~19 mm proud\n // of even the highest bump (jitter 0.5% + grain 6 mm ~ 9 mm) and the\n // inner face is buried below the deepest hollow. Light hemp against the\n // darkened body: the cord must read as a rope lying ON the straw.\n const ropePieces: BufferGeometry[] = []\n const cord = R * 0.064\n const hempBase = tint('cloth', -0.03, 0.5)\n for (const y of ropeYs) {\n const centreRadius = wallR(y) + cord * 0.1\n const ring = arcBarGeometry(centreRadius, cord, 0, Math.PI * 2, 20,\n [0, 0, 0], hempBase.clone())\n ring.rotateX(Math.PI / 2)\n ring.scale(1, 1, squashZ)\n ring.translate(0, y, 0)\n ropePieces.push(ring)\n }\n // The reference top face carries the coil of the binding as circles of\n // cord lying on the crown, half-sunk. Thin, and the SAME light hemp as\n // the wall cords: a first pass tinted them darker and they rendered as\n // charcoal hoops on a lid, which said \"wheel\", not \"binding\". Occlusion\n // already shades their contact line; the cord itself must stay cord\n // coloured.\n for (const rr of [rimR * 0.36, rimR * 0.64]) {\n const topCord = R * 0.028\n const ring = arcBarGeometry(rr, topCord, 0, Math.PI * 2, 16,\n [0, 0, 0], tint('cloth', -0.02, 0.5))\n ring.rotateX(Math.PI / 2)\n ring.scale(1, 1, squashZ)\n ring.translate(0, crownY(rr) + topCord * 0.05, 0)\n ropePieces.push(ring)\n }\n\n // --- Stray stalks ----------------------------------------------------\n // Each `wisps` unit spawns ~2.2 thin stalks. Clustered where the\n // reference silhouette actually breaks: the top rim and the base ring.\n // Every root sits 28% of the stalk length INSIDE the body; base stalks\n // clamp their droop so no tip reaches the floor.\n const wispPieces: BufferGeometry[] = []\n const wispCount = Math.max(0, Math.round(config.wisps * 2.2))\n const thickness = R * 0.013\n\n for (let i = 0; i < wispCount; i += 1) {\n const wl = R * 2 * (0.07 + random() * 0.09) // 7% to 16% of the diameter\n // All stalk tints stay in the pale straw family: a darker minority\n // rendered as twigs or debris wherever they crossed the lighter top.\n const pale = random() < 0.55\n const wisp = boxGeometry(\n [wl, thickness * (0.5 + random() * 0.7), thickness],\n [wl * 0.22, 0, 0], // spans -0.28..+0.72 of wl: root quarter buried\n pale ? tint('strawPale', -0.04, 1.0) : tint('straw', 0.1, 1.0),\n )\n\n const a = random() * Math.PI * 2\n const lane = i % 10\n let rr: number\n let y: number\n let tilt: number\n if (lane < 4) {\n // Top rim: outward and upward, breaking the shoulder line. Minimum\n // tilt 0.15: at 0.05 a stalk near the shoulder lay almost tangent\n // to the crown and read as debris dropped on the top.\n tilt = 0.15 + random() * 0.5\n y = H * (0.9 + random() * 0.05)\n rr = R * 0.82\n } else if (lane < 7) {\n // Base ring: outward with a slight droop, tip clamped above floor.\n y = H * (0.05 + random() * 0.06)\n const maxDroop = Math.max(0, (y - 0.012) / (wl * 0.72))\n tilt = -Math.min(0.02 + random() * 0.1, maxDroop)\n rr = wallR(y) - R * 0.07\n } else if (lane < 9) {\n // Wall scatter over the mid-height.\n y = H * (0.16 + random() * 0.66)\n tilt = jitter(random, 0.25)\n rr = wallR(y) - R * 0.08\n } else {\n // Top disc: steep enough that they PROTRUDE (>= 0.5 rad), rooted\n // below the crown surface so none lies flat on it.\n tilt = 0.5 + random() * 0.6\n rr = R * (0.15 + random() * 0.45)\n y = crownY(rr) - wl * 0.28 * Math.sin(tilt) - R * 0.02\n }\n\n wisp.rotateZ(tilt)\n wisp.rotateY(a)\n wisp.translate(Math.cos(a) * rr, y, -Math.sin(a) * rr * squashZ)\n wispPieces.push(wisp)\n }\n\n return {\n bale: { slot: 'straw' as const, geometry: mergeColoured([body]) },\n wisps: wispPieces.length > 0\n ? { slot: 'straw' as const, geometry: mergeColoured(wispPieces) }\n : undefined,\n ropes: ropePieces.length > 0\n ? { slot: 'cloth' as const, geometry: mergeColoured(ropePieces) }\n : undefined,\n }\n },\n }, overrides)\n}\n",
|
|
771
|
+
"hash": "9fd3ab1a2628e1f03be2cdd06d00d1085059d3768d4322367c74d02e09c8cd99"
|
|
772
772
|
}
|
|
773
773
|
],
|
|
774
774
|
"meta": {
|
|
@@ -856,8 +856,8 @@
|
|
|
856
856
|
{
|
|
857
857
|
"path": "models/iron-anvil/model.ts",
|
|
858
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": "
|
|
859
|
+
"content": "/**\n * @medieval-kit/iron-anvil\n *\n * An anvil's silhouette is the forging process itself: a wide splayed foot, a\n * short pinched waist, a deep body carrying a broad face, and a horn running\n * off one end. The geometry is almost entirely boxes; what gives it character\n * is the proportions, and the proportions are the whole model.\n *\n * PASS 2. The blind critique of pass 1 (63/100) said every landmark existed\n * but the outline never became an anvil, and all three of its fixes were the\n * same fix: the top mass was a cube on a stem instead of a long slab drawn\n * out to a point. What changed, so it does not get changed back:\n *\n * - The horn now springs from directly under the face. Its root circle is\n * buried in the body with its top edge a millimetre under the steel plate,\n * and the whole horn tilts UP a few degrees so the tip ends only ~5 degrees\n * below face level. Pass 1 seated the root at the body's centre height and\n * the horn read as a fin bolted on above the waist. Do not lower it again.\n * - The top mass is a slab ~2.5x longer than wide (defaults moved: faceLength\n * 0.46 -> 0.36, faceWidth 0.13 -> 0.145) and only ~0.36 of the anvil's\n * height. The waist takes the height the body gave up.\n * - The body's profile is one run of stacked taperedBoxes whose cross\n * sections meet EXACTLY at each junction: a near-vertical block under the\n * face, then a concave undercut easing into the waist. Pass 1 stacked\n * chamferedBoxes, and the chamfers cut a V-groove ring at every junction\n * which the critic read as \"two parts butted with a gap\". That is why the\n * profile pieces are now the chamferless primitive; the plate keeps its\n * chamfer because its edges are silhouette.\n * - The plate is flush-sided (it overhangs the body top by under a\n * millimetre, purely to avoid coplanar faces) instead of the four-sided\n * overhanging lid the critic called a construction seam. Its lower third is\n * sunk into the body.\n * - Hardy and pritchel holes at the heel end of the face: dark iron plugs\n * whose tops stand 0.6 mm proud of the steel so nothing is coplanar; at any\n * sane distance they read as the two holes, which the critic called \"pure\n * identification\".\n *\n * The stump under it is pass 1's and was the one thing the critic passed:\n * seating checked clean, so its numbers only scaled with the new footprint.\n *\n * hornReach is 0.5 and belongs where it is. Pass 2 left it at 0.88, which put\n * the tip 0.319 m past the body against a face 0.359 m long, so the horn was\n * as long as the anvil it grew out of; the reference's is half that. It also\n * sat outside its own slider, whose declared maximum is 0.8, and carried the\n * model to 0.718 m against a declared envelope of 0.6.\n */\n\nimport {\n chamferedBoxGeometry,\n createKitModel,\n createTinter,\n boxGeometry,\n prismGeometry,\n roughenGeometry,\n mergeColoured,\n steelTint,\n taperedBoxGeometry,\n} from '../core/index.ts'\n\nexport interface IronAnvilConfig {\n /** Total height (metres). With a real anvil stump it comes to ~0.75 m. */\n readonly height: number\n /** Length of the top face (metres). */\n readonly faceLength: number\n /** Width of the top face (metres). */\n readonly faceWidth: number\n /** How far the horn reaches past the face, as a ratio of the face length. */\n readonly hornReach: number\n readonly seed: number\n}\n\nexport const ironAnvilDefaults: IronAnvilConfig = {\n height: 0.34,\n faceLength: 0.36,\n faceWidth: 0.145,\n hornReach: 0.5,\n seed: 9,\n}\n\nexport type IronAnvilParts = 'base' | 'waist' | 'body' | 'face' | 'horn' | 'stump'\n\nexport function createModel(overrides: Partial<IronAnvilConfig> = {}) {\n return createKitModel<IronAnvilConfig, 'iron' | 'steel' | 'oak', IronAnvilParts>({\n id: 'iron-anvil',\n defaults: ironAnvilDefaults,\n slots: ['iron', 'steel', 'oak'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n\n const half = config.height / 2\n const FL = config.faceLength\n const FW = config.faceWidth\n\n // Height shares. The top mass (plate + body) is ~0.36 of the height and\n // the waist takes what the body gave up; a body at half the height is\n // what made pass 1 read as a lamp.\n const plateHeight = config.height * 0.06\n const baseHeight = config.height * 0.22\n const waistHeight = config.height * 0.42\n const baseTopY = -half + baseHeight\n const waistTopY = baseTopY + waistHeight\n // The plate's lower third is sunk into the body, so the body's top is a\n // little above where the plate begins.\n const bodyTopY = half - plateHeight * 0.7\n const bodyH = bodyTopY - waistTopY\n\n // --- Body ----------------------------------------------------------\n // One faceted profile, bottom-up: gentle out of the waist, steepest in\n // the undercut, then a near-vertical block carrying the face. Adjacent\n // segments share their junction cross section EXACTLY, which is what\n // keeps the seam rings of pass 1 from coming back.\n const profile: readonly (readonly [number, number, number])[] = [\n // y fraction, length fraction, width fraction\n [0.0, 0.48, 0.62],\n [0.22, 0.58, 0.69],\n [0.46, 0.8, 0.85],\n [0.66, 0.955, 0.95],\n [1.0, 0.99, 0.985],\n ]\n const bodyPieces = []\n for (let i = 0; i < profile.length - 1; i += 1) {\n const [t0, l0, w0] = profile[i]!\n const [t1, l1, w1] = profile[i + 1]!\n const y0 = waistTopY + bodyH * t0\n const y1 = waistTopY + bodyH * t1\n bodyPieces.push(\n taperedBoxGeometry(\n [FL * l0, FW * w0],\n [FL * l1, FW * w1],\n y1 - y0,\n [0, (y0 + y1) / 2, 0],\n tint('iron', 0.008, 0.8),\n ),\n )\n }\n\n // Hardy (square) and pritchel (round) holes at the heel end of the\n // face: dark matte plugs standing 0.6 mm proud of the steel. Proud, not\n // flush, so no face is coplanar with the plate's top; the sliver of\n // exposed side wall is invisible at model scale. They live in the iron\n // slot because a hole does not share the plate's polish.\n const proud = 0.0006\n const plugTop = half + proud\n const plugDepth = plateHeight * 0.6 + proud\n const holeTint = tint('iron', -0.02, 0.2)\n const hardy = boxGeometry(\n [0.03, plugDepth, 0.03],\n [-(FL / 2 - 0.034), plugTop - plugDepth / 2, -FW * 0.055],\n holeTint,\n )\n const pritchel = prismGeometry(\n 0.0085,\n 0.0085,\n plugDepth,\n 8,\n [-(FL / 2 - 0.075), plugTop - plugDepth / 2, FW * 0.12],\n tint('iron', -0.02, 0.2),\n )\n\n const body = mergeColoured([...bodyPieces, hardy, pritchel])\n\n // --- Waist and base ------------------------------------------------\n // The waist's minimum is at the TOP, against the undercut, and it eases\n // outward on the way down; the base flares from there to the foot. The\n // base keeps a chamfer (its edges are silhouette), and its top face is\n // a step larger than the waist so the waist lands on the flat inside\n // the chamfer band rather than overhanging the chamfer groove.\n const waist = taperedBoxGeometry(\n [FL * 0.53, FW * 0.66],\n [FL * 0.48, FW * 0.62],\n waistHeight,\n [0, baseTopY + waistHeight / 2, 0],\n tint('iron', 0.004, 0.8),\n )\n const base = chamferedBoxGeometry(\n [FL * 0.72, FW * 1.45],\n [FL * 0.56, FW * 0.7],\n baseHeight,\n FW * 0.028,\n [0, -half + baseHeight / 2, 0],\n tint('iron', 0, 0.8),\n )\n\n // --- Face plate ----------------------------------------------------\n // Flush-sided: it clears the body's top cross section by well under a\n // millimetre per side, enough that nothing is coplanar and nothing\n // reads as a lid. The two metals are told apart by material roughness\n // (steel slot), not by an overhang.\n const face = chamferedBoxGeometry(\n [FL * 0.99, FW * 0.985],\n [FL, FW],\n plateHeight,\n FW * 0.03,\n [0, half - plateHeight / 2, 0],\n steelTint(random),\n )\n\n // --- Horn ----------------------------------------------------------\n // Springs from directly under the face: the root circle's top edge sits\n // a millimetre below the plate's underside and the root is buried in\n // the body's end, sized to nearly the body's height so the horn is the\n // body drawn out to a point. The whole cone tilts UP so the tip ends\n // only ~5 degrees below face level; the taper (octagonal throughout)\n // outruns the tilt, so the top line still eases downward and never\n // breaks through the plate.\n const tiltUp = (5 * Math.PI) / 180\n const reach = FL * config.hornReach\n const hornRootX = FL * 0.055\n const rootRadius = bodyH * 0.475\n const rootY = half - plateHeight - rootRadius - 0.001\n const hornLength = (FL / 2 + reach - hornRootX) / Math.cos(tiltUp)\n const horn = prismGeometry(\n rootRadius,\n FW * 0.05,\n hornLength,\n 8,\n [0, 0, 0],\n tint('iron', 0.012, 0.6),\n )\n // Built standing, then laid down: local +Y becomes world +X (tilted up\n // by tiltUp), so the wide root ends at -X and the point at +X.\n horn.rotateZ(-Math.PI / 2 + tiltUp)\n horn.translate(\n hornRootX + (hornLength / 2) * Math.cos(tiltUp),\n rootY + (hornLength / 2) * Math.sin(tiltUp),\n 0,\n )\n\n // --- Stump ---------------------------------------------------------\n // An anvil is used ON something. It is bedded into the end grain of a\n // log so the block takes the ring out of it and puts the face at the\n // smith's knuckle height. Its own part, so anyone who wants the bare\n // anvil hides `parts.stump` -- the same arrangement as the bell's frame.\n //\n // Radius is set off the foot's half-diagonal: the 9-gon's inradius must\n // beat the foot corner even after roughening, or the foot overhangs the\n // wood. The critic checked this seat and passed it; keep it passing.\n const stumpTop = -half + config.height * 0.06\n const stumpHeight = config.height * 1.2\n const stumpRadius = FL * 0.55\n const side = tint('oak', -0.1, 0.6)\n const endGrain = tint('oakEnd', -0.06, 0.6)\n const stump = prismGeometry(\n stumpRadius * 1.1,\n stumpRadius,\n stumpHeight,\n 9,\n [0, stumpTop - stumpHeight / 2, 0],\n side,\n { colourTop: endGrain },\n )\n roughenGeometry(stump, stumpRadius * 0.035, { salt: 5, scaleY: 0.3 })\n\n return {\n stump: { slot: 'oak', geometry: stump },\n base: { slot: 'iron', geometry: base },\n waist: { slot: 'iron', geometry: waist },\n body: { slot: 'iron', geometry: body },\n face: { slot: 'steel', geometry: face },\n horn: { slot: 'iron', geometry: horn },\n }\n },\n }, overrides)\n}\n",
|
|
860
|
+
"hash": "814d425b9a865856b2b780e39b52b6b2169d0e3a97ece93785b9fe059fd4090d"
|
|
861
861
|
}
|
|
862
862
|
],
|
|
863
863
|
"meta": {
|
|
@@ -941,8 +941,8 @@
|
|
|
941
941
|
{
|
|
942
942
|
"path": "models/iron-cauldron/model.ts",
|
|
943
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": "
|
|
944
|
+
"content": "/**\n * @medieval-kit/iron-cauldron\n *\n * A cauldron slung from a tripod over a ring of hearth stones.\n *\n * The kit had two light sources — a torch and a lantern — and no HEARTH, which\n * is the wrong way round for a medieval catalogue. A cooking fire is where\n * everyone in the scene is facing, and it is the only light in most of them.\n *\n * Three things about the shape are worth stating because they are what make it\n * read as a cauldron rather than as a pot:\n *\n * - The belly is WIDER than the mouth. A cauldron is bulbous, so that its\n * contents sit over the fire rather than beside it, and so that the walls\n * take the heat evenly. Straight-sided, it is a bucket.\n * - It has three short feet. It spends as much time standing in the ashes as\n * hanging over them, and a round-bottomed pot cannot stand at all.\n * - It hangs by a BAIL — a semicircular loop pinned to two lugs — and not by\n * the rim. The bail is what the chain grips, and it swings, which is how a\n * cauldron is taken off the fire without touching it.\n *\n * Parts: `hearth` (the stone ring and its bed of embers), `tripod`, and `pot`\n * (the cauldron with its bail and the chain it hangs on). The chain belongs to\n * the pot rather than to the tripod because it moves with what it carries.\n *\n * FOURTH PASS, after a blind critique (68, worst axis material: \"clay or wood\n * before iron\"). What it found and what was done, so none of it is re-tried:\n *\n * - The previous pass DESCRIBED a flipped interior lathe but never called\n * `flipGeometry`: the inner lathe faced outward and rendered as a pale\n * dome plugging the mouth, which the critic read as a lidless flat disc.\n * The interior is now genuinely flipped — wall faces inward, the bottom\n * cap flips to face UP and becomes the dark interior floor, dropped about\n * a quarter of the pot height below the lip.\n * - The previous pass also hand-rolled a `soot()` HSL helper on the `char`\n * entry and kept the body in the matte `char` slot; it came out mid-grey,\n * LIGHTER than the tripod, and the two darker bands then read as barrel\n * hoops on a pale stave body. The body now lives in the IRON slot and is\n * tinted `iron` exactly like the legs and chain, so slot metalness and\n * palette agree; the two contrasting bands are gone and the one girth\n * bead at the widest point carries the SAME colour as the body, so it\n * shows as a shading break, not a stripe.\n * - The bail ears were horizontal rings floating tangent to the flank (the\n * \"cross-shaped fitting in mid-air\"). They are now solid lug blocks sunk\n * into the outer wall just below the lip at exactly opposite points, and\n * the bail is a symmetric arc about the pot's axis whose slightly\n * over-swept ends terminate INSIDE those blocks.\n * - The straight collar between shoulder and rim is deleted: the profile\n * now runs lip, immediate bulge, widest just below half height, tuck\n * under to a small flat base, with the mouth about 0.9 of the belly.\n * - The stone ring showed daylight: bases hovered (translate was fixed at\n * 0.4·stoneH regardless of each stone's own height) and the taper opened\n * wedge gaps between neighbours. Each stone is now buried by its own\n * height, cut longer at the base so neighbours interpenetrate, and the\n * ash bed extends UNDER the ring so any remaining gap shows ash, not\n * background. The coals ride proud of the ash plane instead of level\n * with it, which is what had them clipping against its silhouette.\n */\nimport { type BufferGeometry } from 'three'\n\nimport {\n arcBarGeometry,\n bandGeometry,\n createKitModel,\n createTinter,\n flipGeometry,\n jitter,\n latheGeometry,\n mergeColoured,\n prismGeometry,\n roughenGeometry,\n taperedBoxGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface IronCauldronConfig {\n /** Height from the ground to the tripod's apex (metres). */\n readonly height: number\n /** Outer radius of the ring of hearth stones (metres). */\n readonly hearthRadius: number\n /** Stones in the ring. */\n readonly stones: number\n /** Radius of the cauldron at its belly (metres). */\n readonly potRadius: number\n /** Links in the chain. */\n readonly links: number\n /** Whether the fire is lit (0/1). */\n readonly lit: number\n readonly seed: number\n}\n\nexport const ironCauldronDefaults: IronCauldronConfig = {\n height: 1.28,\n hearthRadius: 0.46,\n stones: 12,\n potRadius: 0.22,\n links: 4,\n lit: 1,\n seed: 29,\n}\n\nexport type IronCauldronParts = 'hearth' | 'tripod' | 'pot'\n\nexport interface IronCauldronActions {\n /** Lights or puts out the fire. When out, the embers are hidden. */\n setLit(on: boolean): void\n isLit(): boolean\n}\n\nexport function createModel(overrides: Partial<IronCauldronConfig> = {}) {\n let lit = true\n\n return createKitModel<IronCauldronConfig, 'stone' | 'iron' | 'char' | 'ember', IronCauldronParts, IronCauldronActions>({\n id: 'iron-cauldron',\n defaults: ironCauldronDefaults,\n slots: ['stone', 'iron', 'char', 'ember'],\n\n build: ({ config, random }) => {\n const tint = createTinter(random)\n const H = config.height\n const floor = -H / 2\n const hearthR = config.hearthRadius\n\n // --- Hearth -------------------------------------------------------------\n const stones: BufferGeometry[] = []\n const count = Math.max(5, Math.round(config.stones))\n const stoneH = hearthR * 0.32\n const ringR = hearthR - stoneH * 0.5\n\n for (let i = 0; i < count; i += 1) {\n const a = (i / count) * Math.PI * 2 + jitter(random, 0.05)\n const arc = (Math.PI * 2 * ringR) / count\n // Cut LONG at the base, like the well's kerb: a flat-faced block on a\n // curve cannot close its joint otherwise, and the taper towards the\n // top otherwise opens wedge gaps between neighbours that drop straight\n // through to the background.\n const h = stoneH * (0.92 + jitter(random, 0.12))\n const stone = taperedBoxGeometry(\n [arc * (1.18 + jitter(random, 0.08)), stoneH * (1.05 + jitter(random, 0.1))],\n [arc * (0.86 + jitter(random, 0.1)), stoneH * (0.74 + jitter(random, 0.12))],\n h,\n [0, 0, 0],\n tint('stone', jitter(random, 0.1)),\n )\n stone.rotateY(a)\n // Buried by each stone's OWN height. The old translate was a fixed\n // fraction of the nominal height, so short stones hovered and the\n // roughening pass then lifted some bottoms clean off the ground —\n // the black bars the critique saw under the front of the ring.\n stone.translate(\n Math.sin(a) * ringR,\n floor + h * 0.5 - stoneH * 0.08,\n Math.cos(a) * ringR,\n )\n stones.push(stone)\n }\n const hearth = mergeColoured(stones)\n roughenGeometry(hearth, stoneH * 0.08, { salt: 19 })\n\n // Ash bed. It runs UNDER the stone ring, not up to its inner face, so a\n // gap between two stones shows ash behind it rather than background.\n const ashTop = floor + hearthR * 0.055\n const ash = latheGeometry(\n [\n { y: floor + hearthR * 0.005, radius: ringR + stoneH * 0.25 },\n { y: ashTop, radius: ringR - stoneH * 0.05 },\n ] as Level[],\n 11, [0, 0, 0], tint('char', 0.04),\n )\n\n // The embers sit PROUD of the ash plane — base buried, top clear of it —\n // rather than level with it, which is what had them clipping against\n // the ash disc's silhouette. Separate from the ash so the fire can be\n // put out without the ashes going with it.\n const emberPieces: BufferGeometry[] = []\n const emberR = ringR - stoneH * 0.7\n const emberCount = Math.max(4, Math.round(count * 0.7))\n for (let i = 0; i < emberCount; i += 1) {\n const a = i * 2.399963\n const r = emberR * 0.8 * Math.sqrt((i + 0.4) / emberCount)\n const size = hearthR * (0.05 + random() * 0.045)\n const coalH = size * 0.6\n const coal = prismGeometry(\n size, size * 0.7, coalH, 5, [0, 0, 0],\n tint(i % 3 === 0 ? 'emberTip' : 'ember', jitter(random, 0.05), 0.4),\n )\n coal.rotateY(random() * Math.PI * 2)\n coal.translate(\n Math.sin(a) * r,\n ashTop + coalH * 0.2,\n Math.cos(a) * r,\n )\n emberPieces.push(coal)\n }\n\n // --- Tripod -------------------------------------------------------------\n const apexY = floor + H\n const iron: BufferGeometry[] = []\n const legFoot = hearthR * 1.34\n const legRise = apexY - floor\n const legLength = Math.hypot(legFoot, legRise)\n const barT = hearthR * 0.045\n\n for (let i = 0; i < 3; i += 1) {\n // Three legs, not four. A tripod cannot rock: whatever the ground does,\n // three feet are always on it, which is why every cooking fire in\n // Europe stood on one.\n const a = (i / 3) * Math.PI * 2 + 0.4\n const leg = taperedBoxGeometry(\n [barT * 1.15, barT * 1.15],\n [barT * 0.8, barT * 0.8],\n legLength * 1.02,\n [0, 0, 0],\n tint('iron', -0.02 + jitter(random, 0.04), 0.7),\n )\n // NEGATIVE. `rotateX(+t)` carries the bar's TOP towards +Z, and the\n // translate that follows also moves it +Z, so the two add: the head\n // ends up at the full outboard radius and the foot at the axis. The\n // brace stands on its head, splayed at the top and gathered at the\n // bottom, which is the opposite of what carries a load. The bounding\n // box is identical either way, which is why it survives inspection.\n leg.rotateX(-Math.atan2(legFoot, legRise))\n leg.rotateY(a)\n leg.translate(\n Math.sin(a) * legFoot * 0.5,\n (floor + apexY) / 2,\n Math.cos(a) * legFoot * 0.5,\n )\n iron.push(leg)\n }\n // Apex ring: what gathers the three heads and carries the chain.\n iron.push(bandGeometry(\n barT * 2.3, apexY - barT * 0.9, barT * 1.8, barT * 0.7, 8,\n tint('iron', 0.05, 0.7), { inner: true },\n ))\n\n // --- Pot ----------------------------------------------------------------\n // Authored hanging from the APEX, which is where the part's origin goes,\n // so the whole assembly can be raised and lowered on its chain later\n // without any of it being rebuilt.\n const R = config.potRadius\n // Low over the fire, which is the only place a cooking pot is any use.\n // At 0.36 of the tripod's height the cauldron hung half a metre clear of\n // the embers -- close enough to look like a cauldron and far enough to\n // cook nothing.\n const potHang = -H * 0.62\n const potChar: BufferGeometry[] = []\n const potIron: BufferGeometry[] = []\n\n // Chain, from the apex ring down to the bail.\n const linkCount = Math.max(2, Math.round(config.links))\n const chainDrop = -potHang - R * 1.5\n // The link radius comes from the SPACING the links are actually laid out\n // at, which is over (count - 1) gaps, not over count. Dividing by the\n // count made each link a little smaller than half its own gap, so the\n // chain came apart the moment the drop grew -- the same arithmetic slip\n // the tavern sign's chain had, and the support check caught this one for\n // the same reason it caught that one.\n const linkGap = (chainDrop - barT * 1.2) / Math.max(1, linkCount - 1)\n const linkR = Math.max(barT * 0.9, linkGap * 0.62)\n for (let i = 0; i < linkCount; i += 1) {\n const y = -barT * 1.2 - linkGap * i\n const ring = bandGeometry(\n linkR, 0, barT * 0.7, barT * 0.42, 6,\n tint('iron', jitter(random, 0.05), 0.7), { inner: true },\n )\n // Successive links pass through each other at right angles.\n ring.rotateY(i * 0.41)\n ring.rotateX(i % 2 === 0 ? Math.PI / 2 : 0)\n ring.rotateZ(i % 2 === 0 ? 0 : Math.PI / 2)\n ring.translate(0, y, 0)\n potIron.push(ring)\n }\n\n // The cauldron itself: lip, immediate bulge, widest just below half\n // height, tuck under to a small flat base. No straight collar — the\n // collar is what made the profile read as a barrel. One colour for the\n // whole vessel, taken off the SAME palette entry as the tripod and the\n // chain, because the critique's whole material complaint reduces to the\n // pot not being the colour of its own hardware.\n const belly = potHang\n const cast = tint('iron', -0.02, 0.35)\n const profile: Level[] = [\n { y: belly - R * 0.78, radius: R * 0.36 },\n { y: belly - R * 0.62, radius: R * 0.66 },\n { y: belly - R * 0.38, radius: R * 0.9 },\n { y: belly - R * 0.12, radius: R },\n { y: belly + R * 0.14, radius: R * 0.965 },\n { y: belly + R * 0.42, radius: R * 0.9 },\n ]\n potIron.push(latheGeometry(profile, 11, [0, 0, 0], cast, {\n colourTop: tint('iron', 0.02, 0.35),\n capTop: false,\n }))\n // The interior, FLIPPED: the wall faces inward and the bottom cap flips\n // to face up, so from above the mouth is an open cavity with a dark\n // floor about a quarter of the pot height below the lip — not a plug.\n // It stays in the matte char slot (soot does not gleam), but its colour\n // comes off the iron entry: the char entry is warm, and a warm interior\n // read as clay or as contents rather than as an empty black pot.\n potChar.push(flipGeometry(latheGeometry(\n [\n { y: belly + R * 0.13, radius: R * 0.7 },\n { y: belly + R * 0.46, radius: R * 0.82 },\n ] as Level[],\n 11, [0, 0, 0], tint('iron', -0.05, 0.3), { capTop: false },\n )))\n // The rim: an outer ring that closes the annulus between the outer wall\n // and the interior wall — both wall tops end inside it. Same colour as\n // the body, so it reads as a cast lip, not a stripe.\n potIron.push(bandGeometry(\n R * 0.95, belly + R * 0.44, R * 0.09, R * 0.16, 11,\n cast, { inner: true },\n ))\n // One girth bead at the widest point, in the body's own colour: a\n // shading break, the way the reference shows it. The two contrasting\n // dark hoops of the last pass are gone — they were the barrel.\n potIron.push(bandGeometry(\n R * 1.04, belly - R * 0.12, R * 0.07, R * 0.06, 11,\n cast,\n ))\n\n // Three feet. Short, splayed, and the reason it can stand in the ashes.\n for (let i = 0; i < 3; i += 1) {\n const a = (i / 3) * Math.PI * 2 + 0.9\n const foot = taperedBoxGeometry(\n [R * 0.15, R * 0.15],\n [R * 0.1, R * 0.1],\n R * 0.4,\n [0, 0, 0],\n tint('iron', -0.05, 0.6),\n )\n foot.rotateX(0.2)\n foot.rotateY(a)\n foot.translate(\n Math.sin(a) * R * 0.5,\n belly - R * 0.83,\n Math.cos(a) * R * 0.5,\n )\n potIron.push(foot)\n }\n\n // Bail: one symmetric arc about the pot's axis, hung between two solid\n // lug blocks sunk into the outer wall at opposite points just below the\n // lip. The arc is swept a little past the half circle so both end caps\n // terminate INSIDE the lugs; nothing rests on the rim's top plane and\n // nothing floats beside the belly.\n const lugY = belly + R * 0.29\n for (const side of [-1, 1]) {\n potIron.push(taperedBoxGeometry(\n [R * 0.14, R * 0.13],\n [R * 0.11, R * 0.1],\n R * 0.24,\n [side * R * 0.99, lugY, 0],\n tint('iron', 0.02, 0.5),\n ))\n }\n potIron.push(arcBarGeometry(\n R * 0.95, barT * 0.75, -0.08, Math.PI + 0.08, 9,\n [0, lugY, 0],\n tint('iron', 0.06, 0.7),\n ))\n\n return {\n hearth: {\n slot: 'stone' as const,\n geometry: hearth,\n extras: [\n { slot: 'char' as const, geometry: ash },\n { slot: 'ember' as const, geometry: mergeColoured(emberPieces) },\n ],\n },\n tripod: { slot: 'iron' as const, geometry: mergeColoured(iron) },\n pot: {\n slot: 'iron' as const,\n geometry: mergeColoured(potIron),\n extras: [{ slot: 'char' as const, geometry: mergeColoured(potChar) }],\n origin: [0, apexY, 0] as const,\n },\n }\n },\n\n actions: ({ parts, getConfig }) => {\n lit = getConfig().lit >= 0.5\n // The embers are the SECOND body of the hearth part. Hiding the part\n // itself would take the stones with it.\n const embers = parts.hearth.anchor.children[2]\n if (embers) embers.visible = lit\n return {\n setLit: (on) => {\n lit = on\n const body = parts.hearth.anchor.children[2]\n if (body) body.visible = on\n },\n isLit: () => lit,\n }\n },\n }, overrides)\n}\n",
|
|
945
|
+
"hash": "401a79a296c7d75e360d7112f2ea97d2eafe5d7df51841d363b32573c070b2a2"
|
|
946
946
|
}
|
|
947
947
|
],
|
|
948
948
|
"meta": {
|
|
@@ -1129,8 +1129,8 @@
|
|
|
1129
1129
|
{
|
|
1130
1130
|
"path": "models/leather-book/model.ts",
|
|
1131
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": "
|
|
1132
|
+
"content": "/**\n * @medieval-kit/leather-book\n *\n * Leather-bound manuscript with a clasp. Table, shelf, inside a chest, lectern.\n *\n * The whole point of the model is the PAGE BLOCK. What makes a book a book is\n * not its cover but the uneven mass of paper spilling past that cover. Give it\n * as a flat box and it reads like a brick no matter how good the cover is.\n *\n * HISTORY OF DEAD ENDS, do not repeat them:\n *\n * - v1 had the pages OVERHANGING the boards (1.035 of the cover) and read as\n * a ream of paper with a leather lid. The boards overhang the pages; the\n * overhang is called the square. Flush at the spine, inset on the three\n * free edges.\n * - v2 gave the spine as a thin dark slab inset between the boards, with\n * band tabs sitting on its outer face: black void at the joint, floating\n * tabs, and a colour split from the boards it is one piece of leather\n * with.\n * - v3 rebuilt the spine as an elliptical prism but oversized everything\n * around it and the critic read the whole book as a chest. The failures,\n * each of which cost points:\n * - The raised bands were built on radius `half + 4mm` and scaled to\n * stand 1.8mm proud, but `bandGeometry`'s FIRST argument is the OUTER\n * radius and the hoop grows INWARD; adding the 8mm wall on top of an\n * already padded radius put the hoops 11mm proud of the boards, and\n * with the spine crown at 1.06 * half the whole back read as a black\n * barrel the boards were embedded in. The spine semi-axis in y is now\n * EXACTLY `half` (tangent to both cover planes, crown vertex on the\n * board edge line) and the bands are the same ellipse plus about a\n * millimetre, dying into the leather just before the cover planes.\n * - Corner plates with domed bosses sat on BOTH boards; the bottom set\n * protruded below the board and the book stood on eight gold pads\n * with daylight under it. Metal furniture now lives on the FRONT\n * cover only and nothing whatsoever reaches below -half: the book\n * rests on the whole underside of the back board.\n * - The metal was tinted straight from `brass` and read as bright\n * yellow blocks against the reference's tarnished pewter. Fittings\n * now tint from the `bronze` PALETTE entry (dark weathered metal,\n * see materials.ts: palette carries colour, slot carries material)\n * desaturated further toward grey, still on the `brass` slot.\n * - The clasp strap crossed the page recess and stopped at the page\n * edge: attached to nothing at the bottom. The riser now spans board\n * edge to board edge, buried a little into both, with a hinge block\n * on the back board and a catch plate and pin under the tongue.\n * - Page slabs were sized `(thickness - bt * 1.9) * (0.985 + r*0.015)`;\n * at the low end of the jitter that is SHORTER than the board gap and\n * the critic saw open air between the text block and the back board.\n * Slab thickness jitter now only ever ADDS overshoot into the boards.\n *\n * Two period details drove the geometry:\n *\n * - The pages are NOT PAPER but vellum, thick and wavy. The fore edge is\n * built from vertical slabs each pushed in or out a little, so the block\n * reads as a stack of leaves rather than a cream brick.\n * - The book DOES NOT stay shut: vellum swells. That is why manuscripts\n * have a clasp — function, not ornament.\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 prismGeometry,\n roughenGeometry,\n} from '../core/index.ts'\n\nexport interface LeatherBookConfig {\n /** Cover width (metres). */\n readonly width: number\n /** Cover length (metres). */\n readonly length: number\n /** Total thickness of the closed book (metres). */\n readonly thickness: number\n /** Number of raised bands on the spine. */\n readonly bands: number\n /** Number of clasps. */\n readonly clasps: number\n readonly seed: number\n}\n\nexport const leatherBookDefaults: LeatherBookConfig = {\n width: 0.19,\n length: 0.27,\n thickness: 0.062,\n bands: 4,\n clasps: 1,\n seed: 79,\n}\n\nexport type LeatherBookParts = 'cover' | 'pages' | 'clasps'\n\nexport function createModel(overrides: Partial<LeatherBookConfig> = {}) {\n return createKitModel<LeatherBookConfig, 'leather' | 'cloth' | 'brass', LeatherBookParts>({\n id: 'leather-book',\n defaults: leatherBookDefaults,\n slots: ['leather', 'cloth', 'brass'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n /**\n * The book's own leather, desaturated and dropped from the palette entry.\n *\n * Measured against the reference the cover sat 0.21 over on saturation and\n * 0.12 over on lightness: a reddish tan where the photograph is a dark,\n * nearly grey brown. The palette entry itself is right where it is, and it\n * cannot move, because `coin-pouch` shares it and measures within 0.05 of\n * its own reference. So the correction belongs to the model that is wrong,\n * not to the constant they both read.\n */\n const leather = (lift = 0, spread = 0.9): Color => {\n const c = tint('leather', lift, spread)\n c.offsetHSL(0, -0.24, -0.06)\n return c\n }\n /**\n * Tarnished pewter for the fittings: the `bronze` palette entry (dark\n * weathered bell metal) pushed further toward grey. Deliberately NOT\n * `brass`: v3's fittings tinted from brass and read as bright yellow\n * blocks where the reference has dull grey plates.\n */\n const pewter = (lift = 0): Color => {\n const c = tint('bronze', lift, 0.5)\n c.offsetHSL(0, -0.1, 0.03)\n return c\n }\n const half = config.thickness / 2\n const halfWidth = config.width / 2\n // About 8% of the total height each: thin boards over a thick vellum\n // block, matching the reference's proportions. The v2 \"boards must be\n // massive\" note is superseded — what made v1 read as a ream was the\n // pages OVERHANGING, not the boards being thin.\n const boardThickness = config.thickness * 0.085\n\n // --- Covers ------------------------------------------------------------\n const cover: BufferGeometry[] = []\n for (const side of [-1, 1]) {\n cover.push(chamferedBoxGeometry(\n [config.width, config.length],\n [config.width * 0.995, config.length * 0.997],\n boardThickness,\n boardThickness * 0.28,\n [0, side * (half - boardThickness / 2), 0],\n leather(side > 0 ? 0.03 : -0.03),\n ))\n }\n\n // --- Spine --------------------------------------------------------------\n // One piece of leather with the boards, a SHALLOW arc: an elliptical\n // prism whose y semi-axis is exactly `half`, so the crown vertex lands\n // on the board edge line and the silhouette is tangent to both cover\n // planes — it never rises above the top board or below the bottom one,\n // and the seam at the joint closes without a void. The x semi-axis is\n // small: the backbone throws only ~12mm past the board edge.\n const semiX = 0.012\n const spine = prismGeometry(half, half, config.length * 0.995, 16, [0, 0, 0], leather(0.045))\n // Built upright: rotate at the origin so the axis lies along the book,\n // squash the circle into the ellipse, THEN translate.\n spine.rotateX(Math.PI / 2)\n spine.scale(semiX / half, 1, 1)\n spine.translate(-halfWidth, 0, 0)\n cover.push(spine)\n\n // Raised bands: the sewing cords under the leather, wrapping ACROSS the\n // curve of the back. Full hoops around the spine axis; the far half is\n // buried inside the spine, boards and page block. Scaled to sit about a\n // millimetre proud of the leather at the backbone and to sink UNDER it\n // just before the cover planes (y semi-axis a hair below the spine's),\n // so they never break the top or bottom silhouette and never touch the\n // ground.\n const bandCount = Math.max(0, Math.round(config.bands))\n for (let i = 0; i < bandCount; i += 1) {\n const t = (i + 1) / (bandCount + 1)\n const band = bandGeometry(\n half, // OUTER radius — the 8mm wall below grows INWARD from here.\n (t - 0.5) * config.length * 0.85,\n config.length * 0.035,\n 0.008,\n 12,\n leather(0.085),\n // The inner face is not optional here. `bandGeometry` leaves it out by\n // default because a hoop normally wraps a body that hides it, and a\n // band without one is an open surface: four of them left 96 unbalanced\n // edges and the book stopped being a closed solid. Its own docstring\n // says so. The buried half will never be seen, and 24 triangles is\n // what closing it costs.\n { inner: true },\n )\n band.rotateX(Math.PI / 2)\n band.scale((semiX + 0.0011) / half, (half - 0.0002) / half, 1)\n band.translate(-halfWidth, 0, 0)\n cover.push(band)\n }\n\n // --- Page block ---------------------------------------------------------\n // The BOARDS overhang the pages (the square, ~4% of the width). Flush\n // into the spine, inset on the three free edges. Built as seven vertical\n // slabs whose fore edges are each pushed in or out a little, with\n // chamfered rims, so the fore edge is a faceted stack of leaves and not\n // a flat plane. Every slab overshoots INTO both boards — the jitter only\n // ever adds overshoot, so there is never a gap between the block and the\n // board it sits on — and stops well inside them, so nothing reaches a\n // cover surface.\n const square = config.width * 0.04\n const slabCount = 7\n const blockLen = config.length - square * 2\n const dz = blockLen / slabCount\n const slabs: BufferGeometry[] = []\n for (let i = 0; i < slabCount; i += 1) {\n const xFore = halfWidth - square + jitter(random, square * 0.15)\n const xBack = -halfWidth + 0.004 + jitter(random, 0.0006)\n const w = xFore - xBack\n const slabT = config.thickness - boardThickness * (1.2 + random() * 0.5)\n const zLen = dz * 1.12\n slabs.push(chamferedBoxGeometry(\n [w, zLen],\n [w * 0.985, zLen * 0.99],\n slabT,\n config.thickness * 0.04,\n [(xFore + xBack) / 2, 0, -blockLen / 2 + dz * (i + 0.5)],\n tint('cloth', 0.06 + random() * 0.05, 0.7),\n ))\n }\n const pages = mergeColoured(slabs)\n // Vellum is not flat: a light ripple on top of the facets.\n roughenGeometry(pages, config.thickness * 0.012, { salt: 31, scaleY: 0.25 })\n\n // --- Clasp ----------------------------------------------------------------\n // Hinged to the BACK board, wrapping the fore edge, lapping a short\n // tongue onto the front cover where a catch plate and pin receive it.\n // The riser is buried a little into both board edges so every piece is\n // held; nothing crosses the page recess unattached and nothing reaches\n // below the back board's underside.\n const claspCount = Math.max(0, Math.round(config.clasps))\n const strapW = config.width * 0.055\n const strapT = 0.0018\n const metal: BufferGeometry[] = []\n for (let i = 0; i < claspCount; i += 1) {\n const t = claspCount === 1 ? 0.5 : 0.2 + (i / (claspCount - 1)) * 0.6\n const z = (t - 0.5) * config.length * 0.6\n // Hinge block on the back board's fore-edge face. Its inner face is\n // 2 mm into the board rather than a tenth of a millimetre inside the\n // riser's: two parallel faces that overlap and sit that close are a\n // z-fight, whichever of them is meant to be in front.\n // Its underside stops 1.6 mm up the board for the same reason: it used\n // to end 0.1 mm under the riser's foot, directly beneath it. A knuckle\n // does not run the whole thickness of a board anyway.\n const hingeIn = halfWidth - 0.002\n const hingeOut = halfWidth + strapT * 1.3\n const hingeBot = -half + 0.0016\n const hingeTop = -half + boardThickness * 0.85\n metal.push(boxGeometry(\n [hingeOut - hingeIn, hingeTop - hingeBot, strapW * 1.25],\n [(hingeIn + hingeOut) / 2, (hingeBot + hingeTop) / 2, z],\n pewter(-0.02),\n ))\n // Riser up the fore edge, board edge to board edge, inner face buried\n // 0.8mm into both boards.\n const yTop = half + 0.0002\n const yBot = -half + 0.0004\n metal.push(boxGeometry(\n [strapT, yTop - yBot, strapW],\n [halfWidth + strapT / 2 - 0.0008, (yTop + yBot) / 2, z + jitter(random, 0.001)],\n pewter(0.0),\n ))\n // Tongue lapping onto the front cover, bottom face sunk 0.5mm in.\n const tongueL = config.width * 0.035 + 0.0016\n metal.push(boxGeometry(\n [tongueL, strapT, strapW],\n [halfWidth + 0.0016 - tongueL / 2, half + strapT / 2 - 0.0005, z + jitter(random, 0.001)],\n pewter(0.045),\n ))\n // The pin the tongue ends against, rooted in the cover rather than\n // standing on it.\n //\n // A catch plate used to sit under the tongue and it was doing nothing:\n // 1.2 mm thick, seated 0.3 mm inside a 1.8 mm tongue, so it was buried\n // for the whole length the two shared and the pair z-fought on both\n // faces. All that showed of it was a 2.4 mm lip past the tongue's tip.\n // The pin was already the catch.\n //\n // It passes THROUGH the tongue rather than ending inside it. The two\n // overlap by 1.2 mm of the tongue's width, so a pin rooted 0.2 mm under\n // the tongue and stopping 0.6 mm under its top puts two parallel faces\n // that close on both sides of it. Rooted 1.8 mm into the cover and\n // standing 1.3 mm proud of the strap, it is a stud through an eye.\n const xCatch = halfWidth + 0.0016 - tongueL\n metal.push(prismGeometry(0.0016, 0.001, 0.0044, 6,\n [xCatch - 0.0002, half + 0.0004, z], pewter(0.06)))\n }\n\n // --- Corner plates ------------------------------------------------------\n // Tarnished pewter furniture on the FRONT cover only: a thin plate at\n // each corner with a round lobe cusping toward the cover field, thin\n // flaps turning down over the fore and head/tail edges, and a domed\n // rivet boss at the centre. The back board carries nothing, so the book\n // rests flat on its whole underside.\n const wp = config.width * 0.155\n const plateH = 0.0016\n const flapT = 0.0016\n for (const sx of [-1, 1]) {\n for (const sz of [-1, 1]) {\n // The spine-side pair stops clear of the hinge joint; there is no\n // board edge there to turn a flap over.\n const px = sx > 0\n ? halfWidth - wp / 2 - 0.0012\n : -halfWidth + wp / 2 + 0.004\n const pz = sz * (config.length / 2 - wp / 2 - 0.0012)\n const plateColour = pewter(0.01)\n // Plate, bottom sunk 0.4mm into the board.\n metal.push(chamferedBoxGeometry(\n [wp, wp],\n [wp * 0.9, wp * 0.9],\n plateH,\n 0.0004,\n [px, half + plateH / 2 - 0.0004, pz],\n plateColour,\n ))\n // There is no cusp lobe. It sat 0.32 mm under the plate's own top\n // face and overlapped it, which is a z-fight rather than a step, and\n // a plate 1.6 mm thick has no room for a step: the only separate\n // piece that clears both its faces is a 4 mm knob. The chamfer\n // already reads as cast furniture at 29 mm square.\n //\n // Domed rivet boss. Its base is BELOW the plate, not inside it, for\n // the same reason: a base seated anywhere within 1.6 mm of stock\n // lands within a millimetre of one of the two faces. Seated in the\n // board instead, the flare reads as a rivet head drawn down into the\n // plate, and it keeps its height above the plate by growing.\n metal.push(latheGeometry(\n [\n { y: 0, radius: wp * 0.3 },\n { y: 0.0034, radius: wp * 0.24 },\n { y: 0.0056, radius: wp * 0.1 },\n ],\n 10,\n [px, half - 0.0018, pz],\n pewter(0.07),\n ))\n const flapTop = half + plateH * 0.5\n const flapBot = half - boardThickness * 0.75\n const yFlap = (flapTop + flapBot) / 2\n const flapH = flapTop - flapBot\n // Flap over the fore edge, hanging down the board's x face.\n if (sx > 0) {\n metal.push(boxGeometry(\n [flapT, flapH, wp * 0.88],\n [halfWidth + flapT / 2 - 0.0006, yFlap, pz],\n plateColour,\n ))\n }\n // Flap over the head/tail edge, hanging down the board's z face.\n metal.push(boxGeometry(\n [wp * 0.88, flapH, flapT],\n [px, yFlap, sz * (config.length / 2 + flapT / 2 - 0.0006)],\n plateColour,\n ))\n }\n }\n\n return {\n cover: { slot: 'leather' as const, geometry: mergeColoured(cover) },\n pages: { slot: 'cloth' as const, geometry: pages },\n clasps: metal.length > 0\n ? { slot: 'brass' as const, geometry: mergeColoured(metal) }\n : undefined,\n }\n },\n }, overrides)\n}\n",
|
|
1133
|
+
"hash": "eb26d423493c57d1d76fc29b528b39b3451a4f5e2001dd236351c238f230a188"
|
|
1134
1134
|
}
|
|
1135
1135
|
],
|
|
1136
1136
|
"meta": {
|
|
@@ -1219,8 +1219,8 @@
|
|
|
1219
1219
|
{
|
|
1220
1220
|
"path": "models/linen-sack/model.ts",
|
|
1221
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": "
|
|
1222
|
+
"content": "/**\n * @medieval-kit/linen-sack\n *\n * Grain sack with its mouth tied off by a cord. Storeroom, mill, market stall,\n * cart — one of the kit's most widely placeable pieces.\n *\n * What makes a sack a sack is that it TAKES THE SHAPE of what is inside it.\n * Five earlier versions failed on exactly this:\n *\n * 1. The first profile put its widest point a quarter of the way up and\n * narrowed to the neck — a vase.\n * 2. The second fixed the body but kept a straight parallel-sided neck with a\n * flat cylindrical cap above the cord: a stoppered jug.\n * 3. The third grew a gathered fan, but its points were matchstick-thin and\n * near vertical (a crown), and the cord recessed into a notch and vanished\n * from the silhouette.\n * 4. The fourth put the widest ring AT THE FLOOR and tapered in a straight\n * line all the way to the tie: a truncated cone. Its tie was a tall dark\n * cylinder, its petals flared to near horizontal over an exposed interior\n * floor disc, and its bottom ears dipped through the ground plane.\n * 5. The fifth (\"egg on a pillow\") scored 66: still a squat urn. Widest ring\n * at mid-height and nearly as wide as tall, a hard step from the dome into\n * a bottle neck, a low outward skirt ring that read as a turned plinth,\n * tapered-box bottom ears that read as free-floating tabs, a dark sliver\n * at 12 o'clock where the tail showed through the gaps between the three\n * cord wraps, petal bases visibly clear of the tuft, and one flat facet on\n * the left flank from heavy roughen on 14 segments.\n *\n * So, this pass, straight from the critique of v5:\n *\n * - PROPORTION: the body is distinctly taller than wide, about 1:1.5 width to\n * height, with the widest ring at 35% of the body height and the two rings\n * below it pulled in only slightly so the lower body stays heavy.\n * - NO NECK: the same lathe climbs continuously from the shoulder to the tie\n * through a run of gather rings (0.72 → 0.955 of the body, radii falling\n * 0.72W → 0.17W). On top of that, a displacement pass scales vertices\n * radially by cos(8·angle + phase) with the phase held constant up the\n * stack, ramping in above 60% of the body height, so alternate vertices\n * pull in and out and the creases converge on the twine. The amplitude\n * eases off again just under the cord so the pleated cloth (plus roughen)\n * stays INSIDE the twine wraps.\n * - PILLOW BASE: no skirt ring, no rim. The lowest ring is wider than the one\n * above it and the bottom sits flat on the floor. The corner ears are NOT\n * separate boxes: the same displacement pass pushes the bottom 12% of the\n * body radially outward (and a few millimetres down, clamped at the floor)\n * in `ears` sharpened cosine lobes, so the corners grow out of the cloth\n * and share edges with the side faces by construction. `ears` is the lobe\n * count of that displacement, not a mesh-piece count.\n * - The displacement is a pure function of vertex POSITION (like roughen), so\n * the co-located copies in this non-indexed geometry all move together and\n * the surface stays closed.\n * - The three twine wraps are taller than their spacing so they overlap and\n * there is no see-through gap between turns (v5's 12 o'clock sliver was the\n * dark tail visible through those gaps). The tail still roots inside the\n * cinched cloth and drapes down the gather at the gather's own slope.\n * - Petal roots sit lower and closer to the axis (0.35·cinchR at 1.5% of the\n * height above the cord) so they emerge from the tuft, never float over it.\n * - 16 lathe segments and milder roughen, against v5's single flat facet.\n */\nimport type { BufferGeometry } from 'three'\n\nimport {\n bandGeometry,\n createKitModel,\n createTinter,\n jitter,\n latheGeometry,\n mergeColoured,\n roughenGeometry,\n taperedBoxGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface LinenSackConfig {\n /** Total height (metres). */\n readonly height: number\n /** Radius at the widest point (metres). */\n readonly radius: number\n /** How full it is. 1 = packed solid, 0.4 = half empty and slumped. */\n readonly fill: number\n /** Cloth left above the mouth, as a fraction of the height. */\n readonly collar: number\n /** Gathered ears at the bottom. */\n readonly ears: number\n readonly seed: number\n}\n\nexport const linenSackDefaults: LinenSackConfig = {\n height: 0.52,\n radius: 0.16,\n fill: 0.85,\n // Enough neck below the fan for the tie to actually show.\n collar: 0.12,\n // Two opposing corners, like the reference: the seam gathers at the ends of\n // the flat-sewn bottom, not all the way round.\n ears: 2,\n seed: 53,\n}\n\nexport type LinenSackParts = 'body' | 'collar' | 'cord'\n\nexport function createModel(overrides: Partial<LinenSackConfig> = {}) {\n return createKitModel<LinenSackConfig, 'cloth', LinenSackParts>({\n id: 'linen-sack',\n defaults: linenSackDefaults,\n slots: ['cloth'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n // Floored: h and radius are divisors downstream, and a zero here would\n // NaN every vertex (and, through the occlusion bake, every colour).\n const h = Math.max(0.05, config.height)\n const radius = Math.max(0.02, config.radius)\n const half = h / 2\n const fill = Math.max(0.15, Math.min(1, config.fill))\n const collarF = Math.max(0.05, Math.min(0.4, config.collar))\n // Where the cord sits: high on a full sack, low on an empty one.\n const neckY = half - h * collarF\n // Body height, floor to cord, and a helper to place rings on it.\n const bodyH = neckY + half\n const by = (f: number): number => -half + bodyH * f\n // The tie pinches the gathered cloth down to this. Everything near the\n // cord keys off it: the cloth must come in UNDER the wraps or the cord\n // vanishes into a notch.\n const cinchR = radius * 0.18\n // Outer radius of the twine wraps: just proud of the cinched cloth.\n const cordR = cinchR + radius * 0.065\n\n // --- Body (floor to cord, one piece) ---------------------------------\n // An under-filled sack both drops and spreads sideways. Kept narrow\n // enough that the whole sack stands about 1.5x taller than wide.\n const wide = radius * (0.8 + fill * 0.26)\n // Widest ring at 35% of the body, the two rings below it pulled in only\n // slightly so the lower body stays heavy, the lowest ring WIDER than\n // the one above it so the base reads as cloth puddling, and a\n // continuous run of gather rings climbing from the shoulder to the\n // cord with no step and no neck.\n const profile: Level[] = [\n { y: by(0.0), radius: wide * 0.94 },\n { y: by(0.045), radius: wide * 0.92 },\n { y: by(0.14), radius: wide * 0.96 },\n { y: by(0.35), radius: wide }, // widest, 35% up\n { y: by(0.5), radius: wide * 0.975 },\n { y: by(0.62), radius: wide * 0.925 },\n { y: by(0.72), radius: wide * 0.83 },\n { y: by(0.78), radius: wide * 0.7 },\n { y: by(0.84), radius: wide * 0.52 },\n { y: by(0.89), radius: wide * 0.36 },\n { y: by(0.925), radius: wide * 0.25 },\n { y: by(0.955), radius: wide * 0.17 },\n { y: neckY - h * 0.013, radius: cinchR * 1.06 },\n { y: neckY, radius: cinchR },\n ]\n const body = latheGeometry(profile, 16, [0, 0, 0], tint('cloth', -0.04, 1.2), {\n colourTop: tint('cloth', 0.05, 1.2),\n })\n\n // --- Displacement pass: drape pleats and base corners -----------------\n // Both are pure functions of vertex position, so every co-located copy\n // of a corner moves identically and the surface stays closed (same\n // trick roughenGeometry relies on).\n const earN = Math.max(0, Math.round(config.ears))\n const earPhase = 0.7 + jitter(random, 0.18)\n const pleatPhase = random() * Math.PI * 2\n // Pleat depth: ramps in above 60% of the body, peaks at 85%, then eases\n // off approaching the cord so pleats + roughen stay inside the wraps.\n const pleatAmp = (f: number): number => {\n if (f < 0.6) return 0\n if (f < 0.85) return 0.13 * ((f - 0.6) / 0.25)\n return 0.13 * (1 - 0.7 * ((f - 0.85) / 0.15))\n }\n {\n const position = body.getAttribute('position')\n for (let i = 0; i < position.count; i += 1) {\n const x = position.getX(i)\n const y = position.getY(i)\n const z = position.getZ(i)\n const r = Math.hypot(x, z)\n if (r < 1e-6) continue\n const f = (y + half) / bodyH\n const angle = Math.atan2(x, z)\n let scale = 1\n let drop = 0\n // Drape creases: alternate vertices in and out (16 segments, 8\n // lobes: exact alternation), phase constant up the stack so the\n // creases run vertically and converge on the twine.\n scale += pleatAmp(f) * Math.cos(8 * angle + pleatPhase)\n // Base corners: sharpened cosine lobes push the bottom of the\n // cloth out and slightly down. Grown from the body, not appended.\n if (f < 0.12 && earN > 0) {\n const t = 1 - f / 0.12\n const lobe = Math.max(0, Math.cos(earN * (angle - earPhase)))\n const sharp = lobe * lobe * lobe\n scale += 0.2 * t * sharp\n drop = h * 0.008 * t * sharp\n }\n position.setXYZ(i, x * scale, y - drop, z * scale)\n }\n position.needsUpdate = true\n }\n\n // Cloth is not rigid: the surface break-up IS the texture. Milder than\n // v5 (whose 0.038 on 14 segments flattened a whole facet) and small\n // enough that the cinched cloth cannot spike through the twine.\n roughenGeometry(body, radius * 0.03, { salt: 21, scaleY: 0.7 })\n // The floor verts must not dig through the ground plane: clamp the\n // roughened bottom flat, which is also what a sack of grain does.\n {\n const position = body.getAttribute('position')\n for (let i = 0; i < position.count; i += 1) {\n if (position.getY(i) < -half) position.setY(i, -half)\n }\n position.needsUpdate = true\n body.computeVertexNormals()\n }\n\n // --- Gathered crown above the tie ------------------------------------\n // A CLOSED gathered cone rises out of the tie and caps itself, so every\n // gap between petals shows cloth, never the background or an interior\n // floor. The whole crown stays within about 1.2x the cord's diameter.\n const collarPieces: BufferGeometry[] = []\n const core: Level[] = [\n { y: neckY - h * 0.02, radius: cinchR * 0.8 }, // ends INSIDE the body\n { y: neckY + h * 0.024, radius: cinchR * 1.05 }, // clears the top wrap\n { y: neckY + h * 0.052, radius: cinchR * 1.35 },\n { y: neckY + h * 0.075, radius: cinchR * 1.6 },\n ]\n const tuft = latheGeometry(core, 10, [0, 0, 0], tint('cloth', 0.03, 1.2), {\n colourTop: tint('cloth', 0.09, 1.2),\n })\n collarPieces.push(tuft)\n\n // Pleats: widest at the TIP and pinched at the tie, leaning about 40\n // degrees. Roots sit LOW and CLOSE to the axis so every flap visibly\n // emerges from the tuft cloth (v5's roots were higher and wider and\n // read as floating over the rim).\n const points = 8\n for (let i = 0; i < points; i += 1) {\n const angle = (i / points) * Math.PI * 2 + jitter(random, 0.12)\n const tall = h * (0.055 + random() * 0.028)\n const lean = 0.55 + random() * 0.2\n const widthTip = cinchR * (1.25 + random() * 0.35)\n const wedge = taperedBoxGeometry(\n [cinchR * 0.6, cinchR * 0.5], // pinched at the tie\n [widthTip, cinchR * 0.3], // widest at the tip\n tall,\n [0, tall / 2, 0], // base at the origin: build, THEN orient\n tint('cloth', 0.02, 1.3),\n tint('cloth', 0.1, 1.3),\n )\n wedge.rotateZ(jitter(random, 0.08)) // slight twist so no two match\n wedge.rotateX(lean) // top leans towards local +Z\n wedge.rotateY(angle) // +Z now points along (sin a, cos a)\n wedge.translate(\n Math.sin(angle) * cinchR * 0.35,\n neckY + h * 0.015,\n Math.cos(angle) * cinchR * 0.35,\n )\n collarPieces.push(wedge)\n }\n const collar = mergeColoured(collarPieces)\n roughenGeometry(collar, radius * 0.024, { salt: 22, scaleY: 0.8 })\n\n // --- Cord: thin stacked wraps and a loose tail ------------------------\n // Twine, not a hoop: three thin turns close to the body's own value,\n // standing only slightly proud of the cinched cloth. Each wrap is\n // TALLER than the wrap spacing so the turns overlap: v5 left 0.0055h\n // gaps between them and the dark tail behind showed through as a\n // vertical sliver at 12 o'clock.\n const cordRope = tint('cloth', -0.08, 0.7)\n const cordPieces: BufferGeometry[] = []\n const wrapYs = [neckY - h * 0.011, neckY, neckY + h * 0.011]\n for (const wrapY of wrapYs) {\n cordPieces.push(bandGeometry(\n cordR + jitter(random, radius * 0.006),\n wrapY,\n h * 0.015,\n radius * 0.05,\n 10,\n tint('cloth', -0.08, 0.7),\n { inner: true },\n ))\n }\n // The loose tail: emerges from under the bottom wrap and drapes down\n // the gather. The gather is CONCAVE (the flare accelerates downward),\n // so a straight chord leaned at the average slope stands clear of the\n // cloth mid-way and reads as a floating stick — which is exactly what\n // the first render of this pass showed. Instead it leans only slightly\n // (0.35 rad), hugging the narrow column just under the tie, and its far\n // end lands INSIDE the widening cloth lower down, so it reads as a rope\n // end lying on the gather and disappearing into a crease.\n const tailLen = h * 0.1\n const tailLean = 0.35\n const tail = taperedBoxGeometry(\n [radius * 0.055, radius * 0.055],\n [radius * 0.04, radius * 0.04],\n tailLen,\n [0, -tailLen / 2, 0], // hangs DOWN from the origin\n cordRope,\n )\n const tailA = 0.7 + jitter(random, 0.3)\n tail.rotateX(-tailLean) // down and outward along local +Z\n tail.rotateY(tailA)\n tail.translate(\n Math.sin(tailA) * cordR * 0.6,\n neckY - h * 0.01,\n Math.cos(tailA) * cordR * 0.6,\n )\n cordPieces.push(tail)\n\n return {\n body: { slot: 'cloth' as const, geometry: mergeColoured([body]) },\n collar: { slot: 'cloth' as const, geometry: collar },\n cord: { slot: 'cloth' as const, geometry: mergeColoured(cordPieces) },\n }\n },\n }, overrides)\n}\n",
|
|
1223
|
+
"hash": "2155cf701db248b35c5974d8da4854713c255b5c3b8c91cd96da86b78c5cb3d6"
|
|
1224
1224
|
}
|
|
1225
1225
|
],
|
|
1226
1226
|
"meta": {
|
|
@@ -1306,8 +1306,8 @@
|
|
|
1306
1306
|
{
|
|
1307
1307
|
"path": "models/log-pile/model.ts",
|
|
1308
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": "
|
|
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 // Lifted well above the palette oak. That constant is weathered structural\n // timber, grey and dropped in value, which is right for a fence or a\n // ladder and wrong for this: a woodpile is freshly split, and the split\n // faces have never been outside long enough to go grey. Measured against\n // the reference the pile wants about 0.13 more than the kit default.\n tint.offsetHSL(jitter(random, 0.016), jitter(random, 0.07), 0.07 + 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.3 + 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": "026c2e2dc3e973b21a55ad86a16539819ba979938b55540028cadda41a2c153f"
|
|
1311
1311
|
}
|
|
1312
1312
|
],
|
|
1313
1313
|
"meta": {
|
|
@@ -1398,8 +1398,8 @@
|
|
|
1398
1398
|
{
|
|
1399
1399
|
"path": "models/market-stall/model.ts",
|
|
1400
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": "
|
|
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 /**\r\n * Weathered, like the grindstone's frame: a stall lives outdoors.\r\n *\r\n * The weathering is now almost entirely in the saturation, because the\r\n * palette does the rest. This carried a -0.08 lift from when `oak` sat at\r\n * linear lightness 0.153; the constant was measured against its references\r\n * and moved to 0.067, and -0.08 is more than the whole colour. Every part\r\n * went negative, every part clamped to the tinter floor, and the counter,\r\n * the trestle and the posts came out the same flat value with the\r\n * variation between them gone. Measured against the reference the stall\r\n * was then 0.13 too dark.\r\n */\r\n const timber = (lift = 0): Color => {\r\n const c = tint('oak', lift * 0.3, 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": "ef021f74faa066c3f3fa8d65adac32361b9d4e06e3e1369e6e7f90650c2997b7"
|
|
1403
1403
|
}
|
|
1404
1404
|
],
|
|
1405
1405
|
"meta": {
|
|
@@ -1495,8 +1495,8 @@
|
|
|
1495
1495
|
{
|
|
1496
1496
|
"path": "models/oak-tankard/model.ts",
|
|
1497
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": "
|
|
1498
|
+
"content": "/**\n * @medieval-kit/oak-tankard\n *\n * Oak tankard: the barrel at palm size. Same stave language, same iron hoop,\n * only the scale changes — the example that shows how far the kit carries its\n * own vocabulary.\n *\n * Do NOT look for a glass mug: a medieval drinking vessel was wood, leather or\n * pewter. A clear glass would be a period error, and it would also look foreign\n * next to the rest of the kit.\n *\n * LESSONS FROM THE DEAD VERSIONS, in order:\n *\n * 1. A round rod handle looked like a modern mug. A tankard handle is a flat\n * strap or carved grip fixed to the body at two points.\n * 2. A flat box strap through `bendGeometry` with a hand-derived offset ended\n * up buried in the wall: the offset formula did not describe what\n * `bendGeometry` actually does. Curves are specified by where their ends\n * land and how far the belly stands off — `arcBarGeometry` takes exactly\n * that.\n * 3. Even a correct arc fails if it HUGS the wall. The v3 handle stood 7 mm\n * off a 47 mm body: no daylight, and the occlusion bake painted it black.\n * A finger window must stay open between strap and wall.\n * 4. The body was near straight and slightly flared at the RIM, which is a\n * bucket's profile, not a tankard's. The reference (and every surviving\n * coopered mug) is widest at the BASE and narrows to the rim.\n * 5. Per-LEVEL radius jitter gave one stave a bulge at mid-height that read\n * as two coincident panels with a shading break between them. Jitter is\n * now one factor per stave, applied to every level, so each stave is a\n * single clean board.\n * 6. The 3.5%-of-step seam gap opened visible dark slots at the rim. The gap\n * only exists to keep adjacent side faces off a shared plane; 0.6% of a\n * step (~0.2 mm) does that invisibly.\n * 7. Two hoops left a bare band of wood under the lower one. Three hoops,\n * seated where the reference puts them: just under the rim, across the\n * lower middle, and flush with the bottom edge of the staves. Each hoop is\n * rotated half a step so its facets stay concentric with the stave facets\n * instead of crossing them at the seams.\n *\n * PASS 2 (blind critique scored v5 at 66; silhouette was the worst axis):\n *\n * 8. The -0.15 taper at 1.55 height-to-width still read as a straight can.\n * Now rim ≈ 0.82 × base (taper -0.22) and the wood column is ~1.3 × the\n * base diameter (height 0.148): the reference's bucket-like truncated\n * cone, widest element at the bottom band. The mid-level bulge is gone —\n * the critic asked for a linear taper and the 1.004 bulge fought it.\n * 9. The chunky square-section strap on two protruding ears read as a jug\n * handle on lugs, and the ears collected three modelling errors (lug\n * through the bottom band, strap tips poking past the lug caps, hard\n * seams at the wall). The ears are DELETED. The strap is now a SLAB:\n * arcBarGeometry gives a square section, so the geometry is scaled 2.5×\n * along the pre-rotation Z (the tangential direction after mounting) —\n * wide across the body, shallow radially, like the reference. Its ends\n * are buried in two thin flush PADS that hug the wall (tall one just\n * under the top band, short foot about a stave-width above the bottom\n * band), and the apex was pulled in from 0.6 R to 0.45 R off the wall.\n * 10. The palette's `iron` (0x40464d) is steel-blue and read as cold metal\n * against the wood. The hoops get a fixed offsetHSL toward the wood's\n * hue (dark warm oxide, ~21°) applied to the tinted colour — the tinter\n * cannot shift hue that far and core is shared, so the shift lives here.\n * Four rivet studs per hoop sit on the band facets (never at a = 0 where\n * the handle mounts), the reference's most memorable close detail.\n */\nimport { Color, type BufferGeometry } from 'three'\n\nimport {\n arcBarGeometry,\n bandGeometry,\n chamferedBoxGeometry,\n createKitModel,\n createTinter,\n headGeometry,\n jitter,\n mergeColoured,\n staveGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface OakTankardConfig {\n /** Height (metres). */\n readonly height: number\n /** Rim radius (metres). */\n readonly radius: number\n /** Narrowing towards the base. 0 = cylinder; negative widens the base. */\n readonly taper: number\n /** Number of staves. */\n readonly staveCount: number\n /** Number of iron hoops. */\n readonly hoopCount: number\n /** Whether there is a handle (0/1). */\n readonly handle: number\n readonly seed: number\n}\n\nexport const oakTankardDefaults: OakTankardConfig = {\n // Squat: the wood column is ~1.3 × the base diameter (lesson 8). At 0.17\n // the tankard was a can; the reference is a bucket.\n height: 0.148,\n radius: 0.047,\n // Negative on purpose: widest at the base, rim ≈ 0.82 × base (lesson 8).\n taper: -0.22,\n staveCount: 10,\n hoopCount: 3,\n handle: 1,\n seed: 61,\n}\n\nexport type OakTankardParts = 'staves' | 'base' | 'hoops' | 'handle'\n\nexport function createModel(overrides: Partial<OakTankardConfig> = {}) {\n return createKitModel<OakTankardConfig, 'oak' | 'iron', OakTankardParts>({\n id: 'oak-tankard',\n defaults: oakTankardDefaults,\n slots: ['oak', 'iron'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n /**\n * The tankard's own oak, warmer than the palette entry.\n *\n * The palette is pitched for weathered structural timber, which is what\n * most of the kit is made of, and against their own references the crate,\n * the barrel and the chest all land within 0.07 on saturation. This one\n * measured 0.23 under. Its reference is a turned drinking vessel that has\n * spent its life indoors being handled, not a fence in the rain, and it\n * is genuinely a richer wood. Same shape of exception as the ladder, which\n * needs to go the other way and paler.\n */\n const oak = (lift = 0, spread = 1): Color => {\n const c = tint('oak', lift, spread)\n const hsl = { h: 0, s: 0, l: 0 }\n c.getHSL(hsl)\n c.setHSL(hsl.h, Math.min(1, hsl.s * 1.95), hsl.l * 0.86)\n return c\n }\n // Floored: height and radius sit under divisions and a patched zero\n // would spray NaN through the occlusion bake (kit trap 2).\n const height = Math.max(0.04, config.height)\n const radius = Math.max(0.012, config.radius)\n const half = height / 2\n const staves = Math.max(5, Math.round(config.staveCount))\n const thickness = radius * 0.13\n const bottomRadius = Math.max(radius * 0.6, radius * (1 - config.taper))\n // Linear taper (lesson 8): every stave face is a clean trapezoid. The\n // mid level exists only so radiusAt stays a simple two-piece lerp.\n const midRadius = (bottomRadius + radius) / 2\n\n /** Nominal wall radius (at the stave seams) at height y. */\n const radiusAt = (y: number): number =>\n y < 0\n ? bottomRadius + ((midRadius - bottomRadius) * (y + half)) / half\n : midRadius + ((radius - midRadius) * y) / half\n\n // --- Staves -----------------------------------------------------------\n // Offset by half a step so a stave FACE (not a seam) sits at a = 0,\n // where the handle mounts: the pads must land on wood, not on a slit.\n const stavePieces: BufferGeometry[] = []\n const step = (Math.PI * 2) / staves\n // Just enough gap to keep adjacent side faces off a shared plane.\n // Anything visible from arm's length is too much (lesson 6).\n const gap = step * 0.006\n for (let i = 0; i < staves; i += 1) {\n // ONE jitter factor per stave, applied to every level (lesson 5).\n const k = 1 + jitter(random, 0.008)\n const levels: Level[] = [\n { y: -half, radius: bottomRadius * k },\n { y: 0, radius: midRadius * k },\n { y: half, radius: radius * k },\n ]\n const start = (i - 0.5) * step\n stavePieces.push(staveGeometry(\n levels, start + gap, start + step - gap, thickness,\n oak(jitter(random, 0.05)),\n ))\n }\n\n // --- Base --------------------------------------------------------------\n // A disc seated inside the staves; its edge stays buried in the wall\n // even at the flattest chord of the jittered stave polygon.\n const base = headGeometry(\n bottomRadius - thickness * 0.8, -half + height * 0.055,\n staves, 'up', oak(0.06), 3, 0.06,\n )\n\n // --- Hoops --------------------------------------------------------------\n // Warm dark iron, not the palette's steel-blue (lesson 10). The tinted\n // colour is rotated to the wood's side of the wheel; the tinter's own\n // jitter survives underneath the fixed offset.\n const hoops = Math.max(0, Math.round(config.hoopCount))\n const hoopHeight = height * 0.078\n const hoopThickness = radius * 0.09\n const hoopYs: number[] = []\n if (hoops === 3) {\n // Flush with the bottom edge, across the lower middle (below true\n // centre so the lower zone reads shorter than the upper), and just\n // under the rim with a bare wood lip above (lesson 7).\n hoopYs.push(\n -half + hoopHeight / 2 + 0.0006,\n -half + height * 0.40,\n half - height * 0.105,\n )\n } else {\n for (let i = 0; i < hoops; i += 1) {\n const t = hoops === 1 ? 0.5 : 0.05 + (i / (hoops - 1)) * 0.85\n hoopYs.push(-half + height * t)\n }\n }\n const hoopPieces: BufferGeometry[] = []\n const studSize = radius * 0.105\n for (const y of hoopYs) {\n const bandR = radiusAt(y) + 0.0026\n hoopPieces.push(bandGeometry(\n bandR, y, hoopHeight, hoopThickness, staves,\n tint('iron', jitter(random, 0.05), 0.6).offsetHSL(-0.53, 0.13, -0.04),\n ))\n // Rivet studs on the band facets (lesson 10). Facet centres sit at\n // (i + 0.5)·step here; the later half-step rotation of the whole\n // hoops part carries them onto the stave centres, so odd i keeps\n // them clear of the handle's stave at a = 0 and symmetric about it.\n const facetZ = bandR * Math.cos(step / 2)\n for (const i of [1, 3, 5, 7]) {\n const stud = chamferedBoxGeometry(\n [studSize, studSize], [studSize, studSize], studSize, studSize * 0.28,\n [0, 0, facetZ + studSize * 0.08],\n tint('iron', 0.025, 0.5).offsetHSL(-0.53, 0.13, -0.02),\n )\n stud.rotateY((i + 0.5) * step)\n stud.translate(0, y, 0)\n hoopPieces.push(stud)\n }\n }\n // Half a step aligns the hoop facets with the stave facets; unrotated,\n // the hoop's flats cross the stave seams and pinch to nothing there.\n const hoopGeometry = hoopPieces.length > 0\n ? mergeColoured(hoopPieces)\n : undefined\n hoopGeometry?.rotateY(step / 2)\n\n // --- Handle --------------------------------------------------------------\n // A broad flat slab, not a rod and not a strap on lugs (lessons 1, 9):\n // wide across the body's tangent, shallow radially, ends buried in two\n // flush pads that hug the wall.\n let handle: BufferGeometry | undefined\n if (config.handle >= 0.5) {\n const barD = radius * 0.22 // radial depth of the slab\n const barW = barD * 2.5 // tangential width (the flat face)\n /** Outer wall surface at the stave FACE centre (a = 0), not the seam. */\n const wallZ = (y: number): number => radiusAt(y) * Math.cos(step / 2)\n\n const topBandBottom = half - height * 0.105 - hoopHeight / 2\n const bottomBandTop = -half + hoopHeight + 0.0006\n const staveWidth = (Math.PI * 2 * bottomRadius) / staves\n\n // Tall pad immediately under the top band; short foot about one\n // stave-width above the bottom band (the critic's placement, and it\n // keeps the foot out of the bottom band — v5's clearest error).\n const padTopH = radius * 0.6\n const padBottomH = radius * 0.42\n const yTop = topBandBottom - 0.0012 - padTopH / 2\n const yBottom = bottomBandTop + staveWidth * 0.9 + padBottomH / 2\n // The strap ends sit OFF the pad centres — high on the top pad, low\n // on the foot — so the arms cross the pad faces where the reference's\n // do, instead of leaving a blocky shelf of pad above the top arm.\n const yTopEnd = yTop + padTopH * 0.15\n const yBottomEnd = yBottom - padBottomH * 0.1\n const yCentre = (yTopEnd + yBottomEnd) / 2\n // Floored: a degenerate patched height can push the pads past each\n // other, and this feeds a division and an asin below (trap 2).\n const spanHalf = Math.max(radius * 0.15, (yTopEnd - yBottomEnd) / 2)\n\n // One shared colour for strap and pads so the handle reads as one\n // carved piece, not a strap pinned between blocks. Slightly lighter\n // than the body; the underside quads live in shadow.\n const handleColour = oak(0.04)\n\n // Pads stay just proud of the wall (~half the slab depth): the first\n // pass of this pad idea stood 7 mm out and the critic-shaped blocks\n // came straight back. The inner face stays inside the stave solid\n // without breaking through to the inside of the mug.\n const padW = barW * 1.05\n const padD = radius * 0.21\n const pads = ([[yTop, padTopH], [yBottom, padBottomH]] as const).map(\n ([y, h]) => chamferedBoxGeometry(\n [padW, padD], [padW, padD], h, 0.0025,\n [0, y, wallZ(y) - 0.0005],\n handleColour,\n ),\n )\n\n // The arc, specified by where the ends land (buried in the pads and\n // the wall — anchored to the TOP wall radius, the tighter of the\n // two under the taper) and where the outer apex sits: 0.45 R off\n // the wall (lesson 9) — close enough to read as one object, far\n // enough for daylight (lesson 3).\n const endZ = (wallZ(yTopEnd) + wallZ(yBottomEnd)) / 2 + 0.0005\n const apexOuter = wallZ(yCentre) + radius * 0.45\n const bellyCentre = apexOuter - barD / 2\n const depth = Math.max(radius * 0.06, bellyCentre - endZ)\n const arcRadius = (spanHalf * spanHalf + depth * depth) / (2 * depth)\n const theta = Math.atan2(spanHalf, arcRadius - depth)\n // THREE segments on purpose: the reference's outer edge is straight\n // and angular with a hard top corner, not a smooth bow. Three chords\n // give a near-vertical outer face and two straight diagonal arms.\n const strap = arcBarGeometry(\n arcRadius, barD, -theta, theta, 3,\n [0, 0, 0], handleColour,\n )\n // Built in the XY plane with a square section; the pre-rotation Z is\n // the section direction that becomes tangential to the body, so\n // scaling it turns the square bar into the reference's flat slab.\n strap.scale(1, 1, barW / barD)\n // -90° about Y carries +X into +Z, standing the arc beside the body,\n // then it is carried out to the wall.\n strap.rotateY(-Math.PI / 2)\n // The body tapers but the arc's two ends share one z: a small lean\n // about X follows the wall, so the lower arm emerges from its pad at\n // the same depth as the upper one instead of sinking into the\n // thicker base first.\n const lean = Math.asin(Math.min(0.4, Math.max(-0.4,\n (wallZ(yTopEnd) - wallZ(yBottomEnd)) / (2 * spanHalf))))\n strap.rotateX(lean)\n strap.translate(0, yCentre, bellyCentre - arcRadius)\n handle = mergeColoured([strap, ...pads])\n }\n\n return {\n staves: { slot: 'oak' as const, geometry: mergeColoured(stavePieces) },\n base: { slot: 'oak' as const, geometry: mergeColoured([base]) },\n hoops: hoopGeometry\n ? { slot: 'iron' as const, geometry: hoopGeometry }\n : undefined,\n handle: handle ? { slot: 'oak' as const, geometry: handle } : undefined,\n }\n },\n }, overrides)\n}\n",
|
|
1499
|
+
"hash": "edd139f5fbd7dac770adb87100e2e467b06b2566f635557456b01c44a7e4f94f"
|
|
1500
1500
|
}
|
|
1501
1501
|
],
|
|
1502
1502
|
"meta": {
|
|
@@ -1591,8 +1591,8 @@
|
|
|
1591
1591
|
{
|
|
1592
1592
|
"path": "models/pitch-torch/model.ts",
|
|
1593
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": "
|
|
1594
|
+
"content": "/**\n * @medieval-kit/pitch-torch\n *\n * Pitch torch: a stout stick, pitch-soaked cloth bound onto its end with cord,\n * a flame above. This was exactly the period's lighting — candles were\n * expensive, torches were free.\n *\n * THIRD VERSION. What the earlier ones got wrong, so it is not tried again:\n *\n * - v1 flame tapered base-to-tip on one curve and rendered as a ROCKET NOSE.\n * A flame has a swollen base, a waist, and a torn tip. The waist stays.\n * - v2 head was a barrel widest at mid-height with a FLAT OPEN RIM, and its\n * colourTop was charHot. In render that hot-coloured flat cap read as a\n * stray detached flame-coloured quad lying across the rim. The head is now\n * a teardrop widest in its upper third closing to a faceted dome, and the\n * head carries char colours only. Nothing on the head is flame-coloured.\n * - v2 flame ended its lathe at radius 0.22r with a capped fan, then\n * roughened at 0.30r amplitude. The cap centre and its ring hash\n * differently, which split the tip into a V notch, and the amplitude was\n * enough to fold the waist into a self-intersection. The profile now ends\n * at radius 0 (all tip triangles share one vertex position, so the\n * position hash moves them together and no notch can open) and the\n * amplitude is halved.\n * - v2 had NO BINDING, so the bulb met the shaft as a bare cone point and\n * the object read as a moulded club. The binding band of cord turns is the\n * landmark that names a pitch torch, and it gets a zone about as long as\n * the head, exactly as the reference gives it.\n *\n * The flame flickers via `update()` and the source of that flicker is NOT\n * randomness but a sum of sines of elapsed time: randomness would break\n * determinism, a single sine ticks like a metronome, and a self-driving timer\n * would violate \"the consumer owns the loop\". The flame also EMITS NO LIGHT;\n * a consumer who wants the torch to light the scene attaches a PointLight to\n * `parts.flame.anchor`.\n *\n * The binding turn count is DERIVED (zone length over cord pitch), not a\n * config integer: the zone fraction is the config surface, and any change to\n * it moves the geometry continuously.\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 wood-plus-head length, butt to dome top, excluding flame (metres). */\n readonly length: number\n /** Shaft radius (metres). Near-constant along the shaft; a torch stick is not tapered. */\n readonly radius: number\n /** Length of the pitched head bulb, as a fraction of `length`. */\n readonly wrapLength: number\n /** Length of the cord binding zone, as a fraction of the head length. */\n readonly binding: number\n /** Flame height, as a fraction of the head 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 // Longer and thicker than v2: the critique measured the shaft at a fifth of\n // the head's width where the reference is nearer a third, and short. The\n // exposed shaft below the binding now comes to about 60% of the total.\n length: 0.72,\n radius: 0.024,\n wrapLength: 0.22,\n binding: 0.8,\n // The flame stays LOWER than the head is long. A pitch torch burns with a\n // 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 r = Math.max(config.radius, 0.004) // floored: derived divisions below\n const half = config.length / 2\n // Head bulb zone, then the cord binding zone immediately below it.\n const headLength = Math.max(config.length * config.wrapLength, r * 3)\n const headBase = half - headLength\n const bindingLength = Math.max(headLength * config.binding, r * 2)\n const bindingBase = headBase - bindingLength\n\n // --- Shaft -----------------------------------------------------------\n // Near-constant diameter with only a whisper of waver: a torch stick is\n // a straight-cut stave, not a tapering tool handle. The butt closes with\n // a short chamfer so the end reads slightly rounded, and the top ends\n // INSIDE the bulb.\n const shaftTop = headBase + headLength * 0.35\n const shaftLevels: Level[] = [\n { y: -half, radius: r * 0.58 },\n { y: -half + 0.016, radius: r * (0.96 + jitter(random, 0.03)) },\n ]\n const waverSteps = 4\n for (let i = 1; i <= waverSteps; i += 1) {\n const t = i / waverSteps\n shaftLevels.push({\n y: (-half + 0.016) + (shaftTop - (-half + 0.016)) * t,\n radius: r * (1 + jitter(random, 0.05)) * (i === waverSteps ? 0.92 : 1),\n })\n }\n const shaft = mergeColoured([latheGeometry(\n shaftLevels, 7, [0, 0, 0], tint('oak', -0.06),\n { colourTop: tint('oak', 0.02) },\n )])\n\n // --- Binding + head --------------------------------------------------\n // The binding: a stack of cord turns proud of the shaft by about a third\n // of its radius. One wavy lathe rather than stacked rings, so no pair of\n // coincident faces forms where turns touch. Turn count is derived from\n // the zone length over a cord pitch tied to the shaft radius; the\n // defaults give seven turns.\n const pitch = r * 0.75\n const turns = Math.min(12, Math.max(3, Math.round(bindingLength / pitch)))\n const turnPitch = bindingLength / turns\n const grooveR = r * 1.14\n const bindingLevels: Level[] = [{ y: bindingBase, radius: grooveR }]\n for (let k = 0; k < turns; k += 1) {\n bindingLevels.push({\n y: bindingBase + (k + 0.5) * turnPitch,\n radius: r * (1.4 + jitter(random, 0.03)),\n })\n bindingLevels.push({\n y: bindingBase + (k + 1) * turnPitch,\n radius: grooveR * (1 + jitter(random, 0.015)),\n })\n }\n // Soot creeps down the cord from the head, so the top of the binding\n // lerps darker. Both tints are separate Colors (createTinter allocates).\n const binding = latheGeometry(\n bindingLevels, 8, [0, 0, 0], tint('leather', 0.02),\n { colourTop: tint('leather', -0.13) },\n )\n\n // Two loose cord ends angling off the binding: fat end buried inside a\n // crest of the binding solid, thin tip dangling free like a lashing's\n // cut end. Built at the origin along Y, rotated, then translated; the\n // final rotateY swings each around the torch axis so they are not both\n // on the same side.\n const cordEnds = [\n { tilt: Math.PI + 0.22, len: bindingLength * 0.42, crest: turns - 2.5, swing: 0.6 },\n { tilt: Math.PI + 0.35, len: bindingLength * 0.34, crest: 2.5, swing: 3.9 },\n ].map(({ tilt, len, crest, swing }) => {\n const cord = prismGeometry(r * 0.17, r * 0.06, len, 5, [0, 0, 0], tint('leather', -0.04))\n cord.rotateZ(tilt)\n // Where the fat (local bottom) end lands after the rotation, so it can\n // be pinned to a crest of the binding.\n const fatX = (len / 2) * Math.sin(tilt)\n const fatY = -(len / 2) * Math.cos(tilt)\n const anchorY = bindingBase + Math.min(Math.max(crest, 0.5), turns - 0.5) * turnPitch\n cord.translate(r * 1.18 - fatX, anchorY - fatY, 0)\n cord.rotateY(swing)\n return cord\n })\n\n // The head: pitch-soaked rags bound into a teardrop, widest in its\n // upper third, closing to a faceted dome. Its lowest level hides inside\n // the binding's top turn, so the taper terminates in cord, not on bare\n // shaft. The char slot is used because pitch gets coated in soot — an\n // oak colour would be a lie here. No flame colour anywhere on the head.\n const lump = () => 1 + jitter(random, 0.04)\n const bulbLevels: Level[] = [\n { y: headBase - turnPitch * 0.6, radius: r * 1.1 },\n { y: headBase + headLength * 0.1, radius: r * 1.9 * lump() },\n { y: headBase + headLength * 0.3, radius: r * 2.5 * lump() },\n { y: headBase + headLength * 0.55, radius: r * 2.9 * lump() },\n { y: headBase + headLength * 0.7, radius: r * 3.0 },\n { y: headBase + headLength * 0.82, radius: r * 2.75 * lump() },\n { y: headBase + headLength * 0.92, radius: r * 2.15 * lump() },\n { y: headBase + headLength * 0.985, radius: r * 1.15 },\n { y: half, radius: r * 0.55 },\n ]\n const bulb = latheGeometry(\n bulbLevels, 8, [0, 0, 0], tint('char', 0.05),\n { colourTop: tint('char', 0.14) },\n )\n const wrap = mergeColoured([binding, ...cordEnds, bulb])\n\n // --- Flame -----------------------------------------------------------\n // Built at ITS OWN origin; the anchor sits just inside the dome. This is\n // required because the flicker drives the anchor's scale: a flame whose\n // origin is not at its base would sink into the wrap when scaled. The\n // base ring is buried well inside the dome so the roughen displacement\n // can never push it out into view.\n const flameH = Math.max(headLength * config.flameHeight, 0.02)\n const flameProfile: Level[] = [\n { y: 0, radius: r * 1.7 },\n { y: flameH * 0.15, radius: r * 2.35 },\n { y: flameH * 0.38, radius: r * 1.95 },\n { y: flameH * 0.55, radius: r * 2.05 },\n { y: flameH * 0.75, radius: r * 1.3 },\n { y: flameH * 0.9, radius: r * 0.7 },\n { y: flameH, radius: 0 }, // closes to a single shared point: no tip notch\n ]\n const outer = latheGeometry(flameProfile, 7, [0, 0, 0], tint('ember', 0.04, 0.4),\n { colourTop: tint('emberTip', 0, 0.4), capBottom: true })\n roughenGeometry(outer, r * 0.16, { salt: 7, scaleY: 1.25 })\n\n const flame = mergeColoured([\n outer,\n // Inner core: smaller and whiter. Two layers give the flame depth — a\n // single cone reads flat. Its base is NOT CAPPED: a cap was coplanar\n // with the outer base and z-fought, and it is invisible anyway.\n prismGeometry(\n r * 1.1, r * 0.1, flameH * 0.5, 5,\n [0, flameH * 0.32, 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 - headLength * 0.1, 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": "971c8a89083bda13bf68dd7a5be8034fd65946b99774acafa2ab0a1f2d541cd7"
|
|
1596
1596
|
}
|
|
1597
1597
|
],
|
|
1598
1598
|
"meta": {
|
|
@@ -1680,8 +1680,8 @@
|
|
|
1680
1680
|
{
|
|
1681
1681
|
"path": "models/post-mill/model.ts",
|
|
1682
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": "
|
|
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\n * THIRD PASS. The critique before this one scored 66 and its worst axis was\r\n * material, and the cause was arithmetic, not taste: oak's linear lightness is\r\n * about 0.067 and the tinter's default jitter is +-0.05, so every default-\r\n * spread tint call was rolling nearly the whole palette range, and lifts like\r\n * -0.12 (roof) and -0.22 (door ledges) slammed into the tinter's 0.045 floor\r\n * and came out near-black. Everything timber now goes through one local\r\n * `wood()` helper: lifts within +-0.04, spread ~0.3, and saturation halved\r\n * after tinting (safe to mutate -- the tinter returns a new Color) so the\r\n * whole mill sits in one weathered silver-grey family, roof a step LIGHTER\r\n * than the walls as weathered shingle is, and the only dark note is a new\r\n * wrought-iron canister boss at the sail crossing where the old pale bands\r\n * were. Also this pass: each sail gained a solid windboard along the leading\r\n * edge and a wider lattice so it reads as a blade rather than a bare ladder;\r\n * the hub came back to 0.70 of the body depth (perspective on the far-forward\r\n * hub is what made the critic read the arms as unequal -- they never were);\r\n * the quarter-bars got per-arm foot heights because two arms seat on the\r\n * UPPER cross-tree and two on the LOWER, and one shared foot height left the\r\n * lower pair hanging 6 cm above their tree; the buck walls now batter inwards\r\n * toward the top and the plan is longer along the ridge; and the tail ladder\r\n * steepened from 38 to about 51 degrees with its foot pulled in by a metre,\r\n * so it stops competing with the sails for silhouette.\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 WHILE TURNING: the hard limit\r\n // is a sail pointing straight down, hub height above ground minus half the\r\n // span. At 7.4 against a 6.2 m mill that came to 0.27 m, a mill about to\r\n // plough its own field. 7.0 with the hub at 0.80 of the buck leaves 0.59 m\r\n // turning and over 1.5 m in the resting X pose the model is drawn in.\r\n sailSpan: 7.0,\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 // The lattice alone at 0.17 of the length read as a bare ladder; with the\r\n // windboard alongside, 0.22 brings the whole blade to roughly a fifth of\r\n // its length, which is the reference's proportion.\r\n sailWidth: 0.22,\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 /**\r\n * Every piece of timber on this mill, one weathered family.\r\n *\r\n * Two numbers in the tinter make raw `tint('oak', ...)` calls a trap for\r\n * a model this size. Oak's LINEAR lightness is about 0.067, so the\r\n * default-spread lightness jitter of +-0.05 spans most of the palette\r\n * and painted sibling parts in unrelated tones; and any lift beyond\r\n * about -0.04 lands on the tinter's floor and reads near-black. So:\r\n * small spread, small lifts, and saturation halved after the fact --\r\n * safe, because the tinter returns a NEW Color every call -- which is\r\n * what turns brown sawn oak into the silver-grey of wood left out in\r\n * the weather for a century.\r\n */\r\n const wood = (lift = 0, spread = 0.3): Color => {\r\n const colour = tint('oak', lift, spread)\r\n const hsl = { h: 0, s: 0, l: 0 }\r\n colour.getHSL(hsl)\r\n colour.setHSL(hsl.h, hsl.s * 0.5, hsl.l)\r\n return colour\r\n }\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.27 // across the sails' axis\r\n const bodyDepth = H * 0.36 // along it: the plan is longer along the ridge\r\n // The walls batter: the reference buck is visibly wider at the sill than\r\n // at the eaves, about 15 percent. Only the width tapers -- keeping the\r\n // tail wall vertical keeps the door and the gallery flat against it.\r\n const wallTopWidth = bodyWidth * 0.87\r\n const ridge = bodyTop + H * 0.15\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 wood(lower ? -0.015 : -0.005),\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 wood(-0.005),\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 //\r\n // The foot height is PER ARM. The cross-trees are stacked, so the two\r\n // arms that run along the upper tree seat a full timber higher than the\r\n // two along the lower one; a single shared foot height put the lower\r\n // pair's feet at the upper tree's level, 6 cm above the timber that was\r\n // actually underneath them, and the critic saw the gap: a brace stopping\r\n // in mid-air just short of its cross-tree.\r\n const barFoot = treeSpan * 0.36\r\n const barHead = postTop - H * 0.075\r\n for (let i = 0; i < 4; i += 1) {\r\n const a = (i / 4) * Math.PI * 2\r\n // Arms at a = 0 and PI run along the upper (z) tree, the other two\r\n // along the lower (x) tree: the same order the trees were laid in.\r\n const onUpper = i % 2 === 0\r\n const footY = floor + treeSide * (onUpper ? 1.45 : 0.62)\r\n const barRise = barHead - footY\r\n const barLength = Math.hypot(barFoot, barRise)\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 wood(-0.01 + jitter(random, 0.008)),\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 (footY + 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 // Wide enough that it visibly projects past BOTH walls -- at 1.05 x the\r\n // body width the projection was 4 cm a side, swallowed by perspective,\r\n // and the critic read it as a block hanging off one side only.\r\n timber.push(chamferedBoxGeometry(\r\n [bodyWidth * 1.14, H * 0.09],\r\n [bodyWidth * 1.08, H * 0.08],\r\n H * 0.05,\r\n H * 0.006,\r\n [0, postTop - H * 0.012, 0],\r\n wood(0.008),\r\n ))\r\n\r\n // --- Body ----------------------------------------------------------------\r\n const shell: BufferGeometry[] = []\r\n shell.push(chamferedBoxGeometry(\r\n [bodyWidth, bodyDepth],\r\n [wallTopWidth, bodyDepth * 0.99],\r\n bodyHeight,\r\n H * 0.008,\r\n [0, bodyBottom + bodyHeight / 2, 0],\r\n wood(0.012, 0.25),\r\n wood(0, 0.25),\r\n ))\r\n\r\n // Plank language. The reference wall is mostly vertical board lines, and\r\n // a bare box carries none of that, which the critique named second after\r\n // the palette. Vertex colour cannot stripe a single box, so the language\r\n // is battens: proud vertical strips on both side walls, leaned to the\r\n // same batter as the wall face so they lie along it rather than pulling\r\n // away from it towards the eave. Their outer faces are parallel to the\r\n // wall's, never in its plane.\r\n const wallLean = Math.atan2((bodyWidth - wallTopWidth) / 2, bodyHeight)\r\n const wallMidX = (bodyWidth + wallTopWidth) / 4\r\n for (const side of [-1, 1] as const) {\r\n for (let b = 0; b < 5; b += 1) {\r\n const z = ((b + 0.5) / 5 - 0.5) * bodyDepth * 0.88\r\n const batten = chamferedBoxGeometry(\r\n [H * 0.012, H * 0.046],\r\n [H * 0.01, H * 0.04],\r\n bodyHeight * 0.9,\r\n H * 0.003,\r\n [0, 0, 0],\r\n wood(-0.007 + jitter(random, 0.006), 0.25),\r\n )\r\n batten.rotateZ(side * wallLean)\r\n batten.translate(side * (wallMidX + H * 0.003), bodyBottom + bodyHeight / 2, z)\r\n shell.push(batten)\r\n }\r\n }\r\n\r\n // Roof: a solid gable wedge closing the top of the buck, two boarded\r\n // pitch slabs lying on its slopes, and a ridge board over the apex.\r\n //\r\n // The wedge is this pass's fix. The roof used to be ONLY the two thin\r\n // slabs, leaned over a flat-topped box: from anywhere above eave height\r\n // the buck's bare top face showed between them, and the triangular\r\n // gable under each end of the ridge was open sky. The critic read the\r\n // result as two disjoint roof planes at different heights with a gap at\r\n // the gable, and the critic was right. A pitched roof is a solid, not\r\n // two planes; the wedge fills wall-top to ridge, so the gable ends are\r\n // boarded and there is nothing to see between the pitches.\r\n const roofRise = ridge - bodyTop\r\n // Sunk 3 cm into the buck so its bottom face hides inside that solid\r\n // instead of sharing the plane of the buck's top face, and inset a\r\n // little so its gable faces do not share the walls' planes either.\r\n const wedgeSink = 0.03\r\n const wedgeRise = roofRise + wedgeSink\r\n // The wedge sits on the tapered wall top now, so its base matches the\r\n // wall TOP width, not the sill width.\r\n const wedgeBase = wallTopWidth * 0.995\r\n const wedgeHalfRun = (wedgeBase - H * 0.02) / 2\r\n shell.push(taperedBoxGeometry(\r\n [wedgeBase, bodyDepth * 0.985],\r\n [H * 0.02, bodyDepth * 0.985],\r\n wedgeRise,\r\n [0, bodyTop - wedgeSink + wedgeRise / 2, 0],\r\n wood(0.012, 0.25),\r\n wood(0.03, 0.25),\r\n ))\r\n\r\n // The slabs match the wedge's own pitch and are pushed out along the\r\n // slope normal by a whisker LESS than half their thickness, so their\r\n // undersides sit just inside the wedge -- interpenetrating, never\r\n // coplanar -- and their tops are the shingled surface. Extra length is\r\n // shoved downhill so it becomes eave overhang past the walls rather\r\n // than a crossing at the ridge, and extra depth overhangs the gables.\r\n const pitchAngle = Math.atan2(wedgeRise, wedgeHalfRun)\r\n const slopeLength = Math.hypot(wedgeRise, wedgeHalfRun)\r\n const slabT = H * 0.016\r\n for (const side of [-1, 1]) {\r\n const pitch = chamferedBoxGeometry(\r\n [slopeLength * 1.28, bodyDepth * 1.06],\r\n [slopeLength * 1.28, bodyDepth * 1.06],\r\n slabT,\r\n H * 0.004,\r\n [0, 0, 0],\r\n // LIGHTER than the walls, not darker. Weathered shingle bleaches\r\n // ahead of the boarding under it, and this slab at -0.12 was the\r\n // near-black roof the critique led with: on a palette whose oak\r\n // lightness is 0.067 linear, -0.12 is the floor. Matching the\r\n // wedge's top tone also stops the slab's edge reading as a black\r\n // band where the two roof solids meet along the eave.\r\n wood(0.035, 0.25),\r\n )\r\n // The slab starts lying FLAT -- `chamferedBoxGeometry` takes an X-Z\r\n // footprint and a Y height -- so the tilt is the pitch angle from the\r\n // horizontal, negative on the +X side so the eave drops rather than\r\n // rises. Both earlier attempts used the complement of this angle,\r\n // which stood the pitches up like the covers of an open book.\r\n pitch.rotateZ(-side * pitchAngle)\r\n const midX = (wedgeBase / 2 + H * 0.01) / 2\r\n const midY = bodyTop - wedgeSink + wedgeRise / 2\r\n const downhill = slopeLength * 0.11\r\n const slabX = side * (midX + Math.sin(pitchAngle) * slabT * 0.35 + Math.cos(pitchAngle) * downhill)\r\n const slabY = midY + Math.cos(pitchAngle) * slabT * 0.35 - Math.sin(pitchAngle) * downhill\r\n pitch.translate(slabX, slabY, 0)\r\n shell.push(pitch)\r\n\r\n // Shingle courses: three thin strips per pitch, parallel to the ridge\r\n // and slightly proud of the slab, sunk a quarter of their thickness\r\n // into it so they are joined solids rather than sheets lying on a\r\n // face. Without some course lines the roof reads as painted card, and\r\n // the critique called the missing shingle language out by name.\r\n for (let course = 0; course < 3; course += 1) {\r\n const strip = chamferedBoxGeometry(\r\n [H * 0.028, bodyDepth * 1.02],\r\n [H * 0.024, bodyDepth * 1.0],\r\n slabT * 0.55,\r\n H * 0.002,\r\n [0, 0, 0],\r\n wood(0.028 + jitter(random, 0.006), 0.25),\r\n )\r\n strip.rotateZ(-side * pitchAngle)\r\n const along = (course - 1) * slopeLength * 0.32\r\n const out = slabT * 0.5 + slabT * 0.55 * 0.5 - slabT * 0.25\r\n strip.translate(\r\n slabX + side * (Math.sin(pitchAngle) * out + Math.cos(pitchAngle) * along),\r\n slabY + Math.cos(pitchAngle) * out - Math.sin(pitchAngle) * along,\r\n 0,\r\n )\r\n shell.push(strip)\r\n }\r\n }\r\n // Ridge board, capping the line where the two slabs cross.\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.002, 0],\r\n wood(0.02, 0.25),\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 // A plank tone a shade darker than the wall, not the pure black slab\r\n // the critique saw: -0.14 was through the tinter's floor.\r\n wood(-0.022, 0.25),\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 wood(-0.034, 0.25),\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 wood(-0.008),\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. Pulled in again from 0.74: the\r\n // forward hub under a perspective camera is why the critic measured the\r\n // near arm 25 percent longer than its opposite -- the four arms are\r\n // identical -- and every centimetre back shrinks that distortion. At\r\n // 0.68 the rear stock's sweep still clears the roof overhang by ~5 cm.\r\n const hubZ = bodyDepth * 0.68\r\n // High on the gable end, not at the gallery. At 0.72 the hub sat far\r\n // enough below the eave line that from a corner view it read as bolted\r\n // to the wall at gallery height; the reference carries it just under\r\n // the eave, so the sails radiate from beneath the roof apex.\r\n const hubY = bodyBottom + bodyHeight * 0.80\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 wood(-0.008),\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 canister boss: one dark iron block at the crossing, holding both\r\n // stocks to the shaft. It replaces the two thin iron bands of the last\r\n // pass, which at +0.02 lift with the iron material's low roughness came\r\n // out PALE blue-grey -- a colour used nowhere else -- and, sitting at\r\n // -0.02 and +0.03 along the shaft, read as a chip floating beside the\r\n // cross rather than the thing joining it. This block is centred on the\r\n // crossing, deep enough along the shaft to swallow both whips where\r\n // they pass (they interpenetrate it on purpose), and it is the darkest\r\n // value on the whole model: everything else moved up into one pale\r\n // weathered family, and the boss is the single iron note the reference\r\n // keeps dark.\r\n ironWork.push(boxGeometry(\r\n [bodyWidth * 0.155, bodyWidth * 0.155, barT * 6.0],\r\n [0, 0, 0],\r\n tint('iron', -0.02, 0.4),\r\n ))\r\n\r\n // The stocks: TWO continuous timbers, each passing right through the\r\n // crossing and carrying a sail at both ends, one behind the other along\r\n // the shaft. Last pass built four separate whips instead, each STARTING\r\n // at the hub radius, and the critic measured the arms as unequal --\r\n // they were equal, but four inner ends loitering near a perspective-\r\n // distorted hub gave it four slightly different crossings to guess at.\r\n // A through stock cannot have that problem: both of its arms are the\r\n // same timber.\r\n for (const [index, off] of ([-1, 1] as const).entries()) {\r\n const stock = boxGeometry(\r\n [barT * 1.3, config.sailSpan * 0.995, barT * 1.6],\r\n [0, 0, 0],\r\n wood(-0.008 + jitter(random, 0.008)),\r\n )\r\n if (index === 1) stock.rotateZ(Math.PI / 2)\r\n stock.translate(0, 0, off * barT * 2.1)\r\n rig.push(stock)\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\r\n // The outer rail of the frame, parallel to the stock.\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 wood(-0.012),\r\n ))\r\n // The bars across. A solid blade would read as a propeller; the\r\n // 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 wood(0.005 + jitter(random, 0.01)),\r\n ))\r\n }\r\n // The windboard: the solid plank running the length of the leading\r\n // edge, on the opposite side of the stock from the lattice. This is\r\n // what makes a sail read as a BLADE rather than a bare ladder, and\r\n // its absence was the critique's second fix. Thicker than both the\r\n // bars and the stock's own plane so no face of it is coplanar with\r\n // either where they interpenetrate.\r\n sail.push(boxGeometry(\r\n [latticeWidth * 0.48, (sailLength - hubRadius) * 0.94, barT * 1.15],\r\n [-latticeWidth * 0.24 + barT * 0.5, hubRadius + (sailLength - hubRadius) * 0.5, 0],\r\n wood(0.022, 0.25),\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 // Each lattice sits at its own stock's depth along the shaft.\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 resting pose is an X, baked in BEFORE the tilt so `spin` stays a\r\n // plain rotation about the shaft. Built as a \"+\" the mill parks with\r\n // one sail hidden against the roofline and one dead horizontal, and the\r\n // horizontal one reads as a railed walkway running off into space --\r\n // which is exactly what the critique saw. The reference parks in an X,\r\n // two sails up and two down, and so does every mill at rest: the X is\r\n // the pose that keeps canvas, weight and lightning-conductor happy.\r\n sails.rotateZ(Math.PI / 4)\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 const steps: BufferGeometry[] = []\r\n // Steeper than a lever wants, shallower than a stair. Every earlier pass\r\n // reasoned from what a tail ladder DOES and reached 0.56 of the height,\r\n // about 38 degrees -- and the critic, twice now, has read that reach as\r\n // a gangplank competing with the sails for the silhouette. The reference\r\n // has no ladder at all, so the ladder earns its keep only while it stays\r\n // out of the way: 0.36 brings it to about 51 degrees with the foot a\r\n // metre and a half closer in, clearly a way up the back of the buck.\r\n const footZ = -bodyDepth * 0.5 - H * 0.36\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 wood(-0.012),\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 wood(-0.005 + jitter(random, 0.008)),\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": "06dfcf36b22a7854824cc0147d9c57f5031cc05881edec0851bc3ef5e12a1b3b"
|
|
1685
1685
|
}
|
|
1686
1686
|
],
|
|
1687
1687
|
"meta": {
|
|
@@ -1777,8 +1777,8 @@
|
|
|
1777
1777
|
{
|
|
1778
1778
|
"path": "models/round-shield/model.ts",
|
|
1779
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": "
|
|
1780
|
+
"content": "/**\n * @medieval-kit/round-shield\n *\n * A planked round shield with a rawhide rim and an iron boss.\n *\n * The kit had no arms or armour at all, which for a medieval catalogue is a\n * whole category missing rather than a gap. This is the cheapest way in: a\n * shield is the one piece of war gear that stands on its own in a scene —\n * leaning on a wall, stacked by a door — without needing a person to hold it.\n *\n * Three things make it a shield rather than a disc, and all three are\n * structural rather than decorative:\n *\n * - It is BOARDS, not a plate. A round shield is planks butted edge to edge\n * and held by the rim and the boss; the plank lines are the first thing\n * the eye finds and a smooth face reads as a lid.\n * - The rim binds the ENDS of those planks. Without it the boards would\n * split off one by one at the first blow, and without it in the model the\n * silhouette is a wafer.\n * - The boss is a dome over a HOLE. The hand goes behind it, gripping a bar\n * across the back, so the boss is not ornament — it is the knuckle guard,\n * and it is why the centre of the face is iron.\n *\n * The face is quartered because that is what a painted shield looks like and\n * because it costs nothing: each plank is built in two halves anyway, so the\n * upper and lower halves simply take different colours. `hue` turns both\n * quarters together and keeps them opposite, so the slider always produces a\n * pair that belongs together.\n */\nimport { Color, type BufferGeometry } from 'three'\n\nimport {\n bandGeometry,\n boxGeometry,\n createKitModel,\n createTinter,\n jitter,\n latheGeometry,\n mergeColoured,\n prismGeometry,\n taperedBoxGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface RoundShieldConfig {\n /** Radius of the shield (metres). */\n readonly radius: number\n /** Planks across the face. */\n readonly planks: number\n /** Thickness of the boards (metres). */\n readonly thickness: number\n /** How far it leans back from upright (radians). */\n readonly lean: number\n /** Paint colour, 0–1 around the wheel. The second quarter sits opposite. */\n readonly hue: number\n /** Rivets around the boss. */\n readonly rivets: number\n readonly seed: number\n}\n\nexport const roundShieldDefaults: RoundShieldConfig = {\n radius: 0.36,\n planks: 9,\n thickness: 0.016,\n // Barely off vertical. A shield propped against something stands almost\n // upright; at 0.22 it read as lying back against nothing.\n lean: 0.12,\n hue: 0.02,\n rivets: 10,\n seed: 83,\n}\n\nexport type RoundShieldParts = 'boards' | 'rim' | 'boss'\n\nexport function createModel(overrides: Partial<RoundShieldConfig> = {}) {\n return createKitModel<RoundShieldConfig, 'oak' | 'leather' | 'iron', RoundShieldParts>({\n id: 'round-shield',\n defaults: roundShieldDefaults,\n slots: ['oak', 'leather', 'iron'],\n\n build: ({ config, random }) => {\n const tint = createTinter(random)\n const R = config.radius\n const T = config.thickness\n const planks = Math.max(3, Math.round(config.planks))\n const hue = ((config.hue % 1) + 1) % 1\n\n // Saturated and dark. At 0.42 saturation and 0.34 lightness the face\n // came out pastel against a reference whose red and blue are deep enough\n // to read as pigment ground in oil -- which is what they were. Shield\n // paint is not a wash.\n const paint = (h: number, dark: number): Color =>\n new Color().setHSL(\n (h + jitter(random, 0.01) + 1) % 1,\n 0.66 + jitter(random, 0.05),\n dark + jitter(random, 0.03),\n )\n\n // --- Boards ---------------------------------------------------------\n // Each plank is built in two halves so the face can be quartered without\n // any extra geometry: the split the paint needs is a split the boards\n // already have to make anyway.\n const boards: BufferGeometry[] = []\n const step = (R * 2) / planks\n const upper = paint(hue, 0.26)\n const lower = paint((hue + 0.55) % 1, 0.23)\n\n for (let i = 0; i < planks; i += 1) {\n const x = -R + step * (i + 0.5)\n // The plank is a chord of the circle, so its length is set by how far\n // out it sits. Squaring off every plank at full width would give a\n // square with a rim drawn on it.\n //\n // The chord is measured an eighth of the way out, not at the plank's\n // outer edge, and the plank NARROWS to that quarter width at the rim so\n // its far corners sit just inside the rim rather than outside it.\n //\n // Measured at the outer edge, which is what this did, the outermost\n // plank of nine came out exactly zero long and was dropped by the guard\n // below. Both edge planks vanished, the board field ended a full plank\n // short of the rim on each side, and the shield rendered with the\n // background showing through it. The ones that survived stepped in and\n // out against the rim in a sawtooth, because every one of them stopped\n // where its own outer corner met the circle.\n const halfSpan = Math.sqrt(Math.max(0, R * R - Math.pow(Math.abs(x) + step * 0.12, 2)))\n if (halfSpan <= step * 0.2) continue\n for (const half of [-1, 1]) {\n // The two halves of one plank are quartered on the DIAGONAL: the\n // left-hand planks take one colour above and the other below, and\n // the right-hand ones the reverse. That is what makes a quartered\n // shield read as quartered rather than as banded.\n const left = x < 0\n const colour = (half < 0) === left ? upper : lower\n boards.push(taperedBoxGeometry(\n [step * 0.97, T],\n [step * 0.86, T * 0.92],\n halfSpan,\n [x, half * halfSpan * 0.5, 0],\n new Color(colour),\n ))\n }\n }\n\n // --- Rim ------------------------------------------------------------\n // A rawhide binding folded over the edge. It is a band whose thickness\n // reaches INSIDE the boards' own radius, so it grips them rather than\n // sitting against their ends.\n // Thick enough to SWALLOW the plank ends.\n //\n // The boards are rectangles cut to the chord of the circle, so their ends\n // step in and out around the edge -- unavoidable, since a plank is\n // straight and the outline is not. A real rim hides exactly that: it is a\n // wide band of hide folded over the ends and stitched, and it is what\n // turns a stack of boards into a disc. At 0.055 of the radius it was too\n // narrow to cover the steps and the silhouette came out serrated.\n const rim = bandGeometry(\n R * 1.02, 0, T * 3.2, R * 0.105, planks * 3,\n tint('leather', -0.04, 0.9), { inner: true },\n )\n rim.rotateX(Math.PI / 2)\n\n // The cross between the quarters, laid ON the face.\n //\n // Two bands of pale paint dividing the four quarters. It is the detail\n // that makes a quartered shield read at a distance -- without it the\n // quarters meet edge to edge and the eye sees two colours rather than a\n // device. Proud of the boards rather than inlaid, because paint is.\n const trim: BufferGeometry[] = []\n const band = R * 0.1\n const cream = new Color().setHSL(0.11, 0.24, 0.74)\n for (const vertical of [false, true]) {\n const arm = taperedBoxGeometry(\n vertical ? [band, T * 0.5] : [R * 1.94, T * 0.5],\n vertical ? [band * 0.97, T * 0.4] : [R * 1.9, T * 0.4],\n vertical ? R * 1.94 : band,\n [0, 0, T * 0.62],\n new Color(cream),\n )\n trim.push(arm)\n }\n\n // --- Boss -----------------------------------------------------------\n const bossR = R * 0.26\n const iron: BufferGeometry[] = []\n // The dome. Its back is open, because behind it is the hole the hand\n // goes into, and a cap there would be a face nobody can see.\n iron.push(latheGeometry(\n [\n { y: 0, radius: bossR * 0.98 },\n { y: bossR * 0.42, radius: bossR * 0.86 },\n { y: bossR * 0.78, radius: bossR * 0.54 },\n { y: bossR * 0.94, radius: bossR * 0.16 },\n ] as Level[],\n 11, [0, 0, 0], tint('iron', 0.05, 0.7), { capBottom: false },\n // +90, not -90. The dome is turned about Y, and -90 sends its point\n // to -Z -- into the back of the shield, where the only thing visible\n // from the front was the open rim of it. That is why the boss read as a\n // flat black ring.\n ).rotateX(Math.PI / 2).translate(0, 0, T * 0.5))\n // The flange it is riveted through.\n iron.push(bandGeometry(\n bossR * 1.34, 0, T * 1.1, bossR * 0.36, 11,\n tint('iron', -0.02, 0.7), { inner: true },\n ).rotateX(Math.PI / 2).translate(0, 0, T * 0.55))\n\n const rivets = Math.max(0, Math.round(config.rivets))\n for (let i = 0; i < rivets; i += 1) {\n const a = (i / rivets) * Math.PI * 2 + 0.2\n // Flared outwards, like the barrel's: a stud that tapers away from the\n // surface tilts its side normals back towards it, which the radial\n // check reads as reversed winding, and a hammered rivet flares anyway.\n const stud = prismGeometry(\n R * 0.016, R * 0.022, T * 1.4, 4, [0, 0, 0],\n tint('iron', 0.09, 0.6), { capBottom: false },\n )\n stud.rotateX(-Math.PI / 2)\n iron.push(stud.translate(\n Math.sin(a) * bossR * 1.16,\n Math.cos(a) * bossR * 1.16,\n T * 0.8,\n ))\n }\n\n // --- Back ------------------------------------------------------------\n // The grip bar across the hole, and two battens holding the planks. Cheap\n // and invisible from the front, but a shield seen from behind with\n // nothing to hold is worse than one with no back at all.\n const back: BufferGeometry[] = []\n back.push(boxGeometry(\n [R * 1.2, R * 0.09, T * 1.6],\n [0, 0, -T * 1.1],\n new Color(tint('oak', -0.1)),\n ))\n for (const at of [-0.46, 0.46]) {\n back.push(boxGeometry(\n [R * 1.5, R * 0.07, T * 1.2],\n [0, at * R, -T * 0.9],\n new Color(tint('oak', -0.14)),\n ))\n }\n\n const face = mergeColoured([...boards, ...trim, ...back])\n const boss = mergeColoured(iron)\n\n // Leaned back from UPRIGHT, not tipped up from flat.\n //\n // Everything is authored in the XY plane facing +Z, which is already a\n // shield standing on its edge. Rotating by -90 degrees and then adding\n // the lean laid it face-up on the floor with a slight tilt, which is a\n // shield somebody dropped. The lean is the whole rotation.\n for (const g of [face, rim, boss]) g.rotateX(config.lean)\n // Set down by measuring, not by trigonometry: the boss stands proud of\n // the face and the rim wraps past it, so which piece is lowest depends on\n // the lean and is not worth deriving.\n const all = mergeColoured([face, rim, boss])\n all.computeBoundingBox()\n const drop = all.boundingBox?.min.y ?? 0\n for (const g of [face, rim, boss]) g.translate(0, -drop, 0)\n\n return {\n boards: { slot: 'oak' as const, geometry: face },\n rim: { slot: 'leather' as const, geometry: rim },\n boss: { slot: 'iron' as const, geometry: boss },\n }\n },\n }, overrides)\n}\n",
|
|
1781
|
+
"hash": "3c3e685c5f33a449bf45b9bf5de2989559d5810a85ecd0905daedc29f04cecae"
|
|
1782
1782
|
}
|
|
1783
1783
|
],
|
|
1784
1784
|
"meta": {
|
|
@@ -1873,8 +1873,8 @@
|
|
|
1873
1873
|
{
|
|
1874
1874
|
"path": "models/stone-trough/model.ts",
|
|
1875
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": "
|
|
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 // 1.35 by 0.50, not 1.5 by 0.44. The block ran 3.4 long to 1 high and the\r\n // reference is nearer 2.6, which is the difference between a hewn stone\r\n // trough and a long low planter. Split between the two dimensions so neither\r\n // ends up an implausible size on its own.\r\n length: 1.35,\r\n width: 0.58,\r\n height: 0.5,\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 /*\r\n * Tucked in by less than the roughening displaces, which is the only\r\n * inset that does not show.\r\n *\r\n * At `wall * 0.5` the block sat 35 mm inside the walls on every side, and\r\n * on a trough 450 mm tall that put a hard horizontal step across the\r\n * bottom fifth of both long faces. Measured band by band the silhouette\r\n * ran 0.265 wide over the bottom 90 mm and 0.30 above it. The comment\r\n * that used to sit here said the base was invisible from above, and it\r\n * was: what it was not was invisible from the side, which is where a\r\n * trough is looked at.\r\n *\r\n * Now the inset is under the roughening amplitude, so the two surfaces\r\n * interleave into one weathered face instead of meeting at a ledge, and\r\n * the tint follows the walls rather than sitting a step darker.\r\n */\r\n const foot = wall * 0.16\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.02, 0.85)),\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": "83610007eecff26bdbe0cd90cb48340a31db9ea218a2272071b5c5d40be93c05"
|
|
1878
1878
|
}
|
|
1879
1879
|
],
|
|
1880
1880
|
"meta": {
|
|
@@ -2058,8 +2058,8 @@
|
|
|
2058
2058
|
{
|
|
2059
2059
|
"path": "models/straw-broom/model.ts",
|
|
2060
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": "
|
|
2061
|
+
"content": "/**\n * @medieval-kit/straw-broom\n *\n * Besom: a hazel-rod handle with a bundle of birch twigs bound to its end by a\n * withy tie. The period's broom really was this simple, and that is exactly why\n * it turns up in every interior scene.\n *\n * FOURTH attempt. In the first, the bristles were individual rods and the\n * bundle looked like a whisk; in the second, flat sheaves on one ring made a\n * hollow cone shell (\"a lampshade pushed onto a handle\"). The third fixed the\n * shell with concentric rings, but the critique still read it as \"a decorative\n * whisk or wheat sheaf\", and the causes were mass and proportion, not\n * structure:\n *\n * - Shaft and bundle were nearly 1:1 and the shaft was a fat dowel. A besom\n * is a LONG THIN stick with a SHORT dense head: the shaft above the tie is\n * now about 1.6x the bundle and its radius dropped by 40 percent, tapering\n * towards the free end like a cut sapling.\n * - The bundle was bright gold and you could see the background through it.\n * Birch twigs are grey-brown, near the value of the handle itself, so the\n * twig colour is straw pulled towards oak. The count nearly tripled, a\n * fourth ring was added, and a cheap lathe FILLER CONE sits inside the\n * bundle so no sight line passes through it. Fill the middle with a solid,\n * not with more twigs: twigs in the core are invisible anyway.\n * - The flare splayed into a teepee. The tip radius is now about a third of\n * the bundle length rather than half, which also gathers the twig ends so\n * the contact shadow reads as one patch instead of scattered fragments.\n * - Three thin near-black rings floated at the very top. A real withy tie is\n * several adjacent turns forming ONE WRAP BAND over about a quarter of the\n * bundle, in pale withy, not black cord: `bindings` now means turns WITHIN\n * that band, stacked contiguously below the throat.\n */\nimport type { BufferGeometry } from 'three'\n\nimport {\n arcBarGeometry,\n bandGeometry,\n bendGeometry,\n createKitModel,\n createTinter,\n jitter,\n latheGeometry,\n mergeColoured,\n roughenGeometry,\n taperedBoxGeometry,\n type Level,\n} from '../core/index.ts'\n\nexport interface StrawBroomConfig {\n /** Total length (metres). */\n readonly length: number\n /** Shaft radius (metres). */\n readonly shaftRadius: number\n /** Length of the bundle, as a fraction of the total length. */\n readonly headLength: number\n /** Bundle radius at the tie (metres). */\n readonly tieRadius: number\n /** Bundle radius at the sweeping end (metres). The flare derives from these two. */\n readonly tipRadius: number\n /** Total number of twigs. */\n readonly bristles: number\n /** Turns of withy within the single wrap band. */\n readonly bindings: number\n readonly seed: number\n}\n\nexport const strawBroomDefaults: StrawBroomConfig = {\n length: 1.2,\n // Was 0.018: a dowel. A cut hazel rod carrying a twig head runs nearer 22 mm\n // across, and the thin shaft is half of what makes the head read as dense.\n shaftRadius: 0.011,\n // Was 0.52, which made shaft and head nearly equal. The reference carries\n // roughly 1.6x as much shaft above the tie as bundle below it, and 0.38\n // gives 0.74 m of shaft over a 0.46 m head.\n headLength: 0.38,\n tieRadius: 0.042,\n // About a third of the bundle length. The old 0.17 against a 0.62 m bundle\n // splayed the skirt into a teepee; the reference fans out gently and stays\n // gathered, and the tighter skirt also pools the ground shadow into one\n // patch under the head.\n tipRadius: 0.14,\n // Tripled from 46. At 46 the gaps between sheaves were wider than the\n // sheaves and the background showed straight through the cone.\n bristles: 130,\n bindings: 5,\n seed: 59,\n}\n\nexport type StrawBroomParts = 'shaft' | 'bristles' | 'bindings'\n\nexport function createModel(overrides: Partial<StrawBroomConfig> = {}) {\n return createKitModel<StrawBroomConfig, 'oak' | 'straw' | 'cloth', StrawBroomParts>({\n id: 'straw-broom',\n // The auto-derived values stay too coarse for a 1.2 m object: the ambient\n // occlusion darkens the bundle like a blanket, and the mottle cell drops to\n // a couple of samples per twig. Both are tied to the scale of a twig.\n occlusion: { radius: 0.05 },\n mottle: { cell: 0.016 },\n defaults: strawBroomDefaults,\n slots: ['oak', 'straw', 'cloth'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n /**\n * Birch-twig grey-brown: straw pulled towards oak. The palette's straw is\n * bright gold, and a bundle of it read as a wheat sheaf; real besom twigs\n * sit near the value of the handle itself. Both tints are fresh Colors,\n * so the lerp cannot alias (the shared-Color trap).\n */\n const twigTint = (lift: number, spread = 1.2) =>\n tint('straw', lift, spread).lerp(tint('oak', lift * 0.5, spread * 0.7), 0.45)\n const half = config.length / 2\n const headLength = config.length * config.headLength\n const headTop = -half + headLength\n\n // --- The bundle's shape, from a single source ----------------------------\n const tieY = headTop - config.length * 0.017\n const sweepY = -half + config.length * 0.025\n const span = Math.max(0.02, tieY - sweepY)\n const tieRadius = config.tieRadius\n const tipRadius = Math.max(tieRadius * 1.05, config.tipRadius)\n // `tieRadius` and `tipRadius` are the bundle's OUTER radius — what the\n // user would measure looking at a broom. The rings are back-computed from\n // it: outer surface = ring radius + half the twig's width.\n const halfWidth = config.shaftRadius * 0.95\n /** The bundle's OUTER radius at height `y`. The bindings use this too. */\n const bundleRadius = (y: number): number =>\n tieRadius + (tipRadius - tieRadius) * Math.min(1, Math.max(0, (tieY - y) / span))\n\n // A broom has a FACE: held in the hand, the same side always meets the\n // floor and that side wears down more. The direction is chosen once,\n // driven by the seed.\n const faceAngle = random() * Math.PI * 2\n\n // --- Twigs: four concentric rings, and a filler --------------------------\n // The inner rings have little slope, the outer one has the full slope.\n // That is why the inside of the bundle fills in; a single ring is a shell.\n const total = Math.max(8, Math.round(config.bristles))\n const core = Math.max(config.shaftRadius, tieRadius - halfWidth)\n const rings = [\n { share: 0.13, radius: core * 0.28, slope: 0.22, offset: 0 },\n { share: 0.2, radius: core * 0.52, slope: 0.5, offset: Math.PI / 9 },\n { share: 0.28, radius: core * 0.76, slope: 0.78, offset: Math.PI / 5 },\n { share: 0.39, radius: core, slope: 1, offset: Math.PI / 13 },\n ]\n const fullFlare = Math.atan((tipRadius - tieRadius) / span)\n\n const bristles: BufferGeometry[] = []\n for (const ring of rings) {\n const n = Math.max(3, Math.round(total * ring.share))\n for (let i = 0; i < n; i += 1) {\n const angle = (i / n) * Math.PI * 2 + ring.offset + jitter(random, 0.1)\n const flare = fullFlare * ring.slope * (0.92 + random() * 0.16)\n // The worn face is shorter: the asymmetry comes from here, not from\n // flattening the bundle. (The flat fan broom is a 19th-century Shaker\n // invention, an anachronism here.)\n const wear = 1 + 0.07 * Math.cos(angle - faceAngle)\n // The spread runs DOWN from 1.0, never above it: the span is the\n // longest twig and `length` means the whole broom. The tips still\n // feather — a level cut across the bottom is what made version two\n // read as a lampshade — but the variation tightened from a third to\n // a fifth, because at a third the skirt scattered and the critique\n // saw the stray tips' shadows as loose fragments on the ground.\n const length = (span / Math.cos(flare)) * wear * (0.85 + random() * 0.15)\n\n const width = config.shaftRadius * (1.05 + random() * 0.24)\n const depth = config.shaftRadius * (0.6 + random() * 0.14)\n // The cross-section TAPERS DOWNWARDS: the sweeping end wears thin.\n const sheaf = taperedBoxGeometry(\n [width * 1.12, depth * 0.62],\n [width, depth],\n length,\n [0, -length / 2, 0], // top end AT THE ORIGIN: the bundle hangs from the tie\n twigTint(-0.03, 1.3),\n twigTint(-0.09, 1.3),\n )\n roughenGeometry(sheaf, config.shaftRadius * 0.09, { salt: i, scaleY: 0.4 })\n\n // THE SIGN: the sheaf extends along -Y. `rotateX(+f)` throws its end\n // towards -Z, and the following `rotateY(angle)` turns -Z TOWARDS THE\n // AXIS — so a NEGATIVE flare is what spreads the bundle outwards.\n sheaf.rotateZ(jitter(random, 0.09))\n sheaf.rotateX(-flare)\n sheaf.rotateY(angle)\n sheaf.translate(\n Math.sin(angle) * ring.radius,\n tieY + jitter(random, config.length * 0.005),\n Math.cos(angle) * ring.radius,\n )\n bristles.push(sheaf)\n }\n }\n\n // The filler: a dark lathe cone INSIDE the bundle, following the bundle's\n // own profile at 85 percent. Twigs in the core are invisible, so the\n // cheap way to kill every see-through sight line is a solid, not more\n // twigs. It hangs from the tie like everything else and its lower end\n // stops inside the thick of the bundle, hidden by the outer rings.\n const fillLength = span * 0.6\n const filler = latheGeometry(\n [\n { y: 0, radius: tieRadius * 0.85 },\n { y: -fillLength * 0.5, radius: bundleRadius(tieY - fillLength * 0.5) * 0.85 },\n { y: -fillLength, radius: bundleRadius(tieY - fillLength) * 0.8 },\n ],\n 8, [0, tieY, 0], twigTint(-0.13, 0.7),\n )\n bristles.push(filler)\n\n // --- Collar: the cut butts left above the tie ------------------------------\n // It both covers where the shaft enters the bundle and is the cheapest way\n // of saying \"this is a bundle\": short stubs tipped upwards.\n for (let i = 0; i < 11; i += 1) {\n const angle = i * 2.399963 // golden angle: no rows form anywhere\n const stub = config.shaftRadius * (2.0 + random() * 1.5)\n const piece = taperedBoxGeometry(\n [config.shaftRadius * 0.62, config.shaftRadius * 0.4],\n [config.shaftRadius * 0.5, config.shaftRadius * 0.34],\n stub,\n [0, stub / 2, 0], // centre at the LOWER end: this piece juts upwards\n twigTint(0.03, 1.2),\n twigTint(-0.02, 1.2),\n )\n // This piece extends along +Y, so the sign is the OPPOSITE of the\n // sheaves': a positive value throws its end towards +Z and `rotateY`\n // turns it outwards.\n piece.rotateZ(jitter(random, 0.08))\n piece.rotateX(0.24 + random() * 0.22)\n piece.rotateY(angle)\n piece.translate(\n Math.sin(angle) * tieRadius * 0.72,\n tieY + config.length * 0.008,\n Math.cos(angle) * tieRadius * 0.72,\n )\n bristles.push(piece)\n }\n\n // --- Binding: one wrap band of several turns -------------------------------\n // Not three thin cords at the throat: a withy tie is wound turn against\n // turn until the wrap covers about a quarter of the bundle, and it is\n // pale peeled withy, lighter than the twigs, not near-black cord (the old\n // -0.28 lift bottomed out on the tinter's floor).\n const turns = Math.max(0, Math.round(config.bindings))\n const wrapLength = span * 0.19\n const turnHeight = wrapLength / Math.max(1, turns)\n const bindings: BufferGeometry[] = []\n for (let i = 0; i < turns; i += 1) {\n const y = tieY - config.length * 0.004 - turnHeight * (i + 0.5)\n // Each turn sits JUST proud of the bundle's OUTER surface at ITS OWN\n // height and bites into it, so the stack follows the flare. When the\n // turns stood a fifth of a shaft radius proud in pale withy the wrap\n // stepped outwards like a beehive skep and dominated the whole head; a\n // tie is a thin skin over the twigs, not a basket around them.\n const radius = bundleRadius(y) + config.shaftRadius * (0.08 + jitter(random, 0.04))\n bindings.push(bandGeometry(\n radius, y, turnHeight * 0.96, config.shaftRadius * 0.9, 12,\n tint('cloth', -0.04, 0.8).lerp(tint('straw', -0.05, 0.8), 0.5),\n { inner: true },\n ))\n }\n if (turns > 0) {\n // The tucked withy end: the one piece that shows how the tie itself is\n // fastened. Offset so it does not stay parallel to the band's 30° facet.\n const withyY = tieY - config.length * 0.004 - wrapLength * 0.4\n const withy = arcBarGeometry(\n bundleRadius(withyY) + config.shaftRadius * 0.35, config.shaftRadius * 0.5,\n -0.5, 0.9, 3, [0, 0, 0],\n tint('cloth', -0.07, 0.7).lerp(tint('straw', -0.08, 0.7), 0.5),\n )\n withy.rotateX(Math.PI / 2)\n withy.rotateY(0.26 + Math.PI / 12)\n withy.translate(0, withyY, 0)\n bindings.push(withy)\n }\n\n // --- Shaft -------------------------------------------------------------------\n // Not a turned spindle but a hazel rod cut in the forest: the radius\n // wavers along its length and the rod TAPERS TOWARDS THE FREE END, the\n // way a cut sapling does — the old version swelled at the top like a\n // turned grip and read as a dowel. The whittled thin end is the one\n // buried in the bundle. And it is slightly bent — a straight rod always\n // reads as manufactured.\n //\n // Built AT THE ORIGIN and bent, THEN translated: bending it at its final\n // coordinates would fling the whole stick away.\n const shaftBottom = headTop - config.length * 0.12\n const shaftLength = half - shaftBottom\n const r = config.shaftRadius\n const shaftLevels: Level[] = [\n { y: -shaftLength / 2, radius: r * 0.55 },\n { y: -shaftLength / 2 + shaftLength * 0.06, radius: r * 1.1 },\n { y: -shaftLength / 2 + shaftLength * 0.2, radius: r * 1.04 },\n { y: shaftLength * 0.05, radius: r * 0.97 },\n { y: shaftLength / 2 - shaftLength * 0.12, radius: r * 0.88 },\n { y: shaftLength / 2, radius: r * 0.7 },\n ].map((level) => ({ y: level.y, radius: level.radius * (1 + jitter(random, 0.05)) }))\n\n const shaft = latheGeometry(shaftLevels, 6, [0, 0, 0], tint('oak', -0.05), {\n colourTop: tint('oak', 0.05),\n })\n bendGeometry(shaft, jitter(random, 0.22) / shaftLength)\n shaft.rotateY(random() * Math.PI * 2)\n shaft.translate(0, shaftBottom + shaftLength / 2, 0)\n\n return {\n shaft: { slot: 'oak' as const, geometry: mergeColoured([shaft]) },\n bristles: { slot: 'straw' as const, geometry: mergeColoured(bristles) },\n bindings: bindings.length > 0\n ? { slot: 'cloth' as const, geometry: mergeColoured(bindings) }\n : undefined,\n }\n },\n }, overrides)\n}\n",
|
|
2062
|
+
"hash": "21d691c4563a1f67ab768d489a8309e5575862e9810bc7ef2732841024190b02"
|
|
2063
2063
|
}
|
|
2064
2064
|
],
|
|
2065
2065
|
"meta": {
|
|
@@ -2163,8 +2163,8 @@
|
|
|
2163
2163
|
{
|
|
2164
2164
|
"path": "models/tavern-sign/model.ts",
|
|
2165
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": "
|
|
2166
|
+
"content": "/**\n * @medieval-kit/tavern-sign\n *\n * A wooden board swinging on chains from a forged iron arm on a freestanding\n * timber post.\n *\n * Since literacy was rare, a period sign carried a PICTURE, not TEXT: a garland\n * meant the vintner, a boot the cobbler, a mortar the apothecary. So the model\n * gives the board itself, not the device on it — the consumer attaches whatever\n * they want to `parts.board.anchor`. This is exactly what the protocol's idea\n * of semantic parts is good for.\n *\n * The swing is a different pendulum from the bell's: here the restoring force\n * is not gravity but the friction of two rings. So a sign at rest always hangs\n * STRAIGHT, but once pushed it oscillates for a long time. Put next to the\n * bell's hard, fast damping, the difference between the two reads immediately.\n *\n * DEAD ENDS, so far four rounds of them:\n * 1. The bracket was bolted to a wall the model did not contain, so the\n * whole sign floated. It became a standing post.\n * 2. The brace and the curl were boxes put through `bendGeometry`, which\n * shears a two-level box instead of bending it; both were replaced with\n * `arcBarGeometry`.\n * 3. The post was a stick. A blind critique scored the model 60 and put\n * almost all of the loss below the sign; the cross base, heavy post, cap,\n * collars and knee brace date from that pass, as does the grey-washed oak\n * (the `oak` helper below, with its lightness floor against the\n * renderer's gamma crush).\n * 4. A second blind critique (66) found the pass-3 fixes overshot or\n * half-done, plus real modelling bugs:\n * - The battens were built 8 mm BEHIND the board's back face — floating,\n * and from half the angles they read as a stray strap lying on the\n * front. Battens are gone; the planks now touch at a chamfer-groove\n * seam instead.\n * - The planks were wedge-tapered through their thickness, so the board\n * read as two splayed slabs. They are straight now.\n * - The chain lugs were thicker than the board and ran down its face.\n * Replaced with eye rings half-buried in the board's top edge, and the\n * chain stops above the edge so no link crosses the face.\n * - The cone cap overhung the post like a roof; now a slim pyramid whose\n * base matches the post's cross-section (about half a post-width tall).\n * - The two collars were black blocks a third of a post-width tall,\n * stacked into one collar; now two thin straps (about an eighth), one\n * at the arm, one up where the tie rod anchors, projecting only a few\n * percent past the post faces — which also terminates the rod's top.\n * - The bearer was a stub; it is now a real through-timber about 0.8\n * post-widths across reaching 55% of the arm's length, the arm seats\n * flat on its top face, and the knee brace lands inside its underside.\n * - The hexagonal ring \"scroll\" is now a C-scroll volute: a 255-degree\n * arc rising off the arm at the tie-rod junction, its free end closed\n * by a forged ball so nothing terminates in the open.\n * - The post dropped from 1:9 to 1:13 width-to-height (the reference is\n * nearer 1:14), the board widened to 1.26:1 with two planks, and every\n * board corner carries the hanging-sign cusp: the edges step in before\n * the corner and a diagonal tab pokes back out to the corner point.\n * - `plankCount` changed MEANING: it now counts the SEAMS, and the board\n * carries plankCount + 1 planks. Do not change it back to a plank\n * count with a default of 2.\n */\nimport type { BufferGeometry } from 'three'\n\nimport {\n arcBarGeometry,\n bandGeometry,\n boxGeometry,\n chamferedBoxGeometry,\n createKitModel,\n createTinter,\n jitter,\n mergeColoured,\n taperedBoxGeometry,\n} from '../core/index.ts'\n\nexport interface TavernSignConfig {\n /** Board width (metres). */\n readonly width: number\n /** Board height (metres). */\n readonly height: number\n /** How far the bracket projects from the post (metres). */\n readonly reach: number\n /** Height of the post the arm is bolted to (metres). */\n readonly postHeight: number\n /** Length of the hanging chain (metres). */\n readonly drop: number\n /** Seams between the boards: the sign carries plankCount + 1 planks. */\n readonly plankCount: number\n /** How fast the swing damps out. */\n readonly damping: number\n readonly seed: number\n}\n\nexport const tavernSignDefaults: TavernSignConfig = {\n // 1.26:1, wider than tall — the reference board is about 1.2:1. The old\n // 0.72 x 0.52 read as roughly square once the corner cusps came off.\n width: 0.78,\n height: 0.62,\n // About 1.5 board widths. At 0.62 the board's inner planks ended flush\n // against the post face and read as clipped by it; now the inner edge\n // clears the post comfortably.\n reach: 1.15,\n postHeight: 2.15,\n // Long enough to see the chain. At 0.12 the board hung almost against the\n // bracket and the five links that connect them were a smudge; the chain is\n // half of what makes a hanging sign read as hanging.\n drop: 0.3,\n // One seam: two planks, which is what the reference board is made of.\n plankCount: 1,\n damping: 0.42,\n seed: 73,\n}\n\n// The chains are NOT a separate part: they have to swing together with the\n// board, so they live as its `extras` body.\nexport type TavernSignParts = 'bracket' | 'board' | 'post'\n\nexport interface TavernSignActions {\n /** Pushes the sign: wind, or someone coming out of the door. */\n push(strength?: number): void\n still(): void\n /** Current swing angle (radians). */\n lean(): number\n}\n\nexport function createModel(overrides: Partial<TavernSignConfig> = {}) {\n let angle = 0\n let velocity = 0\n\n return createKitModel<TavernSignConfig, 'oak' | 'iron', TavernSignParts, TavernSignActions>({\n id: 'tavern-sign',\n defaults: tavernSignDefaults,\n slots: ['oak', 'iron'],\n\n build: ({ config, random }) => {\n const tint = createTinter(random)\n // Decades outdoors: rain leaches the tannin out of oak and leaves it\n // grey-brown. The palette's oak is fresh-sawn; every timber tint here is\n // pulled toward grey. The critique called the untreated colour\n // \"bright orange-tan\" and it was right.\n const oak = (lift = 0, spread = 1) => {\n const c = tint('oak', lift, spread)\n c.offsetHSL(0.004, -0.17, -0.025)\n // Floor the lightness. The renderer's gamma crushes this palette to\n // pure black somewhere around L 0.18, and the tinter's own jitter can\n // land a piece there even from a mild lift -- that is what painted the\n // old battens (and one unlucky foot per seed) as black plastic.\n const hsl = { h: 0, s: 0, l: 0 }\n c.getHSL(hsl)\n if (hsl.l < 0.19) c.setHSL(hsl.h, hsl.s, 0.19)\n return c\n }\n const bar = config.reach * 0.035\n // A slender structural timber: about 1:13 width to height. The first\n // heavy-post pass used 1:9 and the whole sign came out stocky; the\n // reference is nearer 1:14.\n const w = config.postHeight / 13\n // Axis of rotation: the line where the chains leave the arm. The board\n // and the chains are written RELATIVE to this point.\n const pivotY = config.height * 0.5 + config.drop\n const armY = pivotY\n // The post continues well past the ironwork before the cap, the way the\n // reference does, and the tie rod needs the vertical run.\n const postTop = armY + w * 1.6\n const ground = postTop - config.postHeight\n // Board centre along the arm, and where the two chains meet it.\n const zc = config.reach * 0.72\n const hangA = zc - config.width * 0.3\n const hangB = zc + config.width * 0.3\n\n // --- Post and base -----------------------------------------------------\n const timber: BufferGeometry[] = [chamferedBoxGeometry(\n [w, w],\n [w * 0.92, w * 0.92],\n config.postHeight,\n w * 0.08,\n // The post's foot ends INSIDE the plinth, not on the ground: two\n // ground-touching interpenetrating solids would share coplanar\n // down-facing faces.\n [0, (postTop + ground + w * 0.4) / 2, 0],\n oak(-0.03),\n oak(0.03),\n )]\n // Cap: a slim four-sided pyramid whose base matches the post's\n // cross-section, about half a post-width tall. The old cone overhung the\n // post on every side and read as a fence-post roof.\n const capH = w * 0.55\n timber.push(taperedBoxGeometry(\n [w * 0.945, w * 0.945],\n [w * 0.06, w * 0.06],\n capH,\n [0, postTop - 0.02 + capH / 2, 0],\n oak(-0.02),\n oak(-0.05),\n ))\n\n // The cross base: a centre plinth, four feet radiating on the ground\n // axes, four angled struts. This is the bottom third of the reference.\n //\n // Ground contact is divided so no two down-facing faces overlap in the\n // ground plane: the FEET stand on the ground at four disjoint patches,\n // and the plinth they all embed into rides 6 mm up, hidden behind them.\n const footH = w * 0.75\n const footW = w * 0.88\n const rIn = w * 0.55 // feet start inside the plinth\n const rOut = w * 2.3 // and reach over two post-widths out\n timber.push(chamferedBoxGeometry(\n [w * 1.55, w * 1.55],\n [w * 1.42, w * 1.42],\n footH * 1.2,\n w * 0.06,\n [0, ground + 0.006 + footH * 0.6, 0],\n oak(-0.02),\n ))\n for (const [axis, sign] of [['x', 1], ['x', -1], ['z', 1], ['z', -1]] as const) {\n const len = rOut - rIn\n const foot = chamferedBoxGeometry(\n axis === 'x' ? [footH, footW] : [footW, footH],\n axis === 'x' ? [footH * 0.88, footW * 0.88] : [footW * 0.88, footH * 0.88],\n len,\n footH * 0.14,\n [0, 0, 0],\n oak(jitter(random, 0.025), 0.6),\n )\n // Built standing, then laid over so the chamfered end points outward.\n if (axis === 'x') foot.rotateZ(-sign * Math.PI / 2)\n else foot.rotateX(sign * Math.PI / 2)\n const mid = rIn + len / 2\n foot.translate(axis === 'x' ? sign * mid : 0, ground + footH / 2, axis === 'z' ? sign * mid : 0)\n timber.push(foot)\n\n // Strut: from a fifth of the way up the post down to the outer end of\n // this foot. Both end caps terminate inside their solids.\n const s = w * 0.38\n const top = { r: w * 0.3, y: ground + config.postHeight * 0.19 }\n const bot = { r: rOut - w * 0.4, y: ground + footH * 0.5 }\n const run = bot.r - top.r\n const rise = top.y - bot.y\n const len2 = Math.hypot(run, rise)\n const lean = Math.atan2(-run, rise)\n const strut = boxGeometry([s * 0.92, len2, s], [0, 0, 0], oak(jitter(random, 0.02), 0.6))\n if (axis === 'x') strut.rotateZ(-sign * lean)\n else strut.rotateX(sign * lean)\n strut.translate(\n axis === 'x' ? sign * (top.r + bot.r) / 2 : 0,\n (top.y + bot.y) / 2,\n axis === 'z' ? sign * (top.r + bot.r) / 2 : 0,\n )\n timber.push(strut)\n }\n\n // Bearer: the through-timber the arm actually rests on. About 0.8\n // post-widths across, poking out a little behind the post and reaching\n // 55% of the arm's length in front; the arm seats flat on its top face.\n const armH = bar * 1.5\n const armW = bar * 0.9\n const bearerW = w * 0.8\n const bearerH = w * 0.68\n const bearerTop = armY - armH / 2 + 0.004\n const bearerY = bearerTop - bearerH / 2\n const backZ = -w * 0.9\n // Just short of the inner chain: at 0.55 reach the bearer's end swallowed\n // the inner chain's top link.\n const frontZ = config.reach * 0.47\n const bearer = chamferedBoxGeometry(\n [bearerW, bearerH],\n [bearerW * 0.96, bearerH * 0.96],\n frontZ - backZ,\n w * 0.05,\n [0, 0, 0],\n oak(-0.02),\n )\n bearer.rotateX(Math.PI / 2) // stand it on its side: length now along +Z\n bearer.translate(0, bearerY, (frontZ + backZ) / 2)\n timber.push(bearer)\n // The knee brace: a quarter arc from the post face out to the bearer's\n // underside. One end cap inside the post, the other rises into the\n // bearer; nothing terminates in the open.\n const braceR = Math.max(w * 0.9, Math.min(w * 2.1, frontZ - w * 0.3))\n const brace = arcBarGeometry(\n braceR, w * 0.32, -Math.PI / 2, 0, 6, [0, 0, 0],\n oak(0.02, 0.5),\n )\n // Built in XY; -90 degrees about Y carries +X into +Z, standing the arc\n // in the plane the bracket occupies.\n brace.rotateY(-Math.PI / 2)\n brace.translate(0, bearerY - bearerH / 2 + 0.02, 0)\n timber.push(brace)\n\n // --- Ironwork ----------------------------------------------------------\n const iron: BufferGeometry[] = []\n // The arm: a flat strap seated on the bearer's top face, its back end\n // buried in the post (it stops short of the post's back face).\n iron.push(boxGeometry(\n [armW, armH, config.reach + w * 0.4],\n [0, armY, (config.reach - w * 0.4) / 2],\n tint('iron', 0.02, 0.7),\n ))\n // Forged diamond finial at the tip, slightly proud of the bar the way a\n // hammered spear-end spreads.\n const tipL = w * 0.5\n const tip = taperedBoxGeometry(\n [armW * 1.2, armH * 1.35],\n [armW * 0.12, armH * 0.12],\n tipL,\n [0, 0, 0],\n tint('iron', 0.04, 0.7),\n )\n tip.rotateX(Math.PI / 2) // point it along +Z\n tip.translate(0, armY, config.reach - 0.01 + tipL / 2)\n iron.push(tip)\n // Two thin straps around the post — about an eighth of a post-width\n // tall, projecting a few percent past the faces. One clamps the arm\n // where it enters the post, one up top anchors the tie rod.\n const bandH = bar * 0.65\n const rodTopY = postTop - w * 0.35\n iron.push(boxGeometry([w, bandH, w], [0, rodTopY, 0], tint('iron', -0.04, 0.7)))\n iron.push(boxGeometry([w, bandH * 1.1, w], [0, armY, 0], tint('iron', -0.06, 0.7)))\n // The shallow diagonal tie rod, from the upper strap down into the arm\n // at the scroll junction.\n const scrollZ = config.reach * 0.8\n {\n const t = { y: rodTopY, z: w * 0.28 }\n const b = { y: armY + armH * 0.2, z: scrollZ }\n const len = Math.hypot(t.y - b.y, b.z - t.z)\n const rod = boxGeometry([bar * 0.55, len, bar * 0.55], [0, 0, 0], tint('iron', 0, 0.7))\n // atan2 of (dz, dy) ALONG the rod, top to bottom: getting the y term\n // backwards mirrors the slope and the rod climbs into free air past\n // the arm instead of dropping from the post top onto it.\n rod.rotateX(Math.atan2(b.z - t.z, b.y - t.y))\n rod.translate(0, (t.y + b.y) / 2, (t.z + b.z) / 2)\n iron.push(rod)\n }\n // The C-scroll volute where the rod meets the arm: a 255-degree arc\n // rising off the arm and curling back over itself. Its lower end is\n // buried in the arm; its free end is closed by a forged ball, so neither\n // end terminates in the open. The old closed hexagonal ring read as a\n // washer, not a scroll.\n const rS = w * 0.32\n const scroll = arcBarGeometry(\n rS, bar * 0.6, -Math.PI * 0.44, Math.PI * 0.97, 8, [0, 0, 0],\n tint('iron', 0.05, 0.7),\n )\n scroll.rotateY(-Math.PI / 2)\n scroll.translate(0, armY + rS * 0.9, scrollZ)\n iron.push(scroll)\n iron.push(boxGeometry(\n [bar * 1.1, bar * 1.1, bar * 1.1],\n [0, armY + rS * 0.99, scrollZ - rS],\n tint('iron', 0.02, 0.7),\n ))\n\n // --- Chains ------------------------------------------------------------\n // They have to swing TOGETHER with the board, hence the board's `extras`\n // body. Were they a separate part, the chain would stay bolt upright\n // while the board swung.\n //\n // The COUNT is fixed and the RADIUS is derived, not the other way round.\n // Deriving the count from `drop` guaranteed the links overlap, but it also\n // made the triangle count depend on a continuous slider — which meant the\n // showcase could not morph this model and had to fall back to visibly\n // stepped rebuilds. Fixing the count and growing the links instead keeps\n // both properties: always interlocked, always the same topology.\n const linkCount = 5\n const linkRadius = Math.max(bar * 0.9, (config.drop / (linkCount - 1)) * 0.62)\n const eyeR = linkRadius * 0.9\n // The chain stops ABOVE the board's top edge; the eye ring bridges the\n // last link to the board. Letting the last link reach the edge itself\n // put its lower arc through the board's face from both sides.\n const chainSpan = Math.max(0.01, config.drop - eyeR * 1.2)\n const links: BufferGeometry[] = []\n for (const side of [-1, 1]) {\n const hangZ = side < 0 ? hangA - zc : hangB - zc\n for (let i = 0; i < linkCount; i += 1) {\n // The first link wraps the arm and the last threads the board's eye,\n // so the chain is a real connection rather than two things at\n // roughly the same height.\n const y = -chainSpan * (i / (linkCount - 1))\n const ring2 = bandGeometry(linkRadius, 0, bar * 0.6, bar * 0.35, 6,\n tint('iron', jitter(random, 0.05), 0.7), { inner: true })\n // Each link is indexed a little differently about its own axis, so\n // consecutive interlocking links never leave coplanar facets. The\n // offset is deterministic so the seeded stream is untouched.\n ring2.rotateY(i * 0.37 + side * 0.19)\n // Successive links must pass through at right angles — that is what a\n // chain is.\n ring2.rotateX(i % 2 === 0 ? Math.PI / 2 : 0)\n ring2.rotateZ(i % 2 === 0 ? 0 : Math.PI / 2)\n ring2.translate(0, y, hangZ)\n links.push(ring2)\n }\n // The eye: a ring standing in the board's own plane, its lower half\n // buried in the board's top edge, its upper half threading the last\n // chain link. This replaces the old lugs, which were thicker than the\n // board and ran down its face.\n const eye = bandGeometry(eyeR, 0, bar * 0.55, bar * 0.35, 6,\n tint('iron', 0.03, 0.7), { inner: true })\n eye.rotateZ(Math.PI / 2)\n eye.translate(0, -config.drop + eyeR * 0.35, hangZ)\n links.push(eye)\n }\n\n // --- Board -------------------------------------------------------------\n // Two planks (by default) touching at a chamfer-groove seam, with the\n // hanging-sign cusp cut into all four corners: the outer edges step in a\n // tenth of the board width before the corner, and a diagonal tab pokes\n // back out to the corner point, leaving a concave notch either side.\n const W = config.width\n const H = config.height\n const t = Math.max(0.02, H * 0.05)\n const planks = Math.max(1, Math.round(config.plankCount) + 1)\n const plankH = H / planks\n const n = Math.min(W * 0.09, plankH * 0.42)\n const boardTop = -config.drop\n // Half-width of the shadow gap either side of a plank seam. The seam is\n // an actual groove: the planks pull back a hair and a darker strip sits\n // recessed behind the gap, bridging them. Tint jitter alone was not\n // enough — one seed put the two planks at the same value and the board\n // read as a single slab.\n const g = 0.002\n const board: BufferGeometry[] = []\n for (let i = 0; i < planks; i += 1) {\n const y0 = boardTop - H + i * plankH\n const y1 = y0 + plankH\n const cutTop = i === planks - 1\n const cutBottom = i === 0\n // Alternate the planks light/dark on purpose so the seam always\n // separates two visibly different boards, whatever the seed does.\n const c = oak((i % 2 === 0 ? -0.055 : 0.05) + jitter(random, 0.012))\n // Two boards cut from two trees: the light plank is warmer as well as\n // lighter, so the seam separates two visibly different timbers.\n if (i % 2 === 1) c.offsetHSL(0.012, 0.05, 0)\n // A hair of per-plank thickness difference: it marks the single real\n // seam with a faint step without cutting a groove that could be\n // miscounted as another plank.\n const tp = t * (1 - i * 0.012)\n const yLo = cutBottom ? y0 : y0 + g\n const yHi = cutTop ? y1 : y1 - g\n const aLow = yLo + (cutBottom ? n : 0)\n const aHigh = yHi - (cutTop ? n : 0)\n if (i > 0) {\n // The seam strip: darker, recessed behind both plank faces, and\n // overlapping both planks so the board stays one connected body.\n // Sitting only 1.3 mm behind the faces: any deeper and the lower\n // plank's lit top face fills the slit and the seam reads LIGHT.\n board.push(boxGeometry([t * 0.91, 0.02, W * 0.985], [0, y0, 0], oak(-0.2)))\n }\n // The plank body. Plain and straight through its thickness: the old\n // wedge taper made the front faces splay and the board read as two\n // non-coplanar slabs, and a chamfered body left a groove where the\n // corner-cut strip joins it, which read as a third plank seam.\n board.push(boxGeometry([tp, aHigh - aLow, W], [0, (aLow + aHigh) / 2, 0], c))\n for (const [edge, cut] of [[1, cutTop], [-1, cutBottom]] as const) {\n if (!cut) continue\n const yEdge = edge > 0 ? y1 : y0\n // The outer-edge strip: its inner cross-section matches the body\n // exactly (flush, invisible joint) and it tapers to W - 2n at the\n // outer edge, so each corner is cut by a 45-degree facet.\n board.push(taperedBoxGeometry(\n edge > 0 ? [tp, W] : [tp * 0.99, W - 2 * n],\n edge > 0 ? [tp * 0.99, W - 2 * n] : [tp, W],\n n,\n [0, yEdge - edge * n / 2, 0], c,\n ))\n for (const side of [-1, 1] as const) {\n // The cusp point: a small pointed diamond on the corner diagonal,\n // base buried in the strip, tip reaching exactly the original\n // corner. It stays inside the board's rectangle instead of\n // poking past it.\n const theta = edge > 0 ? side * Math.PI / 4 : Math.PI - side * Math.PI / 4\n const tipTab = taperedBoxGeometry(\n [tp * 0.92, n * 0.95], [tp * 0.45, n * 0.12], n, [0, 0, 0], c,\n )\n tipTab.rotateX(theta)\n tipTab.translate(\n 0,\n yEdge - Math.cos(theta) * n * 0.5,\n side * W / 2 - Math.sin(theta) * n * 0.5,\n )\n board.push(tipTab)\n }\n }\n }\n\n return {\n post: { slot: 'oak' as const, geometry: mergeColoured(timber) },\n bracket: { slot: 'iron' as const, geometry: mergeColoured(iron) },\n board: {\n slot: 'oak' as const,\n geometry: mergeColoured(board),\n // The origin is the point on the ARM the chains hang from, not the\n // post's centre line: the swing action rotates about this origin,\n // and rotating about anywhere else sweeps the chain tops off the\n // bar they are looped through.\n origin: [0, pivotY, zc] as const,\n extras: [{ slot: 'iron' as const, geometry: mergeColoured(links) }],\n },\n }\n },\n\n actions: ({ parts }) => {\n parts.board.anchor.rotation.z = angle\n return {\n push: (strength = 1) => {\n // Reinforces the existing motion instead of resetting it: successive\n // pushes should accumulate the way a real wind does.\n velocity += (velocity >= 0 ? 1 : -1) * 1.6 * strength\n },\n still: () => { angle = 0; velocity = 0; parts.board.anchor.rotation.z = 0 },\n lean: () => angle,\n }\n },\n\n update: (dt, { parts, getConfig }) => {\n const step = Math.min(0.05, Math.max(0, dt))\n if (step === 0) return\n if (Math.abs(angle) < 1e-5 && Math.abs(velocity) < 1e-5) return\n // A SOFTER pendulum than the bell's: weak restoring force, little damping.\n // This is what the long, lazy swing of a heavy board looks like.\n velocity += -angle * 11 * step - velocity * getConfig().damping * step\n angle += velocity * step\n // Limit: the board must stop before it hits the arm.\n const limit = 0.55\n if (Math.abs(angle) > limit) {\n angle = Math.sign(angle) * limit\n velocity *= -0.4\n }\n parts.board.anchor.rotation.z = angle\n },\n }, overrides)\n}\n",
|
|
2167
|
+
"hash": "f9da32cbd21474b8fabfd4cb28f7b3dfc6998bf44709eb19ea66d0402d8c22dd"
|
|
2168
2168
|
}
|
|
2169
2169
|
],
|
|
2170
2170
|
"meta": {
|
|
@@ -2260,8 +2260,8 @@
|
|
|
2260
2260
|
{
|
|
2261
2261
|
"path": "models/trestle-table/model.ts",
|
|
2262
2262
|
"target": "{models}/medieval-kit/trestle-table/model.ts",
|
|
2263
|
-
"content": "/**\
|
|
2264
|
-
"hash": "
|
|
2263
|
+
"content": "/**\n * @medieval-kit/trestle-table\n *\n * Low bench-table on two independent splayed end frames. The top is NOT\n * fixed to the frames, it merely rests on them, which is why the boards\n * stand free of the legs and keep their gaps.\n *\n * THIRD TYPOLOGY. Do not go back to the earlier ones:\n * v1/v2 built the later arrangement — a central post on a sled foot at\n * each end with a full-length stretcher between them. The blind critic\n * (70/100) reported that the reference is the OLDER pattern: two square\n * legs per end raked outward into an A along the length, one cross-rail\n * per frame at about a third height with a through-tenon standing proud,\n * and NO lengthwise member at all. The long stretcher was the single\n * largest thing wrong with the negative space, so it is gone; the\n * 'stretcher' part now carries the two end-frame cross-rails.\n * v2 also read tall and thin: the slab is now about a twelfth of the\n * length, the height about half of it, and the top overhangs each foot\n * by about one plank width.\n * v2's long stretcher rendered grey-brown against the rest — a leftover\n * of the mutable-Color helper era. Everything goes through createTinter,\n * and lifts stay within about ±0.015 because oak's LINEAR lightness sits\n * near 0.067 with the tinter floor at 0.045; the pale salmon top of v2\n * came from a +0.04 lift, which at that scale is a 60 percent lightening.\n *\n * The legs are raked but their end cuts stay horizontal (flat on the floor,\n * flat under the slab). A rotated box cannot do that — its foot would touch\n * on one corner and hang 30 mm clear on the other — so each leg is a\n * vertical box passed through a shear matrix instead.\n */\nimport { Color, Matrix4 } from 'three'\n\nimport {\n chamferedBoxGeometry,\n createKitModel,\n createTinter,\n jitter,\n mergeColoured,\n} from '../core/index.ts'\n\nexport interface TrestleTableConfig {\n /** Top length (metres). */\n readonly length: number\n /** Top width (metres). */\n readonly width: number\n readonly height: number\n /** Number of top boards. */\n readonly plankCount: number\n /** Outward rake of the legs along the length (radians). */\n readonly splay: number\n readonly seed: number\n}\n\nexport const trestleTableDefaults: TrestleTableConfig = {\n length: 1.4,\n width: 0.62,\n height: 0.68,\n plankCount: 4,\n splay: 0.31,\n seed: 19,\n}\n\nexport type TrestleTableParts = 'top' | 'trestles' | 'stretcher'\n\nexport function createModel(overrides: Partial<TrestleTableConfig> = {}) {\n return createKitModel<TrestleTableConfig, 'oak', TrestleTableParts>({\n id: 'trestle-table',\n defaults: trestleTableDefaults,\n slots: ['oak'],\n build: ({ config, random }) => {\n const tinter = createTinter(random)\n const oak = (lift = 0): Color => tinter('oak', lift)\n const half = config.height / 2\n\n // Slab: about a twelfth of the length. This is a heavy bench-table,\n // and in the reference the slab edge is as thick as the legs.\n const board = config.length * 0.081\n // Square-section legs, roughly the slab's own thickness.\n const leg = board * 0.95\n // Rake clamped so a patched config cannot fold the legs flat.\n const rake = Math.min(Math.max(config.splay, 0), 0.5)\n const tan = Math.tan(rake)\n\n // --- top: separate boards with a thin gap between them ---\n const planks = []\n const count = Math.max(1, config.plankCount)\n const gap = config.width * 0.008\n const plankWidth = (config.width - gap * (count - 1)) / count\n for (let i = 0; i < count; i += 1) {\n const z = -config.width / 2 + plankWidth / 2 + i * (plankWidth + gap)\n const thickness = board * (1 + jitter(random, 0.07))\n planks.push(chamferedBoxGeometry(\n [config.length, plankWidth],\n [config.length, plankWidth],\n thickness,\n board * 0.09,\n [jitter(random, config.length * 0.004), half - thickness / 2, z],\n oak(-0.004),\n ))\n }\n\n // --- end frames: two raked legs each, flat-cut top and bottom ---\n // Leg tops end INSIDE the slab (0.45 board above its underside), legs\n // stand flat on the floor, and the foot sits one plank width inboard\n // of the slab end so the top overhangs it.\n const legTopY = half - board * 0.45\n const legSpan = legTopY + half\n const legY = (legTopY - half) / 2\n const footX = config.length / 2 - plankWidth - leg / 2\n const legZ = Math.max(leg * 0.55, config.width / 2 - leg * 0.8)\n const trestles = []\n const rails = []\n const railY = -half + config.height * 0.35\n for (const side of [-1, 1] as const) {\n // x' = x + shear * y: top leans inward, foot swings outward.\n const shear = -side * tan\n const centreX = side * footX + shear * (legSpan / 2)\n for (const dir of [-1, 1] as const) {\n const geometry = chamferedBoxGeometry(\n [leg, leg],\n [leg * 0.94, leg * 0.94],\n legSpan,\n leg * 0.12,\n [0, 0, 0],\n oak(-0.012 + jitter(random, 0.004)),\n )\n geometry.applyMatrix4(new Matrix4().set(\n 1, shear, 0, 0,\n 0, 1, 0, 0,\n 0, 0, 1, 0,\n 0, 0, 0, 1,\n ))\n geometry.translate(centreX, legY, dir * legZ)\n trestles.push(geometry)\n }\n\n // --- cross-rail with through-tenon, one per frame, at 35 % height ---\n // The rail follows the legs' lean, so its x is wherever the leg is\n // at rail height. The body ends inside the legs; the slimmer tenon\n // runs through and stands proud by half a leg width outside.\n const railX = centreX + shear * (railY - legY)\n rails.push(chamferedBoxGeometry(\n [leg * 0.58, (legZ + leg * 0.2) * 2],\n [leg * 0.55, (legZ + leg * 0.2) * 2],\n leg * 0.64,\n leg * 0.09,\n [railX, railY, 0],\n oak(-0.008),\n ))\n rails.push(chamferedBoxGeometry(\n [leg * 0.4, (legZ + leg) * 2],\n [leg * 0.4, (legZ + leg) * 2],\n leg * 0.46,\n leg * 0.07,\n [railX, railY, 0],\n oak(-0.014),\n ))\n }\n\n return {\n top: { slot: 'oak', geometry: mergeColoured(planks) },\n trestles: { slot: 'oak', geometry: mergeColoured(trestles) },\n stretcher: { slot: 'oak', geometry: mergeColoured(rails) },\n }\n },\n }, overrides)\n}\n",
|
|
2264
|
+
"hash": "bbff7ae86ab151b0b1fdf542a5b858d2b5e0d581ac61837ad69f07166aeb6ce3"
|
|
2265
2265
|
}
|
|
2266
2266
|
],
|
|
2267
2267
|
"meta": {
|
|
@@ -2347,8 +2347,8 @@
|
|
|
2347
2347
|
{
|
|
2348
2348
|
"path": "models/vegetables/model.ts",
|
|
2349
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": "
|
|
2350
|
+
"content": "/**\n * @medieval-kit/vegetables\n *\n * A market bundle of vegetables, mixed from eight kinds.\n *\n * Six of them are what a medieval European kitchen had -- turnip, cabbage,\n * onion, leek, parsnip and carrot -- and the last two, tomato and potato, are\n * New World crops that only reach Europe after 1492. They are here because a\n * kit is more useful than it is a museum, and the carrot is orange rather than\n * the purple a medieval one would have been. Anyone who wants the strictly\n * period set can have it without thinking about it: the kinds are ordered so\n * that `lead: 0` with `kinds: 6` gives exactly those six and never reaches the\n * two after them.\n *\n * The heap is two parts -- `bulbs` for the things that sit on their base and\n * `roots` for the things that lie on their side -- because those are the two\n * ways a vegetable rests, and a consumer wanting only one of them should not\n * have to take both.\n *\n * FOURTH VERSION. The third authored the composition as a slot list and that\n * held; what failed was the reading of the individual vegetables. The critic\n * saw an agave, two artichokes and a plank: the greens were straight rigid\n * triangles half the model tall, the two cabbages were olive balls with\n * pointed spikes, the lone carrot lay flat enough to read as a board, and the\n * parsnips vanished under it. So this pass:\n * - The reference's four diagonal parsnips are now four authored lay slots,\n * tips overhanging the front-left edge, crowns tucked under the leek's\n * shank near the centre. The carrot slot is gone (the reference has no\n * carrot; the kind survives for configs that ask for it).\n * - The two cabbages merged into ONE at ~2.3 turnip diameters, slightly\n * oblate, with broad blunt-tipped leaves leaning OUT from the base and only\n * gently curling back, instead of narrow spikes hugging the head. The old\n * spikes' tips also arced to the floor and became the \"detached shadow\n * cards\" under the balls; the new lean keeps every tip above the equator.\n * - The greens are five broad drooping blades on a VISIBLE stem bundle that\n * stands on the ground behind the bulb row (base hidden by the pile), not\n * seven straight triangles converging on a buried point. Each blade bends\n * through four levels so the tip falls below horizontal.\n * - The leek is longer (about twice the bulb-cluster width), tapers, stays\n * pale for its lower half and ends in four splayed flat straps; the tomato\n * left the authored bundle (its slot is an onion, which the reference\n * actually has) and the onions went golden.\n *\n * Dead ends worth not repeating:\n * - The leek was three crossed single-sided quads and read as broken geometry.\n * A leaf is `dishedSheetGeometry` (a real V cross-section with thickness)\n * put through `bendGeometry` to droop. Do NOT build the V from two tapered\n * boxes: the taper walks their inner edges apart and leaves a slit.\n * - Carrot and parsnip had a separate pale \"cut crown\" lathe butted on the\n * thick end. It read as a white hexagonal plug from a different toy. The\n * taper must run continuously tip to shoulder in ONE lathe, and the crown is\n * a flare plus green stubs whose bases end inside the body.\n * - `colourTop` interpolates across the WHOLE profile, so a purple crown on a\n * cream turnip bled to its root. The purple stops in a line on the real\n * thing, and a line is a second lathe overlapped a few percent PROUD of the\n * first so the surfaces cross instead of sharing coplanar faces.\n * - Leaves are built AT THE ORIGIN, rotated, then carried into place.\n * - Nothing is set down by arguing where its lowest point ought to be. The\n * bounding box is measured after posing and the piece is translated so the\n * claim \"lowest point at zero\" is true by construction.\n * - Cabbage leaves given a hard positive droop curl toward their own hollow,\n * which faces the head: the tips dig back in and pit the surface. Lean the\n * leaf out with rotateX and keep the droop mild instead.\n */\nimport { Color, type BufferGeometry } from 'three'\n\nimport {\n bendGeometry,\n createKitModel,\n createRandom,\n dishedSheetGeometry,\n jitter,\n latheGeometry,\n mergeColoured,\n taperedBoxGeometry,\n type Level,\n type SheetLevel,\n} from '../core/index.ts'\n\nexport interface VegetablesConfig {\n /** How many vegetables in the heap. */\n readonly count: number\n /** How many of the eight kinds appear in the mix. */\n readonly kinds: number\n /**\n * Which kind leads the mix, and where it starts counting:\n * 0 turnip, 1 cabbage, 2 onion, 3 leek, 4 parsnip, 5 carrot, 6 tomato,\n * 7 potato.\n */\n readonly lead: number\n /** Overall size of one vegetable (metres). */\n readonly size: number\n /** How far the heap spreads, as a multiple of its own size. */\n readonly spread: number\n readonly seed: number\n}\n\nexport const vegetablesDefaults: VegetablesConfig = {\n count: 15,\n kinds: 8,\n lead: 0,\n size: 0.1,\n // The authored bundle is designed at 1.5; the slider scales it looser or\n // tighter around that.\n spread: 1.5,\n seed: 71,\n}\n\nexport type VegetablesParts = 'bulbs' | 'roots'\n\n/** Kinds in the order the `lead` slider indexes them. */\n// Ordered so the first six are the period-correct set: `lead: 0` with\n// `kinds: 6` yields exactly those and never reaches the two after them.\nconst KINDS = [\n 'turnip', 'cabbage', 'onion', 'leek', 'parsnip', 'carrot', 'tomato', 'potato',\n] as const\ntype Kind = (typeof KINDS)[number]\n\n/** Which of them sit on a base and which lie on their side. */\nconst LIES_DOWN: ReadonlySet<Kind> = new Set(['leek', 'parsnip', 'carrot'])\n\n/**\n * The authored bundle. Coordinates are in units of `size`, scaled by `spread`\n * horizontally. Ordered so that supporters come before the supported: every\n * lying root crosses only slots earlier than itself, so a small `count` still\n * yields a coherent little pile instead of an unsupported one.\n *\n * The map of it, matching the reference: turnips and golden onions in a front\n * row, the potato pair among them, the one big cabbage back right, the leek\n * lying diagonally with its cut butt overhanging the front right, and FOUR\n * parsnips laid at 25-35 degrees across the bulb row, tips out past the\n * front-left edge, crowns tucked under the leek shank near the centre.\n */\ninterface SitSlot {\n readonly kind: Kind\n readonly pose: 'sit'\n readonly x: number\n readonly z: number\n /** Extra height, for pieces resting on the tier below. */\n readonly lift?: number\n}\ninterface LaySlot {\n readonly kind: Kind\n readonly pose: 'lay'\n /** Where the thin end goes (x, z) and how high it is. */\n readonly tip: readonly [number, number]\n readonly tipY: number\n /** Where the thick end aims (x, z) and how high it is. */\n readonly crown: readonly [number, number]\n readonly crownY: number\n /** Anchors to use instead when the kind had to be substituted. */\n readonly alt?: Omit<LaySlot, 'kind' | 'pose' | 'alt'>\n}\ntype Slot = SitSlot | LaySlot\n\nconst SLOTS: readonly Slot[] = [\n { kind: 'turnip', pose: 'sit', x: 0.05, z: 0.70 },\n { kind: 'onion', pose: 'sit', x: 0.85, z: 0.60 },\n { kind: 'turnip', pose: 'sit', x: -0.75, z: 0.55 },\n { kind: 'onion', pose: 'sit', x: 0.45, z: -0.10 },\n { kind: 'potato', pose: 'sit', x: -0.50, z: -0.40 },\n // The one big cabbage, back right, on the ground: lifted it showed a slice\n // of daylight under its base from the rear views. It sits BEHIND the leek's\n // line: at (0.75, -0.75) the shank ran straight through its heart.\n { kind: 'cabbage', pose: 'sit', x: 0.95, z: -0.95 },\n { kind: 'onion', pose: 'sit', x: 1.50, z: 0.45 },\n // The leek lies diagonally across the whole pile: butt overhanging the\n // front right, shank wedged over the right onion and resting on the centre\n // onion, straps ending in the air over the greens at the back left, and the\n // whole line passing in FRONT of the cabbage.\n {\n kind: 'leek', pose: 'lay',\n tip: [2.10, 0.90], tipY: 0.52, crown: [-0.15, -0.10], crownY: 1.27,\n alt: { tip: [1.90, 1.20], tipY: 0.10, crown: [0.20, -0.20], crownY: 1.00 },\n },\n // Left-edge supporters for the parsnip shafts.\n { kind: 'turnip', pose: 'sit', x: -1.40, z: 0.55 },\n { kind: 'potato', pose: 'sit', x: -0.85, z: 1.45 },\n // The reference's signature: four parsnips on the same diagonal, lying\n // NEARLY LEVEL on the bulb shoulders with only the tips overhanging.\n // Pitched harder (tips at 0.3, crowns at 0.9) the tips pointed at the\n // ground and read as fangs from the back views.\n {\n kind: 'parsnip', pose: 'lay',\n tip: [-1.70, 1.05], tipY: 0.44, crown: [0.50, -0.05], crownY: 0.88,\n },\n {\n kind: 'parsnip', pose: 'lay',\n tip: [-1.85, 0.55], tipY: 0.48, crown: [0.40, -0.30], crownY: 0.90,\n },\n {\n kind: 'parsnip', pose: 'lay',\n tip: [-1.20, 1.40], tipY: 0.42, crown: [1.05, -0.10], crownY: 0.78,\n },\n {\n kind: 'parsnip', pose: 'lay',\n tip: [-0.95, 1.75], tipY: 0.40, crown: [0.90, 0.60], crownY: 0.70,\n },\n { kind: 'onion', pose: 'sit', x: 1.20, z: 1.10 },\n]\n\nexport function createModel(overrides: Partial<VegetablesConfig> = {}) {\n return createKitModel<VegetablesConfig, 'produce', VegetablesParts>({\n id: 'vegetables',\n defaults: vegetablesDefaults,\n slots: ['produce'],\n\n build: ({ config, random }) => {\n const S = config.size\n const spreadK = config.spread / 1.5\n const hsl = (h: number, s: number, l: number, r: () => number): Color =>\n new Color().setHSL(\n (h + jitter(r, 0.012) + 1) % 1,\n Math.min(1, Math.max(0, s + jitter(r, 0.06))),\n Math.min(1, Math.max(0, l + jitter(r, 0.05))),\n )\n\n /**\n * One leaf blade: a V-section strip with real thickness, drooping away\n * from its base. Built along +Y with the hollow facing +Z; the bend\n * curls it toward the hollow, so rotateX a little, rotateY to aim, and\n * the blade arcs over in that direction. Four levels so the bend is an\n * arc and the outer half actually turns over instead of shearing.\n * `baseF`/`tipF` set how much of `width` the root and the tip keep: the\n * greens want a narrow petiole out of a thin stem, the cabbage wants a\n * blunt ruffled tip.\n */\n function leafBlade(\n len: number, width: number, cup: number, thick: number,\n base: Color, tipColour: Color, droop: number,\n baseF = 0.42, tipF = 0.12,\n ): BufferGeometry {\n const levels: SheetLevel[] = [\n { y: 0, halfWidth: width * baseF, thickness: thick, curve: cup },\n { y: len * 0.42, halfWidth: width * 0.5, thickness: thick, curve: cup * 0.8 },\n { y: len * 0.74, halfWidth: width * 0.4, thickness: thick, curve: cup * 0.5 },\n { y: len, halfWidth: width * tipF, thickness: thick, curve: cup * 0.15 },\n ]\n return bendGeometry(dishedSheetGeometry(levels, 2, base, tipColour), droop)\n }\n\n /**\n * One vegetable, built STANDING at the origin with its base near y = 0\n * and its own axis up. Posing (laying the roots down, tilting, settling\n * onto the ground) is the placement's job, because the pose depends on\n * where in the bundle the piece goes.\n */\n function grow(kind: Kind, r: () => number): BufferGeometry {\n const pieces: BufferGeometry[] = []\n const scale = S * (0.86 + r() * 0.3)\n\n if (kind === 'turnip') {\n // A squat globe, flat on top, drawn down to a root at the bottom.\n const R = scale * 0.5\n const pale = hsl(0.11, 0.16, 0.79, r)\n const purple = hsl(0.8, 0.32, 0.42, r)\n pieces.push(latheGeometry(\n [\n { y: 0, radius: R * 0.1 },\n { y: R * 0.22, radius: R * 0.72 },\n { y: R * 0.62, radius: R },\n { y: R * 0.92, radius: R * 0.97 },\n ] as Level[],\n // No top cap: it is buried under the shoulder.\n 9, [0, 0, 0], pale, { colourTop: pale, capTop: false },\n ))\n // The purple shoulder, a few percent PROUD of the body so the two\n // surfaces cross instead of meeting in coplanar faces.\n pieces.push(latheGeometry(\n [\n { y: R * 0.78, radius: R * 1.04 },\n { y: R * 1.18, radius: R * 0.9 },\n { y: R * 1.42, radius: R * 0.44 },\n ] as Level[],\n 9, [0, 0, 0], purple, { colourTop: purple, capBottom: false },\n ))\n // The cut stalk, a pale nub on the crown.\n pieces.push(latheGeometry(\n [\n { y: R * 1.34, radius: R * 0.2 },\n { y: R * 1.52, radius: R * 0.12 },\n ] as Level[],\n 6, [0, 0, 0], hsl(0.16, 0.22, 0.66, r), { capBottom: false },\n ))\n } else if (kind === 'cabbage') {\n // ONE savoy head at about 2.3 turnip diameters, slightly wider than\n // tall. Two of these at 1.2 diameters read as artichokes.\n const R = scale * 1.15\n const heart = hsl(0.17, 0.50, 0.28, r)\n const outer = hsl(0.18, 0.55, 0.17, r)\n pieces.push(latheGeometry(\n [\n { y: 0, radius: R * 0.5 },\n { y: R * 0.4, radius: R * 0.9 },\n { y: R * 0.9, radius: R },\n { y: R * 1.4, radius: R * 0.88 },\n { y: R * 1.75, radius: R * 0.4 },\n ] as Level[],\n 9, [0, 0, 0], heart, { colourTop: heart },\n ))\n // Broad blunt outer leaves cupping the LOWER half. They lean out\n // from the base with only a mild curl: a hard positive droop curls a\n // leaf into its own hollow -- which faces the head -- and the tips\n // dig in; a hard lean with a long blade sent the old tips to the\n // floor, where they read as detached shadow cards.\n for (let i = 0; i < 5; i += 1) {\n const a = (i / 5) * Math.PI * 2 + r() * 0.7\n const leaf = leafBlade(\n R * 1.15, R * 1.35, R * 0.5, scale * 0.05,\n outer, hsl(0.21, 0.50, 0.22, r), 1.6 + r() * 0.8,\n 0.42, 0.26,\n )\n leaf.rotateX(-0.62 - r() * 0.15)\n leaf.rotateY(a + Math.PI)\n leaf.translate(Math.sin(a) * R * 0.72, R * 0.06, Math.cos(a) * R * 0.72)\n pieces.push(leaf)\n }\n } else if (kind === 'onion') {\n // Golden: the reference onions are the warmest thing in the bundle,\n // and the old cream skin was read as \"a squat orange tomato\" next to\n // them because only the tomato carried any saturation.\n const R = scale * 0.44\n const skin = hsl(0.075, 0.62, 0.40, r)\n pieces.push(latheGeometry(\n [\n { y: 0, radius: R * 0.16 },\n { y: R * 0.3, radius: R * 0.84 },\n { y: R * 0.78, radius: R },\n { y: R * 1.3, radius: R * 0.72 },\n { y: R * 1.62, radius: R * 0.2 },\n { y: R * 1.95, radius: R * 0.07 },\n ] as Level[],\n 9, [0, 0, 0], skin, { colourTop: hsl(0.09, 0.52, 0.52, r) },\n ))\n } else if (kind === 'leek') {\n // A SOLID shank, pale at the cut butt grading to green, with flat\n // splayed straps at the top. The butt cap is real: in the bundle it\n // faces the viewer. Long: the reference leek spans about twice the\n // bulb cluster and overhangs the front right.\n const L = scale * 4.3\n const R = scale * 0.24\n // The colour lerp runs level by level, so the pale half is held by\n // crowding the levels toward the butt: the midpoint of the blend\n // sits at 70% of the length.\n pieces.push(latheGeometry(\n [\n { y: 0, radius: R },\n { y: L * 0.5, radius: R * 0.96 },\n { y: L * 0.7, radius: R * 0.9 },\n { y: L * 0.86, radius: R * 0.82 },\n { y: L, radius: R * 0.72 },\n ] as Level[],\n 10, [0, 0, 0], hsl(0.14, 0.16, 0.66, r),\n { colourTop: hsl(0.22, 0.50, 0.20, r) },\n ))\n // Three-to-four flat dark straps splaying from the top, fanned only\n // across the local +X half: in the lay pose rotateZ carries local +X\n // upward, so this is what keeps a strap from diving down through\n // the pile like a thrown knife.\n for (let i = 0; i < 4; i += 1) {\n const a = 0.35 + (i / 3) * (Math.PI - 0.7) + jitter(r, 0.12)\n const strap = leafBlade(\n L * 0.40 * (0.9 + r() * 0.2), R * 2.3, R * 0.35, scale * 0.045,\n hsl(0.23, 0.50, 0.16, r), hsl(0.26, 0.55, 0.10, r),\n 1.1 + r() * 0.7, 0.30, 0.16,\n )\n strap.rotateX(0.12 + r() * 0.18)\n strap.rotateY(a)\n strap.translate(Math.sin(a) * R * 0.3, L * 0.94, Math.cos(a) * R * 0.3)\n pieces.push(strap)\n }\n } else if (kind === 'tomato') {\n // Squat, flattened top and bottom, with the calyx still on it.\n const R = scale * 0.42\n pieces.push(latheGeometry(\n [\n { y: 0, radius: R * 0.34 },\n { y: R * 0.24, radius: R * 0.86 },\n { y: R * 0.7, radius: R },\n { y: R * 1.18, radius: R * 0.84 },\n { y: R * 1.36, radius: R * 0.3 },\n ] as Level[],\n 9, [0, 0, 0], hsl(0.02, 0.76, 0.42, r),\n { colourTop: hsl(0.03, 0.7, 0.34, r) },\n ))\n const green = hsl(0.18, 0.62, 0.14, r)\n for (let i = 0; i < 5; i += 1) {\n const a = (i / 5) * Math.PI * 2\n const sepal = taperedBoxGeometry(\n [R * 0.3, R * 0.07],\n [R * 0.1, R * 0.05],\n R * 0.6,\n [0, 0, 0],\n green,\n )\n // Flat on the shoulder and sunk so the base half is inside the\n // crown: sat higher they floated as loose slivers over the top.\n sepal.rotateZ(1.32)\n sepal.rotateY(a)\n sepal.translate(Math.sin(a) * R * 0.16, R * 1.12, Math.cos(a) * R * 0.16)\n pieces.push(sepal)\n }\n } else if (kind === 'potato') {\n // Lumpy and asymmetric on purpose: its levels wander instead of\n // describing a curve, because a smooth ellipsoid is an egg.\n const R = scale * 0.4\n pieces.push(latheGeometry(\n [\n { y: 0, radius: R * 0.4 },\n { y: R * 0.34, radius: R * (0.88 + jitter(r, 0.12)) },\n { y: R * 0.82, radius: R * (1.02 + jitter(r, 0.14)) },\n { y: R * 1.3, radius: R * (0.94 + jitter(r, 0.14)) },\n { y: R * 1.72, radius: R * (0.7 + jitter(r, 0.1)) },\n { y: R * 2, radius: R * 0.3 },\n ] as Level[],\n 8, [0, 0, 0], hsl(0.09, 0.3, 0.56, r),\n { colourTop: hsl(0.08, 0.26, 0.5, r) },\n ))\n } else {\n // Parsnip and carrot: ONE continuous lathe from a thin fibrous tip\n // through a flared shoulder, closed by a rounded crown carrying a\n // cluster of green leaf stubs whose bases end inside the body.\n const carrot = kind === 'carrot'\n const len = scale * (carrot ? 3.1 : 2.7)\n const R = scale * (carrot ? 0.24 : 0.30)\n // The parsnip is TAN, not white: pale as it is next to a carrot, at\n // full lightness it read as a set of horns lying on the pile.\n const body = carrot ? hsl(0.07, 0.82, 0.47, r) : hsl(0.095, 0.38, 0.58, r)\n const crown = carrot ? hsl(0.08, 0.62, 0.56, r) : hsl(0.10, 0.34, 0.64, r)\n pieces.push(latheGeometry(\n [\n { y: 0, radius: R * 0.05 },\n { y: len * 0.18, radius: R * 0.38 },\n { y: len * 0.55, radius: R * 0.74 },\n { y: len * 0.88, radius: R * 0.95 },\n { y: len * 0.97, radius: R * 1.03 },\n { y: len, radius: R * 0.5 },\n ] as Level[],\n 8, [0, 0, 0], body, { colourTop: crown },\n ))\n // A couple of fibrous rootlets at the tip, bases inside the cone.\n const fibre = hsl(0.09, 0.28, 0.44, r)\n for (let i = 0; i < 2; i += 1) {\n const f = taperedBoxGeometry(\n [R * 0.1, R * 0.08], [R * 0.02, R * 0.02], len * 0.12, [0, 0, 0], fibre,\n )\n f.rotateZ(Math.PI - (0.35 + r() * 0.3))\n f.rotateY(r() * Math.PI * 2)\n f.translate(0, len * 0.09, 0)\n pieces.push(f)\n }\n const stub = hsl(0.17, 0.46, 0.17, r)\n for (let i = 0; i < 3; i += 1) {\n const a = (i / 3) * Math.PI * 2 + jitter(r, 0.6)\n const s = taperedBoxGeometry(\n [R * 0.26, R * 0.2], [R * 0.1, R * 0.08], len * 0.14, [0, 0, 0], stub,\n )\n s.rotateZ(0.24 + r() * 0.2)\n s.rotateY(a)\n s.translate(Math.sin(a) * R * 0.16, len * 0.985, Math.cos(a) * R * 0.16)\n pieces.push(s)\n }\n }\n\n return mergeColoured(pieces)\n }\n\n /** Lowest point to y = 0, measured, not argued. */\n function settle(g: BufferGeometry): void {\n g.computeBoundingBox()\n g.translate(0, -(g.boundingBox?.min.y ?? 0), 0)\n }\n\n // --- The bundle -----------------------------------------------------\n const count = Math.max(1, Math.round(config.count))\n const kindCount = Math.min(KINDS.length, Math.max(1, Math.round(config.kinds)))\n const lead = ((Math.round(config.lead) % KINDS.length) + KINDS.length) % KINDS.length\n const mix = Array.from({ length: kindCount }, (_, i) => KINDS[(lead + i) % KINDS.length]!)\n const sitters = mix.filter((k) => !LIES_DOWN.has(k))\n const liers = mix.filter((k) => LIES_DOWN.has(k))\n\n /** The slot's kind if the mix allows it, else one of matching posture. */\n function pickKind(i: number, prefer: Kind, wantsLie: boolean): Kind {\n if (mix.includes(prefer)) return prefer\n const pool = wantsLie\n ? (liers.length > 0 ? liers : mix)\n : (sitters.length > 0 ? sitters : mix)\n return pool[i % pool.length]!\n }\n\n const bulbs: BufferGeometry[] = []\n const roots: BufferGeometry[] = []\n\n for (let i = 0; i < count; i += 1) {\n // A separate stream per item, so adding one vegetable does not\n // reshuffle every vegetable after it.\n const r = createRandom(config.seed * 31 + i * 977)\n const slot = SLOTS[i]\n\n if (slot !== undefined && slot.pose === 'lay') {\n const kind = pickKind(i, slot.kind, true)\n const g = grow(kind, r)\n // A substituted kind has its own proportions, so it may carry its\n // own anchors.\n const a = kind !== slot.kind && slot.alt ? slot.alt : slot\n const K = S * spreadK\n const dx = (a.crown[0] - a.tip[0]) * K\n const dz = (a.crown[1] - a.tip[1]) * K\n const pitch = Math.atan2((a.crownY - a.tipY) * S, Math.hypot(dx, dz))\n g.rotateZ(Math.PI / 2 - pitch)\n g.rotateY(Math.atan2(dz, -dx))\n g.translate(a.tip[0] * K, a.tipY * S, a.tip[1] * K)\n // The anchors put the AXIS at tipY; a substituted round kind is\n // fatter below its axis than the roots these slots were drawn for\n // and can breach the ground. Lifting is allowed, sinking is not.\n g.computeBoundingBox()\n const under = g.boundingBox?.min.y ?? 0\n if (under < 0) g.translate(0, -under, 0)\n ;(LIES_DOWN.has(kind) ? roots : bulbs).push(g)\n } else {\n // Sitting slot, or the overflow ring past the authored bundle.\n const inRing = slot === undefined\n const kind = inRing\n ? mix[i % mix.length]!\n : pickKind(i, (slot as SitSlot).kind, false)\n const g = grow(kind, r)\n if (LIES_DOWN.has(kind)) {\n // A lying kind in a sitting spot is laid flat where it stands.\n g.rotateZ(Math.PI / 2)\n } else {\n g.rotateX(jitter(r, 0.09))\n g.rotateZ(jitter(r, 0.09))\n }\n g.rotateY(r() * Math.PI * 2)\n settle(g)\n if (inRing) {\n const angle = i * 2.399963\n const ring = S * spreadK * (1.9 + 0.3 * Math.floor(i / SLOTS.length))\n g.translate(Math.sin(angle) * ring, 0, Math.cos(angle) * ring)\n } else {\n const s = slot as SitSlot\n g.translate(s.x * S * spreadK, (s.lift ?? 0) * S, s.z * S * spreadK)\n }\n ;(LIES_DOWN.has(kind) ? roots : bulbs).push(g)\n }\n }\n\n // The greens: a narrow stem bundle standing on the ground behind the\n // bulb row (its foot hidden between the potato and the cabbage), with\n // five broad blades fanning from its top, each bending over so the tip\n // falls below horizontal. Dressing, not a counted vegetable. The old\n // version was seven straight blades converging on a buried point, and\n // it read as an agave.\n {\n const r = createRandom(config.seed * 31 + 991)\n const pieces: BufferGeometry[] = []\n const stemTop = 1.6 * S\n pieces.push(latheGeometry(\n [\n { y: 0, radius: 0.20 * S },\n { y: 0.9 * S, radius: 0.17 * S },\n { y: stemTop, radius: 0.13 * S },\n ] as Level[],\n 7, [0, 0, 0], hsl(0.20, 0.35, 0.42, r),\n { colourTop: hsl(0.22, 0.45, 0.30, r) },\n ))\n for (let i = 0; i < 5; i += 1) {\n const a = (i / 5) * Math.PI * 2 + jitter(r, 0.4)\n const len = S * (1.35 + r() * 0.35)\n const leaf = leafBlade(\n len, S * 1.4, S * 0.30, S * 0.05,\n hsl(0.24, 0.52, 0.15, r), hsl(0.21, 0.58, 0.21, r),\n 6.0 + r() * 2.0, 0.14, 0.18,\n )\n leaf.rotateX(0.5 + r() * 0.2)\n leaf.rotateY(a)\n // Base sunk below the stem's top so every petiole is rooted.\n leaf.translate(Math.sin(a) * 0.06 * S, stemTop - 0.15 * S, Math.cos(a) * 0.06 * S)\n pieces.push(leaf)\n }\n const bunch = mergeColoured(pieces)\n bunch.translate(-0.90 * S * spreadK, 0, -0.55 * S * spreadK)\n // A hard-drooping blade can arc below the stem's foot; lift, never\n // sink.\n bunch.computeBoundingBox()\n const under = bunch.boundingBox?.min.y ?? 0\n if (under < 0) bunch.translate(0, -under, 0)\n bulbs.push(bunch)\n }\n\n // `random` is the model's own stream and every vegetable uses its own,\n // so draw from it once to keep the seed meaningful at this level too.\n void random()\n\n return {\n bulbs: { slot: 'produce' as const, geometry: mergeColoured(bulbs.length ? bulbs : roots) },\n roots: roots.length > 0 && bulbs.length > 0\n ? { slot: 'produce' as const, geometry: mergeColoured(roots) }\n : undefined,\n }\n },\n }, overrides)\n}\n",
|
|
2351
|
+
"hash": "8074886d524077f85f09acdb79870e1784e11098e26103a61dec4f3968d5821f"
|
|
2352
2352
|
}
|
|
2353
2353
|
],
|
|
2354
2354
|
"meta": {
|
|
@@ -2432,8 +2432,8 @@
|
|
|
2432
2432
|
{
|
|
2433
2433
|
"path": "models/wicker-basket/model.ts",
|
|
2434
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": "
|
|
2435
|
+
"content": "/**\n * @medieval-kit/wicker-basket\n *\n * A basket woven from willow rods, with a bow handle, optionally filled with\n * 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 * HISTORY, because this model is on its third body:\n *\n * v2 read as a bowl of fired clay, and the blind critique named the three\n * reasons. (1) No handle: the reference's single most identifying feature is a\n * bow handle arcing well above the rim, and without it the silhouette is a\n * bowl, not a carrying basket. (2) The wall was a cone — base half the rim —\n * where the reference is a near-vertical drum whose inward curve lives in the\n * bottom fifth. `radiusAt` now has a knee at t=0.2: eased curve below it,\n * near-vertical above. Because the wall is no longer a straight line, a\n * one-piece leaning stake no longer follows it; each stake is two prisms, a\n * steep lower one under the knee and a near-vertical upper one, overlapped at\n * the joint. (3) Four fat courses with a deep sawtooth read as stacked blocks;\n * there are now more, thinner courses and the undulation amplitude is a third\n * of what it was. The colour was salmon because the oak entry is saturated and\n * every lift pushed it toward peach; the `willow` wrapper below desaturates\n * every tint toward grey-brown instead of lightening it (lifting was tried in\n * v2 and documented as the salmon mistake — do not go back to it).\n *\n * The critique also caught a modelling error: the lowest points of the model\n * were the dips of the bottom course's zigzag, so the basket would rock. The\n * base is now a foot ring standing 2% of the height below the wall, with a\n * flat bottom cap, and its top plate is wide enough to reach under the bottom\n * course and the stakes — an earlier draft of this fix left the base plate at\n * the nominal base radius, which the flaring wall never touches, and the base\n * came out a second floating component.\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 arcBarGeometry,\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 height: 0.16,\n radius: 0.17,\n // Base 0.79 of the rim. The reference's wall is a near-vertical drum; the\n // taper is small and, through the knee in `radiusAt`, spent almost entirely\n // in the bottom fifth.\n taper: 0.21,\n stakes: 11,\n rows: 9,\n produce: 12,\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 // Floors, not trust: several of these appear in denominators or in the\n // sagitta of the handle arc, and a zero patched in from outside must not\n // become a NaN that the occlusion bake then smears over every part.\n const H = Math.max(0.05, config.height)\n const R = Math.max(0.04, config.radius)\n const half = H / 2\n const stakes = Math.max(5, Math.round(config.stakes))\n const rows = Math.max(1, Math.round(config.rows))\n const taper = Math.min(0.7, Math.max(0, config.taper))\n const bottomRadius = R * (1 - taper)\n const rod = H * 0.026 // horizontal withy thickness (halved from v2)\n const stakeThick = H * 0.021 // uprights keep their old, visible section\n // A third of v2's 0.055: the courses should read as bent willow, and at\n // 0.055 they read as stacked blocks. The zigzag survives at 0.02 because\n // consecutive rows still move in antiphase.\n const amplitude = 0.02\n\n /**\n * Willow is not oak-coloured. The `oak` palette entry has the right hue\n * but weathered willow is greyer, and v2 proved that LIFTING the entry\n * moves it toward peach, not toward grey (lightness holds saturation).\n * So every basket tint passes through here and has its saturation set\n * against the reference rather than inherited. The tinter returns a new Color per call, so mutating it in\n * place is safe.\n *\n * The factor was a third, and a third was right when `oak` was a saturated\n * orange-brown. That entry has since been measured against its references\n * and pulled toward grey itself, so taking another third off it greyed the\n * basket 0.20 below its own reference. Two corrections for the same fault,\n * applied one on top of the other. Measured back the other way, the\n * reference willow is in fact WARMER than the corrected oak, so the factor\n * is now above one.\n */\n const willow = (lift: number, spread = 1.2): Color => {\n const c = tint('oak', lift, spread)\n const hsl = { h: 0, s: 0, l: 0 }\n c.getHSL(hsl)\n c.setHSL(hsl.h, Math.max(0, hsl.s * 1.3), hsl.l)\n return c\n }\n\n /**\n * Wall profile. Near-vertical drum over the top four fifths, the whole\n * inward curve eased into the bottom fifth — the knee. `rKnee` is floored\n * at `bottomRadius` so a cylinder (taper 0) stays a cylinder instead of\n * denting inward at the knee.\n */\n const knee = 0.2\n const rKnee = Math.max(bottomRadius, R * 0.97)\n const radiusAt = (t: number): number => {\n if (t >= knee) return rKnee + (R - rKnee) * ((t - knee) / (1 - knee))\n const s = t / knee\n return bottomRadius + (rKnee - bottomRadius) * s * (2 - s)\n }\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 // Two prisms per stake, because the wall is no longer a straight line: a\n // steep lower segment rounds through the knee, a near-vertical upper one\n // runs to the rim. They overlap at the joint; both far ends are buried —\n // the bottom behind the lowest course, the top inside the rim ring — so\n // only the lower bottom is capped and the rest of the tube ends stay open\n // where nothing can see them.\n const pieces: BufferGeometry[] = []\n const yKnee = -half + knee * H\n const yStakeBottom = -half + H * 0.012\n const yStakeTop = half - H * 0.02\n const overlap = H * 0.02\n const rLow = radiusAt(0.012)\n const segment = (\n y0: number, r0: number, y1: number, r1: number, angle: number,\n colour: Color, options: { capTop?: boolean; capBottom?: boolean },\n ): BufferGeometry => {\n const length = Math.hypot(y1 - y0, r1 - r0)\n const piece = prismGeometry(\n stakeThick * 0.52, stakeThick * 0.46, length, 4, [0, 0, 0], colour, options,\n )\n piece.rotateX(Math.atan2(r1 - r0, y1 - y0))\n piece.rotateY(angle)\n const mid = (r0 + r1) / 2\n piece.translate(Math.sin(angle) * mid, (y0 + y1) / 2, Math.cos(angle) * mid)\n return piece\n }\n for (let i = 0; i < stakes; i += 1) {\n const angle = (i / stakes) * Math.PI * 2\n const shade = willow(-0.05, 1.2)\n pieces.push(segment(\n yStakeBottom, rLow, yKnee + overlap, rKnee, angle, shade,\n { capTop: false, capBottom: true },\n ))\n pieces.push(segment(\n yKnee - overlap, rKnee, yStakeTop, R, angle, shade,\n { capTop: false, capBottom: false },\n ))\n }\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.\n //\n // The INNER FACE of the hoops is not generated. In its place there is a\n // single-piece inner liner (below). The weave stops BELOW the rim: the rim\n // is a separate, thicker rod laid over the finished weave, which is how\n // the object is actually made.\n const weaveSpan = H * 0.92\n for (let r = 0; r < rows; r += 1) {\n const t = (r + 0.5) / rows\n const y = -half + weaveSpan * t\n // Rows overlap slightly so they always MEET; they do not z-fight\n // because consecutive rows are undulated half a wave apart AND lean\n // alternately in and out, so no two rows lie on the same cylinder.\n const lean = r % 2 === 0 ? rod * 0.2 : -rod * 0.2\n const ring = bandGeometry(\n radiusAt(t) + lean, y, (weaveSpan / rows) * 1.06, rod * 0.8, stakes * 2,\n willow(0.02 + jitter(random, 0.05)),\n )\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. It follows the wall\n // profile at 0.955 of it — inside the courses, outside the base plate,\n // its top edge buried in the rim ring.\n pieces.push(flipGeometry(latheGeometry([\n { y: -half + H * 0.04, radius: radiusAt(0.05) * 0.955 },\n { y: yKnee, radius: rKnee * 0.955 },\n { y: half - H * 0.02, radius: R * 0.955 },\n ], stakes * 2, [0, 0, 0], willow(-0.02, 1.1), {\n capTop: false,\n capBottom: false,\n })))\n\n // --- Base and foot -------------------------------------------------------\n // One lathe: a foot ring the basket actually stands on (flat bottom cap\n // 2% of the height below the wall, so the bottom course's zigzag dips\n // stay clear of the ground), swelling slightly, then flaring up and OUT\n // to a floor plate at `radiusAt(0.03)` — wide enough to pass under the\n // bottom course and the stake feet, which is what keeps the base part of\n // the same solid as the wall.\n const footR = bottomRadius * 0.98\n const yGround = -half - H * 0.02\n const yFloor = -half + H * 0.06\n pieces.push(latheGeometry([\n { y: yGround, radius: footR * 0.95 },\n { y: yGround + H * 0.03, radius: footR * 1.03 },\n { y: yFloor, radius: radiusAt(0.03) * 0.99 },\n ], stakes * 2, [0, 0, 0], willow(-0.06, 1.2)))\n\n // --- Rim -----------------------------------------------------------------\n // The thick bend that finishes the weave: the heaviest rod in a basket,\n // bent over the finished weave, standing proud on both sides. Everything\n // that must vanish — liner top, stake tops, handle feet — ends inside\n // this solid.\n const rimY = half - H * 0.03\n const rimHalfH = H * 0.05\n const rimOuter = R * 1.02\n const rimThick = rod * 3.5\n const rimPieces: BufferGeometry[] = [\n bandGeometry(rimOuter, rimY, rimHalfH * 2, rimThick, stakes * 2,\n willow(0.05, 1.2), { inner: true }),\n ]\n\n // --- Handle --------------------------------------------------------------\n // The bow that makes it a carrying basket rather than a bowl. A flat\n // band — twice as wide in the plane of the loop as it is deep — arcing\n // rim to rim across the diameter and peaking about 0.6 of the body\n // height above the rim. The arc is solved from its chord and sagitta, so\n // the feet land at mid-wall of the rim ring (inside the solid, not\n // butted against the outer face) whatever the proportions are patched to.\n const handleWide = Math.min(rimThick * 0.9, R * 0.09)\n const handleDeep = handleWide * 0.5\n const footY = rimY - rimHalfH * 0.4\n const footX = rimOuter - rimThick * 0.5\n const peak = rimY + H * 0.62\n const sagitta = Math.max(0.01, peak - footY)\n const arcR = (footX * footX + sagitta * sagitta) / (2 * sagitta)\n const arcCentreY = peak - arcR\n const footAngle = Math.atan2(footY - arcCentreY, footX)\n const handle = arcBarGeometry(\n arcR, handleWide, footAngle, Math.PI - footAngle, 7,\n [0, arcCentreY, 0], willow(0.03, 1.1),\n )\n // The bar comes out square; flattening it in Z (the out-of-loop\n // direction) turns it into the band. Scale is safe here because the arc\n // is only ever offset in Y.\n handle.scale(1, 1, handleDeep / handleWide)\n rimPieces.push(handle)\n\n // Lashings: a short sleeve wrapped around each handle foot just above\n // the rim, its lower end sunk into the rim ring — the bindings that hold\n // a real handle. The sleeve is held by the handle passing through it.\n for (const side of [0, 1] as const) {\n const a = side === 0 ? footAngle + 0.12 : Math.PI - footAngle - 0.12\n const sleeve = prismGeometry(\n handleWide * 0.72, handleWide * 0.66, H * 0.2, 4, [0, 0, 0],\n willow(-0.03, 1.1),\n )\n sleeve.rotateZ(a)\n sleeve.translate(Math.cos(a) * arcR, arcCentreY + Math.sin(a) * arcR, 0)\n rimPieces.push(sleeve)\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 = R * (0.2 + random() * 0.07)\n // Apple profile: dimpled top and bottom, wide in the middle.\n const fruit = latheGeometry([\n { y: -size * 0.82, radius: size * 0.36 },\n { y: -size * 0.45, radius: size * 0.9 },\n { y: size * 0.15, radius: size * 0.97 },\n { y: size * 0.8, radius: size * 0.4 },\n ] as Level[], 6, [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\n // square root, and the heap RESTS ON THE FLOOR PLATE — fruit piles up\n // from the bottom, and with enough of it the heap mounds past the rim.\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 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 // Floor plate + one radius = resting on the floor of the basket.\n // Layers nest at 0.95 of a diameter: stacked fruit settles into the\n // gaps of the layer below rather than sitting on top of it.\n yFloor + 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: mergeColoured(rimPieces) },\n contents: contents.length > 0\n ? { slot: 'produce' as const, geometry: mergeColoured(contents) }\n : undefined,\n }\n },\n }, overrides)\n}\n",
|
|
2436
|
+
"hash": "64e09a60a269cd37c7b3ff36d349cb72a082755d1c7d664cb3390eb1b66b7bab"
|
|
2437
2437
|
}
|
|
2438
2438
|
],
|
|
2439
2439
|
"meta": {
|
|
@@ -2534,8 +2534,8 @@
|
|
|
2534
2534
|
{
|
|
2535
2535
|
"path": "models/wooden-barrel/model.ts",
|
|
2536
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": "
|
|
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 // 0.22, not 0.17. A cask is noticeably bellied and this one was close to a\r\n // drum; at 0.22 the belly is 1.28 times the ends, which is what the reference\r\n // measures.\r\n taper: 0.22,\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, so\r\n // seventeen it is.\r\n //\r\n // Six hoops was wrong, and the note that used to sit here saying four read as\r\n // too few was written against a different photograph. Our reference carries\r\n // four wide bands, and six put them in doubled pairs at each end. Worse, the\r\n // chime hoops stand proud of the staves they wrap, so a pair of them at each\r\n // end fills in the very taper the belly is supposed to show: measured off the\r\n // silhouette the cask read as 1.14 wide at the belly against its ends where\r\n // the staves alone are 1.20, and a critic looking at the render called it a\r\n // straight drum. Cutting to four both matches the reference and lets the\r\n // profile be seen.\r\n staveCount: 17,\r\n hoopCount: 4,\r\n rivets: 4,\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 // DARKER than the band, not lighter. A rivet head is a small dome\r\n // and the light that reaches its underside is what you see of it;\r\n // lifting it turned four pale diamonds loose on every hoop, and at\r\n // the silhouette they read as detached geometry rather than as part\r\n // of the iron.\r\n const studTint = tint.clone()\r\n studTint.offsetHSL(0, 0, -0.05)\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": "17e7d6eac1eecf1d6022585ee99e4b2e330f0e67492c4382129d39d28b2c6de7"
|
|
2539
2539
|
}
|
|
2540
2540
|
],
|
|
2541
2541
|
"meta": {
|
|
@@ -2628,8 +2628,8 @@
|
|
|
2628
2628
|
{
|
|
2629
2629
|
"path": "models/wooden-bench/model.ts",
|
|
2630
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": "
|
|
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 * Two thick end boards and a seat, and NOTHING BETWEEN THEM. This bench carried\r\n * a lengthwise stretcher, copied across from the trestle table, and the\r\n * reference has none: each board runs uninterrupted from the floor to the seat,\r\n * and the clear open span underneath is a large part of what separates a bench\r\n * from a table at a glance. Removing it drops a part from the model, which is a\r\n * breaking change and worth one, because what it buys is the silhouette.\r\n *\r\n * The seat IS fixed to the legs, which is the other thing that separates the\r\n * two: a table top can be lifted off, a bench seat cannot. The legs are tenoned\r\n * into it and pegged through, and the peg is the joint anyone can actually see.\r\n * The tenon is cut off flush with the seat in the reference and reads there as\r\n * a faint rectangle of end grain, which is a texture rather than a shape, so it\r\n * stays housed inside the slab where an earlier pass put it after two separate\r\n * complaints about a block of oak standing proud of the surface you sit on.\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'\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 * Wedge peg: driven through the leg board just under the seat, showing\r\n * on both broad faces.\r\n *\r\n * The reference has it and this model did not, and it is the one joint\r\n * detail a viewer can actually see. The tenon into the seat cannot be\r\n * seen at all -- in the reference its end grain is cut off flush with\r\n * the seat and reads as a faint rectangle, which is a texture and not a\r\n * shape, so there is nothing for geometry to do there. The peg is the\r\n * opposite: a small solid standing out of a flat board, which is\r\n * exactly what lowpoly geometry is good at.\r\n *\r\n * It goes right through and out the far side, because a peg that stops\r\n * inside the board is a peg that is doing nothing.\r\n */\r\n const pegOut = timber * 0.62\r\n const boardThickness = timber * 1.35\r\n legPieces.push(chamferedBoxGeometry(\r\n [legWidth * 0.16, boardThickness + pegOut * 2],\r\n [legWidth * 0.13, boardThickness + pegOut * 2],\r\n timber * 0.72,\r\n timber * 0.12,\r\n [side * legX, seatBottom - timber * 1.15, 0],\r\n tint('oak', 0.07),\r\n ))\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 }\r\n },\r\n }, overrides)\r\n}\r\n",
|
|
2632
|
+
"hash": "b080b2626caa09e9c375c5d3dba50a3a4385f15de7c399990f4688748e1009a1"
|
|
2633
2633
|
}
|
|
2634
2634
|
],
|
|
2635
2635
|
"meta": {
|
|
@@ -2694,8 +2694,7 @@
|
|
|
2694
2694
|
],
|
|
2695
2695
|
"parts": [
|
|
2696
2696
|
"seat",
|
|
2697
|
-
"legs"
|
|
2698
|
-
"stretcher"
|
|
2697
|
+
"legs"
|
|
2699
2698
|
],
|
|
2700
2699
|
"sockets": []
|
|
2701
2700
|
}
|
|
@@ -2811,8 +2810,8 @@
|
|
|
2811
2810
|
{
|
|
2812
2811
|
"path": "models/wooden-chest/model.ts",
|
|
2813
2812
|
"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": "
|
|
2813
|
+
"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 /*\n * Two narrow brackets on each end board, matching the front straps.\n *\n * This was ONE strap `depth * 0.16` wide, which is about three times the\n * section of the straps on the front, and `wallHeight * 0.78` tall centred\n * on the panel, so it stopped a ninth of the wall short at the top and the\n * same at the bottom. A broad slab finishing in mid-panel above the foot\n * does not read as ironwork; it reads as an unfinished cut. Same section\n * and same run as the front now, which is what the reference has.\n */\n for (const side of [-1, 1]) {\n for (const z of [-config.depth * 0.28, config.depth * 0.28]) {\n bandPieces.push(boxGeometry(\n [strap, wallHeight - board * 0.9, strapWidth],\n [side * (config.width / 2 + strap * 0.2), bodyFloor + wallHeight / 2, z],\n ironTint(random, -0.04),\n ))\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",
|
|
2814
|
+
"hash": "e2a8da25318815139f5486a6547cbe01c69936a2e4a8f426c8df72f3b0ce3d9a"
|
|
2816
2815
|
}
|
|
2817
2816
|
],
|
|
2818
2817
|
"meta": {
|
|
@@ -2902,8 +2901,8 @@
|
|
|
2902
2901
|
{
|
|
2903
2902
|
"path": "models/wooden-crate/model.ts",
|
|
2904
2903
|
"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": "
|
|
2904
|
+
"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 // Four rows, not three. The reference reads as a stack of boards and at\n // three courses each one is deep enough to look like a panel with a line\n // scored across it.\n plankRows: 4,\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.015\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 /**\n * The diagonal brace, which is the thing the reference leads with.\n *\n * One board corner post to corner post across each long face, standing\n * proud of the courses by half its own thickness so it crosses them\n * instead of joining them. Without it the long face is a field of\n * parallel horizontals and the crate reads as a panelled box; with it\n * the face reads as framed boarding, which is what it is.\n */\n const diagLen = Math.hypot(spanX, wallTop * 2) * 0.99\n const diagAngle = Math.atan2(spanX, wallTop * 2)\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), 0.03 + jitter(random, 0.04))\n const brace = chamferedBoxGeometry(\n [board * 0.95, board * 0.8], [board * 0.95, board * 0.8],\n diagLen, board * 0.14, [0, 0, 0], tint,\n )\n // Built standing; laid over onto the diagonal. rotateZ sends +Y to\n // (-sin, cos), so the angle is negated to send it toward +X.\n brace.rotateZ(-side * diagAngle)\n brace.translate(0, 0, side * (faceZ + board * 0.45))\n pieces.push(brace)\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",
|
|
2905
|
+
"hash": "fb8163654d689a5afbb9e3382ac087e23acba4719d65dce299248738215790b0"
|
|
2907
2906
|
}
|
|
2908
2907
|
],
|
|
2909
2908
|
"meta": {
|
|
@@ -2990,8 +2989,8 @@
|
|
|
2990
2989
|
{
|
|
2991
2990
|
"path": "models/wooden-fence/model.ts",
|
|
2992
2991
|
"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": "
|
|
2992
|
+
"content": "/**\n * @medieval-kit/wooden-fence\n *\n * Heavy single-bay mortised fence: two stocky posts, round riven rails\n * passing straight through them and overhanging as full-section tenons.\n *\n * THIRD attempt. The first was cardboard (four sticks, no joinery). The\n * second invented the right joint — a real geometric hole through the post,\n * rail passing through, tenon stubs at the ends — and still scored 69,\n * worst axis MASSES. The critic's diagnosis, and it was right on every\n * count once the render sat next to the reference:\n *\n * - The reference is a SHORT, STOCKY object: one bay, two posts about a\n * fifth of the span wide, rails near 60% of the post width. Version two\n * was a 5 m ranch fence of three posts and two bays with thin battens.\n * Same joint, completely different body. So: sections defaults to 1,\n * the span is about 1.4x the height, the post is a broad near-square\n * slab (0.25x height wide), and the rails are fat octagonal logs.\n * - The tenon stubs were nubs. Now the rail itself simply CONTINUES past\n * the far post face by about one post width, in its own full section,\n * with a slightly mushroomed end-grain tip. One continuous member per\n * rail height also makes the joint rule identical at every post, which\n * kills the second modelling error (middle post read as butted while\n * the end posts read as mortised).\n * - The old brace ran diagonally ALONG the fence and its foot hovered\n * 25 mm above the ground — the critic saw the detached shadow. The new\n * one is a raking shore leaning back in Z: foot buried below grade,\n * head terminating INSIDE the post's bridge solid, aimed at the bridge\n * band between the rail slots of that actual post (heights recorded per\n * post, because postH is jittered).\n *\n * Two silent bugs found while in here, both straight from the kit's trap\n * list: the local `shade()` helper returned ONE shared Color, so any call\n * taking two tints as arguments got the second value twice (now\n * createTinter); and the soil mounds carried a -0.19 linear lift, which is\n * most of the palette's whole range — they rendered as black wedges. The\n * reference has bare ground anyway, so the mounds are gone.\n */\nimport type { BufferGeometry } from 'three'\n\nimport {\n chamferedBoxGeometry,\n createKitModel,\n createTinter,\n jitter,\n mergeColoured,\n prismGeometry,\n taperedBoxGeometry,\n} from '../core/index.ts'\n\nexport interface WoodenFenceConfig {\n /** Number of sections. Each section is the span between two posts. */\n readonly sections: number\n /** Length of one section (metres). About 1.4x the height reads right. */\n readonly sectionLength: number\n readonly height: number\n /** Number of horizontal rails. */\n readonly railCount: number\n /** Curvature and height deviation of the posts. 0 = factory straightness. */\n readonly rough: number\n /** Whether a brace is placed at one end (0/1). */\n readonly brace: number\n readonly seed: number\n}\n\nexport const woodenFenceDefaults: WoodenFenceConfig = {\n sections: 1,\n sectionLength: 1.74,\n height: 1.24,\n railCount: 3,\n rough: 1,\n brace: 1,\n seed: 12,\n}\n\nexport type WoodenFenceParts = 'posts' | 'rails'\n\nexport function createModel(overrides: Partial<WoodenFenceConfig> = {}) {\n return createKitModel<WoodenFenceConfig, 'oak', WoodenFenceParts>({\n id: 'wooden-fence',\n // The mottle cell is given by hand: when the automatic derivation takes\n // it from the model's scale a single post falls into a single cell and\n // the texture system does nothing. Wood grain mottle is a few\n // centimetres regardless of the object's size.\n mottle: { cell: 0.05 },\n defaults: woodenFenceDefaults,\n slots: ['oak'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n\n const sections = Math.max(1, Math.round(config.sections))\n const count = Math.max(1, Math.round(config.railCount))\n // Floors: height and span feed every derived dimension; zero here\n // would collapse the whole build (and postH divides bridge tapers).\n const H = Math.max(0.3, config.height)\n const span = Math.max(H * 0.4, config.sectionLength)\n const total = sections * span\n const half = H / 2\n const rough = Math.max(0, config.rough)\n\n // --- Masses, all derived from the height ------------------------------\n // These are the numbers the critique was about. Post: a broad slab a\n // quarter of the height wide and nearly square in plan (about a fifth\n // of the default span). Rail: an octagonal log whose diameter is close\n // to 60% of the post width.\n const postW = H * 0.25 // post width along the fence\n const railR = H * 0.074 // rail radius\n const mortise = railR * 2 + H * 0.02 // Z opening of the hole\n const cheek = H * 0.028 // material each side of the hole\n const postD = mortise + cheek * 2 // total post depth (near-square)\n const slotHalf = railR + H * 0.012 // half-height of the hole\n const tenon = postW * 0.95 // overhang past the outer face\n\n // Rail heights as a fraction of post height. For three rails this puts\n // them near 0.18 / 0.49 / 0.80, which is where the reference has them:\n // tight at the bottom, post standing clear above the top rail.\n const railT = Array.from({ length: count }, (_, r) =>\n count === 1 ? 0.55 : 0.18 + 0.62 * Math.pow(r / (count - 1), 1.05))\n\n // --- Posts ------------------------------------------------------------\n const postPieces: BufferGeometry[] = []\n const endPosts: { x: number; postH: number; sink: number }[] = []\n\n for (let i = 0; i <= sections; i += 1) {\n const x = -total / 2 + i * span\n const postH = H + jitter(random, 0.045 * rough)\n const pieces: BufferGeometry[] = []\n\n // Two cheeks: the walls of the hole. Full height, base to top.\n for (const side of [-1, 1]) {\n pieces.push(chamferedBoxGeometry(\n [postW, cheek],\n [postW * 0.93, cheek * 0.96],\n postH,\n cheek * 0.25,\n [0, postH / 2, side * (mortise + cheek) / 2],\n tint('oak', -0.1),\n tint('oak', 0.02),\n ))\n }\n\n // Bridges: blocks filling the space BETWEEN the rail slots; the hole\n // is exactly the gap they leave. Their Z faces are buried inside the\n // cheeks and their X faces sit a few millimetres behind the cheek\n // edges, so no face pair is coplanar. They stop short of the post\n // ends (inset) so their end faces never share a plane with the cheek\n // ends; the insets hide below grade and under the cap.\n const inset = cheek * 0.3\n const bounds = [0, ...railT.flatMap((t) => [t * postH - slotHalf, t * postH + slotHalf]), postH]\n for (let k = 0; k + 1 < bounds.length; k += 2) {\n const lo = Math.max(inset, bounds[k]!)\n const hi = Math.min(postH - inset, bounds[k + 1]!)\n if (hi - lo < 1e-3) continue\n const taper = 1 - 0.06 * (lo / postH)\n pieces.push(taperedBoxGeometry(\n [postW * taper * 0.97, mortise + cheek * 1.1],\n [postW * (taper - 0.02) * 0.97, mortise + cheek * 1.1],\n hi - lo,\n [0, (lo + hi) / 2, 0],\n tint('oak', -0.06),\n ))\n }\n\n // Cap: a weathered end-grain top. Its base is LARGER than the body's\n // section at that height and sits inside it (the toolSocket pattern),\n // so it reads as a slight lip and no coplanar pair forms.\n pieces.push(taperedBoxGeometry(\n [postW * 1.02, postD * 1.04],\n [postW * 0.8, postD * 0.72],\n H * 0.075,\n [0, postH - H * 0.015, 0],\n tint('oakEnd', -0.02),\n tint('oakEnd', 0.06),\n ))\n\n // Build at origin, ROTATE about the base, then translate. Rotations\n // stay small so the rails keep clearing the hole slack.\n const post = mergeColoured(pieces)\n post.rotateY(jitter(random, 0.03 * rough))\n post.rotateZ(jitter(random, 0.02 * rough))\n post.rotateX(jitter(random, 0.012 * rough))\n const sink = H * (0.015 + Math.abs(jitter(random, 0.012)))\n post.translate(x, -half - sink, 0)\n postPieces.push(post)\n\n if (i === 0 || i === sections) endPosts.push({ x, postH, sink })\n }\n\n // --- Rails ------------------------------------------------------------\n // One continuous octagonal log per rail height, spanning the whole run\n // and overhanging each end post by about a post width in its own full\n // section. Body and end tips are composed in a local frame along Y and\n // rotated as ONE piece, so the tips stay glued to the tilted body.\n const railPieces: BufferGeometry[] = []\n const railHalf = total / 2 + postW / 2 + tenon\n\n for (let r = 0; r < count; r += 1) {\n const y = -half + railT[r]! * H + jitter(random, H * 0.006)\n const r0 = railR * (1 + jitter(random, 0.05))\n const pieces: BufferGeometry[] = []\n\n pieces.push(prismGeometry(\n r0 * 1.02, r0 * 0.96, railHalf * 2, 8,\n [0, 0, 0],\n tint('oak', 0.04),\n ))\n\n // Mushroomed end-grain tips: slightly larger radius, caps offset off\n // the body's cap plane, inner half buried in the body. Different\n // radius and offset planes, so nothing is coincident.\n for (const side of [-1, 1]) {\n const dr = r0 * 1.08\n pieces.push(prismGeometry(\n dr, dr * 0.97, railR * 0.7, 8,\n [0, side * (railHalf - railR * 0.05), 0],\n tint('oakEnd', -0.02),\n { colourTop: tint('oakEnd', 0.06) },\n ))\n }\n\n const rail = mergeColoured(pieces)\n // Spin about its own axis first (flat facet up, plus scatter), then\n // lay it along X with a slight tilt, then a whisper of plan wobble.\n // Tilt and wobble are bounded by the hole slack.\n rail.rotateY(Math.PI / 8 + jitter(random, 0.25))\n rail.rotateZ((r % 2 === 0 ? 1 : -1) * Math.PI / 2 + jitter(random, 0.012 * rough))\n rail.rotateY(jitter(random, 0.005 * rough))\n rail.translate(0, y, jitter(random, railR * 0.1))\n railPieces.push(rail)\n }\n\n // --- Brace ------------------------------------------------------------\n // A raking shore leaning back in Z against one end post. The FOOT is\n // buried below grade; the HEAD terminates INSIDE the post, in the\n // bridge band between the two upper rail slots of that actual post\n // (postH is jittered, so the band is computed from the recorded post,\n // not from the nominal height).\n if (config.brace >= 0.5 && count >= 1) {\n const pick = endPosts[random() < 0.5 ? 0 : endPosts.length - 1]!\n const tTop = count === 1\n ? 0.75\n : (railT[count - 2]! + railT[count - 1]!) / 2 + slotHalf * 0 / pick.postH\n const bandMid = count === 1\n ? 0.75 * pick.postH\n : ((railT[count - 2]! * pick.postH + slotHalf) + (railT[count - 1]! * pick.postH - slotHalf)) / 2\n void tTop\n const xj = pick.x + jitter(random, 0.02)\n const topY = -half - pick.sink + bandMid\n const topZ = -postD * 0.2\n const footY = -half - H * 0.03\n const footZ = topZ - H * 0.46\n const dy = topY - footY\n const dz = topZ - footZ\n const len = Math.hypot(dy, dz)\n const brace = chamferedBoxGeometry(\n [postW * 0.34, postD * 0.3],\n [postW * 0.28, postD * 0.26],\n len,\n postW * 0.03,\n [0, 0, 0],\n tint('oak', -0.05),\n tint('oakEnd', 0.02),\n )\n brace.rotateX(Math.atan2(dz, dy))\n brace.translate(xj, (topY + footY) / 2, (topZ + footZ) / 2)\n postPieces.push(brace)\n }\n\n return {\n posts: { slot: 'oak', geometry: mergeColoured(postPieces) },\n rails: { slot: 'oak', geometry: mergeColoured(railPieces) },\n }\n },\n }, overrides)\n}\n",
|
|
2993
|
+
"hash": "6defd29c2be7d3e5a8c554a163dc66a2b4dc1c1a637ba504a9caef33c9ccc533"
|
|
2995
2994
|
}
|
|
2996
2995
|
],
|
|
2997
2996
|
"meta": {
|
|
@@ -3083,8 +3082,8 @@
|
|
|
3083
3082
|
{
|
|
3084
3083
|
"path": "models/wooden-hoe/model.ts",
|
|
3085
3084
|
"target": "{models}/medieval-kit/wooden-hoe/model.ts",
|
|
3086
|
-
"content": "/**\
|
|
3087
|
-
"hash": "
|
|
3085
|
+
"content": "/**\n * @medieval-kit/wooden-hoe\n *\n * Field hoe after the reference photograph: a long ash shaft ending in a\n * squared, chamfered block, a flat iron band wrapped tight around that block,\n * a short flat strap running forward and down from the band, and a dark\n * forged trapezoid blade hung off the strap at roughly 75 degrees to the\n * shaft.\n *\n * FIFTH pass. The fourth swept the old gooseneck past 180 degrees so the\n * blade genuinely hung below the socket, which fixed \"scythe\" (45 -> 62) but\n * not \"hoe\": the blade descended almost parallel to the shaft, so from the\n * side the tool read as a paddle on a pole, and the free arc of the neck\n * vaulted over the shaft end with air under it and read as a padlock\n * shackle. The blind critique of that pass said to stop interpreting and\n * model the reference literally. So the gooseneck, the bent lathe neck, the\n * forged collar and the toolSocket ferrule (which covered the top tenth of\n * the shaft and read as a long grey sleeve) are all gone.\n *\n * Point by point against that critique:\n * - The blade sweeps FORWARD off the shaft axis. Its plane sits at about\n * 75 degrees to the shaft and its root edge is about 2.3 shaft diameters\n * clear of the wood, so no side profile has the shaft overlapping the\n * blade, and no solid shares volume with the blade except the strap that\n * deliberately laps onto its back at the root.\n * - The blade holds full thickness over its upper two thirds and thins only\n * in the last third down to a lip, so its side faces actually exist and\n * the cutting edge catches light.\n * - The blade moved from the steel slot to the iron slot with a dark tint.\n * It was the palest element in the render and in the reference it is the\n * darkest; the value order was inverted. Only the cutting edge lightens.\n * - The arch is replaced by band + block + strap, and every iron piece\n * starts inside another solid: the strap begins inside the band slab, the\n * band wraps the block, the block swallows the shaft's top cap (which\n * used to poke out of the socket as a wood sliver).\n *\n * `neckSweep` stays in the config because other files reference the\n * interface, but it is REINTERPRETED: it is now the forward pitch of the\n * whole head in degrees from the shaft axis, clamped to 92..140. Larger\n * folds the blade down toward the shaft, smaller lifts it toward square.\n * There is no arc left for it to sweep.\n *\n * Earlier lessons, still load-bearing. The blade sheet is built with its\n * base AT THE ORIGIN and rotated, then translated, in that order. `curve` in\n * a sheet profile is an absolute rise in metres and must be scaled to the\n * blade. Tints come from createTinter, which returns a new Color each call\n * and floors lightness so a dark lift cannot fall through the palette.\n *\n * Still open: the shaft comes from the shared toolShaft helper, whose taper\n * toward the head is real but slight; the reference tapers more. Core is\n * shared, so that stays as it is.\n */\nimport {\n chamferedBoxGeometry,\n createKitModel,\n createTinter,\n dishedSheetGeometry,\n jitter,\n mergeColoured,\n taperedBoxGeometry,\n toolShaft,\n type SheetLevel,\n} from '../core/index.ts'\n\nexport interface WoodenHoeConfig {\n /** Shaft length (metres). */\n readonly length: number\n readonly shaftRadius: number\n /** Width of the cutting edge (metres). */\n readonly bladeWidth: number\n /**\n * Forward pitch of the head (degrees from the shaft axis), clamped to\n * 92..140. At 92 the blade sticks out almost square to the shaft; at 140\n * it folds well down toward it. The blade plane runs at 180 minus this to\n * the shaft, so the default leaves it near 75 degrees, as the reference.\n */\n readonly neckSweep: number\n /** Dish of the blade. 0 = dead flat, which is close to the reference. */\n readonly dish: number\n readonly seed: number\n}\n\nexport const woodenHoeDefaults: WoodenHoeConfig = {\n length: 1.14,\n shaftRadius: 0.021,\n bladeWidth: 0.23,\n neckSweep: 104,\n dish: 0.8,\n seed: 23,\n}\n\nexport type WoodenHoeParts = 'shaft' | 'socket' | 'blade'\n\nexport function createModel(overrides: Partial<WoodenHoeConfig> = {}) {\n return createKitModel<WoodenHoeConfig, 'oak' | 'iron' | 'steel', WoodenHoeParts>({\n id: 'wooden-hoe',\n defaults: woodenHoeDefaults,\n slots: ['oak', 'iron', 'steel'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n // Floored: radius and width divide nothing, but they scale everything,\n // and a zero would collapse the head into degenerate triangles.\n const radius = Math.max(0.008, config.shaftRadius)\n const dia = radius * 2\n const bladeW = Math.max(0.05, config.bladeWidth)\n\n const shaft = toolShaft({ length: config.length, radius, random })\n const top = shaft.top\n\n // --- Squared end block --------------------------------------------------\n // The wood continues past the band as a squared, chamfered block, as in\n // the reference. It starts a diameter INSIDE the round shaft, so the\n // shaft's top cap and its tapered tip are both swallowed whole: the\n // block's half width just exceeds the shaft's top radius.\n const blockHalf = radius * 1.02\n const blockBottom = top - dia * 1.1\n const blockTop = top + dia * 1.2\n const block = chamferedBoxGeometry(\n [blockHalf * 2, blockHalf * 2],\n [blockHalf * 1.84, blockHalf * 1.84],\n blockTop - blockBottom,\n dia * 0.14,\n [0, (blockBottom + blockTop) / 2, 0],\n tint('oakEnd', 0),\n tint('oakEnd', 0.04),\n )\n\n // --- Iron band ----------------------------------------------------------\n // A flat band about one shaft diameter tall, wrapped tight on the block.\n // Modelled as a solid slab; the wood passing through it hides the\n // interior, and from outside it reads as the wrapped hoop it stands for.\n const bandHalf = blockHalf + 0.006\n const bandY = top + dia * 0.25\n const band = chamferedBoxGeometry(\n [bandHalf * 2, bandHalf * 2],\n [bandHalf * 2, bandHalf * 2],\n dia * 0.95,\n 0.0025,\n [0, bandY, 0],\n tint('iron', 0.03),\n tint('iron', 0.07),\n )\n\n // --- Strap (the neck) ---------------------------------------------------\n // A short tapered bar running forward and down from the band; the blade\n // hangs off its end. Direction comes from the head pitch so the whole\n // head follows `neckSweep` as one piece.\n const pitch = (Math.min(140, Math.max(92, config.neckSweep)) * Math.PI) / 180\n const dirY = Math.cos(pitch)\n const dirZ = Math.sin(pitch) // 92..140 degrees keeps this well above 0.6\n\n const startY = bandY - dia * 0.1\n const startZ = radius * 0.6 // buried inside the band slab\n const rootZ = dia * 2.3 // blade root, clear of the wood\n const lenToRoot = (rootZ - startZ) / dirZ\n const lap = dia * 0.5 // how far the strap laps onto the blade\n const strapLen = lenToRoot + lap\n const strap = taperedBoxGeometry(\n [dia * 0.52, dia * 0.3],\n [dia * 0.62, dia * 0.32],\n strapLen,\n [0, strapLen / 2, 0],\n tint('iron', -0.04),\n tint('iron', -0.06),\n )\n strap.rotateX(pitch)\n strap.translate(0, startY, startZ)\n\n const rootY = startY + dirY * lenToRoot\n\n // --- Blade --------------------------------------------------------------\n // A trapezoid widening from a narrow root to the cutting edge, the two\n // edge corners taken off. Thickness is a twenty-fifth of the width, held\n // over the top two thirds and thinned only in the last third to a lip,\n // so the plate shows a side face instead of reading as a sheet. Root\n // dark forged iron, edge worn lighter.\n const bladeLength = bladeW * 0.9\n const halfEdge = bladeW / 2\n const thick = bladeW * 0.04\n const curve = bladeW * 0.045 * config.dish\n const profile: SheetLevel[] = [\n { y: 0, halfWidth: halfEdge * 0.55, thickness: thick, curve: curve * 0.2 },\n { y: bladeLength * 0.3, halfWidth: halfEdge * 0.72, thickness: thick, curve: curve * 0.5 },\n { y: bladeLength * 0.66, halfWidth: halfEdge * 0.88, thickness: thick * 0.94, curve: curve * 0.85 },\n { y: bladeLength * 0.92, halfWidth: halfEdge, thickness: thick * 0.38, curve },\n { y: bladeLength, halfWidth: halfEdge * 0.93, thickness: thick * 0.2, curve: curve * 0.97 },\n ]\n // The lift looks heavy written down, but the blade's face points at the\n // sky in every standing view and direct sky light lifts it a band or\n // two: at -0.05 it still rendered as the palest part of the tool.\n const blade = dishedSheetGeometry(\n profile, 7, tint('iron', -0.16, 0.4), tint('iron', -0.02, 0.4),\n )\n // A forged blade is not perfectly symmetric.\n blade.rotateY(jitter(random, 0.03))\n // Align to the strap direction, then move to its end. Order matters:\n // rotating after the move would swing the blade around the model origin.\n blade.rotateX(pitch)\n blade.translate(0, rootY, rootZ)\n\n return {\n shaft: { slot: 'oak', geometry: mergeColoured([shaft.geometry, block]) },\n socket: { slot: 'iron', geometry: mergeColoured([band, strap]) },\n blade: { slot: 'iron', geometry: mergeColoured([blade]) },\n }\n },\n }, overrides)\n}\n",
|
|
3086
|
+
"hash": "5e2703f9b02daf2d8c60d49a0f33a9d5c1317cc6d8fc38d0b2dce910b6637fae"
|
|
3088
3087
|
}
|
|
3089
3088
|
],
|
|
3090
3089
|
"meta": {
|
|
@@ -3174,8 +3173,8 @@
|
|
|
3174
3173
|
{
|
|
3175
3174
|
"path": "models/wooden-ladder/model.ts",
|
|
3176
3175
|
"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": "
|
|
3176
|
+
"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 // A ladder is bare sapwood that spends its life being gripped and leaned,\n // so it wears pale rather than grey. Every reference photograph of one is\n // markedly lighter than the weathered oak the rest of the kit is cut from,\n // by about this much, and the palette constant is set for the rest of the\n // kit.\n const bleach = 0.13\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), bleach + 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",
|
|
3177
|
+
"hash": "91e461e3dc426e33d7f6c51213a13738008dfe3ceaea231fdbd64f9bb3439951"
|
|
3179
3178
|
}
|
|
3180
3179
|
],
|
|
3181
3180
|
"meta": {
|
|
@@ -3252,8 +3251,8 @@
|
|
|
3252
3251
|
{
|
|
3253
3252
|
"path": "models/wooden-pitchfork/model.ts",
|
|
3254
3253
|
"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
|
|
3256
|
-
"hash": "
|
|
3254
|
+
"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 //\r\n // Sized FROM the tine roots so it cannot overhang them. It was\r\n // `shaftRadius * 2.6 * count`, which has nothing to do with where the\r\n // tines actually sit: at four tines the bar came to 229 mm across while\r\n // the outermost root sat 55 mm from the centre, so 59 mm of iron stuck out\r\n // past the last tine at each end and finished in a flat slab holding\r\n // nothing. The bar now ends just outboard of the outer roots, which is\r\n // where a forged head ends.\r\n const rootHalf = config.shaftRadius * 2.5\r\n const crossWidth = (rootHalf + config.shaftRadius * 1.3) * 2\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 * rootHalf, 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",
|
|
3255
|
+
"hash": "576d76c0ee9e076de6ee3a522fbd75304aa5bff271f5e69e1135d6276ca28419"
|
|
3257
3256
|
}
|
|
3258
3257
|
],
|
|
3259
3258
|
"meta": {
|
|
@@ -3340,8 +3339,8 @@
|
|
|
3340
3339
|
{
|
|
3341
3340
|
"path": "models/wooden-shovel/model.ts",
|
|
3342
3341
|
"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": "
|
|
3342
|
+
"content": "/**\n * @medieval-kit/wooden-shovel\n *\n * Sixth attempt. The reference is an antique one-piece grain scoop: a squared\n * dished plate, nearly as broad as it is long, an iron shoe forged round the\n * leading edge with a return onto both faces, a round bent shaft and a T-grip.\n * History of the earlier passes:\n *\n * attempt 1-2 — boxes / flat panels. Read as boards, not a scoop.\n * attempt 3-4 — one dishedSheetGeometry, but the outline was an oval; the\n * blind critic called it \"a paddle or a wooden spoon\" (67).\n * attempt 5 — trapezoid plan, real shoe band + side straps, seated ferrule.\n * Scored 69: landmarks all present, silhouette still wrong. The critic\n * measured the pan at 1.35 long-to-wide against 1.05 in the photo, called\n * the taper \"a straight-sided funnel\", the pan \"flat\", the shoe \"a cap\n * sitting on the top edge only\", and found both side straps floating\n * outboard of the blade. It also flagged: the socket-end rim reading as a\n * seam with a lighter panel below it, the ferrule not seated (a wedge of\n * dish poking past the collar), the T-bar arms not colinear (the grip was\n * a TAPERED prism, so one arm was fatter than the other), the shaft tip\n * exposed below the crossbar, and a width/colour step mid-shaft (the\n * per-level jitter in core's toolShaft).\n *\n * What this version does differently:\n * - PAN OUTLINE: length-to-width is ~1.04 (bladeWidth 0.29 against a 0.30\n * span). The socket end is 0.78 of full width, and the pull-in happens in\n * the bottom third through a rounded shoulder; the top two thirds run\n * near-parallel into a straight leading edge with small corner chamfers.\n * - DISH WITH WALLS: besides the sheet's parabolic hollow, two wooden side\n * rails (rolled to lie on the local surface slope) and the socket-end rim\n * arc form a raised lip standing proud of the floor. The rim's ends and\n * the rails' lower ends interpenetrate, so the rim reads as the scoop's\n * back wall instead of a floating seam.\n * - SHOE WRAPS THE EDGE: the arc band round the leading edge is joined by\n * two thin dished-sheet RETURNS overlaid on the front and back faces\n * (about 12% of the blade), and the floating side straps are replaced by\n * angled corner caps that overlap both the returns and the pan's edges.\n * - SHAFT: built here as a smooth 7-level lathe (no mid-shaft jitter step),\n * bent gently with bendGeometry (base at the origin first — the bend trap)\n * toward the same side the blade tilts. The T-grip is a SYMMETRIC lathe\n * bar (rounded ends, no taper, so no kink at the handle), and the shaft's\n * bottom tip terminates INSIDE the bar.\n * - SEATING: the collar overlaps the blade base by ~4cm and the base level's\n * curve is halved, so no dish corner pokes out past the collar.\n * - COLOUR: the shoe and the socket are a rust-brown iron (palette iron\n * pulled toward leather) instead of cool slate; the timber is slightly\n * desaturated toward weathered oak. Lifts stay positive: the occlusion\n * bake darkens everything pressed against the sheet.\n *\n * Traps honoured: `dish` is floored before any radius is fitted to it\n * (division by zero → NaN spreads through the occlusion bake), every asin\n * argument is clamped below 1, and every colour is a NEW Color per call.\n */\nimport { Color, type BufferGeometry } from 'three'\n\nimport {\n arcBarGeometry,\n bendGeometry,\n chamferedBoxGeometry,\n createKitModel,\n createTinter,\n dishedSheetGeometry,\n jitter,\n latheGeometry,\n mergeColoured,\n MEDIEVAL_PALETTE,\n type Level,\n type SheetLevel,\n} from '../core/index.ts'\n\nexport interface WoodenShovelConfig {\n readonly length: number\n readonly shaftRadius: number\n /** The widest point of the blade (metres). */\n readonly bladeWidth: number\n /** Blade length, as a fraction of the total length. */\n readonly bladeLength: number\n /** Depth of the scoop: how far the edges rise above the middle. 0 = flat sheet. */\n readonly dish: number\n /** Tilt of the blade relative to the shaft (degrees). */\n readonly bladeAngle: number\n readonly seed: number\n}\n\nexport const woodenShovelDefaults: WoodenShovelConfig = {\n // 0.86, not 1.16. The reference is a short-handled grain scoop, not a\n // long-handled digging shovel: its shaft runs about two and a half blade\n // lengths, ours ran nearly four. At that ratio, stood on its grip with the\n // pan in the air, a blind viewer called it a floor lamp.\n length: 0.86,\n shaftRadius: 0.022,\n bladeWidth: 0.29,\n\n // a shovel blade is wider than it is long; taller than wide is a dustpan.\n // 0.32 of a shorter tool, which keeps the pan the same size in metres while\n // the handle comes down. A shovel blade is wider than it is long; taller\n // than wide is a dustpan.\n bladeLength: 0.32,\n // 0.09, not 0.14. Measured against the reference the hollow wants to be 8 to\n // 10 percent of the blade width. At 0.14, with a lip standing on both long\n // edges, the pan curled into a U-section trough and a blind viewer called it\n // a bin.\n dish: 0.09,\n bladeAngle: 9,\n seed: 31,\n}\n\nexport type WoodenShovelParts = 'shaft' | 'socket' | 'blade'\n\nexport function createModel(overrides: Partial<WoodenShovelConfig> = {}) {\n return createKitModel<WoodenShovelConfig, 'oak' | 'iron', WoodenShovelParts>({\n id: 'wooden-shovel',\n defaults: woodenShovelDefaults,\n slots: ['oak', 'iron'],\n build: ({ config, random }) => {\n const tint = createTinter(random)\n\n /** Pull saturation down: the critic read the timber as new pine. */\n const weather = (colour: Color, drop: number): Color => {\n const hsl = { h: 0, s: 0, l: 0 }\n colour.getHSL(hsl)\n colour.setHSL(hsl.h, Math.max(0, hsl.s - drop), hsl.l)\n return colour\n }\n /**\n * Rust-brown forged iron: the palette's cool slate pulled toward\n * leather. Returns a NEW Color every call — two rusts in one geometry\n * call must not resolve to the same object.\n */\n const rust = (lift: number): Color => {\n const c = new Color(MEDIEVAL_PALETTE.iron).lerp(new Color(MEDIEVAL_PALETTE.leather), 0.45)\n const hsl = { h: 0, s: 0, l: 0 }\n c.getHSL(hsl)\n c.setHSL(\n hsl.h + jitter(random, 0.01),\n Math.min(1, hsl.s + 0.05 + jitter(random, 0.03)),\n Math.max(0.045, hsl.l + lift + jitter(random, 0.03)),\n )\n return c\n }\n\n const span = config.length * config.bladeLength\n const half = config.bladeWidth / 2\n const t = config.length * 0.011\n const r = config.shaftRadius\n const shaftLength = config.length - span * 0.82\n\n // --- shaft -----------------------------------------------------------\n // Own lathe rather than core's toolShaft: the per-level jitter there\n // left a visible width step mid-shaft, and toolShaft cannot bend. The\n // tip radius is small enough to bury inside the T-bar, the swell sits\n // just ABOVE the bar, and the taper is gentle and continuous.\n const rr = (s: number): number => r * s * (1 + jitter(random, 0.015))\n const shaftLevels: Level[] = [\n { y: 0, radius: rr(0.72) }, // tip — ends inside the T-bar\n { y: r * 1.1, radius: rr(1.03) },\n { y: r * 2.6, radius: rr(1.08) }, // grip swell, above the bar\n { y: shaftLength * 0.16, radius: rr(0.97) },\n { y: shaftLength * 0.55, radius: rr(1.01) },\n { y: shaftLength * 0.85, radius: rr(0.97) },\n { y: shaftLength, radius: rr(0.9) }, // taper toward the socket\n ]\n const topRadius = shaftLevels.at(-1)!.radius\n const shaftGeo = latheGeometry(\n shaftLevels, 7, [0, 0, 0],\n weather(tint('oak', 0.05), 0.05),\n { colourTop: weather(tint('oak', -0.01), 0.05) },\n )\n // Gentle bend, the reference's signature. Built with its base at the\n // origin FIRST: bendGeometry wraps around y=0, and a body centred on\n // y=0 bends symmetrically into nothing. Negative curvature bends toward\n // -Z, the side the blade tilts to.\n const bendK = 0.06\n bendGeometry(shaftGeo, -bendK)\n shaftGeo.translate(0, -shaftLength / 2, 0)\n const bendAngle = shaftLength * bendK\n const topY = Math.sin(bendAngle) / bendK - shaftLength / 2\n const topZ = -(1 - Math.cos(bendAngle)) / bendK\n\n // --- T-grip ----------------------------------------------------------\n // A SYMMETRIC lathe bar with rounded ends. The previous tapered prism\n // gave the two arms different diameters, which read as a kink where\n // they met the handle.\n const gripHalfSpan = r * 4.3\n const gripR = r * 1.15\n const gripLevels: Level[] = [\n { y: -gripHalfSpan, radius: gripR * 0.62 },\n { y: -gripHalfSpan + r * 1.3, radius: gripR },\n { y: gripHalfSpan - r * 1.3, radius: gripR },\n { y: gripHalfSpan, radius: gripR * 0.62 },\n ]\n const grip = latheGeometry(gripLevels, 7, [0, 0, 0], weather(tint('oak', 0.04), 0.05))\n grip.rotateZ(Math.PI / 2) // built along Y; lay it along X\n // Centred a whisker above the shaft's bottom tip, so the tip's end face\n // terminates inside the bar instead of resting on the ground beside it.\n grip.translate(0, -shaftLength / 2 + r * 0.15, 0)\n\n // --- socket / ferrule ------------------------------------------------\n // Same profile as core's toolSocket, built here so it can take the rust\n // tone: the critic read the palette's slate-blue iron against the\n // photograph's rust-brown. Raised so the collar overlaps the blade base\n // by ~4cm — seated on the tang, no shadow slot.\n const sockL = config.length * 0.05\n const sy = topY - sockL * 0.18\n const sockLevels: Level[] = [\n { y: sy - sockL * 0.9, radius: topRadius * 1.12 },\n { y: sy - sockL * 0.45, radius: topRadius * 1.34 },\n { y: sy + sockL * 0.1, radius: topRadius * 1.5 },\n { y: sy + sockL * 0.22, radius: topRadius * 1.72 }, // collar\n { y: sy + sockL * 0.34, radius: topRadius * 1.46 },\n ]\n const socket = latheGeometry(sockLevels, 6, [0, 0, 0], rust(0.02), { colourTop: rust(0.06) })\n socket.translate(0, 0, topZ)\n\n // --- pan -------------------------------------------------------------\n // Floored HERE: two radii below are fitted as halfWidth² over twice\n // this, and at dish = 0 that division is infinite and the vertices NaN.\n const c = config.bladeWidth * Math.max(0.012, config.dish)\n\n // Widest AT THE TIP, narrowing back to the throat, with the last two\n // levels drawing the leading corners off.\n //\n // The version before this widened over the bottom fifth, ran parallel\n // through the middle and then pulled back in at the tip. That is a\n // rectangle, and with a lip standing up on all four sides a viewer read\n // it as a framed tray on a post rather than as a shovel. A pan flares to\n // its cutting edge; the narrow end is the end the handle is on.\n //\n // The base level's curve is halved so no dish corner pokes out past the\n // ferrule collar.\n const profile: SheetLevel[] = [\n { y: 0, halfWidth: half * 0.7, thickness: t * 1.35, curve: c * 0.5 },\n { y: span * 0.12, halfWidth: half * 0.83, thickness: t * 1.2, curve: c * 0.75 },\n { y: span * 0.35, halfWidth: half * 0.93, thickness: t * 1.05, curve: c * 0.95 },\n { y: span * 0.7, halfWidth: half * 0.99, thickness: t * 0.95, curve: c },\n { y: span * 0.9, halfWidth: half, thickness: t * 0.88, curve: c },\n { y: span * 0.97, halfWidth: half * 0.95, thickness: t * 0.82, curve: c * 0.98 },\n { y: span, halfWidth: half * 0.84, thickness: t * 0.78, curve: c * 0.96 },\n ]\n const boardLow = weather(tint('oak', -0.04), 0.06)\n const boardHigh = weather(tint('oakEnd', 0.02), 0.05)\n const sheet = dishedSheetGeometry(profile, 8, boardLow, boardHigh)\n\n // There is deliberately NO rail across the throat. One ran there, bent to\n // the dish, and with a lip already standing on both long edges and a band\n // across the tip it closed the pan into a four-sided frame. A shovel is\n // open at the handle end: that is the end the load leaves by.\n\n // Side rails: the raised side walls of the scoop. Rolled about their own\n // long axis to lie on the local surface slope (otherwise their outboard\n // half hangs out behind the sheet), leaned to follow the plan taper, and\n // kept inboard of the plan edge so nothing floats off the shoulder.\n const roll = Math.atan((1.7 * c) / half)\n const railW = config.bladeWidth * 0.095\n const railD = config.bladeWidth * 0.05\n const railA = { x: half * 0.68, y: span * 0.05 }\n const railB = { x: half * 0.95, y: span * 0.93 }\n const railLen = Math.hypot(railB.x - railA.x, railB.y - railA.y)\n const railLean = Math.atan2(railB.x - railA.x, railB.y - railA.y)\n const railMidX = (railA.x + railB.x) / 2\n const railMidU = railMidX / half\n const rails: BufferGeometry[] = []\n for (const side of [-1, 1] as const) {\n const rail = chamferedBoxGeometry(\n [railW, railD], [railW, railD], railLen, t * 0.4,\n [0, 0, 0], weather(tint('oak', 0.03), 0.05),\n )\n rail.rotateY(-side * roll)\n rail.rotateZ(-side * railLean)\n rail.translate(side * railMidX, (railA.y + railB.y) / 2, c * railMidU * railMidU + t * 0.55)\n rails.push(rail)\n }\n\n // --- iron shoe -------------------------------------------------------\n // Band round the leading edge + a thin return sheet overlaid on EACH\n // face + angled corner caps. Lifts positive: the occlusion bake darkens\n // everything pressed against the sheet.\n // Floored against the SHEET thickness, not just scaled to the blade.\n // `span` is a slider, and at its short end a band sized only as a\n // fraction of it becomes thin enough that its end face lands in the same\n // plane as the sheet tip it is supposed to wrap, which is a flicker\n // rather than a shoe.\n const bandTh = Math.max(t * 2.4, span * 0.085)\n const tipR = (0.9 * half) * (0.9 * half) / (2 * (0.96 * c))\n const bandAngle = Math.asin(Math.min(0.995, (half * 0.94) / tipR))\n const band = arcBarGeometry(\n tipR, bandTh,\n -Math.PI / 2 - bandAngle, -Math.PI / 2 + bandAngle,\n 10, [0, 0, 0], rust(0.05),\n )\n band.rotateX(Math.PI / 2)\n band.translate(0, span - bandTh * 0.3, tipR + t * 0.4)\n\n // Face returns: the iron coming back over the wood, ~12% of the blade.\n // Slightly wider than the pan so a hairline of iron shows around the\n // wood edge, offset so they interpenetrate the sheet without sharing a\n // plane with it. Their top edges end inside the band.\n const returns: BufferGeometry[] = []\n for (const face of [1, -1] as const) {\n const ret = dishedSheetGeometry([\n // Starts BELOW the rails finish, not level with them. The rails end at\n // span * 0.89 and the returns used to start at span * 0.88, so two end\n // faces of the same orientation sat a couple of millimetres apart; at\n // the short end of the blade slider that gap closes to under a\n // millimetre and the two planes become one.\n { y: span * 0.8, halfWidth: half, thickness: t * 0.9, curve: c },\n { y: span + t * 0.3, halfWidth: half * 0.91, thickness: t * 0.9, curve: c * 0.97 },\n ], 8, rust(0.05))\n ret.translate(0, 0, face * t * 0.62)\n returns.push(ret)\n }\n\n // Corner caps: angled over the chamfered corners, overlapping the\n // returns above and the pan's side edges below — the iron continues\n // round the corner instead of straps starting in mid-air.\n const capLen = config.bladeWidth * 0.11\n const capW = config.bladeWidth * 0.08\n const capD = config.bladeWidth * 0.105\n const capAngle = Math.atan2(half * 0.09, span * 0.08)\n const caps: BufferGeometry[] = []\n for (const side of [-1, 1] as const) {\n const cap = chamferedBoxGeometry(\n [capW, capD], [capW, capD], capLen, t * 0.35,\n [0, 0, 0], rust(0.05),\n )\n cap.rotateY(-side * roll)\n cap.rotateZ(side * capAngle)\n cap.translate(side * half * 0.9, span * 0.955, c * 0.88 + t * 0.2)\n caps.push(cap)\n }\n\n const blade: BufferGeometry = mergeColoured([sheet, ...rails])\n const shoe = mergeColoured([band, ...returns, ...caps])\n // Both must take the SAME transform, or the shoe won't stay on the\n // blade. The tilt continues the bend's lean plus the config angle.\n const tilt = (config.bladeAngle * Math.PI) / 180 + bendAngle\n for (const piece of [blade, shoe]) {\n piece.rotateX(-tilt)\n piece.translate(0, topY - span * 0.1, topZ)\n }\n\n return {\n shaft: { slot: 'oak', geometry: mergeColoured([shaftGeo, grip]) },\n socket: { slot: 'iron', geometry: socket },\n blade: {\n // Oak, not steel: the blade is a shaped board, and the only metal\n // on it is the shoe protecting its lip.\n slot: 'oak',\n geometry: blade,\n extras: [{ slot: 'iron', geometry: shoe }],\n },\n }\n },\n }, overrides)\n}\n",
|
|
3343
|
+
"hash": "6ee382529fe480ed1923242f98f97896d5ae06c6c0dd177520440354db43ad20"
|
|
3345
3344
|
}
|
|
3346
3345
|
],
|
|
3347
3346
|
"meta": {
|
|
@@ -3437,8 +3436,8 @@
|
|
|
3437
3436
|
{
|
|
3438
3437
|
"path": "models/wooden-stool/model.ts",
|
|
3439
3438
|
"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.
|
|
3441
|
-
"hash": "
|
|
3439
|
+
"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.32,\r\n seatRadius: 0.17,\r\n legCount: 3,\r\n splay: 0.32,\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.97,\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 // 12 percent of the seat DIAMETER, not 7. The reference legs are\r\n // chunky turned posts and these were sticks; the critic and a ruler\r\n // agreed to within a percent.\r\n const thick = config.seatRadius * 0.24\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 // Wider at the FOOT than at the seat, which is the way round a stool\r\n // leg is turned: the mass belongs low. It was tapering the other way.\r\n [thick, thick],\r\n [thick * 0.82, thick * 0.82],\r\n legLength,\r\n // A heavy chamfer, because the reference feet are domed rather than cut\r\n // off square, and at this scale a big chamfer is a dome.\r\n thick * 0.34,\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",
|
|
3440
|
+
"hash": "f4d671ba78708b1dee3d84e2497cfe836ef87f4bcebb3aa654f0c334055ab7f9"
|
|
3442
3441
|
}
|
|
3443
3442
|
],
|
|
3444
3443
|
"meta": {
|