room resize: the 'Размер' tool in the Plan editor (docs/RESIZE.md)

Wall-midpoint handles for every room (finger-sized, pointer-captured, no
stage-pan conflict); dragging moves the wall along its normal with live
preview through the config snapshot, shared stretches drag the neighbour
(T-junctions insert vertices), openings on the wall travel along. Click a
room for the corner scale frame (uniform, about the opposite corner,
neighbours never dragged). Live badges: dragged+adjacent wall lengths and
m² per reshaped room. Grid snap, Esc cancels the drag, Ctrl+Z pops the
resize undo stack (one release = one step). Legacy rects are saved back
as polygons; three blob copies rebuilt.
This commit is contained in:
Matysh
2026-08-01 13:48:43 +03:00
parent 65070c0520
commit ec58b0602a
7 changed files with 581 additions and 125 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+82 -41
View File
File diff suppressed because one or more lines are too long
+306 -2
View File
@@ -24,6 +24,10 @@ import {
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
} from './logic';
import {
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
simplifyPoly, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
} from './resize';
import { ContentSigner } from './signing';
import {
Affine, applyAffine, solveAffine, affineResidual, readVacTelemetry, isVacSourceState,
@@ -55,7 +59,7 @@ const NORM_W = 1000; // side of the render space — the canvas is square (v1.48
const SENSE_FLASH_MS = 3300;
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' | 'merge' | 'split' | 'opening' | 'openwall' | 'delroom';
type MarkupTool = 'draw' | 'merge' | 'split' | 'resize' | 'opening' | 'openwall' | 'delroom';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any;
@@ -215,6 +219,25 @@ class HouseplanCard extends LitElement {
return this._mode === 'plan';
}
private _tool: MarkupTool = 'draw';
// room resize tool (docs/RESIZE.md): selection, live drag, its own undo stack
private _rszSel: string | null = null;
private _rszDrag: {
kind: 'edge' | 'scale';
pid: number;
roomId: string;
plan?: EdgeDragPlan;
fixed?: [number, number];
span0?: number;
rooms: { id: string; poly: number[][] }[];
openings: { id: string; x: number; y: number; length: number }[];
snap: string;
moved: boolean;
d: number;
k: number;
changed: string[];
} | null = null;
private _rszUndo: { space: string; snap: string }[] = [];
private _rszLive: { x: number; y: number; text: string; area?: boolean }[] | null = null;
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _cursorPt: number[] | null = null;
private _mergeSel: string | null = null;
@@ -426,6 +449,8 @@ class HouseplanCard extends LitElement {
_serverCfg: { state: true },
_mode: { state: true },
_tool: { state: true },
_rszSel: { state: true },
_rszLive: { state: true },
_path: { state: true },
_cursorPt: { state: true },
_mergeSel: { state: true },
@@ -565,6 +590,21 @@ class HouseplanCard extends LitElement {
return;
}
if (!undo) return;
if (this._tool === 'resize') {
e.preventDefault();
if (this._rszDrag) {
// Esc (or Ctrl+Z) mid-drag: the original geometry comes back
this._rszCancelDrag();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
this._rszUndoPop();
return;
}
if (this._rszSel) this._rszSel = null;
else this._tool = 'draw';
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') {
@@ -1637,7 +1677,7 @@ class HouseplanCard extends LitElement {
// entirely, which left a phone with no way to zoom or pan the plan
// (owner's report). Pointers that begin on interactive children still
// stay out — labels and handles run their own drags.
if ((ev.target as HTMLElement).closest?.('.roomlabel, .rlhandle, .dev, .oplock, .op-hit, button')) return;
if ((ev.target as HTMLElement).closest?.('.roomlabel, .rlhandle, .rszhandle, .dev, .oplock, .op-hit, button')) return;
}
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
@@ -1959,6 +1999,10 @@ class HouseplanCard extends LitElement {
this._splitSel = null;
this._pendingSplit = null;
this._selId = null;
this._rszSel = null;
this._rszDrag = null;
this._rszLive = null;
this._rszUndo = [];
this._tip = null;
this._decorDraft = null;
this._decorSel = null;
@@ -2093,6 +2137,13 @@ class HouseplanCard extends LitElement {
const path = (ev.composedPath?.() || []) as any[];
if (path.some((n) => n?.classList?.contains?.('roomlabel') || n?.classList?.contains?.('rlhandle'))) return;
const raw = this._svgPoint(ev);
if (this._tool === 'resize') {
// a click picks the room for the scale frame; handle drags never get here
if (this._rszDrag || path.some((n) => n?.classList?.contains?.('rszhandle'))) return;
const room = [...this._spaceModel().rooms].reverse().find((r) => this._pointInRoom(raw, r));
this._rszSel = room?.id || null;
return;
}
if (this._tool === 'delroom') {
const space = this._spaceModel();
const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r));
@@ -2154,6 +2205,247 @@ class HouseplanCard extends LitElement {
this._path = [...this._path, pt];
}
// ================= room resize tool (docs/RESIZE.md) =================
/** Rooms of the current space as render-unit polygons (legacy rects converted). */
private _rszRooms(): { id: string; poly: number[][] }[] {
const out: { id: string; poly: number[][] }[] = [];
for (const r of this._spaceModel().rooms) {
const poly = r.id ? roomPoly(r) : null;
if (poly) out.push({ id: r.id!, poly });
}
return out;
}
private _rszOpenings(): { id: string; x: number; y: number; length: number }[] {
return this._openingsR.map((o) => ({ id: o.id, x: o.rx, y: o.ry, length: o.rlen }));
}
private _rszOpts(): { minDim: number; eps: number } {
return { minDim: this._cmToUnits(MIN_ROOM_CM), eps: this._gridPitch * 0.05 };
}
private _rszSnapshot(): string {
const sp = this._curSpaceCfg;
return JSON.stringify({ rooms: sp?.rooms || [], openings: sp?.openings || [] });
}
private _rszRestore(snap: string): void {
const sp = this._curSpaceCfg;
if (!sp) return;
const s = JSON.parse(snap);
sp.rooms = s.rooms;
sp.openings = s.openings;
this._cfgEpoch++;
this.requestUpdate();
}
/** Live preview: the candidate outlines go straight into the space config (normalized),
* based on the immutable pre-drag snapshot — walls, fills, labels and openings all
* follow in the same render. Nothing is WRITTEN until the handle is released. */
private _rszApplyPreview(polys: Record<string, number[][]>, ops: Record<string, [number, number]>): void {
const g = this._rszDrag;
const sp = this._curSpaceCfg;
if (!g || !sp) return;
const s = JSON.parse(g.snap);
sp.rooms = s.rooms;
sp.openings = s.openings;
const H = this._spaceH;
for (const [id, poly] of Object.entries(polys)) {
const r = sp.rooms.find((x: any) => x.id === id);
if (!r) continue;
r.poly = poly.map((p) => [p[0] / NORM_W, p[1] / H]);
delete r.x; delete r.y; delete r.w; delete r.h; // a resized room is saved as a polygon
}
for (const [id, c] of Object.entries(ops)) {
const o = (sp.openings || []).find((x: any) => x.id === id);
if (!o) continue;
o.x = c[0] / NORM_W;
o.y = c[1] / H;
}
this._cfgEpoch++;
}
private _rszEdgeDown(ev: PointerEvent, roomId: string, edge: number): void {
if (this._tool !== 'resize' || this._rszDrag) return;
ev.stopPropagation();
ev.preventDefault();
capturePointer(ev);
const rooms = this._rszRooms();
const plan = planEdgeDrag(rooms, roomId, edge);
if (!plan) return;
this._rszDrag = {
kind: 'edge', pid: ev.pointerId, roomId, plan,
rooms, openings: this._rszOpenings(), snap: this._rszSnapshot(),
moved: false, d: 0, k: 1, changed: [],
};
}
private _rszCornerDown(ev: PointerEvent, roomId: string, corner: number[], fixed: [number, number]): void {
if (this._tool !== 'resize' || this._rszDrag) return;
ev.stopPropagation();
ev.preventDefault();
capturePointer(ev);
this._rszDrag = {
kind: 'scale', pid: ev.pointerId, roomId, fixed,
span0: Math.hypot(corner[0] - fixed[0], corner[1] - fixed[1]) || 1,
rooms: this._rszRooms(), openings: this._rszOpenings(), snap: this._rszSnapshot(),
moved: false, d: 0, k: 1, changed: [],
};
}
private _rszMove(ev: PointerEvent): void {
const g = this._rszDrag;
if (!g || g.pid !== ev.pointerId) return;
ev.stopPropagation();
const p = this._svgPoint(ev);
if (g.kind === 'edge') {
const plan = g.plan!;
const dRaw = (p[0] - plan.a[0]) * plan.n[0] + (p[1] - plan.a[1]) * plan.n[1];
// the moved wall LINE lands on the grid, like every drawn wall
const sn = this._snap([plan.a[0] + plan.n[0] * dRaw, plan.a[1] + plan.n[1] * dRaw]);
let d = (sn[0] - plan.a[0]) * plan.n[0] + (sn[1] - plan.a[1]) * plan.n[1];
d = clampEdgeDrag(g.rooms, g.openings, plan, d, this._gridPitch, this._rszOpts());
if (d === g.d && g.moved) return;
g.d = d;
g.moved = true;
const res = applyEdgeDrag(g.rooms, g.openings, plan, d, this._rszOpts().eps);
g.changed = Object.keys(res.polys);
this._rszApplyPreview(res.polys, res.openings);
this._rszLive = this._rszEdgeLabels(res, plan);
} else {
const fixed = g.fixed!;
const sn = this._snap(p); // the dragged corner aims at grid nodes
let k = Math.hypot(sn[0] - fixed[0], sn[1] - fixed[1]) / (g.span0 || 1);
k = Math.max(0.05, Math.min(20, k));
k = clampRoomScale(g.rooms, g.openings, g.roomId, fixed, k, this._rszOpts());
if (k === g.k && g.moved) return;
g.k = k;
g.moved = true;
const room = g.rooms.find((r) => r.id === g.roomId)!;
const others = g.rooms.filter((r) => r.id !== g.roomId).map((r) => r.poly);
const res = applyRoomScale(room, g.openings, others, fixed, k, this._rszOpts().eps * 2);
g.changed = [g.roomId];
this._rszApplyPreview({ [g.roomId]: res.poly }, res.openings);
this._rszLive = this._rszScaleLabels(res.poly);
}
this.requestUpdate();
}
private _rszUp(ev: PointerEvent): void {
const g = this._rszDrag;
if (!g || g.pid !== ev.pointerId) return;
ev.stopPropagation();
this._rszDrag = null;
this._rszLive = null;
const changed = g.moved && (g.kind === 'edge' ? Math.abs(g.d) > 1e-9 : Math.abs(g.k - 1) > 1e-9);
if (!changed) {
this._rszRestore(g.snap);
return;
}
// commit: clean the collinear leftovers of T-inserts, then ONE undo step + ONE write
const sp = this._curSpaceCfg;
if (sp) {
for (const id of g.changed) {
const r = sp.rooms.find((x: any) => x.id === id);
if (r?.poly) r.poly = simplifyPoly(r.poly, 1e-9);
}
}
this._rszUndo.push({ space: this._space, snap: g.snap });
if (this._rszUndo.length > 30) this._rszUndo.shift();
// the click synthesized after the drag must not re-pick the selection
this._suppressClick = true;
setTimeout(() => (this._suppressClick = false), 0);
this._saveConfig();
this.requestUpdate();
}
private _rszCancelDrag(): void {
const g = this._rszDrag;
if (!g) return;
this._rszDrag = null;
this._rszLive = null;
this._rszRestore(g.snap);
}
/** Ctrl+Z in the resize tool: one handle release = one undo step (docs/RESIZE.md). */
private _rszUndoPop(): void {
for (let i = this._rszUndo.length - 1; i >= 0; i--) {
if (this._rszUndo[i].space !== this._space) continue;
const [entry] = this._rszUndo.splice(i, 1);
this._rszRestore(entry.snap);
this._saveConfig();
return;
}
}
private _rszEdgeLabels(
res: { polys: Record<string, number[][]> }, plan: EdgeDragPlan,
): { x: number; y: number; text: string; area?: boolean }[] {
const g = this._rszDrag!;
const labels: { x: number; y: number; text: string; area?: boolean }[] = [];
const own = res.polys[plan.roomId] || g.rooms.find((r) => r.id === plan.roomId)!.poly;
const n = own.length;
const i = plan.edge, j = (i + 1) % n;
// the dragged wall and its two adjacent walls
for (const [a, b] of [[own[(i - 1 + n) % n], own[i]], [own[i], own[j]], [own[j], own[(j + 1) % n]]]) {
labels.push({ x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, text: this._fmtLen(a, b) });
}
// live areas of EVERY room the drag reshapes (both sides of a shared wall)
const imperial = this.hass?.config?.unit_system?.length === 'mi';
const ids = Object.keys(res.polys).length ? Object.keys(res.polys) : [plan.roomId];
for (const id of ids) {
const poly = res.polys[id] || g.rooms.find((r) => r.id === id)!.poly;
const c = poleOfInaccessibility(poly);
labels.push({ x: c[0], y: c[1], text: formatArea(areaM2(poly, this._gridPitch, this._cellCm), imperial), area: true });
}
return labels;
}
private _rszScaleLabels(poly: number[][]): { x: number; y: number; text: string; area?: boolean }[] {
const imperial = this.hass?.config?.unit_system?.length === 'mi';
const xs = poly.map((p) => p[0]), ys = poly.map((p) => p[1]);
const w = Math.max(...xs) - Math.min(...xs), h = Math.max(...ys) - Math.min(...ys);
const c = poleOfInaccessibility(poly);
return [
{ x: Math.min(...xs), y: Math.min(...ys), text: `${this._fmtLen([0, 0], [w, 0])} × ${this._fmtLen([0, 0], [h, 0])}` },
{ x: c[0], y: c[1], text: formatArea(areaM2(poly, this._gridPitch, this._cellCm), imperial), area: true },
];
}
/** Handles of the resize tool: wall midpoints + the scale frame of the selected room. */
private _renderResizeLayer(view: { x: number; y: number; w: number; h: number }): TemplateResult {
const hr = Math.max(view.w * 0.013, 5); // finger-sized on touch, like .vacfithandle
const parts: TemplateResult[] = [];
const rooms = this._rszRooms();
for (const r of rooms) {
for (let i = 0; i < r.poly.length; i++) {
const a = r.poly[i], b = r.poly[(i + 1) % r.poly.length];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < this._gridPitch) continue;
parts.push(svg`<circle class="rszhandle" cx="${((a[0] + b[0]) / 2).toFixed(1)}" cy="${((a[1] + b[1]) / 2).toFixed(1)}" r="${hr.toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._rszEdgeDown(e, r.id, i)}
@pointermove=${(e: PointerEvent) => this._rszMove(e)}
@pointerup=${(e: PointerEvent) => this._rszUp(e)}
@pointercancel=${(e: PointerEvent) => this._rszUp(e)}></circle>`);
}
}
const sel = this._rszSel ? rooms.find((r) => r.id === this._rszSel) : null;
if (sel) {
const xs = sel.poly.map((p) => p[0]), ys = sel.poly.map((p) => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
parts.push(svg`<rect class="rszframe" x="${x0}" y="${y0}" width="${x1 - x0}" height="${y1 - y0}"></rect>`);
for (const [cx, cy, fx, fy] of [[x0, y0, x1, y1], [x1, y0, x0, y1], [x1, y1, x0, y0], [x0, y1, x1, y0]]) {
parts.push(svg`<circle class="rszhandle rszcorner" cx="${cx}" cy="${cy}" r="${(hr * 1.15).toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._rszCornerDown(e, sel.id, [cx, cy], [fx, fy] as [number, number])}
@pointermove=${(e: PointerEvent) => this._rszMove(e)}
@pointerup=${(e: PointerEvent) => this._rszUp(e)}
@pointercancel=${(e: PointerEvent) => this._rszUp(e)}></circle>`);
}
}
return svg`${parts}`;
}
/** Openings of the current space in render units. */
private get _openingsR(): (OpeningCfg & { rx: number; ry: number; rlen: number })[] {
const sp = this._curSpaceCfg;
@@ -4361,6 +4653,7 @@ class HouseplanCard extends LitElement {
${this._renderOpenWalls(disp)}
${this._editing ? this._renderAlignGuides() : nothing}
${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
${this._renderOpenings(disp)}
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2] * (this._kiosk ? this._kioskScale.icon : 1)) / view.w).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
@@ -4376,6 +4669,11 @@ class HouseplanCard extends LitElement {
${this._measureAnchor
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
: nothing}
${this._rszLive
? html`<div class="measurelayer">${this._rszLive.map((l) => html`<div
class="measurelabel ${l.area ? 'rszarea' : ''}"
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%">${l.text}</div>`)}</div>`
: nothing}
</div>
${this._zoom > 1
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
@@ -5799,6 +6097,11 @@ class HouseplanCard extends LitElement {
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 === 'resize' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'resize'; this._rszSel = null; }}
title=${this._t('title.markup_resize')}>
<ha-icon icon="mdi:arrow-expand-all"></ha-icon>${this._t('markup.resize')}
</button>
<button class="btn ${this._tool === 'opening' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'opening'; }}
title=${this._t('title.markup_opening')}>
@@ -5820,6 +6123,7 @@ class HouseplanCard extends LitElement {
: this._t('markup.hint_start')}</span>
${this._path.length ? html`<button class="btn ghost" @click=${this._cancelPath}>${this._t('btn.reset')}</button>` : nothing}`
: nothing}
${this._tool === 'resize' ? html`<span class="hint">${this._t('markup.hint_resize')}</span>` : nothing}
<button class="btn barclose" title=${this._t('title.close_editor')}
@click=${() => this._setMode('view')}>
<ha-icon icon="mdi:close"></ha-icon>
+3
View File
@@ -35,6 +35,9 @@
"markup.add": "Add",
"markup.merge": "Merge",
"markup.split": "Split",
"markup.resize": "Resize",
"title.markup_resize": "Resize rooms: drag a wall handle; click a room for a corner scale frame",
"markup.hint_resize": "drag a wall handle · click a room — corner frame · Esc cancels a drag · Ctrl+Z — undo a step",
"markup.opening": "Opening",
"title.markup_opening": "Doors & windows: click a wall to place, click an opening to edit",
"opening.new": "New opening",
+3
View File
@@ -35,6 +35,9 @@
"markup.add": "Добавить",
"markup.merge": "Объединить",
"markup.split": "Разделить",
"markup.resize": "Размер",
"title.markup_resize": "Изменение размера комнат: тяните ручку стены; клик по комнате — угловая рамка масштаба",
"markup.hint_resize": "тяните ручку стены · клик по комнате — угловая рамка · Esc отменяет перетаскивание · Ctrl+Z — отмена шага",
"markup.opening": "Проём",
"title.markup_opening": "Двери и окна: клик по стене — добавить, клик по проёму — редактировать",
"opening.new": "Новый проём",
+23
View File
@@ -1402,6 +1402,29 @@ export const cardStyles = css`
pointer-events: none;
user-select: none;
}
/* room resize tool (docs/RESIZE.md) */
.rszhandle {
fill: var(--hp-bg);
stroke: var(--hp-accent);
stroke-width: 2;
vector-effect: non-scaling-stroke;
cursor: move;
touch-action: none;
}
.rszhandle:hover { fill: var(--hp-accent); }
.rszcorner { cursor: nwse-resize; }
.rszframe {
fill: none;
stroke: var(--hp-accent);
stroke-width: 1.5;
stroke-dasharray: 6 5;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.measurelabel.rszarea {
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.6);
}
.vacfitdot { fill: var(--hp-accent); pointer-events: none; }
.vacfithandle {
fill: var(--hp-bg);