Skip to content

Fix the gatehouse geometry, and build subassemblies off the model - #9

Merged
thebuilder merged 4 commits into
mainfrom
feat/model-building-stages-invalid-5ceb1b
Aug 28, 2026
Merged

thebuilder merged 4 commits into
mainfrom
feat/model-building-stages-invalid-5ceb1b

Conversation

@thebuilder

Copy link
Copy Markdown
Owner

Two changes, from asking whether the invalid-looking build stages were the model files or how we parse them.

The answer was the model file, and we generate it.

The gatehouse was not buildable

Every fault in it came from one confusion in scripts/make-demos.mjs: LDraw puts a part's origin on its top face, and the generator treated it as the bottom.

Fault Cause Fix
Towers and span clipped through the courtyard Referenced at -PLATE instead of -BRICK, sinking 16 LDU onBase = -BRICK
Battlements floated over every tower -6 * BRICK is where a seventh brick goes, not where a plate lands on the sixth -(TOWER_LEVELS - 1) * BRICK - PLATE
Three of each tower's four columns stood off the plate Corners at (±140, ±100), none on the stud grid Corners at (±90, ±70)
Half the span hung over nothing Rotated to run along an axis the courtyard is only 200 deep on Unrotated, down the middle

Confirmed against the real loader before and after. The repacked model now has 0 parts placed with nothing already built to attach to, and everything inside the base footprint.

Numbers alone would just be a different guess, so validate reads the emitted file back and fails the run unless every part rests on the ground or another part's top face, lands on the stud lattice, and shares space with nothing. I mutation-tested it: reintroducing any of the three original bugs fails it, as does nudging a submodel half a stud off the grid.

The wall demo is removed, as requested. It covered inferred build order, which keeps its unit tests in src/ldraw/steps.test.ts but no longer has a bundled model exercising it. It had the same class of bug anyway: coping tiles floating 16 LDU, and an asymmetric end brick embedded inside the 2x4 beside it.

Subassemblies are built off the model

An LDraw file records every brick at its position in the finished model, submodels included. Replayed literally, a subassembly assembles itself in mid-air inside the silhouette of a model that does not exist around it yet. Instruction booklets take you off to a corner of the page for exactly this reason.

New src/ldraw/subassemblies.ts. A submodel occurrence of 5–40 bricks, at most a quarter of the model, built over more than one step and not standing on the ground, is displaced clear of the model while built and slides in on the step its last brick goes on.

Occurrences, not files: the gatehouse references tower.ldr four times, which is one node in the submodel panel but four things to build. flattenModel now records both.

Two decisions worth a reviewer's attention, both changed after measuring:

  • Minimum size is 5, not 3. The Saturn V nests forty-six three-brick submodels; at three this staged 80% of the set in units too small to register as units. At five it stages 67% in units you could pick up.
  • The escape direction is the cheapest axis, not the subassembly's own outward direction. The obvious rule sent the gatehouse span 271 LDU and a Galaxy Explorer hatch 825, because a piece over the middle of a model has to cross all of it. Choosing the shortest of five escapes cuts those to 171 and 467, and a piece over a wide flat model gets lifted and lowered in instead.

Posing is now two legs: floor → staging on the brick's own step, then staging → model over the back half of the install step, so a subassembly is finished before it is fitted. A brick outside a subassembly has a zero displacement and a zero install, which is the same single flight as before.

Camera framing is split (stagedFrames vs bagFrames) so build mode is not pulled back to frame ground nothing uses.

Selection on the bundled models: gatehouse 5, Galaxy Explorer 3, Saturn V 93, car and pyramid 0.

Build mode is unchanged — ghosts, snapping and the carry all still aim at the final position. Staging there would mean reworking all three plus the physics carry, which is a larger piece of work and not in this PR.

A correction to my own diagnosis

