Commit graph

61 commits

Author SHA1 Message Date
CRT
11e20a7b1a Put the handcart's wheels back on the ground
Reported as "the wheels are in the wrong place, it is floating", and it was
three separate faults in one wheel.

The one that showed: the part declares `origin` at the axle so `setRoll` turns
the wheels in place, and a part with an origin has its geometry written
RELATIVE to that point — the anchor supplies the rest. The geometry was ALSO
translated by the axle height and position, so both were applied twice. The
wheels sat 304 mm off the ground at twice the axle height, and the cart stood
on its two shaft tips.

The one hiding behind it: the felloe segments were turned into place and then
moved out to the rim. That looks equivalent to moving out and then turning and
it is not — `rotateZ(a)` sends local +X to (cos a, sin a) and local +Y to
(-sin a, cos a), so a piece placed at (cos a, sin a) has its ARC pointing
radially. Every rim segment was a stubby block sticking 89 mm past its own
felloe, straight through the iron tyre meant to bind it. `cart-wheel` has had
the correct order all along, with a comment saying why; this model did not
follow it.

And the hub: a lathe is built around Y, the wheel is drawn in the XY plane, so
the hub stood upright inside the wheel like a little drum instead of running
along the axle. Invisible behind the spokes, which is how it lasted.

Then a fourth, found while checking the fix. The ground meets a pole's SURFACE,
not its axis: the shafts ran their axis to y = 0, so the square-cut end of an
inclined pole buried its low corner 13.7 mm, and the cart rested on two buried
tips with its wheels still fractionally clear. The axis now stops one tip
radius short and wheels and shafts share one floor.

`restsOn` is the check that would have caught all of it. The support check asks
whether every piece is connected down to the ground, and a cart standing on its
shaft tips answers yes: bed hangs off shafts, wheels hang off bed, nothing
floats. It reported 0 failing cases throughout. Connectivity cannot tell a
wheel from a decoration, and the renderer cannot either — the contact shadow is
cast from the model's own lowest point, so the shadow looked right the whole
time.

What the geometry cannot know, the author does. Naming the parts that carry the
weight turns "it rests on its wheels" into arithmetic, at 1 mm, which is tight
enough to have caught the 13.7 mm as well as the 304 mm. Declared on the cart,
the trestle table, the bench and the market stall.
2026-08-27 15:42:49 +07:00
CRT
a038f622dd Put the config step before the command it is required by
`vibe3d add @medieval-kit` resolves the namespace through `models.json` and
stops dead without it:

    Registry @medieval-kit is not configured in models.json

The registry README printed the two install commands FIRST and the
`models.json` entry underneath, introduced with the word "first". A reader
copies the top block, runs it, gets that error, and leaves. `vibe3d init`
seeds only `@scifi-kit`, so this is every new reader, not an edge case.
Config block first now, commands after it.

The cover card said `bunx vibe3d add @medieval-kit/wooden-chest`. Both address
forms are valid, but the bare namespace resolves through the registry's
`defaultItem` and installs the whole kit, which is what someone looking at a
card of the whole kit is being offered. A card has room for a command and not
for the `models.json` entry that command needs, so the shorter promise is the
honest one — and naming one arbitrary model implied a precision the card cannot
deliver.

Verified against the CLI rather than inferred from it:

    add @medieval-kit/registry   -> Registry @medieval-kit has no item named registry
    add @medieval-kit            -> Would install @medieval-kit · 46 files
    add @medieval-kit/grindstone -> Would install @medieval-kit/grindstone · 10 files
2026-08-27 15:26:45 +07:00
CRT
b29762a540 Draw the card with a command instead of a browser
The card a shared link unfurls to was a picture of the models with no title on
it, and the models on it were too small to tell apart. Both are fixed, but the
part worth writing down is how.

The first attempt was an HTML page composed in the site's own fonts and
screenshotted. It looked right and it was wrong: the model count on it becomes
a picture of a number the moment the shutter closes, and `check-docs.ts`
cannot read a picture. That is the exact failure that check exists for — the
READMEs claimed 27 models when there were 37 — and a card is the worse place
to have it, because nobody reading one can see the kit to know it is stale.

So `bun run cover:build` counts the catalogue and draws the numbers it counted.
`site:build` runs it, which means the published card cannot be older than the
models it shows, and the day a model is added the card says 38 without anyone
remembering to make it.

Drawing type needed type. `scripts/text.ts` reads the outlines out of a `.ttf`
and fills them with a scanline pass — four sub-scanlines a row with exact
horizontal coverage, which is more code than sampling a 4x4 grid and visibly
better at the 16px the two mono lines are set in. It is deliberately partial:
format 4 cmap, simple and composite outlines, advances, no kerning or shaping.
What it cannot draw it throws on, because a headline missing a letter is not
something you notice before it is published. Archivo and IBM Plex Mono come
with it under the OFL, the same two faces the site is set in.

The renderer moved to `scripts/raster.ts` so the card and the contact sheet
draw from the same code and cannot disagree about what a barrel looks like;
`render.ts` is now the command line over it, unchanged in what it does. Its
one new flag is `--ground`, a flat background instead of the per-cell
gradient: forty gradients side by side read as banding, and on the page's own
ink the models float on the card as one field instead of sitting on it as a
grey slab.

Ten columns by four, because 1.91:1 and 37 models want 40 cells and at that
shape the cells come out square, which is what lets a barrel look like a barrel
at 116 pixels. The last row is centred — three empty cells left flush is a
notch in the corner, and a short row centred is how a contact sheet ends.

Byte-identical across runs, so it does not churn the diff on every build.
2026-08-27 15:00:15 +07:00
CRT
dfee59381e Give the published page a card
A link to the site showed a URL and a blank square. It now carries a title, a
description, a canonical URL, an icon and a full set of OpenGraph and Twitter
tags, so a page nobody has opened yet arrives as something you can look at.

Two of those tags have to be ABSOLUTE and it is not a style question: a
scraper fetching the page has no base to resolve `/cover.png` against and
drops it silently. `build-site.ts` now copies the images into the output as
well, because an `og:image` pointing at this host has to have the file ON this
host — a card that 404s shows nothing, and nothing about the page itself looks
wrong while it happens.

The cover is a real 1200x630 render rather than a crop of a square one, which
took a change to the renderer worth having anyway. `newFrame` and
`frameCamera` both took a width and a height already; only `renderOne` was
insisting they be equal. And with a non-square frame the old camera fit showed
its flaw: it fitted the bounding SPHERE, which is one number and wrong for
anything not roughly round. The kit scene is wide and flat, so its sphere is
far taller than the scene is, and fitting that into the vertical field left the
models in a band across the middle with empty air above and below — most of a
1.91:1 card. It now fits the BOX, iteratively, because perspective is not
linear in distance: place the camera, project the eight corners, move by how
far the worst one lands past the edge. Two passes is inside a pixel.

That change improves every render, not just the card: on the contact sheet
every model is visibly larger in its cell and nothing crops.

`check-docs` now watches `viewer.html` too. Its description quotes the model
count and the triangle total, which is the same sentence that went stale three
times in the READMEs before that check existed — and a stale number in a card
is worse than one in a README, because nobody reading the card can see the kit
to know it is wrong. The description says "37" rather than "thirty-seven" for
exactly that reason: a word is not a number the check can find.

Also repointing the npm README's two images from `raw.githubusercontent.com`
to the live site. They were pointing into a private repository and would have
404'd on npmjs.com; now they do not depend on the repository being readable at
all.
2026-08-27 14:34:59 +07:00
CRT
151aeb89b2 Take the oak out
Five attempts and it was never good. The last one is better than the cabbage
that prompted the complaint, and "better than the version you called ugly" is
not a standard worth shipping against — every other model here would have to
carry it.

The constraint underneath is real and worth recording, because it is not a
tuning problem and no sixth attempt was going to find it. The kit's budget is
2500 triangles a model. The oak wanted a bole you can see, limbs radiating out
of it, and a dome of foliage over the lot; at 2500 that is roughly 128 for the
trunk, 880 for the boughs and 1400 for the crown, and 1400 buys about forty
masses. Forty masses hung on twelve limbs is twelve clusters, and twelve
clusters do not make a dome — from a third of the compass you see the gaps
between them. A turntable says so plainly where a single three-quarter view
does not, which is the other thing to keep: I judged this model on one angle
for four rounds.

Everything else in this kit is something a person made, with flat faces and
straight edges, and lowpoly is not a compromise for those — it is close to how
they are actually built. A tree is the one subject where the budget genuinely
bites, and the honest options were to except it from the budget, to abandon
realism for a deliberately stylised shape, or to drop it. Dropped.

Out with it goes the `leaf` slot and its palette key, since the oak was the
only thing using either. Back to thirteen slots. `bark` stays, because the log
pile uses it; `limestone` stays, because the trough and the grindstone do.
`scripts/probe-tree.ts` goes too — it measured one model against one pair of
photographs and there is no longer a model.

37 models, 30 390 triangles, thirteen material slots, all checks pass, 0
floating cases, GLB round-trip clean, Conformant, documentation in step.
2026-08-27 14:25:26 +07:00
CRT
c02ec0dcbe Put the documents back in step
The previous commit changed the tree's triangle count and I pushed it with all
three documents still stating the old total. `check:docs` caught it — it exited
1 inside the same command that ran the commit, and the commit went through
anyway because it was not what the shell was waiting on.

Which is the check working and the process around it not. The check belongs
where a failure stops something: it is in CI, so the push is already flagged,
but a gate I run beside a commit rather than before it is a gate I can walk
past. Worth writing down rather than quietly fixing, since the whole reason
`check:docs` exists is that these numbers had gone stale three times before
anyone noticed.
2026-08-27 14:07:40 +07:00
CRT
e99969aad9 Rebuild the oak's crown, and stop asking it to be one blob
"Very ugly" was the report and it was right. Put beside its reference the
diagnosis is not subtle: the crown read as crumpled paper, the colour was flat,
and the trunk had been swallowed whole.

The correction that mattered was to a rule I had invented myself. I decided
early that "a crown is one piece" and made the probe fail anything else, so
every version since had been tuned until the flood fill returned 1 — which is
the instruction "be a single smooth blob", and that is exactly what it kept
producing. The support rule requires the crown to be connected to the TREE, not
to itself: a clump that swallows a branch end is held up by that branch
whatever its neighbours do. Letting the masses separate is what buys the gaps,
and the gaps are the silhouette. The probe now reports the piece count as
information instead of grading it.

