diff --git a/demo/benchmark_large_house.mjs b/demo/benchmark_large_house.mjs index 1572abcb..e7638df9 100644 --- a/demo/benchmark_large_house.mjs +++ b/demo/benchmark_large_house.mjs @@ -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, diff --git a/demo/performance/card-contract.mjs b/demo/performance/card-contract.mjs index 091e1aae..107a6433 100644 --- a/demo/performance/card-contract.mjs +++ b/demo/performance/card-contract.mjs @@ -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', }), }); diff --git a/demo/smoke_floor_geometry_cache.mjs b/demo/smoke_floor_geometry_cache.mjs new file mode 100644 index 00000000..ef0d002f --- /dev/null +++ b/demo/smoke_floor_geometry_cache.mjs @@ -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); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 17be1870..4e5919fb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index a00a64c9..86835efc 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -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 diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index 23bdc941..15df5228 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -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` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index b3383056..a304dacf 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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'); diff --git a/src/clean-floor.ts b/src/clean-floor.ts index a7d0e595..7321cab9 100644 --- a/src/clean-floor.ts +++ b/src/clean-floor.ts @@ -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; 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; diff --git a/src/floor-geometry-key.ts b/src/floor-geometry-key.ts new file mode 100644 index 00000000..4249f58f --- /dev/null +++ b/src/floor-geometry-key.ts @@ -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(); + 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; + }; +} diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 26503456..dfc8e237 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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(); private _innerContourCache = new Map(); + 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 { 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), diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index 556000e0..3321d930 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -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; diff --git a/src/stairs-editor.ts b/src/stairs-editor.ts index ad2fe2c6..48d301d6 100644 --- a/src/stairs-editor.ts +++ b/src/stairs-editor.ts @@ -70,7 +70,6 @@ export interface StairEditorHostPort { _tool: MarkupTool; _curSpaceCfg: unknown; _cfgEpoch: number; - _cleanFloorCache: Map; _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(); } diff --git a/test/clean-floor.test.mjs b/test/clean-floor.test.mjs index 323f3d6a..371656d6 100644 --- a/test/clean-floor.test.mjs +++ b/test/clean-floor.test.mjs @@ -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, });