I initially reported that Saturn V had "185 parts supported only by a later step" and used that to argue loaded models were broken. That figure came from a vertical-support test, which flags every sideways/SNOT attachment as broken. Under a proper omnidirectional contact test the OMR sets are essentially clean: 0 of 368 parts in Galaxy Explorer and 16 of 1845 in Saturn V are placed with nothing already built to attach to.

So staging is here as a feature, for the reason booklets do it, not as a repair of broken OMR data. Worth knowing before reviewing it as a bug fix.

Parsing and packing were cleared and are untouched: LDrawLoader.computeBuildingSteps already numbers steps with a global depth-first counter, and the packer passes 0 STEP through verbatim.

Verification

  • 521 tests across 33 files, typecheck and lint clean.
  • New tests mutation-tested. Three of four mutations initially passed, so cases were added until all failed.
  • Driven in a real browser: sweeping the gatehouse step by step, each subassembly sits at exactly its staging offset while built and lands on zero after its install step. Finished model has four towers on the corners with the span across the middle, nothing floating or clipping.

One incidental note: jsdom is declared in package.json but was missing from the shared node_modules, so 10 test files were erroring until pnpm install was re-run in the worktree. Nothing to fix in the repo.

Every fault in the gatehouse came from one confusion in
scripts/make-demos.mjs: LDraw puts a part's origin on its *top* face, and
the generator treated it as the bottom. So the towers and the span were
referenced one plate above the courtyard instead of one brick above it,
and sank sixteen units through the plate they were supposed to stand on,
poking out the underside. The battlements went on at `-6 * BRICK`, which
is where a seventh brick would go, not where a plate lands on the sixth,
so every tower had two 1x1 plates floating sixteen units over it. And the
corners at (+/-140, +/-100) put three of each tower's four columns off
the plate entirely, with the fourth straddling the edge by half a stud,
none of them on the stud grid. The span was rotated to run along an axis
the courtyard is only 200 deep on, so half its 240 hung over nothing.

The towers now sit at (+/-90, +/-70), where every column is on the grid
and inside the footprint, and the span runs unrotated down the middle,
where it spans the full width and clears all four towers.

Numbers alone would just be a different guess, so `validate` now reads
the emitted file back and fails the run unless every part rests on the
ground or on another part's top face, lands on the stud lattice, and
shares space with nothing. Reintroducing any of the three original bugs
fails it, as does nudging a submodel half a stud off the grid.

The wall demo goes. It existed to cover inferred build order, and that
path keeps its unit tests in src/ldraw/steps.test.ts, but it had the same
class of bug: coping tiles floating sixteen units up, and an asymmetric
end brick embedded inside the 2x4 beside it.
An LDraw file records every brick at its position in the finished model,
submodels included. Replayed literally, a subassembly assembles itself in
mid-air inside the silhouette of a model that does not exist around it
yet, which is the one thing in the whole build that cannot be what
happened. Instruction booklets take you off to a corner of the page for
exactly this reason.

So the watch flow does the same. A submodel occurrence of five to forty
bricks, at most a quarter of the model, built over more than one step and
not standing on the ground, is displaced clear of the model while it is
built and slides in on the step its last brick goes on.

Occurrences, not files. The gatehouse references tower.ldr four times:
one node in the submodel panel, because isolating "the towers" should
light up all four, but four separate things to build, each with its own
steps and its own place to be built. flattenModel now records both.

The size bounds are where the work was. A real set nests whole stages
inside one file, and staging those would mean building most of the model
somewhere it does not belong, so the walk takes the outermost occurrence
that looks like a subassembly and otherwise descends into it. Taking the
outermost is also what keeps a brick in exactly one subassembly, so there
is one displacement to apply rather than a chain of them. The floor of
five is measured: the Saturn V nests forty-six three-brick submodels, and
at three this staged eighty per cent of the set in units too small to
register as units.

