fix(geometry): canonicalize decor image coordinates

Issue: #431
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-03 11:26:31 +03:00
parent d97bf63feb
commit 58efb6a1b2
20 changed files with 301 additions and 139 deletions
@@ -14,6 +14,7 @@ COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
LATTICE_GRID_N = 240
LATTICE_NOISE_STEPS = 1e-4
DECOR_BOX_KINDS = ("rect", "ellipse", "furniture", "image")
def canonicalize_number(value: Any) -> Any:
@@ -150,7 +151,7 @@ def canonicalize_config_geometry(config: Any) -> Any:
kind = decor.get("kind")
if kind == "line":
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
elif kind in ("rect", "ellipse", "furniture"):
elif kind in DECOR_BOX_KINDS:
_lattice_fields(decor, ("x", "y", "w", "h"))
_scalar_fields(decor, ("angle",))
elif kind == "text":
+54 -54
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "cfbfe510d0898a501060f644db55c8c884e95a72e40d98716de05df63a66dc17",
"fingerprint": "0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-D_n3AgaN.js",
"houseplan-assets/houseplan-card-Bx2y9RQ5.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 290950,
"initialViewGzipBytes": 290965,
"lazyFiles": [
"houseplan-assets/backdrop-pick-CXBNIJbr.js",
"houseplan-assets/de-DQual6Kp.js",
"houseplan-assets/editor-9GYON2-E.js",
"houseplan-assets/fr-jAhvVsg_.js",
"houseplan-assets/houseplan-editor-runtime-oFEwsnfR.js",
"houseplan-assets/houseplan-onboarding-runtime-B21xb3Em.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/de-BPlkz0Hx.js",
"houseplan-assets/editor-C6k58Bzs.js",
"houseplan-assets/fr-Cv32nFOv.js",
"houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js",
"houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js"
],
"lazyGzipBytes": 215461,
"lazyGzipBytes": 215455,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-CXBNIJbr.js",
"houseplan-assets/editor-9GYON2-E.js",
"houseplan-assets/houseplan-editor-runtime-oFEwsnfR.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/editor-C6k58Bzs.js",
"houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js"
],
"lazyEditorGzipBytes": 159773,
"lazyEditorGzipBytes": 159774,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-CXBNIJbr.js",
"houseplan-assets/houseplan-onboarding-runtime-B21xb3Em.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js"
],
"lazyOnboardingGzipBytes": 14131,
"lazyLocaleFiles": [
"houseplan-assets/de-DQual6Kp.js",
"houseplan-assets/fr-jAhvVsg_.js"
"houseplan-assets/de-BPlkz0Hx.js",
"houseplan-assets/fr-Cv32nFOv.js"
],
"lazyLocaleGzipBytes": 48733,
"lazyLocaleGzipBytes": 48727,
"files": [
{
"path": "houseplan-assets/backdrop-pick-CXBNIJbr.js",
"sha256": "1f4f4b3dddd1727c8e8b18f652e2a5a298640b95e842ffa1b59d3c992196153f",
"path": "houseplan-assets/backdrop-pick-NhWEACal.js",
"sha256": "27fac5544c482a28f5f4cb05a114202d293da133076c37953bcd4e7643a9574b",
"rawBytes": 20996,
"gzipBytes": 7176,
"gzipBytes": 7177,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-D_n3AgaN.js"
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-DQual6Kp.js",
"sha256": "0dfde1a5371337cc97c482475f04163d9f38ebf5b10f67253d0ddb153f3150c5",
"path": "houseplan-assets/de-BPlkz0Hx.js",
"sha256": "544d69da019bb0c3aa5d1d112f9e09a4278ef77ea61a040aa8417ffd2955f594",
"rawBytes": 81632,
"gzipBytes": 24638,
"gzipBytes": 24635,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-9GYON2-E.js",
"sha256": "e238f6e60c5a599796f33eb4905e8745662da09c4ec6c0bf8c03dd9817aa40dd",
"path": "houseplan-assets/editor-C6k58Bzs.js",
"sha256": "aba31c59c20787606d75b3fb196520a55e20b58b510a084f7d7f12f0cfca4a91",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1581,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-D_n3AgaN.js"
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-jAhvVsg_.js",
"sha256": "5aa1b8e8e883a319aaa8a3fbbdff3a385b6f9897e1bd04231009e3b78c39d693",
"path": "houseplan-assets/fr-Cv32nFOv.js",
"sha256": "b77f4c31d7343901e8e17b1377c646e0057574a56875274156517de90b69e5da",
"rawBytes": 83754,
"gzipBytes": 24095,
"gzipBytes": 24092,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-D_n3AgaN.js",
"sha256": "d4c5e737a971cc8296394ed0ef85a8387d3e77195263f66196638b96eb3f2833",
"rawBytes": 1032923,
"gzipBytes": 290155,
"path": "houseplan-assets/houseplan-card-Bx2y9RQ5.js",
"sha256": "86231c5d7b42f208e29547b78e1e122eb07dd6374b90298a96fbd68048bb8310",
"rawBytes": 1032927,
"gzipBytes": 290170,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-DQual6Kp.js",
"houseplan-assets/editor-9GYON2-E.js",
"houseplan-assets/fr-jAhvVsg_.js",
"houseplan-assets/houseplan-editor-runtime-oFEwsnfR.js",
"houseplan-assets/houseplan-onboarding-runtime-B21xb3Em.js"
"houseplan-assets/de-BPlkz0Hx.js",
"houseplan-assets/editor-C6k58Bzs.js",
"houseplan-assets/fr-Cv32nFOv.js",
"houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js",
"houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-oFEwsnfR.js",
"sha256": "82a25a6e97f7bab1e93ea6f8617e6932a76ff51fdb20cf569357ee4786c4f316",
"path": "houseplan-assets/houseplan-editor-runtime-bWrIAGv1.js",
"sha256": "6d689b4b7623b306e9a60f15ddcd54ff2bcafdb7c8af7536e865e1393a6c3ba3",
"rawBytes": 575918,
"gzipBytes": 151017,
"gzipBytes": 151016,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CXBNIJbr.js",
"houseplan-assets/houseplan-card-D_n3AgaN.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-B21xb3Em.js",
"sha256": "ad013f565d0a364dafac7e27dea593179cf22fd50697f108aa5cfb049d4ef75b",
"path": "houseplan-assets/houseplan-onboarding-runtime-B1h4ymeY.js",
"sha256": "0410abdf58ae271c96a2350948f800177d4095db472dd773487563ea9aa2518b",
"rawBytes": 28088,
"gzipBytes": 6955,
"gzipBytes": 6954,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CXBNIJbr.js",
"houseplan-assets/houseplan-card-D_n3AgaN.js"
"houseplan-assets/backdrop-pick-NhWEACal.js",
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "d21d311123d5faf9d1833afe4b69aa8d6295eaa358dbd6cdbb8cf8d364960b02",
"sha256": "b250ac4f45a25fb4066fec09bd65042803bc8585c487af955532253489b5ec1b",
"rawBytes": 1183,
"gzipBytes": 795,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-D_n3AgaN.js"
"houseplan-assets/houseplan-card-Bx2y9RQ5.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="cfbfe510d0898a501060f644db55c8c884e95a72e40d98716de05df63a66dc17";import{b as e,l as o,t,dg as s,A as a,dh as i,di as l,dj as n,E as r,c}from"./houseplan-card-D_n3AgaN.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__="0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210";import{b as e,l as o,t,dg as s,A as a,dh as i,di as l,dj as n,E as r,c}from"./houseplan-card-Bx2y9RQ5.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
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="cfbfe510d0898a501060f644db55c8c884e95a72e40d98716de05df63a66dc17";import{l as t,aE as s,A as e,t as o,c as a,c4 as l,c5 as i,c6 as h,c7 as c,aB as n,c8 as r,cd as p,c9 as d,ca as _,cb as u,cc as g,d5 as m,aA as b,aC as $,d6 as v}from"./houseplan-card-D_n3AgaN.js";import{i as f,c as y,e as w,r as D,a as k,b as S,s as C,t as M}from"./backdrop-pick-CXBNIJbr.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="cfbfe510d0898a501060f644db55c8c884e95a72e40d98716de05df63a66dc17";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210";import{l as t,aE as s,A as e,t as o,c as a,c4 as l,c5 as i,c6 as h,c7 as c,aB as n,c8 as r,cd as p,c9 as d,ca as _,cb as u,cc as g,d5 as m,aA as b,aC as $,d6 as v}from"./houseplan-card-Bx2y9RQ5.js";import{i as f,c as y,e as w,r as D,a as k,b as S,s as C,t as M}from"./backdrop-pick-NhWEACal.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:_,tempMax:d,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="cfbfe510d0898a501060f644db55c8c884e95a72e40d98716de05df63a66dc17";try{await import("./houseplan-assets/houseplan-card-D_n3AgaN.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).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+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__="0cb19ee6d556f05dfee84ade0b3ec3f9e06f99985d1fd026d9f3310d29679210";try{await import("./houseplan-assets/houseplan-card-Bx2y9RQ5.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).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+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)}
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- Custom decor images now pass through the same stable coordinate-write barrier
as furniture and shapes, so repeated saves and **Optimize Plans** no longer
retain image-only floating-point noise
([#431](https://github.com/Matysh/houseplan-card/issues/431)).
- Exports made while a reusable decor-image file is missing can now be imported
with the existing confirmation instead of rejecting the whole plan; full,
single-space and plan-only exports preserve the repairable image object
+4
View File
@@ -8,6 +8,10 @@
## Не выпущено
- Пользовательские изображения декора теперь проходят тот же стабильный барьер
записи координат, что мебель и фигуры, поэтому повторные сохранения и
«Оптимизировать планы» больше не сохраняют float-шум только у изображений
([#431](https://github.com/Matysh/houseplan-card/issues/431)).
- Экспорт, созданный при отсутствующем файле пользовательской картинки декора,
теперь можно импортировать с обычным подтверждением вместо ошибки всего
плана; полный экспорт, одно пространство и режим «Только планировка»
+6
View File
@@ -149,6 +149,12 @@ while the frontend adopts the exact candidate it sends. This removes ULP noise
without changing the schema, JSON number type, model/store version or visible
placement.
Decor uses an explicit box-geometry catalog shared by the frontend contract and
mirrored by the integration: `rect`, `ellipse`, `furniture` and `image`.
Their `x/y/w/h` fields follow the lattice rule above and `angle` follows the
scalar rule; image asset, opacity, mirror flags and unknown fields are not
geometry and remain unchanged.
The operation is lossless at the product scale and intentionally narrow.
`view_box`, `cell_cm`, `plan_aspect`, physical centimetre values,
presentation settings, colours, opacity/brightness/temperature, vacuum
@@ -2,7 +2,7 @@
Issue: [#431](https://github.com/Matysh/houseplan-card/issues/431)
Статус документа: ТЗ на ревью.
Статус документа: реализовано.
Источники контракта: [ТЗ #51](051-custom-decor-images.md),
[ТЗ #224](224-config-coordinate-canonicalization.md) и
+26
View File
@@ -2335,6 +2335,32 @@ const MUTANT_DEFINITIONS = [
replace: ' const candidate = this._serverCfg;',
}],
},
{
id: 'image-box-frontend-canonicalization-omitted',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="decor box catalog" '
+ 'test/coordinate-canonicalization.test.mjs',
because: 'the decor type can know about an image while a handwritten traversal silently '
+ 'leaves its x/y/w/h outside the canonical write barrier (#431 AC4)',
patches: [{
file: 'src/coordinate-canonicalization.ts',
find: ' && (DECOR_BOX_KINDS as readonly string[]).includes(value);',
replace: " && value !== 'image'\n"
+ ' && (DECOR_BOX_KINDS as readonly string[]).includes(value);',
}],
},
{
id: 'image-box-python-canonicalization-omitted',
guard: 'node scripts/backend-test-guard.mjs decor_box_catalog_matches_shared_contract '
+ 'tests_backend/test_coordinate_canonicalization.py',
because: 'the Python schema and storage boundary must mirror the complete frontend box '
+ 'catalog instead of accepting image floating-point tails from older clients (#431 AC4)',
patches: [{
file: 'custom_components/houseplan/coordinate_canonicalization.py',
find: 'DECOR_BOX_KINDS = ("rect", "ellipse", "furniture", "image")',
replace: 'DECOR_BOX_KINDS = ("rect", "ellipse", "furniture")',
}],
},
{
id: 'quantization-hits-allowlist',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+14 -2
View File
@@ -5,6 +5,13 @@
* Keep the precision, lattice formula and field allow-list in lockstep.
*/
import {
DECOR_BOX_KINDS,
type DecorBoxKind,
} from './editors/decor/types';
export { DECOR_BOX_KINDS };
export const COORDINATE_DECIMALS = 9;
export const COORDINATE_FACTOR = 10 ** COORDINATE_DECIMALS;
export const LATTICE_GRID_N = 240;
@@ -84,6 +91,11 @@ function records(value: unknown): JsonRecord[] {
: [];
}
function isDecorBoxKind(value: unknown): value is DecorBoxKind {
return typeof value === 'string'
&& (DECOR_BOX_KINDS as readonly string[]).includes(value);
}
function scalarFields(item: JsonRecord, names: readonly string[]): void {
for (const name of names) {
if (Object.prototype.hasOwnProperty.call(item, name)) {
@@ -155,7 +167,7 @@ function visitLatticeCoordinates(
for (const opening of records(space.openings)) fieldValues(opening, ['x', 'y']);
for (const decor of records(space.decor)) {
if (decor.kind === 'line') fieldValues(decor, ['x1', 'y1', 'x2', 'y2']);
else if (decor.kind === 'rect' || decor.kind === 'ellipse' || decor.kind === 'furniture') {
else if (isDecorBoxKind(decor.kind)) {
fieldValues(decor, ['x', 'y', 'w', 'h']);
} else if (decor.kind === 'text') fieldValues(decor, ['x', 'y']);
}
@@ -335,7 +347,7 @@ export function canonicalizeConfigGeometryInPlace<T>(config: T): T {
for (const decor of records(space.decor)) {
if (decor.kind === 'line') latticeFields(decor, ['x1', 'y1', 'x2', 'y2']);
else if (decor.kind === 'rect' || decor.kind === 'ellipse' || decor.kind === 'furniture') {
else if (isDecorBoxKind(decor.kind)) {
latticeFields(decor, ['x', 'y', 'w', 'h']);
scalarFields(decor, ['angle']);
} else if (decor.kind === 'text') {
+3 -1
View File
@@ -5,7 +5,9 @@
* presentation (`width_cm`) is deliberately separate from geometry: resizing
* an object must never make its outline thicker.
*/
export type DecorKind = 'line' | 'rect' | 'ellipse' | 'text' | 'furniture' | 'image';
export const DECOR_BOX_KINDS = ['rect', 'ellipse', 'furniture', 'image'] as const;
export type DecorBoxKind = (typeof DECOR_BOX_KINDS)[number];
export type DecorKind = 'line' | 'text' | DecorBoxKind;
export interface DecorBase {
id: string;
+52
View File
@@ -5,6 +5,7 @@ import { readHouseplanProductionSource } from './houseplan-source.mjs';
import {
COORDINATE_DECIMALS,
DECOR_BOX_KINDS,
LATTICE_GRID_N,
LATTICE_NOISE_STEPS,
canonicalizeConfigGeometry,
@@ -39,6 +40,57 @@ test('frontend and backend share the scalar+lattice fixture contract (#291)', ()
assert.equal(Object.is(layout['rl:poly'].x, -0), false, 'negative zero becomes positive');
});
test('decor box catalog canonicalizes every box kind and preserves image metadata (#431)', () => {
assert.deepEqual(DECOR_BOX_KINDS, fixture.boxKinds);
const inputBoxes = fixture.configInput.spaces[0].decor
.filter((item) => fixture.boxKinds.includes(item.kind));
assert.deepEqual(
inputBoxes.map((item) => item.kind).sort(),
[...fixture.boxKinds].sort(),
'the shared fixture covers the exact runtime box catalog',
);
const output = canonicalizeConfigGeometry(fixture.configInput);
const outputById = new Map(output.spaces[0].decor.map((item) => [item.id, item]));
const expectedById = new Map(fixture.configExpected.spaces[0].decor.map((item) => [item.id, item]));
for (const input of inputBoxes) {
const actual = outputById.get(input.id);
const expected = expectedById.get(input.id);
for (const field of ['x', 'y', 'w', 'h', 'angle']) {
assert.equal(actual[field], expected[field], `${input.kind}.${field} is canonical`);
assert.notEqual(actual[field], input[field], `${input.kind}.${field} exercises the barrier`);
}
}
const inputImage = inputBoxes.find((item) => item.kind === 'image');
const actualImage = outputById.get('image');
for (const field of ['asset_id', 'opacity', 'flip_h', 'flip_v', 'future']) {
assert.deepEqual(actualImage[field], inputImage[field], `image.${field} is preserved`);
}
const futureBox = {
id: 'future', kind: 'future-box', x: 0.1000000004, y: 0.2000000006,
w: 0.3000000004, h: 0.4000000006, angle: 12.1234567896,
};
const unknownResult = canonicalizeConfigGeometry({ spaces: [{ decor: [futureBox] }] });
assert.deepEqual(unknownResult.spaces[0].decor[0], futureBox);
});
test('lattice report includes image box coordinates (#431)', () => {
const node = 83 / 240;
const noise = Number(node.toFixed(9));
const report = latticeCanonicalizationReport({ spaces: [{
id: 'floor', title: 'Floor', cell_cm: 5,
decor: [{ id: 'image', kind: 'image', x: noise, y: node, w: noise, h: 0.06 }],
}] });
assert.equal(report.canonicalized, 2);
assert.equal(report.far, 1);
assert.equal(report.spaces.length, 1);
assert.equal(report.spaces[0].canonicalized, 2);
assert.equal(report.spaces[0].far, 1);
});
test('scalar canonicalization is symmetric and never snaps off-grid geometry (#224)', () => {
assert.equal(canonicalizeNumber(1.2345678905), 1.234567891);
assert.equal(canonicalizeNumber(-1.2345678905), -1.234567891);
+29
View File
@@ -1,5 +1,6 @@
{
"decimals": 9,
"boxKinds": ["rect", "ellipse", "furniture", "image"],
"configInput": {
"spaces": [
{
@@ -117,6 +118,20 @@
"w": 0.1000000004,
"h": 0.1000000006,
"angle": 90.1234567896
},
{
"id": "image",
"kind": "image",
"asset_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"x": 0.5000000004,
"y": 0.5000000006,
"w": 0.2000000004,
"h": 0.3000000006,
"angle": 45.1234567896,
"opacity": 0.5000000004,
"flip_h": true,
"flip_v": false,
"future": {"numeric": 0.1234567896}
}
],
"room_drafts": [
@@ -318,6 +333,20 @@
"w": 0.1,
"h": 0.1,
"angle": 90.12345679
},
{
"id": "image",
"kind": "image",
"asset_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"x": 0.5,
"y": 0.5,
"w": 0.2,
"h": 0.3,
"angle": 45.12345679,
"opacity": 0.5000000004,
"flip_h": true,
"flip_v": false,
"future": {"numeric": 0.1234567896}
}
],
"room_drafts": [
@@ -32,6 +32,7 @@ pytest.importorskip("homeassistant", reason="модуль тянет HA чере
from custom_components.houseplan import virtual_lights
from custom_components.houseplan.coordinate_canonicalization import (
COORDINATE_DECIMALS,
DECOR_BOX_KINDS,
LATTICE_GRID_N,
LATTICE_NOISE_STEPS,
canonicalize_config_geometry,
@@ -50,7 +51,6 @@ from custom_components.houseplan.validation import (
)
from custom_components.houseplan.wall_segment_model import commit_wall_segment_model
FIXTURE = (
Path(__file__).parents[1]
/ "test"
@@ -89,6 +89,32 @@ def test_python_and_frontend_share_the_scalar_lattice_fixture_contract() -> None
assert math.copysign(1.0, layout["rl:poly"]["x"]) == 1.0
def test_decor_box_catalog_matches_shared_contract() -> None:
fixture = _fixture()
assert list(DECOR_BOX_KINDS) == fixture["boxKinds"]
input_decor = fixture["configInput"]["spaces"][0]["decor"]
input_boxes = [item for item in input_decor if item["kind"] in DECOR_BOX_KINDS]
assert sorted(item["kind"] for item in input_boxes) == sorted(fixture["boxKinds"])
result = canonicalize_config_geometry(fixture["configInput"])
output_by_id = {item["id"]: item for item in result["spaces"][0]["decor"]}
expected_by_id = {
item["id"]: item for item in fixture["configExpected"]["spaces"][0]["decor"]
}
for source in input_boxes:
actual = output_by_id[source["id"]]
expected = expected_by_id[source["id"]]
for field in ("x", "y", "w", "h", "angle"):
assert actual[field] == expected[field]
assert actual[field] != source[field]
source_image = next(item for item in input_boxes if item["kind"] == "image")
image = output_by_id["image"]
for field in ("asset_id", "opacity", "flip_h", "flip_v", "future"):
assert image[field] == source_image[field]
def test_scalar_contract_is_symmetric_and_keeps_off_grid_geometry() -> None:
assert canonicalize_number(1.2345678905) == 1.234567891
assert canonicalize_number(-1.2345678905) == -1.234567891