Skip to content

Readme and captures - #3

Merged
chntnm merged 4 commits into
mainfrom
readme-and-captures
Sep 5, 2026
Merged

Readme and captures#3
chntnm merged 4 commits into
mainfrom
readme-and-captures

Conversation

@chntnm

@chntnm chntnm commented Sep 5, 2026

Copy link
Copy Markdown
Owner

No description provided.

…al defect on it

The README ended with a section enumerating what the application does not do:
what is not built, what is approximate, what has never been measured, which
fixtures are synthetic. That is honest and it is the wrong document for it — a
README is read by someone deciding whether to run the thing, and the scope
boundary belongs with the decisions that drew it. PLAN.md §14 already carries
every item, so the section is deleted rather than moved, and the Layout block's
one-line description of PLAN.md now says it is where to look.

Two smaller README claims went with it. The opening said "Phases 0–9 of PLAN.md
are built", which reads as a progress bar rather than a description of the
application; it now just names what is there. And the unit count is 768 rather
than 765.

PLAN.md loses §15 Open Questions — four unresolved design questions, one of
which (room auto-detection) phase 8 answered. §14 stays: a scope boundary is the
opposite of a to-do list, and the phasing table's "deferred and stated rather
than claimed" clauses stay for the same reason, since they record why a decision
went the way it did.

Of everything the deleted section listed, one item was a defect rather than a
choice, and it is now fixed. The lookup endpoint resolved a hostname, refused it
if it pointed anywhere private, and then handed the URL to `fetch` — which
resolves the name a second time. A record that changes between the two is DNS
rebinding: the check sees a public address and the connection lands on
169.254.169.254. The reason it was open is that `fetch` will not pin a socket,
so the fix is to stop using `fetch` for the hop that matters. `assertPublicHost`
becomes `resolvePublicHost` and returns the addresses it validated; the request
goes through `node:https` with a `lookup` that hands those straight back instead
of asking the resolver again. SNI and certificate validation still go by
hostname, so where the connection lands and who it must prove itself to stay
separate questions. All the validated addresses are offered, not just the first,
so a host with one dead address still fails over. A runtime with no `node:https`
degrades to `fetch` — the same runtime that had no `node:dns`, and so never had
a first resolution to disagree with.

Pinning is a guard with no observable output: a correct transport and one that
quietly re-resolves return the same page, right up until the day they do not. So
the addresses are passed to the transport as an argument rather than captured in
a closure, and three tests assert the handover — that the resolver's addresses
are the ones the connection is given, that a redirect re-pins on the new host
rather than reusing the pin for the URL the user typed, and that a runtime with
no resolver pins nothing. The first two were watched failing with the pin
removed. `LookupDeps.fetch` widens from two arguments to three; every existing
stub ignores the third and still typechecks.

Verified against live hosts as well as stubs, since none of the unit tests
exercise `node:https` at all: example.com through the pinned socket, and
wikipedia.org for a real 301 onto a different host, which re-pins and reads the
page it lands on.

Claude-Session: https://claude.ai/code/session_01KFW9vbazRJhHnsDXiXP8ij
`e2e/floors.spec.ts:218` failed about half the time on this machine and had two
causes, neither of them in the application.

The first: a `<canvas>` with no width or height attributes lays out at 300 x 150
until something sizes it, and React Three Fiber sizes it from a ResizeObserver a
frame or two after the element exists. `space-view` being visible does not mean
the canvas inside it has been measured, so `boundingBox()` returned the 300 x 150
default about half the time and the click landed 226 pixels up and left of the
middle. Every failure clicked 414,127; every pass clicked 640,328. Eight runs
produced eight byte-identical canvas screenshots, which is what ruled the camera,
the scene and the raycast out — the geometry was never in question, only the
arithmetic that turned "the middle of the canvas" into a screen pixel.

The second was underneath it and only became visible once the first was fixed.
React Three Fiber keeps its own record of the canvas size and divides a pointer
offset by it to get normalised device coordinates, so between the element being
laid out and the observer firing, a click in the dead centre of the element
computes as being off the edge of the frustum and hits nothing. Sizing the canvas
from CSS made the element correct sooner, which made the click land *earlier*
relative to the renderer catching up, and took the failure rate from a half to
five in six. A test that flakes less is not a test that was fixed.

So the gate is the drawing buffer: `gl.setSize` writes `canvas.width` from the
size React Three Fiber has measured, and a buffer at least as wide as the element
is proof the observer has fired and the raycaster is dividing by the right
number. `>=` rather than `===` because the buffer carries the device pixel ratio.
That lives in `e2e/space.ts` with the click itself, because the same measurement
was open-coded in three specs and the mistake was in all three.

