fix: keep Glow visible with noisy floor geometry

Stabilize polygon-boolean inputs at render time and isolate residual
room failures without weakening fail-dark clipping.

Issue: #218
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-20 14:05:22 +03:00
parent 4c512fa59f
commit 3d11758e2b
14 changed files with 1922 additions and 1497 deletions
File diff suppressed because one or more lines are too long
+133
View File
@@ -0,0 +1,133 @@
/** Issue #218: floating-point room noise cannot darken a whole space. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 760 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const warnings = [];
const originalWarn = console.warn;
console.warn = (...parts) => {
warnings.push(parts.map(String).join(' '));
originalWarn(...parts);
};
const spaceId = card._space;
const lightDevice = card._devices.find((device) => device.space === spaceId
&& device.entities.some((entityId) => entityId.startsWith('light.')));
const lightEntity = lightDevice?.entities.find((entityId) => entityId.startsWith('light.'));
if (!lightDevice || !lightEntity) {
console.warn = originalWarn;
return {
fixtureHasLight: false,
ulpFloorKeepsGlow: false,
ulpNoiseNeedsNoFallback: false,
glowBaseKeepsAllRooms: false,
malformedRoomKeepsHealthyGlow: false,
fallbackWarningDeduplicated: false,
fallbackWarningIsRedacted: false,
};
}
result.fixtureHasLight = true;
const lightState = card.hass.states[lightEntity];
card.hass = {
...card.hass,
states: {
...card.hass.states,
[lightEntity]: { ...lightState, state: 'on' },
},
};
const resetGeometryCaches = () => {
card._cfgEpoch++;
card._modelCache = null;
card._physicalBodiesCache = null;
card._cleanFloorCache.clear();
card._lightPhysicalBodiesCache = null;
card._lightBarrierCache = null;
card._renderDeviceSnapshot = null;
card._glowClipCache.clear();
};
const update = async () => {
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 80));
await card.updateComplete;
};
const replaceRooms = async (rooms, sourcePoint) => {
card._serverCfg = {
...card._serverCfg,
spaces: card._serverCfg.spaces.map((space) => space.id !== spaceId ? space : ({
...space,
rooms,
settings: { ...(space.settings || {}), fill_mode: 'glow' },
walls: [], openings: [], partitions: [], room_drafts: [], wall_columns: [],
})),
};
card._layout = {
...card._layout,
[lightDevice.id]: { s: spaceId, x: sourcePoint[0] / 1000, y: sourcePoint[1] / 1000 },
};
resetGeometryCaches();
await update();
};
const room = (id, poly) => ({
id, name: `Private name ${id}`, area: null,
// The model projects config coordinates into the 1000-unit render canvas.
poly: poly.map(([x, y]) => [x / 1000, y / 1000]),
});
const noisyFloor = [
[[0.46666666666666673, 0.7083333333333334], [0.6125, 0.9],
[0.4666666666666667, 1], [0.46666666666666673, 0.9]],
[[0.1625, 0.3], [0.3458333333333333, 0],
[0.46666666666666673, 1], [0.3458333333333333, 1]],
[[0.7, 0], [0.8, 0], [0.8, 0.7083333333333334], [0.7, 0.7083333333333334]],
[[0.7, 0.7083333333333335], [0.8, 0.7083333333333335], [0.8, 1], [0.7, 1]],
[[0.85, 0], [0.9, 0], [0.9, 0.4], [0.85, 0.4]],
[[0.85, 0.5], [0.9, 0.5], [0.9, 1], [0.85, 1]],
];
await replaceRooms(noisyFloor.map((poly, index) => room(`ulp-${index}`, poly)), [0.36, 0.5]);
const ulpPath = root().querySelector('clipPath[id^="hp-glowclip"] path.glow-lit');
result.ulpFloorKeepsGlow = !!root().querySelector('.glow-pool')
&& !!ulpPath?.getAttribute('d')
&& Number(root().querySelector('.glow-pool')?.dataset.litParts) > 0;
result.ulpNoiseNeedsNoFallback = warnings.length === 0;
result.glowBaseKeepsAllRooms = root().querySelectorAll('.glow-base-layer .glow-base').length
=== noisyFloor.length;
const healthy = [[3, 0], [5, 0], [5, 2], [3, 2]];
const malformed = [[2, 1], [0, 0], [2, 2], [1, 0], [0, 2], [2, 0]];
await replaceRooms([
room('healthy-room', healthy),
room('broken-room', malformed),
], [4, 1]);
const fallbackPath = root().querySelector('clipPath[id^="hp-glowclip"] path.glow-lit');
result.malformedRoomKeepsHealthyGlow = !!root().querySelector('.glow-pool')
&& !!fallbackPath?.getAttribute('d')
&& fallbackPath.getAttribute('d').includes('3 0')
&& fallbackPath.getAttribute('d').includes('5 2');
// Force the pure calculation a second time with the same fingerprint. The
// diagnostic must remain one record, even across sources/renders.
card._glowClipCache.clear();
await update();
const fallbackWarnings = warnings.filter((line) =>
line.startsWith('HOUSEPLAN GLOW GEOMETRY FALLBACK: #218'));
result.fallbackWarningDeduplicated = fallbackWarnings.length === 1
&& fallbackWarnings[0].includes(`space ${spaceId}`)
&& fallbackWarnings[0].includes('room broken-room')
&& card._glowGeometryWarnings.size === 1;
result.fallbackWarningIsRedacted = fallbackWarnings.length === 1
&& !fallbackWarnings[0].includes('Private name')
&& !fallbackWarnings[0].includes(lightEntity)
&& !fallbackWarnings[0].includes('Unable to complete')
&& !/\[\s*-?\d/.test(fallbackWarnings[0]);
console.warn = originalWarn;
return result;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+489 -489
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Fixed Glow disappearing from an entire space when harmless floating-point
tails or one malformed room outline upset floor clipping. Boolean inputs are
now stabilised at render time, and a residual failure is isolated to the
affected room without allowing light outside valid floors
([#218](https://github.com/Matysh/houseplan-card/issues/218)).
- Fixed Text device markers whose outer outline became an ellipse instead of
matching the capsule-shaped value core and designer reference
([#217](https://github.com/Matysh/houseplan-card/issues/217)).
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Исправлено исчезновение Glow во всём пространстве из-за безвредных хвостов
вычислений с плавающей точкой или одного некорректного контура комнаты.
Входные данные для геометрических операций теперь стабилизируются только при
отрисовке, а остаточный сбой изолируется в проблемной комнате и не выпускает
свет за пределы корректного пола
([#218](https://github.com/Matysh/houseplan-card/issues/218)).
- Исправлена внешняя обводка текстовых маркеров устройств: она снова повторяет
капсульную форму значения и дизайнерского макета вместо овала
([#217](https://github.com/Matysh/houseplan-card/issues/217)).
+8
View File
@@ -82,6 +82,14 @@ emit Glow again ([#92](https://github.com/Matysh/houseplan-card/issues/92)).
3. **Intersect with the floor** (`intersectionPaths`): light lands on rooms, not
on the space around the house.
Room and fan coordinates cross the polygon-boolean boundary through a
`1e-6` render-unit numeric grid. This removes sub-pixel arithmetic tails
without rewriting saved plan geometry. If the combined floor still cannot be
processed, clipping retries room by room: a failed room stays dark, every
healthy room keeps its visible light, and the card emits one redacted warning
per space-geometry revision and room. Returning the un-clipped visibility fan
is never a fallback ([#218](https://github.com/Matysh/houseplan-card/issues/218)).
The result is ONE `clipPath` for ONE `<circle>` filled with the source's radial
gradient. A shadow is simply floor that is not in that region.
+12
View File
@@ -1156,6 +1156,18 @@ separately promised workflows:
[auto: smoke_glow, smoke_openwall;
unit: light-visibility, golden-matrix;
golden: lighting-opaque-glow-two-doorways-dark]
- [ ] Glow floor resilience (#218): a six-room floor containing the captured
one-ULP shared-coordinate tails still produces a complete Glow clip and
preserves every Glow-base room without mutating stored outlines. If one
room is deterministically malformed, healthy rooms remain lit, the bad
room is skipped, overlapping healthy rooms are geometrically united, and
an all-invalid floor remains dark rather than exposing the raw visibility
fan. Repeated renders emit exactly one warning for the affected
space/revision/room; it contains no coordinates, room names, entity IDs or
exception text [unit: physical-geometry, golden-matrix; auto:
`node demo/smoke_glow_geometry_resilience.mjs`; mutation:
union-quantization-removed, union-failure-kills-space,
union-failure-silent, glow-fail-dark-weakened].
- [ ] Per-source glow radius (v1.36.2): the device dialog has a "Glow radius"
field (HA units; empty = general-settings default shown as placeholder);
an override changes that source's visibility-clipped pool only [auto: smoke_glow]
+11 -11
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceFingerprint": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceSha256": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceSha256": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceSha256": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceSha256": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"imageSha256": "2f13ea645af306eee9cc7c52699c928377b72f412b08c1a3db6beb848ace1b51"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceSha256": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
},
"device-editor": {
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceSha256": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
},
"device-display-preview": {
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceSha256": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceSha256": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceSha256": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"sourceSha256": "54e06c66d6c97a941c5b5850da6b71f433ec2f93e75100363c0c943c06e56fce",
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
}
}
+53
View File
@@ -40,6 +40,59 @@ import { fileURLToPath } from 'node:url';
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
{
id: 'union-quantization-removed',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="boolean input normalization|six-room ULP" '
+ 'test/physical-geometry.test.mjs',
because: 'ordinary split/merge double tails must collapse at the shared boolean boundary '
+ 'instead of making one room erase every Glow clip in its space',
patches: [{
file: 'src/physical-geometry.ts',
find: ' const qx = Math.round(x / step) * step;\n'
+ ' const qy = Math.round(y / step) * step;',
replace: ' const qx = x;\n const qy = y;',
}],
},
{
id: 'union-failure-kills-space',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="malformed room|fallback unions overlapping" '
+ 'test/physical-geometry.test.mjs',
because: 'one room rejected by polyclip must be skipped locally while every healthy floor '
+ 'fragment remains clipped and visible rather than failing the whole space dark',
patches: [{
file: 'src/physical-geometry.ts',
find: ' return intersectionPathsByBound(base, bounds, options);',
replace: ' return [];',
}],
},
{
id: 'union-failure-silent',
guard: 'node demo/smoke_glow_geometry_resilience.mjs',
because: 'a residual per-room geometry fallback must leave one redacted, deduplicated '
+ 'space/room diagnostic instead of silently looking like a broken Glow setting',
patches: [{
file: 'src/houseplan-card.ts',
find: ' console.warn(\n'
+ ' `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,\n'
+ ' );',
replace: ' if (false) console.warn(\n'
+ ' `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,\n'
+ ' );',
}],
},
{
id: 'glow-fail-dark-weakened',
guard: 'node demo/smoke_glow_fail_dark.mjs',
because: 'resilient floor clipping must not revive a source embedded in opaque masonry; '
+ 'the existing source guard remains a release-blocking fail-dark boundary',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (pointInOpaquePlanBody(sourcePoint, masonryGeometry, opaqueBodies)) {',
replace: ' if (false && pointInOpaquePlanBody(sourcePoint, masonryGeometry, opaqueBodies)) {',
}],
},
{
id: 'optimizer-micro-interval-cleanup-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+29 -1
View File
@@ -1352,6 +1352,8 @@ class HouseplanCard extends LitElement {
floor: number[][]; geom: any; path: string; area: number;
}>();
private _glowClipCache = new Map<string, GlowClipGeometry | null>();
/** Redacted, bounded dedupe for numerical floor fallbacks (#218). */
private _glowGeometryWarnings = new Set<string>();
private _lightBarrierCache: {
key: string;
value: {
@@ -14863,6 +14865,23 @@ class HouseplanCard extends LitElement {
}
}
private _warnGlowGeometryFallback(
spaceId: string, fingerprint: string, roomId: string, phase: string,
): void {
// One diagnostic per geometry revision and room, even if that room happens
// to fail in more than one boolean phase while the card is re-rendering.
const key = `${spaceId}|${fingerprint}|${roomId}`;
if (this._glowGeometryWarnings.has(key)) return;
if (this._glowGeometryWarnings.size >= 128) {
const oldest = this._glowGeometryWarnings.values().next().value;
if (oldest) this._glowGeometryWarnings.delete(oldest);
}
this._glowGeometryWarnings.add(key);
console.warn(
`HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,
);
}
/**
* Everything in a space that stops light, and the floor light may land on.
*
@@ -15098,7 +15117,16 @@ class HouseplanCard extends LitElement {
// reason a column does.
const seen = visibilityPolygon([pos.x, pos.y], R, occluders, GLOW_ARC_STEPS);
geometry = {
lit: seen.length >= 3 ? intersectionPaths([seen], floor) : [],
lit: seen.length >= 3
? intersectionPaths([seen], floor, {
onBoundsFailure: ({ boundIndex, phase }) => {
const room = polys[boundIndex]?.r;
this._warnGlowGeometryFallback(
space.id, fingerprint, room?.id || `#${boundIndex}`, phase,
);
},
})
: [],
};
lruWrite(this._glowClipCache, clipKey, geometry, 256);
}
+103 -15
View File
@@ -11,6 +11,9 @@ import type {
export const COLUMN_MIN_CM = 1;
export const COLUMN_MAX_CM = 150;
/** Boolean operations work far below visible/physical plan precision, but raw
* double tails from split/merge/resize must describe the same shared vertex. */
export const BOOLEAN_COORD_QUANTUM = 1e-6;
export function clampColumnCm(cm: number): number {
if (!Number.isFinite(cm)) return COLUMN_MIN_CM;
@@ -30,6 +33,39 @@ const closedRing = (poly: number[][]): number[][][] => {
return [ring];
};
const samePoint = (a: number[], b: number[]): boolean =>
a[0] === b[0] && a[1] === b[1];
/**
* Copy one open outline into the numeric domain used by polyclip.
*
* Saved geometry stays untouched. Normalising only at the boolean boundary
* collapses arithmetic tails without turning the drawing grid into a storage
* migration or changing what a later editor save writes.
*/
export function normalizeBooleanBody(
body: number[][], quantum = BOOLEAN_COORD_QUANTUM,
): number[][] | null {
const step = Number.isFinite(quantum) && quantum > 0
? quantum
: BOOLEAN_COORD_QUANTUM;
const stable: number[][] = [];
for (const raw of body || []) {
if (!Array.isArray(raw) || raw.length < 2) return null;
const x = Number(raw[0]), y = Number(raw[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
const qx = Math.round(x / step) * step;
const qy = Math.round(y / step) * step;
if (!Number.isFinite(qx) || !Number.isFinite(qy)) return null;
const point = [Object.is(qx, -0) ? 0 : qx, Object.is(qy, -0) ? 0 : qy];
if (!stable.length || !samePoint(stable[stable.length - 1], point)) stable.push(point);
}
if (stable.length > 1 && samePoint(stable[0], stable[stable.length - 1])) stable.pop();
if (stable.length < 3) return null;
if (new Set(stable.map((point) => `${point[0]},${point[1]}`)).size < 3) return null;
return polygonArea(stable) > step * step ? stable : null;
}
export function polyclipPathD(geom: any): string {
const out: string[] = [];
for (const poly of geom || []) for (const ring of poly || []) {
@@ -223,7 +259,10 @@ export function physicalBodySet(
export function unionBodies(bodies: number[][][]): any | null {
try {
const polygons = bodies.filter((body) => body.length >= 3).map((body) => closedRing(body));
const polygons = bodies
.map((body) => normalizeBooleanBody(body))
.filter((body): body is number[][] => !!body)
.map((body) => closedRing(body));
return polygons.length ? union(polygons[0] as any, ...polygons.slice(1) as any[]) : null;
} catch {
return null;
@@ -257,21 +296,70 @@ export function geometryPolygonPaths(geom: any): string[] {
return out;
}
/** `polygons` clipped to `bounds`, as disjoint paths. Empty when they miss. */
export function intersectionPaths(polygons: number[][][], bounds: number[][][]): string[] {
const base = unionBodies(polygons.filter((poly) => poly.length >= 3));
const limit = unionBodies(bounds.filter((poly) => poly.length >= 3));
if (!base) return [];
if (!limit) return [];
try {
return geometryPolygonPaths(intersection(base, limit));
} catch {
// The un-clipped visibility fan may cover the backdrop and the area
// outside the house. A boolean failure must therefore fail dark: returning
// `base` here turns a numerical polyclip exception into a light leak and
// then persists it in the per-source clip cache.
return [];
export interface IntersectionBoundsFailure {
boundIndex: number;
phase: 'bound-union' | 'bound-intersection' | 'result-union';
}
export interface IntersectionPathsOptions {
onBoundsFailure?: (failure: IntersectionBoundsFailure) => void;
}
/** A failed all-floor operation degrades one room at a time, never to a raw fan. */
function intersectionPathsByBound(
base: any, bounds: number[][][], options: IntersectionPathsOptions,
): string[] {
let combined: any = null;
for (let i = 0; i < bounds.length; i++) {
const limit = unionBodies([bounds[i]]);
if (!limit) {
options.onBoundsFailure?.({ boundIndex: i, phase: 'bound-union' });
continue;
}
let clipped: any;
try {
clipped = intersection(base, limit);
} catch {
options.onBoundsFailure?.({ boundIndex: i, phase: 'bound-intersection' });
continue;
}
if (!clipped?.length || geometryArea(clipped) <= BOOLEAN_COORD_QUANTUM ** 2) continue;
if (!combined) {
combined = clipped;
continue;
}
try {
// Keep the normal merged-floor semantics for overlapping legacy rooms.
// Concatenating overlapping fragments into one evenodd path would punch
// a transparent hole through their overlap.
combined = union(combined, clipped);
} catch {
options.onBoundsFailure?.({ boundIndex: i, phase: 'result-union' });
}
}
return combined ? geometryPolygonPaths(combined) : [];
}
/** `polygons` clipped to `bounds`, as disjoint paths. Empty when they miss. */
export function intersectionPaths(
polygons: number[][][], bounds: number[][][], options: IntersectionPathsOptions = {},
): string[] {
const base = unionBodies(polygons.filter((poly) => poly.length >= 3));
// Do not let unionBodies' generic "skip an unusable member" behaviour hide
// a broken room. A rejected ring must enter the room-local fallback so the
// healthy rooms remain visible and the caller can identify the failed one.
const hasRejectedBound = bounds.some((bound) => !normalizeBooleanBody(bound));
const limit = hasRejectedBound ? null : unionBodies(bounds);
if (!base) return [];
if (limit) {
try {
return geometryPolygonPaths(intersection(base, limit));
} catch {
// Continue with the same floor one room at a time. The un-clipped fan may
// cover the backdrop, so returning `base` is never a legal fallback.
}
}
return intersectionPathsByBound(base, bounds, options);
}
export function physicalBodiesPath(bodies: number[][][]): string {
+4 -1
View File
@@ -429,7 +429,10 @@ test('a light source paints exactly one region: the floor it can see', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
// One region per source, and it is the visibility polygon clipped to floor.
assert.match(source, /visibilityPolygon\(\[pos\.x, pos\.y\], R, occluders/);
assert.match(source, /lit: seen\.length >= 3 \? intersectionPaths\(\[seen\], floor\) : \[\]/);
assert.match(source,
/lit: seen\.length >= 3\s*\? intersectionPaths\(\[seen\], floor, \{/);
assert.match(source, /onBoundsFailure: \(\{ boundIndex, phase \}\) =>/,
'room-local boolean failures must be observable without restoring an unclipped fan');
assert.match(source, /<circle class="glow-pool"/);
// No second layer of LIGHT may come back: a spill path or a shadow mask of
// its own is how the rendered result and the computed geometry got to
+91 -2
View File
@@ -1,10 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
canonicalColumnAngle, columnBody, floorMinusBodies, geometryArea,
BOOLEAN_COORD_QUANTUM, canonicalColumnAngle, columnBody, floorMinusBodies, geometryArea,
directionalOccluders, intersectionPaths, partitionBody, pointInPhysicalBody,
physicalBodyParts, physicalBodySet, pointInOpaquePlanBody, pointInPhysicalGeometry,
sameColumnPlacement,
normalizeBooleanBody, sameColumnPlacement, unionBodies,
} from '../test-build/physical-geometry.js';
import {
polygonSegments, splitAtIntersections, visibilityPolygon,
@@ -13,6 +13,20 @@ import {
const closeTo = (got, want, tol = 1e-6) =>
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
// Privacy-minimised topology from the six-room #218 failure. The two relevant
// stored double tails are preserved; names, ids, entities and unrelated plan
// geometry are deliberately absent.
const noisySixRoomFloor = [
[[0.46666666666666673, 0.7083333333333334], [0.6125, 0.9],
[0.4666666666666667, 1], [0.46666666666666673, 0.9]],
[[0.1625, 0.3], [0.3458333333333333, 0],
[0.46666666666666673, 1], [0.3458333333333333, 1]],
[[0.7, 0], [0.8, 0], [0.8, 0.7083333333333334], [0.7, 0.7083333333333334]],
[[0.7, 0.7083333333333335], [0.8, 0.7083333333333335], [0.8, 1], [0.7, 1]],
[[0.85, 0], [0.9, 0], [0.9, 0.4], [0.85, 0.4]],
[[0.85, 0.5], [0.9, 0.5], [0.9, 1], [0.85, 1]],
];
test('partition body keeps the centreline and requested physical width', () => {
const body = partitionBody([0, 0], [1, 0], 10, 5, 0.25);
assert.ok(body);
@@ -111,6 +125,81 @@ test('physical bodies are removed from clean floor area', () => {
closeTo(geometryArea(floorMinusBodies(floor, [obstacle])), 3);
});
test('boolean input normalization collapses ULP tails without mutating saved outlines', () => {
const body = [
[-0, 0],
[0.46666666666666673, 0],
[0.4666666666666667, 0],
[0.4666666666666667, 1],
[0, 1],
[-0, 0],
];
const before = structuredClone(body);
const stable = normalizeBooleanBody(body);
assert.deepEqual(body, before, 'the persisted/input outline stays byte-for-byte untouched');
assert.deepEqual(stable, [[0, 0], [0.466667, 0], [0.466667, 1], [0, 1]]);
assert.equal(Object.is(stable[0][0], -0), false, 'negative zero is canonicalised');
assert.equal(BOOLEAN_COORD_QUANTUM, 1e-6);
assert.equal(normalizeBooleanBody([[0, 0], [1e-12, 0], [0, 1e-12]]), null,
'a ring collapsed by the boolean quantum never reaches polyclip');
});
test('six-room ULP topology keeps a complete visible floor and is permutation-stable', () => {
const before = structuredClone(noisySixRoomFloor);
const fan = [[-0.1, -0.1], [1.1, -0.1], [1.1, 1.1], [-0.1, 1.1]];
const failures = [];
const paths = intersectionPaths([fan], noisySixRoomFloor, {
onBoundsFailure: (failure) => failures.push(failure),
});
assert.ok(paths.length > 0, 'the real ULP topology produces a non-empty Glow clip');
assert.deepEqual(failures, [], 'normal arithmetic noise is repaired before fallback');
assert.deepEqual(noisySixRoomFloor, before, 'render-time stabilisation never rewrites room data');
const direct = unionBodies(noisySixRoomFloor);
const reversed = unionBodies([...noisySixRoomFloor].reverse());
assert.ok(direct && reversed);
closeTo(geometryArea(direct), geometryArea(reversed), BOOLEAN_COORD_QUANTUM ** 2);
closeTo(geometryArea(direct), 0.31835083680549986, 1e-9);
});
test('one malformed room is diagnosed and cannot erase healthy lit floor', () => {
const fan = [[-1, -1], [6, -1], [6, 3], [-1, 3]];
const healthy = [[3, 0], [5, 0], [5, 2], [3, 2]];
// Deterministic polyclip failure: individually invalid rather than merely a
// bow-tie, which polyclip legally resolves into two triangles.
const malformed = [[2, 1], [0, 0], [2, 2], [1, 0], [0, 2], [2, 0]];
const failures = [];
const paths = intersectionPaths([fan], [healthy, malformed], {
onBoundsFailure: (failure) => failures.push(failure),
});
assert.deepEqual(paths, ['M 3 0 L 5 0 L 5 2 L 3 2 Z']);
assert.deepEqual(failures, [{ boundIndex: 1, phase: 'bound-union' }]);
assert.deepEqual(intersectionPaths([fan], [malformed]), [],
'when every room fails there is no raw-fan light leak');
const collapsed = [[0, 0], [1e-12, 0], [0, 1e-12]];
const collapsedFailures = [];
assert.deepEqual(intersectionPaths([fan], [healthy, collapsed], {
onBoundsFailure: (failure) => collapsedFailures.push(failure),
}), ['M 3 0 L 5 0 L 5 2 L 3 2 Z']);
assert.deepEqual(collapsedFailures, [{ boundIndex: 1, phase: 'bound-union' }],
'a ring rejected during normalisation is observable through the same fallback');
});
test('fallback unions overlapping healthy rooms instead of making an evenodd hole', () => {
const fan = [[-1, -1], [16, -1], [16, 3], [-1, 3]];
const malformed = [[12, 1], [10, 0], [12, 2], [11, 0], [10, 2], [12, 0]];
const left = [[0, 0], [3, 0], [3, 2], [0, 2]];
const right = [[2, 0], [5, 0], [5, 2], [2, 2]];
const failures = [];
const paths = intersectionPaths([fan], [malformed, left, right], {
onBoundsFailure: (failure) => failures.push(failure),
});
assert.equal(paths.length, 1, 'overlap is geometrically united, not concatenated as evenodd');
assert.match(paths[0], /M 0 0 L 5 0 L 5 2 L 0 2 Z/);
assert.deepEqual(failures, [{ boundIndex: 0, phase: 'bound-union' }]);
});
test('intersection failure is fail-dark and never returns the unclipped fan', () => {
const rooms = [
[[100, 70], [160, 70], [160, 120], [100, 120]],