feat v1.32.0: polyline split, tool cursors, Esc walk-back in merge/split

- splitRoomPath in logic.ts: wall-to-wall polyline cut with interior
  vertices; validates wall crossings, self-intersection, degenerate
  parts; splitRoom delegates (2-point path); +3 unit tests (93 total)
- split UI: interior clicks add snapped intermediate points, live
  polyline + vertices + preview; new/updated toasts
- cursors: pointer for merge/delroom and split room-pick stage,
  crosshair while cutting (stage tool-* classes)
- Esc: split — drop last point, then room pick, then back to draw;
  merge — clear selection, then back to draw
- smoke_split_polyline.mjs (14 checks); TESTING/CHANGELOG same-commit
This commit is contained in:
Matysh
2026-07-22 14:27:39 +03:00
parent 849117eb27
commit 5e6f9c407c
16 changed files with 256 additions and 58 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.31.2" VERSION = "1.32.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.31.2" "version": "1.32.0"
} }
+1 -1
View File
@@ -14,7 +14,7 @@ const res = await page.evaluate(async () => {
out.amberStroke = cs ? cs.stroke.includes('255, 193, 77') : null; out.amberStroke = cs ? cs.stroke.includes('255, 193, 77') : null;
out.amberFill = cs ? cs.fill.includes('255, 193, 77') : null; out.amberFill = cs ? cs.fill.includes('255, 193, 77') : null;
// split-выбор подсвечивается так же // split-выбор подсвечивается так же
c._mergeSel = null; c._tool = 'split'; c._splitSel = { roomId: room.id }; c.requestUpdate(); await c.updateComplete; c._mergeSel = null; c._tool = 'split'; c._splitSel = { roomId: room.id, pts: [] }; c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250)); await new Promise((r) => setTimeout(r, 250));
const el2 = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked')); const el2 = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77'); out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
+55
View File
@@ -0,0 +1,55 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const stage = () => sr().querySelector('.stage');
c._setMode('plan'); await c.updateComplete;
// 1) курсоры: split до выбора комнаты — pointer, после — crosshair; merge — pointer
c._tool = 'split'; c.requestUpdate(); await c.updateComplete;
out.splitPickCursor = getComputedStyle(stage()).cursor === 'pointer';
const rooms = c._spaceModel().rooms;
const room = rooms.find((r) => r.id === 'r1');
const center = c._roomCenter(room);
c._splitClick(center); await c.updateComplete;
out.roomPicked = c._splitSel?.roomId === 'r1';
out.splitCutCursor = getComputedStyle(stage()).cursor === 'crosshair';
c._tool = 'merge'; c._splitSel = null; c.requestUpdate(); await c.updateComplete;
out.mergeCursor = getComputedStyle(stage()).cursor === 'pointer';
// 2) полилинейный разрез: r1 = прямоугольник? возьмём его poly и построим Г-разрез
c._tool = 'split'; await c.updateComplete;
c._splitClick(center);
const poly = (() => { const r = rooms.find((x) => x.id === 'r1');
return r.poly ? r.poly : [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]]; })();
// старт на середине верхней стены, промежуточная внутри, конец на правой стене
const [A, B, C2, D] = [poly[0], poly[1], poly[2], poly[3]];
const top = [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
const right = [(B[0] + C2[0]) / 2, (B[1] + C2[1]) / 2];
const mid = [(top[0] + right[0]) / 2 - 20, (top[1] + right[1]) / 2 + 20];
c._splitClick(top);
out.firstOnWall = c._splitSel?.pts.length === 1;
c._splitClick(mid);
out.midAdded = c._splitSel?.pts.length === 2;
c._splitClick(right);
out.dialogAfterCut = !!c._pendingSplit && c._roomDialog;
out.partsPolys = c._pendingSplit ? [c._pendingSplit.mainPoly.length, c._pendingSplit.newPoly.length] : null;
// отмена — комната целая
c._roomDialogCancel(); await c.updateComplete;
out.cancelKeepsRoom = !c._pendingSplit && rooms.length === c._spaceModel().rooms.length;
// 3) Esc: пошаговый выход
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
c._tool = 'split'; c._splitSel = null;
c._splitClick(center); c._splitClick(top); c._splitClick(mid); await c.updateComplete;
await esc(); out.escDropsPoint = c._splitSel?.pts.length === 1;
await esc(); out.escDropsFirst = c._splitSel?.pts.length === 0;
await esc(); out.escDropsRoom = c._splitSel === null && c._tool === 'split';
await esc(); out.escExitsTool = c._tool === 'draw';
// merge: выбор → Esc снимает, ещё Esc — выход
c._tool = 'merge'; c._mergeSel = 'r1'; await c.updateComplete;
await esc(); out.escClearsMerge = c._mergeSel === null && c._tool === 'merge';
await esc(); out.escExitsMerge = c._tool === 'draw';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+14 -7
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## v1.32.0 — 2026-07-22 (split polyline, tool cursors, Esc)
- **Split can now cut along a polyline**, not just a straight chord: start on
a wall, click intermediate points inside the room, finish on another wall.
The path is validated (no wall crossings, no self-intersection) and drawn
live with vertices and a preview segment. Two clicks still work as before.
- **Tool cursors**: Merge and delete-room show a pointer; Split shows a
pointer while picking the room, then a crosshair while cutting.
- **Esc walks back out of Merge/Split** step by step: last cut point → first
point → room selection → back to the Draw tool. Merge: selection → tool.
## v1.31.2 — 2026-07-22 ## v1.31.2 — 2026-07-22
- Plan editor: the room picked with the **Merge** tool (and the room selected - Plan editor: the room picked with the **Merge** tool (and the room selected
for **Split**) is highlighted amber again. The `.outlined` markup style, for **Split**) is highlighted amber again. The `.outlined` markup style,
+6
View File
@@ -140,6 +140,12 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto] (explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any - [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
Split shows pointer until a room is picked then crosshair; the cut can be
a polyline — start on a wall, intermediate clicks inside the room, finish
on a wall (path drawn live, walls/self-crossing rejected); Esc walks back:
last cut point → room pick → back to the Draw tool (same for Merge:
selection → tool) [auto]
- [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the - [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the
Merge tool (and the split-selected room) gets an amber outline + fill; Merge tool (and the split-selected room) gets an amber outline + fill;
visible over the blue markup outlines [auto] visible over the blue markup outlines [auto]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.31.2", "version": "1.32.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",
+55 -16
View File
@@ -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, closestPointOnBoundary, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside,
snapToWall, openingAmount, snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
@@ -32,7 +32,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.31.2'; const CARD_VERSION = '1.32.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';
@@ -115,7 +115,7 @@ class HouseplanCard extends LitElement {
private _openingInfo: OpeningCfg | null = null; private _openingInfo: OpeningCfg | null = null;
private _opDrag: { id: string; moved: boolean } | null = null; private _opDrag: { id: string; moved: boolean } | null = null;
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null; 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 private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact // 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 _pendingSplit: { roomId: string; mainPoly: number[][]; newPoly: number[][] } | null = null;
private _areaSel = ''; private _areaSel = '';
@@ -284,6 +284,27 @@ class HouseplanCard extends LitElement {
if (this._tool === 'draw' && this._path.length) { if (this._tool === 'draw' && this._path.length) {
e.preventDefault(); e.preventDefault();
this._undoPoint(); this._undoPoint();
return;
}
if (!undo) return;
// Esc walks back out of merge/split: point by point, then the room pick,
// then the tool itself (back to the neutral draw tool)
if (this._tool === 'split') {
e.preventDefault();
if (this._splitSel?.pts?.length) {
this._splitSel = { ...this._splitSel, pts: this._splitSel.pts.slice(0, -1) };
if (!this._splitSel.pts.length) this._cursorPt = null;
} else if (this._splitSel) {
this._splitSel = null;
} else {
this._tool = 'draw';
}
return;
}
if (this._tool === 'merge') {
e.preventDefault();
if (this._mergeSel) this._mergeSel = null;
else this._tool = 'draw';
} }
} }
@@ -1451,7 +1472,7 @@ class HouseplanCard extends LitElement {
if (!this._splitSel) { if (!this._splitSel) {
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r)); const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
if (!hit?.id) return; if (!hit?.id) return;
this._splitSel = { roomId: hit.id, a: null }; this._splitSel = { roomId: hit.id, pts: [] };
return; return;
} }
const room = rooms.find((r) => r.id === this._splitSel!.roomId); const room = rooms.find((r) => r.id === this._splitSel!.roomId);
@@ -1469,16 +1490,30 @@ class HouseplanCard extends LitElement {
const eps = this._gridPitch * 0.02; const eps = this._gridPitch * 0.02;
const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional
const near = closestPointOnBoundary(raw, poly); const near = closestPointOnBoundary(raw, poly);
const pt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null; const wallPt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null;
if (!pt || !pointOnBoundary(pt, poly, eps)) { const onWall = !!wallPt && pointOnBoundary(wallPt, poly, eps);
this._showToast(this._t('toast.split_pick_wall')); const cur = this._splitSel.pts;
if (!cur.length) {
// the cut starts on a wall
if (!onWall) {
this._showToast(this._t('toast.split_pick_wall'));
return;
}
this._splitSel = { ...this._splitSel, pts: [wallPt!] };
return; return;
} }
if (!this._splitSel.a) { if (!onWall) {
this._splitSel = { ...this._splitSel, a: pt }; // an interior click adds an intermediate vertex of the cut path
const mid = this._snap(raw);
if (!ptInside(mid, poly, eps)) {
this._showToast(this._t('toast.split_pick_inside'));
return;
}
this._splitSel = { ...this._splitSel, pts: [...cur, mid] };
return; return;
} }
const parts = splitRoom(poly, this._splitSel.a, pt, eps); // a wall point finishes the cut
const parts = splitRoomPath(poly, [...cur, wallPt!], eps);
if (!parts) { if (!parts) {
this._showToast(this._t('toast.split_bad_cut')); this._showToast(this._t('toast.split_bad_cut'));
return; return;
@@ -1501,7 +1536,7 @@ class HouseplanCard extends LitElement {
private _markupMove(ev: MouseEvent): void { private _markupMove(ev: MouseEvent): void {
if (!this._markup) return; if (!this._markup) return;
const drawing = this._tool === 'draw' && this._path.length && !this._contourClosed; const drawing = this._tool === 'draw' && this._path.length && !this._contourClosed;
const cutting = this._tool === 'split' && !!this._splitSel?.a; const cutting = this._tool === 'split' && !!this._splitSel?.pts?.length;
if (!drawing && !cutting) return; if (!drawing && !cutting) return;
this._cursorPt = this._snap(this._svgPoint(ev)); this._cursorPt = this._snap(this._svgPoint(ev));
} }
@@ -2490,7 +2525,7 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : nothing} ${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : nothing}
</div> </div>
<div class="stage ${this._markup ? 'markup' : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}" <div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
style="height:calc(100dvh - 118px)" style="height:calc(100dvh - 118px)"
@click=${(e: MouseEvent) => this._markupClick(e)} @click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)} @wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -2822,7 +2857,8 @@ class HouseplanCard extends LitElement {
if (!this._markup || !this._cursorPt) return null; if (!this._markup || !this._cursorPt) return null;
if (this._tool === 'draw' && this._path.length && !this._contourClosed) if (this._tool === 'draw' && this._path.length && !this._contourClosed)
return this._path[this._path.length - 1]; return this._path[this._path.length - 1];
if (this._tool === 'split' && this._splitSel?.a) return this._splitSel.a; if (this._tool === 'split' && this._splitSel?.pts?.length)
return this._splitSel.pts[this._splitSel.pts.length - 1];
return null; return null;
} }
@@ -3089,10 +3125,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 ${this._tool === 'split' && this._splitSel?.pts?.length
? svg`<circle class="vertex first" cx="${this._splitSel.a[0]}" cy="${this._splitSel.a[1]}" r="${g * 0.22}"></circle> ? svg`${this._splitSel.pts.length > 1
? svg`<polyline class="pathline" points="${this._splitSel.pts.map((p) => p.join(',')).join(' ')}"></polyline>`
: nothing}
${this._splitSel.pts.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
${this._cursorPt ${this._cursorPt
? svg`<line class="preview" x1="${this._splitSel.a[0]}" y1="${this._splitSel.a[1]}" ? svg`<line class="preview" x1="${this._splitSel.pts[this._splitSel.pts.length - 1][0]}" y1="${this._splitSel.pts[this._splitSel.pts.length - 1][1]}"
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>` x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
: nothing}` : nothing}`
: nothing} : nothing}
+4 -3
View File
@@ -128,8 +128,8 @@
"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.merge_not_adjacent": "Only rooms that share a wall can be merged",
"toast.rooms_merged": "Rooms merged into “{name}”", "toast.rooms_merged": "Rooms merged into “{name}”",
"toast.split_pick_wall": "Click a grid dot on the rooms wall", "toast.split_pick_wall": "Start the cut on the rooms wall",
"toast.split_bad_cut": "The cut must be a straight line from wall to wall, inside the room", "toast.split_bad_cut": "The cut must run wall to wall inside the room, without crossing walls or itself",
"merge.header": "Merge rooms", "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.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.keep": "Keep",
@@ -260,5 +260,6 @@
"space.label_light": "Lights on/off", "space.label_light": "Lights on/off",
"roomcard.light_on": "On", "roomcard.light_on": "On",
"roomcard.light_off": "Off", "roomcard.light_off": "Off",
"roomcard.light_partial": "{on} of {total}" "roomcard.light_partial": "{on} of {total}",
"toast.split_pick_inside": "Intermediate cut points must be inside the room"
} }
+4 -3
View File
@@ -128,8 +128,8 @@
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться", "toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной", "toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
"toast.rooms_merged": "Комнаты объединены в «{name}»", "toast.rooms_merged": "Комнаты объединены в «{name}»",
"toast.split_pick_wall": "Кликните по узлу сетки на стене комнаты", "toast.split_pick_wall": "Начните разрез на стене комнаты",
"toast.split_bad_cut": "Разрез — прямая от стены до стены внутри комнаты", "toast.split_bad_cut": "Разрез — от стены до стены внутри комнаты, без пересечения стен и самого себя",
"merge.header": "Объединение комнат", "merge.header": "Объединение комнат",
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.", "merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
"merge.keep": "Оставить", "merge.keep": "Оставить",
@@ -260,5 +260,6 @@
"space.label_light": "Свет вкл/выкл", "space.label_light": "Свет вкл/выкл",
"roomcard.light_on": "Вкл", "roomcard.light_on": "Вкл",
"roomcard.light_off": "Выкл", "roomcard.light_off": "Выкл",
"roomcard.light_partial": "{on} из {total}" "roomcard.light_partial": "{on} из {total}",
"toast.split_pick_inside": "Промежуточные точки разреза — внутри комнаты"
} }
+34 -11
View File
@@ -310,27 +310,50 @@ function dropRepeats(pts: number[][], eps: number): number[][] {
export function splitRoom( export function splitRoom(
poly: number[][], a: number[], b: number[], eps = 1e-6, poly: number[][], a: number[], b: number[], eps = 1e-6,
): [number[][], number[][]] | null { ): [number[][], number[][]] | null {
if (!poly || poly.length < 3 || samePoint(a, b, eps)) return null; return splitRoomPath(poly, [a, b], eps);
}
/**
* Split a room along a polyline: first and last points on walls, intermediate
* vertices strictly inside the room. A two-point path is the classic straight
* chord. Returns the two parts, or null when the path is not a clean cut.
*/
export function splitRoomPath(
poly: number[][], pts: number[][], eps = 1e-6,
): [number[][], number[][]] | null {
if (!poly || poly.length < 3 || !pts || pts.length < 2) return null;
const a = pts[0];
const b = pts[pts.length - 1];
if (samePoint(a, b, eps)) return null;
const ia = edgeIndexOf(poly, a, eps); const ia = edgeIndexOf(poly, a, eps);
const ib = edgeIndexOf(poly, b, eps); const ib = edgeIndexOf(poly, b, eps);
if (ia < 0 || ib < 0) return null; // an end is not on a wall if (ia < 0 || ib < 0) return null; // an end is not on a wall
for (let i = 0; i < poly.length; i++) const mids = pts.slice(1, -1);
if (segmentsProperlyCross(a, b, poly[i], poly[(i + 1) % poly.length])) return null; // leaves the room for (const m of mids) if (!pointStrictlyInside(m, poly, eps)) return null;
// a chord lying along a wall has its midpoint ON the outline, not inside it // no path segment may cross a wall
if (!pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps)) return null; for (let sI = 0; sI < pts.length - 1; sI++)
for (let i = 0; i < poly.length; i++)
if (segmentsProperlyCross(pts[sI], pts[sI + 1], poly[i], poly[(i + 1) % poly.length])) return null;
// the path may not properly self-intersect
for (let sI = 0; sI < pts.length - 1; sI++)
for (let t = sI + 2; t < pts.length - 1; t++)
if (segmentsProperlyCross(pts[sI], pts[sI + 1], pts[t], pts[t + 1])) return null;
// a straight chord lying along a wall has its midpoint ON the outline, not inside
if (pts.length === 2 && !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 walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
const pts: number[][] = [from]; const acc: number[][] = [from];
let i = (fromIdx + 1) % poly.length; let i = (fromIdx + 1) % poly.length;
for (let guard = 0; guard <= poly.length; guard++) { for (let guard = 0; guard <= poly.length; guard++) {
pts.push(poly[i]); acc.push(poly[i]);
if (i === toIdx) break; if (i === toIdx) break;
i = (i + 1) % poly.length; i = (i + 1) % poly.length;
} }
pts.push(to); acc.push(to);
return dropRepeats(pts, eps); return dropRepeats(acc, eps);
}; };
const p1 = walk(a, ia, b, ib); const p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
const p2 = walk(b, ib, a, ia); const p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
if (p1.length < 3 || p2.length < 3) return null; if (p1.length < 3 || p2.length < 3) return null;
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null; if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
return [p1, p2]; return [p1, p2];
+6
View File
@@ -445,6 +445,12 @@ export const cardStyles = css`
.stage.markup { .stage.markup {
cursor: crosshair; cursor: crosshair;
} }
/* room-picking stages: merge (both clicks) and split before a room is chosen */
.stage.markup.tool-merge,
.stage.markup.tool-split.pickstage,
.stage.markup.tool-delroom {
cursor: pointer;
}
.stage.markup .room { .stage.markup .room {
pointer-events: none; pointer-events: none;
} }
+36
View File
@@ -3,6 +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,
splitRoomPath,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
@@ -544,3 +545,38 @@ test('spaceDisplayOf: room-card metric flags default to off', () => {
const d1 = spaceDisplayOf({ plan_url: 'x', settings: { label_temp: true, label_light: true } }); const d1 = spaceDisplayOf({ plan_url: 'x', settings: { label_temp: true, label_light: true } });
assert.deepEqual([d1.labelTemp, d1.labelHum, d1.labelLqi, d1.labelLight], [true, false, false, true]); assert.deepEqual([d1.labelTemp, d1.labelHum, d1.labelLqi, d1.labelLight], [true, false, false, true]);
}); });
test('splitRoomPath: polyline cut of a square', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
// Г-образный разрез: верхняя стена (4,0) → внутрь (4,6) → правая стена (10,6)
const parts = splitRoomPath(sq, [[4, 0], [4, 6], [10, 6]], 1e-6);
assert.ok(parts);
const [p1, p2] = parts;
const area = (p) => Math.abs(p.reduce((a, _, i) => {
const [x1, y1] = p[i], [x2, y2] = p[(i + 1) % p.length];
return a + x1 * y2 - x2 * y1;
}, 0)) / 2;
assert.ok(Math.abs(area(p1) + area(p2) - 100) < 1e-6);
// меньшая часть — прямоугольник 6x6 минус... фактически площадь 4*6+... проверим что обе > 0
assert.ok(area(p1) > 0 && area(p2) > 0);
});
test('splitRoomPath: two points == classic straight chord', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
const parts = splitRoomPath(sq, [[5, 0], [5, 10]], 1e-6);
assert.ok(parts);
});
test('splitRoomPath rejects bad paths', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
// промежуточная точка снаружи
assert.equal(splitRoomPath(sq, [[4, 0], [4, -3], [10, 6]], 1e-6), null);
// сегмент пересекает стену (выходит и возвращается)
assert.equal(splitRoomPath(sq, [[4, 0], [14, 5], [10, 6]], 1e-6), null);
// конец не на стене
assert.equal(splitRoomPath(sq, [[4, 0], [5, 5]], 1e-6), null);
// самопересечение пути
assert.equal(splitRoomPath(sq, [[2, 0], [8, 8], [8, 2], [2, 8], [0, 4]], 1e-6), null);
// менее двух точек
assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null);
});