mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
+55
-16
@@ -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
@@ -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 room’s 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 room’s 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
@@ -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
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user