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
+55 -16
View File
@@ -14,7 +14,7 @@ import {
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside,
snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -115,7 +115,7 @@ class HouseplanCard extends LitElement {
private _openingInfo: OpeningCfg | null = null;
private _opDrag: { id: string; moved: boolean } | 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
private _pendingSplit: { roomId: string; mainPoly: number[][]; newPoly: number[][] } | null = null;
private _areaSel = '';
@@ -284,6 +284,27 @@ class HouseplanCard extends LitElement {
if (this._tool === 'draw' && this._path.length) {
e.preventDefault();
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) {
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
if (!hit?.id) return;
this._splitSel = { roomId: hit.id, a: null };
this._splitSel = { roomId: hit.id, pts: [] };
return;
}
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 pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional
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'));
const wallPt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null;
const onWall = !!wallPt && pointOnBoundary(wallPt, poly, eps);
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;
}
if (!this._splitSel.a) {
this._splitSel = { ...this._splitSel, a: pt };
if (!onWall) {
// 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;
}
const parts = splitRoom(poly, this._splitSel.a, pt, eps);
// a wall point finishes the cut
const parts = splitRoomPath(poly, [...cur, wallPt!], eps);
if (!parts) {
this._showToast(this._t('toast.split_bad_cut'));
return;
@@ -1501,7 +1536,7 @@ class HouseplanCard extends LitElement {
private _markupMove(ev: MouseEvent): void {
if (!this._markup) return;
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;
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}
</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)"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -2822,7 +2857,8 @@ class HouseplanCard extends LitElement {
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;
if (this._tool === 'split' && this._splitSel?.pts?.length)
return this._splitSel.pts[this._splitSel.pts.length - 1];
return null;
}
@@ -3089,10 +3125,13 @@ class HouseplanCard extends LitElement {
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
: nothing}
${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._tool === 'split' && this._splitSel?.pts?.length
? 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
? 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>`
: nothing}`
: nothing}
+4 -3
View File
@@ -128,8 +128,8 @@
"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 rooms wall",
"toast.split_bad_cut": "The cut must be a straight line from wall to wall, inside the room",
"toast.split_pick_wall": "Start the cut on the rooms wall",
"toast.split_bad_cut": "The cut must run wall to wall inside the room, without crossing walls or itself",
"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",
@@ -260,5 +260,6 @@
"space.label_light": "Lights on/off",
"roomcard.light_on": "On",
"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.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
"toast.rooms_merged": "Комнаты объединены в «{name}»",
"toast.split_pick_wall": "Кликните по узлу сетки на стене комнаты",
"toast.split_bad_cut": "Разрез — прямая от стены до стены внутри комнаты",
"toast.split_pick_wall": "Начните разрез на стене комнаты",
"toast.split_bad_cut": "Разрез — от стены до стены внутри комнаты, без пересечения стен и самого себя",
"merge.header": "Объединение комнат",
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
"merge.keep": "Оставить",
@@ -260,5 +260,6 @@
"space.label_light": "Свет вкл/выкл",
"roomcard.light_on": "Вкл",
"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(
poly: number[][], a: number[], b: number[], eps = 1e-6,
): [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 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 mids = pts.slice(1, -1);
for (const m of mids) if (!pointStrictlyInside(m, poly, eps)) return null;
// no path segment may cross a wall
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 pts: number[][] = [from];
const acc: number[][] = [from];
let i = (fromIdx + 1) % poly.length;
for (let guard = 0; guard <= poly.length; guard++) {
pts.push(poly[i]);
acc.push(poly[i]);
if (i === toIdx) break;
i = (i + 1) % poly.length;
}
pts.push(to);
return dropRepeats(pts, eps);
acc.push(to);
return dropRepeats(acc, eps);
};
const p1 = walk(a, ia, b, ib);
const p2 = walk(b, ib, a, ia);
const p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
const p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
if (p1.length < 3 || p2.length < 3) return null;
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
return [p1, p2];
+6
View File
@@ -445,6 +445,12 @@ export const cardStyles = css`
.stage.markup {
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 {
pointer-events: none;
}