Four other things, each of which cost a render to see:

  - The clumps were three rings on five sides — a coarse bipyramid — and
    sixty-five of them creased every facet against its neighbour. Five on eight
    fixed the surface and made a cabbage. Four on six is the middle, and the
    middle is where it belongs.
  - `roughenGeometry` came off the crown entirely. Ragged edges read as foliage
    where smooth ones read as stone: true of a SILHOUETTE, false of a surface.
    All it did was crumple the skin. Raggedness has to come from the
    arrangement.
  - The tone between clumps varied by +/-0.09, so every mass was the same green
    and the crown was one object with facet shading on it. Widening it to
    +/-0.19 put seven whole clumps through the floor into black, because the
    tint jitter, the mottle and the baked occlusion all pull the same way
    underneath. It is asymmetric now, -0.03 to +0.23, which is also truer: a
    lit mass is far brighter than the base green while a shaded one is filled
    in by the sky.
  - Every clump sat on the outer half of its limb, making a crown that is a
    RING — full at the rim, hollow in the middle, and from a third of the
    compass you look straight through the hole. I had that right once and
    removed it while cutting triangles, which is the cheapest regression to
    make and the hardest to notice: nothing failed, the model just got worse.

The bole went from 0.037 of the height to 0.046. The reference's own number
came off a photograph of a tree seen whole from a distance; at this size, under
a crown, 0.037 disappears and what is left is a cabbage on a stick.

Twelve limbs instead of nine, spread more evenly, with cheaper geometry paying
for them. And the turntable is now the thing I judge it by rather than a single
three-quarter view — one good angle hid two that were full of daylight.

Honest state: better than it was and not yet good. Three of six angles read as
a tree; the others are still bunched. 38 models, 32 798 triangles, all checks
pass, 0 floating cases.
2026-08-27 14:06:58 +07:00
CRT
4730973fbd Nothing stands proud of a surface you sit on or stand under
Reported for the third time, and the first two answers were wrong in the same
way: I treated "it should not be visible" as a number to reduce rather than a
rule to satisfy. The bench's through-tenons went 42 mm proud, then 3 mm, and
3 mm is still a raised patch with a lit edge on it — visible in the render,
and the thing being complained about. The market stall then reproduced it
exactly: four post heads through the awning, like tacks.

Both were pushed proud DELIBERATELY, to keep two faces out of one plane and
away from the z-fight check. That trade is false, and this is the sentence
worth keeping: a piece that must not show ends INSIDE the solid it enters. A
buried face cannot be coplanar with a surface and cannot be seen. Level with
the surface is the only genuinely bad option, and it was the one I kept
treating as the alternative.

  - The bench's tenon is now housed an eighth of the seat's thickness below
    the top. That is a real joint rather than a compromise: a stub tenon into
    the underside of a board is how a bench is made when the top is thick
    enough to take it, which at 0.135 of the height it is. What is lost is the
    panel of end grain; what is gained is a seat.
  - The stall's posts stop ON the cloth's mid surface, with half its thickness
    over them, and the cloth went from 0.2 of a board to 0.42 so there is
    something to bury them in. The heads now sit 25 mm under the top surface.

And the brief is a check now, because a rule I keep is worth less than a rule
the build keeps. `nothingAbove` names the part nothing else may rise above —
the seat, the counter, the awning — and it reports the offender and the
millimetres. Mutation-tested: pushing the tenon back to 3 mm proud fails it
with "PROUD: legs by 3.0mm".

