Compare commits

...
5 Commits
Author SHA1 Message Date
Matysh 1552bff99a 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).
2026-07-16 07:59:01 +03:00
Matysh 65268a7985 feat v1.19.0: a line is never an entity of its own — walls are derived from rooms
A wall can only exist as an edge of a closed room:
- roomEdges(rooms) derives walls from room outlines, deduping shared ones, so deleting a
  room keeps the borders its neighbours still contribute and drops the rest — the rule
  falls out of the model instead of needing bookkeeping.
- Nothing is persisted while drawing: an outline you never close leaves no lines behind
  (previously every click pair was written to space.segments immediately).
- The 'Erase line' tool is gone; space.segments is stripped on every save (validation
  still tolerates it on read so a stale tab cannot fail a save).
- Dead code out: _addSegment/_removeSegmentByKey/_distToSeg/_pathSegs/_segKey.
Docs (ARCHITECTURE/TESTING/CHANGELOG) updated in the same commit. +2 tests (63).
2026-07-16 07:27:23 +03:00
Matysh 4593d96955 fix v1.18.1: the drawing ruler badge was invisible — .stage.markup hides .devlayer
The v1.18.0 measure label was rendered inside .devlayer, which markup mode hides
entirely (display:none) so icons don't obstruct drawing. Moved it to its own
.measurelayer. Verified visually on a real segment (3.60 m).
2026-07-16 06:53:31 +03:00
Matysh 18a0d279a7 feat v1.18.0: live segment ruler while drawing rooms + per-space scale
Validate / hacs (push) Has been cancelled
Validate / hassfest (push) Has been cancelled
Validate / frontend (push) Has been cancelled
Validate / backend (push) Has been cancelled
- Draw mode shows the current segment length near the cursor (metres or feet+inches
  per the HA unit system). New pure helpers segmentCm/formatLength (unit-tested).
- New per-space 'Scale (grid cell size)' field (space.cell_cm, default 5 cm) so each
  plan has its own real-world dimensions. i18n en/ru, .measurelabel style. +3 tests (61).
