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

8 KiB

Medieval Kit

A lowpoly medieval model library for 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

bunx vibe3d add @medieval-kit/wooden-barrel
bunx vibe3d add @medieval-kit

Configure models.json first so the namespace resolves:

{
  "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:

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:
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

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.

Released under the MIT License.