Files
houseplan-card/demo/smoke_floor_geometry_cache.mjs
T
Claude f5b20e07f6 perf(floor): key the floor-geometry caches by the floor's content (#744)
The physical bodies, the wall union pool, the inner room contours and the
clean floor carried the global config epoch in their keys. Every edit of
any floor bumps it, so after one edit every other floor was cold again:
in large-house the first visit to an untouched floor rebuilt its wall
union and paid ~0.7 s flat / ~0.65 s 2.5D instead of ~40-55 ms.

A floor's geometry reads only its own config record (spaceModels) and
constants, so the key is now a content fingerprint of that record
(src/floor-geometry-key.ts), remembered per epoch and per record object.
The geometry also reads the current floor's config next to the model it
is given; when those records differ the key covers both. The live resize
preview is its own record, so preview frames get their own key; the
editor runtime seeds the pool and re-keys the bodies through the same
reader. The stairs editor no longer clears the clean floors of every
floor: the stairs are part of the floor's record.

The #735 switch-cycle guard now also sees the union pool and the inner
contours (optional members of the large-house card contract, so an older
comparison bundle reads 0). smoke_floor_geometry_cache proves the warm other floor and the
invalidation against an independent card (multi-floor push with shared
walls, a stair, a resize preview and its cancel); two mutants guard it.

Issue: #744
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-02 00:56:04 +03:00

