Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ puts 500 placements on a floor at three densities and times the passes they feed
not in CI, for the reason no timing should be: a benchmark that gates a merge fails on
whatever else the machine was doing.

Milliseconds per call, 500 placements, Node 24 on a Windows laptop, 2026-09-04. *Sparse*
Milliseconds per call, 500 placements, Node 24 on a Windows laptop, 2026-09-05. *Sparse*
is a 1.1m pitch — a furnished floor with clearance around everything. *Touching* is
520mm, where every item overlaps its neighbours and the validation panel has something
to say about all of them. *Piled* is 120mm, which is not a plan anyone drew and is here
Expand All @@ -924,15 +924,22 @@ to show where the cost comes from.
| `blockersOf` | 0.004 | 0.004 | 0.005 |
| `findCollisions` | 0.37 | 5.1 | 188 |
| `validateFloor` | 0.37 | 5.9 | 216 |
| **`stepWalker`** | **0.011** | **0.027** | **0.028** |
| **`stepWalker`, clear** | **0.029** | **0.030** | **0.032** |
| **`stepWalker`, blocked** | **0.045** | **0.046** | **0.049** |

Three things fall out of that.

`stepWalker` is the only row that runs inside a frame; everything above it runs once per
edit, against a scene the cache in `ui/space/scene-cache.ts` keeps until the document
changes. At 0.011–0.028ms against 500 blockers it uses well under a percent of the
16.7ms budget, which means the CPU half of the target is not where the risk is. Whether
500 placements hold 60fps is a question about draw calls, and this says nothing about it.
`stepWalker` is the only pair of rows that runs inside a frame; everything above them
runs once per edit, against a scene the cache in `ui/space/scene-cache.ts` keeps until
the document changes. It is timed twice because a frame that touches nothing and a frame
that is blocked do different work: the first is a bounding-box rejection per blocker,
the second sweeps the blockers it is against for their normals and projects the move
along them. At 0.03ms clear and 0.05ms blocked against 500 blockers, either uses well
under a percent of the 16.7ms budget, which means the CPU half of the target is not where
the risk is. Whether 500 placements hold 60fps is a question about draw calls, and this
says nothing about it. (An earlier figure of 0.011–0.028ms was for a step that touched
nothing — and at one density for a walker that had been stood inside a placement — which
is why the blocked frame is now timed on its own and the bench checks its own geometry.)

