mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
@@ -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
@@ -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,
|
||||||
|
|||||||
Vendored
+72
-76
File diff suppressed because one or more lines are too long
+13
-3
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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')}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user