mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
feat v1.21.0: merge and split rooms
- Merge: click a room, then a neighbour. Adjacency is decided by the RESULT, not a heuristic: mergeRooms unions the outlines and accepts only when they collapse into one hole-free outline (corner touch / apart / hole => refused). A dialog picks the surviving name+area; the kept room keeps its id so its label and devices stay put. - Split: click the room, then two wall points; the chord cuts it, live ruler on the cut. The bigger part stays the room (name/area/devices), the smaller opens the new-room dialog. The cut is applied only on confirm — Cancel leaves the room whole. - Boolean geometry via polyclip-ts (proper ESM + native types; polygon-clipping ships named types but a default-only ESM build, breaking either tsc or the runtime). Verified on the real plan, where neighbouring walls overlap collinearly rather than match exactly — the case a hand-rolled union gets wrong. Bundle 151->202 KB. +5 tests (72). Verified live: merge of Сауна+с/у -> 4-vertex outline, non-adjacent refused, split preserves area (26667 -> 13333+13333), cancel leaves the room whole.
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.20.0"
|
VERSION = "1.21.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"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.20.0"
|
"version": "1.21.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+161
-121
File diff suppressed because one or more lines are too long
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.21.0 — 2026-07-16 (merge and split rooms)
|
||||||
|
- **Merge** (toolbar "Merge"): click a room, then a neighbour. Only rooms that **share a wall**
|
||||||
|
can merge — and that is decided by the result rather than a heuristic: `mergeRooms` unions the
|
||||||
|
outlines and accepts the pair only when they collapse into ONE hole-free outline. A corner
|
||||||
|
touch, rooms apart, or a union enclosing a hole are refused. A dialog picks which name and
|
||||||
|
area survive; the kept room keeps its id, so its label position and devices stay put. The
|
||||||
|
dialog warns that the other area is released.
|
||||||
|
- **Split** (toolbar "Split"): click the room, then two points on its walls — the chord cuts it
|
||||||
|
in two, with the live ruler on the cut. **The bigger part stays the room it was** (name, area,
|
||||||
|
devices); the smaller becomes a new room and its dialog asks for name/area. Cancelling the
|
||||||
|
dialog leaves the room whole — the cut is applied only on confirm. Cuts that do not run
|
||||||
|
wall-to-wall inside the room (ends off the wall, chord leaving a concave room, a chord along a
|
||||||
|
wall) are refused.
|
||||||
|
- Boolean geometry via **polyclip-ts** (proper ESM + native types; `polygon-clipping` ships named
|
||||||
|
types but a default-only ESM build, which breaks either tsc or the runtime). Verified against
|
||||||
|
the real plan, where neighbouring walls overlap collinearly instead of matching exactly —
|
||||||
|
the case a hand-rolled union gets wrong. Bundle: 151 KB → 202 KB.
|
||||||
|
- New pure helpers: `polygonArea`, `mergeRooms`, `splitRoom`. (+5 tests: 67 → 72.)
|
||||||
|
|
||||||
## v1.20.0 — 2026-07-16 (rooms may not overlap)
|
## 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).
|
- **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
|
Being *on* a wall stays legal — neighbouring rooms share walls, and real walls overlap
|
||||||
|
|||||||
+2
-2
@@ -13,14 +13,14 @@
|
|||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.20.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
| Version | **v1.21.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 | 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) |
|
| Tests | 72 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)
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
- [ ] Rooms never overlap (v1.20.0): a click strictly inside an existing room is refused with a
|
- [ ] 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
|
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
|
- [ ] Closing an outline drawn AROUND an existing room is refused; the outline stays open
|
||||||
|
- [ ] Merge (v1.21.0): two rooms sharing a wall merge into one; the dialog picks the surviving
|
||||||
|
name/area; rooms touching only at a corner or apart are refused with a toast
|
||||||
|
- [ ] Split (v1.21.0): click a room, then two points on its walls — the bigger part keeps the
|
||||||
|
name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole
|
||||||
|
- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast
|
||||||
- [ ] 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
|
||||||
|
|||||||
Generated
+29
-3
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.15.0",
|
"version": "1.20.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.15.0",
|
"version": "1.20.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lit": "^3.1.3"
|
"lit": "^3.1.3",
|
||||||
|
"polyclip-ts": "^0.16.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
@@ -597,6 +598,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bignumber.js": {
|
||||||
|
"version": "9.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||||
|
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -797,6 +807,16 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/polyclip-ts": {
|
||||||
|
"version": "0.16.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz",
|
||||||
|
"integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bignumber.js": "^9.1.0",
|
||||||
|
"splaytree-ts": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/randombytes": {
|
"node_modules/randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
@@ -936,6 +956,12 @@
|
|||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/splaytree-ts": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==",
|
||||||
|
"license": "BDS-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/supports-preserve-symlinks-flag": {
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.20.0",
|
"version": "1.21.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",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lit": "^3.1.3"
|
"lit": "^3.1.3",
|
||||||
|
"polyclip-ts": "^0.16.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+208
-10
@@ -14,6 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||||
|
pointOnBoundary, mergeRooms, splitRoom, polygonArea,
|
||||||
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 +28,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.20.0';
|
const CARD_VERSION = '1.21.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' | 'delroom';
|
type MarkupTool = 'draw' | 'merge' | 'split' | '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;
|
||||||
@@ -81,6 +82,11 @@ class HouseplanCard extends LitElement {
|
|||||||
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 _cursorPt: number[] | null = null;
|
private _cursorPt: number[] | null = null;
|
||||||
|
private _mergeSel: string | null = null; // first room picked for a merge
|
||||||
|
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
|
||||||
|
private _splitSel: { roomId: string; a: number[] | null } | null = null; // room being cut + first wall point
|
||||||
|
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
|
||||||
|
private _pendingSplit: { roomId: string; mainPoly: number[][]; newPoly: number[][] } | null = null;
|
||||||
private _areaSel = '';
|
private _areaSel = '';
|
||||||
private _nameSel = '';
|
private _nameSel = '';
|
||||||
private _roomDialog = false;
|
private _roomDialog = false;
|
||||||
@@ -170,6 +176,9 @@ class HouseplanCard extends LitElement {
|
|||||||
_tool: { state: true },
|
_tool: { state: true },
|
||||||
_path: { state: true },
|
_path: { state: true },
|
||||||
_cursorPt: { state: true },
|
_cursorPt: { state: true },
|
||||||
|
_mergeSel: { state: true },
|
||||||
|
_mergeDialog: { state: true },
|
||||||
|
_splitSel: { state: true },
|
||||||
_areaSel: { state: true },
|
_areaSel: { state: true },
|
||||||
_nameSel: { state: true },
|
_nameSel: { state: true },
|
||||||
_roomDialog: { state: true },
|
_roomDialog: { state: true },
|
||||||
@@ -966,6 +975,10 @@ class HouseplanCard extends LitElement {
|
|||||||
this._path = [];
|
this._path = [];
|
||||||
this._cursorPt = null;
|
this._cursorPt = null;
|
||||||
this._tool = 'draw';
|
this._tool = 'draw';
|
||||||
|
this._mergeSel = null;
|
||||||
|
this._mergeDialog = null;
|
||||||
|
this._splitSel = null;
|
||||||
|
this._pendingSplit = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _svgPoint(ev: MouseEvent): number[] {
|
private _svgPoint(ev: MouseEvent): number[] {
|
||||||
@@ -1052,6 +1065,14 @@ class HouseplanCard extends LitElement {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this._tool === 'merge') {
|
||||||
|
this._mergeClick(raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._tool === 'split') {
|
||||||
|
this._splitClick(raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// draw: clicks on grid points build the outline. Nothing is written to the config
|
// 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.
|
// until the contour closes — an abandoned outline leaves no lines behind.
|
||||||
const pt = this._snap(raw);
|
const pt = this._snap(raw);
|
||||||
@@ -1088,14 +1109,103 @@ class HouseplanCard extends LitElement {
|
|||||||
this._path = [...this._path, pt];
|
this._path = [...this._path, pt];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Merge: first click picks a room, second picks the room to merge it with. */
|
||||||
|
private _mergeClick(raw: number[]): void {
|
||||||
|
const rooms = this._spaceModel().rooms;
|
||||||
|
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
|
||||||
|
if (!hit?.id) return;
|
||||||
|
const hitId = hit.id;
|
||||||
|
if (!this._mergeSel || this._mergeSel === hitId) {
|
||||||
|
this._mergeSel = this._mergeSel === hitId ? null : hitId; // click again = deselect
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = rooms.find((r) => r.id === this._mergeSel);
|
||||||
|
const pa = a ? roomPoly(a) : null;
|
||||||
|
const pb = roomPoly(hit);
|
||||||
|
const merged = pa && pb ? mergeRooms(pa, pb) : null;
|
||||||
|
if (!merged) {
|
||||||
|
// only rooms sharing a wall collapse into one outline (see mergeRooms)
|
||||||
|
this._showToast(this._t('toast.merge_not_adjacent'));
|
||||||
|
this._mergeSel = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._mergeDialog = { aId: this._mergeSel, bId: hitId, poly: merged, pick: 'a' };
|
||||||
|
this._mergeSel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _commitMerge(): void {
|
||||||
|
const d = this._mergeDialog;
|
||||||
|
const sp = this._curSpaceCfg;
|
||||||
|
if (!d || !sp) return;
|
||||||
|
const H = this._spaceH;
|
||||||
|
const keepId = d.pick === 'a' ? d.aId : d.bId;
|
||||||
|
const dropId = d.pick === 'a' ? d.bId : d.aId;
|
||||||
|
const keep = sp.rooms.find((r: any) => r.id === keepId);
|
||||||
|
if (!keep) {
|
||||||
|
this._mergeDialog = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// the kept room keeps its id, so its label position and devices stay put
|
||||||
|
keep.poly = d.poly.map((p) => [p[0] / NORM_W, p[1] / H]);
|
||||||
|
delete keep.x; delete keep.y; delete keep.w; delete keep.h; // a merged room is never a rect
|
||||||
|
sp.rooms = sp.rooms.filter((r: any) => r.id !== dropId);
|
||||||
|
this._saveConfig();
|
||||||
|
this._mergeDialog = null;
|
||||||
|
this._regSignature = '';
|
||||||
|
this._maybeRebuildDevices();
|
||||||
|
this._showToast(this._t('toast.rooms_merged', { name: keep.name || '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split: click the room, then two points on its walls. */
|
||||||
|
private _splitClick(raw: number[]): void {
|
||||||
|
const rooms = this._spaceModel().rooms;
|
||||||
|
if (!this._splitSel) {
|
||||||
|
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
|
||||||
|
if (!hit?.id) return;
|
||||||
|
this._splitSel = { roomId: hit.id, a: null };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const room = rooms.find((r) => r.id === this._splitSel!.roomId);
|
||||||
|
const poly = room ? roomPoly(room) : null;
|
||||||
|
if (!room || !poly) {
|
||||||
|
this._splitSel = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const eps = this._gridPitch * 0.02;
|
||||||
|
const pt = this._snap(raw);
|
||||||
|
if (!pointOnBoundary(pt, poly, eps)) {
|
||||||
|
this._showToast(this._t('toast.split_pick_wall'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this._splitSel.a) {
|
||||||
|
this._splitSel = { ...this._splitSel, a: pt };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = splitRoom(poly, this._splitSel.a, pt, eps);
|
||||||
|
if (!parts) {
|
||||||
|
this._showToast(this._t('toast.split_bad_cut'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// the bigger part stays the room it was — name, area and devices go with it
|
||||||
|
const [p1, p2] = parts;
|
||||||
|
const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2;
|
||||||
|
const fresh = main === p1 ? p2 : p1;
|
||||||
|
this._pendingSplit = { roomId: room.id!, mainPoly: main, newPoly: fresh };
|
||||||
|
this._cursorPt = null;
|
||||||
|
this._nameSel = '';
|
||||||
|
this._areaSel = '';
|
||||||
|
this._roomDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
private get _contourClosed(): boolean {
|
private get _contourClosed(): boolean {
|
||||||
return this._path.length >= 4 && this._samePt(this._path[0], this._path[this._path.length - 1]);
|
return this._path.length >= 4 && this._samePt(this._path[0], this._path[this._path.length - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _markupMove(ev: MouseEvent): void {
|
private _markupMove(ev: MouseEvent): void {
|
||||||
if (!this._markup || this._tool !== 'draw' || !this._path.length || this._contourClosed) {
|
if (!this._markup) return;
|
||||||
return;
|
const drawing = this._tool === 'draw' && this._path.length && !this._contourClosed;
|
||||||
}
|
const cutting = this._tool === 'split' && !!this._splitSel?.a;
|
||||||
|
if (!drawing && !cutting) return;
|
||||||
this._cursorPt = this._snap(this._svgPoint(ev));
|
this._cursorPt = this._snap(this._svgPoint(ev));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1113,11 +1223,26 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _commitRoom(): void {
|
private _commitRoom(): void {
|
||||||
if (!this._contourClosed) return;
|
|
||||||
const sp = this._curSpaceCfg;
|
const sp = this._curSpaceCfg;
|
||||||
if (!sp) return;
|
if (!sp) return;
|
||||||
const H = this._spaceH;
|
const H = this._spaceH;
|
||||||
const verts = this._path.slice(0, -1); // without the duplicated closing vertex
|
let verts: number[][];
|
||||||
|
if (this._pendingSplit) {
|
||||||
|
// apply the cut now: the bigger part keeps the original room, this dialog names the rest
|
||||||
|
const main = sp.rooms.find((r: any) => r.id === this._pendingSplit!.roomId);
|
||||||
|
if (!main) {
|
||||||
|
this._pendingSplit = null;
|
||||||
|
this._splitSel = null;
|
||||||
|
this._roomDialog = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
main.poly = this._pendingSplit.mainPoly.map((p) => [p[0] / NORM_W, p[1] / H]);
|
||||||
|
delete main.x; delete main.y; delete main.w; delete main.h;
|
||||||
|
verts = this._pendingSplit.newPoly;
|
||||||
|
} else {
|
||||||
|
if (!this._contourClosed) return;
|
||||||
|
verts = this._path.slice(0, -1); // without the duplicated closing vertex
|
||||||
|
}
|
||||||
const areaName = this._areaSel ? this.hass.areas[this._areaSel]?.name : '';
|
const areaName = this._areaSel ? this.hass.areas[this._areaSel]?.name : '';
|
||||||
sp.rooms.push({
|
sp.rooms.push({
|
||||||
id: 'r' + Date.now().toString(36),
|
id: 'r' + Date.now().toString(36),
|
||||||
@@ -1127,6 +1252,8 @@ class HouseplanCard extends LitElement {
|
|||||||
});
|
});
|
||||||
this._saveConfig();
|
this._saveConfig();
|
||||||
this._path = [];
|
this._path = [];
|
||||||
|
this._pendingSplit = null;
|
||||||
|
this._splitSel = null;
|
||||||
const boundArea = this._areaSel;
|
const boundArea = this._areaSel;
|
||||||
this._areaSel = '';
|
this._areaSel = '';
|
||||||
this._nameSel = '';
|
this._nameSel = '';
|
||||||
@@ -1164,11 +1291,21 @@ class HouseplanCard extends LitElement {
|
|||||||
this._path = [];
|
this._path = [];
|
||||||
this._cursorPt = null;
|
this._cursorPt = null;
|
||||||
this._roomDialog = false;
|
this._roomDialog = false;
|
||||||
|
this._pendingSplit = null;
|
||||||
|
this._splitSel = null;
|
||||||
|
this._mergeSel = null;
|
||||||
|
this._mergeDialog = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
|
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
|
||||||
private _roomDialogCancel(): void {
|
private _roomDialogCancel(): void {
|
||||||
this._roomDialog = false;
|
this._roomDialog = false;
|
||||||
|
if (this._pendingSplit) {
|
||||||
|
// nothing was applied yet — drop the cut entirely, the room stays whole
|
||||||
|
this._pendingSplit = null;
|
||||||
|
this._splitSel = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._undoPoint();
|
this._undoPoint();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1953,6 +2090,8 @@ class HouseplanCard extends LitElement {
|
|||||||
: nothing}
|
: nothing}
|
||||||
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
||||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
||||||
|
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
||||||
|
cls += ' picked';
|
||||||
let style = '';
|
let style = '';
|
||||||
if (!this._markup && (disp.showBorders || disp.fill !== 'none')) {
|
if (!this._markup && (disp.showBorders || disp.fill !== 'none')) {
|
||||||
cls += ' styled';
|
cls += ' styled';
|
||||||
@@ -1999,7 +2138,7 @@ class HouseplanCard extends LitElement {
|
|||||||
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
${this._markup && this._tool === 'draw' && this._path.length && this._cursorPt && !this._contourClosed
|
${this._measureAnchor
|
||||||
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
|
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
@@ -2009,6 +2148,7 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this._roomDialog ? this._renderRoomDialog() : nothing}
|
${this._roomDialog ? this._renderRoomDialog() : nothing}
|
||||||
|
${this._mergeDialog ? this._renderMergeDialog() : nothing}
|
||||||
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
||||||
${this._markerDialog ? this._renderMarkerDialog() : nothing}
|
${this._markerDialog ? this._renderMarkerDialog() : nothing}
|
||||||
${this._infoCard ? this._renderInfoCard() : nothing}
|
${this._infoCard ? this._renderInfoCard() : nothing}
|
||||||
@@ -2122,9 +2262,18 @@ class HouseplanCard extends LitElement {
|
|||||||
>${r.name}</div>`;
|
>${r.name}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Length badge that follows the cursor while drawing the current segment. */
|
/** Where the live measurement starts: the last outline point, or the first split point. */
|
||||||
|
private get _measureAnchor(): number[] | null {
|
||||||
|
if (!this._markup || !this._cursorPt) return null;
|
||||||
|
if (this._tool === 'draw' && this._path.length && !this._contourClosed)
|
||||||
|
return this._path[this._path.length - 1];
|
||||||
|
if (this._tool === 'split' && this._splitSel?.a) return this._splitSel.a;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Length badge that follows the cursor while drawing a segment or a cut. */
|
||||||
private _renderMeasureLabel(view: { x: number; y: number; w: number; h: number }): TemplateResult {
|
private _renderMeasureLabel(view: { x: number; y: number; w: number; h: number }): TemplateResult {
|
||||||
const a = this._path[this._path.length - 1];
|
const a = this._measureAnchor!;
|
||||||
const b = this._cursorPt!;
|
const b = this._cursorPt!;
|
||||||
const left = ((b[0] - view.x) / view.w) * 100;
|
const left = ((b[0] - view.x) / view.w) * 100;
|
||||||
const top = ((b[1] - view.y) / view.h) * 100;
|
const top = ((b[1] - view.y) / view.h) * 100;
|
||||||
@@ -2167,6 +2316,13 @@ class HouseplanCard extends LitElement {
|
|||||||
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
|
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
|
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
|
||||||
|
${this._tool === 'split' && this._splitSel?.a
|
||||||
|
? svg`<circle class="vertex first" cx="${this._splitSel.a[0]}" cy="${this._splitSel.a[1]}" r="${g * 0.22}"></circle>
|
||||||
|
${this._cursorPt
|
||||||
|
? svg`<line class="preview" x1="${this._splitSel.a[0]}" y1="${this._splitSel.a[1]}"
|
||||||
|
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
|
||||||
|
: nothing}`
|
||||||
|
: nothing}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2177,6 +2333,16 @@ 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 === 'merge' ? 'on' : ''}"
|
||||||
|
@click=${() => { this._tool = 'merge'; this._cancelPath(); this._tool = 'merge'; }}
|
||||||
|
title=${this._t('title.markup_merge')}>
|
||||||
|
<ha-icon icon="mdi:vector-union"></ha-icon>${this._t('markup.merge')}
|
||||||
|
</button>
|
||||||
|
<button class="btn ${this._tool === 'split' ? 'on' : ''}"
|
||||||
|
@click=${() => { this._tool = 'split'; this._cancelPath(); this._tool = 'split'; }}
|
||||||
|
title=${this._t('title.markup_split')}>
|
||||||
|
<ha-icon icon="mdi:vector-polyline-remove"></ha-icon>${this._t('markup.split')}
|
||||||
|
</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')}
|
||||||
@@ -2477,6 +2643,38 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _renderMergeDialog(): TemplateResult {
|
||||||
|
const d = this._mergeDialog!;
|
||||||
|
const rooms = this._spaceModel().rooms;
|
||||||
|
const opt = (id: string, key: 'a' | 'b') => {
|
||||||
|
const r = rooms.find((x) => x.id === id);
|
||||||
|
const area = r?.area ? this.hass.areas[r.area]?.name : null;
|
||||||
|
return html`<label class="srcrow">
|
||||||
|
<input type="radio" name="mergekeep" .checked=${d.pick === key}
|
||||||
|
@change=${() => (this._mergeDialog = { ...d, pick: key })} />
|
||||||
|
<span>${r?.name || ''} <span class="muted">· ${area || this._t('merge.no_area')}</span></span>
|
||||||
|
</label>`;
|
||||||
|
};
|
||||||
|
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
||||||
|
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||||
|
<div class="hd"><ha-icon icon="mdi:vector-union"></ha-icon>${this._t('merge.header')}</div>
|
||||||
|
<div class="body">
|
||||||
|
<p class="muted">${this._t('merge.hint')}</p>
|
||||||
|
<label>${this._t('merge.keep')}</label>
|
||||||
|
${opt(d.aId, 'a')}
|
||||||
|
${opt(d.bId, 'b')}
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn ghost" @click=${() => (this._mergeDialog = null)}>${this._t('btn.cancel')}</button>
|
||||||
|
<button class="btn on" @click=${this._commitMerge}>
|
||||||
|
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
private _renderRoomDialog(): TemplateResult {
|
private _renderRoomDialog(): TemplateResult {
|
||||||
const areas = this._freeAreas;
|
const areas = this._freeAreas;
|
||||||
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -27,11 +27,15 @@
|
|||||||
"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_merge": "Merge rooms: click one room, then the neighbour it shares a wall with",
|
||||||
|
"title.markup_split": "Split a room: click the room, then two points on its walls",
|
||||||
"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.merge": "Merge",
|
||||||
|
"markup.split": "Split",
|
||||||
"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",
|
||||||
@@ -94,6 +98,15 @@
|
|||||||
"toast.cfg_save_failed": "Failed to save config: {err}",
|
"toast.cfg_save_failed": "Failed to save config: {err}",
|
||||||
"toast.point_in_room": "That point is inside room “{name}” — rooms must not overlap",
|
"toast.point_in_room": "That point is inside room “{name}” — rooms must not overlap",
|
||||||
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
|
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
|
||||||
|
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
|
||||||
|
"toast.rooms_merged": "Rooms merged into “{name}”",
|
||||||
|
"toast.split_pick_wall": "Click a grid dot on the room’s wall",
|
||||||
|
"toast.split_bad_cut": "The cut must be a straight line from wall to wall, inside the room",
|
||||||
|
"merge.header": "Merge rooms",
|
||||||
|
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
|
||||||
|
"merge.keep": "Keep",
|
||||||
|
"merge.no_area": "no area",
|
||||||
|
"room.split_header": "New room from the split",
|
||||||
"toast.room_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
|
"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.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",
|
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
|
||||||
|
|||||||
@@ -27,11 +27,15 @@
|
|||||||
"title.configure_space": "Настроить пространство",
|
"title.configure_space": "Настроить пространство",
|
||||||
"title.add_space": "Добавить пространство",
|
"title.add_space": "Добавить пространство",
|
||||||
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
|
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
|
||||||
|
"title.markup_merge": "Объединить комнаты: клик по одной, затем по соседней с общей стеной",
|
||||||
|
"title.markup_split": "Разделить комнату: клик по комнате, затем две точки на её стенах",
|
||||||
"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.merge": "Объединить",
|
||||||
|
"markup.split": "Разделить",
|
||||||
"markup.delete": "Удалить",
|
"markup.delete": "Удалить",
|
||||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||||
@@ -94,6 +98,15 @@
|
|||||||
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
||||||
"toast.point_in_room": "Точка внутри комнаты «{name}» — комнаты не должны накладываться",
|
"toast.point_in_room": "Точка внутри комнаты «{name}» — комнаты не должны накладываться",
|
||||||
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
|
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
|
||||||
|
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
|
||||||
|
"toast.rooms_merged": "Комнаты объединены в «{name}»",
|
||||||
|
"toast.split_pick_wall": "Кликните по узлу сетки на стене комнаты",
|
||||||
|
"toast.split_bad_cut": "Разрез — прямая от стены до стены внутри комнаты",
|
||||||
|
"merge.header": "Объединение комнат",
|
||||||
|
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
|
||||||
|
"merge.keep": "Оставить",
|
||||||
|
"merge.no_area": "без зоны",
|
||||||
|
"room.split_header": "Новая комната после разделения",
|
||||||
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
|
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
|
||||||
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
|
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
|
||||||
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
|
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Pure functions with no Lit/DOM dependencies — easy to cover with unit tests.
|
* Pure functions with no Lit/DOM dependencies — easy to cover with unit tests.
|
||||||
*/
|
*/
|
||||||
|
import { union } from 'polyclip-ts';
|
||||||
|
|
||||||
/** Zigbee LQI color: ≤40 — red, ≥180 — green, in between — an hsl gradient. */
|
/** Zigbee LQI color: ≤40 — red, ≥180 — green, in between — an hsl gradient. */
|
||||||
export function lqiColor(lqi: number): string {
|
export function lqiColor(lqi: number): string {
|
||||||
@@ -170,6 +171,88 @@ export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean
|
|||||||
return coversArea(a, b, eps) || coversArea(b, a, eps);
|
return coversArea(a, b, eps) || coversArea(b, a, eps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shoelace area of an outline (absolute value). */
|
||||||
|
export function polygonArea(poly: number[][]): number {
|
||||||
|
if (!poly || poly.length < 3) return 0;
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < poly.length; i++) {
|
||||||
|
const a = poly[i];
|
||||||
|
const b = poly[(i + 1) % poly.length];
|
||||||
|
s += a[0] * b[1] - b[0] * a[1];
|
||||||
|
}
|
||||||
|
return Math.abs(s) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closedRing(poly: number[][]): number[][][] {
|
||||||
|
return [[...poly.map((p) => [p[0], p[1]]), [poly[0][0], poly[0][1]]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of two room outlines, or null when they may not be merged.
|
||||||
|
*
|
||||||
|
* "Adjacent" is decided by the result rather than by a separate heuristic: only rooms that
|
||||||
|
* genuinely share a wall (fully or partially — real walls overlap collinearly rather than
|
||||||
|
* match exactly) collapse into ONE hole-free outline. Rooms that merely touch at a corner,
|
||||||
|
* that are apart, or whose union would enclose a hole do not, and are refused.
|
||||||
|
*/
|
||||||
|
export function mergeRooms(a: number[][], b: number[][]): number[][] | null {
|
||||||
|
if (!a || !b || a.length < 3 || b.length < 3) return null;
|
||||||
|
const res = union(closedRing(a) as any, closedRing(b) as any);
|
||||||
|
if (res.length !== 1) return null; // two pieces → not adjacent
|
||||||
|
if (res[0].length !== 1) return null; // a ring plus holes → not a simple room
|
||||||
|
const pts = res[0][0].slice(0, -1).map((p: number[]) => [p[0], p[1]]); // drop the closing point
|
||||||
|
return pts.length >= 3 ? pts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the outline edge that p sits on, or -1. */
|
||||||
|
function edgeIndexOf(poly: number[][], p: number[], eps: number): number {
|
||||||
|
for (let i = 0; i < poly.length; i++)
|
||||||
|
if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropRepeats(pts: number[][], eps: number): number[][] {
|
||||||
|
const out: number[][] = [];
|
||||||
|
for (const p of pts) if (!out.length || !samePoint(out[out.length - 1], p, eps)) out.push(p);
|
||||||
|
if (out.length > 1 && samePoint(out[0], out[out.length - 1], eps)) out.pop();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut a room in two with a straight chord between two points on its walls.
|
||||||
|
* Returns the two parts, or null when the cut is not a clean wall-to-wall chord:
|
||||||
|
* an end that is not on a wall, a chord that leaves the room (concave outlines) or that
|
||||||
|
* runs along a wall and would carve off a zero-area sliver.
|
||||||
|
*/
|
||||||
|
export function splitRoom(
|
||||||
|
poly: number[][], a: number[], b: number[], eps = 1e-6,
|
||||||
|
): [number[][], number[][]] | null {
|
||||||
|
if (!poly || poly.length < 3 || samePoint(a, b, eps)) return null;
|
||||||
|
const ia = edgeIndexOf(poly, a, eps);
|
||||||
|
const ib = edgeIndexOf(poly, b, eps);
|
||||||
|
if (ia < 0 || ib < 0) return null; // an end is not on a wall
|
||||||
|
for (let i = 0; i < poly.length; i++)
|
||||||
|
if (segmentsProperlyCross(a, b, poly[i], poly[(i + 1) % poly.length])) return null; // leaves the room
|
||||||
|
// a chord lying along a wall has its midpoint ON the outline, not inside it
|
||||||
|
if (!pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps)) return null;
|
||||||
|
const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
|
||||||
|
const pts: number[][] = [from];
|
||||||
|
let i = (fromIdx + 1) % poly.length;
|
||||||
|
for (let guard = 0; guard <= poly.length; guard++) {
|
||||||
|
pts.push(poly[i]);
|
||||||
|
if (i === toIdx) break;
|
||||||
|
i = (i + 1) % poly.length;
|
||||||
|
}
|
||||||
|
pts.push(to);
|
||||||
|
return dropRepeats(pts, eps);
|
||||||
|
};
|
||||||
|
const p1 = walk(a, ia, b, ib);
|
||||||
|
const p2 = walk(b, ib, a, ia);
|
||||||
|
if (p1.length < 3 || p2.length < 3) return null;
|
||||||
|
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
|
||||||
|
return [p1, p2];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marker id by binding: device → device_id, entity → 'lg_'+entity_id,
|
* 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().
|
* virtual → the passed-in existing (if it is already a v_ marker) or a new one via newId().
|
||||||
|
|||||||
@@ -274,6 +274,11 @@ export const cardStyles = css`
|
|||||||
.stage.markup .devlayer {
|
.stage.markup .devlayer {
|
||||||
display: none; /* in markup mode icons must not get in the way */
|
display: none; /* in markup mode icons must not get in the way */
|
||||||
}
|
}
|
||||||
|
.room.picked {
|
||||||
|
stroke: #ffc14d;
|
||||||
|
stroke-width: 3;
|
||||||
|
fill: rgba(255, 193, 77, 0.25);
|
||||||
|
}
|
||||||
.room.outlined {
|
.room.outlined {
|
||||||
stroke: rgba(62, 166, 255, 0.55);
|
stroke: rgba(62, 166, 255, 0.55);
|
||||||
fill: rgba(62, 166, 255, 0.06);
|
fill: rgba(62, 166, 255, 0.06);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||||
|
mergeRooms, splitRoom, polygonArea,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
import {
|
import {
|
||||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||||
@@ -338,3 +339,51 @@ test('roomPoly: polygon rooms as-is, legacy rect rooms as four corners', () => {
|
|||||||
assert.deepEqual(roomPoly({ x: 0, y: 0, w: 2, h: 2 }), SQ);
|
assert.deepEqual(roomPoly({ x: 0, y: 0, w: 2, h: 2 }), SQ);
|
||||||
assert.equal(roomPoly({}), null);
|
assert.equal(roomPoly({}), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('polygonArea: shoelace, orientation-independent', () => {
|
||||||
|
assert.equal(polygonArea([[0, 0], [2, 0], [2, 2], [0, 2]]), 4);
|
||||||
|
assert.equal(polygonArea([[0, 0], [0, 2], [2, 2], [2, 0]]), 4); // reversed winding
|
||||||
|
assert.equal(polygonArea([[0, 0], [1, 1]]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeRooms: only rooms sharing a wall merge; the union is one simple outline', () => {
|
||||||
|
const a = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||||
|
const full = mergeRooms(a, [[2, 0], [4, 0], [4, 2], [2, 2]]); // whole wall shared
|
||||||
|
assert.equal(polygonArea(full), 8);
|
||||||
|
assert.equal(full.length, 4); // collapses to one rectangle
|
||||||
|
// the neighbour's wall is LONGER than ours — the real dacha case
|
||||||
|
const partial = mergeRooms(a, [[2, -1], [4, -1], [4, 3], [2, 3]]);
|
||||||
|
assert.equal(polygonArea(partial), 4 + 8);
|
||||||
|
assert.ok(partial.length >= 6); // an L/T-shaped outline
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeRooms: refuses a corner touch, rooms apart, and a union with a hole', () => {
|
||||||
|
const a = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||||
|
assert.equal(mergeRooms(a, [[2, 2], [4, 2], [4, 4], [2, 4]]), null); // corner only
|
||||||
|
assert.equal(mergeRooms(a, [[5, 5], [6, 5], [6, 6], [5, 6]]), null); // apart
|
||||||
|
const u = [[0, 0], [6, 0], [6, 2], [4, 2], [4, 6], [6, 6], [6, 8], [0, 8]];
|
||||||
|
assert.equal(mergeRooms(u, [[6, 2], [8, 2], [8, 6], [6, 6]]), null); // would enclose a hole
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splitRoom: a wall-to-wall chord cuts the room in two, areas are preserved', () => {
|
||||||
|
const sq = [[0, 0], [4, 0], [4, 4], [0, 4]];
|
||||||
|
const parts = splitRoom(sq, [0, 2], [4, 2]); // straight across
|
||||||
|
assert.ok(parts);
|
||||||
|
assert.equal(polygonArea(parts[0]) + polygonArea(parts[1]), polygonArea(sq));
|
||||||
|
assert.equal(polygonArea(parts[0]), 8);
|
||||||
|
assert.equal(polygonArea(parts[1]), 8);
|
||||||
|
// an off-centre cut → a bigger and a smaller part (the bigger one keeps the room)
|
||||||
|
const off = splitRoom(sq, [0, 1], [4, 1]);
|
||||||
|
const areas = [polygonArea(off[0]), polygonArea(off[1])].sort((x, y) => x - y);
|
||||||
|
assert.deepEqual(areas, [4, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splitRoom: refuses cuts that are not clean wall-to-wall chords', () => {
|
||||||
|
const sq = [[0, 0], [4, 0], [4, 4], [0, 4]];
|
||||||
|
assert.equal(splitRoom(sq, [1, 1], [3, 3]), null); // ends not on a wall
|
||||||
|
assert.equal(splitRoom(sq, [0, 2], [0, 2]), null); // same point
|
||||||
|
assert.equal(splitRoom(sq, [0, 0], [4, 0]), null); // along a wall → zero-area sliver
|
||||||
|
// an L-shaped room: a chord that would leave the room is refused
|
||||||
|
const L = [[0, 0], [4, 0], [4, 2], [2, 2], [2, 4], [0, 4]];
|
||||||
|
assert.equal(splitRoom(L, [4, 1], [1, 4]), null);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user