Fix Glow fail-dark source guard

Issue: #187
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 04:09:13 +03:00
parent fe15d863ce
commit c8755b7c51
8 changed files with 148 additions and 13 deletions
File diff suppressed because one or more lines are too long
+105
View File
@@ -0,0 +1,105 @@
/** Issue #187: Glow source fallback uses light-policy bodies when boolean masonry fails. */
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 update = async () => {
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 60));
await card.updateComplete;
};
const pointIn = (point, body) => {
let inside = false;
for (let i = 0, j = body.length - 1; i < body.length; j = i++) {
const a = body[i], b = body[j];
if (((a[1] > point[1]) !== (b[1] > point[1]))
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1]))
/ ((b[1] - a[1]) || 1e-12) + a[0]) inside = !inside;
}
return inside;
};
const spaceId = card._space;
const lightDevice = card._devices.find((device) => device.space === spaceId
&& device.entities.some((entityId) => entityId.startsWith('light.')));
const sourceEid = lightDevice?.entities.find((entityId) => entityId.startsWith('light.'));
if (!lightDevice || !sourceEid) return {
fixtureHasLightSource: false,
windowFallbackBodyIsOpaque: false,
windowSourceSuppressedOnBooleanFailure: false,
interiorPassageRemainsTransparent: false,
};
result.fixtureHasLightSource = true;
const centre = [500, 500];
const opening = (type) => ({
id: 'hosted-opening', type, x: 0.5, y: 0.5, angle: 0, length: 0.12,
host: { kind: 'partition', id: 'host', t: 0.5 },
});
const currentState = card.hass.states[sourceEid];
card.hass = {
...card.hass,
states: {
...card.hass.states,
[sourceEid]: { ...currentState, state: 'on' },
},
};
card._layout = {
...card._layout,
[lightDevice.id]: { s: spaceId, x: 0.5, y: 0.5 },
};
const replaceSpace = (type) => {
card._serverCfg = {
...card._serverCfg,
spaces: card._serverCfg.spaces.map((space) => space.id !== spaceId ? space : ({
...space,
rooms: [{
id: 'glow-room', name: 'Glow room', area: null,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
}],
settings: { ...(space.settings || {}), fill_mode: 'glow' },
partitions: [{ id: 'host', a: [0.25, 0.5], b: [0.75, 0.5], cm: 15 }],
openings: [opening(type)],
room_drafts: [], wall_columns: [],
})),
};
card._cfgEpoch++;
card._modelCache = null;
card._physicalBodiesCache = null;
card._lightPhysicalBodiesCache = null;
card._lightBarrierCache = null;
card._renderDeviceSnapshot = null;
card._glowClipCache.clear();
};
// Simulate wallBodiesGeometry() returning null after a clipping exception.
// The remainder of the real _lightBarriers path still computes the exact
// light-policy body set, then only its boolean geometry is made unavailable.
const realLightBarriers = card._lightBarriers.bind(card);
card._lightBarriers = (...args) => {
const barriers = realLightBarriers(...args);
if (result.windowFallbackBodyIsOpaque === undefined) {
result.windowFallbackBodyIsOpaque = barriers.opaqueBodies
.some((body) => pointIn(centre, body));
}
return { ...barriers, masonryGeometry: [] };
};
replaceSpace('window');
await update();
const selector = `[data-glow-source="${CSS.escape(sourceEid)}"] .glow-pool`;
result.windowSourceSuppressedOnBooleanFailure = !root().querySelector(selector);
replaceSpace('passage');
await update();
result.interiorPassageRemainsTransparent = !!root().querySelector(selector);
return result;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -38,6 +38,10 @@
editor's visual snap guide keeps its physical gap across the opening
([#132](https://github.com/Matysh/houseplan-card/issues/132),
[#185](https://github.com/Matysh/houseplan-card/issues/185)).
- Glow now remains fail-dark if wall boolean geometry cannot be built: a light
source inside a partition-hosted window or exterior opening stays suppressed,
while valid interior passages remain transparent
([#187](https://github.com/Matysh/houseplan-card/issues/187)).
- Collinear exterior walls now change thickness exactly at their saved
breakpoint. After splitting a room, a 10 cm wall therefore keeps its full
depth up to the divider without leaking onto an adjacent zero-thickness
+4
View File
@@ -47,6 +47,10 @@
направляющая привязки по-прежнему показывает физический разрыв в месте проёма
([#132](https://github.com/Matysh/houseplan-card/issues/132),
[#185](https://github.com/Matysh/houseplan-card/issues/185)).
- Glow теперь остаётся непрозрачным при сбое построения объединённой геометрии
стен: источник света внутри окна или внешнего проёма в независимой стене
подавляется, а корректный внутренний проход по-прежнему пропускает свет
([#187](https://github.com/Matysh/houseplan-card/issues/187)).
- Коллинеарные внешние стены теперь меняют толщину точно в сохранённой точке
перехода. После разделения комнаты стена 10 см сохраняет полную толщину до
перегородки и не продолжается по соседнему фасаду с нулевой толщиной; План,
+11 -7
View File
@@ -1272,7 +1272,7 @@ class HouseplanCard extends LitElement {
key: string;
value: {
occluders: LightSegment[]; floor: number[][][]; fingerprint: string;
masonryGeometry: any;
masonryGeometry: any; opaqueBodies: number[][][];
};
} | null = null;
/** Freeze the SVG blur while a pinch/pan emits animation frames, then adopt
@@ -14628,10 +14628,10 @@ class HouseplanCard extends LitElement {
* The result depends only on the plan, so it is shared by every lamp.
*/
private _lightBarriers(
space: SpaceModel, polys: { r: RoomCfg; poly: number[][] }[], physical: number[][][],
space: SpaceModel, polys: { r: RoomCfg; poly: number[][] }[],
): {
occluders: LightSegment[]; floor: number[][][]; fingerprint: string;
masonryGeometry: any;
masonryGeometry: any; opaqueBodies: number[][][];
} {
// Gates are door-like: their different symbol must not change how light
// crosses the clear opening.
@@ -14737,6 +14737,11 @@ class HouseplanCard extends LitElement {
floor: polys.map((x) => x.poly),
fingerprint,
masonryGeometry: masonry?.geom || [],
// The source guard must fail dark against the same type/floor-filtered
// bodies used to build the light masonry. The ordinary render bodies are
// cut by every hosted opening, including opaque windows and exterior
// doors, and therefore cannot be used when the boolean geometry is empty.
opaqueBodies: lightPhysical,
};
this._lightBarrierCache = { key: cacheKey, value };
return value;
@@ -14754,10 +14759,9 @@ class HouseplanCard extends LitElement {
this._forgetGlowSpace(space.id);
return svg`` as unknown as TemplateResult;
}
const physical = this._physicalBodiesR(space);
const {
occluders, floor, fingerprint, masonryGeometry,
} = this._lightBarriers(space, polys, physical);
occluders, floor, fingerprint, masonryGeometry, opaqueBodies,
} = this._lightBarriers(space, polys);
// Resolve against the whole plan: a controller and its passive lamp may
// legitimately live in different spaces. Ownership is filtered afterwards.
const resolvedSources = resolvedLightSources(
@@ -14812,7 +14816,7 @@ class HouseplanCard extends LitElement {
// Exterior opening tunnels deliberately remain in `masonryGeometry`,
// so placing the source there suppresses the entire pool rather than
// lighting only the indoor half of the tunnel.
if (pointInOpaquePlanBody(sourcePoint, masonryGeometry, physical)) {
if (pointInOpaquePlanBody(sourcePoint, masonryGeometry, opaqueBodies)) {
// This placement cannot produce a valid previous-frame fade: the old
// clip belongs to a different position. Remove its transition state as
// well, rather than leaving a timer for a DOM node no longer rendered.
+18
View File
@@ -177,6 +177,24 @@ test('the source guard combines wall masonry with partitions and columns', () =>
assert.equal(pointInOpaquePlanBody([20, 5], masonry, [partition]), false, 'clear floor');
});
test('empty boolean masonry falls back to light-policy partition bodies', () => {
const space = {
room_drafts: [], wall_columns: [],
partitions: [{ id: 'host', a: [0, 0], b: [10, 0], cm: 10 }],
};
const hostedSlot = { hostId: 'host', a: [4, 0], b: [6, 0], depth: 2 };
const drawnBodies = physicalBodyParts(space, 5, 1, 1e-6, [hostedSlot]).all;
const opaqueWindowBodies = physicalBodyParts(space, 5, 1, 1e-6, []).all;
const source = [5, 0];
assert.equal(pointInOpaquePlanBody(source, [], drawnBodies), false,
'the presentation body has a slot for every hosted opening');
assert.equal(pointInOpaquePlanBody(source, [], opaqueWindowBodies), true,
'a window/exterior opening stays opaque when boolean masonry is unavailable');
assert.equal(pointInOpaquePlanBody(source, [], drawnBodies), false,
'an interior passage remains a valid transparent source position');
});
test('exact column overlays are rejected but rotated square bodies remain distinct', () => {
const square = { id: 'a', shape: 'square', center: [1, 1], cm: 30, angle: 0 };
assert.equal(sameColumnPlacement(square, { ...square, id: 'b', angle: 90 }, 1e-9), true);