mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
feat v1.20.0: rooms may not overlap
- A click strictly inside an existing room is refused while drawing; a click ON a wall stays legal (neighbours share walls, and real walls overlap collinearly rather than match exactly, so vertices land on existing outlines mid-span constantly). - Closing an outline that overlaps an existing room is refused — vertex checks alone are not enough, an outline drawn AROUND a room has every vertex outside it. Nesting counts as overlap. The outline stays open so it can be corrected. - New pure geometry: roomPoly, pointOnBoundary, pointStrictlyInside, segmentsProperlyCross (touching/collinear deliberately not a crossing), roomsOverlap. +4 tests (67). Verified live: all 15 existing neighbour pairs stay clean (no false positives).
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_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.19.0"
|
||||
VERSION = "1.20.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"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",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.19.0"
|
||||
"version": "1.20.0"
|
||||
}
|
||||
|
||||
Vendored
+33
-33
File diff suppressed because one or more lines are too long
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## v1.20.0 — 2026-07-16 (rooms may not overlap)
|
||||
- **A click strictly inside an existing room is refused** while drawing (toast names the room).
|
||||
Being *on* a wall stays legal — neighbouring rooms share walls, and real walls overlap
|
||||
collinearly rather than match exactly, so new vertices land on existing outlines mid-span
|
||||
all the time. `pointStrictlyInside` excludes the boundary explicitly (ray casting alone is
|
||||
unreliable exactly on an edge).
|
||||
- **Closing an outline that overlaps an existing room is refused** — vertex checks alone are not
|
||||
enough: an outline drawn *around* a room has every vertex outside it. The outline stays open
|
||||
so it can be corrected. Nesting one room inside another counts as an overlap.
|
||||
- New pure geometry in logic.ts: `roomPoly`, `pointOnBoundary`, `pointStrictlyInside`,
|
||||
`segmentsProperlyCross` (touching/collinear is deliberately not a crossing), `roomsOverlap`
|
||||
(edge crossings + containment probe, which also catches duplicate outlines). (+4 tests: 63 → 67.)
|
||||
|
||||
## v1.19.0 — 2026-07-16 (a line is never a thing of its own)
|
||||
**Model change.** A wall can only exist as an edge of a closed room. Consequences:
|
||||
- **Walls are derived from room outlines** (`roomEdges` in logic.ts), not stored. A wall
|
||||
|
||||
+2
-2
@@ -13,14 +13,14 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.19.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Version | **v1.20.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
|
||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
||||
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
|
||||
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
|
||||
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
||||
| Tests | 63 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
| Tests | 67 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Deleting a room removes its walls, EXCEPT those shared with a neighbouring room
|
||||
(the neighbour still yields them); deleting the neighbour too removes them as well
|
||||
- [ ] There is no "Erase" tool in the markup toolbar (removed in v1.19.0)
|
||||
- [ ] Rooms never overlap (v1.20.0): a click strictly inside an existing room is refused with a
|
||||
toast; a click ON a shared wall (including mid-span of a longer neighbour wall) still works
|
||||
- [ ] Closing an outline drawn AROUND an existing room is refused; the outline stays open
|
||||
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path
|
||||
- [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog
|
||||
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+40
-4
@@ -13,7 +13,7 @@ import {
|
||||
} from './rules';
|
||||
import {
|
||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||
segmentCm, formatLength, roomEdges,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
@@ -27,7 +27,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.19.0';
|
||||
const CARD_VERSION = '1.20.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';
|
||||
@@ -1010,6 +1010,25 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
}, 500);
|
||||
|
||||
/**
|
||||
* The room that strictly contains p. Being ON a wall does not count: neighbouring
|
||||
* rooms share walls, so new vertices legitimately land on existing outlines.
|
||||
*/
|
||||
private _roomAt(p: number[]): RoomCfg | undefined {
|
||||
return this._spaceModel().rooms.find((r) => {
|
||||
const poly = roomPoly(r);
|
||||
return !!poly && pointStrictlyInside(p, poly);
|
||||
});
|
||||
}
|
||||
|
||||
/** The first existing room the outline would overlap (rooms must not overlap). */
|
||||
private _overlapRoom(verts: number[][]): RoomCfg | undefined {
|
||||
return this._spaceModel().rooms.find((r) => {
|
||||
const poly = roomPoly(r);
|
||||
return !!poly && roomsOverlap(verts, poly);
|
||||
});
|
||||
}
|
||||
|
||||
private _pointInRoom(p: number[], r: RoomCfg): boolean {
|
||||
if (r.poly) return pointInPolygon(p, r.poly);
|
||||
return (
|
||||
@@ -1036,20 +1055,37 @@ class HouseplanCard extends LitElement {
|
||||
// draw: clicks on grid points build the outline. Nothing is written to the config
|
||||
// until the contour closes — an abandoned outline leaves no lines behind.
|
||||
const pt = this._snap(raw);
|
||||
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),
|
||||
// never strictly inside another room
|
||||
if (!closing) {
|
||||
const busy = this._roomAt(pt);
|
||||
if (busy) {
|
||||
this._showToast(this._t('toast.point_in_room', { name: busy.name || '' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!this._path.length) {
|
||||
this._path = [pt];
|
||||
return;
|
||||
}
|
||||
const last = this._path[this._path.length - 1];
|
||||
if (this._samePt(pt, last)) return; // repeated click on the same point
|
||||
if (closing) {
|
||||
// a contour can enclose an existing room without any vertex inside it
|
||||
const clash = this._overlapRoom(this._path);
|
||||
if (clash) {
|
||||
this._showToast(this._t('toast.room_overlap', { name: clash.name || '' }));
|
||||
return; // leave the outline open so it can be corrected
|
||||
}
|
||||
this._path = [...this._path, pt];
|
||||
// closing the outline: a click on the first vertex → the save dialog
|
||||
if (this._path.length >= 4 && this._samePt(pt, this._path[0])) {
|
||||
this._cursorPt = null;
|
||||
this._nameSel = '';
|
||||
this._areaSel = '';
|
||||
this._roomDialog = true;
|
||||
return;
|
||||
}
|
||||
this._path = [...this._path, pt];
|
||||
}
|
||||
|
||||
private get _contourClosed(): boolean {
|
||||
|
||||
@@ -92,6 +92,8 @@
|
||||
"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.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_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
|
||||
"toast.room_saved_no_area": "Room saved ({n}, no area). Outline the next one or exit markup.",
|
||||
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
|
||||
|
||||
@@ -92,6 +92,8 @@
|
||||
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
|
||||
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
|
||||
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
||||
"toast.point_in_room": "Точка внутри комнаты «{name}» — комнаты не должны накладываться",
|
||||
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
|
||||
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
|
||||
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
|
||||
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
|
||||
|
||||
+90
-5
@@ -48,15 +48,19 @@ export function segKey(a: number[], b: number[], prec = 1): string {
|
||||
* room. Shared walls are emitted once, which is what makes deleting a room keep the
|
||||
* borders its neighbours still contribute — the neighbour's polygon still yields them.
|
||||
*/
|
||||
export function roomPoly(r: any): number[][] | null {
|
||||
if (r?.poly?.length >= 3) return r.poly;
|
||||
if (r && r.x != null && r.y != null && r.w != null && r.h != null)
|
||||
return [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function roomEdges(rooms: any[]): number[][] {
|
||||
const out: number[][] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const r of rooms || []) {
|
||||
let pts: number[][] | null = null;
|
||||
if (r?.poly?.length) pts = r.poly;
|
||||
else if (r && r.x != null && r.y != null && r.w != null && r.h != null)
|
||||
pts = [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
|
||||
if (!pts || pts.length < 3) continue;
|
||||
const pts = roomPoly(r);
|
||||
if (!pts) continue;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
@@ -85,6 +89,87 @@ export function pointInPolygon(p: number[], poly: number[][]): boolean {
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** Distance from p to segment ab. */
|
||||
function distToSeg(p: number[], a: number[], b: number[]): number {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is p on the outline itself (within eps)? This is the normal case, not an anomaly:
|
||||
* neighbouring rooms share walls, so their vertices sit on each other's outlines —
|
||||
* including mid-span, since real walls overlap collinearly rather than match exactly.
|
||||
*/
|
||||
export function pointOnBoundary(p: number[], poly: number[][], eps = 1e-6): boolean {
|
||||
if (!poly || poly.length < 2) return false;
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Inside the outline AND not on it — a point on a shared wall is not "inside". */
|
||||
export function pointStrictlyInside(p: number[], poly: number[][], eps = 1e-6): boolean {
|
||||
if (!poly || poly.length < 3) return false;
|
||||
if (pointOnBoundary(p, poly, eps)) return false;
|
||||
return pointInPolygon(p, poly);
|
||||
}
|
||||
|
||||
function cross3(a: number[], b: number[], c: number[]): number {
|
||||
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two segments cross transversally? Touching at an endpoint and collinear overlap
|
||||
* deliberately do NOT count — that is what sharing a wall looks like.
|
||||
*/
|
||||
export function segmentsProperlyCross(
|
||||
p1: number[], p2: number[], p3: number[], p4: number[], eps = 1e-9,
|
||||
): boolean {
|
||||
const d1 = cross3(p3, p4, p1);
|
||||
const d2 = cross3(p3, p4, p2);
|
||||
const d3 = cross3(p1, p2, p3);
|
||||
const d4 = cross3(p1, p2, p4);
|
||||
return (
|
||||
((d1 > eps && d2 < -eps) || (d1 < -eps && d2 > eps)) &&
|
||||
((d3 > eps && d4 < -eps) || (d3 < -eps && d4 > eps))
|
||||
);
|
||||
}
|
||||
|
||||
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
|
||||
function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||
let allOnBoundary = true;
|
||||
for (const v of a) {
|
||||
if (pointStrictlyInside(v, b, eps)) return true;
|
||||
if (!pointOnBoundary(v, b, eps)) allOnBoundary = false;
|
||||
}
|
||||
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
|
||||
if (allOnBoundary) {
|
||||
const c = [
|
||||
a.reduce((s, p) => s + p[0], 0) / a.length,
|
||||
a.reduce((s, p) => s + p[1], 0) / a.length,
|
||||
];
|
||||
return pointStrictlyInside(c, b, eps);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two room outlines share floor area? Rooms must never overlap, but sharing a wall
|
||||
* (fully or partially) and touching at a corner are normal and stay legal. A room nested
|
||||
* inside another counts as an overlap.
|
||||
*/
|
||||
export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean {
|
||||
if (!a || !b || a.length < 3 || b.length < 3) return false;
|
||||
for (let i = 0; i < a.length; i++)
|
||||
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;
|
||||
return coversArea(a, b, eps) || coversArea(b, a, eps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker id by binding: device → device_id, entity → 'lg_'+entity_id,
|
||||
* virtual → the passed-in existing (if it is already a v_ marker) or a new one via newId().
|
||||
|
||||
+42
-1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
segmentCm, formatLength, roomEdges,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
} from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
@@ -297,3 +297,44 @@ test('roomEdges: a wall shared by two rooms is emitted once, and survives deleti
|
||||
// deleting both → no lines remain
|
||||
assert.equal(roomEdges([]).length, 0);
|
||||
});
|
||||
|
||||
const SQ = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||
|
||||
test('pointStrictlyInside: a point on a wall is NOT inside (shared walls are normal)', () => {
|
||||
assert.ok(pointStrictlyInside([1, 1], SQ)); // middle
|
||||
assert.ok(!pointStrictlyInside([2, 1], SQ)); // on a wall mid-span (T-junction vertex)
|
||||
assert.ok(!pointStrictlyInside([0, 0], SQ)); // on a corner
|
||||
assert.ok(!pointStrictlyInside([3, 1], SQ)); // outside
|
||||
assert.ok(pointOnBoundary([2, 1], SQ));
|
||||
assert.ok(!pointOnBoundary([1, 1], SQ));
|
||||
});
|
||||
|
||||
test('roomsOverlap: sharing a wall or a corner is legal; real overlap is not', () => {
|
||||
const right = [[2, 0], [4, 0], [4, 2], [2, 2]]; // shares the whole x=2 wall
|
||||
assert.ok(!roomsOverlap(SQ, right));
|
||||
// neighbour's wall is LONGER than ours — the real dacha case (collinear partial overlap)
|
||||
const tall = [[2, -1], [4, -1], [4, 3], [2, 3]];
|
||||
assert.ok(!roomsOverlap(SQ, tall));
|
||||
// touching only at a corner
|
||||
assert.ok(!roomsOverlap(SQ, [[2, 2], [3, 2], [3, 3], [2, 3]]));
|
||||
// apart
|
||||
assert.ok(!roomsOverlap(SQ, [[5, 5], [6, 5], [6, 6], [5, 6]]));
|
||||
// genuine partial overlap
|
||||
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
|
||||
});
|
||||
|
||||
test('roomsOverlap: nested, identical and enclosing outlines all count as overlap', () => {
|
||||
assert.ok(roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]])); // nested
|
||||
assert.ok(roomsOverlap(SQ, SQ)); // duplicate
|
||||
// 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));
|
||||
// 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]],
|
||||
[[0.5, -1], [1.5, -1], [1.5, 3], [0.5, 3]]));
|
||||
});
|
||||
|
||||
test('roomPoly: polygon rooms as-is, legacy rect rooms as four corners', () => {
|
||||
assert.equal(roomPoly({ poly: SQ }), SQ);
|
||||
assert.deepEqual(roomPoly({ x: 0, y: 0, w: 2, h: 2 }), SQ);
|
||||
assert.equal(roomPoly({}), null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user