Eight kinds mixed by two sliders: how many of them appear, and which one leads. Turnip, cabbage, onion, leek, parsnip, carrot, tomato, potato. The first six are what a medieval European kitchen had and the last two are New World crops that only reach Europe after 1492 -- they are in because a kit is more useful than it is a museum, and the carrot is orange rather than the purple a medieval one would have been. The kinds are ordered so that the period-correct set costs no thought: `lead: 0` with `kinds: 6` gives exactly those six and never reaches the two after them. Two parts, `bulbs` and `roots`, because those are the two ways a vegetable rests -- on its base or on its side -- and a consumer wanting one should not have to take the other. Four faults on the way in, and every one of them was about placement rather than shape. The header claimed each vegetable is authored with its lowest point at the origin, and I asserted that instead of measuring it: a leek's blades lean four times its own radius away from the axis, and a cabbage's loose leaves hang below its base. Whichever vegetable dug in deepest then defined the model's floor and made every other one look as though it were hovering. Reading the bounding box and setting each one down by what it actually measures makes the claim true by construction rather than by an argument that has to be right about eight shapes. The leaves were flung off their vegetables outright. They were built already in place and then rotated, and rotation turns a body about the ORIGIN, so each one moved away by its own distance times the sine of the angle. Origin, rotate, place -- which is the order the hoe's neck already has a comment about. Tightening the heap so it read as a pile rather than a display then made the bulbs collide, twice. Standing vegetables were never turned about their own axis, so every turnip presented its facets at the same phase and neighbours met in parallel faces. And where the turnip's cream body meets its purple shoulder -- two lathes, because `colourTop` interpolates across a whole profile and bled the purple down to the root -- both pieces were capping an end that is buried inside the vegetable. Two turnips touching met on those hidden discs. They are not generated any more, which is the same saving `bandGeometry` makes on the inside of a hoop. 32 models, 26 939 triangles, all checks pass, 0 floating cases, GLB round-trip clean, Conformant, build clean.
176 lines
8 KiB
Markdown
176 lines
8 KiB
Markdown
# Medieval Kit
|
|
|
|
A lowpoly medieval model library for [Vibe3D](https://github.com/vibe-stack/vibe3d)
|
|
and Three.js. Install a prop and you own its source: readable TypeScript in your
|
|
own tree, not an opaque package boundary.
|
|
|
|

|
|
|
|
```sh
|
|
bunx vibe3d add @medieval-kit/wooden-barrel
|
|
bunx vibe3d add @medieval-kit
|
|
```
|
|
|
|
Configure `models.json` first so the namespace resolves:
|
|
|
|
```json
|
|
{
|
|
"registries": {
|
|
"@medieval-kit": {
|
|
"source": "npm:@medieval-kit/registry",
|
|
"version": "latest"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Models
|
|
|
|
32 models and one shared lib. Addresses are `@medieval-kit/<id>`. This table is
|
|
generated from the models themselves with `bun scripts/catalog-table.ts` — a
|
|
hand-written list goes stale on the first model you add, and this one already
|
|
did once.
|
|
|
|
| Model | Category | Triangles | Parts | Size (m) | Material slots | Animated |
|
|
| --- | --- | ---: | ---: | --- | --- | :-: |
|
|
| `wooden-chest` | Furniture | 552 | 4 | 0.86×0.51×0.48 | oak, iron | ✔ |
|
|
| `wooden-barrel` | Props | 1690 | 3 | 0.83×1.05×0.83 | oak, iron | |
|
|
| `wooden-crate` | Props | 1320 | 3 | 0.69×0.52×0.55 | oak, iron | |
|
|
| `wooden-bucket` | Props | 439 | 4 | 0.31×0.46×0.30 | oak, iron | |
|
|
| `trestle-table` | Furniture | 660 | 3 | 1.91×0.74×0.78 | oak | |
|
|
| `wooden-bench` | Furniture | 216 | 3 | 1.62×0.45×0.30 | oak | |
|
|
| `wooden-stool` | Furniture | 180 | 2 | 0.38×0.43×0.36 | oak | |
|
|
| `pitch-torch` | Lighting | 239 | 3 | 0.13×0.70×0.13 | oak, char, ember | ✔ |
|
|
| `iron-lantern` | Lighting | 480 | 4 | 0.15×0.30×0.17 | iron, glass, char, ember | ✔ |
|
|
| `iron-anvil` | Smithy | 256 | 6 | 0.54×0.75×0.41 | iron, steel, oak | |
|
|
| `cart-wheel` | Structure | 1136 | 4 | 1.04×1.06×0.19 | oak, iron | |
|
|
| `log-pile` | Props | 924 | 2 | 0.99×0.58×0.70 | oak | |
|
|
| `hay-bale` | Props | 910 | 3 | 1.04×0.42×0.43 | straw, cloth | |
|
|
| `linen-sack` | Props | 342 | 3 | 0.34×0.53×0.33 | cloth | |
|
|
| `oak-tankard` | Props | 390 | 4 | 0.11×0.16×0.14 | oak, iron | |
|
|
| `straw-broom` | Tools | 1048 | 3 | 0.30×1.22×0.30 | oak, straw, cloth | |
|
|
| `bronze-bell` | Props | 1048 | 4 | 0.51×0.68×0.36 | brass, iron, oak | ✔ |
|
|
| `tavern-sign` | Props | 876 | 3 | 0.38×2.29×0.93 | oak, iron | ✔ |
|
|
| `wicker-basket` | Props | 2326 | 3 | 0.34×0.16×0.35 | oak, produce | |
|
|
| `leather-book` | Props | 440 | 3 | 0.20×0.08×0.27 | leather, cloth, brass | |
|
|
| `glass-phial` | Props | 339 | 3 | 0.06×0.14×0.06 | glass, ember, oak, char | |
|
|
| `coin-pouch` | Props | 602 | 3 | 0.18×0.11×0.16 | leather, cloth, brass | |
|
|
| `wooden-ladder` | Structure | 440 | 2 | 0.49×2.20×0.06 | oak | |
|
|
| `wooden-fence` | Structure | 860 | 2 | 5.18×1.31×0.31 | oak | |
|
|
| `wooden-hoe` | Tools | 394 | 3 | 0.23×1.23×0.33 | oak, iron, steel | |
|
|
| `wooden-shovel` | Tools | 496 | 3 | 0.27×1.20×0.08 | oak, iron | |
|
|
| `wooden-pitchfork` | Tools | 392 | 3 | 0.27×1.58×0.15 | oak, iron | |
|
|
| `iron-cauldron` | Lighting | 1134 | 3 | 1.09×1.37×1.10 | stone, iron, char, ember | |
|
|
| `hand-cart` | Structure | 2044 | 4 | 0.88×1.03×2.60 | oak, iron | |
|
|
| `vegetables` | Props | 1718 | 2 | 0.60×0.12×0.59 | produce | |
|
|
| `stone-well` | Structure | 1376 | 4 | 1.88×2.00×1.28 | stone, oak, iron, cloth | |
|
|
| `post-mill` | Structure | 1672 | 4 | 6.60×7.23×6.98 | oak, iron | |
|
|
|
|
**26 939 triangles** in total. The whole kit in one scene costs less than a
|
|
single mid-complexity character model.
|
|
|
|
Every model depends on `@medieval-kit/core`, a shared support item holding the
|
|
palette, deterministic randomness, and the geometry vocabulary. Pulling a single
|
|
prop brings it along, so an individually installed crate still looks like it
|
|
came from the same catalogue as the barrel beside it.
|
|
|
|
## Plain WebGL
|
|
|
|
This kit does **not** require WebGPU. It declares `capabilities: []` and builds
|
|
on `MeshStandardMaterial` and `MeshBasicMaterial`, so it renders on
|
|
`WebGLRenderer` as well as `WebGPURenderer`.
|
|
|
|
## Configuration
|
|
|
|
Models are procedural, not baked. Every prop exposes typed fields:
|
|
|
|
```ts
|
|
import { createModel } from '@/models/medieval-kit/wooden-barrel/model.ts'
|
|
|
|
const barrel = createModel({ staveCount: 17, taper: 0.22, seed: 42 })
|
|
scene.add(barrel.root)
|
|
|
|
barrel.configure({ hoopCount: 6 }) // rebuilds geometry inside a stable root
|
|
barrel.dispose()
|
|
```
|
|
|
|
`seed` is a real field, not a debug knob. Variation is deterministic: the same
|
|
seed always produces the same prop, so previews, tests, and art direction stay
|
|
reproducible. Change the seed to get a different barrel from the same model.
|
|
|
|
## Runtime anatomy
|
|
|
|
Each model follows the Vibe3D protocol:
|
|
|
|
- **`root`** keeps its object identity for the model's whole lifetime.
|
|
- **`parts`** expose stable semantic anchors. Attach your own lights, labels, or
|
|
gameplay objects to `part.anchor` — they survive `configure()`, because only
|
|
`part.content` is rebuilt.
|
|
- **`materials`** resolve per slot and can be overridden. Materials you supply
|
|
are borrowed: the model never disposes them.
|
|
- **`actions`** and **`update(delta)`** carry interactive state. Five models use
|
|
both — `wooden-chest`, `pitch-torch`, `iron-lantern`, `bronze-bell` and
|
|
`tavern-sign`:
|
|
|
|
```ts
|
|
const chest = createModel()
|
|
chest.actions.setOpen(true)
|
|
chest.update(deltaSeconds) // the lid eases open, frame-rate independent
|
|
|
|
const torch = createModel()
|
|
torch.actions.setLit(false)
|
|
torch.update(deltaSeconds) // flame flicker and light modulation
|
|
```
|
|
|
|
A model that never receives `update()` simply stands still; nothing animates
|
|
behind your back. Reach for `configure()` when a user changes a setting; it
|
|
rebuilds topology and is not cheap. Anything that changes per frame belongs in
|
|
`update()`.
|
|
|
|
## Material slots
|
|
|
|
Twelve slots, and each one exists because a single material could not carry the
|
|
difference.
|
|
|
|
| Slot | What it covers | Why it is separate |
|
|
| --- | --- | --- |
|
|
| `oak` | timber | — |
|
|
| `iron` | forged iron, oxidised and matte | separate from `steel`: the difference is not in colour but in ROUGHNESS, and vertex colour cannot carry roughness |
|
|
| `steel` | steel polished by use | the anvil face, the shovel blade, the pitchfork tines |
|
|
| `brass` | bronze and copper | the bell, the coins |
|
|
| `straw` | straw, wicker, broom bristle | a bale declaring itself "oak" would be a lie told to the consumer |
|
|
| `cloth` | linen, sackcloth, rope | — |
|
|
| `leather` | worked leather | — |
|
|
| `glass` | blown glass | transparent, `depthWrite` OFF, `DoubleSide` |
|
|
| `produce` | fruit and vegetable skin | given straw's roughness, an apple would look like dry grass |
|
|
| `ember` | flame | `MeshBasicMaterial` — it does not receive light, it emits |
|
|
| `char` | charcoal and pitch | — |
|
|
| `stone` | dressed and rubble masonry | its colour is close to weathered oak; what separates them at a glance is that stone scatters light completely flat, and roughness is not something vertex colour can carry |
|
|
|
|
`ember` also acts as a rule in two places: bodies in that slot are skipped
|
|
entirely while ambient occlusion and mottle are baked. The reason is simple —
|
|
in an unlit material the vertex colour *is* the final colour, so darkening it
|
|
puts the flame out. The rule is attached to the slot itself rather than to a
|
|
per-model flag, so it cannot be forgotten.
|
|
|
|
Colour variation lives in the geometry, not in the materials: each of the
|
|
barrel's 17 staves carries its own tone as vertex colours, and all of them share
|
|
one material and therefore one draw call.
|
|
|
|
## Building from source
|
|
|
|
```sh
|
|
bun build.ts # emit dist/registry.json
|
|
bunx vibe3d registry validate dist/registry.json
|
|
```
|
|
|
|
The build walks `models/`. A folder containing `model.ts` becomes a
|
|
`vibe3d:model`; any other folder becomes a `vibe3d:lib`. Registry dependencies
|
|
are derived from the source — an import from `../core/` makes
|
|
`@medieval-kit/core` a dependency automatically, so there is no hand-maintained
|
|
list to go stale.
|
|
|
|
`drafts/` is deliberately outside that walk: the code is kept in the tree but
|
|
never reaches the published package. See [`drafts/README.md`](drafts/README.md).
|
|
|
|
Released under the MIT License.
|