The displacement is a pure translation, so the thing being built off to
the side is recognisably the thing that will slot in, and it takes the
cheapest of the five ways out of the model's silhouette. Escaping along
the subassembly's own outward direction was the obvious rule and the
wrong one: it sent the gatehouse span 271 units and a Galaxy Explorer
hatch 825, because a piece over the middle of a model has to cross the
whole of it. Choosing the shortest axis instead cuts those to 171 and
467, and for a piece sitting over a wide flat model it picks up, and the
piece is lowered in. Down is excluded, because that is where the loose
bricks are poured.

Posing is now two legs: floor to staging on the brick's own step, then
staging to model over the back half of the install step, so a
subassembly is finished before it is fitted. A brick outside a
subassembly has a zero displacement and a zero install, which is the same
single flight from the floor as before.

Framing is split. The camera has to take in the staging ground while
watching or the subassembly is built where it cannot be seen, but build
mode puts every brick straight where it belongs, so framing that empty
ground there would only push the model away from the viewer.

Build mode is otherwise untouched: ghosts, snapping and the carry all
still aim at the final position.
@vercel

vercel Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ldraw-builder Ready Ready Preview Aug 28, 2026 8:49am

@thebuilder
thebuilder marked this pull request as ready for review August 28, 2026 08:35
One conflict, in the bundled-models prose. Both sides had edited the same
sentence: this branch because the wall demo is gone and the gatehouse is
now the model that covers submodels, main because the README no longer
has the OMR section sitting directly below it. Kept both, so the sentence
describes one generated demo and no longer points "below".

Note for anyone grepping: main landed src/scene/subassembly.ts, which is
free build working out what is holding up what so picking a brick up
brings what it was carrying. It is unrelated to src/ldraw/subassemblies.ts
on this branch, which is submodel occurrences built off the model in the
watch flow. No symbols collide; only the words do.
`fallow audit` gates on findings the changeset introduces, and this branch
introduced four. Three were mine outright and one was an artefact worth
fixing anyway.

flattenModel was already long, and threading submodel occurrences through
its walk pushed it over the cognitive threshold. The walk was doing two
jobs: reading the loaded hierarchy, and deciding what a submodel boundary
is. It is now `collectBricks`, with the boundary decision named as
`descend`, which returns the path and occurrence a node's children belong
to. Measuring a brick's own collider comes out too, as `measureCollider`;
it was the deepest nesting in the function and reads better with a name
on it than as the tail of a loop that is otherwise about world bounds.

In the demo validator, `walk` and `collisions` both sat at cyclomatic 6.
Nothing under scripts/ is covered, so CRAP collapses to cyclomatic² plus
cyclomatic and six is over the ceiling. Parsing the reference lines is
now `referencesIn`, building the box is `boxOf`, and the three-way
overlap test is `sharesSpace`, which each read better named anyway.

The fourth was disposeModel's traverse callback, unchanged by this branch
but shifted down four lines, which is enough for the gate to read it as
new. Pulling the material collection out drops it well under the ceiling.

Everything else the gate reports in these files is inherited and stays
that way: eight findings across pack-models.mjs, loadModel and the test
fixtures, none of them touched here, all correctly attributed and
excluded. Nothing is suppressed.
@thebuilder
thebuilder merged commit 68836c9 into main Aug 28, 2026
3 checks passed
@thebuilder
thebuilder deleted the feat/model-building-stages-invalid-5ceb1b branch August 28, 2026 08:54
thebuilder added a commit that referenced this pull request Aug 28, 2026
Every decision in here came with the reasoning that produced it: why bags exist
before what they are, why a hand-written palette would go stale before what the
rules yield, a paragraph on instruction booklets before the subassembly rule.
Read once it is interesting. Read while you are trying to find the threshold you
came for, it is in the way.

The facts stay, numbers included. What goes is the argument around them: 4,457
words to 3,234.

Also fixes the test counts, which had drifted to 513 across 32 files, and drops
the Running Bond Wall row, which left with #9.

This branch was successfully deployed

1 active deployment
Preview — 8f54ae22 Deployed Aug 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant