feat: add smooth furniture transforms

Issue: #383
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-30 11:56:35 +03:00
parent 4a83bd0540
commit 686557c5cf
55 changed files with 1360 additions and 536 deletions
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "7d9dd4574e49f91701004b99bff143525cff2f5b0a41568acab1e979cf27c06d",
"fingerprint": "0dd9b462752134961ced7b2cc16ed4e83c20a99e0304df7db05c3cb4d6a64b3f",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DtBnECuO.js",
"houseplan-assets/houseplan-card-2zPsVKa0.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 277994,
"initialViewGzipBytes": 279147,
"lazyFiles": [
"houseplan-assets/backdrop-pick-aV3NiaC9.js",
"houseplan-assets/de-fvCwSozd.js",
"houseplan-assets/editor-Cg0xzOaz.js",
"houseplan-assets/fr-lrcBAJ0g.js",
"houseplan-assets/houseplan-editor-runtime-DBo3J7HA.js",
"houseplan-assets/houseplan-onboarding-runtime-9MJDx5Yx.js"
"houseplan-assets/backdrop-pick-Cpw00Iem.js",
"houseplan-assets/de-SitG35ct.js",
"houseplan-assets/editor-BS87B7_Z.js",
"houseplan-assets/fr-C6YF5img.js",
"houseplan-assets/houseplan-editor-runtime-Clod3Q5A.js",
"houseplan-assets/houseplan-onboarding-runtime-DW-o2kzI.js"
],
"lazyGzipBytes": 192313,
"lazyGzipBytes": 193146,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-aV3NiaC9.js",
"houseplan-assets/editor-Cg0xzOaz.js",
"houseplan-assets/houseplan-editor-runtime-DBo3J7HA.js"
"houseplan-assets/backdrop-pick-Cpw00Iem.js",
"houseplan-assets/editor-BS87B7_Z.js",
"houseplan-assets/houseplan-editor-runtime-Clod3Q5A.js"
],
"lazyEditorGzipBytes": 140237,
"lazyEditorGzipBytes": 141036,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-aV3NiaC9.js",
"houseplan-assets/houseplan-onboarding-runtime-9MJDx5Yx.js"
"houseplan-assets/backdrop-pick-Cpw00Iem.js",
"houseplan-assets/houseplan-onboarding-runtime-DW-o2kzI.js"
],
"lazyOnboardingGzipBytes": 9450,
"lazyOnboardingGzipBytes": 9447,
"lazyLocaleFiles": [
"houseplan-assets/de-fvCwSozd.js",
"houseplan-assets/fr-lrcBAJ0g.js"
"houseplan-assets/de-SitG35ct.js",
"houseplan-assets/fr-C6YF5img.js"
],
"lazyLocaleGzipBytes": 45581,
"lazyLocaleGzipBytes": 45617,
"files": [
{
"path": "houseplan-assets/backdrop-pick-aV3NiaC9.js",
"sha256": "d69bf449fbfbf78d5ecb20946dd08960412cdb7d87a8acb896b0f7db15bed072",
"path": "houseplan-assets/backdrop-pick-Cpw00Iem.js",
"sha256": "dbff8db361964d5f92d2d78171b5dbf36170c25a21fdee6c15b8b6e45eca0001",
"rawBytes": 6967,
"gzipBytes": 2955,
"gzipBytes": 2954,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DtBnECuO.js"
"houseplan-assets/houseplan-card-2zPsVKa0.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-fvCwSozd.js",
"sha256": "8ee8a41141e01712bd9441c2074909bd966bcc3351565aecc7d79f3646779e0a",
"rawBytes": 75596,
"gzipBytes": 23009,
"path": "houseplan-assets/de-SitG35ct.js",
"sha256": "62f5c67eadeefdb42d0866a6c00eefcd186fd153913e21528447dd6da3e469c6",
"rawBytes": 75668,
"gzipBytes": 23026,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Cg0xzOaz.js",
"sha256": "41786591029748d0f10d5e3dfddeba9d819a6c46d8e1e363098295fdc38a8411",
"path": "houseplan-assets/editor-BS87B7_Z.js",
"sha256": "33f60174950207011d09f7f1dd33054eb4482186837356450b0a8c04d116c4af",
"rawBytes": 3831,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DtBnECuO.js"
"houseplan-assets/houseplan-card-2zPsVKa0.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-lrcBAJ0g.js",
"sha256": "80bae04891526c11b3b7dc17b0ac97958e66b77fe8efea2bdacad059fe5128c3",
"rawBytes": 77737,
"gzipBytes": 22572,
"path": "houseplan-assets/fr-C6YF5img.js",
"sha256": "d52f235d4c5dc84e6c23b6c0f5595d2d40a22248c17442b82e53eb511abd832a",
"rawBytes": 77821,
"gzipBytes": 22591,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DtBnECuO.js",
"sha256": "e045e2ed3f28ec80afdecb90b818f57599775756f34b74dddb0a7da297872c42",
"rawBytes": 979611,
"gzipBytes": 277198,
"path": "houseplan-assets/houseplan-card-2zPsVKa0.js",
"sha256": "015c1f66878c0276f4623c1642de7f4f088ac630100dacd28ae040c99ed0bb50",
"rawBytes": 983144,
"gzipBytes": 278351,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-fvCwSozd.js",
"houseplan-assets/editor-Cg0xzOaz.js",
"houseplan-assets/fr-lrcBAJ0g.js",
"houseplan-assets/houseplan-editor-runtime-DBo3J7HA.js",
"houseplan-assets/houseplan-onboarding-runtime-9MJDx5Yx.js"
"houseplan-assets/de-SitG35ct.js",
"houseplan-assets/editor-BS87B7_Z.js",
"houseplan-assets/fr-C6YF5img.js",
"houseplan-assets/houseplan-editor-runtime-Clod3Q5A.js",
"houseplan-assets/houseplan-onboarding-runtime-DW-o2kzI.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DBo3J7HA.js",
"sha256": "702ceb450bc56585f87ebaff2888fa584a87a8b953ba3e439fe1e01eb52adafd",
"rawBytes": 526561,
"gzipBytes": 135699,
"path": "houseplan-assets/houseplan-editor-runtime-Clod3Q5A.js",
"sha256": "32a55e71fd2e9e958860c0e7e62e1e537f8c787b69192b5f88b613bfc638d3d3",
"rawBytes": 530418,
"gzipBytes": 136499,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-aV3NiaC9.js",
"houseplan-assets/houseplan-card-DtBnECuO.js"
"houseplan-assets/backdrop-pick-Cpw00Iem.js",
"houseplan-assets/houseplan-card-2zPsVKa0.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-9MJDx5Yx.js",
"sha256": "4debe25fee56b279d2656af252520d6e0c9267dbc0cd5daca9ac9f904b72795c",
"path": "houseplan-assets/houseplan-onboarding-runtime-DW-o2kzI.js",
"sha256": "a75c2ad698e2d59aa71c36572c8976d1b60b0e2671190c00ef2ce4570dc20dd8",
"rawBytes": 26195,
"gzipBytes": 6495,
"gzipBytes": 6493,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-aV3NiaC9.js",
"houseplan-assets/houseplan-card-DtBnECuO.js"
"houseplan-assets/backdrop-pick-Cpw00Iem.js",
"houseplan-assets/houseplan-card-2zPsVKa0.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "ea9294f40721467c6a61941e796290ca519fef0c9f2d27f93bedc8210a531f7c",
"sha256": "c1fbd7123af664bb5ba87018f51d8e85c22d2b7811d322656ed4d26a2fb1dbf9",
"rawBytes": 1183,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DtBnECuO.js"
"houseplan-assets/houseplan-card-2zPsVKa0.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,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d9dd4574e49f91701004b99bff143525cff2f5b0a41568acab1e979cf27c06d";import{c as e,l as o,t,c$ as s,A as a,d0 as i,d1 as l,d2 as n,E as r,b as c}from"./houseplan-card-DtBnECuO.js";class f 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,f={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")},h=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__="0dd9b462752134961ced7b2cc16ed4e83c20a99e0304df7db05c3cb4d6a64b3f";import{c as e,l as o,t,d3 as s,A as a,d4 as i,d5 as l,d6 as n,E as r,b as c}from"./houseplan-card-2zPsVKa0.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}
.computeLabel=${e=>f[e.name]||e.name}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${d(f.slice(0,3))}
${_?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:_})}
</div>`:a}
${d(h.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)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
${d(f.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);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d9dd4574e49f91701004b99bff143525cff2f5b0a41568acab1e979cf27c06d";try{await import("./houseplan-assets/houseplan-card-DtBnECuO.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__="0dd9b462752134961ced7b2cc16ed4e83c20a99e0304df7db05c3cb4d6a64b3f";try{await import("./houseplan-assets/houseplan-card-2zPsVKa0.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)}
+1 -1
View File
@@ -96,7 +96,7 @@ _DECOR_KIND_FIELDS = {
"rect": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
"furniture": ("symbol", "x", "y", "w", "h", "angle"),
"furniture": ("symbol", "x", "y", "w", "h", "angle", "flip_h", "flip_v"),
}
@@ -1374,6 +1374,7 @@ DECOR_SCHEMA = vol.Any(
vol.Required("symbol"): _FURN_SYMBOL,
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool,
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
extra=vol.ALLOW_EXTRA),
)
+14
View File
@@ -1203,6 +1203,20 @@ export async function prepareGoldenScenario(page, scenario) {
+ `cursor=${JSON.stringify(card._cursorPt)} nearest=${JSON.stringify(nearest)}`);
}
}
if (scenario.decorSelection) {
card._decorTool = 'select';
card._decorSel = scenario.decorSelection;
card.requestUpdate();
await card.updateComplete;
await frame();
const selected = card._decorList.find((shape) => shape.id === scenario.decorSelection);
const transformFrame = card.renderRoot.querySelector('.dtframe.dtfurnitureframe');
if (selected?.kind !== 'furniture' || !transformFrame
|| transformFrame.querySelectorAll('.dthandle.dtedge').length !== 4
|| transformFrame.querySelectorAll('.dthandle').length !== 9) {
throw new Error(`golden furniture transform frame is incomplete: ${scenario.id}`);
}
}
if (scenario.editorTray) {
let expectedKind = '';
if (scenario.editorTray === 'plan-selection') {
+32 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 51;
export const GOLDEN_MATRIX_VERSION = 52;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -432,6 +432,37 @@ export const GOLDEN_SCENARIOS = Object.freeze([
w: 0.26, h: 0.17, color: '#141832', opacity: 1, width_cm: 1.2 },
],
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'furniture-transform-light', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
decorOverride: [
...decorLayerFixture.filter((shape) => shape.kind !== 'furniture'),
{ id: 'furn-normal', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.08, y: 0.16,
w: 0.18, h: 0.12, color: '#141832', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-h', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.30, y: 0.16,
w: 0.18, h: 0.12, flip_h: true, color: '#141832', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-v', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.52, y: 0.16,
w: 0.18, h: 0.12, flip_v: true, color: '#141832', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-hv', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.74, y: 0.16,
w: 0.18, h: 0.12, flip_h: true, flip_v: true,
color: '#141832', opacity: 1, width_cm: 1.2 },
],
decorSelection: 'furn-flip-hv', language: 'ru', theme: 'light',
viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'furniture-transform-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
decorOverride: [
...decorLayerFixture.filter((shape) => shape.kind !== 'furniture'),
{ id: 'furn-normal', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.08, y: 0.16,
w: 0.18, h: 0.12, angle: 30, color: '#d8def0', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-h', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.30, y: 0.16,
w: 0.18, h: 0.12, angle: 30, flip_h: true,
color: '#d8def0', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-v', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.52, y: 0.16,
w: 0.18, h: 0.12, angle: 30, flip_v: true,
color: '#d8def0', opacity: 1, width_cm: 1.2 },
{ id: 'furn-flip-hv', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.74, y: 0.16,
w: 0.18, h: 0.12, angle: 30, flip_h: true, flip_v: true,
color: '#d8def0', opacity: 1, width_cm: 1.2 },
],
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'tray-narrow-tool-ru', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
editorTray: 'decor-tool', language: 'ru', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
+111 -21
View File
@@ -8,8 +8,9 @@
// конфиг; touch/pen не рисуют hover и сохраняют только чистый tap;
// 5) магнит к стене: предмет прижимается к ближайшей стене и доворачивается
// по ней; Shift временно обходит магнит, сохраняя grid snap;
// 6) у выделенного предмета та же рамка, что у текстового блока, — угловые
// ручки (НЕзависимые ширина и глубина) и ручка поворота с шагом 5°;
// 6) у выделенного предмета расширенная рамка: угловые и четыре одноосевые
// ручки; угол сохраняет пропорции без Shift, с Shift оси независимы;
// поворот свободный, а Shift привязывает его к 45°;
// 7) во время растягивания угла показываются живые плашки размеров;
// 8) фигура пишется в конфиг и переживает пересборку, хуки card-mod на месте.
// ПАДАЕТ на сборке до этой задачи: инструмента «Мебель» нет, палитры нет,
@@ -314,14 +315,28 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
// ================= 7. рамка, углы, плашки, поворот =======================
// Previous movement deliberately exercised the wall magnet and may have
// rotated the piece. Isolate the transform fixture at 0° and without flips.
c._curSpaceCfg.decor = c._decorList.map((shape) => {
if (shape.id !== sofaId) return shape;
const clean = { ...shape };
delete clean.angle; delete clean.flip_h; delete clean.flip_v;
return clean;
});
c._cfgEpoch++;
c._decorSel = sofaId; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.sameFrameAsTheTextBlock = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
&& frame().querySelectorAll('.dtknob').length === 5
out.furnitureFrameHasFourSideHandles = !!frame()
&& frame().classList.contains('dtfurnitureframe')
&& frame().querySelectorAll('.dthandle').length === 9
&& frame().querySelectorAll('.dtknob').length === 9
&& frame().querySelectorAll('.dthandle.dtedge').length === 4
&& !!frame().querySelector('.dtrot');
out.sideHandlesExposeAxisCursors = [...frame().querySelectorAll('.dthandle.dtedge')]
.every((handle) => ['ew-resize', 'ns-resize'].includes(getComputedStyle(handle).cursor));
out.rotationHandleUsesCircularCursor = getComputedStyle(frame().querySelector('.dtrot')).cursor.includes('url(');
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
out.handleSizeIsTheTaskOneSize = Math.abs(rOf('.dtknob') * 4 - rOf('.dthandle.dtrot'))
/ Math.max(rOf('.dthandle.dtrot'), 1e-9) < 0.05;
@@ -329,7 +344,16 @@ const res = await page.evaluate(async () => {
&& near(c._dtBox.w, sofaNow().w * 1000, 1e-6)
&& near(c._dtBox.h, sofaNow().h * 1000, 1e-6);
// угол: НЕзависимые ширина и глубина + живые плашки
const visiblePath = el(sofaId);
const hitPath = sr().querySelector(`.dfurniturehit[data-id="${sofaId}"]`);
out.selectionHaloUsesTheRealArtworkPath = !!visiblePath && !!hitPath
&& hitPath.getAttribute('d') === visiblePath.getAttribute('d')
&& hitPath.getAttribute('transform') === visiblePath.getAttribute('transform')
&& Number(hitPath.getAttribute('stroke-width')) > Number(visiblePath.getAttribute('stroke-width'))
&& getComputedStyle(hitPath).pointerEvents === 'stroke'
&& /transparent|rgba\(0, 0, 0, 0\)/.test(getComputedStyle(hitPath).stroke);
// Shift: independent, continuous width/depth + live plates.
const before = { w: sofaNow().w, h: sofaNow().h };
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
@@ -341,29 +365,59 @@ const res = await page.evaluate(async () => {
const grown = sofaNow();
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
&& near(((grown.h * 1000) / PITCH) % 1, 0, 1e-6);
&& near(grown.h * 1000, b.h + 20, 1e-6);
out.shiftResizeIsSubGrid = !near((grown.h * 1000) / PITCH,
Math.round((grown.h * 1000) / PITCH), 1e-4);
out.oppositeCornerStaysPut = near(grown.x * 1000, b.x, 1e-6) && near(grown.y * 1000, b.y, 1e-6);
const plates = [...sr().querySelectorAll('.measurelabel.furnmeasure')];
out.twoLivePlates = plates.length === 2;
out.platesShowRealLengths = plates.length === 2
&& plates.every((p) => /\d/.test(p.textContent))
&& plates.some((p) => /m|м|′/.test(p.textContent));
// …и без Shift размер прилипает к сетке
// Without Shift the original ratio is preserved, still without grid snap.
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
const snapped = sofaNow();
const wCells = (snapped.w * 1000) / PITCH;
const hCells = (snapped.h * 1000) / PITCH;
// Normalised config is rounded to six decimals, so a mathematical cell 27
// may read back as 26.9999999. Compare with the nearest integer, not `% 1`.
out.sizeSnapsToTheGrid = near(wCells, Math.round(wCells), 1e-4)
&& near(hCells, Math.round(hCells), 1e-4);
const proportional = sofaNow();
const wCells = (proportional.w * 1000) / PITCH;
out.defaultResizePreservesRatio = near(proportional.w / proportional.h, before.w / before.h, 1e-5);
out.defaultResizeIsSubGrid = !near(wCells, Math.round(wCells), 1e-4);
ev('pointerup', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
out.cornerDragEnded = !c._dtDrag;
out.noPlatesAfterTheDrag = !sr().querySelector('.measurelabel.furnmeasure');
// поворот: шаг 5°, Shift мимо шага — та же механика, что у текста
// A middle handle changes only one axis and keeps the opposite edge fixed.
const edgeBefore = { ...sofaNow() };
const eb = c._dtBox;
const rightEdge = [...frame().querySelectorAll('.dthandle.dtedge')].find((handle) =>
near(+handle.getAttribute('cx'), eb.x + eb.w, 1e-6));
ev('pointerdown', rightEdge, eb.x + eb.w, eb.y + eb.h / 2);
ev('pointermove', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
await c.updateComplete;
const edgeAfter = sofaNow();
out.edgeHandleChangesOneAxisContinuously = near(edgeAfter.h, edgeBefore.h, 1e-9)
&& near(edgeAfter.w * 1000, edgeBefore.w * 1000 + 13.7, 1e-6)
&& near(edgeAfter.x, edgeBefore.x, 1e-9) && near(edgeAfter.y, edgeBefore.y, 1e-9);
ev('pointerup', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
await c.updateComplete;
// Crossing the fixed corner toggles only the crossed axis. pointercancel
// must restore the exact pre-gesture object and create no persisted flip.
const crossBefore = JSON.parse(JSON.stringify(sofaNow()));
const cb = c._dtBox;
const crossHandles = [...frame().querySelectorAll('.dthandle')];
const crossSe = crossHandles[3];
ev('pointerdown', crossSe, cb.x + cb.w, cb.y + cb.h);
ev('pointermove', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
await c.updateComplete;
const crossed = sofaNow();
out.crossingTogglesOnlyHorizontalFlip = crossed.flip_h === true && !crossed.flip_v
&& crossed.w > 0 && crossed.h > 0 && near(crossed.x * 1000 + crossed.w * 1000, cb.x, 1e-6);
ev('pointercancel', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
await c.updateComplete;
out.crossingCancelRestoresTheObject = JSON.stringify(sofaNow()) === JSON.stringify(crossBefore);
// Furniture rotation is free; Shift snaps to the nearest 45°.
await sleep(40); await c.updateComplete;
const cx = (sofaNow().x + sofaNow().w / 2) * 1000;
const cy = (sofaNow().y + sofaNow().h / 2) * 1000;
@@ -376,12 +430,14 @@ const res = await page.evaluate(async () => {
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
await c.updateComplete;
out.snapsTo5 = (sofaNow().angle || 0) === 0;
const fine = sofaNow().angle;
out.rotationIsFreeWithoutShift = fine > 0.5 && fine < 5;
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
await c.updateComplete;
const fine = sofaNow().angle;
out.shiftGoesPastTheStep = fine > 0.5 && fine < 5;
ev('pointerup', stageEl(), cx + 100, cy + 2, { shiftKey: true });
out.shiftSnapsRotationTo45 = (sofaNow().angle || 0) === 0;
ev('pointermove', stageEl(), cx + 100, cy + 100);
await c.updateComplete;
ev('pointerup', stageEl(), cx + 100, cy + 100);
await c.updateComplete;
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
@@ -403,7 +459,41 @@ const res = await page.evaluate(async () => {
out.objectDialogSelectsStoredSymbol = sr().querySelector(
'.body select.namein option:checked',
)?.value === stored.symbol;
c._decorShapeDialog = null;
const sizeInputs = [...sr().querySelectorAll('hp-dialog input[type="number"][step="any"]')];
const flipInputs = [...sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')];
out.furniturePropertiesExposeSignedSizesAndTwoFlips = sizeInputs.length === 2 && flipInputs.length === 2;
if (sizeInputs[0]) {
sizeInputs[0].value = String(-Math.abs(Number(sizeInputs[0].value)));
sizeInputs[0].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
}
out.negativeWidthSynchronisesHorizontalCheckbox = !!c._decorShapeDialog?.flipH
&& !!sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')[0]?.checked;
const currentFlipInputs = [...sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')];
if (currentFlipInputs[1]) {
currentFlipInputs[1].checked = true;
currentFlipInputs[1].dispatchEvent(new Event('change', { bubbles: true, composed: true }));
await c.updateComplete;
}
const currentSizes = [...sr().querySelectorAll('hp-dialog input[type="number"][step="any"]')];
out.verticalCheckboxSynchronisesNegativeDepth = !!c._decorShapeDialog?.flipV
&& Number(currentSizes[1]?.value) < 0;
if (currentSizes[0]) {
currentSizes[0].value = '0';
currentSizes[0].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
}
out.zeroSizeBlocksSave = sr().querySelector('hp-dialog button.btn.primary')?.disabled === true
&& sr().querySelector('hp-dialog input[type="number"][step="any"]')?.getAttribute('aria-invalid') === 'true';
c._decorShapeDialog = {
...c._decorShapeDialog,
sizeWField: String(-Math.abs(Number(stored.w) * 1000 * CELL / PITCH / 100)),
};
c._decorSaveShape(); await c.updateComplete;
const propertySaved = sofaNow();
out.propertySavePersistsPositiveExtentsAndFlags = propertySaved.w > 0 && propertySaved.h > 0
&& propertySaved.flip_h === true && propertySaved.flip_v === true
&& c._decorShapeDialog === null;
// ================= 9. инертность и удаление ==============================
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
+57 -57
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "7d9dd4574e49f91701004b99bff143525cff2f5b0a41568acab1e979cf27c06d",
"fingerprint": "0dd9b462752134961ced7b2cc16ed4e83c20a99e0304df7db05c3cb4d6a64b3f",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DtBnECuO.js",
"houseplan-assets/houseplan-card-2zPsVKa0.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 277994,
"initialViewGzipBytes": 279147,
"lazyFiles": [
"houseplan-assets/backdrop-pick-aV3NiaC9.js",
"houseplan-assets/de-fvCwSozd.js",
"houseplan-assets/editor-Cg0xzOaz.js",
"houseplan-assets/fr-lrcBAJ0g.js",
"houseplan-assets/houseplan-editor-runtime-DBo3J7HA.js",
"houseplan-assets/houseplan-onboarding-runtime-9MJDx5Yx.js"
"houseplan-assets/backdrop-pick-Cpw00Iem.js",
"houseplan-assets/de-SitG35ct.js",
"houseplan-assets/editor-BS87B7_Z.js",
"houseplan-assets/fr-C6YF5img.js",
"houseplan-assets/houseplan-editor-runtime-Clod3Q5A.js",
"houseplan-assets/houseplan-onboarding-runtime-DW-o2kzI.js"
],
"lazyGzipBytes": 192313,
"lazyGzipBytes": 193146,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-aV3NiaC9.js",
"houseplan-assets/editor-Cg0xzOaz.js",
"houseplan-assets/houseplan-editor-runtime-DBo3J7HA.js"
"houseplan-assets/backdrop-pick-Cpw00Iem.js",
"houseplan-assets/editor-BS87B7_Z.js",
"houseplan-assets/houseplan-editor-runtime-Clod3Q5A.js"
],
"lazyEditorGzipBytes": 140237,
"lazyEditorGzipBytes": 141036,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-aV3NiaC9.js",
"houseplan-assets/houseplan-onboarding-runtime-9MJDx5Yx.js"
"houseplan-assets/backdrop-pick-Cpw00Iem.js",
"houseplan-assets/houseplan-onboarding-runtime-DW-o2kzI.js"
],
"lazyOnboardingGzipBytes": 9450,
"lazyOnboardingGzipBytes": 9447,
"lazyLocaleFiles": [
"houseplan-assets/de-fvCwSozd.js",
"houseplan-assets/fr-lrcBAJ0g.js"
"houseplan-assets/de-SitG35ct.js",
"houseplan-assets/fr-C6YF5img.js"
],
"lazyLocaleGzipBytes": 45581,
"lazyLocaleGzipBytes": 45617,
"files": [
{
"path": "houseplan-assets/backdrop-pick-aV3NiaC9.js",
"sha256": "d69bf449fbfbf78d5ecb20946dd08960412cdb7d87a8acb896b0f7db15bed072",
"path": "houseplan-assets/backdrop-pick-Cpw00Iem.js",
"sha256": "dbff8db361964d5f92d2d78171b5dbf36170c25a21fdee6c15b8b6e45eca0001",
"rawBytes": 6967,
"gzipBytes": 2955,
"gzipBytes": 2954,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DtBnECuO.js"
"houseplan-assets/houseplan-card-2zPsVKa0.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-fvCwSozd.js",
"sha256": "8ee8a41141e01712bd9441c2074909bd966bcc3351565aecc7d79f3646779e0a",
"rawBytes": 75596,
"gzipBytes": 23009,
"path": "houseplan-assets/de-SitG35ct.js",
"sha256": "62f5c67eadeefdb42d0866a6c00eefcd186fd153913e21528447dd6da3e469c6",
"rawBytes": 75668,
"gzipBytes": 23026,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Cg0xzOaz.js",
"sha256": "41786591029748d0f10d5e3dfddeba9d819a6c46d8e1e363098295fdc38a8411",
"path": "houseplan-assets/editor-BS87B7_Z.js",
"sha256": "33f60174950207011d09f7f1dd33054eb4482186837356450b0a8c04d116c4af",
"rawBytes": 3831,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DtBnECuO.js"
"houseplan-assets/houseplan-card-2zPsVKa0.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-lrcBAJ0g.js",
"sha256": "80bae04891526c11b3b7dc17b0ac97958e66b77fe8efea2bdacad059fe5128c3",
"rawBytes": 77737,
"gzipBytes": 22572,
"path": "houseplan-assets/fr-C6YF5img.js",
"sha256": "d52f235d4c5dc84e6c23b6c0f5595d2d40a22248c17442b82e53eb511abd832a",
"rawBytes": 77821,
"gzipBytes": 22591,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DtBnECuO.js",
"sha256": "e045e2ed3f28ec80afdecb90b818f57599775756f34b74dddb0a7da297872c42",
"rawBytes": 979611,
"gzipBytes": 277198,
"path": "houseplan-assets/houseplan-card-2zPsVKa0.js",
"sha256": "015c1f66878c0276f4623c1642de7f4f088ac630100dacd28ae040c99ed0bb50",
"rawBytes": 983144,
"gzipBytes": 278351,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-fvCwSozd.js",
"houseplan-assets/editor-Cg0xzOaz.js",
"houseplan-assets/fr-lrcBAJ0g.js",
"houseplan-assets/houseplan-editor-runtime-DBo3J7HA.js",
"houseplan-assets/houseplan-onboarding-runtime-9MJDx5Yx.js"
"houseplan-assets/de-SitG35ct.js",
"houseplan-assets/editor-BS87B7_Z.js",
"houseplan-assets/fr-C6YF5img.js",
"houseplan-assets/houseplan-editor-runtime-Clod3Q5A.js",
"houseplan-assets/houseplan-onboarding-runtime-DW-o2kzI.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DBo3J7HA.js",
"sha256": "702ceb450bc56585f87ebaff2888fa584a87a8b953ba3e439fe1e01eb52adafd",
"rawBytes": 526561,
"gzipBytes": 135699,
"path": "houseplan-assets/houseplan-editor-runtime-Clod3Q5A.js",
"sha256": "32a55e71fd2e9e958860c0e7e62e1e537f8c787b69192b5f88b613bfc638d3d3",
"rawBytes": 530418,
"gzipBytes": 136499,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-aV3NiaC9.js",
"houseplan-assets/houseplan-card-DtBnECuO.js"
"houseplan-assets/backdrop-pick-Cpw00Iem.js",
"houseplan-assets/houseplan-card-2zPsVKa0.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-9MJDx5Yx.js",
"sha256": "4debe25fee56b279d2656af252520d6e0c9267dbc0cd5daca9ac9f904b72795c",
"path": "houseplan-assets/houseplan-onboarding-runtime-DW-o2kzI.js",
"sha256": "a75c2ad698e2d59aa71c36572c8976d1b60b0e2671190c00ef2ce4570dc20dd8",
"rawBytes": 26195,
"gzipBytes": 6495,
"gzipBytes": 6493,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-aV3NiaC9.js",
"houseplan-assets/houseplan-card-DtBnECuO.js"
"houseplan-assets/backdrop-pick-Cpw00Iem.js",
"houseplan-assets/houseplan-card-2zPsVKa0.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "ea9294f40721467c6a61941e796290ca519fef0c9f2d27f93bedc8210a531f7c",
"sha256": "c1fbd7123af664bb5ba87018f51d8e85c22d2b7811d322656ed4d26a2fb1dbf9",
"rawBytes": 1183,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DtBnECuO.js"
"houseplan-assets/houseplan-card-2zPsVKa0.js"
],
"dynamicImports": []
}
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,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d9dd4574e49f91701004b99bff143525cff2f5b0a41568acab1e979cf27c06d";import{c as e,l as o,t,c$ as s,A as a,d0 as i,d1 as l,d2 as n,E as r,b as c}from"./houseplan-card-DtBnECuO.js";class f 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,f={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")},h=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__="0dd9b462752134961ced7b2cc16ed4e83c20a99e0304df7db05c3cb4d6a64b3f";import{c as e,l as o,t,d3 as s,A as a,d4 as i,d5 as l,d6 as n,E as r,b as c}from"./houseplan-card-2zPsVKa0.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}
.computeLabel=${e=>f[e.name]||e.name}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${d(f.slice(0,3))}
${_?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:_})}
</div>`:a}
${d(h.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)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
${d(f.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);
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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d9dd4574e49f91701004b99bff143525cff2f5b0a41568acab1e979cf27c06d";try{await import("./houseplan-assets/houseplan-card-DtBnECuO.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__="0dd9b462752134961ced7b2cc16ed4e83c20a99e0304df7db05c3cb4d6a64b3f";try{await import("./houseplan-assets/houseplan-card-2zPsVKa0.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
@@ -1249,6 +1249,10 @@ its configured space.
`width_cm`, text `size_cm`, opacity/fill fields, optional per-line
`line_style` (`solid` / `dashed`; the frontend omits the legacy solid
default), and legacy width/text-size representations for read compatibility.
Furniture keeps positive `w/h`; optional `flip_h/flip_v` mirror only its SVG
art. Its continuous local-axis resize/45° rotation path is deliberately
separate from the shared grid-snapped box controller, while Undo/Redo,
canonicalization and persistence remain common.
- **Plan image transform**: `planRect()` resolves the fitted image plus
`plan_x/y`, independent `plan_scale_x/y` and `plan_angle`; legacy
`plan_scale` feeds both axes. The image is interactive only in its own
+6 -3
View File
@@ -489,9 +489,12 @@ An incompatible ordinary snap is ignored; only then does the nearest grid node
on the ray become the fallback. The green angle badge uses the actual vector
(horizontal, vertical or `|dx| = |dy|`), so 89.9°/90.1° are not advertised as
exact. For other tools Shift modifies only the current gesture: square/circle creation,
independent resize axes, free decor/backdrop rotation, the compass step, or
bypassing the furniture wall magnet while the ordinary decor/room/grid magnet
remains active. It cannot create an off-grid object.
independent ordinary-decor resize axes, free ordinary-decor/backdrop rotation,
the compass step, or bypassing the furniture wall magnet while the ordinary
decor/room/grid magnet remains active. Furniture resize is the explicit
exception to positional quantisation: it is continuous in both modes, with
Shift selecting independent axes, while Shift on its rotation handle snaps to
45°. Furniture placement and movement remain grid-bound.
### 9.5 «Оптимизировать планы» — explicit whole-plan maintenance
+6
View File
@@ -7,6 +7,12 @@
happens only on an actual change of binding
([#385](https://github.com/Matysh/houseplan-card/issues/385)).
- Furniture can now be resized smoothly with corner or one-axis handles,
mirrored horizontally/vertically by dragging through an edge or from its
properties, and rotated freely with `Shift` snapping to 45°. Selection also
gets a physical 10 cm path-shaped tolerance without making the empty box
clickable ([#383](https://github.com/Matysh/houseplan-card/issues/383)).
- Device markers can now use **Do nothing** as their tap action. Short click,
tap, Enter and Space are then quiet no-ops, while marker appearance,
long-press House Plan details and right-click HA more-info remain available
+6
View File
@@ -13,6 +13,12 @@
при реальной смене привязки
([#385](https://github.com/Matysh/houseplan-card/issues/385)).
- Мебель теперь можно плавно масштабировать угловыми и одноосевыми ручками,
зеркалить по горизонтали/вертикали перетаскиванием через грань или в
свойствах и свободно вращать с привязкой к 45° по `Shift`. Область выбора
расширена на физические 10 см вдоль линий предмета, не захватывая пустую
рамку ([#383](https://github.com/Matysh/houseplan-card/issues/383)).
- Для маркера устройства теперь можно выбрать действие **«Ничего не делать»**.
Короткий клик, тап, Enter и Space ничего не открывают и не отправляют, при
этом внешний вид маркера, карточка House Plan по долгому нажатию и HA
+14
View File
@@ -207,6 +207,20 @@ fallback may show or rewrite it as a door. Before a permanent rollback, convert
saved passages deliberately in a current version; automatic conversion is not
performed because it would invent a leaf and binding semantics.
## Furniture mirror flags (#383)
`space.decor[]` furniture records may contain optional boolean `flip_h` and
`flip_v`. Their `w` and `h` remain strictly positive; absent flags mean the
historical unmirrored orientation, so no migration is required. Full, space
and plan-only transfers preserve both flags, while coordinate canonicalization
and Optimize leave them untouched.
The same field names already exist on door/window/gate opening records, where
they describe leaf direction. This is not a shared semantic field: openings
and furniture are validated, canonicalized and exported through separate
object schemas and allowlists. Older renderers ignore furniture flags and show
the original orientation; older plan-only exporters may omit them.
## Independent-wall opening host (#132)
`space.openings[].host` is an optional discriminated object
+9 -4
View File
@@ -12,13 +12,13 @@ device state or Home Assistant actions.
| Invariant | Contract |
|---|---|
| Coordinates | Axis-aligned positions and every box dimension are quantised to the space grid. A rotated resize keeps its opposite world-space corner fixed and quantises dimensions; its derived unrotated storage origin is not post-snapped, because that would translate the entire object. `Shift` never disables dimension snapping. |
| Coordinates | Axis-aligned positions and ordinary shape dimensions are quantised to the space grid. A rotated ordinary-shape resize keeps its opposite world-space corner fixed and quantises dimensions; its derived unrotated storage origin is not post-snapped. Furniture is the explicit exception: resize is continuous, stores positive extents and projects crossing into `flip_h`/`flip_v`. |
| Physical values | Stroke and text sizes are stored in centimetres; the UI shows cm/in for small values and m/ft for object dimensions. |
| Undo | Decor and backdrop use the same named 50-command stack as plan geometry. |
| Cancel | `Esc` restores the state at the start of an active drag/draw/resize/rotate gesture. A completed gesture is reverted with Undo. |
| Selection | One click selects; drag moves; the selected object has one common transform frame; double click opens all editable properties. |
| Scale | Corner drag preserves aspect ratio. Hold `Shift` for independent axes. |
| Rotation | 5° steps by default; `Shift` gives free rotation. Lines use endpoint handles instead of a rotation handle. |
| Scale | Corner drag preserves aspect ratio. Hold `Shift` for independent axes. Furniture also has four one-axis middle handles and may cross the fixed edge to mirror. |
| Rotation | Ordinary decor uses 5° steps by default and `Shift` for free rotation. Furniture is free by default and `Shift` snaps to 45°. Lines use endpoint handles instead of a rotation handle. |
| Magnet targets | Only other decor objects and room contours: corners, edge centres, centres and edges. The image, devices and openings are excluded. |
| Context emphasis | Decor and its editing chrome stay fully opaque. Rooms, labels, devices, openings, positive-thickness walls and solid/dashed zero-thickness walls are contextual only and render at 35% opacity. The whole device presentation (core, ring, capsule, values and badges) is pointer-inert: it never hovers, opens, acts or drags, and the active Background tool receives a press through it. |
| View composition | All decor kinds form one layer above room/data fills, room hover fill, opening-tunnel fills and Glow base. Live Glow, sun, physical walls, opening symbols, devices and room labels remain above decor. The plan image remains below it. |
@@ -34,7 +34,7 @@ device state or Home Assistant actions.
| Rectangle | Drag a diagonal; `Shift` makes a square | width × height and area | size, angle, contour, optional independent fill colour/opacity |
| Oval | Drag its bounding box; `Shift` makes a circle | `R` for a circle, `Rx × Ry` for an oval | bounding size, angle, contour and optional fill |
| Text | Click to open the text form | the saved label is selected immediately | content, HA variables, colour/opacity, physical size and angle |
| Furniture | Pick a symbol, then click its centre | wall magnet unless `Shift` is held | size, angle and contour style; the common frame preserves ratio unless `Shift` is held |
| Furniture | Pick a symbol, then click its centre | wall magnet unless `Shift` is held | signed size, H/V mirror, angle and contour style; corners resize smoothly, four middle handles change one axis, rotation is free/`Shift` 45° |
| Erase | Click a decor object; text uses its whole logical bounding box, including spaces between glyphs | confirmation dialog, then atomic removal of the whole object | A miss changes nothing; Undo restores a removed object |
The editor always opens on **Select**. If the space has an image, the Plan
@@ -118,6 +118,7 @@ history wins while focus is inside an input.
|---|---|
| persisted decor types and future custom-image transform contract | `src/editors/decor/types.ts` |
| physical style conversion, oriented boxes, resize and snapping | `src/editors/decor/geometry.ts` |
| furniture-only continuous resize, flip projection and SVG transform | `src/furniture.ts` |
| shared colour/opacity field | `src/hp-color-opacity.ts` |
| orchestration, dialogs and SVG transform frames | `src/houseplan-card.ts` |
| static/full-card backdrop rendering and content bounds | `src/space-render.ts`, `src/space-geometry.ts` |
@@ -141,6 +142,10 @@ implicitly.
- Degenerate drafts below half a cell are discarded and do not enter history.
- Unknown furniture symbols remain stored but render nothing in an older card.
- Furniture `w/h` never become negative: signed property values and handle
crossing are stored as positive extents plus optional boolean mirror flags.
- Select uses a path-shaped furniture target extending 10 physical centimetres
beyond painted strokes; empty bounding-box space remains a miss.
- A rotated box contributes all four rotated corners to content bounds.
- A rotated backdrop is hit-tested in its own local coordinate system.
- Changing `cell_cm` changes the rendered width/font size of canonical physical
+33 -10
View File
@@ -1,15 +1,16 @@
# Furniture library
Status: **implemented and expanded by issue #159**. The Background editor
Status: **implemented and expanded by issues #159 and #383**. The Background editor
stores furniture as ordinary decor:
```text
{ kind: "furniture", symbol, x, y, w, h, color, opacity, width_cm, angle? }
{ kind: "furniture", symbol, x, y, w, h, color, opacity, width_cm, angle?, flip_h?, flip_v? }
```
The schema and saved coordinates did not change. Existing furniture therefore
keeps its position, size, rotation and styling; only 18 built-in drawings gain
the new artwork after an update.
`w` and `h` are always positive physical extents. Optional boolean `flip_h`
and `flip_v` mirror the drawing inside that unchanged box; absent flags are
the original orientation. Existing furniture therefore keeps its position,
size, rotation, orientation and styling after an update.
## Library and source
@@ -73,9 +74,30 @@ The properties dialog remains a flat native select, grouped by category. Its
optgroup label includes both the parent group and category because HTML selects
cannot nest optgroups.
## Transform interaction
Selected furniture has four corner and four middle-edge resize handles. Corner
resize is continuous and preserves the original aspect ratio; `Shift` lets
width and depth change independently. A middle handle changes only its local
axis, including on a rotated object. Dragging any handle across the fixed
opposite edge keeps the gesture alive and mirrors the corresponding axis.
Rotation is continuous normally and snaps to the nearest multiple of 45° while
`Shift` is held. The rotation handle uses a circular-arrow cursor. These rules
are furniture-only: other decor and the backdrop keep their existing grid and
modifier contracts.
Properties show signed width/depth. A negative width is horizontal mirror and
a negative depth is vertical mirror; the two adjacent checkboxes are the same
state expressed explicitly. Save stores the absolute extents plus optional
flags. Zero is not a valid saved dimension.
## Rendering contract
Each object is still one `<path>` and one erase hit path. Designer artwork
Each object is still one visible `<path>` and one erase hit path. In Select,
an additional invisible path follows the same artwork and extends the target
10 physical centimetres beyond each visible stroke edge; empty areas of the
bounding box remain non-interactive. Designer artwork
keeps its native SVG `viewBox`; the renderer applies the user's stored width
and depth with a non-uniform transform. `vector-effect="non-scaling-stroke"`
rejects only that local width/depth distortion in the visible result; the
@@ -98,10 +120,11 @@ room aggregation, collision model or automatic binding to later wall edits.
## Compatibility and performance
No backend schema, configuration version or migration was added. The 18
replacement ids preserve their identity; the remaining old ids remain
available. Default dimensions change only for a newly picked replacement—an
already saved object's `w` and `h` are never rewritten.
The backend schema additively accepts optional boolean furniture `flip_h` and
`flip_v`; no configuration version or migration is needed. The 18 replacement
ids preserve their identity; the remaining old ids remain available. Default
dimensions change only for a newly picked replacement—an already saved
object's `w` and `h` are never rewritten.
Plan artwork is available in View and kiosk. Front-view menu art is imported
only after the editor runtime is requested. Touch View/kiosk support is
+22 -11
View File
@@ -3278,23 +3278,34 @@ require hands on real hardware — they remain for the human pass.
- [ ] **One stamp per pick**: after placing, the editor is back in **Select**
with the new piece selected, and the palette is disarmed — clicking the
plan again does not place a second one [auto: smoke_furniture]
- [ ] **The frame is the text block's frame**: four corner beads and a rotate
handle, the beads a quarter of their hit area. Grab one with a finger on
a tablet, aiming roughly: it is caught [auto: smoke_furniture]
- [ ] **The furniture frame**: four corner beads, four one-axis middle beads
and a rotate handle; every bead is a quarter of its unchanged hit area.
Ordinary decor keeps its previous five-handle frame. Grab a furniture
bead with a finger on a tablet, aiming roughly: it is caught
[auto: smoke_furniture]
- [ ] **Proportional by default**: drag a corner sideways or down — the current
ratio is preserved about the opposite corner. Hold Shift to change width
and depth independently. Two live badges show both in metres (or feet)
while you drag, and they match a later measurement against the grid;
neither mode creates sizes off the grid
while you drag, and they match a later physical measurement. Both modes
are continuous and can create a sub-grid size; the four middle handles
change only width or only depth
[auto: smoke_furniture]
- [ ] **Rotation**: the handle above the box turns the piece in 5° steps about
its CENTRE (not a corner); Shift goes past the step; turning back to
straight removes the angle entirely [auto: smoke_furniture]
- [ ] **Crossing and mirroring**: drag a corner or middle handle through its
fixed opposite edge — the gesture continues with positive stored size
and the corresponding H/V mirror toggled. `Esc`/pointer cancel restores
the complete pre-drag object [auto: smoke_furniture + furniture.test]
- [ ] **Rotation**: the handle above the box turns the piece freely about its
CENTRE (not a corner); Shift snaps to the nearest 45° and can be pressed
or released during the same gesture [auto: smoke_furniture]
- [ ] **Complete properties**: double click a piece and change its symbol,
width/depth in m/ft, angle, contour colour/opacity and line width in
cm/in. Save keeps its centre/transform and reloads exactly; Cancel changes
nothing; reopening a non-first symbol selects that exact option
signed width/depth in m/ft, H/V mirror checkboxes, angle, contour
colour/opacity and line width in cm/in. Signs and checkboxes stay in sync;
zero/invalid size disables Save. Save keeps its centre and reloads exactly;
Cancel changes nothing; reopening a non-first symbol selects that option
[auto: smoke_furniture; manual]
- [ ] **Selection tolerance**: in Select, a press up to 10 physical centimetres
from a drawn furniture stroke selects it at any zoom/scale; an empty area
of its bounding box does not [auto: smoke_furniture + source contract]
- [ ] **It is only decor**: a piece takes no tap in view mode, has no entity
and no state, and does not appear in any room aggregation. Erase removes
it; Delete removes the selected one [auto: smoke_furniture]
+9 -2
View File
@@ -715,10 +715,17 @@ this plan.
| Rectangle | Drag diagonal; Shift makes a square | Stroke plus independent fill, size and angle |
| Oval | Drag bounds; Shift makes a circle | Stroke plus independent fill, radii and angle |
| Text | Click to open dialog | Multiline text, HA tokens, colour, physical size and angle |
| Furniture | Pick a front-view category, pick a top-view variant, then click | Symbol, size, colour, outline and wall magnet |
| Furniture | Pick a front-view category, pick a top-view variant, then click | Symbol, smooth size, horizontal/vertical mirror, colour, outline and wall magnet |
| Erase | Click an item | Confirmed deletion, undoable |
Creation and transform snap to the grid plus nearby room/background anchors.
Creation and ordinary decor transforms snap to the grid plus nearby
room/background anchors. Furniture resize is the exception: corners move
smoothly and preserve proportion (`Shift` changes axes independently), while
four middle handles change one axis. Crossing the opposite edge mirrors the
item. Rotation is smooth and `Shift` snaps it to 45°; signed size fields and
the two mirror checkboxes provide the same result numerically. Furniture is
selected within 10 physical centimetres of its drawn strokes, not throughout
its empty bounding box.
The plan image is interactive only with Backdrop selected. Undo/Redo shares the
50-command editor history.
+5 -5
View File
@@ -256,7 +256,7 @@ desktop: для точного рисования, Resize, модификато
- если один или несколько объектов далеко от основного плана, появляется подсказка о дальних объектах;
- в киоске при увеличении выше 1:1 горизонтальный жест двигает план, а не листает этажи;
- любая ручная операция в киоске приостанавливает автолистание на 60 секунд;
- `Shift` не отключает позиционную сетку; при рисовании контура комнаты он фиксирует стену по ближайшему направлению с шагом 45°; в редакторе подложки делает прямоугольник квадратом/овал кругом при создании, разрешает непропорциональное масштабирование и свободный поворот; у компаса включает шаг 15°.
- `Shift` не отключает позиционную сетку при размещении и перемещении; при рисовании контура комнаты он фиксирует стену по ближайшему направлению с шагом 45°; в редакторе подложки делает прямоугольник квадратом/овал кругом при создании и разрешает непропорциональное масштабирование обычного декора; у мебели плавный resize не зависит от сетки, а `Shift` разрешает разные оси и привязывает поворот к 45°; у компаса включает шаг 15°.
### Клавиши отмены
@@ -1267,10 +1267,10 @@ Power=`off`/`unavailable` подавляет даже устаревший ак
| Прямоугольник | Drag по диагонали; `Shift` фиксирует квадрат | Контур и независимая заливка с прозрачностью; показываются ширина × высота и площадь |
| Овал | Drag по габаритам; `Shift` фиксирует круг | Контур и независимая заливка с прозрачностью; показывается `R` либо `Rx × Ry` |
| Текст | Клик открывает диалог | Многострочный текст, переменные HA, цвет/прозрачность, физический размер и угол; масштаб и поворот ручками |
| Мебель | Выбор категории спереди, затем варианта сверху и клик по центру | Символ, размер, цвет, толщина, поворот; магнитится к стене |
| Мебель | Выбор категории спереди, затем варианта сверху и клик по центру | Символ, плавный размер, отражение по горизонтали/вертикали, цвет, толщина, поворот; магнитится к стене |
| Стереть | Клик по объекту | После подтверждения удаляет объект; действие можно отменить через Undo |
Новые фигуры создаются не меньше половины клетки. После создания объект остаётся выбранным. Создание, движение и изменение размера всегда квантуются по сетке. При рисовании и перемещении действует лёгкий магнит к углам, серединам, центрам и рёбрам других объектов декора и комнат; устройства, проёмы и картинка-подложка не являются целями магнита.
Новые фигуры создаются не меньше половины клетки. После создания объект остаётся выбранным. Создание, движение и изменение размера обычного декора квантуются по сетке. Resize мебели — исключение: углы двигаются плавно и по умолчанию сохраняют пропорции (`Shift` разрешает разные оси), а четыре средние ручки меняют только одну ось. Переход через противоположную грань зеркалит предмет. Поворот плавный, с `Shift` привязывается к 45°. Знаковые размеры и две галочки отражения в свойствах дают тот же результат численно. Выбрать мебель можно в пределах физических 10 см от её нарисованных линий, но не по всей пустой рамке. При рисовании и перемещении действует лёгкий магнит к углам, серединам, центрам и рёбрам других объектов декора и комнат; устройства, проёмы и картинка-подложка не являются целями магнита.
Картинка подложки интерактивна только в своём инструменте. В остальных инструментах этого редактора она показана с непрозрачностью `0.5`, а в просмотре и других редакторах — `1.0`.
@@ -1308,7 +1308,7 @@ Power=`off`/`unavailable` подавляет даже устаревший ак
| Текст | Полный диалог текста и HA-переменных |
| Линия | Длина, угол, цвет/прозрачность и физическая толщина; концы также двигаются отдельными ручками |
| Прямоугольник/овал | Ширина, высота, угол, контур и независимая заливка с прозрачностью |
| Мебель | Символ, ширина, глубина, угол, цвет/прозрачность и физическая толщина контура |
| Мебель | Символ, знаковые ширина/глубина, отражение по горизонтали/вертикали, угол, цвет/прозрачность и физическая толщина контура |
| Картинка-подложка | Ширина, высота и угол (двойной клик в инструменте «Подложка») |
### Живой текст Home Assistant
@@ -1353,7 +1353,7 @@ Power=`off`/`unavailable` подавляет даже устаревший ак
- всегда прижимается к ближайшей стене в радиусе магнита, иначе размещается по сетке;
- с зажатым `Shift` пропускает стеновой магнит, но всё равно привязывается к декору, комнатам и сетке;
- угловыми ручками по умолчанию сохраняет пропорции, а с `Shift` меняет ширину и глубину независимо;
- поворачивается с шагом 5°, а с `Shift` — свободно.
- свободно поворачивается, а с `Shift` привязывается к ближайшим 45°.
## 15. Солнце: фон и оконные лучи
+6 -5
View File
@@ -1,12 +1,12 @@
# Issue #383 — плавные трансформации и зеркалирование мебели
- **Issue:** https://github.com/Matysh/houseplan-card/issues/383
- **Статус документа:** готово к ревью ТЗ
- **Статус документа:** реализовано, готово к code review
- **Приоритет / тип:** P2 · feature / polish
- **Область:** Редактор подложки, furniture geometry/render, свойства мебели,
selection hit-area, backend schema, import/export, i18n, документация и QA
- **Связи:** заменяет #382; продолжает контракт мебели из #177/#179/#359/#361
- **Ревизия:** 1 (2026-08-30)
- **Ревизия:** 3 (2026-08-30; реализация и целевой browser-smoke завершены)
## Сценарий
@@ -213,8 +213,8 @@ flip_v: true # optional; false канонически отсутствует
| Ключ | RU | EN | DE | FR |
|---|---|---|---|---|
| `furn.flip_h` | Отзеркалить по горизонтали | Flip horizontally | Horizontal spiegeln | Retourner horizontalement |
| `furn.flip_v` | Отзеркалить по вертикали | Flip vertically | Vertikal spiegeln | Retourner verticalement |
| `decor.flip_h` | Отзеркалить по горизонтали | Flip horizontally | Horizontal spiegeln | Retourner horizontalement |
| `decor.flip_v` | Отзеркалить по вертикали | Flip vertically | Vertikal spiegeln | Retourner verticalement |
- Checkboxes имеют native label и keyboard activation; порядок tab следует
DOM после размеров. Числовые inputs сохраняют локализованную подпись единиц.
@@ -225,7 +225,8 @@ flip_v: true # optional; false канонически отсутствует
## Затронутые файлы и модули
- `src/editors/decor/types.ts`, `src/editors/decor/geometry.ts`.
- `src/editors/decor/types.ts`, `src/furniture.ts`; общий decor geometry не
меняется, потому что новый controller строго furniture-only.
- `src/houseplan-card.ts`, `src/houseplan-editor-runtime.ts`, стили transform
frame и furniture hit-area.
- `src/i18n/{en,ru,de,fr}.json`.
+3
View File
@@ -70,6 +70,9 @@ export interface DecorText extends DecorBase {
export interface DecorFurniture extends DecorBoxBase {
kind: 'furniture';
symbol: string;
/** Mirroring is presentation state; persisted extents always stay positive. */
flip_h?: boolean;
flip_v?: boolean;
}
export type DecorShape = DecorLine | DecorRect | DecorEllipse | DecorText | DecorFurniture;
+155
View File
@@ -633,3 +633,158 @@ export function furnitureResize(
const ncy = fy + (sgx > 0 ? w / 2 : -w / 2) * uy + (sgy > 0 ? h / 2 : -h / 2) * vy;
return { x: ncx - w / 2, y: ncy - h / 2, w, h };
}
export interface FurnitureTransformBox {
x: number;
y: number;
w: number;
h: number;
angle?: number;
flip_h?: boolean;
flip_v?: boolean;
}
export interface FurnitureResizeResult extends FurnitureTransformBox {
flip_h?: boolean;
flip_v?: boolean;
}
/**
* #383 — continuous furniture-only resize.
*
* `sgx`/`sgy` name the dragged local edge (-1, 0 or +1). Zero keeps that
* axis byte-for-byte, which is how the four new middle handles work. Unlike
* the legacy shared decor helper this function never snaps to the grid and a
* signed pointer distance may cross the fixed opposite edge. The sign is
* projected into flip flags; the returned extents remain strictly positive.
*/
export function resizeFurnitureTransform(
orig: FurnitureTransformBox,
sgx: number,
sgy: number,
px: number,
py: number,
keepAspect: boolean,
minSize: number,
): FurnitureResizeResult {
const minimum = Number.isFinite(minSize) && minSize > 0 ? minSize : 1e-6;
const a = ((Number(orig.angle) || 0) * Math.PI) / 180;
const ux = Math.cos(a), uy = Math.sin(a);
const vx = -Math.sin(a), vy = Math.cos(a);
const cx = orig.x + orig.w / 2, cy = orig.y + orig.h / 2;
const flx = sgx > 0 ? -orig.w / 2 : sgx < 0 ? orig.w / 2 : 0;
const fly = sgy > 0 ? -orig.h / 2 : sgy < 0 ? orig.h / 2 : 0;
const fx = cx + flx * ux + fly * vx;
const fy = cy + flx * uy + fly * vy;
const rx = px - fx, ry = py - fy;
const rawW = sgx
? (rx * ux + ry * uy) * (sgx > 0 ? 1 : -1)
: orig.w;
const rawH = sgy
? (rx * vx + ry * vy) * (sgy > 0 ? 1 : -1)
: orig.h;
let w = sgx ? Math.abs(rawW) : orig.w;
let h = sgy ? Math.abs(rawH) : orig.h;
if (keepAspect && sgx && sgy) {
const kx = w / Math.max(orig.w, minimum);
const ky = h / Math.max(orig.h, minimum);
// Follow whichever local pointer axis has moved farther from the original
// scale. Equal deltas deliberately prefer width for deterministic ties.
const scale = Math.abs(kx - 1) >= Math.abs(ky - 1) ? kx : ky;
const floor = Math.max(minimum / orig.w, minimum / orig.h);
const safeScale = Math.max(floor, Number.isFinite(scale) ? scale : floor);
w = orig.w * safeScale;
h = orig.h * safeScale;
}
w = Math.max(minimum, Number.isFinite(w) ? w : minimum);
h = Math.max(minimum, Number.isFinite(h) ? h : minimum);
const crossX = !!sgx && rawW < 0;
const crossY = !!sgy && rawH < 0;
const dirX = sgx ? (sgx > 0 ? 1 : -1) * (crossX ? -1 : 1) : 0;
const dirY = sgy ? (sgy > 0 ? 1 : -1) * (crossY ? -1 : 1) : 0;
const ncx = fx + dirX * (w / 2) * ux + dirY * (h / 2) * vx;
const ncy = fy + dirX * (w / 2) * uy + dirY * (h / 2) * vy;
const flipH = !!orig.flip_h !== crossX;
const flipV = !!orig.flip_v !== crossY;
return {
x: ncx - w / 2,
y: ncy - h / 2,
w,
h,
...(Number(orig.angle) ? { angle: Number(orig.angle) } : {}),
...(flipH ? { flip_h: true } : {}),
...(flipV ? { flip_v: true } : {}),
};
}
/** Furniture is free by default; Shift snaps to world-space 45° bearings. */
export function furnitureRotationAngle(
angle0: number,
pointerStartDeg: number,
pointerDeg: number,
snap45: boolean,
): number {
let angle = Number(angle0) + (Number(pointerDeg) - Number(pointerStartDeg));
if (![angle, pointerStartDeg, pointerDeg].every(Number.isFinite)) angle = 0;
if (snap45) {
const steps = angle / 45;
// Keep the exact half-step deterministic on both sides of zero. Native
// Math.round(-0.5) returns -0, which would make symmetric drags snap in
// different directions.
angle = (steps < 0 ? -Math.round(Math.abs(steps)) : Math.round(steps)) * 45;
}
angle = ((angle % 360) + 360) % 360;
return angle > 180 ? angle - 360 : angle;
}
/** One canonical transform for visible art, selection halo and future consumers. */
export function furnitureRenderTransform(
shape: FurnitureTransformBox,
canvasW: number,
canvasH: number,
artW: number,
artH: number,
): string {
const x = shape.x * canvasW, y = shape.y * canvasH;
const w = shape.w * canvasW, h = shape.h * canvasH;
const cx = x + w / 2, cy = y + h / 2;
const tx = x + (shape.flip_h ? w : 0);
const ty = y + (shape.flip_v ? h : 0);
const sx = (shape.flip_h ? -1 : 1) * w / artW;
const sy = (shape.flip_v ? -1 : 1) * h / artH;
const angle = Number(shape.angle) || 0;
return `${angle ? `rotate(${angle} ${cx} ${cy}) ` : ''}`
+ `translate(${tx} ${ty}) scale(${sx} ${sy})`;
}
/** Signed m/ft property field -> centimetres; null keeps invalid drafts visible. */
export function furnitureSignedFieldCm(
value: unknown,
imperial: boolean,
maxCm: number,
minCm = 0.1,
): number | null {
if (typeof value === 'string' && !value.trim()) return null;
const shown = Number(value);
if (!Number.isFinite(shown) || shown === 0) return null;
const cm = shown * (imperial ? 30.48 : 100);
const magnitude = Math.abs(cm);
if (magnitude < minCm || magnitude > maxCm) return null;
return cm;
}
/** Positive stored centimetres + one flag -> a lossless-enough signed m/ft field. */
export function furnitureSignedFieldValue(
cm: number,
flipped: boolean,
imperial: boolean,
): string {
const shown = Math.abs(Number(cm)) / (imperial ? 30.48 : 100);
// Six decimals keep the physical 0.1 cm minimum representable in both
// metres (0.001) and feet (~0.003281) without filling ordinary values with
// trailing zeroes.
const rounded = Number(shown.toFixed(6));
return String(flipped ? -rounded : rounded);
}
+30 -11
View File
@@ -65,6 +65,7 @@ import {
FURNITURE_GROUPS, furnitureOfGroup, furnitureSymbol, furnitureDefaultCm,
furnitureGraphic, furnitureCorners, snapFurnitureToWall,
furniturePlanScreenScale, furnitureStrokePx,
furnitureRenderTransform,
cmToNorm, clampFurnSize, clampFurnCm, FURN_WALL_CELLS,
type FurnitureGroup, type FurniturePlacement,
} from './furniture';
@@ -1021,6 +1022,7 @@ export class HouseplanCard extends LitElement {
fill?: boolean; fillColor?: string; fillOpacity?: number;
lengthCm?: number; sizeWCm?: number; sizeHCm?: number; angle: string;
symbol?: string;
sizeWField?: string; sizeHField?: string; flipH?: boolean; flipV?: boolean;
} | null = null;
private _backdropDialog: { widthCm: number; heightCm: number; angle: string } | null = null;
/** Last textarea selection survives moving focus to the HA pickers. */
@@ -1056,7 +1058,7 @@ export class HouseplanCard extends LitElement {
ax: number; ay: number;
/** distance / bearing of the pointer at the start, and the stored values */
r0: number; a0: number; textSizeCm0: number; angle0: number;
/** box shapes: the dragged corner signs and the oriented box at drag start */
/** box shapes: the dragged local edge signs and the oriented box at drag start */
sgx?: number; sgy?: number;
orig?: DecorBox;
origShape: DecorShape;
@@ -8044,7 +8046,11 @@ export class HouseplanCard extends LitElement {
[-1, -1, 'nwse'], [1, -1, 'nesw'], [1, 1, 'nwse'], [-1, 1, 'nesw'],
];
const arm = hr * 2.2;
return svg`<g class="dtframe" transform=${ang ? `rotate(${ang} ${ax} ${ay})` : nothing}>
const furniture = sh.kind === 'furniture';
const sides: [number, number, string][] = furniture ? [
[0, -1, 'ns'], [1, 0, 'ew'], [0, 1, 'ns'], [-1, 0, 'ew'],
] : [];
return svg`<g class="dtframe${furniture ? ' dtfurnitureframe' : ''}" transform=${ang ? `rotate(${ang} ${ax} ${ay})` : nothing}>
<rect class="dtbox" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}"></rect>
<line class="dtstem" x1="${b.x + b.w / 2}" y1="${b.y}" x2="${b.x + b.w / 2}" y2="${b.y - arm}"></line>
<circle class="dthandle dtrot" cx="${b.x + b.w / 2}" cy="${b.y - arm}" r="${hr.toFixed(1)}"
@@ -8054,6 +8060,14 @@ export class HouseplanCard extends LitElement {
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${hr.toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'scale', [sx, sy])}></circle><circle class="dtknob"
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${kr.toFixed(2)}"></circle>`)}
${sides.map(([sx, sy, cur]) => {
const x = sx < 0 ? b.x : sx > 0 ? b.x + b.w : b.x + b.w / 2;
const y = sy < 0 ? b.y : sy > 0 ? b.y + b.h : b.y + b.h / 2;
return svg`<circle class="dthandle dtedge dt-${cur}" cx="${x}" cy="${y}"
r="${hr.toFixed(1)}" @pointerdown=${(e: PointerEvent) =>
this._dtStart(e, 'scale', [sx, sy])}></circle>
<circle class="dtknob dtedgeknob" cx="${x}" cy="${y}" r="${kr.toFixed(2)}"></circle>`;
})}
</g>` as unknown as TemplateResult;
}
@@ -8076,10 +8090,7 @@ export class HouseplanCard extends LitElement {
const art = furnitureGraphic(placement.symbol);
if (!art) return nothing;
const W = NORM_W, H = this._decorH;
const W2 = placement.w * W, H2 = placement.h * H;
const cx = placement.x * W + W2 / 2, cy = placement.y * H + H2 / 2;
const transform = `${placement.angle ? `rotate(${placement.angle} ${cx} ${cy}) ` : ''}`
+ `translate(${placement.x * W} ${placement.y * H}) scale(${W2 / art.viewW} ${H2 / art.viewH})`;
const transform = furnitureRenderTransform(placement, W, H, art.viewW, art.viewH);
const style = this._decorStyle;
const strokeWidth = furnitureStrokePx(
decorCmToUnits(style.widthCm, this._cellCm, this._gridPitch),
@@ -8167,18 +8178,26 @@ export class HouseplanCard extends LitElement {
// physical plan zoom.
// An unknown symbol renders as nothing: a plan from a newer card must
// open in an older one, not break it.
const W2 = sh.w * W, H2 = sh.h * H;
const art = furnitureGraphic(sh.symbol);
if (!art) return nothing;
const ang = Number(sh.angle) || 0;
const cx = sh.x * W + W2 / 2, cy = sh.y * H + H2 / 2;
const tr = `${ang ? `rotate(${ang} ${cx} ${cy}) ` : ''}translate(${sh.x * W} ${sh.y * H}) scale(${W2 / art.viewW} ${H2 / art.viewH})`;
const tr = furnitureRenderTransform(sh, W, H, art.viewW, art.viewH);
const visibleStrokePx = furnitureStrokePx(strokeWidth, furnitureScreenScale);
const selectStrokePx = furnitureStrokePx(
strokeWidth + decorCmToUnits(20, this._cellCm, this._gridPitch),
furnitureScreenScale,
);
return svg`<path class="${cls} dfurn" data-hp="decor" data-id="${sh.id}"
data-kind="${sh.kind}" data-symbol="${sh.symbol}" d="${art.d}" transform=${tr}
stroke="${style.color}" stroke-opacity="${style.opacity}"
stroke-width="${furnitureStrokePx(strokeWidth, furnitureScreenScale)}" fill="none"
stroke-width="${visibleStrokePx}" fill="none"
stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"
@pointerdown=${down} @dblclick=${dbl}></path>
${editing && this._decorTool === 'select' ? svg`<path
class="dshape dfurniturehit" data-hp="decor" data-id="${sh.id}"
data-kind="${sh.kind}" data-symbol="${sh.symbol}" d="${art.d}" transform=${tr}
stroke-width="${selectStrokePx}" fill="none" stroke-linecap="round"
stroke-linejoin="round" vector-effect="non-scaling-stroke"
@pointerdown=${down} @dblclick=${dbl}></path>` : nothing}
${erasing ? svg`<path class="dshape derasehit" data-hp="decor" data-id="${sh.id}"
data-kind="${sh.kind}" data-symbol="${sh.symbol}" d="${art.d}" transform=${tr}
vector-effect="non-scaling-stroke"
+157 -12
View File
@@ -66,8 +66,10 @@ import {
FURNITURE_GROUPS, furnitureOfGroup, furnitureDefaultCm,
furnitureGraphic, furnitureCorners, snapFurnitureToWall,
resolveFurniturePlacement,
resizeFurnitureTransform, furnitureRotationAngle,
furnitureSignedFieldCm, furnitureSignedFieldValue,
clampFurnCm, FURN_WALL_CELLS,
type FurnitureGroup, type FurniturePlacement,
type FurnitureGroup, type FurniturePlacement, type FurnitureResizeResult,
} from './furniture';
import { GENERATED_FURNITURE_MENU } from './furniture-menu-art.generated';
import {
@@ -876,7 +878,7 @@ export interface HouseplanEditorHostPort {
_decorMove: { id: string; start: number[]; orig: DecorShape; pid: number; moved: boolean; before: SpaceGeometryState | null; } | null;
_decorResolvedStyle: (shape?: DecorShape | null) => DecorStyle;
_decorSel: string | null;
_decorShapeDialog: { id: string; kind: "line" | "rect" | "ellipse" | "furniture"; color: string; opacity: number; widthCm: number; lineStyle?: "solid" | "dashed"; fill?: boolean; fillColor?: string; fillOpacity?: number; lengthCm?: number; sizeWCm?: number; sizeHCm?: number; angle: string; symbol?: string; } | null;
_decorShapeDialog: { id: string; kind: "line" | "rect" | "ellipse" | "furniture"; color: string; opacity: number; widthCm: number; lineStyle?: "solid" | "dashed"; fill?: boolean; fillColor?: string; fillOpacity?: number; lengthCm?: number; sizeWCm?: number; sizeHCm?: number; angle: string; symbol?: string; sizeWField?: string; sizeHField?: string; flipH?: boolean; flipV?: boolean; } | null;
_decorSmallCm: (value: number) => number;
_decorSmallField: (cm: number) => number;
_decorSnapCache: { epoch: number; space: string; height: number; exclude: string; geometry: SnapGeometry; } | null;
@@ -4314,7 +4316,19 @@ public _openDecorProperties(shape: DecorShape): void {
sizeWCm: decorUnitsToCm(box.w, this.host._cellCm, this.host._gridPitch),
sizeHCm: decorUnitsToCm(box.h, this.host._cellCm, this.host._gridPitch),
} : {}),
...(shape.kind === 'furniture' ? { symbol: shape.symbol } : {}),
...(shape.kind === 'furniture' ? {
symbol: shape.symbol,
flipH: !!shape.flip_h,
flipV: !!shape.flip_v,
sizeWField: furnitureSignedFieldValue(
decorUnitsToCm(box!.w, this.host._cellCm, this.host._gridPitch),
!!shape.flip_h, this.host._imperial,
),
sizeHField: furnitureSignedFieldValue(
decorUnitsToCm(box!.h, this.host._cellCm, this.host._gridPitch),
!!shape.flip_v, this.host._imperial,
),
} : {}),
...(shape.kind === 'rect' || shape.kind === 'ellipse' ? {
fill: style.fill, fillColor: style.fillColor, fillOpacity: style.fillOpacity,
} : {}),
@@ -4437,6 +4451,15 @@ public _decorSaveShape(): void {
fillOpacity: clamp01(d.fillOpacity, 0.25),
};
const sp = this.host._curSpaceCfg;
const furnitureWcm = d.kind === 'furniture'
? furnitureSignedFieldCm(
d.sizeWField, this.host._imperial, CANVAS_LIMIT * this.host._cellCm,
) : null;
const furnitureHcm = d.kind === 'furniture'
? furnitureSignedFieldCm(
d.sizeHField, this.host._imperial, CANVAS_LIMIT * this.host._cellCm,
) : null;
if (d.kind === 'furniture' && (furnitureWcm === null || furnitureHcm === null)) return;
sp.decor = this.host._decorList.map((shape) => {
if (shape.id !== d.id) return shape;
const fillable = d.kind === 'rect' || d.kind === 'ellipse';
@@ -4456,7 +4479,25 @@ public _decorSaveShape(): void {
...(d.lineStyle === 'dashed' ? { line_style: 'dashed' as const } : {}),
} as DecorShape;
}
if (shape.kind === 'rect' || shape.kind === 'ellipse' || shape.kind === 'furniture') {
if (shape.kind === 'furniture') {
const oldW = shape.w * NORM_W, oldH = shape.h * this.host._decorH;
const w = decorCmToUnits(Math.abs(furnitureWcm!), this.host._cellCm, this.host._gridPitch);
const h = decorCmToUnits(Math.abs(furnitureHcm!), this.host._cellCm, this.host._gridPitch);
const cx = shape.x * NORM_W + oldW / 2, cy = shape.y * this.host._decorH + oldH / 2;
const angle = normalizeAngle(d.angle);
const { width: _legacyWidth, angle: _oldAngle, flip_h: _oldFlipH,
flip_v: _oldFlipV, ...rest } = shape;
return { ...rest, ...visual,
x: clampCanvasN((cx - w / 2) / NORM_W),
y: clampCanvasN((cy - h / 2) / this.host._decorH),
w: w / NORM_W, h: h / this.host._decorH,
...(d.symbol ? { symbol: d.symbol } : {}),
...(furnitureWcm! < 0 ? { flip_h: true } : {}),
...(furnitureHcm! < 0 ? { flip_v: true } : {}),
...(angle ? { angle } : {}),
} as DecorShape;
}
if (shape.kind === 'rect' || shape.kind === 'ellipse') {
const oldW = shape.w * NORM_W, oldH = shape.h * this.host._decorH;
const w = Math.max(this.host._gridPitch, snapToGrid(
decorCmToUnits(Number(d.sizeWCm), this.host._cellCm, this.host._gridPitch), this.host._gridPitch,
@@ -4474,7 +4515,6 @@ public _decorSaveShape(): void {
return { ...rest, ...visual,
x: clampCanvasN(topLeft[0] / NORM_W), y: clampCanvasN(topLeft[1] / this.host._decorH),
w: w / NORM_W, h: h / this.host._decorH,
...(shape.kind === 'furniture' && d.symbol ? { symbol: d.symbol } : {}),
...(angle ? { angle } : {}),
} as DecorShape;
}
@@ -4568,6 +4608,20 @@ public _dtMove(ev: PointerEvent): void {
return;
}
if (d.kind === 'scale' && d.orig) {
if (d.origShape.kind === 'furniture') {
const box = resizeFurnitureTransform(
{ ...d.orig, flip_h: d.origShape.flip_h, flip_v: d.origShape.flip_v },
d.sgx ?? 1, d.sgy ?? 1, p[0], p[1],
!ev.shiftKey, decorCmToUnits(0.1, this.host._cellCm, this.host._gridPitch),
);
const changed = Math.abs(box.x - d.orig.x) > 1e-9 || Math.abs(box.y - d.orig.y) > 1e-9
|| Math.abs(box.w - d.orig.w) > 1e-9 || Math.abs(box.h - d.orig.h) > 1e-9
|| !!box.flip_h !== !!d.origShape.flip_h || !!box.flip_v !== !!d.origShape.flip_v;
if (!changed && !d.moved) return;
d.moved ||= changed;
this._decorApplyFurnitureBox(d.id, box);
return;
}
// Box geometry preserves ratio by default and separates axes with Shift.
// Each dimension lands on a whole cell.
const box = resizeDecorBox(
@@ -4594,11 +4648,16 @@ public _dtMove(ev: PointerEvent): void {
return;
}
const a = (Math.atan2(p[1] - d.ay, p[0] - d.ax) * 180) / Math.PI;
let ang = d.angle0 + (a - d.a0);
// 5° rotation steps; Shift remains an angle-only precision modifier.
if (!ev.shiftKey) ang = Math.round(ang / DT_ANGLE_STEP) * DT_ANGLE_STEP;
ang = ((ang % 360) + 360) % 360;
if (ang > 180) ang -= 360;
let ang: number;
if (d.origShape.kind === 'furniture') {
ang = furnitureRotationAngle(d.angle0, d.a0, a, ev.shiftKey);
} else {
ang = d.angle0 + (a - d.a0);
// Existing non-furniture contract: 5° normally, Shift = free.
if (!ev.shiftKey) ang = Math.round(ang / DT_ANGLE_STEP) * DT_ANGLE_STEP;
ang = ((ang % 360) + 360) % 360;
if (ang > 180) ang -= 360;
}
const changed = Math.abs(ang - d.angle0) > 1e-6;
if (!changed && !d.moved) return;
d.moved ||= changed;
@@ -4869,6 +4928,27 @@ public _decorApplyBox(id: string, box: { x: number; y: number; w: number; h: num
this.host.requestUpdate();
}
public _decorApplyFurnitureBox(id: string, box: FurnitureResizeResult): void {
const sp = this.host._curSpaceCfg;
if (!sp) return;
const W = NORM_W, H = this.host._decorH;
const minUnits = decorCmToUnits(0.1, this.host._cellCm, this.host._gridPitch);
sp.decor = this.host._decorList.map((shape) => {
if (shape.id !== id || shape.kind !== 'furniture') return shape;
const { flip_h: _oldFlipH, flip_v: _oldFlipV, ...rest } = shape;
return {
...rest,
x: clampCanvasN(box.x / W), y: clampCanvasN(box.y / H),
w: Math.max(minUnits / W, Math.min(CANVAS_LIMIT * 2, box.w / W)),
h: Math.max(minUnits / H, Math.min(CANVAS_LIMIT * 2, box.h / H)),
...(box.flip_h ? { flip_h: true } : {}),
...(box.flip_v ? { flip_v: true } : {}),
} as DecorShape;
});
this.host._cfgEpoch++;
this.host.requestUpdate();
}
public _renderFurnPalette(): TemplateResult {
const pal = this.host._furnPalette;
const categoryId = this.host._furnCategory;
@@ -5530,11 +5610,53 @@ public _renderDecorTextDialog(): TemplateResult {
</hp-dialog>`;
}
private _decorFurnitureSizeInput(axis: 'w' | 'h', raw: string): void {
const d = this.host._decorShapeDialog;
if (!d || d.kind !== 'furniture') return;
const cm = furnitureSignedFieldCm(
raw, this.host._imperial, CANVAS_LIMIT * this.host._cellCm,
);
const field = axis === 'w' ? 'sizeWField' : 'sizeHField';
const size = axis === 'w' ? 'sizeWCm' : 'sizeHCm';
const flip = axis === 'w' ? 'flipH' : 'flipV';
this.host._decorShapeDialog = {
...d,
[field]: raw,
...(cm === null ? {} : { [size]: Math.abs(cm), [flip]: cm < 0 }),
};
}
private _decorFurnitureFlip(axis: 'w' | 'h', checked: boolean): void {
const d = this.host._decorShapeDialog;
if (!d || d.kind !== 'furniture') return;
const raw = axis === 'w' ? d.sizeWField : d.sizeHField;
const parsed = furnitureSignedFieldCm(
raw, this.host._imperial, CANVAS_LIMIT * this.host._cellCm,
);
const fallback = axis === 'w' ? d.sizeWCm : d.sizeHCm;
const cm = Math.abs(parsed ?? fallback ?? 0.1);
const field = axis === 'w' ? 'sizeWField' : 'sizeHField';
const flip = axis === 'w' ? 'flipH' : 'flipV';
this.host._decorShapeDialog = {
...d,
[field]: furnitureSignedFieldValue(cm, checked, this.host._imperial),
[flip]: checked,
};
}
public _renderDecorShapeDialog(): TemplateResult {
const d = this.host._decorShapeDialog!;
const canFill = d.kind === 'rect' || d.kind === 'ellipse';
const kindLabel = this.host._t(('decor.' + d.kind) as any);
const unit = this.host._t(this.host._imperial ? 'gs.unit_ft' : 'gs.unit_m');
const furnitureWcm = d.kind === 'furniture' ? furnitureSignedFieldCm(
d.sizeWField, this.host._imperial, CANVAS_LIMIT * this.host._cellCm,
) : null;
const furnitureHcm = d.kind === 'furniture' ? furnitureSignedFieldCm(
d.sizeHField, this.host._imperial, CANVAS_LIMIT * this.host._cellCm,
) : null;
const invalidFurnitureSize = d.kind === 'furniture'
&& (furnitureWcm === null || furnitureHcm === null);
return html`<hp-dialog .hass=${this.host.hass}
.title=${this.host._t('decor.object_title', { kind: kindLabel })} icon="mdi:pencil-outline"
dismiss-on-scrim @hp-close=${() => (this.host._decorShapeDialog = null)}>
@@ -5585,7 +5707,29 @@ public _renderDecorShapeDialog(): TemplateResult {
.value=${String(this.host._decorLargeField(d.lengthCm || 0))}
@input=${(e: Event) => (this.host._decorShapeDialog = { ...d,
lengthCm: this.host._decorLargeCm(Number((e.target as HTMLInputElement).value)) })} />
<span class="opl">${unit}</span></div>` : html`
<span class="opl">${unit}</span></div>` : d.kind === 'furniture' ? html`
<label>${this.host._t('decor.size')}</label>
<div class="colorrow"><input class="namein" type="number" step="any"
aria-invalid=${furnitureWcm === null ? 'true' : 'false'}
.value=${d.sizeWField || ''}
@input=${(e: Event) => this._decorFurnitureSizeInput(
'w', (e.target as HTMLInputElement).value,
)} />
<span>×</span><input class="namein" type="number" step="any"
aria-invalid=${furnitureHcm === null ? 'true' : 'false'}
.value=${d.sizeHField || ''}
@input=${(e: Event) => this._decorFurnitureSizeInput(
'h', (e.target as HTMLInputElement).value,
)} />
<span class="opl">${unit}</span></div>
<label class="dfill"><input type="checkbox" .checked=${!!d.flipH}
@change=${(e: Event) => this._decorFurnitureFlip(
'w', (e.target as HTMLInputElement).checked,
)} />${this.host._t('decor.flip_h')}</label>
<label class="dfill"><input type="checkbox" .checked=${!!d.flipV}
@change=${(e: Event) => this._decorFurnitureFlip(
'h', (e.target as HTMLInputElement).checked,
)} />${this.host._t('decor.flip_v')}</label>` : html`
<label>${this.host._t('decor.size')}</label>
<div class="colorrow"><input class="namein" type="number" min="0.01" step="0.01"
.value=${String(this.host._decorLargeField(d.sizeWCm || 0))}
@@ -5615,7 +5759,8 @@ public _renderDecorShapeDialog(): TemplateResult {
<div class="row" slot="footer">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this.host._decorShapeDialog = null)}>${this.host._t('btn.cancel')}</button>
<button class="btn primary" @click=${() => this._decorSaveShape()}>${this.host._t('btn.save')}</button>
<button class="btn primary" ?disabled=${invalidFurnitureSize}
@click=${() => this._decorSaveShape()}>${this.host._t('btn.save')}</button>
</div>
</hp-dialog>`;
}
+2
View File
@@ -518,6 +518,8 @@
"decor.fill_color": "Füllfarbe",
"decor.length": "Länge",
"decor.size": "Größe",
"decor.flip_h": "Horizontal spiegeln",
"decor.flip_v": "Vertikal spiegeln",
"decor.angle": "Rotationswinkel, °",
"decor.text_size": "Textgröße",
"decor.backdrop_properties": "Eigenschaften des Planhintergrunds",
+2
View File
@@ -518,6 +518,8 @@
"decor.fill_color": "Fill color",
"decor.length": "Length",
"decor.size": "Size",
"decor.flip_h": "Flip horizontally",
"decor.flip_v": "Flip vertically",
"decor.angle": "Rotation angle, °",
"decor.text_size": "Text size",
"decor.backdrop_properties": "Plan backdrop properties",
+2
View File
@@ -518,6 +518,8 @@
"decor.fill_color": "Couleur de remplissage",
"decor.length": "Longueur",
"decor.size": "Taille",
"decor.flip_h": "Retourner horizontalement",
"decor.flip_v": "Retourner verticalement",
"decor.angle": "Angle de rotation, °",
"decor.text_size": "Taille du texte",
"decor.backdrop_properties": "Propriétés de l’arrière-plan du plan",
+2
View File
@@ -518,6 +518,8 @@
"decor.fill_color": "Цвет заливки",
"decor.length": "Длина",
"decor.size": "Размер",
"decor.flip_h": "Отзеркалить по горизонтали",
"decor.flip_v": "Отзеркалить по вертикали",
"decor.angle": "Угол поворота, °",
"decor.text_size": "Размер текста",
"decor.backdrop_properties": "Свойства картинки-подложки",
+17
View File
@@ -728,6 +728,17 @@ export const planStyles = css`
pointer-events: stroke;
cursor: move;
}
/* #383: furniture keeps a path-shaped target. Its per-object physical
width is supplied by the renderer; no empty bounding-box area is hit. */
.stage.mode-decor .decorlayer .dshape.dfurniturehit {
fill: none;
stroke: transparent;
pointer-events: none;
}
.stage.mode-decor.dtool-select .decorlayer .dshape.dfurniturehit {
pointer-events: stroke;
cursor: move;
}
.decorlayer .dsel {
filter: drop-shadow(0 0 3px var(--hp-accent));
}
@@ -837,6 +848,12 @@ export const planStyles = css`
.dtframe .dt-nwse { cursor: nwse-resize; }
.dtframe .dt-nesw { cursor: nesw-resize; }
.dtframe .dtrot { cursor: grab; }
.dtframe .dt-ew { cursor: ew-resize; }
.dtframe .dt-ns { cursor: ns-resize; }
.dtfurnitureframe .dtrot {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M18.4 7.2A8 8 0 1 0 20 12' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='m15.5 3.8 3.2 3.5-4.6.8' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 12 12, grab;
}
.dtfurnitureframe .dtrot:active { cursor: grabbing; }
.dtframe .dtendpoint { cursor: crosshair; }
.bdframe .dtrot { cursor: grab; }
.dtarea {
+12
View File
@@ -99,6 +99,18 @@ test('Optimize can apply the same barrier in-place without a second clone (#291)
assert.deepEqual(input, fixture.configExpected);
});
test('#383 furniture flip flags survive frontend canonicalization unchanged', () => {
const config = { spaces: [{
id: 's', decor: [{
id: 'f', kind: 'furniture', symbol: 'sofa', x: 0.2000000001, y: 0.3,
w: 0.18, h: 0.075, flip_h: true, flip_v: false,
}],
}] };
const furniture = canonicalizeConfigGeometry(config).spaces[0].decor[0];
assert.equal(furniture.flip_h, true);
assert.equal(furniture.flip_v, false);
});
test('one position changes only x/y and preserves future metadata (#224)', () => {
const input = {
s: 'floor', x: 0.1000000006, y: -0.0000000004,
+2 -1
View File
@@ -26,7 +26,8 @@ test('#361 saved furniture and placement preview share the physical screen resol
// must fall back to 1 so furniture strokes track ordinary decor there.
assert.match(layer,
/const furnitureScreenScale = this\._renderProjection === 'iso' \? 1 : furniturePlanScreenScale\(/);
assert.match(layer, /stroke-width="\$\{furnitureStrokePx\(strokeWidth, furnitureScreenScale\)\}"/);
assert.match(layer, /const visibleStrokePx = furnitureStrokePx\(strokeWidth, furnitureScreenScale\)/);
assert.match(layer, /stroke-width="\$\{visibleStrokePx\}"/);
assert.match(layer, /_renderFurniturePlacementPreview\(furnitureScreenScale\)/);
assert.equal(
(layer.match(/furniturePlanScreenScale\(/g) || []).length,
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const runtime = readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../src/styles/plan.styles.ts', import.meta.url), 'utf8');
test('#383 furniture uses its continuous signed path without changing shared decor transforms', () => {
const move = runtime.slice(runtime.indexOf('public _dtMove('), runtime.indexOf('public _dtUp('));
assert.match(move, /d\.origShape\.kind === 'furniture'[\s\S]*resizeFurnitureTransform\(/);
assert.match(move, /!ev\.shiftKey, decorCmToUnits\(0\.1/);
assert.match(move, /furnitureRotationAngle\(d\.angle0, d\.a0, a, ev\.shiftKey\)/);
assert.match(move, /Existing non-furniture contract: 5° normally, Shift = free/);
assert.match(move, /resizeDecorBox\([\s\S]*this\.host\._gridPitch, this\.host\._gridPitch/);
});
test('#383 furniture frame alone has four middle handles and a local rotation cursor', () => {
const frame = card.slice(card.indexOf('private _renderTextFrame('), card.indexOf('private get _furniturePreviewPlacement'));
assert.match(frame, /const sides:[\s\S]*\[0, -1, 'ns'\][\s\S]*\[-1, 0, 'ew'\]/);
assert.match(frame, /sh\.kind === 'furniture'/);
assert.match(frame, /dtfurnitureframe/);
assert.match(styles, /\.dtfurnitureframe \.dtrot[\s\S]*data:image\/svg\+xml/);
assert.match(styles, /\.dtframe \.dt-ew \{ cursor: ew-resize; \}/);
});
test('#383 furniture selection halo follows the symbol path and adds 20 physical cm', () => {
const layer = card.slice(card.indexOf('private _renderDecorLayer('), card.indexOf('// ================= shared editor secondary surface'));
assert.match(layer, /class="dshape dfurniturehit"/);
assert.match(layer, /strokeWidth \+ decorCmToUnits\(20, this\._cellCm, this\._gridPitch\)/);
assert.match(layer, /data-symbol="\$\{sh\.symbol\}" d="\$\{art\.d\}" transform=\$\{tr\}/);
assert.match(styles, /dtool-select \.decorlayer \.dshape\.dfurniturehit[\s\S]*pointer-events: stroke/);
assert.doesNotMatch(layer, /dfurniturehit[\s\S]{0,200}bounding-box/);
});
test('#383 properties keep signed fields and checkboxes as one state', () => {
const dialog = runtime.slice(runtime.indexOf('private _decorFurnitureSizeInput('), runtime.indexOf('public _renderBackdropDialog('));
assert.match(dialog, /furnitureSignedFieldCm\(/);
assert.match(dialog, /furnitureSignedFieldValue\(/);
assert.match(dialog, /decor\.flip_h/);
assert.match(dialog, /decor\.flip_v/);
assert.match(dialog, /\?disabled=\$\{invalidFurnitureSize\}/);
});
+81
View File
@@ -7,6 +7,8 @@ import {
FURNITURE, FURNITURE_GROUPS, furnitureSymbol, furnitureOfGroup,
furnitureDefaultCm, furniturePathD, furnitureGraphic, furnitureCorners, furnitureResize,
snapFurnitureToWall, resolveFurniturePlacement, furniturePlanScreenScale, furnitureStrokePx,
resizeFurnitureTransform, furnitureRotationAngle, furnitureRenderTransform,
furnitureSignedFieldCm, furnitureSignedFieldValue,
cmToNorm, normToCm, clampFurnSize, clampFurnCm,
FURN_MIN_N, FURN_MIN_CM, FURN_MAX_CM, FURN_WALL_CELLS,
} from '../test-build/furniture.js';
@@ -311,3 +313,82 @@ test('resizing a ROTATED piece works along its own axes and keeps its own fixed
// rotated 90 degrees, pulling DOWN in world space grows the piece's WIDTH
closeTo(r.w, 200, 1e-6);
});
test('#383 furniture resize is sub-grid, proportional by default and independent with Shift', () => {
const orig = { x: 10, y: 20, w: 40, h: 20 };
const proportional = resizeFurnitureTransform(orig, 1, 1, 50.37, 40.02, true, 0.1);
closeTo(proportional.w / proportional.h, 2);
assert.ok(Math.abs(proportional.w / GRID_PITCH - Math.round(proportional.w / GRID_PITCH)) > 1e-3,
'continuous resize must not land on the cell lattice by construction');
const independent = resizeFurnitureTransform(orig, 1, 1, 50.37, 40.02, false, 0.1);
closeTo(independent.w, 40.37);
closeTo(independent.h, 20.02);
});
test('#383 edge handles change one local axis and keep the opposite world edge fixed', () => {
const orig = { x: 10, y: 20, w: 40, h: 20, angle: 90 };
const before = furnitureCorners(orig.x, orig.y, orig.w, orig.h, orig.angle);
const right = resizeFurnitureTransform(orig, 1, 0, 30, 90, false, 0.1);
closeTo(right.h, orig.h);
const after = furnitureCorners(right.x, right.y, right.w, right.h, orig.angle);
closeTo((after[0][0] + after[3][0]) / 2, (before[0][0] + before[3][0]) / 2);
closeTo((after[0][1] + after[3][1]) / 2, (before[0][1] + before[3][1]) / 2);
const bottom = resizeFurnitureTransform(orig, 0, 1, -20, 40, false, 0.1);
closeTo(bottom.w, orig.w);
});
test('#383 crossing keeps positive extents, toggles only crossed flips and preserves fixed corner', () => {
const orig = { x: 0, y: 0, w: 40, h: 20, angle: 30, flip_h: true };
const fixed = furnitureCorners(orig.x, orig.y, orig.w, orig.h, orig.angle)[0];
const crossed = resizeFurnitureTransform(orig, 1, 1, fixed[0] - 20, fixed[1] + 5, false, 0.1);
assert.ok(crossed.w > 0 && crossed.h > 0);
assert.equal(crossed.flip_h, undefined, 'crossing horizontal axis toggles existing H flip off');
assert.equal(crossed.flip_v, undefined);
const after = furnitureCorners(crossed.x, crossed.y, crossed.w, crossed.h, orig.angle);
// A crossed active corner becomes the adjacent geometric corner; the fixed
// world point is nevertheless still one of the box corners.
assert.ok(after.some((point) => Math.hypot(point[0] - fixed[0], point[1] - fixed[1]) < 1e-8));
const both = resizeFurnitureTransform({ x: 0, y: 0, w: 40, h: 20 }, 1, 1, -5, -8, false, 0.1);
assert.equal(both.flip_h, true);
assert.equal(both.flip_v, true);
assert.deepEqual([both.w, both.h], [5, 8]);
});
test('#383 furniture rotation is free normally and Shift snaps to 45 degrees', () => {
closeTo(furnitureRotationAngle(10, 20, 33.4, false), 23.4);
assert.equal(furnitureRotationAngle(10, 20, 33.4, true), 45);
assert.equal(furnitureRotationAngle(170, 0, 30, true), 180);
assert.equal(furnitureRotationAngle(0, 0, 22.5, true), 45);
assert.equal(furnitureRotationAngle(0, 0, -22.5, true), -45);
});
test('#383 render transform mirrors inside the same positive box before rotation', () => {
const base = { x: 0.1, y: 0.2, w: 0.3, h: 0.4, angle: 30 };
assert.equal(
furnitureRenderTransform(base, 1000, 500, 100, 50),
'rotate(30 250 200) translate(100 100) scale(3 4)',
);
assert.equal(
furnitureRenderTransform({ ...base, flip_h: true, flip_v: true }, 1000, 500, 100, 50),
'rotate(30 250 200) translate(400 300) scale(-3 -4)',
);
});
test('#383 signed property fields project flags without persisting negative extents', () => {
assert.equal(furnitureSignedFieldValue(180, false, false), '1.8');
assert.equal(furnitureSignedFieldValue(180, true, false), '-1.8');
assert.equal(furnitureSignedFieldValue(30.48, true, true), '-1');
assert.equal(furnitureSignedFieldValue(0.1, false, false), '0.001');
assert.equal(furnitureSignedFieldCm(furnitureSignedFieldValue(0.1, false, false), false, 10000), 0.1);
closeTo(
furnitureSignedFieldCm(furnitureSignedFieldValue(0.1, false, true), true, 10000),
0.1,
0.0001,
);
assert.equal(furnitureSignedFieldCm('-1.8', false, 10000), -180);
assert.equal(furnitureSignedFieldCm('1', true, 10000), 30.48);
for (const invalid of ['', '0', 0, 'wat', Infinity])
assert.equal(furnitureSignedFieldCm(invalid, false, 10000), null);
});
+18 -1
View File
@@ -373,7 +373,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 51);
assert.equal(GOLDEN_MATRIX_VERSION, 52);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -872,6 +872,23 @@ test('issue 359 has one deterministic light-theme furniture placement preview go
});
});
test('issue 383 goldens cover every furniture mirror orientation in light and dark themes', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.id.startsWith('furniture-transform-'));
assert.equal(scenarios.length, 2);
assert.deepEqual(new Set(scenarios.map((item) => item.theme)), new Set(['light', 'dark']));
for (const scenario of scenarios) {
const furniture = scenario.decorOverride.filter((shape) => shape.kind === 'furniture');
const orientations = furniture.map((shape) =>
`${shape.flip_h ? 'h' : ''}${shape.flip_v ? 'v' : ''}` || 'normal');
assert.deepEqual(new Set(orientations), new Set(['normal', 'h', 'v', 'hv']), scenario.id);
assert.ok(furniture.every((shape) => shape.symbol === 'sofa_corner_right'));
}
const selected = scenarios.find((scenario) => scenario.decorSelection);
assert.equal(selected?.theme, 'light');
assert.equal(selected?.mode, 'decor');
assert.equal(selected?.decorSelection, 'furn-flip-hv');
});
test('device dialog goldens expose the complete light-source controls at desktop and mobile widths', () => {
const dialogs = GOLDEN_SCENARIOS.filter((scenario) => scenario.id.startsWith('device-dialog-'));
assert.equal(dialogs.length, 3);
@@ -104,6 +104,22 @@ def test_backend_schemas_apply_the_same_allowlist() -> None:
assert POS_SCHEMA(fixture["layoutInput"]["virtual"]) == fixture["layoutExpected"]["virtual"]
def test_furniture_flip_flags_survive_coordinate_canonicalization_unchanged() -> None:
config = {"spaces": [{
"id": "s", "title": "S", "view_box": [0, 0, 1, 1], "rooms": [],
"decor": [{
"id": "f", "kind": "furniture", "symbol": "sofa",
"x": 0.2000000001, "y": 0.3, "w": 0.18, "h": 0.075,
"flip_h": True, "flip_v": False,
}],
}]}
result = canonicalize_config_geometry(config)
furniture = result["spaces"][0]["decor"][0]
assert furniture["flip_h"] is True
assert furniture["flip_v"] is False
assert CONFIG_SCHEMA(result) == result
def test_optimize_roundtrip_fixture_has_one_backend_canonical_target() -> None:
"""#248: Python consumes the same cross-runtime target as the Node test."""
fixture = json.loads(OPTIMIZE_ROUNDTRIP_FIXTURE.read_text(encoding="utf-8"))
+3
View File
@@ -630,6 +630,7 @@ def _plan_only_source() -> tuple[dict[str, Any], dict[str, Any]]:
{
"id": "furniture", "kind": "furniture", "symbol": "sofa",
"x": 0.5, "y": 0.5, "w": 0.2, "h": 0.1, "angle": 10,
"flip_h": True, "flip_v": False,
},
],
})
@@ -701,6 +702,8 @@ def test_plan_only_export_projects_geometry_and_round_trips_room_labels(tmp_path
assert not {"entity", "attr", "unit"} & set(by_id["legacy"])
assert by_id["static"]["text"] == "Literal {not a reference} and sensor.user_text"
assert by_id["furniture"]["symbol"] == "sofa"
assert by_id["furniture"]["flip_h"] is True
assert by_id["furniture"]["flip_v"] is False
parsed = parse_document(json.dumps(document).encode())
preview = create_preview(
+6
View File
@@ -1588,6 +1588,8 @@ def test_decor_furniture():
out = cfg({"angle": 90, "color": "#607d8b", "width": 3})["spaces"][0]["decor"][0]
assert out["angle"] == 90 and out["color"] == "#607d8b" and out["width"] == 3.0
assert cfg({"angle": -360}) and cfg({"angle": 360}) and cfg({"angle": 12.5})
mirrored = cfg({"flip_h": True, "flip_v": False})["spaces"][0]["decor"][0]
assert mirrored["flip_h"] is True and mirrored["flip_v"] is False
# a symbol this backend has never heard of is still SAVED: the card may
# learn a new one before the integration does, and a plan must not be
# refused for being newer than the server reading it
@@ -1607,6 +1609,10 @@ def test_decor_furniture():
for bad in (361, -361, float("nan")):
with pytest.raises(vol.Invalid):
cfg({"angle": bad})
for field in ("flip_h", "flip_v"):
for bad in (0, 1, "true", None, [], {}):
with pytest.raises(vol.Invalid):
cfg({field: bad})
for bad in (float("nan"), v.CANVAS_LIMIT + 1, -v.CANVAS_LIMIT - 1):
with pytest.raises(vol.Invalid):
cfg({"x": bad})