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).
This commit is contained in:
Matysh
2026-07-16 07:27:23 +03:00
parent 4593d96955
commit 65268a7985
16 changed files with 269 additions and 258 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.18.1" VERSION = "1.19.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
@@ -31,7 +31,6 @@ async def async_get_config_entry_diagnostics(
"has_plan": bool(s.get("plan_url")), "has_plan": bool(s.get("plan_url")),
"rooms": len(s.get("rooms", [])), "rooms": len(s.get("rooms", [])),
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")), "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", []) 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", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.18.1" "version": "1.19.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("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("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
vol.Required("rooms"): [ROOM_SCHEMA], 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))], vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
}, },
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
+72 -76
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+) ## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/erase/delroom), `_path` (the current outline, State inside the card: `_markup` (mode), `_tool` (draw/delroom), `_path` (the current outline,
vertices on the GRID_N=60 grid). Clicks on the stage → `_svgPoint``_snap`. Each pair of points adds a vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint``_snap`. The outline is closed
segment to `space.segments` (dedup by key, saved via config/set with debounce). The outline is closed
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and = 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). 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 ## Integration WS API
| Command | Parameters | Response | | Command | Parameters | Response |
+18
View File
@@ -1,5 +1,23 @@
# Changelog # Changelog
## 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) ## 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 - 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 inside `.devlayer`, and `.stage.markup .devlayer { display: none }` hides that whole layer
+2 -2
View File
@@ -13,14 +13,14 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.18.1** everywhere (manifest, const.py, package.json, CARD_VERSION) | | Version | **v1.19.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 | | 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) | | 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 | | 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 | | 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 | | 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) | | 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 | 61 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 | 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) |
## Recent milestones (details in CHANGELOG.md) ## Recent milestones (details in CHANGELOG.md)
+8 -1
View File
@@ -63,7 +63,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Room markup editor ★ ## 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)
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path - [ ] 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 - [ ] 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 - [ ] 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", "name": "houseplan-card",
"version": "1.18.1", "version": "1.19.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",
+21 -88
View File
@@ -12,8 +12,8 @@ import {
type IconRule, type CompiledIconRule, type IconRule, type CompiledIconRule,
} from './rules'; } from './rules';
import { import {
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding, lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, segmentCm, formatLength, roomEdges,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
@@ -27,14 +27,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.18.1'; const CARD_VERSION = '1.19.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' | 'erase' | 'delroom'; type MarkupTool = 'draw' | '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;
@@ -80,7 +80,6 @@ class HouseplanCard extends LitElement {
private _markup = false; private _markup = false;
private _tool: MarkupTool = 'draw'; private _tool: MarkupTool = 'draw';
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid) 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 _cursorPt: number[] | null = null;
private _areaSel = ''; private _areaSel = '';
private _nameSel = ''; private _nameSel = '';
@@ -221,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 { private _undoPoint(): void {
if (!this._path.length) return; 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); 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() { public static getConfigElement() {
return document.createElement('houseplan-card-editor'); return document.createElement('houseplan-card-editor');
} }
@@ -967,11 +946,15 @@ class HouseplanCard extends LitElement {
return sp ? NORM_W / sp.aspect : NORM_W; 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[][] { private get _segments(): number[][] {
const sp = this._curSpaceCfg; const sp = this._curSpaceCfg;
const H = this._spaceH; 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 { private _toggleMarkup(): void {
@@ -1000,12 +983,17 @@ class HouseplanCard extends LitElement {
return samePoint(a, b); 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(() => { private _saveConfig = debounce(() => {
if (!this._serverCfg) return; if (!this._serverCfg) return;
this._dropLegacySegments();
this.hass this.hass
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev }) .callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
.then((r: any) => { .then((r: any) => {
@@ -1022,33 +1010,6 @@ class HouseplanCard extends LitElement {
}); });
}, 500); }, 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;
}
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);
}
private _pointInRoom(p: number[], r: RoomCfg): boolean { private _pointInRoom(p: number[], r: RoomCfg): boolean {
if (r.poly) return pointInPolygon(p, r.poly); if (r.poly) return pointInPolygon(p, r.poly);
return ( return (
@@ -1059,26 +1020,6 @@ class HouseplanCard extends LitElement {
private _markupClick(ev: MouseEvent): void { private _markupClick(ev: MouseEvent): void {
if (!this._markup) return; if (!this._markup) return;
const raw = this._svgPoint(ev); 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') { if (this._tool === 'delroom') {
const space = this._spaceModel(); const space = this._spaceModel();
const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r)); const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r));
@@ -1092,17 +1033,15 @@ class HouseplanCard extends LitElement {
this.requestUpdate(); this.requestUpdate();
return; 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 pt = this._snap(raw);
if (!this._path.length) { if (!this._path.length) {
this._path = [pt]; this._path = [pt];
this._pathSegs = [];
return; return;
} }
const last = this._path[this._path.length - 1]; const last = this._path[this._path.length - 1];
if (this._samePt(pt, last)) return; // repeated click on the same point 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]; this._path = [...this._path, pt];
// closing the outline: a click on the first vertex → the save dialog // closing the outline: a click on the first vertex → the save dialog
if (this._path.length >= 4 && this._samePt(pt, this._path[0])) { if (this._path.length >= 4 && this._samePt(pt, this._path[0])) {
@@ -1152,7 +1091,6 @@ class HouseplanCard extends LitElement {
}); });
this._saveConfig(); this._saveConfig();
this._path = []; this._path = [];
this._pathSegs = [];
const boundArea = this._areaSel; const boundArea = this._areaSel;
this._areaSel = ''; this._areaSel = '';
this._nameSel = ''; this._nameSel = '';
@@ -1188,7 +1126,6 @@ class HouseplanCard extends LitElement {
private _cancelPath(): void { private _cancelPath(): void {
this._path = []; this._path = [];
this._pathSegs = [];
this._cursorPt = null; this._cursorPt = null;
this._roomDialog = false; this._roomDialog = false;
} }
@@ -1589,7 +1526,6 @@ class HouseplanCard extends LitElement {
aspect: d.source === 'draw' ? drawAspect : 1.414, aspect: d.source === 'draw' ? drawAspect : 1.414,
view_box: [0, 0, 1, 1], view_box: [0, 0, 1, 1],
rooms: [], rooms: [],
segments: [],
}; };
cfg.spaces.push(sp); cfg.spaces.push(sp);
} else { } else {
@@ -1669,6 +1605,7 @@ class HouseplanCard extends LitElement {
user's retry starts from the fresh config instead of hitting the same user's retry starts from the fresh config instead of hitting the same
conflict again. */ conflict again. */
private async _saveConfigNow(): Promise<void> { private async _saveConfigNow(): Promise<void> {
this._dropLegacySegments();
try { try {
const r = await this.hass.callWS({ const r = await this.hass.callWS({
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev, type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
@@ -2204,10 +2141,6 @@ class HouseplanCard extends LitElement {
title=${this._t('title.markup_add')}> title=${this._t('title.markup_add')}>
<ha-icon icon="mdi:vector-polyline-plus"></ha-icon>${this._t('markup.add')} <ha-icon icon="mdi:vector-polyline-plus"></ha-icon>${this._t('markup.add')}
</button> </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')} <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')}
-2
View File
@@ -27,13 +27,11 @@
"title.configure_space": "Configure space", "title.configure_space": "Configure space",
"title.add_space": "Add space", "title.add_space": "Add space",
"title.markup_add": "Add a room: connect grid dots with lines until the outline closes", "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.markup_delroom": "Delete a room: click inside the room",
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)", "title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
"title.choose_area": "Select a Home Assistant area", "title.choose_area": "Select a Home Assistant area",
"title.need_plan": "Upload a floor-plan image", "title.need_plan": "Upload a floor-plan image",
"markup.add": "Add", "markup.add": "Add",
"markup.erase": "Erase",
"markup.delete": "Delete", "markup.delete": "Delete",
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one", "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", "markup.hint_start": "click a grid dot to start the outline",
-2
View File
@@ -27,13 +27,11 @@
"title.configure_space": "Настроить пространство", "title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство", "title.add_space": "Добавить пространство",
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура", "title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
"title.markup_erase": "Стереть линию: клик по линии",
"title.markup_delroom": "Удалить комнату: клик внутри комнаты", "title.markup_delroom": "Удалить комнату: клик внутри комнаты",
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)", "title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
"title.choose_area": "Выберите зону Home Assistant", "title.choose_area": "Выберите зону Home Assistant",
"title.need_plan": "Загрузите подложку (план этажа)", "title.need_plan": "Загрузите подложку (план этажа)",
"markup.add": "Добавить", "markup.add": "Добавить",
"markup.erase": "Стереть",
"markup.delete": "Удалить", "markup.delete": "Удалить",
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой", "markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
"markup.hint_start": "кликните точку сетки, чтобы начать контур", "markup.hint_start": "кликните точку сетки, чтобы начать контур",
+35 -3
View File
@@ -31,10 +31,42 @@ export function formatLength(cm: number, imperial: boolean): string {
return `${(cm / 100).toFixed(2)} m`; return `${(cm / 100).toFixed(2)} m`;
} }
/** Canonical key of a segment (independent of direction). */ /**
export function segKey(a: number[], b: number[]): string { * 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]; 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 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;
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. */ /** Point equality within a tolerance. */
+22 -1
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,
segmentCm, formatLength, segmentCm, formatLength, roomEdges,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
import { import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses, iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -276,3 +276,24 @@ test('formatLength: imperial feet + inches, with inch rollover', () => {
assert.equal(formatLength(30.48, true), '1 0″'); assert.equal(formatLength(30.48, true), '1 0″');
assert.equal(formatLength(29.464, 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);
});