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
+50 -34
View File
@@ -21,7 +21,8 @@ import {
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, isExact45Vector, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, roomGlowOf, contentUrl,
snapToWall, snapPointAlongPoly, openingAmount, openingShoulders, interiorPoint,
isInteriorLightOpeningType, openingEntityReferences, filterOpeningEntityCandidates,
isInteriorLightOpeningType, openingLightApertureLength, openingLightStateSignature,
openingEntityReferences, filterOpeningEntityCandidates,
poleOfInaccessibility, subst,
averageLqi, fitView, declump, safeUrl, floorsOf, type FloorInfo,
stateIcon, lightColorOf, parseRoomRef, diffNewDevices, resolveGlowValues, resolveGlowAppearance,
@@ -156,13 +157,13 @@ import {
directionalOccluders, floorMinusBodies, geometryArea, geometryOuterRings,
geometryAllRings, intersectionPaths, partitionBody, polyclipPathD,
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement,
physicalBodyParts,
physicalBodyParts, scalePartitionOpeningCut,
type PartitionOpeningCut,
} from './physical-geometry';
import {
hostedOpeningIntervalsOverlap, materializePartitionOpening,
partitionOpeningJambMargin, partitionOpeningNeedsStrictValidation,
partitionOpeningFace,
partitionOpeningCut, partitionOpeningFace,
partitionPlacementIntervals, resolvePartitionOpeningCompat, resolvePartitionOpeningStrict,
type PartitionOpeningOrphanReason, type ResolvedPartitionOpening,
} from './partition-openings';
@@ -10260,7 +10261,8 @@ export class HouseplanCard extends LitElement {
* indoor lamp must not wash the street, so the light's masonry is cut by
* passages only and differs on purpose from the drawn one.
*
* The result depends only on the plan, so it is shared by every lamp.
* The result depends on the plan plus the small signature of bound interior
* door/gate states, so it is shared by every lamp and unrelated HA updates.
*/
private _lightBarriers(
space: SpaceModel, polys: { r: RoomCfg; poly: number[][] }[],
@@ -10274,15 +10276,38 @@ export class HouseplanCard extends LitElement {
// 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 geometryFingerprint = contentFingerprint([raw, this._cellCm, this._gridPitch]);
// Gates are door-like: their different symbol must not change how light
// crosses the clear opening.
// An opening is transparent only where it leads from floor to floor. A
// front door has nothing behind it to light, so for light it is masonry
// like a window — otherwise its tunnel glows halfway, up to the centreline
// where the room polygon ends, and the plan shows a lit doorway to nowhere.
const probe = Math.max(this._cmToUnits(10), this._gridPitch * 0.5);
const onFloor = (point: number[]): boolean =>
polys.some((x) => this._pointInRoom(point, x.r));
const passageStates = this._openingsR.flatMap((opening) => {
if (!isInteriorLightOpeningType(String(opening.type))) return [];
const o = opening;
const rad = (o.angle * Math.PI) / 180;
const nx = -Math.sin(rad);
const ny = Math.cos(rad);
const interior = onFloor([o.rx + nx * probe, o.ry + ny * probe])
&& onFloor([o.rx - nx * probe, o.ry - ny * probe]);
return interior ? [{ opening: o, amount: this._openingAmt(o) }] : [];
});
const openingStateSignature = openingLightStateSignature(
passageStates.map(({ opening, amount }) => ({
id: opening.id, type: opening.type, contact: opening.contact, amount,
})),
);
const fingerprint = contentFingerprint([geometryFingerprint, openingStateSignature]);
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();
// Physical masonry has no body at every zero wall, independent of style.
// Light transport differs: solid zero walls are added back below as exact
@@ -10292,21 +10317,12 @@ export class HouseplanCard extends LitElement {
// zero axes stay in the room outline and are also registered below as
// exact standalone barriers for independent/outer-wall parity.
const cuts: number[][] = [...zeroWalls.transmissive];
// An opening is transparent only where it leads from floor to floor. A
// front door has nothing behind it to light, so for light it is masonry
// like a window — otherwise its tunnel glows halfway, up to the centreline
// where the room polygon ends, and the plan shows a lit doorway to nowhere.
const probe = Math.max(this._cmToUnits(10), this._gridPitch * 0.5);
const onFloor = (point: number[]): boolean =>
polys.some((x) => this._pointInRoom(point, x.r));
const passages = this._openingsR.filter((o) => {
if (!isInteriorLightOpeningType(String(o.type))) return false;
const rad = (o.angle * Math.PI) / 180;
const nx = -Math.sin(rad);
const ny = Math.cos(rad);
return onFloor([o.rx + nx * probe, o.ry + ny * probe])
&& onFloor([o.rx - nx * probe, o.ry - ny * probe]);
});
const passages = passageStates
.filter(({ amount }) => amount > 0)
.map(({ opening, amount }) => ({
...opening,
rlen: openingLightApertureLength(opening.rlen, amount),
}));
const roomPassages = this._roomWallOpeningInputs(passages, space);
// Openings omitted from `passages` remain part of light's opaque masonry.
// Consequently a source centred in an exterior door/gate or any window is
@@ -10317,19 +10333,17 @@ export class HouseplanCard extends LitElement {
const dy = (Math.sin(rad) * opening.length) / 2;
cuts.push([opening.x - dx, opening.y - dy, opening.x + dx, opening.y + dy]);
}
const transparentHostedIds = new Set(
passages.filter((opening) => opening.partitionHost).map((opening) => opening.id),
);
const lightPhysicalKey = [
space.id, this._cfgEpoch, this._cellCm, this._gridPitch,
[...transparentHostedIds].sort().join(','),
].join('|');
const lightPartitionCuts = passageStates.flatMap(({ opening, amount }) =>
amount > 0 && opening.partitionHost
? [scalePartitionOpeningCut(partitionOpeningCut(opening.partitionHost), amount)]
: []);
const lightPhysicalKey = cacheKey;
if (this._lightPhysicalBodiesCache?.key !== lightPhysicalKey) {
this._lightPhysicalBodiesCache = {
key: lightPhysicalKey,
all: physicalBodyParts(
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
this._partitionOpeningCuts(space, (opening) => transparentHostedIds.has(opening.id)),
lightPartitionCuts,
).all,
};
}
@@ -10345,7 +10359,7 @@ export class HouseplanCard extends LitElement {
const sharedFingerprint = (sharedWallGeometry as unknown as {
sourceFingerprint?: string;
} | null)?.sourceFingerprint;
const recut = sharedWallGeometry && sharedFingerprint === fingerprint
const recut = sharedWallGeometry && sharedFingerprint === geometryFingerprint
? recutWallBodiesGeometry(sharedWallGeometry, roomPassages, lightPhysical)
: null;
const masonry = recut || (walls.length || lightPhysical.length
@@ -12316,10 +12330,12 @@ export class HouseplanCard extends LitElement {
/** Live state of an opening's contact, 0..1 drawn amount. */
private _openingAmt(o: OpeningCfg): number {
const st = o.contact && this._renderOpeningEntityAvailable(o.contact)
? this._renderPlanHass.states[o.contact]?.state
const entity = o.contact && this._renderOpeningEntityAvailable(o.contact)
? this._renderPlanHass.states[o.contact]
: null;
return openingAmount(o.type, st, !!o.invert);
return openingAmount(
o.type, entity?.state, !!o.invert, entity?.attributes?.current_position,
);
}
/** Deleted bindings are unavailable to every plan-level consumer. */
+39 -3
View File
@@ -314,13 +314,19 @@ export function openingShoulders(
* `unavailable`/`unknown` freeze the default too: an outage must not fake motion.
*/
export function openingAmount(
type: 'door' | 'window' | 'gate' | 'passage', state: string | null | undefined, invert = false,
type: 'door' | 'window' | 'gate' | 'passage', state: string | null | undefined,
invert = false, currentPosition?: unknown,
): number {
if (type === 'passage') return 1;
if (state == null || state === 'unavailable' || state === 'unknown')
return type === 'window' ? 0 : 1;
const open = isActiveState(state) !== !!invert;
return open ? 1 : 0;
const rawPosition = typeof currentPosition === 'string'
? (currentPosition.trim() ? Number(currentPosition) : NaN)
: Number(currentPosition);
const base = currentPosition != null && Number.isFinite(rawPosition)
? Math.max(0, Math.min(1, rawPosition / 100))
: (isActiveState(state) ? 1 : 0);
return invert ? 1 - base : base;
}
/** Explicit light allowlist. Unknown future opening types stay fail-dark until
@@ -329,6 +335,36 @@ export function isInteriorLightOpeningType(type: string): type is 'door' | 'gate
return type === 'door' || type === 'gate' || type === 'passage';
}
export interface OpeningLightStateEntry {
id: string;
type: string;
contact?: string | null;
amount: number;
}
/** State-only identity for interior light apertures. Structural geometry is
* fingerprinted separately, so unrelated HA updates cannot evict barriers. */
export function openingLightStateSignature(
openings: readonly OpeningLightStateEntry[],
): string {
return openings
.filter((opening) => !!opening.contact
&& (opening.type === 'door' || opening.type === 'gate'))
.map((opening) => {
const amount = Math.max(0, Math.min(1, Number(opening.amount) || 0));
return `${opening.id}:${amount.toFixed(3)}`;
})
.sort()
.join('|');
}
/** Centre-preserving aperture length used by room-wall and partition cuts. */
export function openingLightApertureLength(length: number, amount: number): number {
const safeLength = Number.isFinite(length) && length > 0 ? length : 0;
const safeAmount = Number.isFinite(amount) ? Math.max(0, Math.min(1, amount)) : 0;
return safeLength * safeAmount;
}
/** Entity bindings that may affect a rendered opening. A passage is purely
* architectural, so stale legacy fields must never create HA subscriptions. */
export function openingEntityReferences(opening: {
+17
View File
@@ -149,6 +149,23 @@ export interface PartitionOpeningCut {
depth: number;
}
/** Shorten an independent-wall cut around its existing centre. The host and
* depth stay authoritative; only the light aperture changes with door state. */
export function scalePartitionOpeningCut(
cut: PartitionOpeningCut, amount: number,
): PartitionOpeningCut {
const safeAmount = Number.isFinite(amount) ? Math.max(0, Math.min(1, amount)) : 0;
const cx = (cut.a[0] + cut.b[0]) / 2;
const cy = (cut.a[1] + cut.b[1]) / 2;
const hx = ((cut.b[0] - cut.a[0]) * safeAmount) / 2;
const hy = ((cut.b[1] - cut.a[1]) * safeAmount) / 2;
return {
...cut,
a: [cx - hx, cy - hy],
b: [cx + hx, cy + hy],
};
}
/**
* Cut only the explicitly hosted independent-wall body. Boolean failure keeps
* the original body opaque (fail-dark) instead of manufacturing a light leak.
+5 -3
View File
@@ -508,9 +508,11 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const wallStroke = disp.color || '#607d8b';
const hostedOpeningSymbols = disp.hideOpenings ? [] : resolvedHosted.map((resolved) => {
const opening = resolved.opening;
const state = opening.type === 'passage' || !opening.contact
? null : planHass.states?.[opening.contact]?.state;
const amount = openingAmount(opening.type, state, !!opening.invert);
const entity = opening.type === 'passage' || !opening.contact
? null : planHass.states?.[opening.contact];
const amount = openingAmount(
opening.type, entity?.state, !!opening.invert, entity?.attributes?.current_position,
);
const active = amount > 0 && !!opening.contact;
const faceFlipV = opening.type === 'gate' ? !opening.flip_v : !!opening.flip_v;
const spec: OpeningVisibleSpec = {