38 models, all checks pass, 0 floating cases, documentation in step.
2026-08-27 13:52:08 +07:00
CRT
672041cceb Make the site build install what it needs
The host's build ran `bun run site:build` and stopped at `vite: command not
found`, after `registry:build` had already succeeded — which is exactly the
shape of the fault. `registry:build` needs nothing but bun, and bun was
installed; `viewer:build` needs vite, vite is a devDependency, and nowhere in
the build log is there an install step at all. The runner fetched bun 1.3.14,
went straight to the user command, and everything that did not need
`node_modules` passed on the way to the thing that did.

`site:ci` installs from the lockfile and then builds. It lives in the
repository rather than in a dashboard field because a build command typed into
a web form is not versioned, not reviewable, and not visible to anyone reading
this project later.

CI now runs `site:build` too. It had never run anywhere but a Windows laptop —
the workflow built the app and the registry and rendered the contact sheet, and
never once built the thing that actually gets published. This is the second
time in two days that a step nobody exercised turned out to be the broken one,
and the answer is the same both times: run it where it will run.

Also gitignoring `wrangler.toml`/`.json`/`.jsonc`. Cloudflare's "import a
repository" flow offers to write one, and it describes a WORKER serving static
assets rather than a Pages project. The site is Pages. A config for the other
path does not belong in the tree, and it would not be inert there either: the
build log reports "Checking for configuration in a Wrangler configuration
file", so a stray one changes how the project is treated. There is one sitting
untracked in the working copy right now, which is why this is a rule and not a
deletion.
2026-08-27 13:37:07 +07:00
CRT
7c13f2d66b Hash the site's bundles, and take the ops notes out of the README
Two corrections, and the first was a bug I had just written.

`vercel.json` served `/viewer.js` and `/viewer.css` with a year of immutable
caching, and those names carry no content hash — they are pinned in the vite
config. Immutable caching on a fixed name means a returning visitor is handed
the bundle they already have, for a year, no matter how many times the site is
rebuilt. The rule and the filenames were written a few minutes apart and
contradicted each other.

So the bundles are hashed now, which makes the caching correct rather than
wrong, and the cost is exactly the thing the rule creates: nothing downstream
may name them literally. `build-artifact.ts` finds them by pattern. So does
`build-site.ts` — whose own first version listed `viewer.js` by name in its
size report, and fell over on the very build that introduced the hash. The
mistake and its consequence landed in the same commit.

The cache rules now live in a `_headers` file written into the output, where
the host looks for them: a year on the hashed bundles, and revalidation on the
HTML, which must NOT be cached that way — it is what points at the current
hash, and a stale copy of it pins a visitor to a build that no longer exists.

Second: the README had grown a hosting comparison — which providers are free
with a private repository, what GitHub Pages costs. None of that belongs in a
README. It is a public-facing document about a model registry, read by people
deciding whether to install it, and my deployment arithmetic is neither their
business nor true for their situation. What stays is the part that is about
this project: the command, what the output is, and that nothing is fetched at
run time. `vercel.json` goes with it, since it was config for a host this
project does not use.
2026-08-27 13:27:29 +07:00
CRT
747efdbf7d Make the viewer publishable as a static site
`bun run site:build` and the output is a site: the viewer at a domain root,
with an `index.html` a host can answer `/` with. The viewer build already
emitted everything else — `viewer.js` and `viewer.css` loaded by absolute path,
which is correct at a root and wrong nowhere — but it emitted no index, so a
static host serving the directory answered `/` with a 404 and the site would
have looked broken while every file on it was fine.

The landing page is the VIEWER rather than the demo app, which is the one
decision in the script and the reason it is a script: someone arriving at the
address should be able to turn a model over and press play, not read a page
about a model they cannot see.

Verified the way a host would see it rather than through the dev server: built,
served from the directory as a root, and every one of the page's own requests
came back 200 — `/`, `/viewer.js`, `/viewer.css` — with a clean console and all
38 models in the rail. Nothing is fetched at run time, either; the registry
address in the sidebar is a label, not a request, so there is no API to stand
up.

`vercel.json` carries the build command, the output directory and a year of
immutable caching on the two hashed assets. Cloudflare Pages takes the same
two settings in its dashboard.

GitHub Pages is in the README's table with a "no" against it, because it is the
obvious guess and the answer costs money: on the free plan Pages serves public
repositories only. Worth stating where someone will look for it rather than
leaving them to find out.

And one line that is a decision rather than a fact, so it is written down:
publishing the viewer publishes the models. The bundle carries every builder in
it, minified. That is a different thing from the npm package — which ships the
source AS source, comments and all — but it is not nothing.
2026-08-27 13:19:50 +07:00
CRT
e9f479eead Add the market stall
The piece the kit had been assembled around without having. The basket, the
vegetables, the sack, the crate and the coin pouch all belong on one, and a
market with none of it is a row of props on a floor. It is also the only thing
here with a roof.

The awning SAGS, and that is the whole model. A flat sheet on four posts is a
table with a lid; cloth slung between four points hangs, and the dip is what
says it is cloth before the colour does. It costs nothing:
`dishedSheetGeometry` bends its cross section by `curve`, and one turn about X
maps that bend onto the world's vertical, so the sag is the helper's own
parameter rather than anything hand-built. Per level, because the reference's
front edge dips further than its back — cloth pulled over a ridge and left
loose at the eaves does not hang symmetrically.

Three faults, each of them a thing I had drawn correctly and built wrong:

  - The posts were one height, and the cloth is not one height. It is a
    parabola across its length and deeper at the front than the back, so a
    single post reached some corners and not others: the awning came away as
    its own floating piece 67 mm above the post heads, held up in the drawing
    and by nothing in the geometry. Each post is now sized by the sheet's OWN
    arithmetic — same parabola, same per-level sag, same quarter turn, same
    tilt — rather than by a second version of it that can drift.
  - The legs ran up to the rail's top face and ended level with it. Two upward
    faces in the plane y = 0.745, at all four corners. A leg is housed in a
    rail, not flush with it.
  - The posts stopped at the rail's underside, which put their feet in ITS
    plane — the same fault one storey down. They now run past it and finish as
    a stub below, which is the reference's detail as well as the fix.

And one thing the render corrected rather than the checks: the posts were
inboard, so the counter's boards had posts driven through them a hand's
breadth from each end. That is not how a stall is built and not what the
reference shows — the posts stand at the ends and the top lies between them.
The sag came down from 0.17 of the depth to 0.10 for the same reason: at 0.17
the cloth dropped a tenth of its own span and read as a hammock.

38 models, 32 700 triangles, all checks pass, 0 floating cases, GLB round-trip
clean, Conformant, documentation in step — including the showcase arithmetic,
which every new model moves.
2026-08-27 03:17:02 +07:00
CRT
3a215db997 Add the grindstone, which turns
The kit had an anvil, a forge and four edged tools, and nothing that put an
edge on any of them. This is the machine that did, and the only piece of
village equipment here with a working part in it — so it turns: crank it and it
runs for three revolutions over five and a half seconds, which is a heavy stone
on a wooden bearing rather than a flywheel.

The wheel is `limestone` and that was measured, not assumed: the reference
reads hue 32.5, saturation 0.196, lightness 0.511 against the trough's 36.6,
0.161, 0.500. A sandstone wheel and a weathered trough are the same rock as far
as this palette is concerned, and neither is the grey `stone` of a dressed
wall. The frame is `oak` taken well down in value — the reference's timber is
0.25 against the palette's 0.40 — but it stays oak, because a third wood key
for one model would be describing the weather rather than the material.

Three faults, all found by the checks and all of them things I had reasoned my
way past:

  - The axle and its washers were built, merged into a local array, and then
    never returned. The wheel hung on nothing and the crank floated beside the
    machine at 0.60 m. An array you forget to use is not a type error.
  - The axle sat 48 mm ABOVE the rails and touched them nowhere. The reference
    answers this itself and I had left the answer out: there are bearing
    blocks bolted on top of each rail with the axle running through them.
  - The low stretcher ran down the centre line from end to end and touched
    nothing on the way, because at that height the legs have splayed out to
    z = 0.21 and it was at z = 0. It tied the trestles together in the drawing
    and not in the geometry. There are now two rails, one down each side, and
    where the legs are at that height is worked out from the strut's own two
    ends so it stays true when the splay is changed.

The last two shared a symptom — the support check reporting two components,
one on the ground and one containing everything above it — and finding them
took a detour worth writing down. Sampling the frame's VERTICES to see where
the legs were at rail height found no legs at all, because a tapered box has
corners only at its ends. That is the third time in this project the same
mistake has been made with the same consequence: a measurement that returns a
confident number about nothing. Slicing the triangles instead put the leg's
cross section exactly where the arithmetic said it was, which is what proved
the gap was somewhere else.

What is NOT here, said plainly: the reference has a treadle and a linkage down
to a foot board. This has a hand crank instead. Both are period; the treadle is
three more moving parts to serve an animation the crank already carries.

37 models, 32 316 triangles, all checks pass, 0 floating cases at 224 voxels as
well as at 112, GLB round-trip clean, Conformant, documentation in step.
2026-08-27 03:06:35 +07:00
CRT
0b2623755f Add the stone trough, and water to put in it
The kit could water nobody. It had a well, a bucket and a cauldron, and all
three are things you carry water IN; a trough is the thing a village keeps
water in, standing in the street outside the smithy.

Two additions to the core, both earned by measurement rather than by wanting
them:

  - `limestone`, a palette key. `stone` was measured off dressed and rubble
    masonry and is grey, because a wall is quarried and laid face out. One
    block left in the open for a century reads hue 36 and saturation 0.16
    against the wall's 44 and 0.05 — warmer, three times more saturated,
    lighter. Both are right and neither replaces the other. The rendered block
    now matches its reference to within a fifth of a degree of hue and 0.004
    of lightness.
  - `water`, a slot, and it is far less green than instinct says. Sampled
    inside the reference's basin the film reads hue 39.5 — which is the hue of
    the stone UNDERNEATH it, not a colour of its own. It darkens and slightly
    saturates whatever it lies on and does that through the material's
    opacity. Painted the pond green I was about to reach for, the composite
    would have missed the reference by a hundred degrees.

Making it read as CARVED rather than sawn took three goes, and the two failures
are opposite errors worth keeping:

  1. Each wall in two tiers, to give `roughenGeometry` more corners to move.
     The tiers' corners sit at different positions, the hash moved them by
     different amounts, and the render showed a lid on a base with a hard seam
     right round it.
  2. One box per wall, roughened harder to compensate. A box has eight corners
     and every face is two triangles, so moving those corners folded each wall
     along its own diagonal. Not stone: creased cardboard.

Both were trying to get subdivision out of the wrong thing. The walls are
`dishedSheetGeometry`, whose own docstring says the trough wants it: one
continuous body with levels along its length, so the rim undulates because
each level carries its own height, the wall thickens and thins because each
carries its own thickness, and there is no join for a seam to open in.

The z-fight checker earned its keep twice on the same plane, `0,-1,0 | 0`.
Four walls standing on the ground all have a face down there, and where the
corners overlap — which is where they are MEANT to overlap — two of those
faces share it. Lifting the basin floor only moved the pairing from
floor-against-wall to wall-against-wall. The answer was to stop there being
four things down there at all: a trough is solid below the water line, so it
is one block from the ground to the basin with the walls sunk into it, by
different amounts, because two buried faces in one plane are still two faces
in one plane.

Last, two defaults that the render corrected rather than the reference. The
walls at 0.27 of the width shut the basin so far that a three-quarter view
showed only its inner face, against the reference's opening of about three
fifths — now 0.21. And the water at 0.55 of the basin's depth sits below the
near rim and the trough reads as dry from every angle anyone will look at it
from; the reference stands a couple of centimetres under the rim, which is
0.85.

36 models, 31 748 triangles, fourteen material slots, all checks pass, 0
floating cases, GLB round-trip clean, Conformant, documentation in step.
2026-08-27 02:46:05 +07:00
CRT
30c7c4863a Take the shoulder off the bench's feet
Reported twice. The first time I planed the through-tenons from 42 mm proud to
3 and called it done, which answered the tenons rather than the question — the
tenons were never it. Measured again now: nothing stands more than 3.9 mm above
the seat at any slider setting, and the thing that was catching was on the
LEGS.

The leg board widens on the way down; that is the splay. The two feet, cut out
of its bottom edge by the arch, were built at `footWidth` — the board's width
at the FLOOR. But a foot starts at the top of the arch, a third of the way up,
where the board has not widened that far yet. So every foot stood proud of the
board it hangs from, on both sides, as a hard shoulder at shin height: 14 mm by
default, 20 mm on a wide bench, 32 mm at full splay. Setting the splay to zero
made it vanish, which is what finally named the cause.

The leg's outer edge is a straight line from `footWidth / 2` at the floor to
`legWidth / 2` under the seat, and a foot's outer face has to lie ON that line
rather than run vertically up from its own base. A tapered box narrows about
its centre, so taking twice the taper off the top width moves the outer face in
by exactly the taper — and moves the inner face out by the same, narrowing the
arch as it rises, which is also what a splayed leg does. 14.2 mm becomes 0.2,
32.3 becomes 0.4, and 0.2 mm is the sampling resolution rather than a step.

`scripts/probe-bench.ts` is how it was found and it took two wrong versions to
get there, both failing the same way — by measuring something and reporting a
number that was not the answer:

  - The first sampled vertices into height bands. A tapered box has corners
    only at its top and bottom, so the bands between them came out empty, were
    skipped, and it reported a confident 0.0 mm step for a leg with a 17 mm
    shoulder on it. It now slices the actual triangles, which gives a width at
    any height whether a corner is there or not.
  - The second scanned the full height of the leg part and found its biggest
    drop at 86% — exactly where the board ends and only the tenon carries on
    into the seat. That is what a tenon IS. A real fault of 14 mm was sitting
    underneath a 66 mm reading that was not a fault at all, so the scan now
    stops at the seat's underside.

Both media images regenerated, since the bench is in each of them.
2026-08-27 02:02:35 +07:00
CRT
a48431ca9c Lay the whole-kit scene out square, and let it be the picture
The image both READMEs open with was a contact sheet whose FIRST cell — top
left, the one everybody's eye lands on — was the `kit` entry: all thirty-five
models arranged in one scene, squeezed into a 300 px cell, an illegible smear.
It is not a model and it does not belong in a grid of models. Rendered on its
own it is by far the better picture anyway, so it is now the picture, and the
contact sheet is a second image beneath it.

Rendering it on its own is also what showed the arrangement had quietly gone
wrong. The row width was a fixed 5.6 m, which was right for a kit of tankards
and barrels and became wrong the moment anything bigger than a row went in: the
oak is 8.6 m across and the mill 6.6, so each took a row to itself while the
small props packed four and five deep. The kit had stretched into a long thin
diagonal, and since a camera fits a bounding SPHERE, the sphere around a
diagonal line is enormous next to the things inside it — the kit occupied about
a quarter of its own picture and the rest was empty floor.

So the row width is measured from the models now rather than fixed: the square
root of the total footprint, which packs rows about as deep as they are wide
whatever the kit happens to contain, floored at the widest single model since
no row can be narrower than the thing standing in it. Swept for the factor:
1.15 gave 15.1 x 21.7 m, 1.45 gave 19.2 x 13.3 — the square is between them,
and 1.24 lands at 16.4 x 14.8, aspect 1.11, filling the framed disc better than
any of them.

It reads as a village yard now: mill and oak at the back, forge and cart in the
middle, the small props at the front. Which is what the entry is FOR — every
model on one ground plane at one scale is the only place a difference in scale
or tone has nowhere to hide.
2026-08-27 01:50:26 +07:00
CRT
d6c76dae32 Report the backend that is actually in use
The viewer's readout took `navigator.gpu ? 'WebGPU' : 'WebGL2 (fallback)'`,
which is the browser's CAPABILITY and not the backend the renderer settled on.
The two agree in every ordinary case, which is exactly why it would have gone
on being wrong indefinitely.

It came up while checking what a visitor without WebGPU actually gets from the
page, since that page is meant to be published and Safari and Firefox only got
WebGPU recently. Forcing the WebGL2 path, the readout went on saying WebGPU
throughout. It now asks the renderer, and forcing the path again proves it:
with `navigator.gpu` still present it correctly reads WebGL2 (fallback). One
line, and it is the only signal anyone has about which path they are on — a
readout that can be wrong is worse than none.

The check itself was worth more than the fix. The whole viewer works on the
WebGL2 backend, `@scifi-kit`'s TSL node materials included: the gauge renders
with its emissive dial and its brass intact, and the console is clean. So the
README's warning was wrong in the direction that costs you readers — it told
people they needed WebGPU to run the viewer, when only the separate demo app
insists on it.

Also: the comment above the showcase buttons still did its arithmetic for 27
models. Same class of thing as the stale README numbers, in a place
`check-docs` cannot see, so it now names all three durations and says why 90 s
exists.
2026-08-27 01:43:57 +07:00
CRT
5e390bcc85 Run the gate in CI
Everything here already ran locally and every one of the checks has caught a
real defect, but "it passed on my machine" is thin evidence for a registry
whose whole premise is that other people install the SOURCE. A broken model
does not reach them as a bad download they can retry; it reaches them as broken
code inside their own project.

The workflow runs the same gate the README documents: types, ~1,000
conformance checks, the support audit across every configuration, the GLB round
trip, the documentation check, the registry protocol validation, the build.

Two things worth noting about it. None of it needs a browser or a GPU — the
conformance checks, the round trip and the renderer were all written headless,
so this is an ordinary ubuntu runner and about a minute. And it uploads the
contact sheet as an artifact, because every check in the list is geometric and
not one of them can say "this does not look like a shovel"; the shovel was
rewritten four times on the evidence of those PNGs and nothing else. Attaching
the picture to the run is what makes that reviewable by someone other than me.

The render invocation was tested rather than assumed: `bun run render -- --size
300 --out renders` does pass its flags through, which is the sort of line that
otherwise fails once, on GitHub, after a push.
2026-08-27 01:40:39 +07:00
CRT
6ee7a9dcf0 Check the documentation against the kit it describes
REFERENCE.md was 751 lines long and said the kit had 27 models, 15,425
triangles and eleven material slots. It had 35, 31,404 and thirteen, and the
document named none of the last six models at all. A reference nobody can trust
the numbers in is worse than no reference, because a reader has no way to tell
which parts are still true.

This is the third time the same failure has happened here. It is why
`catalog-table.ts` exists — the reference once said "four items" when the kit
had twenty-three — but regenerating the TABLE never touched the prose around
it, and the prose kept its own copies of every number.

So the counts are now checked the way the geometry is: against the models
themselves, by a script that fails. `check-docs.ts` reads the built kit and
requires each of the three documents to state the right model count and
triangle total, to list every model that exists, to name none that does not,
and to get the slot count right where it is written as a word.

Mutation-tested, as everything else here is — a wrong total, a dropped model
row and a stale slot word each fail it. And that test cost me the very fix it
was testing: `git checkout REFERENCE.md` to undo a mutation threw away the
uncommitted repairs along with it. Mutation-test after committing, not before.

Two things it caught immediately:

  - The root README never stated a triangle total at all, so the number a
    reader most wants for judging whether a kit is affordable was in two of
    the three documents and absent from the front door.
  - My own first version of the slot check. It looked for any word before
    "slots", matched the ordinary phrase "material slots" several paragraphs
    earlier, found that "material" is not a number, and silently checked
    nothing — reporting nothing rather than failing. A check that passes by
    not running is worse than no check, because it reads as evidence.

`bun run check:docs`, and it is in the README's verify list with the others.
2026-08-27 01:37:18 +07:00
CRT
9de2721422 Frame every model in the tour, and measure that it does
The showcase is the one deliverable a still render cannot check, and until now
the only way to check it was to watch it -- which is how a model half out of
frame gets shipped. `__probe.framing()` projects the current model's bounding
box into the frame and reports the worst corner in normalised device
coordinates: 0 is comfortably inside, 1 is exactly on the edge. The whole 60
seconds can then be swept and the bad moments found by number.

Swept, it found five models leaving the frame. Four of them were during the dip
between beats, where the veil is 92% opaque and the point is that you cannot see
the swap -- so the honest figure is two frames out of 1060, the broom and the
ladder, each for a twentieth of a second. Not much. Also not nothing, when the
whole point of the tour is that someone records it once and posts it.

The first fix was to widen the framing margin from 1.55 to 1.62. It worked and
it was the wrong fix: it took a slice of the frame off all thirty-five models to
buy back two frames from one, and mean fill fell to 0.515 -- every model sitting
in half a screen of empty space.

The right fix is in the chase. The camera's distance follows the model's size by
exponential approach at a single rate, and the two directions are not the same
mistake: falling behind a model that is GROWING crops it, and falling behind one
that is shrinking only leaves it small for a moment, which nobody notices. The
broom was the last offender because its bundle and its handle both sweep. So the
camera now retreats at 18 and closes at 7, and with that in place the margin
came back in past where it started, to 1.38.

Measured over the visible frames of a full 60 s run: no frame crops, the closest
any model comes to the edge is 0.81, and mean fill is 0.605 against 0.515 for
the wide-margin version and 0.55 for the original. Models are bigger in frame
than before AND nothing is cut.

Also here: the tour verified end to end -- 1201 frames, 60.05 s, 35 models, no
repeats, chest to oak -- and two stale numbers in the README. It claimed ~800
checks (1,044) and did its showcase arithmetic for 27 models, which is the kind
of sentence that goes quietly wrong every time a model is added, so it now says
what the number does when the kit grows.

One correction to my own measurements, twice over. `showcase.update` clamps its
delta to 0.05 internally, which `frameStep` does not, so stepping 0.2 advances
0.05 -- my first two readings of the tour's length were wrong by 4x and 10x and
disagreed with each other, which is what gave it away. And the run that
reported 34 models in 57.8 s was racing a page reload, not a dropped model.
2026-08-27 01:30:34 +07:00
CRT
7d2877c901 Add the oak
The first thing in this kit that grew rather than got made, and the first one
built entirely against reference photographs from the start rather than after
three rounds of guessing.

Two references, generated with the same Codex tool the rest of the kit now
uses: one oak in full leaf and the same tree bare. Both of them contradicted
what I was about to build, and the default lowpoly tree -- a tall pole with a
ball on it -- turns out to be wrong in every measurement that matters.

  crown width / tree height     1.32 to 1.43.  An open-grown oak is half again
                                as WIDE as it is tall. Nothing else in this kit
                                is wider than it is tall.
  widest at                     24% of the height. Not halfway up: a quarter,
                                just above the ground, because the lowest limbs
                                are the longest and leave almost horizontally.
  bole runs to                  72% of the height as one continuous stem. It
                                does not fork low into equal leaders, which is
                                what the leafy photograph alone suggested and
                                the winter one disproved.
  bole width / height           0.130 at 2% of the height, 0.073 at 10%. It
                                nearly halves in the bottom tenth and then
                                barely tapers at all for a quarter of the tree.

The taper table in the model is those rows, read off the photograph one at a
time. The limbs SWEEP -- out at a shallow angle, turning up at the end -- which
comes free from bending each limb along its own axis before rotating it into
place, and which cost a correction I had not expected: bending a limb swings
its whole chord up by half the arc, so every limb was arriving 0.21 rad high
and the crown's widest point had climbed to 38%. Aiming low by exactly what the
bend will lift is the fix, and it is a constant.

Measuring is the whole story of this one. `scripts/probe-tree.ts` prints the
four reference numbers off the built geometry the same way they came off the
photographs, plus one a photograph cannot give: how many separate pieces the
crown is in. A crown is one piece. This one was four, then one, then two, then
one again, and the flood fill was right every time my eye was vague.

Four things it caught that looking would not have:

  - Forks throwing DOWNWARD. The rise of a fork is sin(splay) * sin(spin), so
    the sign of the PRODUCT decides it. I constrained the spin and alternated
    the splay, which left every second fork pointing at the floor. The
    measurement said so plainly while the reasoning said the opposite.
  - The crown torn in two, and not where I kept looking. Locating the loose
    piece -- height 10-64%, bearing 228-313, reaching 5.7 m where the main mass
    stopped at 4.5 -- ended four rounds of widening clumps and evening out
    bearings, none of which were the gap. The gap was the MIDDLE: pushing the
    leaves outboard to open the space under the crown had taken the inside out
    with it, and nine lobes hung off a bare hub. The reference's crown is full
    from the bole outward; the clear space is underneath.
  - Occlusion, exonerated. Turning it off entirely changed the clipping by
    nothing at all, which took two minutes and saved a redesign.
  - My own metric, wrong twice. It counted every dark pixel in the crown's box
    and 87% of what it found was the cast shadow, so it read 10.8% clipping on
    a crown that was barely clipping. And it measured the reference by row
    width but our own geometry by twice its radius, which is a different
    quantity on anything that is not symmetric.

Colour is measured too, and the measurement contradicted me again: I was about
to bake a top-to-bottom gradient into the crown, and sampled in three bands the
reference reads 0.362, 0.336, 0.324 -- no gradient at all. What is there is
local, a spread of 0.45 in lightness within one band. Foliage varies leaf to
leaf, not top to bottom. Chasing that 0.45 with tint, mottle and occlusion all
at once put 2.2% of the crown through the floor into pure black against the
reference's 0.19%, which is the straw trap for the third time: a statistic taken
off fine detail cannot be added on top of the machinery that already
reproduces it.

The bark is measured off the WINTER photograph after the summer one gave a
colour 28 degrees too green -- a trunk under a full crown is lit through the
leaves. Low saturation made it worse than the hue did: a nearly neutral colour
takes its hue from its light, and this renderer's blue-grey sky dragged the
rendered bole to hue 119 on a tree whose bark is brown.

Finished: hue 79.6 against 77.9, saturation 0.386 against 0.381, lightness
0.386 against 0.373, 0.00% clipped. Crown 1.325 wide for its height, in one
piece, hanging from 8% where the reference hangs from 11%.

The honest limits. What fills a real winter silhouette is thousands of twigs
and no budget here reaches them, so `leafiness: 0` is a bare tree of LIMBS --
a good dead or winter tree, not a photograph of one. And the crown still
carries more of its width high than the reference does: widest at 50% against
24%, on a reference profile that is nearly flat from 24 to 44 so the peak is
softer than the number sounds.

2310 triangles, inside the kit's 2500 budget, which cost a side off every clump
and a level off every fork -- but not one clump, because the count is what
holds the crown together. 35 models, 31 404 triangles, thirteen material slots,
all checks pass, 0 floating cases, GLB round-trip clean, Conformant.
2026-08-23 19:47:21 +07:00
CRT
fb092b8ef0 Stand the bellows on something
Opening the viewer found in ten seconds what the contact sheet had hidden
through two rounds of refinement: the bellows was cantilevered off a single
post driven through its middle, with four fifths of a door-sized leather bag
hanging in open air. The 300px thumbnail is too small to see what is under a
thing. A viewport you can orbit is not.

The support check passed it, and was right to by its own lights. The post
reaches the floor and the nose touches the hearth wall, so the assembly IS
connected to the ground. Connected is not the same as looking like it could
hold itself up, and looking like it can is the entire rule. The check
answers a question about topology; the rule is about weight.

So the bellows gets the bench it always had in life: two bearers let into the
forge wall at one end -- let INTO, a third of the way through, because a
bearer that stops at the wall face is a bearer resting on nothing -- carried
on legs at the other, with a rail across their tail. Six plain boxes, no
rotations at all, which after three two-rotation mistakes in this one model
was the point. The post moves out beyond the tail where it stops spearing
the bag.

Two more things came out of the same look.

The lever ran the wrong way -- from the post out into empty air, away from
everything. The end a smith pulls is the end he can reach standing at the
fire, so it now falls back over the bellows towards the hearth. And it had
nothing to pull: a rocker with no connection to the top board is a stick
resting on a post. There is now an iron link rod through the board, which is
also the only thing tying the two halves of the assembly together to the eye.

And the leather. The last round widened it so it would stop hiding between
the boards, but width and length were the same parameter, so it grew a third
longer in the same breath and the bag ended up running well past the boards
it is nailed to. Leather cannot outrun its boards -- they are what it is
fastened to. The two scales are now separate and the hide is wider (1.18 to
1.33) and shorter (0.82 to 0.9), which is the real thing and also spreads
the five end caps further apart than the old scheme managed, since they are
now staggered on purpose rather than as a by-product of the width.

+72 triangles, six boxes. 34 models, 29 094 triangles, all checks pass,
0 floating cases, GLB round-trip clean, Conformant, build clean.
2026-08-23 17:19:23 +07:00
CRT
548ff0dfac Finish the shield and the forge
Both are now at the kit's standard, so the catalogue table and the contact
sheet go with them this time.

Five things, each named from looking at a render beside its reference.

The fire bed was a smooth disc of the darkest colour in the palette, which
reads as a hole cut in the hearth with a few sparks in it. A fire bed is
BROKEN -- irregular lumps of charcoal with the glow coming up between them --
and vertex colour cannot fake that, so the lumps are modelled. They are
five-sided prisms at eight triangles each, and they are the difference
between a forge and a stone box with a black lid. Which ones glow is
decided by where they sit: the middle of a working fire is where the air
arrives.

The bellows' leather was cut NARROWER than the boards it lies between, so
it hid in the gap and the whole thing read as a stack of loose planks with
daylight through it. A bellows is a bag under pressure and its widest point
is the leather, not the wood.

The hearth's walls are slabs -- that is the saving that pays for the kerb
being real blocks -- and a slab of one colour stays a slab however much it
is mottled. Three string courses to a face, twelve boxes in all, say
COURSED without laying two hundred stones, and they stand proud because a
rib level with its wall is invisible.

The shield's paint was pastel at 0.42 saturation against a reference whose
red and blue are pigment ground in oil. And its planks are rectangles cut
to the chord of a circle, so their ends step in and out around the edge --
unavoidable, since a plank is straight and the outline is not. That is
exactly what a rim is for: a wide band of hide folded over the ends. At
0.055 of the radius it was too narrow to cover them and the silhouette came
out serrated.

Last, the cream cross between the quarters. Without it the quarters meet
edge to edge and the eye sees two colours rather than a device.

34 models, 29 022 triangles, all checks pass, 0 floating cases, GLB
round-trip clean, Conformant, build clean.
2026-08-23 16:59:06 +07:00
CRT
1148d3ed25 Add the shield and the forge, both unfinished
Committing these because the code should not be lost, not because they are
ready. Both pass every check and neither looks good enough to ship, and the
catalogue table and contact sheet are deliberately NOT regenerated so the
kit does not advertise them yet.

The shield opens a category the kit did not have at all -- no arms or armour
anywhere -- and its construction is right: planks cut to the chord of the
circle rather than squared off, a rawhide rim gripping their ends, an iron
boss over the hole the hand goes through, and quarters painted on the
diagonal by giving each plank's two halves different colours, which costs
no geometry because the halves exist anyway.

The forge is the other half of a smithy that has had an anvil and no fire
since early on: a raised hearth at working height, a bed that `setLit` puts
out without taking the charcoal with it, a tapering stack over the back,
and a bellows blowing through the side.

Four faults found and fixed on the way, and three of them are the same
mistake:

A two-rotation chain put the bellows boards on the wrong axis -- thickness
along Z, length along Y -- so both boards stood on edge in nearly one plane
with every face coplanar. The lever went the same way, ending up across the
bellows instead of over it and touching the post at neither end, which the
support check called a floating bar. And the shield's boss was turned -90
degrees about X instead of +90, which pointed the dome into the back of the
shield and left only its open rim showing from the front -- the reason the
boss rendered as a flat black ring.

The fourth was subtler and took three passes to see. Every plate of the
bellows -- two boards, a gusset, two ribs -- was built from one profile, so
all five ended at exactly the same x and began at exactly the same x. I
staggered the tails and the check moved to the noses; staggering both is
what fixed it.

What is still wrong, and neither is small: the forge's fire bed reads as a
black hole with a few orange specks rather than a bed of coals, and the
bellows still reads as loose boards rather than a bag of leather. The
shield's paint comes out pastel where the reference is deep, and it leans
further than it should.

34 models, 28 728 triangles, all checks pass, 0 floating cases, build clean.
2026-08-23 15:47:44 +07:00
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
CRT
9d027fcd06 Add the cauldron and the handcart
Two models that each fill a hole the catalogue could name.

The kit had a torch and a lantern and no HEARTH, which is the wrong way
round: a cooking fire is what everyone in a medieval scene is facing and
the only light in most of them. The cauldron hangs from a tripod over a
ring of hearth stones, with embers that `setLit` puts out. Its belly is
wider than its mouth, it has three short feet because it stands in the
ashes as often as it hangs over them, and it hangs by a bail rather than by
its rim.

And there was a `cart-wheel` with nothing to put it on -- while every
container in the catalogue, crates and sacks and barrels and baskets, is a
thing you would MOVE. The handcart's axle sits aft of the bed's centre so a
load leans on the puller rather than away from him, and its shafts leave
the bed at an angle and rest on the ground, which is what lets one person
lift them to waist height without the load shifting.

The cauldron found a fault in the mill on its way in. Both build their
splayed legs the same way -- lean about X, swing about Y, translate out
along the radius -- and the lean was POSITIVE, which carries the top the
same way the translate does. The two add, so the head ends up at the full
outboard radius and the foot at the axis: the tripod stood on its apex and
the mill's quarter-bars stood on their heads, splayed over the cross-trees
and gathered at the post, which is precisely backwards for a brace whose
job is to carry a load out to the ground. Nothing had caught it in the
mill, because a bar joins post to cross-tree either way round and the
bounding box is identical; in the cauldron it separated the tripod from the
ground and the support check finally had something to say.

Lowering the pot to where it could actually cook then broke its chain, and
for a reason this session has seen before: the link radius was derived by
dividing the drop by the link COUNT while the links are laid out over
count-1 gaps, so each link came out a little smaller than half its own gap
and the chain came apart as soon as the drop grew. The tavern sign's chain
had the same slip.

31 models, 25 221 triangles, all checks pass, 0 floating cases, GLB
round-trip clean, Conformant, build clean.
2026-08-23 14:57:31 +07:00
CRT
ad6aed30dd Add the stone well, and stone to the palette
A village well: a drystone kerb, a timber frame over it, a windlass with
its collars and crank, and a rope and bucket that lower down the shaft.
`setDepth` and `setWinding` drive the bucket; its part origin is the
roller's axis, so winding is a translation and costs nothing per frame.

The kit had no masonry at all, which for a medieval catalogue is a hole
rather than an omission -- a well, a trough, a millstone, a boundary wall
and a hearth are all stone before they are anything else. `stone` is the
twelfth slot. Its colour sits close to weathered oak on purpose: what
separates the two at a glance is not hue but that stone scatters light
completely flat, and roughness is not something vertex colour can carry,
which is exactly the argument the slot table makes for keeping iron and
steel apart.

Four faults on the way in, and three of them are ones this session has seen
before in other models.

The blocks were written footprint-first as [wall, arc]. `rotateY` carries a
footprint's +Z out along the radius and its +X round the circumference, so
every block sat across the ring rather than along it -- each one sticking a
block's length out of a wall a block's width thick. The kerb came out as
rubble thrown at a circle.

The rope started at the roller's axis, which put its whole surface inside
the barrel's hollow interior touching nothing. Then it started in the eye
of the wound coil, which is also a hole. It has to begin within the coil's
material, between 1.0 and 1.34 of the roller radius, so that it crosses the
coil's outer face on the way down.

The actions set `anchor.position.y = 0`, which destroys what `origin` had
just done and dropped the whole rope and bucket by the height of the
roller. Everything the actions touch is now an offset from where the origin
put the anchor.

And the masonry was over the triangle budget at 2 784, almost 1 900 of
which were chamfers on forty-four blocks -- edges the roughen pass softens
anyway, and stone is the one material here where a sharp arris is not
wrong. Plain tapered boxes bring the model to 1 376.

Two things about the wall itself, both from looking at it. Varying each
block by a tenth in arc and thickness put every stone at its own radius and
its own width; a dry wall is irregular the way a hand-cut stone is
irregular, which is a few percent. And blocks cut to exactly their share of
the arc cannot close: a flat face on a curve leaves a wedge of daylight at
every joint, so they are cut long and bite into each other -- safe here
because each is rotated to its own angle and no two adjoining faces are
parallel.

29 models, 22 043 triangles, all checks pass, 0 floating cases, GLB
round-trip clean, Conformant, build clean.
2026-08-23 14:18:08 +07:00
CRT
6b86bb383b Refresh the catalogue table and contact sheet
The mill and the sign both changed shape after the table was last generated,
so it was claiming 20 523 triangles against 20 667 and drawing the sign the
wrong way round.
2026-08-23 14:02:29 +07:00
CRT
a531931069 Hang the tavern sign along its arm instead of across it
The board hung the wrong way round. Its width ran in X while the bracket
projected in Z, so the sign stuck out sideways from the end of the arm --
which is not how an inn sign hangs. The arm comes out from the post and the
board hangs UNDER it, in line with it, so it faces the road both ways and
can be read walking past. Everything that positions the board, its battens,
its lugs and its chains moves from one axis to the other, and it swings
about the arm's own axis now rather than across it.

Turning it the right way also removed a piece. The cross-bar existed
because two chains set either side of a centre-line arm passed it on both
sides and touched nothing; with the board in line, the chains hang from two
points ALONG the arm and the arm is the cross-bar. The note explaining why
that piece had to exist is worth keeping in the history: it was a correct
fix to a problem that only existed because of the orientation.

28 models, 20 667 triangles, all checks pass, 0 floating cases, GLB
round-trip clean, Conformant, build clean.
2026-08-23 13:30:04 +07:00
CRT
1d4950445f Sweep the mill's sails clear of it, and give the ladder a door to reach
Two faults, both reported from looking at the model.

The sails passed through the buck. A tilted shaft spreads its sails through
depth -- at 0.14 rad a 3.5 m sail swings 0.49 m fore and aft -- and with the
hub only 0.08 m in front of the gable the whole lower half of the disc was
inside the building. Both halves of the fix are what a real mill does: the
shaft tilts a few degrees rather than eight, just enough to keep the sails
off the boarding and put some of their weight on the thrust bearing, and it
projects far enough out that the sweep happens clear of the wall. Far
enough and no further -- the clearance the disc needs is sailLength times
sin(tilt), about 0.26 m, and the first correction overshot to nearly a
metre, which left the sails hanging off the gable with only the shaft
between them.

And the tail ladder climbed to blank boarding. A mill is entered from its
tail, through the one wall the sails never sweep past, and the miller comes
up the same ladder he turns the mill with; a flight of steps ending at a
plain wall was the one thing about this model that could not be true. The
door is boarded proud of the wall with two ledges and a frame, because
there are no booleans in this geometry and a door of applied planks is how
a plank building is actually closed.

28 models, 20 679 triangles, all checks pass, 0 floating cases, build clean.
2026-08-23 13:27:06 +07:00
CRT
1888616315 Land the spilled coins, plane the bench's tenons, and sharpen the support check
Three defects reported from looking at the models, and the first of them
says something about the check that was supposed to catch it.

Every third coin was lifted to "land on top of the previous one" while its
position stayed independently scattered, so it was almost never above
anything and simply hovered about 3 mm off the ground. A stacking coin now
takes the previous coin's PLACE as well as its height. The tilted ones were
lifted by half their thickness plus their circumradius; a coin is a
nine-sided prism and can come to rest on a face rather than a corner, so
the lift uses the apothem and its own radius, erring towards bedding into
the ground rather than floating above it.

The support check missed all of that, and the reason is worth writing down:
its voxel is the model's extent over the resolution, so at 64 a 0.19 m coin
pouch gets a 3 mm voxel -- exactly the size of the gap it existed to find. A
check coarser than the defect it hunts is worth very little. At 112 the
whole suite still runs in fifteen seconds.

Raising it immediately turned up a real fault nobody had seen. The log
pile's sawn ends were cut to exactly the body's end radius and placed
exactly at its rim, so disc and lathe met along a seam of no width: they
touched and never overlapped, and there was nothing joining them but two
edges in the same place. The caps are now three percent wider and seated
slightly inside, which is two millimetres here and true of a real log
anyway, since bark wears back from a cut face.

And the bench's through-tenons stood 42 mm proud of the seat -- a block of
oak the size of a thumb sticking straight up out of the surface you are
meant to sit on. A real through-tenon is wedged and then planed back, so
what shows is end grain and a hairline of shoulder. Three millimetres
proud, ten housed below.

28 models, 20 547 triangles, all checks pass at resolution 112, 0 floating
cases, build clean.
2026-08-23 13:23:09 +07:00
CRT
2b6e2b4d02 Lay the mill's windshaft along its own axis, and band it
Opening the viewer showed the mill reporting one material while declaring
two. It declared an `iron` slot and used it nowhere: the windshaft's own
comment called it iron-banded and then painted it oak like everything else.
Four sails pull on one shaft from four directions and what holds the whips
to it is iron strapping, so the bands are real and now exist.

Adding them exposed something that had been wrong since the model was
written. `taperedBoxGeometry` takes its third argument as a Y height, so
the shaft -- written with no rotation and only a z offset -- was a short
vertical post standing inside the buck rather than a shaft projecting from
the front gable. Buried in the body it survived every render unseen. The
bands, built the same way, came out coaxial with it, and the z-fight check
reported that immediately.

Two smaller things settled on the way.

The bands first went into `trestle`, where the support check found them
hanging at the model's origin: they are authored around the hub, so they
only make sense in the frame of the part whose origin is the hub. They
belong to `sails` anyway, since the shaft turns with them -- in a real mill
the sails are keyed to it.

And the four sails converged on the hub in perfect symmetry with their
faces meeting there. The fix is how the sails are actually carried: TWO
stocks pass through the shaft at right angles, one behind the other, each
carrying a sail at both ends. Four timbers cannot occupy one crossing, and
the check was right to say so.

Worth recording how the shaft was found, because reasoning about it failed
three times. Rebuilding the four sails alone, in every combination of whip,
rail and bars, gave zero overlaps -- which ruled the sails out and left only
what else was in that part.

28 models, 20 547 triangles, all checks pass, 0 floating cases, GLB
round-trip clean, Conformant, build clean.
2026-08-23 13:15:12 +07:00
CRT
d159acfd97 Add the post mill
The largest thing in the kit, and the one whose name is its whole design:
the entire body -- millstones, gearing, miller and all -- is balanced on one
massive vertical post and turns bodily on it, so the sails can be pointed
into whatever wind there is. The miller does that by walking the tail
ladder round, which is why the ladder is not a way up but a lever, and why
it reaches so far behind the mill.

Four parts, in the order the thing is built: `trestle` (two cross-trees,
the post standing on them, four quarter-bars), `body` (the boarded buck and
its pitched roof), `sails` (a windshaft leaving the front gable, with its
own origin so `setTurning` spins it alone), and `ladder`. `setTurning`,
`setAngle` and `update` drive the sails at about ten revolutions a minute
-- a working mill turns slowly, not in the blur people draw.

The sails are where the triangles went, and deliberately. A sail is not a
blade, it is a LATTICE the miller dresses with canvas according to the
wind. Drawn solid the silhouette is wrong in the one place everyone looks,
so the bars are modelled and the budget is taken from the buck's boarding,
which reads perfectly well as vertex colour. 1 516 triangles.

Four faults on the way in, all found by measuring rather than by looking:

The roof pitches were built `slope * 2` long and tilted by the complement
of the roof angle, which made two panels 2.79 m across a building 1.80 m
wide, standing up like the covers of an open book. Each pitch covers half a
roof, and the tilt is measured from the horizontal because
`chamferedBoxGeometry` takes an X-Z footprint.

The rails of the tail ladder were tilted the wrong way round. `run` is
positive -- the head sits at a larger z than the foot -- so rotating by
`-lean` laid the rails along the opposite diagonal to the one the rungs
were placed on. They crossed near the middle and diverged towards the ends,
and the bounding box looked correct the whole time because the translation
put the ladder where it belonged and only its internals were wrong. The
support audit found it as loose rungs, and found it only at the small end
of the height slider, where the voxel grid is fine enough not to bridge the
gap: the joint was never real at any size.

Fixing the ladder's foot, which sat 0.10 m below the cross-trees so the
mill hovered above whatever it was placed on, then displaced the rails
perpendicular to their own axis by almost exactly a rung's half-depth --
correcting the rails alone was worse than not correcting at all. Rails and
rungs now share one lift.

And the lowest sail tip came within 0.27 m of the ground, which reads as a
mill about to plough its own field.

28 models, 20 523 triangles, all checks pass, 0 floating cases, GLB
round-trip clean, Conformant at 30 items, build clean.
2026-08-23 13:01:33 +07:00
CRT
6c2e5bbeca Record at 1080p, and stop recording when the tab stops drawing
Testing the recording flow turned up two faults, both of which produce a
bad file rather than an error.

The clip took whatever size the browser window happened to be. Measured
here that was 1220x900 -- a 1.36:1 video, which is not a shape any platform
expects and which each of them letterboxes differently. A recording now
locks the renderer to 1920x1080 at pixel ratio 1 and restores the previous
size when it stops. Pixel ratio matters: `captureStream` takes the drawing
buffer, so leaving it at 2 would have recorded 3840x2160 and paid four
times the pixels for a clip nobody watches above 1080.

The second is worse because it is silent. `setAnimationLoop` runs on
requestAnimationFrame, which browsers stop completely while a tab is
hidden -- measured at zero frames per second. The tour stops advancing and
the canvas stops feeding `captureStream`, but the recorder keeps writing,
so the file comes out the right length and full of one frozen image. A 60 s
tour is long enough that reaching for another window is the natural thing
to do. The recording now ends there, with a console warning saying why: a
short clip you know about beats a full-length one that is fifty seconds of
a still frame.

Adds `__probe.step(delta)` alongside the existing `renderOnce`. The file
already explained that the probe exists because requestAnimationFrame stops
in a background tab and automated tests could not get a frame; `renderOnce`
draws one but advances nothing, so it could not answer whether the showcase
gets through its beats. The animation loop's body is now a named function
that both the loop and the probe call.

Verified through that hook rather than by watching, since the browser pane
does not composite here:

  tour          1201 steps = 60.1 s, ends clean, 27 distinct captions --
                every model once, in order
  output        video/webm;codecs=vp9, decodes, duration finite at 2.489 s
                for a 2.5 s capture (MediaRecorder often reports Infinity)
  canvas        976x720 before, 1920x1080 during, 976x720 after
  download      fires as medieval-kit-60s.webm
  hidden tab    showcase and recorder both stop
  cost          1.3 ms per frame at 1920x1080 -- ample headroom for 60 fps

27 models, 19007 triangles, all checks pass, build clean.
2026-08-23 12:38:21 +07:00
CRT
21b0393e1f Bring both READMEs up to date, and give them a picture
The registry README's model table was generated once and had gone stale in
exactly the way its own note warns about: it claimed 15 425 triangles
against the current 19 007, listed a barrel at 806 triangles that now has
rivets and six hoops at 1 690, and described "the barrel's 13 staves" when
there are 17. Regenerated from the models themselves.

Both READMEs now open with a contact sheet of the whole kit. It lives in a
tracked `media/` directory rather than in `renders/`, which is ignored
because it is rebuilt output -- a README cannot reference a file that is not
in the repository. The registry's copy points at the raw GitHub URL, since
the npm package ships only dist, README and LICENSE and a relative path
would resolve to nothing there.

The root README gains a Showcase section -- the 60 s default, why it is the
default rather than 30 s, and what makes the variation visible -- and the
support check as a fifth note among the checks worth explaining. It is the
most distinctive thing in the suite and the only one that catches a whole
class of fault the others are blind to by construction.

Also: the check count in that section said ~500 and the suite now runs 818.
2026-08-23 12:25:14 +07:00
CRT
84a4bca24a Make the 60 s tour the showcase's default
The tour splits its running time equally between the models, so with 27 of
them a 30 s run is 1.1 s each. A beat spends that time sweeping its
continuous parameters and stepping its counts at the midpoint, and none of
that reads in a second. 30 s is a flyby of the kit; 60 s is the length at
which the variation work is actually visible. The order and the emphasis
now say which is which, and the 30 s button is labelled for what it is.
2026-08-23 12:21:35 +07:00
CRT
69a9e6d833 Make the showcase's variations visible
"The variations are not even noticeable" was right, and the cause was
structural rather than a matter of degree.

Integer controls cannot be morphed -- they change the triangle count, and a
morph needs both ends to share a topology -- so each beat drew one value and
held it. That quietly removed the most legible parameter almost every model
has: staveCount, plankCount, spokeCount, rows, tineCount, produce. What was
left sweeping were widths, radii and tapers, which at a second or two per
model barely register. The parameters a viewer would actually notice were
the ones pinned.

A beat is now two halves. The integers change at the join, where the same
dip that hides a model swap hides the rebuild; the continuous parameters
carry straight through. The mid-beat dip is shallower than the ones at the
ends, because this is the same object with a different stave count, not a
different object, and dipping to black there would read as a cut.

Continuous controls now travel the whole usable band every beat, from one
end or the other. Drawing both ends at random inside a band trimmed 18% at
each side, then nudging them apart only when they landed within a quarter
of the span, meant most beats moved somewhere between a third and two
thirds of the range with no guarantee which. The band is also wider now, at
12% off each end.

Measured by driving the real showcase against a stub host over a full 60 s
run, rather than by watching it: every model gets exactly two halves,
41 of 41 integer controls change value between them, and continuous
controls travel 76% of their span every time -- against no integer change
at all and a 32-64% unguaranteed sweep before.

The last of those 41 needed its own fix. Trimming 12% off a slider that
runs 3 to 5 leaves 3.24 to 4.76, and both draws round to 4, which is how
the stool's leg count came out identical in both halves. Narrow integer
ranges use the whole range, and the two values are forced to differ.

Still open, and not something this change can reach: 27 models in a 30 s
tour is 1.1 s each. That is a flyby, and no amount of parameter travel
reads in a second. The 60 s option gives 2.2 s and is where this work
shows.

27 models, 19007 triangles, all checks pass, 0 floating cases, build clean.
2026-08-23 12:19:17 +07:00
CRT
96defca639 Stop createTinter sharing one Color, and fix what that was hiding
The tinter returned a single Color and mutated it on every call. The
reasoning was sound -- the geometry helpers read the colour immediately, so
an allocation per call is waste -- and it holds right up until two tints
appear as arguments of the SAME call:

    latheGeometry(profile, 7, origin, tint('char', 0.06),
      { colourTop: tint('charHot', -0.12) })

Both arguments are the same object and both carry whatever the second call
computed. That is why the torch's pitch head rendered hot orange instead of
black: the char never reached the geometry. The same mistake turned up
twice more in one session -- the anvil's stump came out painted in end
grain on every face, and the shovel nearly went the same way. Three
occurrences means the hazard belongs to the helper, not to the callers, and
a few hundred allocations at build time is not a price worth one class of
silent, invisible bug. `steelTint` and `ironTint` already allocate; the two
disagreeing was itself part of the trap.

With the colour actually arriving, the torch head is also fatter -- at 2.5
shaft radii it was a spindle, where a torch head is a fist of tow and rags
bound on, and it is the whole reason the object is not a stick -- and its
flame is shorter than the head that feeds it rather than twice its height.

The cart wheel's tyre was 23 mm of iron on a 0.52 m radius, set wider than
the wheel itself, and read as a pneumatic. A real tyre is 8 to 12 mm of bar
shrunk onto the felloe. It could not simply be narrowed: the felloe's axial
thickness jitters by ±7%, so anything from 0.93 to 1.07 of the width will
sooner or later share a plane with some piece, and the earlier fix for that
flicker was to go wider than the whole range at 1.1 -- which is what made it
look pneumatic. Going UNDER the range solves it the same way and is the
truer shape: the hoop is set into the tread, and on a worn wheel a sliver
of felloe shows at each edge where the iron has bedded in.

27 models, 19007 triangles, all checks pass, 0 floating cases, GLB
round-trip clean, Conformant, build clean.
2026-08-23 12:10:20 +07:00
CRT
64e8932d1b Bind the book the way a board binding is bound, and soften the pouch
The page block was written as 1.035 of the cover, spilling past it on three
sides -- the comment above it said so plainly, and it is backwards. The
overhang is called the square and it is the entire point of a board
binding: the boards take the knocks so the leaves do not. Written the wrong
way round the model read as a ream of paper with a leather lid, which is
what it had always looked like. Flush at the spine, inset on the three free
edges, and the leather now shows around the block.

With that, the boards are also thicker. At a ninth of the book each they
came to 14 mm against 48 mm of paper; a medieval binding is oak boards
first and leather second, and they are substantial.

And the book now has its corner bosses. They are the most recognisable
thing about a book of this period and there were none. Not ornament
either: a volume this heavy was stored flat, often on its side, and the
bosses hold the leather clear of the shelf so it does not abrade -- the
same edges the square protects.

The pouch's body goes from nine sides to thirteen. Leather has no facets,
and the body is the whole silhouette of that model.

The spilled coins beside it stay. That is not a fault: the config field
says "number of coins spilled outside", so the scatter is the model's own
idea and pulling it in would be redesigning rather than fixing.

27 models, 19007 triangles, all checks pass, 0 floating cases, build clean.
2026-08-23 12:04:00 +07:00
CRT
85f423fe10 Build the hoe's blade as a dished sheet instead of a bent box
The blade was a `chamferedBoxGeometry` put through `bendGeometry`. That has
four levels in Y, and once the blade was deep enough to look like a hoe the
bend had to work a 50-degree arc across those four: the cutting edge came
out as a row of steps rather than a curve. `dishedSheetGeometry` exists for
exactly this and is what the shovel's blade already uses -- one seamless
concave surface, curved at every level. It also removes the last
bend-a-box from this model, which is the pattern the helper now refuses.

The blade is deeper and properly wedged. A hoe is narrow where the neck
carries it and wide where it meets the ground, and at 0.115 of the tool's
length with sides nearly parallel it read as a tab riveted to a stick.

Two mistakes on the way, both mine, both caught by looking:

I lowered `neckSweep` from 112 to 95 after reading a render in which the
blade happened to sit edge-on to the camera, and mistook foreshortening for
a wrong angle. Reverted.

Then `curve` was written as `-0.34 * dish`. It is an absolute rise in
metres, not a ratio, so that asked for 340 mm across a 230 mm blade --
seven times its own half-width -- and the sheet came out as a pair of
wings. The shovel scales it by its own blade width and this now does the
same. The dish sliders on the two models run over different ranges, so the
coefficient differs; the quantity does not.

One thing deliberately NOT changed. The reference generated for this model
is a plain strap hoe, while this one is a gooseneck -- a curved forged neck
carrying the blade ahead of the shaft axis, as its description says. Both
are period-correct, and pulling the model towards the photograph there
would be redesigning it rather than fixing it. The blade's depth and wedge
are a different matter: those are true of a hoe whichever neck it hangs on.

27 models, 18759 triangles, all checks pass, 0 floating cases, build clean.
2026-08-23 11:57:59 +07:00
CRT
2772064c14 Thicken the bench and the table, and cut the arch into the bench's legs
Across the contact sheet several pieces read as flimsy, and I called that
systemic before measuring it. Measured, it is two models out of three.

The bench seat was height * 0.09, which is 40 mm on a bench 1.62 m long --
one fortieth of its own span, against roughly one twentieth in the
reference. The table top was height * 0.045, a 33 mm plank across 1.9 m.
Both are now about half again as thick. Medieval furniture is riven rather
than machined: the board is whatever the log gave, and a thin slab over a
long span also looks like it would flex, which is the specific way these
were reading as cheap.

The fence, which looked as thin as either, measures fine -- 150 mm posts
on a 1.25 m fence. What made it look spindly is that the default is 4.8 m
long, so the whole run gets scaled down to fit the frame while the
reference shows a single short section. That is a framing artefact, not a
defect, and nothing about the fence changes here.

The bench also gets the arch cut out of its feet. It is not decoration: a
plank leg standing on its whole edge rocks on any floor that is not flat,
and no medieval floor was, so the cut leaves two feet per board and gives
the bench four points to find the ground with. It is also the most
recognisable line in the silhouette and the reference has it. There are no
booleans in this geometry -- it is all non-indexed triangle soup -- so the
notch is made by building the board in three pieces and not filling it.

27 models, 18647 triangles, all checks pass, 0 floating cases, build clean.
2026-08-23 11:51:01 +07:00
CRT
7e14d9b314 Make the wooden tools wooden, and give the spade its handle
Three faults across two tools, and two of them come down to a model being
named after a material it was not made of.

The pitchfork had three steel tines, 0.24 m on a 1.5 m shaft -- 16% of the
tool against roughly 22% in the reference -- fanned out like a cultivator.
A hay fork has four or five, they are the working length of the thing, and
they stay close to parallel so a forkful stays on them. They are also
wood: the model is called wooden-pitchfork and a hay fork really was cut
and steamed from a branch, because iron is heavy at the end of a long shaft
and hay needs no edge. The socket stays iron, since that is the ferrule.

The shovel's blade was a sheet of polished steel. The strap that runs up
behind it has been modelled as an iron shoe all along -- a shoe for a board
that was being rendered as steel. A medieval digging shovel is a shaped
board with iron along its lip, so the blade moves to oak and the shoe
becomes the only metal on the head.

And it had no handle: the shaft simply ended in a swelled knob. A digging
spade is worked with both hands with the lower one round a crossbar, which
is what separates a spade from a stick with a plate on the end, and it sits
right at the end of the silhouette where the eye lands. The bar crosses the
grip swell rather than butting onto it.

Two notes for later. The wider tine spread pushed the fork past its size
guard at 0.35 m across; 30-40 cm is the head of a real hay fork, so the
limit moved rather than the geometry. And `createTinter` returns one Color
and mutates it while `steelTint` and `ironTint` allocate per call -- the
new code copies out of the first before the next call, but the two helpers
disagreeing about this is a trap in itself.

27 models, 18599 triangles, all checks pass, 0 floating cases, build clean.
2026-08-23 11:21:37 +07:00
CRT
7d043cd141 Put the anvil on the block it is used on
The file header has claimed this model "comes to ~0.75 m with a real anvil
stump" for as long as it has existed, while shipping a 0.34 m anvil on its
own, lying on the floor. An anvil is bedded into the end grain of a log:
the block kills the ring, and it puts the face at the smith's knuckle. The
stump is its own part, so anyone wanting the bare anvil for their own bench
hides `parts.stump` -- the same arrangement the bell's frame uses.

Two things went wrong on the way in, and the second corrects the first.

Passing `woodTint('oak', ...)` and `woodTint('oakEnd', ...)` as two
arguments of one call hands the callee the same object twice: the tinter
returns one Color and mutates it, which its own docstring warns about. The
block was painted end-grain on every face. That is a real bug and the fix
stands -- but it was not why the stump looked wrong, and the measurement is
what said so. Against the bench and the table on the same oak slot our
stump is LESS saturated, not more (0.35 against 0.45 and 0.49), so the
colour handling was never anomalous.

The actual gap was in one axis: value 0.54 against the reference's 0.28. A
chopping block is old, split and weathered nearly black, which is a
genuinely darker piece of wood than fresh oak, not a rendering artefact.
Sweeping the lift and measuring lands at hue 30 / saturation 0.28 / value
0.34, against 31 / 0.29 / 0.28.

The block is also wider than first drawn. The anvil overhung it, and a
block the anvil overhangs looks like it would go over the first time
anyone swung at it.

27 models, 18517 triangles, all checks pass, 0 floating cases, build clean.
2026-08-23 11:13:46 +07:00
CRT
363a20e991 Make bendGeometry refuse geometry it cannot bend
Every vertex is mapped by its own Y, so the curve only exists where there
are Y levels to sample it at. Handed a `boxGeometry` -- two levels, top and
bottom -- there is no midpoint to displace: the top face swings to one
angle, the bottom to another, and what comes out is a sheared wedge. It
looks like a curve in the source and is not one in the geometry, and
nothing anywhere reports it.

This session found that same mistake in three separate models: the
tankard's handle, and both the brace and the curl of the tavern sign. In
each one the author had written a curve, the geometry had never curved, and
every render ever produced had shipped the wedge. The sign's was the
expensive one -- the sheared curl pushed the bracket's top above the post it
is bolted to, and it was also hiding a second fault underneath it. Three of
the same bug is a pattern, so the helper now refuses rather than waiting
for a fourth.

The error names the fix, because the tempting response is to adjust the
curvature and the curvature was never the problem: give the piece levels,
or use `arcBarGeometry`, which sweeps a real arc and asks for the two
things that actually matter -- where the ends land.

Mutation-tested both ways: a box is rejected, a five-level lathe still
bends, and all four remaining callers (the broom's shaft, the hoe's neck
and blade, the pitchfork's tines) build unchanged, so they were legitimate
bends all along.

27 models, 18481 triangles, all checks pass, build clean.
2026-08-23 11:07:43 +07:00
CRT
82844abc3e Reshape the sack so it stops being an amphora
The profile put its widest point a quarter of the way up and narrowed from
there all the way to the neck, which is the silhouette of a vase. A filled
sack does close to the opposite: it SPREADS where it meets the ground,
because the grain settles and cloth has no stiffness of its own, then runs
nearly parallel up most of its height, and gathers in sharply only where
the cord is tied. Almost the entire difference between the two shapes
lives in the bottom level and in where the taper starts.

With the body right, the collar was what still read as a vessel: it opened
out to 0.46 of the radius over a fifth of the sack's height, which is the
mouth of a bottle. Above the tie there should be a hand's width of gathered
cloth and no flare to speak of.

27 models, 18481 triangles, all checks pass, 0 floating cases.
2026-08-22 14:05:31 +07:00
CRT
6bae10f593 Stand the bell's frame clear of the bell, and stop painting it brass
Three faults, one of them mine and one that had been there from the start.

The braces were the change this session declined and that reached the repo
anyway, in 715d6af: moved outboard of the uprights, where they connect to
nothing and read as broken sticks. Reverting them was not enough, because
the version they replaced was also wrong -- a single diagonal tilted about
Z, in the same plane as the bell, which had to be shoved in front of it and
drew a line straight across the one silhouette in this model that has to
read cleanly. Neither is what a bell frame does. A real frame is two
A-frames, one at each end of the beam, braced fore-and-aft in their own
planes, with the bell swinging in the gap. Tilting about X instead of Z
puts them there and the bell is never in the way.

The uprights stood at beamLength * 0.34, which at the default yoke is 0.165
against a bell radius of 0.18. With their half-width they spanned 0.147 to
0.183 and ran through the mouth of the bell. Nothing objected: the support
test sees one connected mass, which is precisely what a post driven through
a bell looks like. They now stand at the larger of the yoke's reach and the
bell's radius plus a margin, so a short yoke cannot close the frame onto
the bell -- and the beam's span is derived from that rather than from the
yoke alone, because decoupling the two first left the rail too short to
reach the uprights at the bottom of the slider and the two A-frames came
apart into separate objects.

The foot sat height * 0.16 below the mouth, leaving 24 mm between sill and
lip. A bell hangs clear of its sill; at that gap it read as resting on one.

Colour last. The bell was painted from the `brass` entry and rendered at
hue 39 / saturation 0.56 / value 0.56, against a reference at 33 / 0.34 /
0.33. That is not a subtle disagreement and, unlike the hay bale, the
measurement can be trusted here: a bale photographs desaturated because it
is thousands of straws shadowing each other, while a cast bell is a smooth
surface, so the number is the material rather than the texture. A separate
`bronze` palette entry, derived by inverting the renderer's own gain,
brings it to 30 / 0.31 / 0.43. It is a palette entry and not a slot -- the
mesh still resolves through `brass` -- so the coin pouch and the book's
corner fittings, where fresh yellow bronze is right, are untouched.

27 models, 18457 triangles, all checks pass, 0 floating cases, build clean.
2026-08-22 14:02:22 +07:00
CRT
177f06c9f7 Give the sign real ironwork, and hang the board from what it hangs on
The sign read as broken, and the reason was the same one the tankard's
handle had: `bendGeometry` applied to a `boxGeometry`. A box has two levels
in Y, and two levels cannot bend -- they shear. Both the brace and the
curl were sheared parallelograms rather than curves, and the sheared curl
is what pushed the bracket's top to 0.579 while the post it is bolted to
ends at 0.442. An arm standing above its own post is not ironwork, it is a
mistake, and it had been in every render of this model. Both are now real
sweeps from `arcBarGeometry`, with their ends placed inside the post and
inside the arm rather than corrected towards them afterwards.

Replacing the curl immediately exposed a second fault that it had been
hiding. The board's part origin sat at [0, pivotY, 0] -- the post's centre
line -- while every piece of the board and its chains is authored hanging
from the cross-bar at z = reach * 0.86, half a metre away. At rest that is
invisible. The moment the swing action runs, the assembly turns about a
point it does not hang from and the tops of the chains sweep off the bar
they are looped through. The support audit had been reporting this as a
detached board only because the sheared curl was accidentally filling the
gap; with the curl the right size, it failed honestly. The origin is now
the cross-bar and the geometry is written relative to it: same place in the
world, correct axis to turn about.

Two proportions with it. The post was 62 mm square carrying a 0.72 m board
two metres up -- thinner than the board is thick, and part of a habit of
under-sizing timber that the bench, the table and the fence share. And the
chain drop was 0.12, so the five links that connect board to bracket were a
smudge; the chain is half of what makes a hanging sign read as hanging.

Lengthening the drop grew the links from radius 0.0195 to 0.0434, at which
point they interlock in earnest and their six-sided facets, all built at
the same phase, met coplanar. Each link is now indexed differently about
its own axis -- deterministically, so the seeded stream is untouched, and
a real chain does not index its links either.

27 models, 18369 triangles, all checks pass, 0 floating cases, build clean.
2026-08-22 13:40:31 +07:00
CRT
88147f759e Give the bale back its cylinder, and its cords back their thickness
Against a photograph this was the worst model in the kit: an amorphous
pale lump with spikes in it, where the reference is plainly a drum of
straw with three cords round it.

Three numbers were doing the damage. The body is a seven-sided lathe --
too few sides to read as round on its own -- and it was then displaced by
0.045 of the height, which is 19 mm against a radius of 210 mm. A tenth of
the radius is not texture, it is deformation, and it left no axis to
recognise. Meanwhile the ends were pulled in by a third, turning the
silhouette into a cinched sack rather than a bale with the broad flat ends
the cord holds it square against. Thirteen sides, a sixth of the
displacement and a third of the end fade, and the form comes back. The
stray stalks are what should say "loose straw", and they can only do that
against a body that holds its shape -- so they are shorter too, since at
0.30 of the height they read as spikes driven in rather than stalks
working loose.

The cords were worse, in the quiet way. The ring radius was written as
`0.5 * cinch(x) + cord * 0.35`, which adds four millimetres to a
normalised 0.5: the body is built in radius-1 space and squashed
afterwards, so anything radial has to be in those units. The hoop stood
0.0044 proud before the squash and 1.85 mm after it. Three cords have been
modelled, merged, exported and invisible in every render this model has
ever produced. They are now expressed in the body's own units and sit in
the groove they pull, which is what a tie does.

27 models, 18257 triangles, all checks pass, 0 floating cases.
2026-08-22 13:30:48 +07:00
CRT
f136250707 Make the besom spray, and stop cutting its twigs to one length
The head read as a lampshade pushed onto a stick, and two numbers were
responsible.

The spray angle is derived as atan((tip - tie) / span), which at a tip
radius of 0.102 came to five degrees. Five degrees is not a besom; the
reference fans out until the spread at the sweeping end is comparable to
the length of the bundle itself, which is about twenty. The tip radius
carries that, and the slider range moves with it.

The twig lengths varied by three percent, so every twig finished within a
few millimetres of its neighbours and the bundle ended in a straight cut
across the bottom. That single edge is most of what made it look
manufactured: no bundle of cut birch ends level, and the ragged tip is
what identifies a besom on sight. Lengths now spread by about a third --
downwards only, never above the nominal span. Centring the spread on 1.0
first produced a 1.32 m broom from a declared length of 1.2, which the size
guard caught and which was a real inconsistency rather than a tolerance
problem.

Two supporting changes. The bundle ran from the binding down for 36% of the
broom against roughly half in the reference, so it is longer. And each
sheaf was a 31 mm slat, which at thirty-two of them read as a fan of laths;
they are narrower now and there are forty-six, keeping about the same mass
in the bundle with the pieces at the right scale.

One correction to the record: the head was not, as I first estimated by
eye, a fifth of the broom. Measured, it was 46% of the whole and 36% below
the binding. The eye was reading a diagonal reference against a vertical
render.

27 models, 18021 triangles, all checks pass, 0 floating cases.
2026-08-22 13:25:08 +07:00
CRT
ebc5cbec6d Close the basket's weave, fill it, and stop calling willow straw
Three defects, two of them mine.

The weave had gaps you could see the contents through. The band height was
a flat 0.11 of the model height while the spacing between rows is 1/rows --
0.167 at the default six -- leaving a 12 mm hole between every course. It
was a slatted crate, not a weave. The height now derives from the row
count so rows meet at any setting.

The fruit was invisible, and that was a regression I introduced. An earlier
pass fixed produce that hung level with the rim over a gap by resting it on
the base. That was right; what did not follow was that a basket deeper than
its contents then needs enough contents to fill it. Nine apples in two
layers topped out 12 cm below a 10 cm rim -- correctly supported, and
completely out of sight. The basket is shallower, holds fifteen, and the
layers nest at 0.95 of a diameter rather than 1.5, so the heap now mounds
over the rim, which is the only state a fruit basket is ever drawn in.

Closing the weave then exposed two collisions. Overlapping rows meet in
coplanar faces at the nodes where the undulation crosses zero -- on a
cylindrical basket every row shares one radius -- so alternate rows now sit
slightly proud of and behind each other, which is how a weaver's rod really
passes the stakes. And the rim's inner surface sat at 0.16055 against the
liner's 0.16065: a tenth of a millimetre apart, by coincidence, and the
same 22-sided prism. The rim is now the heavy rod it should be and clears
the liner.

Colour: the basket drew from the `straw` palette and rendered at hue 41,
saturation 0.59 against a reference at 29 and 0.50 -- bright yellow
plastic. Willow is a wood, so both the colour and the material slot move to
oak, which measures 7 and 0.03 away instead of 12 and 0.09.

It still does not look like the photograph, and the measurement cannot say
why. Filtering to pixels above 0.18 saturation discards precisely the grey,
weathered pixels that give the reference its character, so the statistic
reports agreement that the eye does not see. Setting the lift to 0.12 to
make willow "paler than oak" turned it salmon -- raising HSL lightness
holds saturation, so a saturated brown climbs towards peach. Sweeping the
lift over 0.00 / 0.04 / 0.08 moved the measurement by 0.01, which is the
clearest evidence that this axis is not where the remaining gap lives.

27 models, 17853 triangles, all checks pass, 0 floating cases, build clean.
2026-08-22 12:08:56 +07:00