333 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #744: the four floor-geometry caches (physical bodies, the wall union,
// inner room contours, the clean floor) are keyed by the content of ONE
// floor's record, not by the global config epoch.
//
// AC1 an edit of `f1` (a rename through the room dialog) leaves `garden`
// warm: its first visit after the edit builds nothing. On the epoch key
// the visit rebuilt the union (+1), every contour and clean floor (+N).
// AC2a a server push that changes walls of BOTH floors (shared walls: one
// thickness moves the faces of two rooms) while `f1` is on screen: each
// floor draws the walls and areas an independent card computes from
// scratch on the same config, and they differ from the previous ones.
// AC2b a stair placed on `f1` through the stairs tool shrinks the room by its
// footprint, as the independent card says, and `garden` keeps its clean
// floors (the stairs editor used to clear the cache of every floor).
// AC2c a live Resize preview on `f1`: the frame follows the preview record,
// as the independent card on that record says; cancelling restores the
// walls and areas of the stored record.
//
// The independent card is the oracle: a new `houseplan-card` given the same
// config through its own `config/get`, with nothing in its caches to reuse.
// Card internals are read-only here; writes go through the harness facade,
// the room dialog, the stairs tool and real pointer input.
import { launch, checkAll, finish } from './serve.mjs';
import { fixtureWallKey } from './fixtures/wall-key.mjs';
const { page, browser } = await launch({ width: 1100, height: 850 });
/** Every edge of every room once, as a real wall record of `cm`. */
const wallsOf = (rooms, cm, overrides = {}) => {
const byKey = new Map();
for (const room of rooms) {
room.poly.forEach((a, index) => {
const b = room.poly[(index + 1) % room.poly.length];
const key = fixtureWallKey(a, b);
if (!byKey.has(key)) byKey.set(key, { key, cm: overrides[key] ?? cm, a, b });
});
}
return [...byKey.values()];
};
const rect = (x0, y0, x1, y1) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
// On the 1/240 editor grid, every interior edge shared by exactly two rooms.
const f1Rooms = [
{ id: 'r1', name: 'Living room', area: 'living_room', poly: rect(0.1, 0.15, 0.5, 0.5) },
{ id: 'r2', name: 'Kitchen', area: 'kitchen', poly: rect(0.5, 0.15, 0.9, 0.5) },
{ id: 'r3', name: 'Hallway', area: 'hallway', poly: rect(0.1, 0.5, 0.5, 0.85) },
{ id: 'r4', name: 'Bedroom', area: 'bedroom', poly: rect(0.5, 0.5, 0.9, 0.85) },
];
const gardenRooms = [
{ id: 'g1', name: 'Garden west', area: 'garden', poly: rect(0.1, 0.2, 0.5, 0.8) },
{ id: 'g2', name: 'Garden east', poly: rect(0.5, 0.2, 0.9, 0.8) },
];
const f1Shared = fixtureWallKey([0.5, 0.15], [0.5, 0.5]);
const gardenShared = fixtureWallKey([0.5, 0.2], [0.5, 0.8]);
const fixture = {
f1: {
rooms: f1Rooms,
walls: wallsOf(f1Rooms, 15),
partitions: [{ id: 'f1-wall', a: [0.6, 0.62], b: [0.8, 0.62], cm: 10 }],
wall_columns: [{ id: 'f1-column', shape: 'circle', center: [0.7, 0.3], cm: 20 }],
},
garden: {
rooms: gardenRooms,
walls: wallsOf(gardenRooms, 15),
partitions: [{ id: 'garden-wall', a: [0.2, 0.5], b: [0.4, 0.5], cm: 10 }],
wall_columns: [{ id: 'garden-column', shape: 'square', center: [0.7, 0.5], cm: 20, angle: 0 }],
},
// AC2a: one push thickens the shared wall of each floor.
thick: {
f1: wallsOf(f1Rooms, 15, { [f1Shared]: 30 }),
garden: wallsOf(gardenRooms, 15, { [gardenShared]: 30 }),
},
};
const res = await page.evaluate(async (fixture) => {
const out = {};
const diag = {};
const card = window.__card;
const hp = window.__hpTest;
const root = () => card.renderRoot;
const settled = () => hp.settled();
const frames = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const writesIdle = async () => {
const busy = () => card._saveConfigDebounced.pending() || card._writesPending > 0;
const deadline = performance.now() + 5000;
while (busy() && performance.now() < deadline) await new Promise((done) => setTimeout(done, 16));
await settled();
return !busy();
};
// ---- what a floor shows: wall outlines and room areas ---------------------
const wallPaths = (host = card) => [...host.renderRoot.querySelectorAll('[data-hp="wall"]')]
.map((node) => `${node.getAttribute('data-kind')}:${node.getAttribute('d')}`).join(' | ');
const areas = (host = card) => Object.fromEntries((host._spaceModel()?.rooms || [])
.map((room) => [room.id, host._roomArea(room)]));
const shown = (host = card) => ({
space: host._spaceModel()?.id, walls: wallPaths(host), areas: areas(host),
});
const sameShown = (a, b) => a.space === b.space && a.walls === b.walls
&& JSON.stringify(a.areas) === JSON.stringify(b.areas);
const sizes = () => ({
wallUnionPool: card._wallUnionPool.size,
innerContour: card._innerContourCache.size,
cleanFloor: card._cleanFloorCache.size,
});
const gardenCleanKeys = () => [...card._cleanFloorCache.keys()].filter((key) => key.startsWith('garden|'));
// ---- the oracle: a fresh card on the given config -------------------------
let oracleRev = 900000;
const oracle = async (config, spaceId) => {
const rev = ++oracleRev;
const base = card.hass;
const hass = {
...base,
callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: structuredClone(config), rev, can_write: false };
if (message.type === 'houseplan/config/set') return { ok: true, rev };
return base.callWS(message);
},
};
const host = document.createElement('div');
host.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;opacity:0;pointer-events:none';
document.body.appendChild(host);
const fresh = document.createElement('houseplan-card');
fresh.setConfig({ type: 'custom:houseplan-card', floor: spaceId });
host.appendChild(fresh);
fresh.hass = hass;
const deadline = performance.now() + 9000;
while (performance.now() < deadline && !(fresh._loadOk && fresh._booting === false
&& fresh._cfgRev === rev && fresh._spaceModel()?.id === spaceId)) {
await new Promise((done) => setTimeout(done, 30));
}
fresh.hass = { ...hass };
await fresh.updateComplete;
await frames();
await fresh.updateComplete;
const value = { ...shown(fresh), ready: fresh._cfgRev === rev };
host.remove();
return value;
};
window.__hp744 = { diag, shown, sameShown, oracle, settled, writesIdle };
// ---- fixture: walls on every room edge, a partition and a column per floor -
// The demo record still carries the legacy `segments: []`, and the first
// write of the card canonicalises EVERY floor (`_dropLegacySegments` drops
// it and gives a square column its `angle`): a real, one-time change of
// garden's record. The fixture starts canonical, so the rename below is
// the only change.
await hp.setServerConfig((cfg) => {
for (const space of cfg.spaces) {
const own = fixture[space.id];
if (!own) continue;
Object.assign(space, structuredClone(own), { cell_cm: 5 });
space.settings = { ...(space.settings || {}), show_borders: true };
delete space.stairs;
delete space.segments;
}
return cfg;
});
await hp.switchSpace('f1');
await writesIdle();
const f1Initial = shown();
out.fixtureF1HasWallBodies = f1Initial.walls.includes('union:') && Object.keys(f1Initial.areas).length === 4;
// ======================= AC1: an edit of f1 leaves garden warm ============
await hp.switchSpace('garden');
const gardenInitial = shown();
out.fixtureGardenHasWallBodies = gardenInitial.space === 'garden' && gardenInitial.walls.includes('union:')
&& Object.keys(gardenInitial.areas).length === 2;
const gardenBodiesKey = card._physicalBodiesCache?.key ?? null;
out.fixtureGardenHasPhysicalBodies = !!gardenBodiesKey && (card._physicalBodiesR()?.length ?? 0) >= 2;
const gardenRecord = () => JSON.stringify(card._serverCfg.spaces.find((space) => space.id === 'garden'));
const gardenRecordBefore = gardenRecord();
await hp.switchSpace('f1');
await hp.setMode('plan');
const epochBeforeRename = card._cfgEpoch;
const dialog = await hp.openRoomEdit('r1');
await hp.input(dialog.querySelector('#room-name'), 'Living room 744');
dialog.querySelector('[data-hp="dialog-confirm"]')?.click();
await settled();
await hp.setMode('view');
out.ac1WritesSettled = await writesIdle();
const renamed = card._serverCfg.spaces.find((space) => space.id === 'f1')
?.rooms.find((room) => room.id === 'r1')?.name;
out.ac1TheEditIsARealSave = renamed === 'Living room 744' && card._cfgEpoch > epochBeforeRename;
out.ac1TheEditLeavesGardenRecordAlone = gardenRecord() === gardenRecordBefore;
const beforeVisit = sizes();
await hp.switchSpace('garden');
const afterVisit = sizes();
diag.ac1 = { beforeVisit, afterVisit, gardenBodiesKey, after: card._physicalBodiesCache?.key };
out.ac1GardenVisitBuildsNoWallUnion = afterVisit.wallUnionPool - beforeVisit.wallUnionPool;
out.ac1GardenVisitBuildsNoContour = afterVisit.innerContour - beforeVisit.innerContour;
out.ac1GardenVisitBuildsNoCleanFloor = afterVisit.cleanFloor - beforeVisit.cleanFloor;
out.ac1GardenKeepsItsPhysicalBodiesKey = card._physicalBodiesCache?.key === gardenBodiesKey;
out.ac1GardenLooksTheSame = sameShown(shown(), gardenInitial);
// ============ AC2a: a server push changes both floors while f1 is shown ====
const gardenBefore = shown();
await hp.switchSpace('f1');
const f1Before = shown();
await hp.setServerConfig((cfg) => {
for (const space of cfg.spaces) {
if (fixture.thick[space.id]) space.walls = structuredClone(fixture.thick[space.id]);
}
return cfg;
});
const pushed = structuredClone(card._serverCfg);
const f1After = shown();
await hp.switchSpace('garden');
const gardenAfter = shown();
const f1Oracle = await oracle(pushed, 'f1');
const gardenOracle = await oracle(pushed, 'garden');
diag.ac2a = { f1Before, f1After, f1Oracle, gardenBefore, gardenAfter, gardenOracle };
out.ac2aOraclesReady = f1Oracle.ready && gardenOracle.ready;
out.ac2aPushChangedBothFloors = f1After.walls !== f1Before.walls && gardenAfter.walls !== gardenBefore.walls
&& f1After.areas.r1 !== f1Before.areas.r1 && f1After.areas.r2 !== f1Before.areas.r2
&& gardenAfter.areas.g1 !== gardenBefore.areas.g1 && gardenAfter.areas.g2 !== gardenBefore.areas.g2;
out.ac2aCurrentFloorEqualsAFreshCard = sameShown(f1After, f1Oracle);
out.ac2aOtherFloorEqualsAFreshCard = sameShown(gardenAfter, gardenOracle);
// ======== AC2b: a stair on f1 shrinks its room; garden keeps its floors =====
const gardenKeys = gardenCleanKeys();
out.ac2bGardenHasCleanFloors = gardenKeys.length >= 2;
await hp.switchSpace('f1');
await hp.setMode('plan');
const r3 = () => card._spaceModel().rooms.find((room) => room.id === 'r3');
const areaBeforeStair = card._roomArea(r3());
await hp.setTool('stairs');
const straight = root().querySelector('[data-hp="tray"] [data-group-item="straight"]');
straight?.click();
await settled();
// The default straight stair (240 × 100 cm) centred well inside r3.
const at = new DOMPoint(300, 680).matrixTransform(root().querySelector('.plan-svg').getScreenCTM());
root().querySelector('.stage').dispatchEvent(new MouseEvent('click', {
clientX: at.x, clientY: at.y, bubbles: true, composed: true, cancelable: true, button: 0,
}));
await settled();
await writesIdle();
const stairs = card._serverCfg.spaces.find((space) => space.id === 'f1')?.stairs || [];
out.ac2bTheStairIsPlaced = !!straight && stairs.length === 1;
const areaAfterStair = card._roomArea(r3());
const stairOracle = await oracle(structuredClone(card._serverCfg), 'f1');
const number = (text) => Number(String(text).replace(/[^\d.,-]/g, '').replace(',', '.'));
diag.ac2b = { areaBeforeStair, areaAfterStair, oracle: stairOracle.areas.r3, gardenKeys };
out.ac2bRoomAreaShrinks = number(areaAfterStair) < number(areaBeforeStair);
out.ac2bRoomAreaEqualsAFreshCard = stairOracle.ready && areaAfterStair === stairOracle.areas.r3;
out.ac2bGardenCleanFloorsKept = gardenKeys.filter((key) => !card._cleanFloorCache.has(key));
// ======== AC2c setup: the Resize tool and the r1/r2 shared wall handle =====
// Outer walls of this fixture are partial (their records span two rooms) and
// stay disabled; the shared wall moves the faces of both rooms.
await hp.setTool('resize');
diag.ac2cStored = shown();
const handle = [...root().querySelectorAll('.rszhandle:not(.rszcorner)')].find((node) =>
Math.abs(Number(node.getAttribute('cx')) - 500) < 0.5
&& Math.abs(Number(node.getAttribute('cy')) - 325) < 0.5);
out.ac2cHandleEnabled = handle?.getAttribute('aria-disabled') === 'false';
const box = handle?.getBoundingClientRect();
const leftBy = new DOMPoint(450, 325).matrixTransform(root().querySelector('.plan-svg').getScreenCTM());
out.ac2cGesture = box ? {
x: box.left + box.width / 2, y: box.top + box.height / 2, toX: leftBy.x,
} : null;
return out;
}, fixture);
// ======== AC2c: hold a real drag of the shared wall 50 units into r1 ==========
const { ac2cGesture: gesture, ...checks } = res;
if (gesture) {
await page.mouse.move(gesture.x, gesture.y);
await page.mouse.down();
await page.mouse.move((gesture.x + gesture.toX) / 2, gesture.y, { steps: 3 });
await page.mouse.move(gesture.toX, gesture.y, { steps: 3 });
Object.assign(checks, await page.evaluate(async () => {
const card = window.__card;
const { diag, shown, settled } = window.__hp744;
await settled();
const preview = card._resize.preview;
diag.ac2cPreview = shown();
diag.ac2cPreviewRecord = preview ? structuredClone(preview.sp) : null;
diag.ac2cLiveAreas = (card._resize.liveLabels || []).filter((label) => label.kind === 'area')
.map((label) => [label.roomId, label.text]);
return { ac2cPreviewIsLive: !!preview && preview.space === 'f1' && card._resize.dragging };
}));
await page.keyboard.press('Escape');
await page.mouse.up();
Object.assign(checks, await page.evaluate(async () => {
const card = window.__card;
const { diag, shown, sameShown, oracle, settled } = window.__hp744;
await settled();
const out = {};
const cancelled = shown();
out.ac2cCancelled = !card._resize.dragging && !card._resize.preview;
out.ac2cCancelRestoresTheStoredFrame = sameShown(cancelled, diag.ac2cStored);
const record = diag.ac2cPreviewRecord;
if (record) {
const config = structuredClone(card._serverCfg);
config.spaces = config.spaces.map((space) => (space.id === 'f1' ? record : space));
const fresh = await oracle(config, 'f1');
diag.ac2cOracle = fresh;
out.ac2cOracleReady = fresh.ready;
out.ac2cPreviewMovesBothRooms = diag.ac2cPreview.areas.r1 !== diag.ac2cStored.areas.r1
&& diag.ac2cPreview.areas.r2 !== diag.ac2cStored.areas.r2;
// The live layer draws the moving wall; its faces are where the fresh
// card on the preview record draws them, and not where they were.
const numbers = (text) => new Set((text.match(/-?\d+(?:\.\d+)?/g) || []).map(Number));
const stored = numbers(diag.ac2cStored.walls);
const moved = [...numbers(fresh.walls)].filter((value) => !stored.has(value));
const previewPath = numbers(diag.ac2cPreview.walls.split(' | ')
.filter((part) => part.startsWith('preview:')).join(' '));
out.ac2cPreviewWallStandsWhereAFreshCardDrawsIt = moved.length > 0
&& moved.every((value) => previewPath.has(value));
// Room areas through the cached contour, and the live labels beside the
// moving wall, are the fresh card's numbers for the preview record.
out.ac2cPreviewAreasEqualAFreshCard = JSON.stringify(diag.ac2cPreview.areas) === JSON.stringify(fresh.areas);
out.ac2cLiveAreaLabelsEqualAFreshCard = diag.ac2cLiveAreas.length === 2
&& diag.ac2cLiveAreas.every(([roomId, text]) => fresh.areas[roomId] === text);
}
diag.ac2cCancelled = cancelled;
return out;
}));
}
checkAll(checks, {
ac1GardenVisitBuildsNoWallUnion: 0,
ac1GardenVisitBuildsNoContour: 0,
ac1GardenVisitBuildsNoCleanFloor: 0,
ac2bGardenCleanFloorsKept: [],
});
if (process.env.HP_744_DIAG) console.log(JSON.stringify(await page.evaluate(() => window.__hp744.diag), null, 1));
await finish(browser, checks);