${this._t(Eu[t.display])}
+${this._t(Nu[t.display])}
${"static_icon"===t.display&&this._bindingHasAlarm(t.binding)?j` - `}}op.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_continuityEpoch:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_hoverRoom:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_wallDialog:{state:!0},_drawWallField:{state:!0},_activeDraftId:{state:!0},_physicalSel:{state:!0},_physicalDialog:{state:!0},_physicalDrag:{state:!0},_physicalRotate:{state:!0},_duplicateColumnId:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_opMeasure:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingPreset:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_openWallAnchor:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorEraseConfirm:{state:!0},_decorTextDialog:{state:!0},_decorShapeDialog:{state:!0},_backdropDialog:{state:!0},_furnPalette:{state:!0},_bdDrag:{state:!0},_dtBox:{state:!0},_dtDrag:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_vacAllCamerasFor:{state:!0},_vacCalConfirm:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomCustomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_alignDialog:{state:!0},_backupExportDialog:{state:!0},_backupImportDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},op.ZOOM_MAX=8,op.ZOOM_MIN=1/3,op._touchSeen=!1,op._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,op.styles=[Ot,hd],customElements.get("houseplan-card")||customElements.define("houseplan-card",op),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info(`%c HOUSEPLAN-CARD %c v${Au} `,"background:#3ea6ff;color:#04121f;font-weight:700",""); + `}}lp.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_continuityEpoch:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_hoverRoom:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_wallDialog:{state:!0},_drawWallField:{state:!0},_activeDraftId:{state:!0},_physicalSel:{state:!0},_physicalDialog:{state:!0},_physicalDrag:{state:!0},_physicalRotate:{state:!0},_duplicateColumnId:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_opMeasure:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingPreset:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_openWallAnchor:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorEraseConfirm:{state:!0},_decorTextDialog:{state:!0},_decorShapeDialog:{state:!0},_backdropDialog:{state:!0},_furnPalette:{state:!0},_bdDrag:{state:!0},_dtBox:{state:!0},_dtDrag:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_vacAllCamerasFor:{state:!0},_vacCalConfirm:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomCustomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_alignDialog:{state:!0},_backupExportDialog:{state:!0},_backupImportDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},lp.ZOOM_MAX=8,lp.ZOOM_MIN=1/3,lp._touchSeen=!1,lp._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,lp.styles=[Ot,pd],customElements.get("houseplan-card")||customElements.define("houseplan-card",lp),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info(`%c HOUSEPLAN-CARD %c v${zu} `,"background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/demo/smoke_room_autoclose.mjs b/demo/smoke_room_autoclose.mjs new file mode 100644 index 00000000..c45f38ce --- /dev/null +++ b/demo/smoke_room_autoclose.mjs @@ -0,0 +1,149 @@ +/** Issue #138: a new room may close along one existing continuous solid wall interval. */ +import { launch, checkAll, finish } from './serve.mjs'; + +const { page, browser } = await launch({ width: 900, height: 820 }, 1); + +const out = await page.evaluate(async () => { + const result = {}; + const card = window.__card; + const root = () => card.shadowRoot || card.renderRoot; + const update = async () => { card.requestUpdate(); await card.updateComplete; }; + const clone = (value) => JSON.parse(JSON.stringify(value)); + const baseSpace = { + id: 'autoclose', title: 'Autoclose', cell_cm: 5, view_box: [0, 0, 1, 0.7], + rooms: [{ + id: 'existing', name: 'Existing', area: null, + poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]], + }], + walls: [{ + key: '0.500000,0.300000@1.5708', + a: [0.5, 0.1], b: [0.5, 0.5], cm: 40, + }], + }; + const reset = async (spacePatch = {}) => { + card._serverCfg = { spaces: [{ ...clone(baseSpace), ...clone(spacePatch) }], markers: [], settings: {} }; + card._layout = {}; + card._space = 'autoclose'; + card._modelCache = null; + card._frame = null; + card._cfgEpoch++; + card._planSnapGeometryCache = null; + card._path = []; + card._activeDraftId = null; + card._draftSegmentCms = []; + card._closingWallCm = null; + card._roomDialog = false; + card._roomEditId = null; + card._pendingSplit = null; + card._toast = ''; + card._drawWallField = '15'; + card._setMode('plan'); + card._tool = 'draw'; + card._clearPlanSnapHover(); + await update(); + }; + + await reset(); + const stage = root().querySelector('.stage'); + const eventAt = (x, y, extra = {}) => { + const rect = stage.getBoundingClientRect(); + const view = card._viewOr(card._baseVb()); + return new MouseEvent('click', { + clientX: rect.left + ((x - view.x) / view.w) * rect.width, + clientY: rect.top + ((y - view.y) / view.h) * rect.height, + bubbles: true, + ...extra, + }); + }; + const click = async (x, y, extra = {}) => { + card._markupClick(eventAt(x, y, extra)); + await update(); + }; + const close = (a, b, epsilon = 1e-5) => Math.abs(a - b) <= epsilon; + const currentDraft = () => card._curSpaceCfg.room_drafts?.find((draft) => + draft.id === card._activeDraftId); + + // r1 regression guard: two points on the common wall are still only an open line. + await click(500, 100); + await click(500, 500); + result.secondCommonWallPointStaysOpen = card._path.length === 2 + && !card._roomDialog && !card._toast + && currentDraft()?.points.length === 2 + && currentDraft()?.segments.length === 1; + + // Endpoint-to-endpoint auto-close after the new room has enough vertices. + await reset(); + await click(500, 100); + await click(800, 100); + await click(800, 500); + await click(500, 500); + const successfulDraft = clone(currentDraft()); + result.endpointAutoCloseOpensDialog = card._roomDialog + && card._path.length === 5 + && close(card._path[0][0], card._path.at(-1)[0]) + && close(card._path[0][1], card._path.at(-1)[1]); + result.terminalSegmentPersistsBeforeDialog = successfulDraft?.points.length === 4 + && successfulDraft?.segments.length === 3 + && close(successfulDraft.points.at(-1)[0], 0.5) + && close(successfulDraft.points.at(-1)[1], 0.5) + && successfulDraft.segments.every((segment) => close(segment.cm, 15)); + result.roomIsNotCommittedBeforeSave = card._curSpaceCfg.rooms.length === 1; + + card._roomDialogCancel(); + await update(); + result.cancelKeepsTerminalOpenDraft = !card._roomDialog + && card._path.length === 4 + && !card._contourClosed + && JSON.stringify(currentDraft()) === JSON.stringify(successfulDraft) + && close(card._path.at(-1)[0], 500) && close(card._path.at(-1)[1], 500); + + // Save promotes the same draft, keeps the existing shared thickness and creates no partition. + await reset(); + await click(500, 100); + await click(800, 100); + await click(800, 500); + await click(500, 500); + card._nameSel = 'Adjacent'; + card._saveRoom(); + await update(); + const sharedWall = (card._curSpaceCfg.walls || []).find((wall) => wall.cm === 40 + && wall.a && wall.b + && close(wall.a[0], 0.5) && close(wall.b[0], 0.5) + && close(Math.min(wall.a[1], wall.b[1]), 0.1) + && close(Math.max(wall.a[1], wall.b[1]), 0.5)); + result.savePromotesDraftWithoutPartition = card._curSpaceCfg.rooms.length === 2 + && card._curSpaceCfg.rooms.some((room) => room.name === 'Adjacent') + && !card._curSpaceCfg.room_drafts?.length + && !card._curSpaceCfg.partitions?.length + && card._path.length === 0; + result.sharedWallKeepsNeighbourThickness = !!sharedWall + && (card._curSpaceCfg.walls || []).filter((wall) => wall.cm === 15).length >= 3; + + // A canonical cut splits the room wall, so the same endpoint pair no longer closes. + await reset({ openings: [{ + id: 'door', type: 'door', x: 0.5, y: 0.3, angle: 90, length: 0.1, + }] }); + await click(500, 100); + await click(800, 100); + await click(800, 500); + await click(500, 500); + result.openingCutPreventsAutoClose = !card._roomDialog + && card._path.length === 4 + && currentDraft()?.points.length === 4; + + // An eligible but self-intersecting prospective ring is consumed without partial write. + await reset(); + await click(500, 100); + await click(400, 300); + await click(700, 300); + const invalidDraftBefore = clone(currentDraft()); + await click(500, 500); + result.invalidCloseHasNoPartialWrite = !card._roomDialog + && card._path.length === 3 + && JSON.stringify(currentDraft()) === JSON.stringify(invalidDraftBefore) + && card._toast === card._t('toast.contour_cannot_close'); + + return result; +}); + +await finish(browser, checkAll(out)); diff --git a/demo/srv/assets/houseplan-card.js b/demo/srv/assets/houseplan-card.js index 7fcd5b3f..37e8f459 100755 --- a/demo/srv/assets/houseplan-card.js +++ b/demo/srv/assets/houseplan-card.js @@ -1,4 +1,4 @@ -globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="73d643bf705af32e159c6256b90d6a180960f6b42907ee5a14fcc0e90c4eea7f";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let n=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const o=(t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new n(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.reactiveElementPolyfillSupport,f=(t,e)=>t,v={toAttribute(t,e){switch(e){case Boolean:t=t?m:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!a(t,e),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:y};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...d(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,g?.({ReactiveElement:w}),(p.reactiveElementVersions??=[]).push("2.1.2");const k=globalThis,$=t=>t,x=k.trustedTypes,S=x?x.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,D="?"+C,T=`<${D}>`,P=document,R=()=>P.createComment(""),A=t=>null===t||"object"!=typeof t&&"function"!=typeof t,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,N=/>/g,O=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const Z=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"")),s]};class J{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=Z(t,e);if(this.el=J.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length${this._t(Eu[t.display])}
+${this._t(Nu[t.display])}
${"static_icon"===t.display&&this._bindingHasAlarm(t.binding)?j` - `}}op.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_continuityEpoch:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_hoverRoom:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_wallDialog:{state:!0},_drawWallField:{state:!0},_activeDraftId:{state:!0},_physicalSel:{state:!0},_physicalDialog:{state:!0},_physicalDrag:{state:!0},_physicalRotate:{state:!0},_duplicateColumnId:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_opMeasure:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingPreset:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_openWallAnchor:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorEraseConfirm:{state:!0},_decorTextDialog:{state:!0},_decorShapeDialog:{state:!0},_backdropDialog:{state:!0},_furnPalette:{state:!0},_bdDrag:{state:!0},_dtBox:{state:!0},_dtDrag:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_vacAllCamerasFor:{state:!0},_vacCalConfirm:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomCustomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_alignDialog:{state:!0},_backupExportDialog:{state:!0},_backupImportDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},op.ZOOM_MAX=8,op.ZOOM_MIN=1/3,op._touchSeen=!1,op._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,op.styles=[Ot,hd],customElements.get("houseplan-card")||customElements.define("houseplan-card",op),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info(`%c HOUSEPLAN-CARD %c v${Au} `,"background:#3ea6ff;color:#04121f;font-weight:700",""); + `}}lp.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_continuityEpoch:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_hoverRoom:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_wallDialog:{state:!0},_drawWallField:{state:!0},_activeDraftId:{state:!0},_physicalSel:{state:!0},_physicalDialog:{state:!0},_physicalDrag:{state:!0},_physicalRotate:{state:!0},_duplicateColumnId:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_opMeasure:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingPreset:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_openWallAnchor:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorEraseConfirm:{state:!0},_decorTextDialog:{state:!0},_decorShapeDialog:{state:!0},_backdropDialog:{state:!0},_furnPalette:{state:!0},_bdDrag:{state:!0},_dtBox:{state:!0},_dtDrag:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_vacAllCamerasFor:{state:!0},_vacCalConfirm:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomCustomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_alignDialog:{state:!0},_backupExportDialog:{state:!0},_backupImportDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},lp.ZOOM_MAX=8,lp.ZOOM_MIN=1/3,lp._touchSeen=!1,lp._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,lp.styles=[Ot,pd],customElements.get("houseplan-card")||customElements.define("houseplan-card",lp),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info(`%c HOUSEPLAN-CARD %c v${zu} `,"background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9aa7a72b..1d6f51ca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -414,6 +414,16 @@ field. Double click edits physical-object properties, pointer drag moves the whole object on the grid, and Delete removes only the selected object. The legacy root `space.segments` array is still stripped on every save. +Adjacent-room auto-close reuses the immutable architectural snap snapshot and +requires the first and prospective terminal points to belong to one completed +room's same solid segment after opening/open-span cuts. It runs only after the +draft has two edges and after explicit first-point/Ctrl closure handling. The +prospective polygon is checked for range, self-intersection and room overlap +before `_path` or `room_drafts` changes; success persists the terminal segment +and then opens the ordinary room dialog. Cancel therefore returns to the open +draft at that terminal point, while Save lets the normal room/wall +normalization preserve an existing shared wall's thickness. + While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/ `formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is per-space `cell_cm` — cm represented by one grid cell (default 5, so 240 cells ≈ 12 m). diff --git a/docs/CANVAS.md b/docs/CANVAS.md index 71aaee7a..c03bcf03 100644 --- a/docs/CANVAS.md +++ b/docs/CANVAS.md @@ -569,3 +569,13 @@ current anchor is excluded to prevent zero-length segments, while the first point of a valid room outline remains an explicit closure target. The static geometry is cached by structural editor state; pointer movement changes at most the single active candidate and never writes config, layout or storage. + +For **Room outline**, the same completed-room provenance also enables adjacent +auto-close. Once the draft already has two edges, a click that places its next +point on the same uninterrupted solid room-wall segment as the first point is +validated as the prospective polygon before any mutation. A valid click +persists that terminal draft segment and opens the standard room dialog with +the existing wall as the closing edge. A cut, a different edge, a saved draft +or an independent partition is ineligible; an eligible but invalid polygon is +reported without appending a point or segment. Explicit first-point and +Ctrl/Cmd closure retain priority. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d3e8e4aa..c9d9d208 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- A new room outline that starts and ends on one uninterrupted solid interval + of an existing room wall now closes automatically and opens the normal room + dialog. Endpoints and wall-bound T-connection points work alike; openings, + intentional gaps, different edges and a second point without enough sides do + not trigger the shortcut ([#138](https://github.com/Matysh/houseplan-card/issues/138)). - Connected Room outline and Partition segments now form one seamless thick wall immediately, including the live rubber-band. Right and oblique corners use bounded mitre/bevel joins, T-connections have no visible butt face, each diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 904a8e62..fe6f9b05 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,12 @@ ## Unreleased +- Новый контур комнаты, начатый и законченный на одном непрерывном сплошном + участке стены существующей комнаты, теперь замыкается автоматически и + открывает обычный диалог комнаты. Одинаково работают конечные узлы и точки + T-соединения на линии; проёмы, намеренные разрывы, разные грани и второй клик + без достаточного числа сторон быстрым замыканием не считаются + ([#138](https://github.com/Matysh/houseplan-card/issues/138)). - Соединённые отрезки «Контура комнаты» и «Перегородки» теперь сразу, включая активный rubber-band, образуют одну бесшовную толстую стену. Прямые и косые углы получают ограниченный mitre/bevel, T-соединение не показывает торец, diff --git a/docs/TESTING.md b/docs/TESTING.md index b478245f..6d770c6d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -362,6 +362,18 @@ separately promised workflows: - [ ] Ctrl/Cmd+click closes the current endpoint back to the first point without adding another vertex. It requires at least two existing edges and refuses degenerate or self-intersecting closure [auto: smoke_editor_tabs] +- [ ] Adjacent-room auto-close: after two drawn edges, ending on the same + uninterrupted solid existing-room wall interval as the first point adds + the terminal segment and opens the normal room dialog. Endpoints and + wall-interior snap points both work; Save creates one room without an + independent partition and preserves the shared wall thickness. Cancel + restores the open draft ending at that point [auto: smoke_room_autoclose; + unit: plan-snap-overlay.test.mjs] +- [ ] Adjacent-room auto-close safety: a second point alone stays open; different + room edges, draft/partition-only axes and points separated by an opening + or open-span cut do not trigger it. An eligible self-intersecting contour + shows the existing error and writes neither the terminal point nor segment + [auto: smoke_room_autoclose; unit: plan-snap-overlay.test.mjs] - [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name - [ ] Room dialog uses the medium width and its body has no horizontal overflow; long options stay inside it at desktop and narrow widths [auto: diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 5567a7b3..7c91b66b 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -263,6 +263,12 @@ desktop: для точного рисования, Resize, модификато 5. Замкните контур кликом по первой точке или `Ctrl` + кликом в любом месте. Быстрое замыкание доступно после двух нарисованных граней и не сработает, если замыкающая стена создаёт самопересечение. + Если первая и текущая точки лежат на одном непрерывном сплошном участке + стены существующей комнаты, текущий клик сам добавит последнюю грань, + замкнёт контур общей стеной и откроет тот же диалог. Можно попадать как в + конечный узел, так и в точку на линии. Проём или намеренный разрыв разделяет + участок, разные грани не объединяются, а второй клик после единственной + первой точки остаётся обычным незамкнутым отрезком. 6. В диалоге задайте название, выберите свободную HA-зону либо оставьте в списке «— без зоны —», затем нажмите «Сохранить». Во время рисования показываются длина сегмента и угол. Углы, кратные 45°, выделяются. Удерживайте `Shift`, чтобы зафиксировать текущий отрезок по ближайшему такому направлению; предпросмотр и поставленная точка останутся на сетке. Новая комната не может частично перекрывать другую, но полностью вложенная «островная» комната поддерживается. diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 0a71d805..11147117 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -132,7 +132,7 @@ import { physicalBodies, physicalBodySet, } from './physical-geometry'; import { - buildPlanSnapGeometry, resolvePlanSnap, + buildPlanSnapGeometry, findSharedRoomSnapSegment, resolvePlanSnap, type PlanSnapCandidate, type PlanSnapGeometry, type PlanSnapSegment, } from './plan-snap-overlay'; import { @@ -6424,27 +6424,33 @@ class HouseplanCard extends LitElement { return false; } - /** Validate and close the draft without using the closing click as a vertex. */ - private _closeRoomContour(showMinimumError = false): void { + /** Validate one prospective room ring without mutating the live draft. */ + private _validateRoomContour( + path: number[][], showMinimumError = false, + ): { closingCm: number } | null { // Three placed vertices mean two existing edges; the closing edge becomes // the third one. Anything shorter cannot enclose a room. - if (this._path.length < 3) { + if (path.length < 3) { if (showMinimumError) this._showToast(this._t('toast.contour_min_edges')); - return; + return null; } const closingCm = this._drawWallCm; - if (closingCm == null) { this._showPhysicalRange(100); return; } - if (this._contourSelfIntersects(this._path) || polygonArea(this._path) <= 1e-6) { + if (closingCm == null) { this._showPhysicalRange(100); return null; } + if (this._contourSelfIntersects(path) || polygonArea(path) <= 1e-6) { this._showToast(this._t('toast.contour_cannot_close')); - return; // keep the draft editable + return null; // keep the draft editable } // A contour can enclose an existing room without any vertex inside it. - const clash = this._overlapRoom(this._path); + const clash = this._overlapRoom(path); if (clash) { this._showToast(this._t('toast.room_overlap', { name: clash.name || '' })); - return; + return null; } - this._path = [...this._path, [...this._path[0]]]; + return { closingCm }; + } + + private _openRoomContourDialog(path: number[][], closingCm: number): void { + this._path = [...path, [...path[0]]]; this._closingWallCm = closingCm; this._clearPlanSnapHover(); this._nameSel = ''; @@ -6453,6 +6459,52 @@ class HouseplanCard extends LitElement { this._roomDialog = true; } + /** Validate and close the draft without using the closing click as a vertex. */ + private _closeRoomContour(showMinimumError = false): void { + const valid = this._validateRoomContour(this._path, showMinimumError); + if (!valid) return; + this._openRoomContourDialog(this._path, valid.closingCm); + } + + /** The same append limits guard both an ordinary point and an auto-close terminal point. */ + private _canAppendRoomDraftPoint(): boolean { + if (this._drawWallCm == null) { this._showPhysicalRange(100); return false; } + if (this._path.length >= MAX_DRAFT_POINTS) { + this._showToast(this._t('toast.physical_limit')); + return false; + } + const spCfg = this._curSpaceCfg as any; + const newDraft = !this._activeDraftId; + if ((newDraft && (spCfg?.room_drafts || []).length >= MAX_ROOM_DRAFTS) + || this._draftSegmentCount(spCfg) >= MAX_DRAFT_SEGMENTS) { + this._showToast(this._t('toast.physical_limit')); + return false; + } + return true; + } + + /** Consume a qualifying click whether it closes successfully or reports a validation error. */ + private _tryAutoCloseRoomContour(pt: number[]): boolean { + // One existing point plus B is only a line. Preserve the ordinary second + // click, symmetrically with the existing first-point closure gate. + if (this._path.length < 2) return false; + const shared = findSharedRoomSnapSegment( + this._planSnapGeometrySnapshot().value, + this._path[0], + pt, + this._gridPitch * 0.0002, + ); + if (!shared) return false; + if (!this._canAppendRoomDraftPoint()) return true; + const prospective = [...this._path, pt]; + const valid = this._validateRoomContour(prospective); + if (!valid) return true; + this._path = prospective; + this._persistActiveDraftSegment(); + this._openRoomContourDialog(this._path, valid.closingCm); + return true; + } + private _markupClick(ev: MouseEvent): void { if (this._vacFit) return; // the fit overlay owns all pointer input if (!this._markup) return; @@ -6555,23 +6607,13 @@ class HouseplanCard extends LitElement { this._closeRoomContour(); return; } + if (this._tryAutoCloseRoomContour(pt)) return; const join = this._draftEndAt(pt, this._activeDraftId || undefined); if (join) { this._mergeDraftEndpoint(join); return; } - if (this._drawWallCm == null) { this._showPhysicalRange(100); return; } - if (this._path.length >= MAX_DRAFT_POINTS) { - this._showToast(this._t('toast.physical_limit')); - return; - } - const spCfg = this._curSpaceCfg as any; - const newDraft = !this._activeDraftId; - if ((newDraft && (spCfg?.room_drafts || []).length >= MAX_ROOM_DRAFTS) - || this._draftSegmentCount(spCfg) >= MAX_DRAFT_SEGMENTS) { - this._showToast(this._t('toast.physical_limit')); - return; - } + if (!this._canAppendRoomDraftPoint()) return; this._path = [...this._path, pt]; this._persistActiveDraftSegment(); } diff --git a/src/plan-snap-overlay.ts b/src/plan-snap-overlay.ts index 46127235..7bcd2f5f 100644 --- a/src/plan-snap-overlay.ts +++ b/src/plan-snap-overlay.ts @@ -101,6 +101,10 @@ function segmentKey(source: SourceSegment, a: readonly number[], b: readonly num return `${sourceKey(source)}|${pointKey(ca)}|${pointKey(cb)}`; } +function sourceRank(kind: PlanSnapSourceKind): number { + return kind === 'room' ? 0 : kind === 'draft' ? 1 : 2; +} + function touches(point: readonly number[], segment: readonly number[], epsilon: number): boolean { return pointsEqual(point, [segment[0], segment[1]], epsilon) || pointsEqual(point, [segment[2], segment[3]], epsilon); @@ -171,7 +175,10 @@ export function buildPlanSnapGeometry(options: BuildPlanSnapGeometryOptions): Pl sourceId: source.id, }; const existing = segmentsByAxis.get(axisKey); - if (!existing || candidate.key.localeCompare(existing.key) < 0) { + if (!existing + || sourceRank(candidate.sourceKind) < sourceRank(existing.sourceKind) + || (sourceRank(candidate.sourceKind) === sourceRank(existing.sourceKind) + && candidate.key.localeCompare(existing.key) < 0)) { segmentsByAxis.set(axisKey, candidate); } } @@ -189,6 +196,39 @@ export function buildPlanSnapGeometry(options: BuildPlanSnapGeometryOptions): Pl }; } +function pointOnSnapSegment( + point: readonly number[], segment: PlanSnapSegment, epsilon: number, +): boolean { + const dx = segment.b[0] - segment.a[0]; + const dy = segment.b[1] - segment.a[1]; + const length = Math.hypot(dx, dy); + if (!(length > epsilon)) return false; + const ux = dx / length; + const uy = dy / length; + const px = point[0] - segment.a[0]; + const py = point[1] - segment.a[1]; + const along = px * ux + py * uy; + const perpendicular = Math.abs(px * uy - py * ux); + return perpendicular <= epsilon && along >= -epsilon && along <= length + epsilon; +} + +/** + * Return the stable completed-room solid interval that contains both points. + * Because room openings and open spans are cut while the snapshot is built, + * points on opposite sides of a gap can never share a returned segment. + */ +export function findSharedRoomSnapSegment( + geometry: PlanSnapGeometry, + a: readonly number[], + b: readonly number[], + epsilon = DEFAULT_EPSILON, +): PlanSnapSegment | null { + if (!finitePoint(a) || !finitePoint(b) || pointsEqual(a, b, epsilon)) return null; + return geometry.segments.find((segment) => segment.sourceKind === 'room' + && pointOnSnapSegment(a, segment, epsilon) + && pointOnSnapSegment(b, segment, epsilon)) || null; +} + function isExcluded( point: readonly number[], excluded: readonly (readonly number[])[], diff --git a/test/plan-snap-overlay.test.mjs b/test/plan-snap-overlay.test.mjs index e6200774..cf839443 100644 --- a/test/plan-snap-overlay.test.mjs +++ b/test/plan-snap-overlay.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { buildPlanSnapGeometry, + findSharedRoomSnapSegment, resolvePlanSnap, } from '../test-build/plan-snap-overlay.js'; @@ -71,6 +72,51 @@ test('room cuts leave solid intervals but do not create cut-boundary endpoints', 'an original endpoint remains when another solid wall still meets it'); }); +test('shared-room interval contains endpoints and interior wall-bound points only on one edge', () => { + const geometry = buildPlanSnapGeometry({ + space: space({ rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }] }), + }); + assert.ok(findSharedRoomSnapSegment(geometry, [0, 0], [100, 0])); + assert.ok(findSharedRoomSnapSegment(geometry, [0, 0], [40, 0])); + assert.ok(findSharedRoomSnapSegment(geometry, [20, 0], [80, 0])); + assert.equal(findSharedRoomSnapSegment(geometry, [0, 0], [100, 100]), null, + 'different room edges never imply an auto-closing wall'); + assert.equal(findSharedRoomSnapSegment(geometry, [40, 0], [40, 0]), null, + 'one point cannot define a closing interval'); + assert.equal(findSharedRoomSnapSegment(geometry, [-1, 0], [40, 0], 0.001), null, + 'collinearity outside the closed segment is insufficient'); +}); + +test('shared-room interval respects cuts and rejects draft or partition-only axes', () => { + const cut = buildPlanSnapGeometry({ + space: space({ rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }] }), + roomCuts: [[40, 0, 60, 0]], + }); + assert.ok(findSharedRoomSnapSegment(cut, [0, 0], [30, 0])); + assert.equal(findSharedRoomSnapSegment(cut, [0, 0], [100, 0]), null, + 'opening or open-span cuts split eligibility'); + + const independent = buildPlanSnapGeometry({ + space: space({ + room_drafts: [{ id: 'draft', points: [[0, 10], [100, 10]], segments: [{ cm: 15 }] }], + partitions: [{ id: 'partition', a: [0, 20], b: [100, 20], cm: 15 }], + }), + }); + assert.equal(findSharedRoomSnapSegment(independent, [0, 10], [100, 10]), null); + assert.equal(findSharedRoomSnapSegment(independent, [0, 20], [100, 20]), null); +}); + +test('a completed room remains the authority for a coincident deduplicated axis', () => { + const geometry = buildPlanSnapGeometry({ + space: space({ + rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }], + partitions: [{ id: 'partition', a: [100, 0], b: [0, 0], cm: 15 }], + }), + }); + const shared = findSharedRoomSnapSegment(geometry, [0, 0], [100, 0]); + assert.equal(shared?.sourceKind, 'room'); +}); + test('endpoint wins over a closer line and tie resolution is stable', () => { const geometry = buildPlanSnapGeometry({ space: space({