perf: reuse wall topology for light transport

Issue: #322
User-Visible: no
This commit is contained in:
Matysh
2026-08-27 15:00:30 +03:00
parent 674607694f
commit 780b7a6c7a
9 changed files with 271 additions and 77 deletions
File diff suppressed because one or more lines are too long
+6 -6
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": "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"
}
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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', () => {
+28
View File
@@ -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\)/);
});
+32 -1
View File
@@ -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) =>