One of those three was passing for the wrong reason. `space.spec.ts`'s "refuses
to select a wall in furnish mode" asserts that *nothing* is selected, which is
also what a click that misses the scene entirely produces — so half its runs
proved nothing. It now clicks where it means to.

The CSS is kept rather than reverted. A canvas that lays out at 300 x 150 and
jumps to full size a frame later is a real flash in the 3D view; the renderer's
inline size still wins the moment it arrives.

Claude-Session: https://claude.ai/code/session_01KFW9vbazRJhHnsDXiXP8ij
Blocked moves were retried one axis at a time: full, then x-only, then y-only.
That is a clean slide along an axis-aligned wall and nothing at all along a
diagonal one, because neither retry can answer there — moving on one axis alone
is the move that was just refused, and moving on the other lands exactly where
the walker already stands, which is clear, so "stay put" wins. Walk at a diagonal
wall and you stop dead against it.

The move is now retried along the surface first: take the contact normal, drop
the component of the move going into it, keep the rest. On an axis-aligned wall
that is arithmetically the same thing as the old x-then-y retry — the tangent of
a north wall is the x axis — so the common case is unchanged and its test did not
move.

The normal is averaged over every edge within the body radius rather than taken
from the nearest one, because a walker in an inside corner is against two
surfaces at once and either one alone points straight into the other. The average
points out of the corner, which fails `isClear` — and that failure is the point:
it is what hands the move down to the axis retries, which can still slide along
whichever of the two walls the walker is actually pressed against. So the retries
stay, behind the surface pass rather than instead of it.

Taken at the blocked position, not at the start: the start is clear by the check
above it and therefore touches nothing to take a normal from. A body that is
already inside a blocker measures from itself to the nearest edge instead, so the
normal still points at open air — and the existing escape hatch above, which lets
a walker who has been built around move freely until they are out, is unchanged
and now asserted.

`closestPointOnSegment` comes out of `distanceToSegment` in `geometry/vec.ts`,
which was already computing it and throwing the point away.

Four tests, the first of them watched failing: it slides, the slide does not push
through the surface, a move squarely into a wall still stops dead because there
is no tangential component left to keep, and a walker inside a solid still gets
out.

Claude-Session: https://claude.ai/code/session_01KFW9vbazRJhHnsDXiXP8ij
PLAN.md §10.4 has carried a target of 500 placements at 60fps since it was
written and nobody had run it. Most of that target is the renderer and still is:
one mesh per solid, no instancing, and the only browser available here is a
software rasteriser, so a frame rate taken from it would be a number about
SwiftShader dressed as a number about the application.

Everything the renderer is handed can be measured, though, and one of those
passes runs inside the frame. `scene.bench.ts` puts 500 placements on a floor at
three densities and times them; `pnpm bench` runs it. Not in CI, for the reason
no timing belongs there — a benchmark that gates a merge fails on whatever else
the machine was doing.

The result that matters: `stepWalker` costs 0.011ms against 500 blockers and
0.028ms when they are all overlapping, out of a 16.7ms frame. The traversal half
of the target is not close to being the risk, and the cache in `scene-cache.ts`
is what keeps it that way — every other pass here runs once per edit, not per
frame.

The result I did not expect: placement count is not the variable. Colliding pairs
are. The same 500 placements cost 0.37ms to validate at a 1.1m pitch, 5.9ms when
every item touches its neighbours, and 216ms when they are piled at 120mm — 17
pairs, 398 pairs, 17,315 pairs. The broad phase is doing its job; what is left is
one polygon-clipping pass per surviving pair, which is the honest price of
reporting an overlap, and a floor with 17,000 of them is a floor where every
answer is already "this does not fit". Nobody reaches that by planning a room.

The middle row is the one worth watching. 5.9ms per edit is inside a frame but
not by much, and 500 items packed against each other is a document someone could
actually have. If it ever becomes a complaint the answer is a grid index in
`findCollisions`, which the existing sweep leaves room for — and not instancing,
which is a renderer question that this row never reaches.

§10.4 now carries the table, the machine, the date, and what is still unmeasured.

Claude-Session: https://claude.ai/code/session_01KFW9vbazRJhHnsDXiXP8ij
@chntnm
chntnm merged commit f4745a8 into main Sep 5, 2026
1 check passed
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