medieval-kit/my-registry/README.md
CRT ad449f1677 Add the vegetable heap
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.
2026-08-23 15:21:08 +07:00

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.
![The whole kit](https://raw.githubusercontent.com/CRTkafa/medieval-kit/main/media/kit.png)
```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.