mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
|||||||
FILES_URL = "/houseplan_files/files"
|
FILES_URL = "/houseplan_files/files"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.36.4"
|
VERSION = "1.37.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.36.4"
|
"version": "1.37.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ ROOM_SCHEMA = vol.All(
|
|||||||
vol.Required("id"): str,
|
vol.Required("id"): str,
|
||||||
vol.Required("name"): str,
|
vol.Required("name"): str,
|
||||||
vol.Optional("area"): vol.Any(str, None),
|
vol.Optional("area"): vol.Any(str, None),
|
||||||
|
vol.Optional("open_to"): [str],
|
||||||
vol.Optional("x"): vol.Coerce(float),
|
vol.Optional("x"): vol.Coerce(float),
|
||||||
vol.Optional("y"): vol.Coerce(float),
|
vol.Optional("y"): vol.Coerce(float),
|
||||||
vol.Optional("w"): vol.Coerce(float),
|
vol.Optional("w"): vol.Coerce(float),
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { launch } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const out = {};
|
||||||
|
const c = window.__card;
|
||||||
|
const sr = () => c.shadowRoot || c.renderRoot;
|
||||||
|
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||||
|
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
|
||||||
|
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||||
|
c._openWallClick([550, 0.25 * H]);
|
||||||
|
await c.updateComplete;
|
||||||
|
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
|
||||||
|
const r2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
|
||||||
|
out.linked = (r1.open_to || []).includes('r2') && (r2.open_to || []).includes('r1');
|
||||||
|
// пунктир отрисован
|
||||||
|
out.dashes = sr().querySelectorAll('.openwall').length > 0;
|
||||||
|
out.hotClass = !!sr().querySelector('.openwalls.hot');
|
||||||
|
// повторный клик закрывает
|
||||||
|
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||||
|
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
|
||||||
|
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
|
||||||
|
// клик мимо стен — тост, без изменений
|
||||||
|
c._openWallClick([100, 100]);
|
||||||
|
out.missToast = !!c._toast;
|
||||||
|
// снова открыть для glow-теста
|
||||||
|
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||||
|
// glow: свет из r1 заливает и r2 (clip содержит оба полигона)
|
||||||
|
c._setMode('view'); await c.updateComplete;
|
||||||
|
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||||
|
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||||
|
const litLight = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
|
||||||
|
const c1 = c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r1'));
|
||||||
|
const aspect = c._curSpaceCfg.aspect || 1;
|
||||||
|
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
|
||||||
|
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
|
||||||
|
// транзитивность: r2↔r3 тоже открыть → clip получит 3 полигона
|
||||||
|
const rr2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
|
||||||
|
const rr3 = c._curSpaceCfg.rooms.find((r) => r.id === 'r3');
|
||||||
|
if (rr3) {
|
||||||
|
rr2.open_to = [...(rr2.open_to || []), 'r3'];
|
||||||
|
rr3.open_to = [...(rr3.open_to || []), 'r2'];
|
||||||
|
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||||
|
const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
|
||||||
|
out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
|
||||||
|
} else out.transitive = 'no r3';
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res, null, 1));
|
||||||
|
await browser.close();
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+38
-17
File diff suppressed because one or more lines are too long
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.37.0 — 2026-07-23 (open boundaries — virtual walls)
|
||||||
|
- Rooms divided only by zoning can now share an **open boundary**: the new
|
||||||
|
"Open boundary" tool in the Plan editor toggles it with a click on the wall
|
||||||
|
two rooms share. The stretch renders dashed; while the tool is active open
|
||||||
|
boundaries highlight amber. Stored as `room.open_to` links by room id, so
|
||||||
|
redrawing/merging neighbours doesn't break them.
|
||||||
|
- In the light-sources fill, light flows through open boundaries freely —
|
||||||
|
transitively across the whole connected zone (kitchen ↔ living ↔ hall as
|
||||||
|
one open space), still limited by the glow radius. Door sectors now work
|
||||||
|
from any outer wall of the zone.
|
||||||
|
|
||||||
## v1.36.4 — 2026-07-23
|
## v1.36.4 — 2026-07-23
|
||||||
- Glow: sharper light edge — the pool is fully lit for the inner 80% of the
|
- Glow: sharper light edge — the pool is fully lit for the inner 80% of the
|
||||||
radius, the gradient falloff lives only in the outer 20%.
|
radius, the gradient falloff lives only in the outer 20%.
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
(explicit ripple color still wins); off/white lights unchanged [auto]
|
(explicit ripple color still wins); off/white lights unchanged [auto]
|
||||||
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
||||||
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
||||||
|
- [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool
|
||||||
|
toggles a virtual wall between two rooms by clicking their shared wall
|
||||||
|
(pull like Split; miss → toast); open stretches render dashed (amber
|
||||||
|
highlight while the tool is active); glow light floods the whole
|
||||||
|
connected open zone transitively, door sectors work from the zone's
|
||||||
|
outer walls; merge/split keep links by room id [auto]
|
||||||
- [ ] Sector wedge fix (v1.36.3): door sectors never darken the light INSIDE
|
- [ ] Sector wedge fix (v1.36.3): door sectors never darken the light INSIDE
|
||||||
the room — room outline and sectors are separate clipPath children
|
the room — room outline and sectors are separate clipPath children
|
||||||
(union), not subpaths of one nonzero path [auto]
|
(union), not subpaths of one nonzero path [auto]
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.36.4",
|
"version": "1.37.0",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+101
-8
@@ -14,7 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
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,
|
snapToWall, openingAmount,
|
||||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||||
@@ -32,14 +32,14 @@ import './space-card';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
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_KEY = 'houseplan_card_layout_v1';
|
||||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
const NORM_W = 1000; // width of the render space for normalized configs
|
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)
|
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 fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||||
const ev = new Event(type, { bubbles: true, composed: true }) as any;
|
const ev = new Event(type, { bubbles: true, composed: true }) as any;
|
||||||
@@ -343,6 +343,11 @@ class HouseplanCard extends LitElement {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (this._mergeSel) this._mergeSel = null;
|
if (this._mergeSel) this._mergeSel = null;
|
||||||
else this._tool = 'draw';
|
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,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
area: r.area ?? null,
|
area: r.area ?? null,
|
||||||
|
open_to: r.open_to || undefined,
|
||||||
x: r.x != null ? r.x * NORM_W : undefined,
|
x: r.x != null ? r.x * NORM_W : undefined,
|
||||||
y: r.y != null ? r.y * H : undefined,
|
y: r.y != null ? r.y * H : undefined,
|
||||||
w: r.w != null ? r.w * NORM_W : undefined,
|
w: r.w != null ? r.w * NORM_W : undefined,
|
||||||
@@ -1290,6 +1296,10 @@ class HouseplanCard extends LitElement {
|
|||||||
this._mergeClick(raw);
|
this._mergeClick(raw);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this._tool === 'openwall') {
|
||||||
|
this._openWallClick(raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this._tool === 'split') {
|
if (this._tool === 'split') {
|
||||||
this._splitClick(raw);
|
this._splitClick(raw);
|
||||||
return;
|
return;
|
||||||
@@ -1588,6 +1598,73 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>`;
|
</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. */
|
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
|
||||||
private _openingClick(raw: number[]): void {
|
private _openingClick(raw: number[]): void {
|
||||||
const eps = this._gridPitch * 1.5;
|
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));
|
const home = [...polys].reverse().find((x) => this._pointInRoom([pos.x, pos.y], x.r));
|
||||||
let clip: string[] | null = null;
|
let clip: string[] | null = null;
|
||||||
if (home) {
|
if (home) {
|
||||||
const shapes: string[] = ['M ' + home.poly.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z'];
|
// open (virtual) boundaries: light flows through the whole connected
|
||||||
// doorways of this room spill light into neighbouring rooms only
|
// 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) {
|
for (const o of doors) {
|
||||||
const near = closestPointOnBoundary([o.rx, o.ry], home.poly);
|
const onZoneWall = zoneList.some((z) => {
|
||||||
if (!near || Math.hypot(near[0] - o.rx, near[1] - o.ry) > g * 0.75) continue;
|
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 rad = (o.angle * Math.PI) / 180;
|
||||||
const dx = (Math.cos(rad) * o.rlen) / 2;
|
const dx = (Math.cos(rad) * o.rlen) / 2;
|
||||||
const dy = (Math.sin(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;
|
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);
|
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');
|
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>`;
|
@mouseleave=${() => (this._tip = null)}></rect>`;
|
||||||
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
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}
|
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
||||||
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
||||||
${this._renderOpenings(disp)}
|
${this._renderOpenings(disp)}
|
||||||
@@ -3662,6 +3750,11 @@ class HouseplanCard extends LitElement {
|
|||||||
title=${this._t('title.markup_opening')}>
|
title=${this._t('title.markup_opening')}>
|
||||||
<ha-icon icon="mdi:door"></ha-icon>${this._t('markup.opening')}
|
<ha-icon icon="mdi:door"></ha-icon>${this._t('markup.opening')}
|
||||||
</button>
|
</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')}
|
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
|
||||||
title=${this._t('title.markup_delroom')}>
|
title=${this._t('title.markup_delroom')}>
|
||||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}
|
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}
|
||||||
|
|||||||
+6
-1
@@ -293,5 +293,10 @@
|
|||||||
"marker.controls_filter": "Search lights and switches…",
|
"marker.controls_filter": "Search lights and switches…",
|
||||||
"info.controls": "Controls",
|
"info.controls": "Controls",
|
||||||
"marker.glow_radius_label": "Glow radius (light-sources fill)",
|
"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
@@ -293,5 +293,10 @@
|
|||||||
"marker.controls_filter": "Поиск ламп и выключателей…",
|
"marker.controls_filter": "Поиск ламп и выключателей…",
|
||||||
"info.controls": "Управляет",
|
"info.controls": "Управляет",
|
||||||
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
|
"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}» снова закрыта"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -851,6 +851,76 @@ export function isControllable(entityId: string): boolean {
|
|||||||
return entityId.startsWith('light.') || entityId.startsWith('switch.');
|
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. */
|
/** Device classes whose active state is an emergency, not a status. */
|
||||||
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
|
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
|
||||||
|
|
||||||
|
|||||||
@@ -538,9 +538,21 @@ export const cardStyles = css`
|
|||||||
/* room-picking stages: merge (both clicks) and split before a room is chosen */
|
/* room-picking stages: merge (both clicks) and split before a room is chosen */
|
||||||
.stage.markup.tool-merge,
|
.stage.markup.tool-merge,
|
||||||
.stage.markup.tool-split.pickstage,
|
.stage.markup.tool-split.pickstage,
|
||||||
|
.stage.markup.tool-openwall,
|
||||||
.stage.markup.tool-delroom {
|
.stage.markup.tool-delroom {
|
||||||
cursor: pointer;
|
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 {
|
.stage.markup .room {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/** Shared types of the House Plan card. */
|
/** Shared types of the House Plan card. */
|
||||||
|
|
||||||
export interface RoomCfg {
|
export interface RoomCfg {
|
||||||
|
/** Rooms this one has an OPEN (virtual) boundary with — light flows through. */
|
||||||
|
open_to?: string[] | null;
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
area: string | null;
|
area: string | null;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
splitRoomPath, polyContainsPoly, islandsOf,
|
splitRoomPath, polyContainsPoly, islandsOf,
|
||||||
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
|
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
|
||||||
controlsAction, isControllable,
|
controlsAction, isControllable,
|
||||||
|
sharedBoundary, openZoneOf, distToSegment,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
@@ -666,3 +667,39 @@ test('isControllable: lights and switches only', () => {
|
|||||||
assert.ok(!isControllable('cover.gate'));
|
assert.ok(!isControllable('cover.gate'));
|
||||||
assert.ok(!isControllable('alarm_control_panel.home'));
|
assert.ok(!isControllable('alarm_control_panel.home'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sharedBoundary: exact, partial-collinear and none', () => {
|
||||||
|
const A = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||||
|
// точное общее ребро x=2
|
||||||
|
const B = [[2, 0], [4, 0], [4, 2], [2, 2]];
|
||||||
|
const s1 = sharedBoundary(A, B);
|
||||||
|
assert.equal(s1.length >= 1, true);
|
||||||
|
const len = (s) => Math.hypot(s[2] - s[0], s[3] - s[1]);
|
||||||
|
assert.ok(Math.abs(Math.max(...s1.map(len)) - 2) < 1e-6);
|
||||||
|
// частичное коллинеарное наложение (дачный случай): стена соседа длиннее
|
||||||
|
const C = [[2, -1], [4, -1], [4, 3], [2, 3]];
|
||||||
|
const s2 = sharedBoundary(A, C);
|
||||||
|
assert.ok(Math.abs(Math.max(...s2.map(len)) - 2) < 1e-6); // перекрытие = наша стена
|
||||||
|
// нет наложения
|
||||||
|
assert.equal(sharedBoundary(A, [[5, 5], [6, 5], [6, 6], [5, 6]]).length, 0);
|
||||||
|
// перпендикулярное касание — не граница
|
||||||
|
assert.equal(sharedBoundary(A, [[2, 2], [3, 2], [3, 3], [2, 3]]).filter((s) => len(s) > 1e-6).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openZoneOf: transitive, either-direction links', () => {
|
||||||
|
const rooms = [
|
||||||
|
{ id: 'a', open_to: ['b'] },
|
||||||
|
{ id: 'b' }, // связь только со стороны a
|
||||||
|
{ id: 'c', open_to: ['b'] }, // b↔c со стороны c
|
||||||
|
{ id: 'd' }, // не связан
|
||||||
|
];
|
||||||
|
const z = openZoneOf('a', rooms);
|
||||||
|
assert.deepEqual([...z].sort(), ['a', 'b', 'c']);
|
||||||
|
assert.deepEqual([...openZoneOf('d', rooms)], ['d']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distToSegment', () => {
|
||||||
|
assert.equal(distToSegment([0, 5], [0, 0, 0, 10]), 0);
|
||||||
|
assert.equal(distToSegment([3, 5], [0, 0, 0, 10]), 3);
|
||||||
|
assert.ok(Math.abs(distToSegment([-3, -4], [0, 0, 0, 10]) - 5) < 1e-9);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user