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
This commit is contained in:
Claude
2026-10-02 00:56:04 +03:00
parent c4cb0649ae
commit f5b20e07f6
13 changed files with 456 additions and 24 deletions
+5
View File
@@ -178,6 +178,11 @@ try {
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
// #744: the union is pooled per floor, and the inner contours are the
// second structural cache of a floor visit; 0/1 above cannot see a
// cold floor whose union is rebuilt while its clean floor stays warm.
wallUnionPool: card._wallUnionPool?.size ?? 0,
innerContour: card._innerContourCache?.size ?? 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
+5
View File
@@ -68,6 +68,9 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_liveEditorPaintCount', '_onLabsSnapshot',
'_planSnapGeometryCache', '_roomDialog', '_syncVolumetricSetting', '_wallFaceBatch',
'_wallFaceGraphCache',
// #744: the #735 switch-cycle guard counts the union pool and the inner
// contours; a comparison bundle without them reads 0.
'_wallUnionPool', '_innerContourCache',
]),
// #649: members that only comparison bundles own. The benchmark feature-probes
// them (`typeof card._setProjection === 'function'`); the current card no
@@ -111,6 +114,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
_path: 'array',
_isoGeometryCache: 'map',
_isoStructuralBuildCount: 'number',
_innerContourCache: 'map',
_planSnapGeometryCache: 'object',
_roomDialog: 'boolean',
_resize: 'object',
@@ -119,6 +123,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
_space: 'string',
_tool: 'string',
_wallFaceGraphCache: 'array',
_wallUnionPool: 'map',
}),
});
+332
View File
@@ -0,0 +1,332 @@
// #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);
+3
View File
@@ -42,6 +42,9 @@
now keeps its transparency there in every state, and a room without a fill
or border has them at 0
([#746](https://github.com/Matysh/houseplan-card/issues/746)).
- After a plan edit, switching to other floors no longer stalls: only the
edited floor is rebuilt
([#744](https://github.com/Matysh/houseplan-card/issues/744)).
## v1.79.0-beta.1 — 2026-09-30
+3
View File
@@ -51,6 +51,9 @@
всех состояниях хранится в них, а у комнаты без заливки или границы они
равны 0
([#746](https://github.com/Matysh/houseplan-card/issues/746)).
- После правки плана переход на другие этажи больше не подвисает:
перестраивается только изменённый этаж
([#744](https://github.com/Matysh/houseplan-card/issues/744)).
## v1.79.0-beta.1 — 2026-09-30
@@ -17,8 +17,8 @@ to prove that the Node witness actually kills it.
| Paint, cascade and layer composition | 26 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Pointer geometry and trusted interaction | 45 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
| Custom-element and HA browser lifecycle | 97 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **214 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
| Custom-element and HA browser lifecycle | 99 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **216 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
## Measured effect
@@ -237,6 +237,8 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `editor-neutral-escape-does-not-exit`
- `empty-space-cleanup-disabled`
- `fixed-floor-transition-guard-bypassed`
- `floor-geometry-key-global-epoch`
- `floor-geometry-key-ignores-content`
- `french-locale-wrong-dictionary`
- `hp-dialog-escape-does-not-close`
- `junction-limit-baseline-cache-stale`
+23
View File
@@ -14178,6 +14178,29 @@ const MUTANT_DEFINITIONS = [
+ ' controlTop: this.host._kiosk ? this.stage.controlTop : 0 });\n',
}],
},
// #744: четыре кэша геометрии этажа — по отпечатку записи этажа, а не по эпохе.
{
id: 'floor-geometry-key-global-epoch',
guard: 'node demo/smoke_floor_geometry_cache.mjs',
because: '#744 AC1: an edit of one floor bumps the global epoch; a floor key made of the epoch '
+ 'turns every other floor cold, and its first visit rebuilds the union, contours and clean floors',
patches: [{
file: 'src/floor-geometry-key.ts',
find: ' const key = `${spaceId}|${contentFingerprint(model === current ? model : [model, current])}`;\n',
replace: ' const key = `${spaceId}|${source._cfgEpoch}`; // mutant: the global epoch\n',
}],
},
{
id: 'floor-geometry-key-ignores-content',
guard: 'node demo/smoke_floor_geometry_cache.mjs',
because: '#744 AC2a: a server push that changes a floor\'s walls must give the floor a new key; a key '
+ 'without the content fingerprint draws the walls and areas of the previous record',
patches: [{
file: 'src/floor-geometry-key.ts',
find: ' const key = `${spaceId}|${contentFingerprint(model === current ? model : [model, current])}`;\n',
replace: ' const key = `${spaceId}|`; // mutant: no content fingerprint\n',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+3 -2
View File
@@ -18,7 +18,8 @@ export function cleanFloorForRoom(input: {
room: RoomCfg;
floor: number[][];
space?: SpaceModel;
configEpoch: number;
/** The floor's content key (#744, floor-geometry-key.ts); it starts with the floor id. */
floorKey(spaceId: string): string;
resizePreview: boolean;
cache: Map<string, CleanFloorResult>;
physicalBodies(space: SpaceModel): number[][][];
@@ -30,7 +31,7 @@ export function cleanFloorForRoom(input: {
floor, geom: null, path: '', area: geometryArea([[[...floor, floor[0]]]]),
};
const roomKey = room.id || `#${space.rooms.indexOf(room)}`;
const key = `${space.id}|${input.configEpoch}|${roomKey}`;
const key = `${input.floorKey(space.id)}|${roomKey}`;
if (!input.resizePreview) {
const cached = lruRead(input.cache, key);
if (cached.hit) return cached.value;
+59
View File
@@ -0,0 +1,59 @@
/**
* #744: the structural key of one floor for the four floor-geometry caches of
* the card — physical bodies, the wall union, inner room contours and the
* clean floor.
*
* Those caches used to carry the global `_cfgEpoch`. Every edit of any floor
* bumps it, so one edit made every other floor cold again: in the large-house
* fixture the first visit to an untouched floor rebuilt its wall union for
* ≈0.6 s. A floor's geometry reads only its own config record (rooms, walls,
* openings, partitions, columns, stairs, `cell_cm`, frame — `spaceModels`)
* and constants, so the key is a content fingerprint of that record. It is
* complete by construction: any change of the record is a new key, and an
* edit of another floor cannot change it.
*
* The geometry reads the model of the floor it is given next to the CURRENT
* floor's config (`_curSpaceCfg`: walls, openings, `cell_cm`). Both come from
* the config as rendered, live resize preview included. When the two records
* differ — a fallback or a foreign floor — the key fingerprints both, so such
* a value never shares a key with the floor's own geometry.
*
* A fingerprint costs ≈1.5 ms per floor in large-house, so it is remembered
* per epoch and per source record object: every mutation path bumps the epoch
* (`_saveConfig`), the resize preview is its own record object (`pv.sp`), and
* a config replaced before the next `willUpdate` brings new record objects. An
* in-place edit without a new epoch shows on the next epoch, as it always did
* for these caches.
*/
import { contentFingerprint } from './visual-continuity';
/** The card members the key reads; the card passes itself. */
export interface FloorKeySource {
readonly _cfgEpoch: number;
/** The config as rendered: the live resize preview substituted in. */
readonly _renderCfg: { readonly spaces: readonly unknown[] } | null;
/** The current floor's record as rendered (resize preview included). */
readonly _curSpaceCfg: unknown;
}
type Slot = { model: unknown; current: unknown; key: string };
/** A reader `spaceId → key`, remembered per epoch and per source record. */
export function floorGeometryKeyReader(source: FloorKeySource): (spaceId: string) => string {
let epoch = Number.NaN;
const slots = new Map<string, Slot>();
return (spaceId) => {
if (source._cfgEpoch !== epoch) {
epoch = source._cfgEpoch;
slots.clear();
}
const model = source._renderCfg?.spaces
.find((space) => (space as { id?: unknown } | null)?.id === spaceId) ?? null;
const current = source._curSpaceCfg ?? null;
const slot = slots.get(spaceId);
if (slot && slot.model === model && slot.current === current) return slot.key;
const key = `${spaceId}|${contentFingerprint(model === current ? model : [model, current])}`;
slots.set(spaceId, { model, current, key });
return key;
};
}
+9 -11
View File
@@ -269,6 +269,7 @@ import {
import type { DecorShape, DecorStyle } from './editors/decor/types';
import { StairViewRuntime, type StairViewHostPort } from './stairs-view';
import { cleanFloorForRoom, type CleanFloorResult } from './clean-floor';
import { floorGeometryKeyReader, type FloorKeySource } from './floor-geometry-key';
import {
DECOR_ASSETS_API_VERSION, decorAssetIds, projectDecorImage,
resolveDecorAssets, type DecorAsset,
@@ -1799,6 +1800,7 @@ export class HouseplanCard extends LitElement {
private _lightPhysicalBodiesCache: { key: string; all: number[][][] } | null = null;
private _cleanFloorCache = new Map<string, CleanFloorResult>();
private _innerContourCache = new Map<string, number[][] | null>();
private readonly _floorKey = floorGeometryKeyReader(this as unknown as FloorKeySource); // #744: key of the four caches above
private readonly _glowRuntimeState: GlowRuntimeState = createGlowRuntimeState();
private readonly _glowRuntimeHost: GlowRuntimeHost = {
window: () => this.ownerDocument.defaultView || window,
@@ -8754,14 +8756,14 @@ export class HouseplanCard extends LitElement {
return united?.paperD ? [{ path: united.paperD }] : paperRoomShapes(rooms);
}
/** Canonical paper + masonry geometry, cached by structural config epoch. */
/** Canonical paper + masonry geometry, cached by the floor's content key (#744). */
private _wallUnionGeometry(): ReturnType<typeof wallBodiesUnionPath> {
const space = this._spaceModel();
if (!space) return null;
const walls = this._spaceWalls;
const extras = this._physicalBodiesR();
if (!walls.length && !extras.length) return null;
const unionKey = `${this._space}|${this._cfgEpoch}|${space.rooms.length}`;
const unionKey = `${this._floorKey(space.id)}|${space.rooms.length}`;
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
const cached = lruRead(this._wallUnionPool, unionKey);
if (cached.hit) this._wallUnionCache = cached.value;
@@ -8779,10 +8781,7 @@ export class HouseplanCard extends LitElement {
value: contentFingerprint([this._curSpaceCfg, this._cellCm, this._gridPitch]),
enumerable: false,
});
const entry = {
key: unionKey,
value,
};
const entry = { key: unionKey, value };
lruWrite(this._wallUnionPool, unionKey, entry, 8);
this._wallUnionCache = entry;
}
@@ -8812,9 +8811,8 @@ export class HouseplanCard extends LitElement {
multiWallNodes = this._wallUnionGeometry()?.multiWallNodes,
): number[][] | null {
const cutsKey = openCuts.map((cut) => cut.join(',')).join(';');
const key = `${space.id}|${this._cfgEpoch}|${roomId}|${cutsKey}`;
// Resize advances the structural epoch before publishing every preview,
// so editor and View consumers can safely share one per-epoch answer.
const key = `${this._floorKey(space.id)}|${roomId}|${cutsKey}`;
// #744: one answer per floor content (a resize preview is its own record).
const cached = lruRead(this._innerContourCache, key);
if (cached.hit) return cached.value;
const value = innerContourForRoom(
@@ -9468,7 +9466,7 @@ export class HouseplanCard extends LitElement {
* does not depend on show_borders. */
private _physicalBodiesR(space: SpaceModel | undefined = this._spaceModel()): number[][][] {
if (!space) return [];
const key = `${space.id}|${this._cfgEpoch}|${this._cellCm}|${this._gridPitch}`;
const key = `${this._floorKey(space.id)}|${this._cellCm}|${this._gridPitch}`;
if (this._physicalBodiesCache?.key === key) return this._physicalBodiesCache.all;
const frame = physicalBodyParts(
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
@@ -9492,7 +9490,7 @@ export class HouseplanCard extends LitElement {
room: RoomCfg, floor: number[][], space: SpaceModel | undefined = this._spaceModel(),
): CleanFloorResult {
return cleanFloorForRoom({
room, floor, space, configEpoch: this._cfgEpoch,
room, floor, space, floorKey: this._floorKey,
resizePreview: !!this._resize?.preview,
cache: this._cleanFloorCache,
physicalBodies: (model) => this._physicalBodiesR(model),
+7 -6
View File
@@ -486,6 +486,7 @@ export interface HouseplanEditorHostPort {
_capturedSnapshotConfigEpoch: number;
_cellCm: number;
_cfgEpoch: number;
_floorKey: (spaceId: string) => string;
readonly _cfgRev: number;
_clearTransientHover: (suspend?: boolean) => void;
_closeInfoCard: () => void;
@@ -3181,11 +3182,11 @@ public _rszAcceptPreview(
): void {
this.host._cfgEpoch++;
if (this.host._physicalBodiesCache) this.host._physicalBodiesCache.key =
`${this.host._space}|${this.host._cfgEpoch}|${this.host._cellCm}|${this.host._gridPitch}`;
`${this.host._floorKey(this.host._space)}|${this.host._cellCm}|${this.host._gridPitch}`;
if (!preview || !wallGeometry) return;
const projected = wallBodiesGeometryPath(wallGeometry);
if (!projected) return;
const key = `${this.host._space}|${this.host._cfgEpoch}|${preview.sp.rooms.length}`;
const key = `${this.host._floorKey(this.host._space)}|${preview.sp.rooms.length}`;
Object.defineProperty(projected, 'sourceFingerprint', {
value: contentFingerprint([preview.sp, this.host._cellCm, this.host._gridPitch]),
enumerable: false,
@@ -3253,7 +3254,7 @@ public _rszEdgeDown(ev: PointerEvent, roomId: string, edge: number): void {
this.host._serverCfg, this.host._space, null, new Set(plan.roomIds));
} catch { this._resizeBaselineLimits = []; }
const start = this._svgPoint(ev);
const wallUnionKey = `${this.host._space}|${this.host._cfgEpoch}|${rooms.length}`;
const wallUnionKey = `${this.host._floorKey(this.host._space)}|${rooms.length}`;
const wallUnionBefore = this.host._wallUnionCache?.key === wallUnionKey
? this.host._wallUnionCache.value : null;
const snapshotIdentity = this._rszSnapshot();
@@ -3373,12 +3374,12 @@ public _rszCancelDrag(pointerId?: number): void {
if (result.restoreEpoch !== null) this.host._cfgEpoch = result.restoreEpoch;
else this.host._cfgEpoch++;
if (this.host._physicalBodiesCache) this.host._physicalBodiesCache.key =
`${this.host._space}|${this.host._cfgEpoch}|${this.host._cellCm}|${this.host._gridPitch}`;
`${this.host._floorKey(this.host._space)}|${this.host._cellCm}|${this.host._gridPitch}`;
if (result.restoreWallUnion) {
// Alias the already-proved pre-drag union under the restored epoch.
// Alias the already-proved pre-drag union under the restored floor key (#744).
const space = this.host._spaceModel();
if (space) {
const key = `${this.host._space}|${this.host._cfgEpoch}|${space.rooms.length}`;
const key = `${this.host._floorKey(space.id)}|${space.rooms.length}`;
const entry = { key, value: result.restoreWallUnion };
lruWrite(this.host._wallUnionPool, key, entry, 8);
this.host._wallUnionCache = entry;
+2 -2
View File
@@ -70,7 +70,6 @@ export interface StairEditorHostPort {
_tool: MarkupTool;
_curSpaceCfg: unknown;
_cfgEpoch: number;
_cleanFloorCache: Map<unknown, unknown>;
_modelCache: unknown;
_gridPitch: number;
_cellCm: number;
@@ -152,8 +151,9 @@ export class StairEditorRuntime {
if (!space) return;
if (stairs.length) space.stairs = stairs;
else delete space.stairs;
// #744: the stairs are part of this floor's record, so the new epoch gives
// this floor a new geometry key; other floors keep their clean floors.
this.owner._cfgEpoch++;
this.owner._cleanFloorCache.clear();
this.owner._modelCache = null;
this.owner.requestUpdate();
}
+1 -1
View File
@@ -25,7 +25,7 @@ const counting = () => {
};
const build = (overrides = {}) => cleanFloorForRoom({
room, floor, space, configEpoch: 3, resizePreview: false, cache: new Map(),
room, floor, space, floorKey: (spaceId) => `${spaceId}|3`, resizePreview: false, cache: new Map(),
physicalBodies: () => [column], ...overrides,
});