mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
perf: reuse wall topology for light transport
Issue: #322 User-Visible: no
This commit is contained in:
File diff suppressed because one or more lines are too long
Vendored
+6
-6
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"sourceFingerprint": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceFingerprint": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceSha256": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceSha256": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceSha256": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceSha256": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceSha256": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -69,7 +69,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceSha256": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceSha256": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceSha256": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -102,7 +102,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceSha256": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "5bd5c452cf113bb83c9eb8f3f0cb2905e7980ced18b1afe6f8919cc93a80a4cc",
|
||||
"sourceSha256": "b02ada1b0cc255adaac71a7493e154c2ce867dce60a65ab1b3cfdf39058605f2",
|
||||
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1631,8 +1631,8 @@ export const MUTANTS = [
|
||||
+ 'стену в сплошную и обязан увидеть смену освещённости соседней комнаты',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'if (this._lightBarrierCache?.key === cacheKey) return this._lightBarrierCache.value;',
|
||||
replace: 'if (this._lightBarrierCache) return this._lightBarrierCache.value;',
|
||||
find: ' const cacheKey = `${space.id}|${fingerprint}`;',
|
||||
replace: ' const cacheKey = space.id; // mutant: ignore changed geometry',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
+51
-40
@@ -68,6 +68,7 @@ import {
|
||||
degradeWalls, rekeyWallsAfterMoveChecked, wallRecordCarrierViolations,
|
||||
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
|
||||
wallEdgeBodies, wallBodiesGeometry, wallBodiesGeometryPath, wallBodiesUnionPath,
|
||||
recutWallBodiesGeometry,
|
||||
floorFootprintGeometry,
|
||||
innerContourForRoom, roomWallProfile, outsetContour,
|
||||
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
|
||||
@@ -1649,6 +1650,9 @@ class HouseplanCard extends LitElement {
|
||||
masonryGeometry: any; opaqueBodies: number[][][];
|
||||
};
|
||||
} | null = null;
|
||||
/** Space switching is presentation-only. Keep recently resolved light
|
||||
* topology just like wall topology, rather than rebuilding both per tab. */
|
||||
private _lightBarrierPool = new Map<string, NonNullable<typeof this._lightBarrierCache>>();
|
||||
/** Freeze the SVG blur while a pinch/pan emits animation frames, then adopt
|
||||
* the final screen-space value after the gesture. */
|
||||
private _glowFeatherUnits: number | null = null;
|
||||
@@ -8944,6 +8948,10 @@ class HouseplanCard extends LitElement {
|
||||
const projected = wallBodiesGeometryPath(preflight.wallGeometry);
|
||||
if (projected) {
|
||||
const key = `${this._space}|${this._cfgEpoch}|${sp.rooms.length}`;
|
||||
Object.defineProperty(projected, 'sourceFingerprint', {
|
||||
value: contentFingerprint([sp, this._cellCm, this._gridPitch]),
|
||||
enumerable: false,
|
||||
});
|
||||
const entry = { key, value: projected };
|
||||
lruWrite(this._wallUnionPool, key, entry, 8);
|
||||
this._wallUnionCache = entry;
|
||||
@@ -11615,19 +11623,27 @@ class HouseplanCard extends LitElement {
|
||||
const walls = this._spaceWalls;
|
||||
const extras = this._physicalBodiesR();
|
||||
if (!walls.length && !extras.length) return null;
|
||||
const openCuts = this._openCuts();
|
||||
const openings = this._roomWallOpeningInputs();
|
||||
const unionKey = `${this._space}|${this._cfgEpoch}|${space.rooms.length}`;
|
||||
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
|
||||
const cached = lruRead(this._wallUnionPool, unionKey);
|
||||
if (cached.hit) this._wallUnionCache = cached.value;
|
||||
else {
|
||||
// Opening association and physical-body extraction are structural
|
||||
// work. Do them only for a real cache miss; this method is intentionally
|
||||
// called by many room consumers in one render.
|
||||
const openCuts = this._openCuts();
|
||||
const openings = this._roomWallOpeningInputs();
|
||||
const value = wallBodiesUnionPath(
|
||||
space.rooms, walls, openCuts, openings,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
|
||||
);
|
||||
if (value) Object.defineProperty(value, 'sourceFingerprint', {
|
||||
value: contentFingerprint([this._curSpaceCfg, this._cellCm, this._gridPitch]),
|
||||
enumerable: false,
|
||||
});
|
||||
const entry = {
|
||||
key: unionKey,
|
||||
value: wallBodiesUnionPath(
|
||||
space.rooms, walls, openCuts, openings,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
|
||||
),
|
||||
value,
|
||||
};
|
||||
lruWrite(this._wallUnionPool, unionKey, entry, 8);
|
||||
this._wallUnionCache = entry;
|
||||
@@ -16499,6 +16515,19 @@ class HouseplanCard extends LitElement {
|
||||
occluders: LightSegment[]; floor: number[][][]; fingerprint: string;
|
||||
masonryGeometry: any; opaqueBodies: number[][][];
|
||||
} {
|
||||
const raw = this._curSpaceCfg;
|
||||
// Exact content identity keeps the old in-place-mutation protection while
|
||||
// moving the cache lookup ahead of passage classification, physical-body
|
||||
// booleans and wall topology. `_cfgEpoch` alone is deliberately not used:
|
||||
// old editor paths and third-party harnesses can still mutate a structure
|
||||
// before advancing it.
|
||||
const fingerprint = contentFingerprint([raw, this._cellCm, this._gridPitch]);
|
||||
const cacheKey = `${space.id}|${fingerprint}`;
|
||||
const pooled = lruRead(this._lightBarrierPool, cacheKey);
|
||||
if (pooled.hit) {
|
||||
this._lightBarrierCache = pooled.value;
|
||||
return pooled.value.value;
|
||||
}
|
||||
// Gates are door-like: their different symbol must not change how light
|
||||
// crosses the clear opening.
|
||||
const zeroWalls = this._zeroWalls();
|
||||
@@ -16552,47 +16581,27 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
}
|
||||
const lightPhysical = this._lightPhysicalBodiesCache.all;
|
||||
// Keyed by what it is made of, never by `_cfgEpoch`: geometry edited in
|
||||
// place leaves the epoch behind, and a stale barrier set is invisible —
|
||||
// the plan simply keeps lighting through a wall that now exists.
|
||||
let hash = 0x811c9dc5;
|
||||
const mix = (value: number) => {
|
||||
hash ^= Math.round((Number.isFinite(value) ? value : 0) * 64);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
};
|
||||
const walls = this._spaceWalls;
|
||||
mix(this._cellCm);
|
||||
mix(this._gridPitch);
|
||||
mix(this._wallKeyPitch);
|
||||
for (const { poly } of polys) { mix(poly.length); for (const p of poly) { mix(p[0]); mix(p[1]); } }
|
||||
for (const cut of cuts) for (const value of cut) mix(value);
|
||||
mix(zeroWalls.style === 'solid' ? 1 : 0);
|
||||
for (const barrier of zeroWalls.barriers) for (const value of barrier) mix(value);
|
||||
for (const body of lightPhysical) {
|
||||
mix(body.length);
|
||||
for (const point of body) { mix(point[0]); mix(point[1]); }
|
||||
}
|
||||
for (const wall of walls) {
|
||||
mix(wall.cm);
|
||||
mix(wall.a?.[0] ?? 0); mix(wall.a?.[1] ?? 0);
|
||||
mix(wall.b?.[0] ?? 0); mix(wall.b?.[1] ?? 0);
|
||||
}
|
||||
const fingerprint = hash.toString(36);
|
||||
const cacheKey = `${space.id}|${fingerprint}`;
|
||||
if (this._lightBarrierCache?.key === cacheKey) return this._lightBarrierCache.value;
|
||||
const eps = this._gridPitch * 0.02;
|
||||
const occluders: LightSegment[] = [];
|
||||
// The masonry the plan draws, cut by passages only — real thickness, real
|
||||
// jamb faces. Treating a wall as its centreline let light bleed half a wall
|
||||
// deep (a bright bar at every opening) and started every shadow half a wall
|
||||
// away from the corner that casts it.
|
||||
const masonry = walls.length || lightPhysical.length
|
||||
? wallBodiesGeometry(
|
||||
space.rooms, walls, openCuts,
|
||||
roomPassages,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, lightPhysical,
|
||||
)
|
||||
const sharedWallGeometry = this._wallUnionGeometry();
|
||||
const sharedFingerprint = (sharedWallGeometry as unknown as {
|
||||
sourceFingerprint?: string;
|
||||
} | null)?.sourceFingerprint;
|
||||
const recut = sharedWallGeometry && sharedFingerprint === fingerprint
|
||||
? recutWallBodiesGeometry(sharedWallGeometry, roomPassages, lightPhysical)
|
||||
: null;
|
||||
const masonry = recut || (walls.length || lightPhysical.length
|
||||
? wallBodiesGeometry(
|
||||
space.rooms, walls, openCuts,
|
||||
roomPassages,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, lightPhysical,
|
||||
)
|
||||
: null);
|
||||
if (masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')) {
|
||||
for (const component of masonry.components) {
|
||||
for (const ring of geometryAllRings(component.geom)) occluders.push(...polygonSegments(ring));
|
||||
@@ -16627,7 +16636,9 @@ class HouseplanCard extends LitElement {
|
||||
// doors, and therefore cannot be used when the boolean geometry is empty.
|
||||
opaqueBodies: lightPhysical,
|
||||
};
|
||||
this._lightBarrierCache = { key: cacheKey, value };
|
||||
const entry = { key: cacheKey, value };
|
||||
lruWrite(this._lightBarrierPool, cacheKey, entry, 8);
|
||||
this._lightBarrierCache = entry;
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
+131
-7
@@ -38,8 +38,13 @@ export interface WallBodiesGeometryResult {
|
||||
components: readonly WallGeometryComponent[];
|
||||
/** Canonical room masonry before independent bodies. */
|
||||
roomGeom: any;
|
||||
/** Uncut room-masonry components. Usually one primary component; a rare
|
||||
* boolean fallback keeps an isolated exterior shell here as well. */
|
||||
roomComponents?: readonly WallGeometryComponent[];
|
||||
paperGeom: any;
|
||||
depthUnits: number;
|
||||
/** Exact transverse slot padding used by the opening-cut pass. */
|
||||
openingPadUnits?: number;
|
||||
openingIndex: OpeningWallIndex | null;
|
||||
/** Canonical junction topology reused by per-room inner contours. */
|
||||
multiWallNodes: MultiWallNodeMap | null;
|
||||
@@ -3606,6 +3611,7 @@ export function wallBodiesGeometry(
|
||||
): WallBodiesGeometryResult {
|
||||
if (!walls?.length && !extraBodies.length) return {
|
||||
status: 'not-applicable', geom: [], components: [], roomGeom: [], paperGeom: [],
|
||||
roomComponents: [],
|
||||
depthUnits: 0, openingIndex: null, multiWallNodes: null, degradedExtraCount: 0,
|
||||
};
|
||||
const roomRings: MultiWallRoomRing[] = [];
|
||||
@@ -3774,6 +3780,14 @@ export function wallBodiesGeometry(
|
||||
body = dropDegenerateRings(body, Math.max(multiWallNodes.epsilon, 1e-9) ** 2);
|
||||
}
|
||||
const roomGeom = body || [];
|
||||
const roomComponents: WallGeometryComponent[] = [
|
||||
...(structurallyValidWallGeometry(roomGeom)
|
||||
? [{ id: 'room-primary', geom: roomGeom }]
|
||||
: []),
|
||||
...isolatedCore.map((component, index) => ({
|
||||
id: `room-isolated-${index}`, geom: component.geom,
|
||||
})),
|
||||
];
|
||||
// cut opening tunnels (axis-aligned to opening angle)
|
||||
corePhase = 'openings';
|
||||
for (const o of openings) {
|
||||
@@ -3835,14 +3849,17 @@ export function wallBodiesGeometry(
|
||||
];
|
||||
return {
|
||||
status: degradedExtraCount || degradedCoreCount ? 'degraded-extra' : 'ok',
|
||||
geom: primary, components, roomGeom, paperGeom,
|
||||
depthUnits: maxDepth, openingIndex, multiWallNodes,
|
||||
geom: primary, components, roomGeom, roomComponents, paperGeom,
|
||||
depthUnits: maxDepth,
|
||||
openingPadUnits: Math.max(maxDepth, pitch * coordScale) * 1.25,
|
||||
openingIndex, multiWallNodes,
|
||||
degradedExtraCount: degradedExtraCount + degradedCoreCount,
|
||||
};
|
||||
} catch {
|
||||
operations.onCoreFailure?.(corePhase);
|
||||
return {
|
||||
status: 'failed-core', geom: [], components: [], roomGeom: [], paperGeom: [],
|
||||
roomComponents: [],
|
||||
depthUnits: maxDepth, openingIndex: null, multiWallNodes, degradedExtraCount: 0,
|
||||
};
|
||||
}
|
||||
@@ -3867,9 +3884,12 @@ export function wallBodiesUnionPath(
|
||||
paths: readonly { id: string; d: string; fillRule: 'evenodd' }[];
|
||||
components: readonly WallGeometryComponent[];
|
||||
roomGeom: any;
|
||||
roomComponents: readonly WallGeometryComponent[];
|
||||
openingIndex: OpeningWallIndex | null;
|
||||
multiWallNodes: MultiWallNodeMap | null;
|
||||
paperD: string;
|
||||
depthUnits: number;
|
||||
openingPadUnits?: number;
|
||||
fillRule: 'evenodd' | 'nonzero';
|
||||
} | null {
|
||||
if (!walls?.length && !extraBodies.length) return null;
|
||||
@@ -3889,9 +3909,12 @@ export function wallBodiesGeometryPath(
|
||||
paths: readonly { id: string; d: string; fillRule: 'evenodd' }[];
|
||||
components: readonly WallGeometryComponent[];
|
||||
roomGeom: any;
|
||||
roomComponents: readonly WallGeometryComponent[];
|
||||
openingIndex: OpeningWallIndex | null;
|
||||
multiWallNodes: MultiWallNodeMap | null;
|
||||
paperD: string;
|
||||
depthUnits: number;
|
||||
openingPadUnits?: number;
|
||||
fillRule: 'evenodd' | 'nonzero';
|
||||
} | null {
|
||||
if (united.status === 'failed-core' || united.status === 'not-applicable') return null;
|
||||
@@ -3900,11 +3923,25 @@ export function wallBodiesGeometryPath(
|
||||
})).filter((component) => !!component.d);
|
||||
const d = paths[0]?.d || '';
|
||||
const paperD = polyclipToPathD(united.paperGeom);
|
||||
if (paths.length) return {
|
||||
status: united.status, d, paths, components: united.components,
|
||||
roomGeom: united.roomGeom, multiWallNodes: united.multiWallNodes, paperD,
|
||||
depthUnits: united.depthUnits, fillRule: 'evenodd',
|
||||
};
|
||||
if (paths.length) {
|
||||
const projected = {
|
||||
status: united.status, d, paths, components: united.components,
|
||||
roomGeom: united.roomGeom,
|
||||
multiWallNodes: united.multiWallNodes, paperD,
|
||||
depthUnits: united.depthUnits,
|
||||
fillRule: 'evenodd',
|
||||
} as unknown as NonNullable<ReturnType<typeof wallBodiesGeometryPath>>;
|
||||
// Retained topology is an internal acceleration seam, not part of the
|
||||
// enumerable SVG projection contract. Keeping it non-enumerable preserves
|
||||
// structural equality and serialization for callers that compare path
|
||||
// payloads while still allowing a second opening policy to reuse it.
|
||||
Object.defineProperties(projected, {
|
||||
roomComponents: { value: united.roomComponents || [], enumerable: false },
|
||||
openingIndex: { value: united.openingIndex, enumerable: false },
|
||||
openingPadUnits: { value: united.openingPadUnits, enumerable: false },
|
||||
});
|
||||
return projected;
|
||||
}
|
||||
// successful empty result: do not resurrect raw rings
|
||||
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
|
||||
// that creates an exterior tooth at a corner Split, so resurrecting it after
|
||||
@@ -3912,6 +3949,93 @@ export function wallBodiesGeometryPath(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recut an already-built room masonry pass for another opening policy.
|
||||
*
|
||||
* Plan drawing and light transport share the expensive room topology but not
|
||||
* their cuts: every visual opening cuts the plan, while only an interior
|
||||
* door/gate transmits light. Rebuilding all room rings, junctions and the
|
||||
* exterior envelope for that second policy dominated first paint. This helper
|
||||
* starts from the immutable uncut components retained by wallBodiesGeometry,
|
||||
* applies the alternate slots, and then merges the policy-specific independent
|
||||
* bodies. Any unexpected core failure returns null so callers can fall back to
|
||||
* a completely independent canonical pass.
|
||||
*/
|
||||
export function recutWallBodiesGeometry(
|
||||
base: Pick<WallBodiesGeometryResult,
|
||||
'status' | 'roomGeom' | 'roomComponents' | 'openingIndex' | 'depthUnits'
|
||||
| 'openingPadUnits'>,
|
||||
openings: Array<{ x: number; y: number; angle: number; length: number }> = [],
|
||||
extraBodies: number[][][] = [],
|
||||
operations: WallGeometryOperations = {},
|
||||
): { status: 'ok' | 'degraded-extra'; geom: any; components: readonly WallGeometryComponent[] } | null {
|
||||
if (base.status === 'failed-core' || base.status === 'not-applicable') return null;
|
||||
const retained = base.roomComponents?.length
|
||||
? base.roomComponents
|
||||
: (structurallyValidWallGeometry(base.roomGeom)
|
||||
? [{ id: 'room-primary', geom: base.roomGeom }]
|
||||
: []);
|
||||
let body: any = retained[0]?.geom || null;
|
||||
const isolatedCore: WallGeometryComponent[] = retained.slice(1)
|
||||
.map((component) => ({ ...component }));
|
||||
try {
|
||||
for (const opening of openings) {
|
||||
if (!(opening.length > 0) || !base.openingIndex) continue;
|
||||
const association = resolveOpeningWallAssociation(base.openingIndex, opening, true);
|
||||
if (!association.negative && !association.positive) continue;
|
||||
const rad = (opening.angle * Math.PI) / 180;
|
||||
const ux = Math.cos(rad), uy = Math.sin(rad);
|
||||
const nx = -uy, ny = ux;
|
||||
const half = opening.length / 2;
|
||||
const pad = base.openingPadUnits ?? Math.max(base.depthUnits, 1) * 1.25;
|
||||
const slot = closedRing([
|
||||
[opening.x - ux * half - nx * pad, opening.y - uy * half - ny * pad],
|
||||
[opening.x + ux * half - nx * pad, opening.y + uy * half - ny * pad],
|
||||
[opening.x + ux * half + nx * pad, opening.y + uy * half + ny * pad],
|
||||
[opening.x - ux * half + nx * pad, opening.y - uy * half + ny * pad],
|
||||
]) as any;
|
||||
if (body) body = difference(body, slot);
|
||||
for (const component of isolatedCore) component.geom = difference(component.geom, slot);
|
||||
}
|
||||
const isolated: WallGeometryComponent[] = [...isolatedCore];
|
||||
let degradedExtraCount = isolatedCore.length;
|
||||
const mergeExtra = operations.mergeExtra
|
||||
|| ((primary: any, extra: any) => primary ? union(primary, extra) : extra);
|
||||
for (let index = 0; index < extraBodies.length; index++) {
|
||||
const extra = extraBodies[index];
|
||||
if (extra.length < 3 || !extra.every((point) => point.length >= 2
|
||||
&& Number.isFinite(point[0]) && Number.isFinite(point[1]))
|
||||
|| Math.abs(polygonArea(extra)) <= 1e-9) {
|
||||
degradedExtraCount++;
|
||||
continue;
|
||||
}
|
||||
const standalone: any = [closedRing(extra)];
|
||||
try {
|
||||
const merged = mergeExtra(body, standalone, index);
|
||||
if (!structurallyValidWallGeometry(merged)) throw new Error('invalid extra union');
|
||||
body = merged;
|
||||
} catch {
|
||||
degradedExtraCount++;
|
||||
if (structurallyValidWallGeometry(standalone)) {
|
||||
isolated.push({ id: `policy-extra-${index}`, geom: standalone });
|
||||
}
|
||||
}
|
||||
}
|
||||
isolated.sort((left, right) => polyclipToPathD(left.geom).localeCompare(polyclipToPathD(right.geom)));
|
||||
const primary = body || [];
|
||||
return {
|
||||
status: degradedExtraCount ? 'degraded-extra' : 'ok',
|
||||
geom: primary,
|
||||
components: [
|
||||
...(structurallyValidWallGeometry(primary) ? [{ id: 'primary', geom: primary }] : []),
|
||||
...isolated.map((component, index) => ({ ...component, id: `isolated-${index}` })),
|
||||
],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-edge wall quads for styling hooks and thick-cut suppression — one body
|
||||
* per unique wall key. Shared and outer walls both grow ±½ from the
|
||||
|
||||
@@ -764,11 +764,11 @@ test('a light source paints exactly one region: the floor it can see', () => {
|
||||
// Barriers are keyed by their own content: `_cfgEpoch` lags behind geometry
|
||||
// edited in place, and a stale barrier set lights straight through a wall.
|
||||
assert.doesNotMatch(glow, /_cfgEpoch/);
|
||||
assert.match(source, /const fingerprint = hash\.toString\(36\)/);
|
||||
assert.match(source, /for \(const point of body\) \{ mix\(point\[0\]\); mix\(point\[1\]\); \}/);
|
||||
assert.match(source, /mix\(wall\.b\?\.\[0\] \?\? 0\); mix\(wall\.b\?\.\[1\] \?\? 0\)/);
|
||||
assert.match(source, /const fingerprint = contentFingerprint\(\[/);
|
||||
assert.match(source, /contentFingerprint\(\[raw, this\._cellCm, this\._gridPitch\]\)/);
|
||||
assert.match(source, /const cacheKey = `\$\{space\.id\}\|\$\{fingerprint\}`/);
|
||||
assert.match(source, /mix\(this\._cellCm\)[\s\S]*mix\(this\._gridPitch\)/);
|
||||
assert.match(source, /sharedFingerprint === fingerprint/,
|
||||
'an in-place edit must never recut stale shared masonry');
|
||||
});
|
||||
|
||||
test('all destructive editor dialogs use the medium shell and shared responsive footer groups', () => {
|
||||
|
||||
@@ -111,3 +111,31 @@ test('room inner faces are structurally cached and shared by both fill layers',
|
||||
assert.equal([...viewRooms.matchAll(/innerContourForRoom\(/g)].length, 0);
|
||||
assert.match(viewRooms, /this\._innerRoomContour\(space, r\.id, allZeroCuts, roomWallGeometry\)/);
|
||||
});
|
||||
|
||||
test('wall and light geometry reuse bounded caches before structural work', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
|
||||
const unionStart = source.indexOf('private _wallUnionGeometry()');
|
||||
const unionEnd = source.indexOf('\n /** Thick-wall spans', unionStart);
|
||||
assert.ok(unionStart >= 0 && unionEnd > unionStart, 'wall-union helper is present');
|
||||
const union = source.slice(unionStart, unionEnd);
|
||||
assert.ok(
|
||||
union.indexOf('lruRead(this._wallUnionPool, unionKey)')
|
||||
< union.indexOf('const openCuts = this._openCuts();'),
|
||||
'a cached wall union must avoid resolving openings',
|
||||
);
|
||||
assert.match(union, /lruWrite\(this\._wallUnionPool, unionKey, entry, 8\)/);
|
||||
|
||||
const lightStart = source.indexOf('private _lightBarriers(');
|
||||
const lightEnd = source.indexOf('\n /** Light pools', lightStart);
|
||||
assert.ok(lightStart >= 0 && lightEnd > lightStart, 'light-barrier helper is present');
|
||||
const light = source.slice(lightStart, lightEnd);
|
||||
assert.ok(
|
||||
light.indexOf('lruRead(this._lightBarrierPool, cacheKey)')
|
||||
< light.indexOf('const zeroWalls = this._zeroWalls();'),
|
||||
'a cached light barrier must return before geometry classification',
|
||||
);
|
||||
assert.match(light, /contentFingerprint\(\[/);
|
||||
assert.match(light, /recutWallBodiesGeometry\(sharedWallGeometry, roomPassages, lightPhysical\)/);
|
||||
assert.match(light, /lruWrite\(this\._lightBarrierPool, cacheKey, entry, 8\)/);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
drawWallPreviewD, linearWallBody, linearWallJoinPatches,
|
||||
DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
|
||||
wallCmToUnits, insetContour, outsetContour, inwardNormal, edgeKinds, wallEdgeBodies,
|
||||
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
|
||||
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, recutWallBodiesGeometry,
|
||||
floorFootprintGeometry,
|
||||
virtualJunctionPatches, stableJunctionPatch, unionJunctionPatches,
|
||||
innerContourForRoom, innerEdgeSpan, ownEdgeOffsets,
|
||||
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT, VISUAL_MITRE_LIMIT,
|
||||
@@ -138,6 +139,36 @@ test('#278 rejects degraded extras in strict preflight while render geometry rem
|
||||
assert.equal(result.roomGeom.length, 0, 'independent bodies never enter room area masonry');
|
||||
});
|
||||
|
||||
test('alternate opening policy recuts the retained room masonry exactly', () => {
|
||||
const rooms = [
|
||||
{ id: 'left', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] },
|
||||
{ id: 'right', poly: [[100, 0], [200, 0], [200, 100], [100, 100]] },
|
||||
];
|
||||
const unique = new Map();
|
||||
for (const room of rooms) for (let index = 0; index < room.poly.length; index++) {
|
||||
const a = room.poly[index], b = room.poly[(index + 1) % room.poly.length];
|
||||
const key = wallKey(a, b, 1);
|
||||
if (!unique.has(key)) unique.set(key, { key, a, b, cm: 20 });
|
||||
}
|
||||
const walls = [...unique.values()];
|
||||
const interiorDoor = { x: 100, y: 50, angle: 90, length: 30 };
|
||||
const exteriorWindow = { x: 50, y: 0, angle: 0, length: 24 };
|
||||
const extras = [[[130, 35], [150, 35], [150, 45], [130, 45]]];
|
||||
const visual = wallBodiesGeometry(
|
||||
rooms, walls, [], [interiorDoor, exteriorWindow], 1, 5, 5, 1, extras,
|
||||
);
|
||||
const directLight = wallBodiesGeometry(
|
||||
rooms, walls, [], [interiorDoor], 1, 5, 5, 1, extras,
|
||||
);
|
||||
const recutLight = recutWallBodiesGeometry(visual, [interiorDoor], extras);
|
||||
assert.equal(visual.status, 'ok');
|
||||
assert.ok(visual.roomComponents?.length, 'uncut room masonry is retained');
|
||||
assert.equal(directLight.status, 'ok');
|
||||
assert.equal(recutLight?.status, 'ok');
|
||||
closeTo(geometryDifferenceArea(directLight.geom, recutLight.geom), 0, 1e-6);
|
||||
closeTo(geometryDifferenceArea(recutLight.geom, directLight.geom), 0, 1e-6);
|
||||
});
|
||||
|
||||
const enclosedLocalHoleRings = (geometry, node) => {
|
||||
const radius = MITRE_LIMIT * node.halfDepth + 1e-6;
|
||||
return (geometry || []).flatMap((polygon) => (polygon || []).slice(1)).filter((ring) =>
|
||||
|
||||
Reference in New Issue
Block a user