Placement count is not the variable. **Colliding pairs** are: 17 pairs cost 0.37ms and
17,315 pairs cost 216ms, on the same 500 placements. The broad phase is doing its job —
Expand Down
7 changes: 4 additions & 3 deletions media/capture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ async function nameRoom(page: Page, at: { x: number; y: number }, name: string)
* T-junctions where the partition meets the shell.
*
* The interior door sits on y = 3000 on purpose. The walker seeds at the centre of
* the largest room facing east, so that line is the one along which a scripted
* clip can hold one key and end up in the next room — and the layout keeps it
* clear of everything but the rug, which is 10mm tall and meant to be walked over.
* the largest room facing north, and the clip's first beat turns it 90° to face
* east — so that line is the one along which holding one key ends up in the next
* room, and the layout keeps it clear of everything but the rug, which is 10mm tall
* and meant to be walked over.
*/
async function apartment(page: Page) {
const stage = page.getByTestId('plan-stage');
Expand Down
6 changes: 5 additions & 1 deletion playwright.media.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export default defineConfig({
webServer: {
command: `pnpm run build && pnpm run preview --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
reuseExistingServer: true,
// Always start our own server, as the e2e config does. Reusing whatever is on the
// port would photograph a preview left over from an earlier build and commit it
// as the current application — and every assert here checks state, not pixels,
// so nothing would notice.
reuseExistingServer: false,
timeout: 180_000,
},
});
56 changes: 50 additions & 6 deletions src/core/scene.bench.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { bench, describe } from 'vitest';
import { createCatalogItem } from './catalog';
import { findCollisions } from './geometry/collision';
import { findCollisions, type Volume } from './geometry/collision';
import { createDocument, type Floor, type SpaceDocument } from './document';
import { blockersOf, buildScene, buildStack } from './scene';
import { commitRoomRect } from './tools';
import { validateFloor } from './validation';
import { NO_INPUT, createWalker, stepWalker } from './walk';
import {
NO_INPUT,
WALK_SPEED_MMS,
bodySpan,
createWalker,
forwardVector,
isClear,
stepWalker,
type Walker,
} from './walk';

/**
* What PLAN.md §10.4's target can honestly be checked against without a GPU.
Expand All @@ -18,7 +27,10 @@ import { NO_INPUT, createWalker, stepWalker } from './walk';
* What *is* measurable is everything the renderer is handed, and one thing that runs
* inside the frame. `buildScene` and `validateFloor` run once per edit;
* `stepWalker` runs sixty times a second against the cached blocker list, so it is
* the only figure here that comes out of the 16.7ms budget.
* the only figure here that comes out of the 16.7ms budget. It is timed twice, because
* a frame that touches nothing and a frame that is blocked do different work: the
* first is a bounding-box rejection per blocker, the second sweeps the blockers again
* for the surfaces it is against and projects the move along them.
*
* Three densities, because placement count turns out not to be the variable that
* matters — the number of *overlapping pairs* is, and those are two very different
Expand Down Expand Up @@ -77,14 +89,42 @@ function floorOf(pitchMm: number): { doc: SpaceDocument; floor: Floor } {
return { doc, floor };
}

/**
* Check that a walker is standing clear and that one frame's stride ahead is what the
* bench says it is. A bench that times the wrong thing is worse than none — the first
* version of this file stood its walker inside a placement at one pitch and in open
* floor at the others, and the blocked frame it claimed to measure never happened.
*/
function assertStride(walker: Walker, blockers: readonly Volume[], blocked: boolean) {
const span = bodySpan(walker);
const { x, y } = walker.position;
if (!isClear(walker.position, span, blockers)) {
throw new Error(`the walker at ${x},${y} is standing inside something`);
}
const f = forwardVector(walker.heading);
const stride = WALK_SPEED_MMS / 60;
const ahead = { x: x + f.x * stride, y: y + f.y * stride };
if (isClear(ahead, span, blockers) === blocked) {
throw new Error(`the step from ${x},${y} should be ${blocked ? 'blocked' : 'clear'}`);
}
}

for (const [label, pitchMm] of Object.entries(PITCHES)) {
describe(`${COUNT} placements, ${label}`, () => {
const { doc, floor } = floorOf(pitchMm);
const scene = buildScene(doc, floor);
const blockers = blockersOf(scene);
const walker = createWalker({ x: 20000, y: 16000 }, 45);
const world = { blockers, mode: 'walk' as const };

// Open floor in the far corner, beyond the grid at every pitch.
const clear = createWalker({ x: 32000, y: 28000 }, 45);
// Ten millimetres clear of the first row's face and a stride short of touching
// it, walking south-east — into the row, with an east component for the slide to
// keep, which is the whole of the blocked path.
const blocked = createWalker({ x: 900, y: 390 }, 135);
assertStride(clear, blockers, false);
assertStride(blocked, blockers, true);

bench('buildScene', () => {
buildScene(doc, floor);
});
Expand All @@ -106,8 +146,12 @@ for (const [label, pitchMm] of Object.entries(PITCHES)) {
});

// The frame. Everything above is per edit.
bench('stepWalker', () => {
stepWalker(walker, { ...NO_INPUT, forward: 1 }, 1 / 60, world);
bench('stepWalker, clear', () => {
stepWalker(clear, { ...NO_INPUT, forward: 1 }, 1 / 60, world);
});

bench('stepWalker, blocked', () => {
stepWalker(blocked, { ...NO_INPUT, forward: 1 }, 1 / 60, world);
});
});
}
28 changes: 25 additions & 3 deletions src/core/walk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,12 +374,34 @@ describe('sliding', () => {

it('still stops dead when the move is straight into the surface', () => {
// Nothing tangential is left to keep: the whole move is the component that has
// to go. A wall you walk squarely at is a wall you stop at.
const from = { x: 1000, y: 1600 };
const into = { x: 300, y: -300 };
// to go. A wall you walk squarely at is a wall you stop at — *exactly* where you
// were, not a few 1e-13mm off it.
//
// The move lands short of the line rather than on it. Standing on the line, no
// edge is at any distance at all and there is no normal to project against, so
// the axis retries would give the same answer for a different reason and the
// projection would go untested. And the start is chosen so that the projection
// does leave residue — from (1000, 1600) it happens to cancel to zero exactly,
// and this assertion would pass without the exact-stop rule it is here for.
const from = { x: 1000, y: 1523 };
const into = { x: 198, y: -198 };
expect(slide(from, into, { bottom: 0, top: 1800 }, [diagonal])).toEqual(from);
});

it('finds the way out when a long step lands its centre inside the surface', () => {
// A running step on a slow frame is longer than the body radius, so the blocked
// position can be *inside* the polygon, where every edge counts as touched and
// their normals average to something that points along or into it. The nearest
// edge alone says where open air is — and the slide goes along the surface, not
// back the way it came.
const from = { x: 1000, y: 1600 };
const after = slide(from, { x: 500, y: -200 }, { bottom: 0, top: 1800 }, [diagonal]);

expect(after.x).toBeGreaterThan(from.x);
expect(after.y).toBeGreaterThan(from.y);
expect(isClear(after, { bottom: 0, top: 1800 }, [diagonal])).toBe(true);
});

it('leaves a walker who is already inside something free to get out', () => {
// Unchanged, and asserted here because the contact normal is computed from the
// blocked candidate: a walker standing inside a wall has candidates that are all
Expand Down
85 changes: 59 additions & 26 deletions src/core/walk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import { circleIntersects, spansOverlap, type Span, type Volume } from './geomet
import { containsPoint } from './geometry/polygon';
import {
closestPointOnSegment,
distanceToSegment,
dot,
length,
normalize,
Expand Down Expand Up @@ -249,43 +248,71 @@ export function groundHeight(
}

/**
* Which way the surfaces touching `at` face, averaged, or null if nothing touches it.
* Every blocker a body of `span` centred at `at` is touching. Empty means clear.
*
* `isClear` stops at the first hit; this collects them, for the one caller that goes
* on to ask which way they face and should not have to sweep the list a second time
* to find out.
*/
function contacts(
at: Vec2,
span: Span,
blockers: readonly Volume[],
radiusMm: number,
): Volume[] {
const touching: Volume[] = [];
for (const blocker of blockers) {
if (!spansOverlap(span, blocker.span)) continue;
if (circleIntersects(blocker.outline, at, radiusMm)) touching.push(blocker);
}
return touching;
}

/**
* Which way the surfaces `touching` a body centred at `at` face, averaged, or null if
* none of them can say.
*
* Averaged rather than nearest-wins because a walker in an inside corner is against
* two surfaces at once, and one of them alone would send them straight into the other.
* The average points out of the corner, which is a direction that will fail `isClear`
* — and failing is the right answer there, because it is what hands the move to the
* axis retries that can still slide along one of the two walls.
*
* A body already *inside* a blocker takes the direction from itself to the nearest
* edge instead, so the normal still points at open air rather than deeper in.
* A body whose centre is *inside* a blocker — a running step on a slow frame is longer
* than the body radius, and can land one there — takes the direction to that
* blocker's nearest edge instead. From inside every edge counts as touched, and for a
* rectangle, which is every wall and most of the furniture, their normals cancel to
* exactly nothing. The nearest edge alone is the way out.
*/
function contactNormal(
at: Vec2,
span: Span,
blockers: readonly Volume[],
radiusMm: number,
): Vec2 | null {
function contactNormal(at: Vec2, touching: readonly Volume[], radiusMm: number): Vec2 | null {
let sum: Vec2 = { x: 0, y: 0 };

for (const blocker of blockers) {
if (!spansOverlap(span, blocker.span)) continue;
if (!circleIntersects(blocker.outline, at, radiusMm)) continue;

const inside = containsPoint(blocker.outline, at);
for (const blocker of touching) {
const pts = blocker.outline.pts;

if (containsPoint(blocker.outline, at)) {
let out: Vec2 | null = null;
let nearest = Infinity;
for (let i = 0; i < pts.length; i++) {
const away = sub(closestPointOnSegment(at, pts[i]!, pts[(i + 1) % pts.length]!), at);
const d = length(away);
if (d < nearest) {
nearest = d;
out = away;
}
}
if (out && nearest > 1e-6) sum = { x: sum.x + out.x / nearest, y: sum.y + out.y / nearest };
continue;
}

for (let i = 0; i < pts.length; i++) {
const a = pts[i]!;
const b = pts[(i + 1) % pts.length]!;
// Only the edges actually being touched. A long wall's far edge is part of the
// same polygon and points the opposite way; including it would cancel the
// normal out to nothing.
if (!inside && distanceToSegment(at, a, b) > radiusMm) continue;

const closest = closestPointOnSegment(at, a, b);
const away = inside ? sub(closest, at) : sub(at, closest);
if (length(away) < 1e-6) continue;
sum = { x: sum.x + normalize(away).x, y: sum.y + normalize(away).y };
const away = sub(at, closestPointOnSegment(at, pts[i]!, pts[(i + 1) % pts.length]!));
const d = length(away);
if (d > radiusMm || d < 1e-6) continue;
sum = { x: sum.x + away.x / d, y: sum.y + away.y / d };
}
}

Expand Down Expand Up @@ -314,21 +341,27 @@ export function slide(
}

const full = { x: from.x + delta.x, y: from.y + delta.y };
if (isClear(full, span, blockers, radiusMm)) return full;
const touching = contacts(full, span, blockers, radiusMm);
if (touching.length === 0) return full;

const candidates: Vec2[] = [];

// The normal is taken at the blocked position rather than at `from`, because `from`
// is clear by the check above and so touches nothing to take a normal from.
const normal = contactNormal(full, span, blockers, radiusMm);
const normal = contactNormal(full, touching, radiusMm);
if (normal) {
const into = dot(delta, normal);
// Only a move that goes *into* the surface has a component to lose. A move that
// is already leaving it was blocked by something else, and projecting would take
// away the escape.
if (into < 0) {
const along = sub(delta, scale(normal, into));
candidates.push({ x: from.x + along.x, y: from.y + along.y });
// A move squarely into the surface has nothing left once its normal component
// goes. What floating point leaves of it — a few 1e-13mm — would still be a new
// position every frame, so it is `from` itself, exactly.
candidates.push(
length(along) < 1e-6 ? from : { x: from.x + along.x, y: from.y + along.y },
);
}
}

Expand Down
Loading
Loading