2026-07-14 18:48:56 +03:00
Matysh 6cbf4ece4e fix v1.17.2: gate the humidity badge on device_class:humidity (primary entity), not the water-percent icon — so a humidity sensor whose name forces another icon (e.g. Myheat) still shows its %. +1 test.
Validate / hacs (push) Has been cancelled
Validate / hassfest (push) Has been cancelled
Validate / frontend (push) Has been cancelled
Validate / backend (push) Has been cancelled
2026-07-11 15:06:51 +03:00
19 changed files with 672 additions and 287 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_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.17.1"
VERSION = "1.20.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
@@ -31,7 +31,6 @@ async def async_get_config_entry_diagnostics(
"has_plan": bool(s.get("plan_url")),
"rooms": len(s.get("rooms", [])),
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
"segments": len(s.get("segments", [])),
}
for s in config.get("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",
"requirements": [],
"single_config_entry": true,
"version": "1.17.1"
"version": "1.20.0"
}
@@ -100,6 +100,9 @@ SPACE_SCHEMA = vol.Schema(
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
vol.Required("rooms"): [ROOM_SCHEMA],
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
# independent existence. Still accepted so a stale browser tab cannot fail a save;
# the card strips the field on every write.
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
},
extra=vol.ALLOW_EXTRA,
+112 -89
View File
File diff suppressed because one or more lines are too long
+13 -3
View File
@@ -111,12 +111,22 @@ If the server config is empty, the card falls back to the legacy bundle (the dac
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/erase/delroom), `_path` (the current outline,
vertices on the GRID_N=60 grid). Clicks on the stage → `_svgPoint`→`_snap`. Each pair of points adds a
segment to `space.segments` (dedup by key, saved via config/set with debounce). The outline is closed
State inside the card: `_markup` (mode), `_tool` (draw/delroom), `_path` (the current outline,
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
**A line is never an entity of its own (v1.19.0).** Nothing is persisted while you draw: an
outline you never close leaves no trace. Walls are *derived* from the room outlines by
`roomEdges(rooms)` (logic.ts) and deduped by `segKey`, so a wall shared by two rooms is emitted
once — deleting a room therefore keeps the borders its neighbours still contribute and drops the
rest, with no bookkeeping. The legacy `space.segments` array is stripped on every save (validation
still tolerates it on read; see CHANGELOG v1.19.0).
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).
## Integration WS API
| Command | Parameters | Response |
+59
View File
@@ -1,5 +1,64 @@
# 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
shared by two rooms is emitted once, so **deleting a room keeps the borders its neighbours
still contribute** — and drops the walls nobody else uses. This falls out of the model
instead of needing bookkeeping.
- **An abandoned outline leaves nothing behind.** Previously every click pair was written to
`space.segments` immediately, so a contour you never closed left orphan lines on the plan.
Now nothing is persisted until the room is saved.
- **The "Erase line" tool is gone** — there is no standalone line to erase. Mis-clicks are
undone with Esc / Ctrl+Z as before.
- **`space.segments` is dropped on every save** (legacy configs shed it on first write).
Validation still tolerates the field so a stale browser tab cannot fail a save; diagnostics
no longer reports it. Lines that belonged to no room disappear on upgrade — by design.
- Dead code removed: `_addSegment`, `_removeSegmentByKey`, `_distToSeg`, `_pathSegs`, `_segKey`.
`segKey(a, b, prec)` gained a precision argument (normalized coords need more than render
units). (+2 tests: 61 → 63.)
## v1.18.1 — 2026-07-16 (fix: the drawing ruler was invisible)
- Fix on top of v1.18.0: the length badge never showed up while drawing. It was rendered
inside `.devlayer`, and `.stage.markup .devlayer { display: none }` hides that whole layer
in markup mode (so icons do not get in the way) — the badge was in the DOM but invisible.
It now lives in its own `.measurelayer` (absolute, `pointer-events: none`), which markup
mode does not hide. Verified visually on a real drawn segment ("3.60 m" on screen).
- Testing lesson (see docs/TESTING.md): asserting on `textContent` is not enough — a DOM
query passes on elements hidden by an ancestor. Check `offsetParent`/rect or look at a
screenshot.
## v1.18.0 — 2026-07-14 (live measurements while drawing rooms + per-space scale)
- **Ruler while drawing.** In room-markup "draw" mode, a badge follows the cursor showing the
length of the current segment (last placed vertex → cursor). Units come from the HA unit
system: metric → metres ("1.25 m"), imperial → feet+inches ("4′ 1″").
- **Per-space scale.** New "Scale (grid cell size)" field in the space dialog — cm represented
by one grid cell (default **5 cm**, i.e. 240 cells ≈ 12 m). Stored as `space.cell_cm`; each
plan can have its own real-world size.
- Pure helpers `segmentCm` / `formatLength` in logic.ts (unit-tested); `_fmtLen` +
`_renderMeasureLabel` in the card; `.measurelabel` style; i18n `space.scale_label`/`scale_unit`.
(+3 tests: 58 → 61 frontend.)
## v1.17.2 — 2026-07-11 (humidity badge: gate on the sensor, not the icon)
- Fix on top of v1.17.1: the humidity `%` badge is now shown whenever the marker's primary
entity is a humidity sensor (`device_class: humidity`), regardless of the resolved icon.
Previously it required the `mdi:water-percent` icon, so a humidity sensor whose name matched
another icon rule (e.g. a "Myheat Влажность …" sensor → boiler icon) showed no value.
Verified live (45.2 → 45%). (+1 test: 57 → 58.)
## v1.17.1 — 2026-07-11 (humidity value next to the icon, like temperature)
- **Humidity sensors now show their value (%) next to the icon**, mirroring the temperature
badge. Any marker resolved to the humidity icon (`mdi:water-percent`) — a humidity device or a
+2 -2
View File
@@ -13,14 +13,14 @@
| Item | State |
|---|---|
| Version | **v1.17.1** 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 | 57 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)
+11 -1
View File
@@ -63,7 +63,17 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Room markup editor ★
- [ ] Grid appears; dots snap; segments draw pair-by-pair; shared walls reused
- [ ] Grid appears; dots snap; the outline draws pair-by-pair; shared walls reused
- [ ] Ruler: while drawing, the length of the current segment follows the cursor
(metres, or feet+inches on an imperial HA); scale = space "cm per cell" (default 5)
- [ ] A line cannot exist on its own: start an outline, do NOT close it, leave markup —
no lines are left behind (nothing was written to the config)
- [ ] 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
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.17.1",
"version": "1.20.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+4 -4
View File
@@ -227,7 +227,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
};
item.primary = primaryEntity(h, entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
if (icon === 'mdi:water-percent') item.hum = humFor(h, entIds);
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
rest.push(item);
}
@@ -276,8 +276,8 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
};
item.primary = primaryEntity(h, entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
if (icon === 'mdi:water-percent') item.hum = humFor(h, entIds);
if (icon === 'mdi:water-percent') item.hum = humFor(h, entIds);
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
applyMarker(item, m);
rest.push(item);
} else if (kind === 'entity') {
@@ -301,7 +301,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
bindingRef: ref,
};
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, [ref]);
if (icon === 'mdi:water-percent') item.hum = humFor(h, [ref]);
if (isHumEntity(h, ref)) item.hum = humFor(h, [ref]);
applyMarker(item, m);
rest.push(item);
} else {
+97 -88
View File
@@ -12,12 +12,13 @@ import {
type IconRule, type CompiledIconRule,
} from './rules';
import {
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
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,
} from './logic';
import { buildDevices, lqiFor, tempFor, humFor, areaLights, areaTemp } from './devices';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices';
import type {
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
} from './types';
@@ -26,14 +27,14 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.17.1';
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';
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' | 'erase' | 'delroom';
type MarkupTool = 'draw' | 'delroom';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any;
@@ -79,7 +80,6 @@ class HouseplanCard extends LitElement {
private _markup = false;
private _tool: MarkupTool = 'draw';
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _pathSegs: (string | null)[] = []; // keys of the segments added by outline steps
private _cursorPt: number[] | null = null;
private _areaSel = '';
private _nameSel = '';
@@ -132,6 +132,7 @@ class HouseplanCard extends LitElement {
fillMode: 'none' | 'lqi' | 'light' | 'temp';
tempMin: number;
tempMax: number;
cellCm: number; // real-world cm represented by one grid cell
busy: boolean;
} | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
@@ -219,32 +220,12 @@ class HouseplanCard extends LitElement {
}
}
/** Remove the last placed point (and its line, if it was added by that step). */
/** Remove the last placed point. An unfinished outline is never persisted. */
private _undoPoint(): void {
if (!this._path.length) return;
if (this._path.length === 1) {
this._path = [];
this._pathSegs = [];
return;
}
const segKey = this._pathSegs[this._pathSegs.length - 1];
this._pathSegs = this._pathSegs.slice(0, -1);
if (segKey) this._removeSegmentByKey(segKey);
this._path = this._path.slice(0, -1);
}
private _removeSegmentByKey(key: string): void {
const sp = this._curSpaceCfg;
if (!sp?.segments) return;
const idx = this._segments.findIndex(
(s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key,
);
if (idx >= 0) {
sp.segments.splice(idx, 1);
this._saveConfig();
}
}
public static getConfigElement() {
return document.createElement('houseplan-card-editor');
}
@@ -643,7 +624,7 @@ class HouseplanCard extends LitElement {
private _liveHum(d: DevItem): number | null {
if (!this._config?.show_temperature) return null; // same "sensor values" toggle as temperature
if (d.icon !== 'mdi:water-percent') return null;
if (!d.primary || !isHumEntity(this.hass, d.primary)) return null;
return humFor(this.hass, d.entities);
}
@@ -944,6 +925,18 @@ class HouseplanCard extends LitElement {
return NORM_W / GRID_N;
}
/** cm represented by one grid cell for the current space (default 5). */
private get _cellCm(): number {
const v = Number(this._curSpaceCfg?.cell_cm);
return Number.isFinite(v) && v > 0 ? v : 5;
}
/** Human-readable length of a segment (render units) using the HA unit system. */
private _fmtLen(a: number[], b: number[]): string {
const cm = segmentCm(a, b, this._gridPitch, this._cellCm);
return formatLength(cm, this.hass?.config?.unit_system?.length === 'mi');
}
private get _curSpaceCfg(): any {
return this._serverCfg?.spaces.find((s: any) => s.id === this._space);
}
@@ -953,11 +946,15 @@ class HouseplanCard extends LitElement {
return sp ? NORM_W / sp.aspect : NORM_W;
}
/** Segments of the current space in render units. */
/**
* Walls of the current space in render units — DERIVED from the room outlines.
* There is no standalone "line" entity: every wall belongs to a closed room, and a
* wall shared with a neighbour survives deleting either room (the other still yields it).
*/
private get _segments(): number[][] {
const sp = this._curSpaceCfg;
const H = this._spaceH;
return (sp?.segments || []).map((s: number[]) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
return roomEdges(sp?.rooms || []).map((s) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
}
private _toggleMarkup(): void {
@@ -986,12 +983,17 @@ class HouseplanCard extends LitElement {
return samePoint(a, b);
}
private _segKey(a: number[], b: number[]): string {
return segKeyOf(a, b);
/**
* Walls are derived from rooms, so the legacy per-space `segments` array is dead
* weight: drop it on every save. Configs written before v1.19.0 shed it on first write.
*/
private _dropLegacySegments(): void {
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
}
private _saveConfig = debounce(() => {
if (!this._serverCfg) return;
this._dropLegacySegments();
this.hass
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
.then((r: any) => {
@@ -1008,31 +1010,23 @@ class HouseplanCard extends LitElement {
});
}, 500);
/** Add a segment (render units) to the space skeleton (no duplicates). true = new. */
private _addSegment(a: number[], b: number[]): boolean {
const sp = this._curSpaceCfg;
if (!sp) return false;
const H = this._spaceH;
const key = this._segKey(a, b);
const exists = this._segments.some((s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key);
if (exists) return false;
sp.segments = sp.segments || [];
sp.segments.push([a[0] / NORM_W, a[1] / H, b[0] / NORM_W, b[1] / H]);
this._saveConfig();
return true;
/**
* 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);
});
}
private _distToSeg(p: number[], s: number[]): number {
const [x, y] = p;
const [x1, y1, x2, y2] = s;
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy || 1;
let t = ((x - x1) * dx + (y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const px = x1 + t * dx;
const py = y1 + t * dy;
return Math.hypot(x - px, y - py);
/** 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 {
@@ -1045,26 +1039,6 @@ class HouseplanCard extends LitElement {
private _markupClick(ev: MouseEvent): void {
if (!this._markup) return;
const raw = this._svgPoint(ev);
if (this._tool === 'erase') {
const sp = this._curSpaceCfg;
if (!sp?.segments?.length) return;
const segs = this._segments;
let best = -1;
let bestD = this._gridPitch * 0.5;
segs.forEach((s, i) => {
const d = this._distToSeg(raw, s);
if (d < bestD) {
bestD = d;
best = i;
}
});
if (best >= 0) {
sp.segments.splice(best, 1);
this._saveConfig();
this.requestUpdate();
}
return;
}
if (this._tool === 'delroom') {
const space = this._spaceModel();
const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r));
@@ -1078,25 +1052,40 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
return;
}
// draw: clicks on grid points, pairs of points get connected with a line
// 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];
this._pathSegs = [];
return;
}
const last = this._path[this._path.length - 1];
if (this._samePt(pt, last)) return; // repeated click on the same point
const added = this._addSegment(last, pt);
this._pathSegs = [...this._pathSegs, added ? this._segKey(last, pt) : null];
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])) {
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];
this._cursorPt = null;
this._nameSel = '';
this._areaSel = '';
this._roomDialog = true;
return;
}
this._path = [...this._path, pt];
}
private get _contourClosed(): boolean {
@@ -1138,7 +1127,6 @@ class HouseplanCard extends LitElement {
});
this._saveConfig();
this._path = [];
this._pathSegs = [];
const boundArea = this._areaSel;
this._areaSel = '';
this._nameSel = '';
@@ -1174,7 +1162,6 @@ class HouseplanCard extends LitElement {
private _cancelPath(): void {
this._path = [];
this._pathSegs = [];
this._cursorPt = null;
this._roomDialog = false;
}
@@ -1509,6 +1496,7 @@ class HouseplanCard extends LitElement {
showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
tempMin: disp.tempMin, tempMax: disp.tempMax,
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
busy: false,
};
} else {
@@ -1518,6 +1506,7 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
cellCm: 5,
busy: false,
};
}
@@ -1573,7 +1562,6 @@ class HouseplanCard extends LitElement {
aspect: d.source === 'draw' ? drawAspect : 1.414,
view_box: [0, 0, 1, 1],
rooms: [],
segments: [],
};
cfg.spaces.push(sp);
} else {
@@ -1602,6 +1590,7 @@ class HouseplanCard extends LitElement {
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
};
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
await this._saveConfigNow();
this._spaceDialog = null;
if (d.mode === 'create') this._space = sp.id;
@@ -1652,6 +1641,7 @@ class HouseplanCard extends LitElement {
user's retry starts from the fresh config instead of hitting the same
conflict again. */
private async _saveConfigNow(): Promise<void> {
this._dropLegacySegments();
try {
const r = await this.hass.callWS({
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
@@ -1690,6 +1680,7 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
cellCm: 5,
busy: false,
};
}
@@ -2008,6 +1999,9 @@ class HouseplanCard extends LitElement {
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
: nothing}
</div>
${this._markup && this._tool === 'draw' && this._path.length && this._cursorPt && !this._contourClosed
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
: nothing}
</div>
${this._zoom > 1
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
@@ -2128,6 +2122,15 @@ class HouseplanCard extends LitElement {
>${r.name}</div>`;
}
/** Length badge that follows the cursor while drawing the current segment. */
private _renderMeasureLabel(view: { x: number; y: number; w: number; h: number }): TemplateResult {
const a = this._path[this._path.length - 1];
const b = this._cursorPt!;
const left = ((b[0] - view.x) / view.w) * 100;
const top = ((b[1] - view.y) / view.h) * 100;
return html`<div class="measurelabel" style="left:${left}%;top:${top}%">${this._fmtLen(a, b)}</div>`;
}
private _roomCenter(r: RoomCfg): number[] {
if (r.poly) {
const n = r.poly.length;
@@ -2174,10 +2177,6 @@ class HouseplanCard extends LitElement {
title=${this._t('title.markup_add')}>
<ha-icon icon="mdi:vector-polyline-plus"></ha-icon>${this._t('markup.add')}
</button>
<button class="btn ${this._tool === 'erase' ? 'on' : ''}" @click=${() => (this._tool = 'erase')}
title=${this._t('title.markup_erase')}>
<ha-icon icon="mdi:eraser"></ha-icon>${this._t('markup.erase')}
</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')}
@@ -2402,6 +2401,16 @@ class HouseplanCard extends LitElement {
</select>`
: nothing}
<label>${this._t('space.scale_label')}</label>
<div class="colorrow">
<input class="namein tempin" type="number" min="0.1" step="0.1" .value=${String(d.cellCm)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
this._spaceDialog = { ...d, cellCm: Number.isFinite(n) && n > 0 ? n : d.cellCm };
}} />
<span class="opl">${this._t('space.scale_unit')}</span>
</div>
<label class="dispsection">${this._t('space.display_section')}</label>
<label class="srcrow">
<input type="checkbox" .checked=${d.showBorders}
+4 -2
View File
@@ -27,13 +27,11 @@
"title.configure_space": "Configure space",
"title.add_space": "Add space",
"title.markup_add": "Add a room: connect grid dots with lines until the outline closes",
"title.markup_erase": "Erase a line: click the line",
"title.markup_delroom": "Delete a room: click inside the room",
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
"title.choose_area": "Select a Home Assistant area",
"title.need_plan": "Upload a floor-plan image",
"markup.add": "Add",
"markup.erase": "Erase",
"markup.delete": "Delete",
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
"markup.hint_start": "click a grid dot to start the outline",
@@ -94,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",
@@ -152,6 +152,8 @@
"import.progress": "Floor {i} of {n}",
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
"btn.skip": "Skip",
"space.scale_label": "Scale (grid cell size)",
"space.scale_unit": "cm per cell",
"space.display_section": "Display",
"space.show_borders": "Always show room borders",
"space.show_names": "Show room names (drag to move)",
+4 -2
View File
@@ -27,13 +27,11 @@
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
"title.markup_erase": "Стереть линию: клик по линии",
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
"title.choose_area": "Выберите зону Home Assistant",
"title.need_plan": "Загрузите подложку (план этажа)",
"markup.add": "Добавить",
"markup.erase": "Стереть",
"markup.delete": "Удалить",
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
@@ -94,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": "Редактирование устройств доступно после переноса конфига на сервер",
@@ -152,6 +152,8 @@
"import.progress": "Этаж {i} из {n}",
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
"btn.skip": "Пропустить",
"space.scale_label": "Масштаб (размер клетки сетки)",
"space.scale_unit": "см на клетку",
"space.display_section": "Отображение",
"space.show_borders": "Всегда отображать границы комнат",
"space.show_names": "Отображать названия комнат (перетаскиваются)",
+138 -3
View File
@@ -13,10 +13,64 @@ export function snapToGrid(v: number, pitch: number): number {
return Math.round(v / pitch) * pitch;
}
/** Canonical key of a segment (independent of direction). */
export function segKey(a: number[], b: number[]): string {
/** Real-world length (cm) of a segment given the grid pitch (render units per cell) and cm per cell. */
export function segmentCm(a: number[], b: number[], gridPitch: number, cellCm: number): number {
const cells = Math.hypot(b[0] - a[0], b[1] - a[1]) / gridPitch;
return cells * cellCm;
}
/** Format a length (cm) for display: metric metres ("1.25 m") or imperial feet+inches ("4′ 1″"). */
export function formatLength(cm: number, imperial: boolean): string {
if (imperial) {
const totalIn = cm / 2.54;
let ft = Math.floor(totalIn / 12);
let inch = Math.round(totalIn - ft * 12);
if (inch === 12) { ft += 1; inch = 0; }
return `${ft}′ ${inch}″`;
}
return `${(cm / 100).toFixed(2)} m`;
}
/**
* Canonical key of a segment (independent of direction).
* `prec` = decimals used to compare coordinates: the default 1 suits render units,
* normalized (0..1) coordinates need more (see roomEdges).
*/
export function segKey(a: number[], b: number[], prec = 1): string {
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
}
/**
* Wall segments derived from room outlines (normalized coordinates in and out).
*
* A line has no independent existence on the plan: it can only be an edge of a closed
* 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 || []) {
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];
const k = segKey(a, b, 5);
if (seen.has(k)) continue;
seen.add(k);
out.push([a[0], a[1], b[0], b[1]]);
}
}
return out;
}
/** Point equality within a tolerance. */
@@ -35,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().
+19
View File
@@ -236,6 +236,25 @@ export const cardStyles = css`
z-index: 1;
}
.roomlabel:active { cursor: grabbing; }
.measurelayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.measurelabel {
position: absolute;
transform: translate(12px, -150%);
font-size: 12px;
font-weight: 600;
padding: 1px 6px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
white-space: nowrap;
pointer-events: none;
user-select: none;
z-index: 3;
}
.rlabel {
fill: var(--hp-muted);
font-size: 15px;
+11
View File
@@ -269,3 +269,14 @@ test('buildDevices: humidity entity marker shows a humidity badge (item.hum), wa
assert.equal(hum.hum, 55);
assert.equal(hum.temp, undefined);
});
test('buildDevices: humidity badge is gated on device_class, not the icon (name may force another icon)', () => {
const h = mkHass({
entities: { 'sensor.mh_hum': { entity_id: 'sensor.mh_hum', device_id: 'mh', platform: 'demo' } },
// name contains "myheat" → icon rule resolves to water-boiler, NOT water-percent…
states: { 'sensor.mh_hum': { state: '45.2', attributes: { device_class: 'humidity', friendly_name: 'Myheat Влажность 1 этаж' } } },
});
const d = buildDevices(baseCtx(h, { markers: [{ id: 'e', binding: 'entity:sensor.mh_hum' }] })).find((x) => x.id === 'e');
assert.notEqual(d.icon, 'mdi:water-percent'); // name rule won the icon
assert.equal(d.hum, 45); // …but the humidity value is still shown (gated on device_class)
});
+80
View File
@@ -3,6 +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, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
} from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -258,3 +259,82 @@ test('spaceDisplayOf: temp bounds default to 20..25 and accept overrides', () =>
assert.equal(o.tempMin, 18.5);
assert.equal(o.tempMax, 23);
});
test('segmentCm: cells scaled by cm-per-cell', () => {
assert.equal(segmentCm([0, 0], [30, 40], 10, 5), 25); // 50 units / pitch 10 = 5 cells * 5cm
assert.ok(Math.abs(segmentCm([0, 0], [240, 0], 1000 / 240, 5) - 288) < 1e-9);
});
test('formatLength: metric metres with 2 decimals', () => {
assert.equal(formatLength(25, false), '0.25 m');
assert.equal(formatLength(125, false), '1.25 m');
assert.equal(formatLength(0, false), '0.00 m');
});
test('formatLength: imperial feet + inches, with inch rollover', () => {
assert.equal(formatLength(124.46, true), '4′ 1″');
assert.equal(formatLength(30.48, true), '1′ 0″');
assert.equal(formatLength(29.464, true), '1′ 0″');
});
test('roomEdges: a line exists only as a room edge; polygons and rects both yield walls', () => {
const sq = { poly: [[0, 0], [1, 0], [1, 1], [0, 1]] };
assert.equal(roomEdges([sq]).length, 4); // closed outline → 4 walls
assert.equal(roomEdges([{ x: 0, y: 0, w: 1, h: 1 }]).length, 4); // legacy rect room
assert.equal(roomEdges([]).length, 0); // no rooms → no lines at all
assert.equal(roomEdges([{ poly: [[0, 0], [1, 1]] }]).length, 0); // not a closed room → nothing
});
test('roomEdges: a wall shared by two rooms is emitted once, and survives deleting either room', () => {
const left = { id: 'a', poly: [[0, 0], [0.5, 0], [0.5, 1], [0, 1]] };
const right = { id: 'b', poly: [[0.5, 0], [1, 0], [1, 1], [0.5, 1]] }; // shares x=0.5 wall
const both = roomEdges([left, right]);
assert.equal(both.length, 7); // 4 + 4 - 1 shared, deduped regardless of direction
const shared = (segs) => segs.some((s) => s[0] === 0.5 && s[2] === 0.5);
assert.ok(shared(both));
// deleting 'left' → the shared wall stays, because 'right' still contributes it
assert.ok(shared(roomEdges([right])));
// 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);
});