mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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));
|
||||
+489
-489
File diff suppressed because one or more lines are too long
Vendored
+489
-489
File diff suppressed because one or more lines are too long
@@ -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)).
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Исправлено исчезновение Glow во всём пространстве из-за безвредных хвостов
|
||||
вычислений с плавающей точкой или одного некорректного контура комнаты.
|
||||
Входные данные для геометрических операций теперь стабилизируются только при
|
||||
отрисовке, а остаточный сбой изолируется в проблемной комнате и не выпускает
|
||||
свет за пределы корректного пола
|
||||
([#218](https://github.com/Matysh/houseplan-card/issues/218)).
|
||||
- Исправлена внешняя обводка текстовых маркеров устройств: она снова повторяет
|
||||
капсульную форму значения и дизайнерского макета вместо овала
|
||||
([#217](https://github.com/Matysh/houseplan-card/issues/217)).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]],
|
||||
|
||||
Reference in New Issue
Block a user