feat v1.37.0: open boundaries (virtual walls)

- room.open_to symmetric links; 'Open boundary' plan tool toggles the
  shared wall pair under the click (sharedBoundary collinear-overlap
  math + distToSegment pull, same as Split); dashed rendering with an
  amber hot state while the tool is active; Esc -> Draw
- glow: the clip becomes the transitive open zone (openZoneOf BFS,
  either-direction links) + door sectors from the zone's outer walls
- model rooms carry open_to; backend ROOM_SCHEMA open_to: [str]
- +3 unit tests (104), smoke_openwall.mjs (8 checks); docs same-commit
This commit is contained in:
Matysh
2026-07-23 14:18:50 +03:00
parent 3a89966e4b
commit 20c7b55a87
17 changed files with 420 additions and 64 deletions
+101 -8
View File
@@ -14,7 +14,7 @@ import {
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment,
snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
@@ -32,14 +32,14 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.36.4';
const CARD_VERSION = '1.37.0';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
const NORM_W = 1000; // width of the render space for normalized configs
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'delroom';
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'openwall' | 'delroom';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any;
@@ -343,6 +343,11 @@ class HouseplanCard extends LitElement {
e.preventDefault();
if (this._mergeSel) this._mergeSel = null;
else this._tool = 'draw';
return;
}
if (this._tool === 'openwall' || this._tool === 'opening' || this._tool === 'delroom') {
e.preventDefault();
this._tool = 'draw';
}
}
@@ -417,6 +422,7 @@ class HouseplanCard extends LitElement {
id: r.id,
name: r.name,
area: r.area ?? null,
open_to: r.open_to || undefined,
x: r.x != null ? r.x * NORM_W : undefined,
y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined,
@@ -1290,6 +1296,10 @@ class HouseplanCard extends LitElement {
this._mergeClick(raw);
return;
}
if (this._tool === 'openwall') {
this._openWallClick(raw);
return;
}
if (this._tool === 'split') {
this._splitClick(raw);
return;
@@ -1588,6 +1598,73 @@ class HouseplanCard extends LitElement {
</div>`;
}
/** Dashed strokes over open (virtual) boundaries; highlighted in the tool. */
private _renderOpenWalls(): TemplateResult {
const pairs = this._openPairs();
if (!pairs.length) return svg`` as unknown as TemplateResult;
const hot = this._markup && this._tool === 'openwall';
return svg`<g class="openwalls ${hot ? 'hot' : ''}">
${pairs.flatMap((p) => p.segs.map((sg) => svg`<line class="openwall"
x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`))}
</g>` as unknown as TemplateResult;
}
/** All open-boundary pairs of the current space with their shared segments. */
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
const rooms = this._spaceModel().rooms.filter((r) => r.id);
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
for (let i = 0; i < rooms.length; i++)
for (let j = i + 1; j < rooms.length; j++) {
const a = rooms[i], b = rooms[j];
const linked = ((a as any).open_to || []).includes(b.id) || ((b as any).open_to || []).includes(a.id);
if (!linked) continue;
const pa = roomPoly(a), pb = roomPoly(b);
if (!pa || !pb) continue;
const segs = sharedBoundary(pa, pb, this._gridPitch * 0.02);
if (segs.length) res.push({ a, b, segs });
}
return res;
}
/** Open-boundary tool: a click on a shared wall toggles its "virtual" state. */
private _openWallClick(raw: number[]): void {
const rooms = this._spaceModel().rooms.filter((r) => r.id);
const pull = this._gridPitch * 6;
let best: { a: RoomCfg; b: RoomCfg; d: number } | null = null;
for (let i = 0; i < rooms.length; i++)
for (let j = i + 1; j < rooms.length; j++) {
const pa = roomPoly(rooms[i]), pb = roomPoly(rooms[j]);
if (!pa || !pb) continue;
const segs = sharedBoundary(pa, pb, this._gridPitch * 0.02);
for (const seg of segs) {
const d = distToSegment(raw, seg);
if (d <= pull && (!best || d < best.d)) best = { a: rooms[i], b: rooms[j], d };
}
}
if (!best) {
this._showToast(this._t('toast.openwall_pick'));
return;
}
const sp = this._curSpaceCfg;
const ra = sp.rooms.find((r: any) => r.id === best!.a.id);
const rb = sp.rooms.find((r: any) => r.id === best!.b.id);
if (!ra || !rb) return;
const linked = (ra.open_to || []).includes(rb.id) || (rb.open_to || []).includes(ra.id);
if (linked) {
ra.open_to = (ra.open_to || []).filter((x: string) => x !== rb.id);
rb.open_to = (rb.open_to || []).filter((x: string) => x !== ra.id);
if (!ra.open_to.length) delete ra.open_to;
if (!rb.open_to.length) delete rb.open_to;
this._showToast(this._t('toast.openwall_closed', { a: ra.name || '', b: rb.name || '' }));
} else {
ra.open_to = [...(ra.open_to || []), rb.id];
rb.open_to = [...(rb.open_to || []), ra.id];
this._showToast(this._t('toast.openwall_opened', { a: ra.name || '', b: rb.name || '' }));
}
this._saveConfig();
this.requestUpdate();
}
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
private _openingClick(raw: number[]): void {
const eps = this._gridPitch * 1.5;
@@ -2700,15 +2777,25 @@ class HouseplanCard extends LitElement {
const home = [...polys].reverse().find((x) => this._pointInRoom([pos.x, pos.y], x.r));
let clip: string[] | null = null;
if (home) {
const shapes: string[] = ['M ' + home.poly.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z'];
// doorways of this room spill light into neighbouring rooms only
// open (virtual) boundaries: light flows through the whole connected
// zone of rooms, not just the source's own room (owner's spec)
const zoneIds = home.r.id ? openZoneOf(home.r.id, space.rooms) : new Set([home.r.id]);
const zone = polys.filter((x) => x.r.id && zoneIds.has(x.r.id));
const zoneList = zone.length ? zone : [home];
const shapes: string[] = zoneList.map(
(z) => 'M ' + z.poly.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z',
);
// doorways on the ZONE's walls spill light into rooms outside the zone
const others = polys.filter((x) => !zoneList.includes(x)).map((x) => x.poly);
for (const o of doors) {
const near = closestPointOnBoundary([o.rx, o.ry], home.poly);
if (!near || Math.hypot(near[0] - o.rx, near[1] - o.ry) > g * 0.75) continue;
const onZoneWall = zoneList.some((z) => {
const near = closestPointOnBoundary([o.rx, o.ry], z.poly);
return near && Math.hypot(near[0] - o.rx, near[1] - o.ry) <= g * 0.75;
});
if (!onZoneWall) continue;
const rad = (o.angle * Math.PI) / 180;
const dx = (Math.cos(rad) * o.rlen) / 2;
const dy = (Math.sin(rad) * o.rlen) / 2;
const others = polys.filter((x) => x !== home).map((x) => x.poly);
if (!hasRoomBehind([o.rx, o.ry], o.angle, [pos.x, pos.y], others, g * 0.6)) continue;
const sector = doorSector([pos.x, pos.y], [o.rx - dx, o.ry - dy], [o.rx + dx, o.ry + dy], R);
if (sector) shapes.push('M ' + sector.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z');
@@ -3066,6 +3153,7 @@ class HouseplanCard extends LitElement {
@mouseleave=${() => (this._tip = null)}></rect>`;
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
})}
${this._renderOpenWalls()}
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)}
@@ -3662,6 +3750,11 @@ class HouseplanCard extends LitElement {
title=${this._t('title.markup_opening')}>
<ha-icon icon="mdi:door"></ha-icon>${this._t('markup.opening')}
</button>
<button class="btn ${this._tool === 'openwall' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'openwall'; }}
title=${this._t('title.markup_openwall')}>
<ha-icon icon="mdi:border-none-variant"></ha-icon>${this._t('markup.openwall')}
</button>
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
title=${this._t('title.markup_delroom')}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}
+6 -1
View File
@@ -293,5 +293,10 @@
"marker.controls_filter": "Search lights and switches…",
"info.controls": "Controls",
"marker.glow_radius_label": "Glow radius (light-sources fill)",
"marker.glow_radius_hint": "empty = default from general settings"
"marker.glow_radius_hint": "empty = default from general settings",
"markup.openwall": "Open boundary",
"title.markup_openwall": "Open boundary — click a wall shared by two rooms to make it virtual: light flows through, the line turns dashed. Click again to close it.",
"toast.openwall_pick": "Click a wall shared by two rooms",
"toast.openwall_opened": "Boundary “{a}” ↔ “{b}” is now open",
"toast.openwall_closed": "Boundary “{a}” ↔ “{b}” is closed again"
}
+6 -1
View File
@@ -293,5 +293,10 @@
"marker.controls_filter": "Поиск ламп и выключателей…",
"info.controls": "Управляет",
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек"
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек",
"markup.openwall": "Открытая граница",
"title.markup_openwall": "Открытая граница — клик по общей стене двух комнат делает её условной: свет проходит насквозь, линия становится пунктирной. Повторный клик закрывает.",
"toast.openwall_pick": "Кликните по стене, разделяющей две комнаты",
"toast.openwall_opened": "Граница «{a}» ↔ «{b}» теперь открыта",
"toast.openwall_closed": "Граница «{a}» ↔ «{b}» снова закрыта"
}
+70
View File
@@ -851,6 +851,76 @@ export function isControllable(entityId: string): boolean {
return entityId.startsWith('light.') || entityId.startsWith('switch.');
}
// ---------------- open (virtual) boundaries ----------------
/**
* Collinear overlapping stretches of two room outlines their shared
* boundary. Handles the real-house case where neighbouring walls only
* PARTIALLY overlap (collinear, different lengths). Returns segments
* [x1,y1,x2,y2] with length > eps.
*/
export function sharedBoundary(a: number[][], b: number[][], eps = 1e-6): number[][] {
const res: number[][] = [];
if (!a || !b || a.length < 3 || b.length < 3) return res;
for (let i = 0; i < a.length; i++) {
const p1 = a[i], p2 = a[(i + 1) % a.length];
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy);
if (len < eps) continue;
const ux = dx / len, uy = dy / len;
for (let j = 0; j < b.length; j++) {
const q1 = b[j], q2 = b[(j + 1) % b.length];
// both q endpoints must lie on the line of p1-p2
const d1 = Math.abs((q1[0] - p1[0]) * uy - (q1[1] - p1[1]) * ux);
const d2 = Math.abs((q2[0] - p1[0]) * uy - (q2[1] - p1[1]) * ux);
const tol = Math.max(eps, len * 1e-6);
if (d1 > tol || d2 > tol) continue;
// overlap of parameter intervals along the line
const t1 = (q1[0] - p1[0]) * ux + (q1[1] - p1[1]) * uy;
const t2 = (q2[0] - p1[0]) * ux + (q2[1] - p1[1]) * uy;
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(len, Math.max(t1, t2));
if (hi - lo > eps) {
res.push([p1[0] + ux * lo, p1[1] + uy * lo, p1[0] + ux * hi, p1[1] + uy * hi]);
}
}
}
return res;
}
/**
* Connected component of rooms joined by open (virtual) boundaries the
* "open zone" light flows through. The open_to link counts in either
* direction. Returns a set of room ids including the start.
*/
export function openZoneOf(roomId: string, rooms: { id?: string; open_to?: string[] | null }[]): Set<string> {
const zone = new Set<string>([roomId]);
const linked = (x: any, y: any) =>
(x.open_to || []).includes(y.id) || (y.open_to || []).includes(x.id);
let grew = true;
while (grew) {
grew = false;
for (const r of rooms) {
if (!r.id || zone.has(r.id)) continue;
for (const z of rooms) {
if (!z.id || !zone.has(z.id)) continue;
if (linked(r, z)) { zone.add(r.id); grew = true; break; }
}
}
}
return zone;
}
/** Distance from a point to a segment [x1,y1,x2,y2]. */
export function distToSegment(p: number[], s: number[]): number {
const dx = s[2] - s[0], dy = s[3] - s[1];
const len2 = dx * dx + dy * dy;
if (!len2) return Math.hypot(p[0] - s[0], p[1] - s[1]);
let t = ((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (s[0] + t * dx), p[1] - (s[1] + t * dy));
}
/** Device classes whose active state is an emergency, not a status. */
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
+12
View File
@@ -538,9 +538,21 @@ export const cardStyles = css`
/* room-picking stages: merge (both clicks) and split before a room is chosen */
.stage.markup.tool-merge,
.stage.markup.tool-split.pickstage,
.stage.markup.tool-openwall,
.stage.markup.tool-delroom {
cursor: pointer;
}
.openwall {
stroke: var(--card-background-color, var(--hp-bg, #fff));
stroke-width: 3.2;
stroke-dasharray: 7 7;
pointer-events: none;
opacity: 0.9;
}
.openwalls.hot .openwall {
stroke: #ffc14d;
opacity: 1;
}
.stage.markup .room {
pointer-events: none;
}
+2
View File
@@ -1,6 +1,8 @@
/** Shared types of the House Plan card. */
export interface RoomCfg {
/** Rooms this one has an OPEN (virtual) boundary with — light flows through. */
open_to?: string[] | null;
id?: string;
name: string;
area: string | null;