perf: reuse validated wall geometry

Issue: #322
User-Visible: no
This commit is contained in:
Matysh
2026-08-27 13:58:06 +03:00
parent 87ff906545
commit 59a23de81b
8 changed files with 380 additions and 274 deletions
File diff suppressed because one or more lines are too long
+115 -115
View File
File diff suppressed because one or more lines are too long
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceFingerprint": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f2df45c488a90c46b3483701e59600abbf3ba470aae7d9d7a17fd6e232f600f8",
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
+91 -26
View File
@@ -67,7 +67,7 @@ import {
import {
degradeWalls, rekeyWallsAfterMoveChecked, wallRecordCarrierViolations,
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
wallEdgeBodies, wallBodiesGeometry, wallBodiesGeometryPath, wallBodiesUnionPath,
floorFootprintGeometry,
innerContourForRoom, roomWallProfile, outsetContour,
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
@@ -1578,10 +1578,31 @@ class HouseplanCard extends LitElement {
private _physicalPickCycle: {
signature: string; index: number; x: number; y: number; at: number;
} | null = null;
private _wallUnionCache: {
private _wallUnionCacheValue: {
key: string;
value: ReturnType<typeof wallBodiesUnionPath>;
} | null = null;
/** A floor switch is presentation-only: retain the bounded structural union
* for recently shown floors instead of rebuilding it on every tab click. */
private _wallUnionPool = new Map<string, {
key: string;
value: ReturnType<typeof wallBodiesUnionPath>;
}>();
/** Keep the historical cache property observable by performance/smoke
* contracts. Explicit test/product invalidation also clears the pool. */
private get _wallUnionCache(): {
key: string;
value: ReturnType<typeof wallBodiesUnionPath>;
} | null {
return this._wallUnionCacheValue;
}
private set _wallUnionCache(value: {
key: string;
value: ReturnType<typeof wallBodiesUnionPath>;
} | null) {
this._wallUnionCacheValue = value;
if (value === null) this._wallUnionPool.clear();
}
private _isoGeometryCache = new Map<string, {
geometry: IsoWallGeometry;
floor: IsoFloorGeometry;
@@ -8913,14 +8934,46 @@ class HouseplanCard extends LitElement {
if (wallRecordCarrierViolations(
changedWalls, wallCarriers, this._wallKeyPitch, NORM_W, s.walls || [],
).length) return { ok: false, reason: 'wall-metadata' };
if (!this._rszSpaceCandidateRenderable(this._space, sp)) {
const preflight = this._rszSpaceCandidateGeometry(this._space, sp);
if (!preflight.ok) {
return { ok: false, reason: 'physical-geometry' };
}
this._rszPreview = { space: this._space, sp };
this._cfgEpoch++;
if (preflight.wallGeometry) {
const projected = wallBodiesGeometryPath(preflight.wallGeometry);
if (projected) {
const key = `${this._space}|${this._cfgEpoch}|${sp.rooms.length}`;
const entry = { key, value: projected };
lruWrite(this._wallUnionPool, key, entry, 8);
this._wallUnionCache = entry;
}
}
return { ok: true };
}
/** Validate once and retain the exact geometry pass for the preview render. */
private _rszSpaceCandidateGeometry(spaceId: string, sp: any): {
ok: boolean;
wallGeometry: ReturnType<typeof wallBodiesGeometry> | null;
} {
if (!this._serverCfg) return { ok: false, wallGeometry: null };
const candidate = {
...this._serverCfg,
spaces: this._serverCfg.spaces.map((space: any) =>
space.id === spaceId ? sp : space),
} as ServerConfig;
let wallGeometry: ReturnType<typeof wallBodiesGeometry> | null = null;
try {
const check = this._checkSpacePhysicalGeometry(
candidate, spaceId, (geometry) => { wallGeometry = geometry; },
);
return { ok: check.ok, wallGeometry };
} catch {
return { ok: false, wallGeometry: null };
}
}
/** Fail-closed check for one exact candidate through the common barrier. */
private _rszSpaceCandidateRenderable(spaceId: string, sp: any): boolean {
if (!this._serverCfg) return false;
@@ -9117,7 +9170,6 @@ class HouseplanCard extends LitElement {
const imperial = this.hass?.config?.unit_system?.length === 'mi';
const ids = plan.roomIds;
const walls = this._spaceWalls;
const openCuts = this._openCuts();
const physical = this._physicalBodiesR();
const base = this._baseVb();
const currentView = this._view && this._view.w > 0 && this._view.h > 0
@@ -9145,11 +9197,11 @@ class HouseplanCard extends LitElement {
);
for (const id of ids) {
const poly = res.polys[id] || g.rooms.find((r) => r.id === id)!.poly;
const floor = walls.length
? (innerContourForRoom(
Object.entries(res.polys).map(([rid, p]) => ({ id: rid, poly: p })),
id, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
) || poly)
// The preview is already the active render model. Reuse the same shared
// masonry union + contour cache that the following render consumes;
// rebuilding both independently here doubled one Resize frame.
const floor = walls.length && space
? (this._innerRoomContour(space, id) || poly)
: poly;
const m2 = physical.length
? geometryArea(floorMinusBodies(floor, physical))
@@ -11567,13 +11619,19 @@ class HouseplanCard extends LitElement {
const openings = this._roomWallOpeningInputs();
const unionKey = `${this._space}|${this._cfgEpoch}|${space.rooms.length}`;
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
this._wallUnionCache = {
key: unionKey,
value: wallBodiesUnionPath(
space.rooms, walls, openCuts, openings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
),
};
const cached = lruRead(this._wallUnionPool, unionKey);
if (cached.hit) this._wallUnionCache = cached.value;
else {
const entry = {
key: unionKey,
value: wallBodiesUnionPath(
space.rooms, walls, openCuts, openings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
),
};
lruWrite(this._wallUnionPool, unionKey, entry, 8);
this._wallUnionCache = entry;
}
}
return this._wallUnionCache.value;
}
@@ -11597,23 +11655,21 @@ class HouseplanCard extends LitElement {
roomId: string,
openCuts: number[][] = this._openCuts(),
roomWalls = this._wallUnionGeometry()?.roomGeom,
multiWallNodes = this._wallUnionGeometry()?.multiWallNodes,
): number[][] | null {
const cutsKey = openCuts.map((cut) => cut.join(',')).join(';');
const key = `${space.id}|${this._cfgEpoch}|${roomId}|${cutsKey}`;
// Editor previews can replace room polygons without advancing the saved
// config epoch. Cache only the immutable View surface; otherwise a Resize
// drag would keep painting (and hit-testing) the pre-drag contour.
const cacheable = this._mode === 'view' && !this._rszPreview;
if (cacheable) {
const cached = lruRead(this._innerContourCache, key);
if (cached.hit) return cached.value;
}
// Resize advances the structural epoch before publishing every preview,
// so editor and View consumers can safely share one per-epoch answer.
const cached = lruRead(this._innerContourCache, key);
if (cached.hit) return cached.value;
const value = innerContourForRoom(
space.rooms, roomId, this._spaceWalls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
roomWalls,
multiWallNodes,
);
if (cacheable) lruWrite(this._innerContourCache, key, value, 600);
lruWrite(this._innerContourCache, key, value, 600);
return value;
}
@@ -15614,9 +15670,18 @@ class HouseplanCard extends LitElement {
}
/** One strict production source shared by editor commits and stale rechecks. */
private _checkSpacePhysicalGeometry(config: ServerConfig, spaceId: string) {
private _checkSpacePhysicalGeometry(
config: ServerConfig,
spaceId: string,
captureWallGeometry?: (
geometry: ReturnType<typeof wallBodiesGeometry>,
) => void,
) {
return checkSpacePhysicalGeometry(config, spaceId, {
fallbackSpaceName: (index) => this._t('gs.align_preflight_space', { n: String(index) }),
captureWallGeometry: captureWallGeometry
? (_input, geometry) => captureWallGeometry(geometry)
: undefined,
});
}
+7
View File
@@ -107,6 +107,12 @@ export interface CheckOptimizeGeometryOptions {
wallPass?: typeof wallBodiesGeometry;
floorPass?: typeof floorFootprintGeometry;
fingerprint?: typeof contentFingerprint;
/** Internal reuse seam: receives the exact successful wall pass without
* adding private plan geometry to the serializable preflight result. */
captureWallGeometry?: (
input: SpacePhysicalGeometryInputs,
geometry: ReturnType<typeof wallBodiesGeometry>,
) => void;
}
export interface SpacePhysicalGeometryResult extends OptimizeSpaceGeometryCheck {
@@ -392,6 +398,7 @@ export function checkOptimizeGeometry(
spaces.push({ ...identity, status: 'failed', reason: 'wall-failed-core' });
continue;
}
options.captureWallGeometry?.(input, united);
}
if (input.space.rooms.length && united?.paperGeom == null) {
+28 -6
View File
@@ -41,6 +41,8 @@ export interface WallBodiesGeometryResult {
paperGeom: any;
depthUnits: number;
openingIndex: OpeningWallIndex | null;
/** Canonical junction topology reused by per-room inner contours. */
multiWallNodes: MultiWallNodeMap | null;
degradedExtraCount: number;
}
@@ -2416,7 +2418,7 @@ export function multiWallNodeAt(
|| a.point[0] - b.point[0] || a.point[1] - b.point[1])[0] || null;
}
function multiWallNodesForGeometry(
export function multiWallNodesForGeometry(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
@@ -2743,6 +2745,8 @@ export function innerContourForRoom(
coordScale = 1,
/** Canonical room-wall masonry before opening cuts; pass the render cache. */
sharedRoomWallGeometry?: any,
/** Canonical junction topology from the same wall-geometry pass. */
sharedMultiWallNodes?: MultiWallNodeMap | null,
): number[][] | null {
const room = (rooms || []).find((r) => r?.id === roomId);
const poly = roomPoly(room);
@@ -2750,7 +2754,7 @@ export function innerContourForRoom(
if (!walls?.length) return poly.map((p) => [p[0], p[1]]);
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr || !pr.offsets.some((o) => o > 0)) return poly.map((p) => [p[0], p[1]]);
const multiWallNodes = multiWallNodesForGeometry(
const multiWallNodes = sharedMultiWallNodes || multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
const inset = insetContour(pr.poly, pr.offsets, multiWallNodes);
@@ -3602,7 +3606,7 @@ export function wallBodiesGeometry(
): WallBodiesGeometryResult {
if (!walls?.length && !extraBodies.length) return {
status: 'not-applicable', geom: [], components: [], roomGeom: [], paperGeom: [],
depthUnits: 0, openingIndex: null, degradedExtraCount: 0,
depthUnits: 0, openingIndex: null, multiWallNodes: null, degradedExtraCount: 0,
};
const roomRings: MultiWallRoomRing[] = [];
const multiWallNodes = multiWallNodesForGeometry(
@@ -3832,14 +3836,14 @@ export function wallBodiesGeometry(
return {
status: degradedExtraCount || degradedCoreCount ? 'degraded-extra' : 'ok',
geom: primary, components, roomGeom, paperGeom,
depthUnits: maxDepth, openingIndex,
depthUnits: maxDepth, openingIndex, multiWallNodes,
degradedExtraCount: degradedExtraCount + degradedCoreCount,
};
} catch {
operations.onCoreFailure?.(corePhase);
return {
status: 'failed-core', geom: [], components: [], roomGeom: [], paperGeom: [],
depthUnits: maxDepth, openingIndex: null, degradedExtraCount: 0,
depthUnits: maxDepth, openingIndex: null, multiWallNodes, degradedExtraCount: 0,
};
}
}
@@ -3863,6 +3867,7 @@ export function wallBodiesUnionPath(
paths: readonly { id: string; d: string; fillRule: 'evenodd' }[];
components: readonly WallGeometryComponent[];
roomGeom: any;
multiWallNodes: MultiWallNodeMap | null;
paperD: string;
depthUnits: number;
fillRule: 'evenodd' | 'nonzero';
@@ -3872,6 +3877,23 @@ export function wallBodiesUnionPath(
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
operations,
);
return wallBodiesGeometryPath(united);
}
/** Project one already validated wall-geometry pass into the SVG payload. */
export function wallBodiesGeometryPath(
united: WallBodiesGeometryResult,
): {
status: 'ok' | 'degraded-extra';
d: string;
paths: readonly { id: string; d: string; fillRule: 'evenodd' }[];
components: readonly WallGeometryComponent[];
roomGeom: any;
multiWallNodes: MultiWallNodeMap | null;
paperD: string;
depthUnits: number;
fillRule: 'evenodd' | 'nonzero';
} | null {
if (united.status === 'failed-core' || united.status === 'not-applicable') return null;
const paths = united.components.map((component) => ({
id: component.id, d: polyclipToPathD(component.geom), fillRule: 'evenodd' as const,
@@ -3880,7 +3902,7 @@ export function wallBodiesUnionPath(
const paperD = polyclipToPathD(united.paperGeom);
if (paths.length) return {
status: united.status, d, paths, components: united.components,
roomGeom: united.roomGeom, paperD,
roomGeom: united.roomGeom, multiWallNodes: united.multiWallNodes, paperD,
depthUnits: united.depthUnits, fillRule: 'evenodd',
};
// successful empty result: do not resurrect raw rings
+4 -1
View File
@@ -92,9 +92,12 @@ test('room inner faces are structurally cached and shared by both fill layers',
const helperEnd = source.indexOf('\n /**', helperStart);
assert.ok(helperStart >= 0 && helperEnd > helperStart, 'inner-contour cache helper is present');
const helper = source.slice(helperStart, helperEnd);
assert.match(helper, /const cacheable = this\._mode === 'view' && !this\._rszPreview;/);
assert.match(helper, /lruRead\(this\._innerContourCache, key\)/);
assert.match(helper, /lruWrite\(this\._innerContourCache, key, value, 600\)/);
assert.match(helper, /multiWallNodes/);
assert.match(source, /lruRead\(this\._wallUnionPool, unionKey\)/);
assert.match(source, /lruWrite\(this\._wallUnionPool, unionKey, entry, 8\)/);
assert.match(source, /wallBodiesGeometryPath\(preflight\.wallGeometry\)/);
const glowStart = source.indexOf('private _renderGlowBaseRooms(');
const glowEnd = source.indexOf('\n private _renderWallBodies(', glowStart);
+9
View File
@@ -153,6 +153,15 @@ test('#278 strict one-space barrier rejects degraded render-safe geometry', () =
id: 'strict', title: 'Strict', view_box: [0, 0, 1, 1],
rooms: [room()], walls: [wall()],
}]);
let captured = null;
const accepted = checkSpacePhysicalGeometry(config, 'strict', {
captureWallGeometry: (input, geometry) => { captured = { input, geometry }; },
});
assert.equal(accepted.ok, true);
assert.equal(captured?.input?.space?.id, 'strict');
assert.equal(captured?.geometry?.status, 'ok');
assert.ok(captured?.geometry?.multiWallNodes);
const degraded = checkSpacePhysicalGeometry(config, 'strict', {
wallPass: () => ({
status: 'degraded-extra', geom: [], components: [], roomGeom: [], paperGeom: [],