mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 21:58:56 +00:00
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:
+3
-2
@@ -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;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user