The root README was written when none of this was published, and it showed.
The install block was BROKEN in the same way the registry README's was, and I
fixed that one without looking here:
bunx vibe3d init
bunx vibe3d add @medieval-kit/wooden-barrel
`init` seeds `models.json` with `@scifi-kit` only, so the second line stops with
`Registry @medieval-kit is not configured in models.json`. The registry entry
and the two dependencies are now in the block, in the order they have to be run.
The npm link pointed at a file in this repository rather than at the package,
because when it was written there was no package to point at. There is now.
Neither document linked the live viewer at all — the one thing a stranger
landing here can use without installing anything. It is the second thing on the
page now.
REFERENCE's "publish your own registry, you need nobody's permission" section
gets the receipt: this registry is live and vibe3d was not involved. It also
gets the warning I would have wanted before doing it — npm stamps the
publishing account's email into the packument per version and later account
changes do not rewrite it, so the address on the account at that moment is
public permanently.
Checked every outbound link in both documents as a logged-out stranger: bun.sh,
vibe-stack/vibe3d and medieval.crt.fyi all 200. npmjs.com answers 403 to a
scripted request, which is its bot protection and not a broken link — the
registry API serves 0.1.1 as latest.
Still 404 for a stranger, and deliberately: github.com/CRTkafa/medieval-kit and
its /issues, which npm renders as the Repository and Issues links on the
package page. They come back the moment the repository goes public.
Found by installing 0.1.0 from npm into an empty directory and doing exactly
what the README says, which is the first time anyone has done that — until an
hour ago there was nothing on npm to install.
Everything held except one line. `bunx vibe3d init`, the `models.json` registry
entry, `vibe3d add @medieval-kit` (46 files), `three` and `@types/three`: all
correct. `@types/three` is genuinely not optional, confirmed by removing it and
watching three TS7016s land on the runtime files init had just written.
The Project setup block was wrong. I wrote it by copying this repository's own
`tsconfig.json`, which carries `"baseUrl": "."` — and this repository pins
typescript at ^5.9.2, so it never sees that a fresh `bun add -d typescript`
installs 7.0.2, where the option has been REMOVED:
error TS5102: Option 'baseUrl' has been removed.
So the one instruction on the page that a reader cannot check against anything
else was the one that stopped them. `paths` resolves relative to the tsconfig
on its own and has since 4.1, so the block just loses the line; the same config
now typechecks the installed source clean on 7.0.2.
The lesson is the one this repo keeps relearning in a new costume: a setting
copied from a working project is evidence about THAT project. This one was
right here and wrong in the only place it was published to.
Verified after the change in the same clean project: tsc silent, and
`createModel()` from the npm-installed hand-cart builds 2044 triangles with its
floor at y = -0.0000 — so the wheels that started this whole thread are on the
ground in the published package too.
Two things, both found by pulling on the handcart's thread.
THE LADDER. I said three models had parts floating a few millimetres. One did.
The other two readings were my own measurement error and worth writing down,
because it is the same error as the vertex-sampling ones: I measured each part
against the model's BOUNDING BOX minimum, and a part deliberately bedded into
the ground drags that datum below the floor, so every correctly placed part
beside it appears to float by exactly the bedding depth. The forge's base
stones settle 6.8 mm and the well's drystone kerb 9.3 mm, both on purpose, and
the bellows and the well frame sit exactly on the floor their own source
declares. Nothing wrong with either.
The post mill's tail ladder was real: 14.5 mm clear. It already carried two
analytic corrections for the same corner, both correct about what they
describe, and neither accounting for the chamfer — the corner they compute the
drop of has been cut off, so the residual was very nearly the chamfer itself.
Rather than add a third derivation to a spot that has now been got wrong three
times, the assembled ladder is measured and put on the floor. A bounding box
cannot be wrong about this the way arithmetic can, and it stays right when the
chamfer or the rail section changes.
`restsOn` now covers the mill's trestle and ladder too, and its doc comment
carries the bedding caveat, so the next person to reach for it knows the datum
is the bounding box and knows what that costs.
THE ANIMATED COLUMN. `catalog-table.ts` decided it with `built.action`, which
is the VIEWER's hand-registered button and exists for five models. So the
generated table published the grindstone, the stone well, the post mill, the
cauldron, the forge and the handcart as static — six of the eleven models that
carry actions, including three of the most animated things in the kit.
The obvious replacement is worse. `core/kit.ts` wraps update as
`update: (dt) => options.update?.(dt, runtime)`, so it is a function on every
model whether or not that model does anything with it, and switching to it
marked all thirty-seven animated. One flag too narrow, its replacement too
broad.
So the generator now runs each model — a second of frames, then its action if
it has one — and asks whether anything moved. That catches what moves by
itself. What moves ON DEMAND it cannot catch, because the motion is behind an
interface the catalogue never exposed, so `Entry` now carries `actionNames`
from the model's own actions. Eleven models, and both documents list them.
Worth keeping: this table is generated precisely so it cannot go stale, it says
so in its own prose, and it was wrong for months. Generated is not the same as
correct — it only moves the mistake into the generator, where nobody looks.
Also in the npm README, which is the page npmjs.com renders: `vibe3d init` is
now the first line rather than unmentioned, `@types/three` is called out (three
ships no declarations, so without it the installed source does not typecheck),
the `@/` alias and `allowImportingTsExtensions` have a Project setup block, and
the one relative link is gone — npm resolves those against the repository, and
this one pointed at a directory the package deliberately does not ship.
`homepage` now points at the live site instead of a repository that is private.
Reported as: in fullscreen the left rail cannot be scrolled to the bottom, it
looks clipped.
I could not reproduce it. In this environment the browser pane will not
composite frames, so there is no real wheel input and no screenshot, and
`requestFullscreen` needs a gesture and is refused — so the fullscreen case
specifically is untested by me. Everything I could measure came back healthy at
1920x1080, 900x620 and 600x900: the rail scrolls, reaches its last element,
has 17 px under the final control, carries no overlay across its bottom, has no
nested scroll container stealing the wheel, and `100vh` equals `innerHeight`.
What follows is therefore hardening against the mechanism that produces exactly
this symptom, not a confirmed fix, and it is written that way in the CSS.
`#app` was `height: 100vh`. `vh` is the LARGEST the viewport gets, not the size
it currently is, and where the two differ the grid stands taller than the
window. The body clips its overflow, so that difference is not scrollable, it
is gone — and what goes is the bottom of the rail, the one part of this page
with more content than fits. `100dvh` tracks the viewport actually in use, with
`100vh` left above it for anything that does not know the unit. In an ordinary
window they are the same number, which is why this can only ever appear
somewhere they come apart.
`min-height: 0` on the rail closes the neighbouring hole: a grid item's
automatic minimum size is its content, and the browser relaxes that to zero
only for as long as the item stays a scroll container. It is one line of CSS
away from not being one, and then the row grows past the window and takes its
own scrollbar out of reach.
Verified after the change at all three sizes: the grid matches the window
exactly, the body never scrolls, the rail still reaches its last element, and
the stacked narrow layout still splits 414 / 486.
`my-registry/undefined/` — ten files, a copy of core and one barrel. It is the
output of a `vibe3d add` run made from a directory with no `models.json`
resolved against it, so the CLI wrote the install under the string
"undefined". It was a pre-publish audit's scratch work, not part of the kit,
and `git add -A` swept it up.
Nothing referenced it and nothing shipped it: the npm tarball is driven by
package.json "files" (dist, README.md, LICENSE) so it was never going to reach
the registry. Removed all the same, because a directory called "undefined" in a
repository someone is meant to read from is its own kind of defect.
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.
`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
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.
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.
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.
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.
"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.
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.
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.
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.
`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.
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.
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.
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.