feat: make Glow follow door opening state (#20)

Issue: #20
User-Visible: yes
This commit is contained in:
Matysh
2026-08-28 22:33:07 +03:00
parent 72e6939f67
commit f62926fbeb
41 changed files with 689 additions and 390 deletions
+11 -5
View File
@@ -354,7 +354,11 @@ test('doorway spill golden exposes the opaque-fill failure mode from issue 71',
assert.equal(scenario.glowEnabled, true);
assert.equal(scenario.sunRays, false);
assert.equal(scenario.allLightsOff, true);
assert.equal(scenario.extraOpenings?.filter((opening) => opening.type === 'door').length, 1);
assert.equal(scenario.extraOpenings?.filter((opening) => opening.type === 'door').length, 2);
assert.equal(scenario.extraOpenings?.find((opening) => opening.id === 'light-door-second')?.contact,
'cover.golden_door_closed');
assert.equal(scenario.extraOpenings?.find((opening) => opening.id === 'light-door-third')?.contact,
'cover.golden_door_half');
assert.equal(scenario.stateOverrides?.['light.golden_light_one']?.state, 'on');
assert.deepEqual(scenario.layoutOverrides?.['golden-light-one'], {
s: 'golden-lighting', x: 0.40, y: 0.48,
@@ -710,7 +714,9 @@ test('golden harness applies doorway, state and layout overrides to a cloned fix
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'lighting-opaque-glow-two-doorways-dark');
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(space.openings.filter((opening) => opening.type === 'door').length, 2);
assert.equal(space.openings.filter((opening) => opening.type === 'door').length, 3);
assert.equal(fixture.states['cover.golden_door_closed'].attributes.current_position, 0);
assert.equal(fixture.states['cover.golden_door_half'].attributes.current_position, 50);
assert.equal(fixture.states['light.golden_light_one'].state, 'on');
assert.deepEqual(fixture.layout['golden-light-one'], {
s: 'golden-lighting', x: 0.40, y: 0.48,
@@ -765,10 +771,10 @@ 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 = contentFingerprint\(\[/);
assert.match(source, /contentFingerprint\(\[raw, this\._cellCm, this\._gridPitch\]\)/);
assert.match(source, /const geometryFingerprint = contentFingerprint\(\[raw, this\._cellCm, this\._gridPitch\]\)/);
assert.match(source, /const fingerprint = contentFingerprint\(\[geometryFingerprint, openingStateSignature\]\)/);
assert.match(source, /const cacheKey = `\$\{space\.id\}\|\$\{fingerprint\}`/);
assert.match(source, /sharedFingerprint === fingerprint/,
assert.match(source, /sharedFingerprint === geometryFingerprint/,
'an in-place edit must never recut stale shared masonry');
});
+43 -1
View File
@@ -16,7 +16,11 @@ import {
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, openingEntityReferences, fillColorsOf, lerpColor, roomFillStyle, resolveEffectiveRoomFill, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, coverMoving,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall,
openingAmount, openingLightApertureLength, openingLightStateSignature,
openingShoulders, openingEntityReferences, fillColorsOf, lerpColor, roomFillStyle,
resolveEffectiveRoomFill, stateIcon, lightColorOf, isAlarmState, parseRoomRef,
diffNewDevices, poleOfInaccessibility, runServiceFor, coverMoving,
normalizeDeviceDisplay, isAlarmCapable,
liveText, liveTextValue, liveTextReference, liveTextToken,
hassValue, valueWithUnit, decorTextScale, decorTextLines,
@@ -492,6 +496,44 @@ test('openingAmount: door-like openings default open, windows closed; outages fr
}
});
test('openingAmount shares positioned cover progress with light apertures', () => {
assert.equal(openingAmount('gate', 'open', false, 0), 0);
assert.equal(openingAmount('gate', 'open', false, 1), 0.01);
assert.equal(openingAmount('gate', 'open', false, 50), 0.5);
assert.equal(openingAmount('gate', 'open', false, 100), 1);
assert.equal(openingAmount('gate', 'open', false, -20), 0);
assert.equal(openingAmount('gate', 'open', false, 140), 1);
assert.equal(openingAmount('gate', 'open', false, '40'), 0.4);
assert.equal(openingAmount('gate', 'open', false, ''), 1, 'empty position falls back to state');
assert.equal(openingAmount('gate', 'off', false, 'bad'), 0, 'invalid position falls back to state');
assert.equal(openingAmount('gate', 'open', true, 25), 0.75);
assert.equal(openingAmount('gate', 'unavailable', true, 25), 1,
'outage fallback ignores stale position and invert');
assert.equal(openingAmount('passage', 'off', true, 0), 1);
assert.equal(openingLightApertureLength(80, 0.5), 40);
assert.equal(openingLightApertureLength(80, -1), 0);
assert.equal(openingLightApertureLength(80, 2), 80);
assert.equal(openingLightApertureLength(NaN, 0.5), 0);
});
test('opening light state signature is ordered, bounded and contact-only', () => {
const a = [
{ id: 'gate-b', type: 'gate', contact: 'cover.gate', amount: 0.5004 },
{ id: 'door-a', type: 'door', contact: 'binary_sensor.door', amount: 1 },
{ id: 'passage', type: 'passage', contact: null, amount: 1 },
{ id: 'window', type: 'window', contact: 'binary_sensor.window', amount: 0 },
{ id: 'unbound', type: 'door', contact: '', amount: 0 },
];
const expected = 'door-a:1.000|gate-b:0.500';
assert.equal(openingLightStateSignature(a), expected);
assert.equal(openingLightStateSignature([...a].reverse()), expected);
assert.equal(openingLightStateSignature([
{ id: 'low', type: 'door', contact: 'binary_sensor.low', amount: -2 },
{ id: 'high', type: 'gate', contact: 'cover.high', amount: 2 },
]), 'high:1.000|low:0.000');
});
test('stale passage bindings stay inert while ordinary opening bindings remain live', () => {
const stale = { type: 'passage', contact: 'binary_sensor.door', lock: 'lock.front' };
assert.deepEqual(openingEntityReferences(stale), []);
+32 -1
View File
@@ -4,7 +4,7 @@ import {
BOOLEAN_COORD_QUANTUM, canonicalColumnAngle, columnBody, floorMinusBodies, geometryArea,
directionalOccluders, intersectionPaths, partitionBody, pointInPhysicalBody,
physicalBodyParts, physicalBodySet, pointInOpaquePlanBody, pointInPhysicalGeometry,
normalizeBooleanBody, sameColumnPlacement, unionBodies,
normalizeBooleanBody, sameColumnPlacement, scalePartitionOpeningCut, unionBodies,
} from '../test-build/physical-geometry.js';
import {
polygonSegments, splitAtIntersections, visibilityPolygon,
@@ -36,6 +36,37 @@ test('partition body keeps the centreline and requested physical width', () => {
closeTo(geometryArea([[[...body, body[0]]]]), 0.5);
});
test('partition light aperture scales around the same centre and keeps host depth', () => {
const full = { hostId: 'wall', a: [2, 4], b: [10, 8], depth: 3 };
assert.deepEqual(scalePartitionOpeningCut(full, 0.5), {
hostId: 'wall', a: [4, 5], b: [8, 7], depth: 3,
});
assert.deepEqual(scalePartitionOpeningCut(full, 0), {
hostId: 'wall', a: [6, 6], b: [6, 6], depth: 3,
});
assert.deepEqual(scalePartitionOpeningCut(full, 2), full);
assert.deepEqual(full, { hostId: 'wall', a: [2, 4], b: [10, 8], depth: 3 },
'scaling does not mutate canonical geometry');
});
test('partition body uses the same full, partial and closed light aperture', () => {
const space = {
room_drafts: [], wall_columns: [],
partitions: [{ id: 'host', a: [0, 0], b: [10, 0], cm: 10 }],
};
const full = { hostId: 'host', a: [4, 0], b: [6, 0], depth: 2 };
const half = scalePartitionOpeningCut(full, 0.5);
const fullBodies = physicalBodyParts(space, 5, 1, 1e-6, [full]).all;
const halfBodies = physicalBodyParts(space, 5, 1, 1e-6, [half]).all;
const closedBodies = physicalBodyParts(space, 5, 1, 1e-6, []).all;
assert.equal(pointInOpaquePlanBody([5, 0], [], fullBodies), false, 'full slot centre');
assert.equal(pointInOpaquePlanBody([5.75, 0], [], fullBodies), false, 'full slot shoulder');
assert.equal(pointInOpaquePlanBody([5, 0], [], halfBodies), false, 'half slot centre');
assert.equal(pointInOpaquePlanBody([5.75, 0], [], halfBodies), true, 'half slot shoulder');
assert.equal(pointInOpaquePlanBody([5, 0], [], closedBodies), true, 'closed slot is masonry');
});
test('joined partitions fill straight and oblique endpoint teeth without changing flat free caps', () => {
const base = {
room_drafts: [], wall_columns: [],