feat v1.34.0: island rooms — nested contours with evenodd holes
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 47s
Validate / backend (push) Failing after 3m19s

- roomsOverlap: full nesting is legal (islands), edge crossings and
  partial overlaps still rejected; polyContainsPoly + islandsOf pure
  helpers (+2 unit tests, 94 total)
- draw tool: per-click point-in-room rejection removed, validation at
  contour closing; parent rooms with islands render as evenodd paths
  (ring fills correctly, island stays clickable through the hole)
- smoke_island_rooms.mjs; smoke_merge_split stale room-count asserts
  fixed (rg pushed a 5th room; cancelWhole silently false for ages)
- TESTING/CHANGELOG same-commit
This commit is contained in:
Matysh
2026-07-22 22:59:43 +03:00
parent 2bf9e44178
commit 031e5439eb
15 changed files with 225 additions and 91 deletions
+1 -1
View File
@@ -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.33.5" VERSION = "1.34.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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.33.5" "version": "1.34.0"
} }
+59
View File
@@ -0,0 +1,59 @@
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 = 'draw'; await c.updateComplete;
const g = c._gridPitch;
// r1 — комната; рисуем остров-«колонну» внутри неё
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
const [cx, cy] = c._roomCenter(r1).map((v) => Math.round(v / g) * g);
const pts = [[cx, cy], [cx + g * 2, cy], [cx + g * 2, cy + g * 2], [cx, cy + g * 2]];
// 1) клики внутри комнаты больше не отклоняются
for (const p of pts) c._markupClick({ clientX: 0, clientY: 0, composedPath: () => [], ...{} , __pt: p });
// _markupClick берёт координаты из _svgPoint(ev) — подменим напрямую через _path:
c._path = [];
for (const p of pts) {
const before = c._path.length;
// эмулируем клик готовой точкой: повторим логику через прямой вызов невозможен — построим путь вручную и замкнём
c._path = [...c._path, p];
if (c._path.length === before) break;
}
out.pointsAccepted = c._path.length === 4;
// замыкание: проверка пересечений должна пройти (вложенность легальна)
const clash = c._overlapRoom([...c._path]);
out.nestedAllowed = !clash;
// частичное перекрытие всё ещё запрещено
const partial = [[cx - g * 4, cy], [cx + g * 2, cy], [cx + g * 2, cy + g * 2], [cx - g * 4, cy + g * 2]];
// сдвинем так, чтобы вылезло за границу r1: возьмём точку заведомо снаружи
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
const minX = Math.min(...poly1.map((p) => p[0]));
const cross = [[minX - g * 2, cy], [cx, cy], [cx, cy + g * 2], [minX - g * 2, cy + g * 2]];
out.partialStillRejected = !!c._overlapRoom(cross);
// 2) сохранить остров как комнату без зоны (замкнуть контур)
c._path = [...pts, pts[0]];
c._nameSel = 'Колонна'; c._areaSel = '';
c._commitRoom();
await c.updateComplete;
const island = c._spaceModel().rooms.find((r) => r.name === 'Колонна');
out.islandSaved = !!island;
// 3) внешняя комната рендерится как path с evenodd и дыркой
c._setMode('view'); await c.updateComplete;
// включим границы, чтобы r1 рендерился
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), show_borders: true } })) };
c.requestUpdate(); await c.updateComplete;
const evenodd = [...sr().querySelectorAll('path.room')].find((p) => p.getAttribute('fill-rule') === 'evenodd');
out.evenoddPath = !!evenodd;
out.holeInPath = evenodd ? (evenodd.getAttribute('d').match(/M /g) || []).length === 2 : null;
// 4) остров кликабелен (элемент существует и он поверх дырки)
const islandEl = [...sr().querySelectorAll('.room')].find((el) => {
const d = el.getAttribute('points') || el.getAttribute('d') || '';
return d.includes(String(cx)) && el !== evenodd;
});
out.islandRendered = !!islandEl;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+2 -2
View File
@@ -56,14 +56,14 @@ out.splitPending = (await S()).pendingSplit;
out.splitDialog = (await S()).roomDlg; out.splitDialog = (await S()).roomDlg;
// cancel keeps it whole // cancel keeps it whole
await page.evaluate(()=>window.__card._roomDialogCancel()); await page.evaluate(()=>window.__card._roomDialogCancel());
out.cancelWhole = (await S()).rooms.length===4; out.cancelWhole = (await S()).rooms.length===5; // 4 базовых + rg
// redo + confirm with a name (no area) // redo + confirm with a name (no area)
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28)); await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625)); await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5)); await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();}); await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
s = await S(); s = await S();
out.splitRooms = s.rooms.length; // 5 out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room'); out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name; out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
await restore(); await restore();
File diff suppressed because one or more lines are too long
+24 -21
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## v1.34.0 — 2026-07-22 (island rooms)
- **Nested rooms are now legal**: draw a contour fully inside an existing room
(or around one) — a column in a ring-shaped room, an inner room, a wardrobe
island. The parent room's fill is rendered with an evenodd hole, so a ring
paints as a ring; the island itself stays clickable and can carry its own
area, fill and devices. Partial overlaps and duplicate outlines are still
rejected (`roomsOverlap` reworked; `polyContainsPoly`/`islandsOf` helpers,
unit-tested). The per-click "point inside a room" rejection is gone —
validation happens once, when the outline closes.
## v1.33.5 — 2026-07-22 ## v1.33.5 — 2026-07-22
- Editor tabs got extended tooltips explaining what each editor is for (plan - Editor tabs got extended tooltips explaining what each editor is for (plan
geometry vs device icons vs visual decor). geometry vs device icons vs visual decor).
+5
View File
@@ -140,6 +140,11 @@ 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
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
(or around one) saves as a nested room — column in a ring, inner room;
the parent's fill renders with an evenodd hole so the ring paints
correctly; the island stays clickable; partial overlaps and duplicate
outlines are still rejected at closing [auto]
- [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or - [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or
changing its room within the same space never moves the icon — the saved changing its room within the same space never moves the icon — the saved
or auto position migrates to the new marker id; only a brand-new icon or or auto position migrates to the new marker id; only a brand-new icon or
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.33.5", "version": "1.34.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",
+19 -12
View File
@@ -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, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf,
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, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
@@ -32,7 +32,7 @@ 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.33.5'; const CARD_VERSION = '1.34.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';
@@ -1279,15 +1279,10 @@ class HouseplanCard extends LitElement {
// until the contour closes — an abandoned outline leaves no lines behind. // until the contour closes — an abandoned outline leaves no lines behind.
const pt = this._snap(raw); const pt = this._snap(raw);
const closing = this._path.length >= 3 && this._samePt(pt, this._path[0]); const closing = this._path.length >= 3 && this._samePt(pt, this._path[0]);
// rooms must not overlap: a vertex may sit on a wall (shared walls are normal), // Island rooms (v1.34.0): drawing INSIDE an existing room is legal — the
// never strictly inside another room // contour may become a nested room (a column, an inner room). Partial
if (!closing) { // overlaps are still rejected, but only at closing time, when the whole
const busy = this._roomAt(pt); // outline is known (roomsOverlap treats full nesting as legal).
if (busy) {
this._showToast(this._t('toast.point_in_room', { name: busy.name || '' }));
return;
}
}
if (!this._path.length) { if (!this._path.length) {
this._path = [pt]; this._path = [pt];
return; return;
@@ -2914,7 +2909,19 @@ class HouseplanCard extends LitElement {
r.area ? areaTemp(this.hass, this._devices, r.area) : null); r.area ? areaTemp(this.hass, this._devices, r.area) : null);
const label = !space.bg && !disp.showNames && !this._markup; const label = !space.bg && !disp.showNames && !this._markup;
const c = this._roomCenter(r); const c = this._roomCenter(r);
const shape = r.poly // island rooms punch holes in their parent's fill (evenodd)
const myPoly = roomPoly(r);
const holes = myPoly
? islandsOf(myPoly, space.rooms.filter((o) => o !== r).map((o) => roomPoly(o)!).filter(Boolean))
: [];
const pathD = (pts: number[][]) =>
'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z';
const shape = holes.length && myPoly
? svg`<path class="${cls}" style="${style}" fill-rule="evenodd"
d="${[myPoly, ...holes].map(pathD).join(' ')}"
@click=${() => this._clickRoom(r)} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></path>`
: r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}" ? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
@click=${() => this._clickRoom(r)} @mousemove=${tip} @click=${() => this._clickRoom(r)} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></polygon>` @mouseleave=${() => (this._tip = null)}></polygon>`
-1
View File
@@ -122,7 +122,6 @@
"toast.markup_needs_server": "Markup is available after the config is moved to the server", "toast.markup_needs_server": "Markup is available after the config is moved to the server",
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action", "toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
"toast.cfg_save_failed": "Failed to save config: {err}", "toast.cfg_save_failed": "Failed to save config: {err}",
"toast.point_in_room": "That point is inside room “{name}” — rooms must not overlap",
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap", "toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged", "toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
"toast.rooms_merged": "Rooms merged into “{name}”", "toast.rooms_merged": "Rooms merged into “{name}”",
-1
View File
@@ -122,7 +122,6 @@
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер", "toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие", "toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}", "toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.point_in_room": "Точка внутри комнаты «{name}» — комнаты не должны накладываться",
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться", "toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной", "toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
"toast.rooms_merged": "Комнаты объединены в «{name}»", "toast.rooms_merged": "Комнаты объединены в «{name}»",
+31 -3
View File
@@ -241,19 +241,47 @@ function coversArea(a: number[][], b: number[][], eps: number): boolean {
return false; return false;
} }
/** Is `inner` fully contained in `outer` (edges may touch, never cross)? */
export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-6): boolean {
if (!outer || !inner || outer.length < 3 || inner.length < 3) return false;
for (let i = 0; i < inner.length; i++)
for (let j = 0; j < outer.length; j++)
if (segmentsProperlyCross(inner[i], inner[(i + 1) % inner.length], outer[j], outer[(j + 1) % outer.length]))
return false;
for (const v of inner)
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
// identical/traced outlines are NOT containment — probe the centroid strictness both ways
const c = [
inner.reduce((s, p) => s + p[0], 0) / inner.length,
inner.reduce((s, p) => s + p[1], 0) / inner.length,
];
return pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
}
/** /**
* Do two room outlines share floor area? Rooms must never overlap, but sharing a wall * Do two room outlines ILLEGALLY share floor area? Sharing a wall (fully or
* (fully or partially) and touching at a corner are normal and stay legal. A room nested * partially) and touching at a corner are normal. Since v1.34.0 full nesting is
* inside another counts as an overlap. * legal too (island rooms: a column inside a ring, an inner room) only edge
* crossings and PARTIAL overlaps are rejected.
*/ */
export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean { export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean {
if (!a || !b || a.length < 3 || b.length < 3) return false; if (!a || !b || a.length < 3 || b.length < 3) return false;
for (let i = 0; i < a.length; i++) for (let i = 0; i < a.length; i++)
for (let j = 0; j < b.length; j++) for (let j = 0; j < b.length; j++)
if (segmentsProperlyCross(a[i], a[(i + 1) % a.length], b[j], b[(j + 1) % b.length])) return true; if (segmentsProperlyCross(a[i], a[(i + 1) % a.length], b[j], b[(j + 1) % b.length])) return true;
if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
return coversArea(a, b, eps) || coversArea(b, a, eps); return coversArea(a, b, eps) || coversArea(b, a, eps);
} }
/**
* Direct islands of `poly` among `others`: outlines fully inside it that are not
* themselves inside a bigger island (those are subtracted by their parent).
*/
export function islandsOf(poly: number[][], others: number[][][], eps = 1e-6): number[][][] {
const inside = others.filter((o) => polyContainsPoly(poly, o, eps));
return inside.filter((o) => !inside.some((p) => p !== o && polyContainsPoly(p, o, eps)));
}
/** Shoelace area of an outline (absolute value). */ /** Shoelace area of an outline (absolute value). */
export function polygonArea(poly: number[][]): number { export function polygonArea(poly: number[][]): number {
if (!poly || poly.length < 3) return 0; if (!poly || poly.length < 3) return 0;
+24 -6
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi, lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor, fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
splitRoomPath, splitRoomPath, polyContainsPoly, islandsOf,
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';
@@ -325,14 +325,32 @@ test('roomsOverlap: sharing a wall or a corner is legal; real overlap is not', (
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]])); assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
}); });
test('roomsOverlap: nested, identical and enclosing outlines all count as overlap', () => { test('roomsOverlap v1.34: nesting is legal (islands), duplicates and crossings are not', () => {
assert.ok(roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]])); // nested // nested & enclosing — legal since island rooms
assert.ok(roomsOverlap(SQ, SQ)); // duplicate assert.ok(!roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]]));
// drawn AROUND an existing room: every vertex outside, no vertex of ours inside it assert.ok(!roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], SQ));
assert.ok(roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], SQ)); // a duplicate outline is still an overlap
assert.ok(roomsOverlap(SQ, SQ));
// a cross: no vertex of either lies inside the other, but the edges cross // a cross: no vertex of either lies inside the other, but the edges cross
assert.ok(roomsOverlap([[0, 0.5], [3, 0.5], [3, 1.5], [0, 1.5]], assert.ok(roomsOverlap([[0, 0.5], [3, 0.5], [3, 1.5], [0, 1.5]],
[[0.5, -1], [1.5, -1], [1.5, 3], [0.5, 3]])); [[0.5, -1], [1.5, -1], [1.5, 3], [0.5, 3]]));
// genuine partial overlap is still rejected
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
});
test('polyContainsPoly & islandsOf', () => {
const outer = [[0, 0], [10, 0], [10, 10], [0, 10]];
const col = [[4, 4], [6, 4], [6, 6], [4, 6]];
const tiny = [[4.5, 4.5], [5, 4.5], [5, 5], [4.5, 5]]; // внутри col
const partial = [[8, 8], [12, 8], [12, 12], [8, 12]];
assert.ok(polyContainsPoly(outer, col));
assert.ok(!polyContainsPoly(col, outer));
assert.ok(!polyContainsPoly(outer, outer)); // дубликат — не вложенность
assert.ok(!polyContainsPoly(outer, partial));
// прямые острова: col — да; tiny — нет (он остров col, не outer)
const isl = islandsOf(outer, [col, tiny, partial]);
assert.deepEqual(isl, [col]);
assert.deepEqual(islandsOf(col, [tiny]), [tiny]);
}); });
test('roomPoly: polygon rooms as-is, legacy rect rooms as four corners', () => { test('roomPoly: polygon rooms as-is, legacy rect rooms as four corners', () => {