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
@@ -1,111 +1,111 @@
{
"schema": 1,
"fingerprint": "c673cca61c08a44252a2303b34ba28b96411e9d5fddb18c938f79fbbc00ccd7f",
"fingerprint": "20bb943045ad5eaa2ebc207fc30c1f5a570b0157da1a32450c71882cbd939019",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-CmrzFyoP.js",
"houseplan-assets/houseplan-card-Cjdt1t7f.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 271168,
"initialViewGzipBytes": 271455,
"lazyFiles": [
"houseplan-assets/de-B04VJMDv.js",
"houseplan-assets/editor-C0gbLi11.js",
"houseplan-assets/houseplan-editor-runtime-BbpRSsiv.js",
"houseplan-assets/houseplan-onboarding-runtime-BkJZgs7A.js",
"houseplan-assets/space-deletion-BfZFFcpf.js"
"houseplan-assets/de-DRFSDdUU.js",
"houseplan-assets/editor-Rs89b855.js",
"houseplan-assets/houseplan-editor-runtime-BL8faVDv.js",
"houseplan-assets/houseplan-onboarding-runtime-Ci7QymAY.js",
"houseplan-assets/space-deletion-1NoE8x_l.js"
],
"lazyGzipBytes": 165928,
"lazyGzipBytes": 165917,
"lazyEditorFiles": [
"houseplan-assets/editor-C0gbLi11.js",
"houseplan-assets/houseplan-editor-runtime-BbpRSsiv.js",
"houseplan-assets/space-deletion-BfZFFcpf.js"
"houseplan-assets/editor-Rs89b855.js",
"houseplan-assets/houseplan-editor-runtime-BL8faVDv.js",
"houseplan-assets/space-deletion-1NoE8x_l.js"
],
"lazyEditorGzipBytes": 136819,
"lazyEditorGzipBytes": 136808,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-BkJZgs7A.js",
"houseplan-assets/space-deletion-BfZFFcpf.js"
"houseplan-assets/houseplan-onboarding-runtime-Ci7QymAY.js",
"houseplan-assets/space-deletion-1NoE8x_l.js"
],
"lazyOnboardingGzipBytes": 7201,
"lazyOnboardingGzipBytes": 7195,
"lazyLocaleFiles": [
"houseplan-assets/de-B04VJMDv.js"
"houseplan-assets/de-DRFSDdUU.js"
],
"lazyLocaleGzipBytes": 22462,
"lazyLocaleGzipBytes": 22465,
"files": [
{
"path": "houseplan-assets/de-B04VJMDv.js",
"sha256": "0b2029018d30a920e040e0e8cc0213031782aecec97550726a2832bcba4c571e",
"path": "houseplan-assets/de-DRFSDdUU.js",
"sha256": "93319bdb17d7b9902672342eb6daa15f32ab09658dd6415deb157decb0edf613",
"rawBytes": 73919,
"gzipBytes": 22462,
"gzipBytes": 22465,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-C0gbLi11.js",
"sha256": "71b26e609aa270fa02c90b56757e82b9a081f23a3605eacd0dd3ae32e3bb5782",
"path": "houseplan-assets/editor-Rs89b855.js",
"sha256": "93bd34541456059f9dd5369dd9a51a5bd8a39f99089927d610bf6fe6cfbf87c5",
"rawBytes": 3831,
"gzipBytes": 1583,
"gzipBytes": 1582,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CmrzFyoP.js"
"houseplan-assets/houseplan-card-Cjdt1t7f.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CmrzFyoP.js",
"sha256": "19d936972988d0561b927cad612ee34675783cf0d0c1bfcd7d796156aedc7305",
"rawBytes": 956498,
"gzipBytes": 270414,
"path": "houseplan-assets/houseplan-card-Cjdt1t7f.js",
"sha256": "10d36979e2f624b0dc5e98a445ea0c8dedec6469857b12dbd99c84f8dded4ee5",
"rawBytes": 957284,
"gzipBytes": 270703,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-B04VJMDv.js",
"houseplan-assets/editor-C0gbLi11.js",
"houseplan-assets/houseplan-editor-runtime-BbpRSsiv.js",
"houseplan-assets/houseplan-onboarding-runtime-BkJZgs7A.js"
"houseplan-assets/de-DRFSDdUU.js",
"houseplan-assets/editor-Rs89b855.js",
"houseplan-assets/houseplan-editor-runtime-BL8faVDv.js",
"houseplan-assets/houseplan-onboarding-runtime-Ci7QymAY.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-BbpRSsiv.js",
"sha256": "cff3061e998268f86098047985b0c3b4f0d14740e8981b0a46e43ffa98a90590",
"path": "houseplan-assets/houseplan-editor-runtime-BL8faVDv.js",
"sha256": "ea20c56b4b365bd0accc4cd07950322b5b9dda70bc15a17b5d69c14df0ed0a55",
"rawBytes": 520292,
"gzipBytes": 134682,
"gzipBytes": 134675,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CmrzFyoP.js",
"houseplan-assets/space-deletion-BfZFFcpf.js"
"houseplan-assets/houseplan-card-Cjdt1t7f.js",
"houseplan-assets/space-deletion-1NoE8x_l.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-BkJZgs7A.js",
"sha256": "bbf33f54ddf0b5c9025351822042f17615ef9195a180ddc7616c9c1da7f11142",
"path": "houseplan-assets/houseplan-onboarding-runtime-Ci7QymAY.js",
"sha256": "f18f2ec9af6091f8f731a2c8d969f05cdd8e34dec1c76c35a11857f7ab1e9d3a",
"rawBytes": 26356,
"gzipBytes": 6647,
"gzipBytes": 6644,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CmrzFyoP.js",
"houseplan-assets/space-deletion-BfZFFcpf.js"
"houseplan-assets/houseplan-card-Cjdt1t7f.js",
"houseplan-assets/space-deletion-1NoE8x_l.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-BfZFFcpf.js",
"sha256": "ffae5e9921eacc23faf7db2551eb327f76f99677c549adf89ab3b9c7d5de27d3",
"rawBytes": 911,
"gzipBytes": 554,
"path": "houseplan-assets/space-deletion-1NoE8x_l.js",
"sha256": "20448d45c7f3cbeed2ca72852535b30c0b7dd5bf70d6b9c354fd71ca693abf84",
"rawBytes": 906,
"gzipBytes": 551,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "f1f688dceb0fc517658234151a7999de73f9111b8249b51a8997c6d21915d2b4",
"sha256": "15b38319f0dc22546a9f747ae4c227b49f10b5f21595cdf2ce758e09a7402be5",
"rawBytes": 1089,
"gzipBytes": 754,
"gzipBytes": 752,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-CmrzFyoP.js"
"houseplan-assets/houseplan-card-Cjdt1t7f.js"
],
"dynamicImports": []
}
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="c673cca61c08a44252a2303b34ba28b96411e9d5fddb18c938f79fbbc00ccd7f";import{c as e,l as o,t,c_ as s,A as a,c$ as i,d0 as l,d1 as n,E as r,b as c}from"./houseplan-card-CmrzFyoP.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="20bb943045ad5eaa2ebc207fc30c1f5a570b0157da1a32450c71882cbd939019";import{c as e,l as o,t,c_ as s,A as a,c$ as i,d0 as l,d1 as n,E as r,b as c}from"./houseplan-card-Cjdt1t7f.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},_=this._schema,f=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
${d(_.slice(0,3))}
${f?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
${t(s,"editor.default_floor_missing",{id:f})}
</div>`:a}
${d(f.slice(3))}
${d(_.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
@@ -1 +1 @@
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="c673cca61c08a44252a2303b34ba28b96411e9d5fddb18c938f79fbbc00ccd7f";export{t as a,e as c,o as i,r as s,s as t};
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function a(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),a=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),t=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&a.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:t,count:t.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="20bb943045ad5eaa2ebc207fc30c1f5a570b0157da1a32450c71882cbd939019";export{a,e as c,o as i,r as s,s as t};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="c673cca61c08a44252a2303b34ba28b96411e9d5fddb18c938f79fbbc00ccd7f";try{await import("./houseplan-assets/houseplan-card-CmrzFyoP.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="20bb943045ad5eaa2ebc207fc30c1f5a570b0157da1a32450c71882cbd939019";try{await import("./houseplan-assets/houseplan-card-Cjdt1t7f.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+12
View File
@@ -144,6 +144,18 @@ const runtime = (includeAppliance = false) => {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 180, elevation: 24 },
},
// State-only YAML covers deliberately have no registry/device row. They
// keep the Glow opening-state golden independent from entity-registry
// discovery while exercising the same exact-reference projection as a
// user's template cover.
'cover.golden_door_closed': {
entity_id: 'cover.golden_door_closed', state: 'closed',
attributes: { friendly_name: 'Golden closed door', current_position: 0 },
},
'cover.golden_door_half': {
entity_id: 'cover.golden_door_half', state: 'open',
attributes: { friendly_name: 'Golden half-open door', current_position: 50 },
},
};
// Keep sun.sun state-only on purpose. Core/runtime entities and YAML
// entities without unique_id may have a live state without a registry row.
+12 -3
View File
@@ -580,11 +580,20 @@ export const GOLDEN_SCENARIOS = Object.freeze([
'light.golden_light_one': { state: 'on', attributes: { rgb_color: [255, 196, 112], brightness: 255 } },
},
extraOpenings: [
{ id: 'light-door-second', type: 'door', x: 0.50, y: 0.32, angle: 90, length: 0.13 },
{
id: 'light-door-second', type: 'door', x: 0.50, y: 0.30,
angle: 90, length: 0.13, contact: 'cover.golden_door_closed',
},
{
id: 'light-door-third', type: 'door', x: 0.50, y: 0.76,
angle: 90, length: 0.13, contact: 'cover.golden_door_half',
},
],
layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.40, y: 0.48 } },
// The golden is protection only if the receiving half actually contains
// rendered light. A data-only check once let a visually empty baseline pass.
// One frame contains the complete #20 matrix: the original unbound door is
// fully open, the upper bound door is closed and the lower positional door
// exposes 50%. Protection is meaningful only if the receiving half also
// contains rendered light; a data-only check once let an empty baseline pass.
warmPixelRegion: { x: 0.5, y: 0, w: 0.5, h: 1, minPixels: 2500, minRedBlueDelta: 25 },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-custom-glow-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
+100 -2
View File
@@ -310,8 +310,12 @@ const rasterFixture = await page.evaluate(async () => {
const walls = [...edges.values()].map(({ a, b }) => ({
key: wallKey(a, b), a, b, cm: 15,
}));
const dynamicContact = 'cover.glow_dynamic_door';
const openings = [
{ id: 'glow-raster-east', type: 'door', x: 0.55, y: 0.32, angle: 90, length: 0.09 },
{
id: 'glow-raster-east', type: 'door', x: 0.55, y: 0.32,
angle: 90, length: 0.09, contact: dynamicContact,
},
{ id: 'glow-raster-south', type: 'door', x: 0.32, y: 0.58, angle: 0, length: 0.09 },
];
const wallSource = [...edges.values()]
@@ -350,6 +354,14 @@ const rasterFixture = await page.evaluate(async () => {
...states[sourceEid], state: 'on',
attributes: { ...(states[sourceEid]?.attributes || {}), brightness: 255, rgb_color: [255, 196, 112] },
};
states[dynamicContact] = {
entity_id: dynamicContact,
state: 'open',
attributes: { current_position: 100, friendly_name: 'Glow dynamic door' },
last_changed: new Date().toISOString(),
last_updated: new Date().toISOString(),
context: { id: 'glow-dynamic-door', parent_id: null, user_id: null },
};
c.hass = { ...c.hass, states };
c._cfgEpoch++;
c._glowClipCache.clear();
@@ -473,7 +485,10 @@ const rasterFixture = await page.evaluate(async () => {
edge: edgePlan.map(({ offset, point }) => ({ offset, point: toStage(point) })),
};
}
return { ready: true, litParts, sourceEid, sourceId: source.id, wallSource, doors, shadow };
return {
ready: true, litParts, sourceEid, sourceId: source.id, wallSource,
dynamicContact, doors, shadow,
};
});
if (rasterFixture.ready) {
@@ -580,6 +595,87 @@ if (rasterFixture.ready) {
res.shadowEdgeIsCrisp = !!metrics.shadow
&& metrics.shadow.high - metrics.shadow.low >= 8
&& metrics.shadow.edgeWidth <= 4;
// The east opening is a positional cover. Exercise live 100 → 0 → 50
// transitions without clearing any Glow cache: the rendered pixels must
// prove both state invalidation and the centre-preserving partial aperture.
const setDoorPosition = async (position) => page.evaluate(async ({ contact, position: next }) => {
const c = window.__card;
const previous = c.hass.states[contact];
c.hass = {
...c.hass,
states: {
...c.hass.states,
[contact]: {
...previous,
state: next > 0 ? 'open' : 'closed',
attributes: { ...(previous?.attributes || {}), current_position: next },
},
},
};
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}, { contact: rasterFixture.dynamicContact, position });
await page.evaluate(async (sourceEid) => {
const c = window.__card;
const previous = c.hass.states[sourceEid];
c.hass = {
...c.hass,
states: { ...c.hass.states, [sourceEid]: { ...previous, state: 'on' } },
};
await c.updateComplete;
}, rasterFixture.sourceEid);
await setDoorPosition(0);
const doorClosed = await stage.screenshot({ animations: 'disabled' });
await setDoorPosition(50);
const doorHalf = await stage.screenshot({ animations: 'disabled' });
const dynamicDoor = rasterFixture.doors.find((door) => door.id === 'glow-raster-east');
const dynamicMetrics = dynamicDoor ? await page.evaluate(async ({ images64, door }) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const images = await Promise.all(images64.map(decode));
const width = images[0].width;
const height = images[0].height;
const pixels = images.map((image) => {
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
return context.getImageData(0, 0, width, height).data;
});
const luma = (data, point) => {
const x = Math.max(0, Math.min(width - 1, Math.round(point[0])));
const y = Math.max(0, Math.min(height - 1, Math.round(point[1])));
const offset = (y * width + x) * 4;
return data[offset] * 0.2126 + data[offset + 1] * 0.7152 + data[offset + 2] * 0.0722;
};
const [open, closed, half, off] = pixels;
const delta = (data, point) => luma(data, point) - luma(off, point);
const widthOf = (data) => {
const lit = door.scan.filter(({ point }) => delta(data, point) >= 3);
return lit.length ? lit[lit.length - 1].offset - lit[0].offset : 0;
};
return {
openDelta: delta(open, door.sample),
closedDelta: delta(closed, door.sample),
openWidth: widthOf(open),
halfWidth: widthOf(half),
};
}, {
images64: [
lightOn.toString('base64'), doorClosed.toString('base64'),
doorHalf.toString('base64'), lightOff.toString('base64'),
],
door: dynamicDoor,
}) : null;
console.log('Dynamic door Glow metrics:', JSON.stringify(dynamicMetrics));
res.closedDoorBlocksGlow = !!dynamicMetrics
&& dynamicMetrics.openDelta >= 8 && dynamicMetrics.closedDelta <= 3;
res.partialDoorNarrowsGlow = !!dynamicMetrics
&& dynamicMetrics.halfWidth > 0
&& dynamicMetrics.halfWidth < dynamicMetrics.openWidth;
await setDoorPosition(100);
const sourceInsideWall = await page.evaluate(async ({ sourceEid, sourceId, wallSource }) => {
if (!wallSource) return false;
const c = window.__card;
@@ -621,6 +717,8 @@ if (rasterFixture.ready) {
res.shadowSamplesAreValid = false;
res.occluderCastsShadow = false;
res.shadowEdgeIsCrisp = false;
res.closedDoorBlocksGlow = false;
res.partialDoorNarrowsGlow = false;
res.sourceInsideWallSuppressed = false;
}
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
+50 -50
View File
@@ -1,111 +1,111 @@
{
"schema": 1,
"fingerprint": "c673cca61c08a44252a2303b34ba28b96411e9d5fddb18c938f79fbbc00ccd7f",
"fingerprint": "20bb943045ad5eaa2ebc207fc30c1f5a570b0157da1a32450c71882cbd939019",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-CmrzFyoP.js",
"houseplan-assets/houseplan-card-Cjdt1t7f.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 271168,
"initialViewGzipBytes": 271455,
"lazyFiles": [
"houseplan-assets/de-B04VJMDv.js",
"houseplan-assets/editor-C0gbLi11.js",
"houseplan-assets/houseplan-editor-runtime-BbpRSsiv.js",
"houseplan-assets/houseplan-onboarding-runtime-BkJZgs7A.js",
"houseplan-assets/space-deletion-BfZFFcpf.js"
"houseplan-assets/de-DRFSDdUU.js",
"houseplan-assets/editor-Rs89b855.js",
"houseplan-assets/houseplan-editor-runtime-BL8faVDv.js",
"houseplan-assets/houseplan-onboarding-runtime-Ci7QymAY.js",
"houseplan-assets/space-deletion-1NoE8x_l.js"
],
"lazyGzipBytes": 165928,
"lazyGzipBytes": 165917,
"lazyEditorFiles": [
"houseplan-assets/editor-C0gbLi11.js",
"houseplan-assets/houseplan-editor-runtime-BbpRSsiv.js",
"houseplan-assets/space-deletion-BfZFFcpf.js"
"houseplan-assets/editor-Rs89b855.js",
"houseplan-assets/houseplan-editor-runtime-BL8faVDv.js",
"houseplan-assets/space-deletion-1NoE8x_l.js"
],
"lazyEditorGzipBytes": 136819,
"lazyEditorGzipBytes": 136808,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-BkJZgs7A.js",
"houseplan-assets/space-deletion-BfZFFcpf.js"
"houseplan-assets/houseplan-onboarding-runtime-Ci7QymAY.js",
"houseplan-assets/space-deletion-1NoE8x_l.js"
],
"lazyOnboardingGzipBytes": 7201,
"lazyOnboardingGzipBytes": 7195,
"lazyLocaleFiles": [
"houseplan-assets/de-B04VJMDv.js"
"houseplan-assets/de-DRFSDdUU.js"
],
"lazyLocaleGzipBytes": 22462,
"lazyLocaleGzipBytes": 22465,
"files": [
{
"path": "houseplan-assets/de-B04VJMDv.js",
"sha256": "0b2029018d30a920e040e0e8cc0213031782aecec97550726a2832bcba4c571e",
"path": "houseplan-assets/de-DRFSDdUU.js",
"sha256": "93319bdb17d7b9902672342eb6daa15f32ab09658dd6415deb157decb0edf613",
"rawBytes": 73919,
"gzipBytes": 22462,
"gzipBytes": 22465,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-C0gbLi11.js",
"sha256": "71b26e609aa270fa02c90b56757e82b9a081f23a3605eacd0dd3ae32e3bb5782",
"path": "houseplan-assets/editor-Rs89b855.js",
"sha256": "93bd34541456059f9dd5369dd9a51a5bd8a39f99089927d610bf6fe6cfbf87c5",
"rawBytes": 3831,
"gzipBytes": 1583,
"gzipBytes": 1582,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CmrzFyoP.js"
"houseplan-assets/houseplan-card-Cjdt1t7f.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CmrzFyoP.js",
"sha256": "19d936972988d0561b927cad612ee34675783cf0d0c1bfcd7d796156aedc7305",
"rawBytes": 956498,
"gzipBytes": 270414,
"path": "houseplan-assets/houseplan-card-Cjdt1t7f.js",
"sha256": "10d36979e2f624b0dc5e98a445ea0c8dedec6469857b12dbd99c84f8dded4ee5",
"rawBytes": 957284,
"gzipBytes": 270703,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-B04VJMDv.js",
"houseplan-assets/editor-C0gbLi11.js",
"houseplan-assets/houseplan-editor-runtime-BbpRSsiv.js",
"houseplan-assets/houseplan-onboarding-runtime-BkJZgs7A.js"
"houseplan-assets/de-DRFSDdUU.js",
"houseplan-assets/editor-Rs89b855.js",
"houseplan-assets/houseplan-editor-runtime-BL8faVDv.js",
"houseplan-assets/houseplan-onboarding-runtime-Ci7QymAY.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-BbpRSsiv.js",
"sha256": "cff3061e998268f86098047985b0c3b4f0d14740e8981b0a46e43ffa98a90590",
"path": "houseplan-assets/houseplan-editor-runtime-BL8faVDv.js",
"sha256": "ea20c56b4b365bd0accc4cd07950322b5b9dda70bc15a17b5d69c14df0ed0a55",
"rawBytes": 520292,
"gzipBytes": 134682,
"gzipBytes": 134675,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CmrzFyoP.js",
"houseplan-assets/space-deletion-BfZFFcpf.js"
"houseplan-assets/houseplan-card-Cjdt1t7f.js",
"houseplan-assets/space-deletion-1NoE8x_l.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-BkJZgs7A.js",
"sha256": "bbf33f54ddf0b5c9025351822042f17615ef9195a180ddc7616c9c1da7f11142",
"path": "houseplan-assets/houseplan-onboarding-runtime-Ci7QymAY.js",
"sha256": "f18f2ec9af6091f8f731a2c8d969f05cdd8e34dec1c76c35a11857f7ab1e9d3a",
"rawBytes": 26356,
"gzipBytes": 6647,
"gzipBytes": 6644,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CmrzFyoP.js",
"houseplan-assets/space-deletion-BfZFFcpf.js"
"houseplan-assets/houseplan-card-Cjdt1t7f.js",
"houseplan-assets/space-deletion-1NoE8x_l.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-BfZFFcpf.js",
"sha256": "ffae5e9921eacc23faf7db2551eb327f76f99677c549adf89ab3b9c7d5de27d3",
"rawBytes": 911,
"gzipBytes": 554,
"path": "houseplan-assets/space-deletion-1NoE8x_l.js",
"sha256": "20448d45c7f3cbeed2ca72852535b30c0b7dd5bf70d6b9c354fd71ca693abf84",
"rawBytes": 906,
"gzipBytes": 551,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "f1f688dceb0fc517658234151a7999de73f9111b8249b51a8997c6d21915d2b4",
"sha256": "15b38319f0dc22546a9f747ae4c227b49f10b5f21595cdf2ce758e09a7402be5",
"rawBytes": 1089,
"gzipBytes": 754,
"gzipBytes": 752,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-CmrzFyoP.js"
"houseplan-assets/houseplan-card-Cjdt1t7f.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="c673cca61c08a44252a2303b34ba28b96411e9d5fddb18c938f79fbbc00ccd7f";import{c as e,l as o,t,c_ as s,A as a,c$ as i,d0 as l,d1 as n,E as r,b as c}from"./houseplan-card-CmrzFyoP.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="20bb943045ad5eaa2ebc207fc30c1f5a570b0157da1a32450c71882cbd939019";import{c as e,l as o,t,c_ as s,A as a,c$ as i,d0 as l,d1 as n,E as r,b as c}from"./houseplan-card-Cjdt1t7f.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},_=this._schema,f=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
${d(_.slice(0,3))}
${f?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
${t(s,"editor.default_floor_missing",{id:f})}
</div>`:a}
${d(f.slice(3))}
${d(_.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
@@ -1 +1 @@
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="c673cca61c08a44252a2303b34ba28b96411e9d5fddb18c938f79fbbc00ccd7f";export{t as a,e as c,o as i,r as s,s as t};
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function a(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),a=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),t=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&a.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:t,count:t.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="20bb943045ad5eaa2ebc207fc30c1f5a570b0157da1a32450c71882cbd939019";export{a,e as c,o as i,r as s,s as t};
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="c673cca61c08a44252a2303b34ba28b96411e9d5fddb18c938f79fbbc00ccd7f";try{await import("./houseplan-assets/houseplan-card-CmrzFyoP.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="20bb943045ad5eaa2ebc207fc30c1f5a570b0157da1a32450c71882cbd939019";try{await import("./houseplan-assets/houseplan-card-Cjdt1t7f.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+11 -8
View File
@@ -1259,10 +1259,11 @@ its configured space.
`_lightBarriers` collects everything opaque: the wall bodies exactly as the
plan draws them (`wallBodiesGeometry`, real thickness, mitred junctions),
every independent body (partition, column, draft), and the bare outline of
any edge that carries no thickness. It cuts out the exceptions: doorways
and gates — cut through the masonry, so an opening is a real gap
between two jamb faces and a beam is narrowed by the returns of a thick wall
— plus dashed zero-thickness walls. Solid zero-thickness walls instead add
any edge that carries no thickness. It cuts out the exceptions: interior
doorways and gates according to their resolved live opening amount — a
closed bound opening keeps the masonry, while a positional cover cuts a
centre-aligned fraction between the jambs — and saved passages, which remain
fully open, plus dashed zero-thickness walls. Solid zero-thickness walls instead add
their exact axes as zero-area barriers. Windows stay
solid, so an indoor lamp never washes the street; the light's masonry is cut
by passages only and therefore differs on purpose from the drawn one. So does
@@ -1289,10 +1290,12 @@ its configured space.
open-zone graph and no shadow mask left in the light path.
Barriers are cached per space by a fingerprint of their complete geometry
(every body point, both wall endpoints and scale inputs), never by
`_cfgEpoch`: the epoch lags behind geometry edited in place, and a stale
barrier set is invisible — the plan simply keeps lighting through a wall that
now exists. The same fingerprint keys the per-source region cache. A cached per-
(every body point, both wall endpoints and scale inputs) plus a sorted
signature of bound interior door/gate opening amounts, never by `_cfgEpoch`:
the epoch lags behind geometry edited in place, and a stale barrier set is
invisible — the plan simply keeps lighting through a wall or closed door that
now exists. Unrelated HA updates retain the same signature and reuse the
barrier set. The combined fingerprint keys the per-source region cache. A cached per-
`Document` raster probe verifies actual SVG screen pixels rather than trusting
CSS syntax support; pending/unsupported/error/timeout states render with
deterministic normal blending and a successful probe requests one update.
+6
View File
@@ -8,6 +8,12 @@
first frame was also the last one
([#358](https://github.com/Matysh/houseplan-card/issues/358)).
- Glow now follows the live state of bound interior doors and gates: a closed
opening blocks light, an open one passes it, and a positional cover exposes
the matching fraction of the aperture. Passages remain open, windows and
exterior openings remain opaque, and unrelated Home Assistant updates reuse
the existing light geometry ([#20](https://github.com/Matysh/houseplan-card/issues/20)).
- Tapping a device on a freshly opened page works again without first
visiting any editor screen. A wall switch controlling virtual light sources
(and every other tap-to-toggle device) silently ignored the tap on a cold
+7
View File
@@ -14,6 +14,13 @@
загрузила, — первый кадр оказывался последним
([#358](https://github.com/Matysh/houseplan-card/issues/358)).
- Glow теперь учитывает текущее состояние привязанных внутренних дверей и
ворот: закрытый проём останавливает свет, открытый пропускает его, а cover с
позицией открывает соответствующую долю прохода. Открытые проходы остаются
прозрачными, окна и наружные проёмы — непрозрачными; посторонние обновления
Home Assistant переиспользуют готовую геометрию света
([#20](https://github.com/Matysh/houseplan-card/issues/20)).
- Нажатие на устройство на только что открытой странице снова работает без
предварительного захода в какой-либо редактор. Выключатель, управляющий
виртуальными источниками света (и любое другое устройство с тап-переключением),
+23 -9
View File
@@ -20,7 +20,8 @@ all. None of those states is expressible now.
## What stops light, and what does not
`_lightBarriers()` (`src/houseplan-card.ts`) builds the barrier set once per
plan geometry and shares it between every lamp in the space.
plan geometry and relevant opening-state signature, then shares it between
every lamp in the space.
**Opaque**
@@ -34,8 +35,11 @@ plan geometry and shares it between every lamp in the space.
**Transparent**
- doorways, gates and saved `passage` openings — cut through the masonry, so an opening is a real
gap between two jamb faces and a thick wall's returns narrow the beam;
- doorways and gates to floor on both sides, but only by their resolved opening
amount. An unbound opening is fully transparent, a bound closed one is opaque,
and a positional cover cuts a centre-aligned fraction of the full aperture;
- saved `passage` openings — always cut through the masonry, so an opening is a
real gap between two jamb faces and a thick wall's returns narrow the beam;
- every zero-thickness wall when its space uses the **Dashed** style.
The setting is intentionally one semantic switch, not just paint. Missing or
@@ -52,7 +56,12 @@ input is only a v8 read projection and migrates to ordinary `cm:0` atoms.
sides are floor; otherwise a front door glows halfway — up to the centreline,
where the room polygon ends — and the plan shows a lit doorway to nowhere.
The classifier is an explicit `door | gate | passage` allowlist. Any unknown
The classifier is an explicit `door | gate | passage` allowlist. Door and gate
state is resolved by the same `openingAmount()` used for their visible symbol:
binary contacts yield zero or full aperture, finite `current_position` yields
`clamp(position / 100)`, and `invert` mirrors a known amount. Missing,
disabled, `unknown` or `unavailable` contacts preserve the static-plan fallback
of a fully open door/gate rather than manufacturing a closure. Any unknown
future opening type remains opaque until its physical semantics are reviewed;
it never inherits transparency merely by not being a window.
@@ -129,11 +138,13 @@ pass over the layer costs a fifth of one blurred mask per source.
## Caching
Barriers are keyed by a fingerprint of their own geometry, never by
`_cfgEpoch`: the epoch lags behind geometry edited in place, and a stale barrier
set is invisible — the plan simply keeps lighting through a wall that now
exists. The same fingerprint, plus position and radius, keys the per-source
region cache (`_glowClipCache`).
Barriers are keyed by their geometry fingerprint plus a compact, sorted
signature of bound interior door/gate opening amounts, never by `_cfgEpoch`:
the epoch lags behind geometry edited in place, and a stale barrier set is
invisible — the plan simply keeps lighting through a wall or closed door that
now exists. Unrelated HA updates preserve the signature and hit the bounded
barrier cache. The combined fingerprint, plus source position and radius, keys
the per-source region cache (`_glowClipCache`).
The masonry boolean receives room walls after passage cuts plus the cached
joined independent body set. Its outer/hole rings are the authoritative
@@ -216,6 +227,9 @@ The complete UI and runtime truth table lives in
exterior opening masonry, wall bodies, partitions and columns are fail-dark,
while a real interior passage remains a valid source position; the rendered
Glow path is pinned to that shared guard.
- `test/logic.test.mjs` and `test/physical-geometry.test.mjs` — the shared
opening amount, stable state signature and centre-preserving partial cut for
contour and independent walls.
- `demo/smoke_glow.mjs` — pixels on a rendered plan: the aperture itself is lit,
the floor behind a door is lit, the visible beam is no wider than twice the
opening, there is no light behind a column while the floor beside it is lit,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 134 KiB

+21 -21
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": null,
"sourceFingerprint": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"sourceFingerprint": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"captureScriptSha256": "fe76c8e2e27501e9a377d7aa15dd6cba6a018d55c07deb602dbee097f0a0a5ba",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,8 +15,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"imageSha256": "78d328498bd17a9ee1ec44b389d244b61fca9ac52c9aad6631aa85c53233fec0"
"sourceSha256": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -26,8 +26,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"imageSha256": "3be239a5a494f6776540a9c0049694ee6f9d2ba927c8bcb4b26ee5a69a228a7d"
"sourceSha256": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"imageSha256": "a4aa3af48578ee41594a9b1a6e98b4d39f3324a949a9fd9e63d5854576d5db85"
},
"space-create": {
"file": "03-space-create.png",
@@ -37,8 +37,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"imageSha256": "a55b463055a565892ca139014e314a8f63fb5301c87fff2e48d7e69210fe9b6c"
"sourceSha256": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
},
"room-contour-close": {
"file": "04-room-contour-close.png",
@@ -48,8 +48,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"imageSha256": "dfad9a27371cfa397a3fd8dce7005f42d24649743f5322105ee894067d3a9f16"
"sourceSha256": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -59,8 +59,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"imageSha256": "b75f348c632296da5c2fc142d53df613f757e1516acac315be50f5b4557729cc"
"sourceSha256": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -70,8 +70,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
"sourceSha256": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -81,8 +81,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
"sourceSha256": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -92,8 +92,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
"sourceSha256": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
"file": "08-room-card.png",
@@ -103,8 +103,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
"sourceSha256": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
"file": "09-device-info.png",
@@ -114,8 +114,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "2bb218477956888a84706e4a8a76248b823d70dc26fc80bcc5272c30e4556a38",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
"sourceSha256": "0896c482848c70ed7071b4e2396294fd83072352403a859447b53fc3c10cc6ae",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
}
+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 = {
+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: [],