Readme and captures - #3
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.