mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
Merge audit-4: audit of v1.16-v1.21, split wall-snap fix, docs v1.21.1
This commit is contained in:
@@ -120,6 +120,14 @@ As soon as the outline is closed, the room-save dialog appears. Here you need to
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm).
|
||||||
|
|
||||||
|
Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later:
|
||||||
|
|
||||||
|
- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive.
|
||||||
|
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
|
||||||
|
|
||||||
|
|
||||||
### Step 3. Devices appear by themselves
|
### Step 3. Devices appear by themselves
|
||||||
|
|
||||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ title: План дома
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
|
||||||
|
|
||||||
|
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
|
||||||
|
|
||||||
|
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
|
||||||
|
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
|
||||||
|
|
||||||
|
|
||||||
### Шаг 3. Устройства появляются сами
|
### Шаг 3. Устройства появляются сами
|
||||||
|
|
||||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||||
|
|||||||
@@ -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.21.0"
|
VERSION = "1.21.1"
|
||||||
|
|
||||||
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.21.0"
|
"version": "1.21.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords).
|
||||||
|
import { launch } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||||
|
const restore = () => page.evaluate((s) => {
|
||||||
|
const c = window.__card; c._serverCfg = JSON.parse(s);
|
||||||
|
c._mergeSel = null; c._mergeDialog = null; c._splitSel = null; c._pendingSplit = null; c._roomDialog = false;
|
||||||
|
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); return c.updateComplete && true;
|
||||||
|
}, snap);
|
||||||
|
// norm→render helper mirrors what _markupClick passes to handlers
|
||||||
|
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||||
|
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||||
|
}, [nx, ny]);
|
||||||
|
const S = () => page.evaluate(() => {
|
||||||
|
const c = window.__card;
|
||||||
|
return { rooms: c._serverCfg.spaces.find((s) => s.id==='f1').rooms.map((r)=>({id:r.id,name:r.name,area:r.area})),
|
||||||
|
mergeDlg: !!c._mergeDialog, roomDlg: !!c._roomDialog, pendingSplit: !!c._pendingSplit, toast: c._toast };
|
||||||
|
});
|
||||||
|
const enter = (t) => page.evaluate((t)=>{const c=window.__card; if(!c._markup)c._toggleMarkup(); c._tool=t; return true;}, t);
|
||||||
|
const out = {};
|
||||||
|
|
||||||
|
// MERGE living(r1)+kitchen(r2) — share wall x=0.55, y∈[0.05..0.45]
|
||||||
|
await enter('merge');
|
||||||
|
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.3,0.3));
|
||||||
|
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.75,0.25));
|
||||||
|
out.mergeDialog = (await S()).mergeDlg;
|
||||||
|
await page.evaluate(()=>window.__card._commitMerge());
|
||||||
|
let s = await S();
|
||||||
|
out.mergeRooms = s.rooms.length; // 4→3
|
||||||
|
out.mergeKeptR1 = s.rooms.some(r=>r.id==='r1');
|
||||||
|
out.mergeDroppedR2 = !s.rooms.some(r=>r.id==='r2');
|
||||||
|
await restore();
|
||||||
|
|
||||||
|
// MERGE non-adjacent: living(r1)+garden's-only? use r2+r4 (kitchen & hallway don't share a wall)
|
||||||
|
await enter('merge');
|
||||||
|
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.75,0.25)); // kitchen
|
||||||
|
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.3,0.8)); // hallway
|
||||||
|
s = await S();
|
||||||
|
out.nonAdjRefused = !s.mergeDlg && s.rooms.length===4;
|
||||||
|
out.nonAdjToast = !!s.toast;
|
||||||
|
await restore();
|
||||||
|
|
||||||
|
// grid-aligned test room (real rooms are snapped; demo rooms are not)
|
||||||
|
await page.evaluate(()=>{
|
||||||
|
const c=window.__card;
|
||||||
|
c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
|
||||||
|
{id:'rg', name:'GridRoom', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
|
||||||
|
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate();
|
||||||
|
});
|
||||||
|
// SPLIT rg vertical chord x=0.25 (all on grid nodes)
|
||||||
|
await enter('split');
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28)); // pick rg
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625)); // wall pt a
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5)); // wall pt b
|
||||||
|
out.splitPending = (await S()).pendingSplit;
|
||||||
|
out.splitDialog = (await S()).roomDlg;
|
||||||
|
// cancel keeps it whole
|
||||||
|
await page.evaluate(()=>window.__card._roomDialogCancel());
|
||||||
|
out.cancelWhole = (await S()).rooms.length===4;
|
||||||
|
// redo + confirm with a name (no area)
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
|
||||||
|
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
|
||||||
|
s = await S();
|
||||||
|
out.splitRooms = s.rooms.length; // 5
|
||||||
|
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
|
||||||
|
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
|
||||||
|
await restore();
|
||||||
|
|
||||||
|
// SPLIT along a wall → refused (both pts on top wall of rg)
|
||||||
|
await page.evaluate(()=>{const c=window.__card; c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
|
||||||
|
{id:'rg2', name:'G2', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
|
||||||
|
c._regSignature=''; c._maybeRebuildDevices();});
|
||||||
|
await enter('split');
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.1,0.0625));
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.4,0.0625));
|
||||||
|
s = await S();
|
||||||
|
out.alongWallRefused = !s.roomDlg && !s.pendingSplit;
|
||||||
|
|
||||||
|
console.log(JSON.stringify(out,null,1));
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Split now works on a non-grid-aligned room (imported/legacy polygons).
|
||||||
|
import { launch } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||||
|
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||||
|
}, [nx, ny]);
|
||||||
|
const out = {};
|
||||||
|
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._toggleMarkup(); c._tool='split';});
|
||||||
|
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall
|
||||||
|
out.pending = await page.evaluate(()=>!!window.__card._pendingSplit);
|
||||||
|
out.dialog = await page.evaluate(()=>!!window.__card._roomDialog);
|
||||||
|
// accidental click in the middle of the room must NOT become a wall point
|
||||||
|
await page.evaluate(()=>{const c=window.__card; c._roomDialog=false; c._pendingSplit=null; c._splitSel=null;});
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick again
|
||||||
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // centre click = miss
|
||||||
|
out.centreRefused = await page.evaluate(()=>window.__card._splitSel?.a == null);
|
||||||
|
console.log(JSON.stringify(out));
|
||||||
|
await browser.close();
|
||||||
+210
-129
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.21.1 — 2026-07-16 (audit: split snaps to the wall, docs for merge/split)
|
||||||
|
- **Fix (found in audit):** Split required each click to land on a grid node, so it
|
||||||
|
silently refused rooms whose walls are not grid-aligned (imported or older polygons)
|
||||||
|
— the "pick a wall" toast fired no matter where you clicked. The click now snaps to
|
||||||
|
the room's nearest wall (`closestPointOnBoundary`) instead of the grid, with the pull
|
||||||
|
capped at ~6 grid cells so an accidental click in the middle of a room stays a miss
|
||||||
|
rather than becoming a wall point the user never meant; `splitRoom()` still rejects
|
||||||
|
a cut that is not a clean wall-to-wall chord. Also makes aiming easier on the fine
|
||||||
|
(240-cell) grid.
|
||||||
|
- **Docs:** README (en + ru) now documents room Merge, Split, the drawing ruler and the
|
||||||
|
per-space scale — these v1.18–v1.21 features were shipped without user-facing docs.
|
||||||
|
`docs/TESTING.md` gained merge/split rows and a fresh self-run record.
|
||||||
|
- New smokes `demo/smoke_merge_split.mjs` and `smoke_split_nonsnap.mjs`;
|
||||||
|
`closestPointOnBoundary` unit-tested. (+1 test: 72 → 73.)
|
||||||
|
|
||||||
## v1.21.0 — 2026-07-16 (merge and split rooms)
|
## 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**
|
- **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
|
can merge — and that is decided by the result rather than a heuristic: `mergeRooms` unions the
|
||||||
|
|||||||
+2
-2
@@ -13,14 +13,14 @@
|
|||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.21.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
| Version | **v1.21.1** 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 | 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) |
|
| Tests | 73 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)
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
- [ ] Split (v1.21.0): click a room, then two points on its walls — the bigger part keeps the
|
- [ ] 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
|
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
|
- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast
|
||||||
|
- [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms
|
||||||
|
(imported/legacy polygons), not only on rooms drawn on the current grid [auto]
|
||||||
|
- [ ] Split: a click far from any wall (middle of the room) is a miss with a toast —
|
||||||
|
the wall-snap pull is capped, accidental clicks do not pick a wall [auto]
|
||||||
- [ ] 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
|
||||||
@@ -170,6 +174,16 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
|
|
||||||
## Last self-run
|
## Last self-run
|
||||||
|
|
||||||
|
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[auto]` items pass (73 frontend
|
||||||
|
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
|
||||||
|
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
|
||||||
|
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
|
||||||
|
Finding turned into a fix (shipped this release): **Split required the click to land on a grid
|
||||||
|
node**, so it silently failed on rooms whose walls are not grid-aligned (imported/legacy
|
||||||
|
polygons) — the click now snaps to the nearest wall instead of the grid, and `splitRoom()`
|
||||||
|
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
|
||||||
|
was missing. The earlier self-run record follows.
|
||||||
|
|
||||||
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass
|
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass
|
||||||
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
|
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
|
||||||
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
|
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.20.0",
|
"version": "1.21.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.20.0",
|
"version": "1.21.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lit": "^3.1.3",
|
"lit": "^3.1.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.21.0",
|
"version": "1.21.1",
|
||||||
"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",
|
||||||
|
|||||||
+12
-4
@@ -14,7 +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,
|
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
||||||
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,
|
||||||
@@ -28,7 +28,7 @@ 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.21.0';
|
const CARD_VERSION = '1.21.1';
|
||||||
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';
|
||||||
@@ -1171,9 +1171,17 @@ class HouseplanCard extends LitElement {
|
|||||||
this._splitSel = null;
|
this._splitSel = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A split point lands on the room's nearest wall — the user aims at a wall,
|
||||||
|
// and rooms need not be grid-aligned (imported/legacy polygons), so snapping
|
||||||
|
// to the grid would miss the outline. The pull is capped: a click far from
|
||||||
|
// any wall (e.g. an accidental one in the middle of the room) is a miss and
|
||||||
|
// gets the toast, not a wall the user never meant. splitRoom() still rejects
|
||||||
|
// any cut that is not a clean wall-to-wall chord.
|
||||||
const eps = this._gridPitch * 0.02;
|
const eps = this._gridPitch * 0.02;
|
||||||
const pt = this._snap(raw);
|
const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional
|
||||||
if (!pointOnBoundary(pt, poly, eps)) {
|
const near = closestPointOnBoundary(raw, poly);
|
||||||
|
const pt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null;
|
||||||
|
if (!pt || !pointOnBoundary(pt, poly, eps)) {
|
||||||
this._showToast(this._t('toast.split_pick_wall'));
|
this._showToast(this._t('toast.split_pick_wall'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,29 @@ function distToSeg(p: number[], a: number[], b: number[]): number {
|
|||||||
* neighbouring rooms share walls, so their vertices sit on each other's outlines —
|
* neighbouring rooms share walls, so their vertices sit on each other's outlines —
|
||||||
* including mid-span, since real walls overlap collinearly rather than match exactly.
|
* including mid-span, since real walls overlap collinearly rather than match exactly.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Project a point onto the nearest edge of a polygon and return that point,
|
||||||
|
* or null when the polygon has no edges. Used to snap a Split click onto the
|
||||||
|
* actual wall (rooms may not be grid-aligned — imported polygons, older configs),
|
||||||
|
* so cutting no longer requires hitting a grid node exactly on the outline.
|
||||||
|
*/
|
||||||
|
export function closestPointOnBoundary(p: number[], poly: number[][]): number[] | null {
|
||||||
|
if (!poly || poly.length < 2) return null;
|
||||||
|
let best: number[] | null = null;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (let i = 0; i < poly.length; i++) {
|
||||||
|
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||||
|
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||||
|
const len2 = dx * dx + dy * dy;
|
||||||
|
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
const q = [a[0] + t * dx, a[1] + t * dy];
|
||||||
|
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
|
||||||
|
if (d < bestD) { bestD = d; best = q; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
export function pointOnBoundary(p: number[], poly: number[][], eps = 1e-6): boolean {
|
export function pointOnBoundary(p: number[], poly: number[][], eps = 1e-6): boolean {
|
||||||
if (!poly || poly.length < 2) return false;
|
if (!poly || poly.length < 2) return false;
|
||||||
for (let i = 0; i < poly.length; i++)
|
for (let i = 0; i < poly.length; i++)
|
||||||
|
|||||||
+9
-1
@@ -4,7 +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,
|
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
import {
|
import {
|
||||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||||
@@ -387,3 +387,11 @@ test('splitRoom: refuses cuts that are not clean wall-to-wall chords', () => {
|
|||||||
const L = [[0, 0], [4, 0], [4, 2], [2, 2], [2, 4], [0, 4]];
|
const L = [[0, 0], [4, 0], [4, 2], [2, 2], [2, 4], [0, 4]];
|
||||||
assert.equal(splitRoom(L, [4, 1], [1, 4]), null);
|
assert.equal(splitRoom(L, [4, 1], [1, 4]), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('closestPointOnBoundary: projects a click onto the nearest wall', () => {
|
||||||
|
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||||
|
assert.deepEqual(closestPointOnBoundary([5, -3], sq), [5, 0]); // above the bottom edge
|
||||||
|
assert.deepEqual(closestPointOnBoundary([13, 5], sq), [10, 5]); // right of the right edge
|
||||||
|
assert.deepEqual(closestPointOnBoundary([5, 4], sq), [5, 0]); // inside → nearest edge (bottom)
|
||||||
|
assert.equal(closestPointOnBoundary([0, 0], [[0, 0]]), null); // no edges
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user