mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
v1.55.0
This commit is contained in:
@@ -28,7 +28,8 @@ right on your Lovelace dashboard.
|
||||
**Feature highlights**
|
||||
|
||||
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
|
||||
virtual walls and a visual decor layer, all drawn with clicks; smart
|
||||
virtual walls and a visual decor layer, all drawn with clicks; room resize
|
||||
by dragging walls, with live lengths and areas as you drag; smart
|
||||
alignment guides and a live ruler in real meters/feet.
|
||||
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
|
||||
whole groups of lights (works for dumb switches and stateless remotes too).
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@
|
||||
**Главное**
|
||||
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
|
||||
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.54.3"
|
||||
VERSION = "1.55.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"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",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.54.3"
|
||||
"version": "1.55.0"
|
||||
}
|
||||
|
||||
@@ -53,6 +53,37 @@ const res = await page.evaluate(async () => {
|
||||
await setMotion('on', 'motion');
|
||||
out.retripFlashesAgain = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
|
||||
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
|
||||
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс senseflash
|
||||
// не снимался и animation-name не менялся — браузер продолжал старый
|
||||
// timeline, и после его конца (base opacity 0) второй trip был невидим.
|
||||
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
|
||||
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
|
||||
.find((a) => a.animationName && a.animationName.startsWith('hp-sense'));
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion'); // trip №1
|
||||
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
await sleep(1200); // середина первой вспышки (< 3.3s)
|
||||
const a1 = senseAnim(sr().querySelector('.dev.senseflash'));
|
||||
out.rapidFirstAnimMidway = !!a1 && a1.currentTime > 900 && a1.currentTime < 3300;
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion'); // trip №2 — retrip до конца вспышки
|
||||
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
const a2 = senseAnim(sr().querySelector('.dev.senseflash'));
|
||||
// новый timeline: currentTime у начала (старый уже был ~1.2s и продолжал бы расти)
|
||||
out.rapidRetripRestartsAnim = !!a2 && a2.currentTime != null && a2.currentTime < 300;
|
||||
// кольцо реально играет ПОСЛЕ момента, где закончилась бы первая анимация:
|
||||
// ~2.3s после trip №2 = ~3.5s после trip №1 (старый timeline уже отыграл бы)
|
||||
await sleep(2300);
|
||||
const el3 = sr().querySelector('.dev.senseflash');
|
||||
const a3 = senseAnim(el3);
|
||||
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
|
||||
out.rapidRingVisibleAfterFirstWindow = !!el3
|
||||
&& parseFloat(getComputedStyle(el3, '::after').opacity) > 0.03;
|
||||
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
|
||||
await sleep(1400); await c.updateComplete;
|
||||
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.senseflash').length === 0;
|
||||
|
||||
// --- occupancy: статичное кольцо sensehold, без анимации ----------------
|
||||
await setMotion('on', 'occupancy');
|
||||
const holdEl = sr().querySelector('.dev.sensehold');
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// Room resize tool (docs/RESIZE.md): wall drag with shared walls (T-junction),
|
||||
// live badges, stops (neighbour min size, opening anchor), the corner scale
|
||||
// frame, Esc-cancel and one-step undo. Vertex positions are checked NUMERICALLY.
|
||||
import { launch, check, finish } 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._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = [];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
|
||||
const enter = (tool) => page.evaluate((t) => {
|
||||
const c = window.__card;
|
||||
if (!c._markup) c._setMode('plan');
|
||||
c._tool = t; c._rszSel = null; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, tool);
|
||||
|
||||
const handleCount = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszhandle').length);
|
||||
const roomPolyN = (id) => page.evaluate((id) => {
|
||||
const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id);
|
||||
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
|
||||
}, id);
|
||||
const openingOf = (id) => page.evaluate((id) =>
|
||||
(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings || []).find((o) => o.id === id) || null, id);
|
||||
// screen position of a render-unit point (the svg viewBox fills the stage exactly)
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const svg = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
|
||||
const labels = () => page.evaluate(() =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.measurelabel')].map((el) => el.textContent.trim()));
|
||||
const polyClose = (name, got, want, tol = 1e-4) => {
|
||||
check(name + '.len', got?.length, want.length);
|
||||
if (got?.length !== want.length) return;
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
check(`${name}[${i}]`, Math.hypot(got[i][0] - want[i][0], got[i][1] - want[i][1]) <= tol, true);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- no handles outside the resize tool ----------
|
||||
await enter('draw');
|
||||
check('draw_tool_no_handles', await handleCount(), 0);
|
||||
await enter('opening');
|
||||
check('opening_tool_no_handles', await handleCount(), 0);
|
||||
|
||||
// ---------- handles appear in the resize tool ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
check('resize_handles_16', await handleCount(), 16); // 4 rooms × 4 walls
|
||||
|
||||
// ---------- wall drag: T-stack r1|r2+r3, opening rides along, live badges ----------
|
||||
// opening op2 sits ON the shared wall x=0.55 → it must travel with it
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [{ id: 'op2', type: 'door', x: 0.55, y: 0.30, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
});
|
||||
await settle();
|
||||
const [hx, hy] = await screenPt(550, 360); // r1 right-wall handle (mid y of 140..580)
|
||||
const [tx] = await screenPt(650, 360); // +100 render units
|
||||
await page.mouse.move(hx, hy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((hx + tx) / 2, hy, { steps: 4 });
|
||||
await settle();
|
||||
const mid = await labels();
|
||||
check('drag_badges_visible', mid.length >= 6); // 3 wall lengths + 3 areas (r1, r2, r3)
|
||||
check('drag_badges_have_area', mid.filter((t) => t.includes('m²')).length >= 3);
|
||||
check('drag_badges_have_len', mid.some((t) => /\d\.\d\d m$/.test(t)));
|
||||
const midR1 = await roomPolyN('r1');
|
||||
check('preview_moves_r1', Math.abs(midR1[1][0] - 0.6) < 0.011); // halfway ≈ 0.60 (snapped)
|
||||
await page.mouse.move(tx, hy, { steps: 4 });
|
||||
await settle();
|
||||
const end = await labels();
|
||||
check('drag_badges_change', JSON.stringify(mid) !== JSON.stringify(end));
|
||||
check('area_updates_live', mid.find((t) => t.includes('m²')) !== end.find((t) => t.includes('m²')));
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
// committed geometry: r1 grew, r2 translated, r3 became L-shaped with 6 vertices
|
||||
polyClose('r1_after', await roomPolyN('r1'), [[0.04, 0.14], [0.65, 0.14], [0.65, 0.58], [0.04, 0.58]]);
|
||||
polyClose('r2_after', await roomPolyN('r2'), [[0.65, 0.14], [0.96, 0.14], [0.96, 0.46], [0.65, 0.46]]);
|
||||
polyClose('r3_after', await roomPolyN('r3'),
|
||||
[[0.65, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86], [0.55, 0.58], [0.65, 0.58]]);
|
||||
const op2 = await openingOf('op2');
|
||||
check('opening_travels', Math.abs(op2.x - 0.65) < 1e-4 && Math.abs(op2.y - 0.30) < 1e-6);
|
||||
|
||||
// ---------- undo: one release = one step back ----------
|
||||
await page.keyboard.press('Control+z');
|
||||
await settle();
|
||||
polyClose('undo_r1', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
|
||||
polyClose('undo_r2', await roomPolyN('r2'), [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]]);
|
||||
polyClose('undo_r3', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
|
||||
const op2b = await openingOf('op2');
|
||||
check('undo_opening_back', Math.abs(op2b.x - 0.55) < 1e-9);
|
||||
await restore();
|
||||
|
||||
// ---------- stop: the shrinking neighbour keeps ~30 cm ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(550, 360);
|
||||
const [bx] = await screenPt(1050, 360); // far past r2/r3 (right walls at 0.96)
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, ay, { steps: 6 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r1 = await roomPolyN('r1');
|
||||
const r2 = await roomPolyN('r2');
|
||||
const width2 = (r2[1][0] - r2[0][0]) * 1000;
|
||||
check('neighbour_min_stop', Math.abs(r1[1][0] - 0.93333) < 0.005); // clamped, NOT 1.05
|
||||
check('neighbour_min_width', width2 >= 25 - 1e-6 && width2 <= 25 + 1000 / 240 + 1e-6);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- stop: an opening anchors the shortening wall ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
// a door on r1's LEFT wall (x=0.04), spans y 0.16..0.24. Dragging r1's TOP
|
||||
// wall down shortens that wall from above — it must stop at the door edge.
|
||||
// (No collinear neighbour wall can adopt this door — a real «упор».)
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [{ id: 'op1', type: 'door', x: 0.04, y: 0.20, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
});
|
||||
await settle();
|
||||
const [ax, ay] = await screenPt(295, 140); // r1 top-wall handle
|
||||
const [, by] = await screenPt(295, 300); // try to push far past the door top (y=160)
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(ax, by, { steps: 5 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r1 = await roomPolyN('r1');
|
||||
const topY = r1[0][1] * 1000;
|
||||
check('opening_stop', topY <= 160 + 1e-6); // never below the door top
|
||||
check('opening_stop_close', topY >= 160 - 1000 / 240 - 1e-6); // and clamped right at it
|
||||
const op1 = await openingOf('op1');
|
||||
check('anchored_opening_static', Math.abs(op1.y - 0.2) < 1e-9 && Math.abs(op1.x - 0.04) < 1e-9);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- the scale frame: proportional, neighbours stay, stops work ----------
|
||||
await enter('resize');
|
||||
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
check('frame_corners', await page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszcorner').length), 4);
|
||||
check('frame_present', await page.evaluate(() => !!window.__card.renderRoot.querySelector('.rszframe')));
|
||||
{
|
||||
// shrink about the top-left corner (40,580): drag br (550,860) toward it
|
||||
const [ax, ay] = await screenPt(550, 860);
|
||||
const [bx, by] = await screenPt(448, 804); // ≈ k=0.8
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 5 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r4 = await roomPolyN('r4');
|
||||
const base = [[0.04, 0.58], [0.55, 0.58], [0.55, 0.86], [0.04, 0.86]];
|
||||
// ALL vertices scale by the SAME factor about the fixed corner (0.04, 0.58)
|
||||
const ks = base.map((b, i) => {
|
||||
const dx = b[0] - 0.04, dy = b[1] - 0.58;
|
||||
if (Math.abs(dx) > 1e-9) return (r4[i][0] - 0.04) / dx;
|
||||
if (Math.abs(dy) > 1e-9) return (r4[i][1] - 0.58) / dy;
|
||||
return null;
|
||||
}).filter((k) => k != null);
|
||||
const kAvg = ks.reduce((a, b) => a + b, 0) / ks.length;
|
||||
check('scale_shrinks', kAvg > 0.7 && kAvg < 0.9);
|
||||
check('scale_proportional', ks.every((k) => Math.abs(k - kAvg) < 1e-3));
|
||||
const ky = base.map((b, i) => (Math.abs(b[1] - 0.58) > 1e-9 ? (r4[i][1] - 0.58) / (b[1] - 0.58) : null)).filter((k) => k != null);
|
||||
check('scale_uniform_xy', ky.every((k) => Math.abs(k - kAvg) < 1e-3));
|
||||
// neighbours did NOT move (scale never drags them — docs/RESIZE.md exception)
|
||||
polyClose('scale_r1_static', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
|
||||
polyClose('scale_r3_static', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
|
||||
}
|
||||
await restore();
|
||||
await enter('resize');
|
||||
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
{
|
||||
// growing about the top-left corner immediately runs into r3 → the scale stops at ≈1
|
||||
const [ax, ay] = await screenPt(550, 860);
|
||||
const [bx, by] = await screenPt(805, 1000); // ≈ k=1.5
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 5 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r4 = await roomPolyN('r4');
|
||||
check('scale_neighbour_stop', Math.abs(r4[1][0] - 0.55) < 0.005 && Math.abs(r4[2][1] - 0.86) < 0.005);
|
||||
polyClose('scale_stop_r3_static', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- Esc cancels the current drag ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(550, 360);
|
||||
const [bx] = await screenPt(700, 360);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, ay, { steps: 4 });
|
||||
await settle();
|
||||
const midPoly = await roomPolyN('r1');
|
||||
check('esc_preview_moved', Math.abs(midPoly[1][0] - 0.70) < 0.011);
|
||||
await page.keyboard.press('Escape');
|
||||
await settle();
|
||||
polyClose('esc_restores_r1', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
|
||||
polyClose('esc_restores_r2', await roomPolyN('r2'), [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]]);
|
||||
check('esc_no_badges', (await labels()).length, 0);
|
||||
await page.mouse.up();
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- leaving the tool removes every handle ----------
|
||||
await enter('merge');
|
||||
check('merge_tool_no_handles', await handleCount(), 0);
|
||||
await page.evaluate(() => { window.__card._setMode('devices'); });
|
||||
await settle();
|
||||
check('devices_mode_no_handles', await handleCount(), 0);
|
||||
|
||||
await finish(browser, { done: true });
|
||||
@@ -144,6 +144,53 @@ out.ownerSecondSwitchOk = out.editorZoomNeverInSpaceStore.secondSwitch;
|
||||
out.ownerLsClean = out.editorZoomNeverInSpaceStore.lsClean;
|
||||
delete out.editorZoomNeverInSpaceStore;
|
||||
|
||||
// -- HP-1543-01: смена этажа ВНУТРИ редактора не тащит editor zoom в view --
|
||||
// view-зум сохранён на обоих этажах → редактор на f1 → переключение ВКЛАДКОЙ
|
||||
// на garden → editor zoom 5.0 → выход в view (остаёмся на garden): вид обязан
|
||||
// вернуться к сохранённому view-зуму garden (1.4) с штатным центром
|
||||
// _restoreZoom(), а не остаться на редакторских 500% (снапшот-guard по space
|
||||
// отбрасывал восстановление, а текущий _zoom так и оставался редакторским).
|
||||
out.crossFloorEditorExit = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
c._setMode('view');
|
||||
c._resetZoom(); c._zoomAt(10, 10, 1.6); c._saveZoom(); // f1 view zoom
|
||||
c._slideTo('garden', 'left'); await wait(350);
|
||||
c._resetZoom(); c._zoomAt(10, 10, 1.4); c._saveZoom(); // garden view zoom
|
||||
c._slideTo('f1', 'right'); await wait(350);
|
||||
c._setMode('devices'); await raf2();
|
||||
// этаж меняется НАСТОЯЩЕЙ вкладкой — именно этот путь под подозрением
|
||||
const idx = c._model.findIndex((s) => s.id === 'garden');
|
||||
sr.querySelectorAll('.tabs .tab')[idx].click();
|
||||
await c.updateComplete; await raf2();
|
||||
c._zoomAt(10, 10, 5.0); c._saveZoom(); // 500% — рабочий инструмент
|
||||
c._setMode('view'); await raf2(); await wait(60);
|
||||
const stillGarden = c._space === 'garden';
|
||||
const gotZoom = c._zoom;
|
||||
const v = c._view; const vb = c._baseVb();
|
||||
const centred = !!v && Math.abs(v.x + v.w / 2 - (vb[0] + vb[2] / 2)) < 1
|
||||
&& Math.abs(v.y + v.h / 2 - (vb[1] + vb[3] / 2)) < 1;
|
||||
const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
|
||||
const store = { ...c._zoomBySpace };
|
||||
c._slideTo('f1', 'right'); await wait(350); c._resetZoom(); // вернуть сцену следующим тестам
|
||||
return {
|
||||
stillGarden,
|
||||
viewZoomRestoredCrossFloor: Math.abs(gotZoom - 1.4) < 0.01, // было 5.0 (HP-1543-01)
|
||||
centerRestoredCrossFloor: centred, // центр как у _restoreZoom()
|
||||
storeIntactCrossFloor: Math.abs((store.garden || 1) - 1.4) < 0.01, // _zoomBySpace не тронут
|
||||
lsIntactCrossFloor: Math.abs((ls.garden || 1) - 1.4) < 0.01
|
||||
&& Math.abs((ls.f1 || 1) - 1.6) < 0.01, // LS_ZOOM не тронут
|
||||
};
|
||||
});
|
||||
out.crossFloorStillGarden = out.crossFloorEditorExit.stillGarden;
|
||||
out.crossFloorViewZoomRestored = out.crossFloorEditorExit.viewZoomRestoredCrossFloor;
|
||||
out.crossFloorCenterRestored = out.crossFloorEditorExit.centerRestoredCrossFloor;
|
||||
out.crossFloorStoreIntact = out.crossFloorEditorExit.storeIntactCrossFloor;
|
||||
out.crossFloorLsIntact = out.crossFloorEditorExit.lsIntactCrossFloor;
|
||||
delete out.crossFloorEditorExit;
|
||||
|
||||
// -- devices outside rooms stretch the default frame ---------------------
|
||||
out.devicesStretchFrame = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+98
-42
File diff suppressed because one or more lines are too long
@@ -1,5 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
## v1.55.0 — 2026-08-01
|
||||
|
||||
Minor release: rooms are no longer set in stone — a dedicated Resize
|
||||
tool in the plan editor changes their size by dragging walls; plus the
|
||||
two findings of the v1.54.3 audit (HP-1543-01/02).
|
||||
|
||||
- **Room resize — a new «Размер» tool in the plan editor** (spec:
|
||||
docs/RESIZE.md). Every wall gets a handle at its midpoint; drag it
|
||||
and the whole wall moves, staying parallel to itself, with grid
|
||||
snap. Works for polygons (L-shapes included) and for legacy
|
||||
rectangles alike.
|
||||
- **Shared walls always move together.** If the dragged wall coincides
|
||||
with a neighbour's boundary, the neighbour follows: your room grows,
|
||||
the neighbour shrinks — gaps and overlaps cannot appear by
|
||||
construction. At T-junctions only the coinciding stretch follows,
|
||||
inserting new corners into the neighbour's outline where needed.
|
||||
- **Live numbers while you drag.** The dragged wall and its two
|
||||
adjacent walls show their lengths in real meters/feet, and the room
|
||||
area in m² updates live at the room centre — for a shared wall, the
|
||||
areas of both rooms.
|
||||
- **The wall stops where it must.** Rooms cannot get thinner than
|
||||
~30 cm (neither yours nor the neighbour's), an outline never crosses
|
||||
itself, a growing wall stops at foreign rooms and island rooms, and
|
||||
doors and windows are anchors: an opening travels with its wall, and
|
||||
a wall carrying openings can never get too short for them.
|
||||
- **A scale frame for the whole room.** Click a room in the resize
|
||||
tool to select it: dragging a corner of the dashed frame scales the
|
||||
outline proportionally, with the same stops applied.
|
||||
- **Esc and Ctrl+Z.** Esc cancels the current drag and puts the
|
||||
original geometry back; one released drag is one undo step, and
|
||||
Ctrl+Z walks back up to 30 of them while the tool is active.
|
||||
- **Fix: room overlap detection missed equal-height rectangles slid
|
||||
over each other.** Found while building the resize stops; the
|
||||
slide-over case now counts as an overlap everywhere the check is
|
||||
consulted, including the draw tool.
|
||||
- **Fix (HP-1543-01): editor zoom stayed on screen after a floor
|
||||
switch made inside the editor.** Exiting an editor on a different
|
||||
floor than the one you started on skipped the viewport restore and
|
||||
left the editor working zoom in view mode. Such an exit now falls
|
||||
back to the current floor's saved view zoom.
|
||||
- **Fix (HP-1543-02): a motion sensor re-tripped mid-flash stayed
|
||||
invisible.** A rapid off→on before the current flash ended kept the
|
||||
old CSS animation timeline, so the second detection played nothing.
|
||||
Every witnessed trip now gets a fresh animation identity and the
|
||||
flash restarts; under reduced motion the static ring stays, as
|
||||
before.
|
||||
|
||||
## v1.54.3 — 2026-08-01
|
||||
|
||||
Patch release: three quality-of-life rounds — icon satellites now follow
|
||||
|
||||
@@ -6,6 +6,53 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.55.0 — 2026-08-01
|
||||
|
||||
Минорный релиз: комнаты больше не высечены в камне — новый инструмент
|
||||
«Размер» в редакторе плана меняет их габариты перетаскиванием стен;
|
||||
плюс обе находки аудита v1.54.3 (HP-1543-01/02).
|
||||
|
||||
- **Ресайз комнат — новый инструмент «Размер» в редакторе плана**
|
||||
(спека: docs/RESIZE.md). У каждой стены появляется ручка в середине;
|
||||
тяните её — и стена едет целиком, оставаясь параллельной себе, с
|
||||
привязкой к сетке. Работает и для полигонов (включая Г-образные), и
|
||||
для старых прямоугольников.
|
||||
- **Общие стены всегда двигаются вместе.** Если перетаскиваемая стена
|
||||
совпадает с границей соседа, сосед следует за ней: ваша комната
|
||||
растёт — соседняя сжимается, щели и наложения не появляются по
|
||||
построению. На Т-стыках следует только совпадающий отрезок, при
|
||||
необходимости в контур соседа вставляются новые вершины.
|
||||
- **Живые цифры во время перетаскивания.** Перетаскиваемая стена и две
|
||||
смежные показывают длины в реальных метрах/футах, а площадь комнаты
|
||||
в м² обновляется в центре комнаты вживую — для общей стены — площади
|
||||
обеих комнат.
|
||||
- **Стена упирается там, где должна.** Комната не может стать тоньше
|
||||
~30 см (ни своя, ни соседская), контур не пересекает сам себя,
|
||||
растущая стена останавливается о чужие комнаты и острова, а двери и
|
||||
окна — якоря: проём едет вместе со стеной, и стена с проёмами не
|
||||
может стать для них слишком короткой.
|
||||
- **Рамка масштабирования всей комнаты.** Клик по комнате в
|
||||
инструменте выбирает её: перетаскивание уголка пунктирной рамки
|
||||
пропорционально масштабирует контур — с теми же упорами.
|
||||
- **Esc и Ctrl+Z.** Esc отменяет текущее перетаскивание и возвращает
|
||||
исходную геометрию; одно отпускание ручки — один шаг отмены, Ctrl+Z
|
||||
откатывает до 30 шагов, пока инструмент активен.
|
||||
- **Исправлено: детекция наложения комнат пропускала равновысокие
|
||||
прямоугольники, надвинутые друг на друга.** Найдено при постройке
|
||||
упоров ресайза; slide-over теперь считается наложением везде, где
|
||||
используется эта проверка, включая инструмент рисования.
|
||||
- **Исправлено (HP-1543-01): редакторский зум оставался на экране
|
||||
после смены этажа внутри редактора.** Выход из редактора на другом
|
||||
этаже, чем тот, где начинали, пропускал восстановление вьюпорта и
|
||||
оставлял рабочий зум редактора в режиме просмотра. Теперь такой
|
||||
выход откатывается к сохранённому view-зуму текущего этажа.
|
||||
- **Исправлено (HP-1543-02): датчик движения, сработавший повторно
|
||||
посреди вспышки, оставался невидимым.** Быстрое off→on до конца
|
||||
текущей вспышки не перезапускало CSS-анимацию — второе срабатывание
|
||||
ничего не проигрывало. Теперь каждый зафиксированный trip получает
|
||||
новую идентичность анимации, и вспышка стартует заново; при
|
||||
prefers-reduced-motion остаётся статичное кольцо, как и раньше.
|
||||
|
||||
## v1.54.3 — 2026-08-01
|
||||
|
||||
Патч-релиз: три раунда качества жизни — спутники значка теперь слушаются
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Room resize — the spec (source of truth)
|
||||
|
||||
Status: approved by the owner 2026-08-01. Dev-branch feature, **no
|
||||
release**. Scope decisions final: a dedicated tool mode, wall-drag with
|
||||
shared walls always moving together, a corner-scale frame for the
|
||||
selected room, live numbers (wall lengths + room areas), grid snap,
|
||||
Esc-cancel, one drag = one undo step.
|
||||
|
||||
## Principle
|
||||
|
||||
Until now room geometry could only be changed by split/merge or by
|
||||
redrawing the outline — there was no vertex or wall dragging at all
|
||||
(the `.rlhandle` corners belong to the room LABEL card, not to the
|
||||
room). Resize adds exactly two mechanisms, both living ONLY inside a
|
||||
dedicated Plan-editor tool «Изменение размера комнат» (`_tool ===
|
||||
'resize'`). In every other tool the plan looks and behaves exactly as
|
||||
before — no handles, no new hit areas.
|
||||
|
||||
## Mechanism A — wall drag
|
||||
|
||||
- Every visible room shows a small handle at the midpoint of every
|
||||
wall (handles for all rooms at once — owner's UX pick; they are
|
||||
finger-sized but unobtrusive, radius derived from `view.w` like
|
||||
`.vacfithandle`).
|
||||
- Dragging a handle moves the wall along its outward normal; **both
|
||||
ends of the edge translate together** (the wall stays parallel to
|
||||
itself; adjacent walls stretch/shrink). Works for any polygon
|
||||
(L-shaped included) and for legacy `x/y/w/h` rectangles — those are
|
||||
converted through `roomPoly` and are **saved back as `poly`**.
|
||||
- The moved wall position snaps to the drawing grid (`snapToGrid`,
|
||||
same pitch as the draw tool).
|
||||
|
||||
## Shared walls — ALWAYS together
|
||||
|
||||
If a stretch of the dragged wall coincides with a neighbour's boundary
|
||||
(collinear overlap with an epsilon, the `sharedBoundary` notion), the
|
||||
coinciding stretches of the neighbour move synchronously: your room
|
||||
grows — the neighbour shrinks. Gaps and overlaps cannot appear by
|
||||
construction.
|
||||
|
||||
Partial contact (T-junctions): only the coinciding stretch of the
|
||||
neighbour moves. Where the stretch ends inside a neighbour wall, new
|
||||
vertices are inserted into the neighbour outline, which may legally
|
||||
become L-shaped. All of this is shown as a live preview during the
|
||||
drag. On commit collinear leftovers are simplified away
|
||||
(`simplifyPoly`), so geometry stays clean.
|
||||
|
||||
## Stops (the wall stops dead)
|
||||
|
||||
1. **Minimum size** — neither the own room nor a shrinking neighbour
|
||||
may get thinner than ~30 cm (`MIN_ROOM_CM`, expressed in canvas
|
||||
units through `cell_cm`). Measured as the normal clearance between
|
||||
the moved stretch and any parallel opposite wall with overlapping
|
||||
projection. Rooms that are ALREADY thinner keep their clearance
|
||||
(the drag may improve it, never worsen it).
|
||||
2. **Self-intersection** — a wall never passes through the opposite
|
||||
side; the outline must stay a simple polygon with its orientation
|
||||
and a positive area.
|
||||
3. **Foreign rooms** — a growing wall stops when it would overlap a
|
||||
room that is not a shared-wall neighbour (`roomsOverlap`; touching
|
||||
walls are legal, crossing is not).
|
||||
4. **Island rooms** — islands inside the room (`islandsOf`) must stay
|
||||
fully inside; a wall shrinking onto an island stops.
|
||||
5. **Openings are anchors** — a door/window ON the moving stretch
|
||||
travels with the wall (its `openings[].x/y` centre is shifted, the
|
||||
angle is unchanged). A wall that carries openings cannot get too
|
||||
short for them: every opening previously sitting on a wall of an
|
||||
affected room must still fit fully on some wall afterwards — for
|
||||
the own room AND for the neighbour.
|
||||
|
||||
## Mechanism B — the scale frame
|
||||
|
||||
- In the resize tool a click inside a room SELECTS it: a dashed
|
||||
bounding frame with 4 corner handles appears.
|
||||
- Dragging a corner scales ALL vertices proportionally (uniform
|
||||
similarity) about the opposite bbox corner — the same maths family
|
||||
as the vacuum fit panel (`reanchorFit`), only without rotation.
|
||||
- The same stops apply (minimum size, foreign overlap, islands,
|
||||
openings; self-intersection is impossible under a similarity).
|
||||
- **The one exception to «shared walls always together»:** a scale
|
||||
breaks collinear coincidence (walls move apart at an angle-preserving
|
||||
ratio, not along a normal), so neighbours are NOT dragged along.
|
||||
Growing into a neighbour simply stops the scale (the neighbour is a
|
||||
wall to hit); shrinking away from a neighbour legally opens a gap.
|
||||
- Openings exclusive to the scaled room follow the transform
|
||||
(position scales, physical length does not); openings on a wall
|
||||
shared with an unchanged neighbour stay with the neighbour's wall.
|
||||
|
||||
## Live numbers
|
||||
|
||||
While a handle is being dragged:
|
||||
|
||||
- length badges (`.measurelabel` style, `segmentCm`/`formatLength`,
|
||||
metric or imperial per the HA unit system) on the dragged wall and
|
||||
its two adjacent walls;
|
||||
- the room area in m² (`polygonArea` × scale²) at the room centre,
|
||||
live; when a shared wall is dragged — the areas of BOTH rooms
|
||||
(owner picked «стены + площадь»);
|
||||
- Esc cancels the current drag and puts the original geometry back;
|
||||
- releasing the handle commits: one write through the standard
|
||||
debounced `_saveConfig` path.
|
||||
|
||||
## Undo
|
||||
|
||||
One operation (handle release that changed something) = one undo step.
|
||||
The plan editor has no committed-operation undo stack (Ctrl+Z/Esc only
|
||||
walk back draw/split points), so the resize tool keeps its own stack
|
||||
of pre-drag snapshots (rooms + openings of the space, capped at 30)
|
||||
and Ctrl+Z/⌘Z pops it while the tool is active.
|
||||
|
||||
## Out of scope / invariants
|
||||
|
||||
- Device positions are not touched; the room settings button (pole of
|
||||
inaccessibility) recomputes itself from the new outline.
|
||||
- Saving goes through the standard config path (`houseplan/config/set`
|
||||
with `expected_rev`); backend validation already covers polygons
|
||||
(`_GEOM` ±4, `MAX_POLY_POINTS` 500) — inserted neighbour vertices are
|
||||
just polygon points, openings keep their schema, nothing new to
|
||||
validate server-side.
|
||||
- Touch: handles are finger-sized, use pointer capture and swallow
|
||||
`pointerdown`, so the stage pan/pinch never fights a handle drag.
|
||||
- The label-card corners (`.rlhandle`, `_rlResizeDown`) are untouched.
|
||||
|
||||
## Geometry home
|
||||
|
||||
All pure geometry lives in `src/resize.ts` (edge normals, edge move,
|
||||
shared-span search and vertex insertion, all stops, the scale clamp,
|
||||
area formatting) under node:test units in `test/resize.test.mjs`;
|
||||
`src/houseplan-card.ts` only wires pointers, preview, badges and undo.
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.54.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.55.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.54.3** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.55.0** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
|
||||
|
||||
@@ -833,3 +833,36 @@ require hands on real hardware — they remain for the human pass.
|
||||
clicks must land on the overlay (elementFromPoint smoke guards it).
|
||||
- Multi-floor: one matrix per robot map (Dreame `selected_map` on the
|
||||
vacuum entity names the active one).
|
||||
|
||||
## Room resize (docs/RESIZE.md)
|
||||
|
||||
- [ ] The «Размер» tool appears in the Plan editor toolbar; in EVERY other
|
||||
tool (and in Devices/Decor/View) there is not a single `.rszhandle`
|
||||
- [ ] Handles sit at the midpoint of every wall of every room; they are
|
||||
finger-sized, capture the pointer and never start a stage pan
|
||||
- [ ] Dragging a handle moves the wall along its normal, both edge ends
|
||||
together; the wall line snaps to the drawing grid
|
||||
- [ ] A wall fully shared with a neighbour drags the neighbour's wall with
|
||||
it — the neighbour shrinks/grows, no gap and no overlap can appear
|
||||
- [ ] A partially shared wall (T-junction) moves only the coinciding
|
||||
stretch: the neighbour becomes L-shaped (new vertices), shown live
|
||||
- [ ] Legacy x/y/w/h rectangle rooms resize the same way and are saved back
|
||||
as polygons
|
||||
- [ ] Live badges while dragging: lengths of the dragged wall + both
|
||||
adjacent walls, and the m² area at the room centre; dragging a shared
|
||||
wall shows BOTH areas; all numbers update continuously
|
||||
- [ ] Stops: ~30 cm minimum for the own room AND the shrinking neighbour;
|
||||
a foreign room in the path (touch is ok, overlap never); islands
|
||||
inside; a door/window on a shortening wall (the wall corner can not
|
||||
pass the opening edge)
|
||||
- [ ] A door/window ON the moving wall travels with it (openings x/y
|
||||
recompute; angle unchanged)
|
||||
- [ ] Esc mid-drag cancels: the original geometry is back instantly
|
||||
- [ ] Click a room in the resize tool → dashed bbox frame with 4 corner
|
||||
handles; dragging a corner scales all vertices proportionally about
|
||||
the opposite corner; neighbours are NOT dragged along (the one
|
||||
exception to «shared walls together»), growing into one stops
|
||||
- [ ] Ctrl+Z / ⌘Z after releasing a handle restores the previous geometry —
|
||||
one release = one undo step (rooms AND openings)
|
||||
- [ ] Device markers do not move; the room settings gear re-centres itself
|
||||
- [ ] Smoke: `node demo/smoke_room_resize.mjs`
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.54.3",
|
||||
"version": "1.55.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.54.3",
|
||||
"version": "1.55.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.54.3",
|
||||
"version": "1.55.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+337
-8
@@ -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,
|
||||
@@ -42,7 +46,7 @@ import { cardStyles } from './styles';
|
||||
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.54.3';
|
||||
const CARD_VERSION = '1.55.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';
|
||||
@@ -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;
|
||||
@@ -362,7 +385,7 @@ class HouseplanCard extends LitElement {
|
||||
the flash is keyed to the TRANSITION, not to the 'on' state. `timer`
|
||||
is the one setTimeout per entry that repaints the card when the flash
|
||||
window closes (cleared in disconnectedCallback). */
|
||||
private _senseRt = new Map<string, { last: string; flashTs: number; timer: number }>();
|
||||
private _senseRt = new Map<string, { last: string; flashTs: number; timer: number; gen: number }>();
|
||||
/** live-vacuum runtime per marker: RAW robot coords (matrix applied at render) */
|
||||
private _vacRt = new Map<string, { trail: VacPt[]; lastKey: string; lastTs: number;
|
||||
moving: boolean; jump: boolean; endedTs: number; lastPos: VacPt | 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') {
|
||||
@@ -1331,7 +1371,12 @@ class HouseplanCard extends LitElement {
|
||||
// the state has not changed. A new off→on trip re-arms the flash.
|
||||
if (dc === 'motion') {
|
||||
const rt = this._senseRt.get(d.id);
|
||||
return rt && rt.flashTs && Date.now() - rt.flashTs < SENSE_FLASH_MS ? 'senseflash' : '';
|
||||
if (!(rt && rt.flashTs && Date.now() - rt.flashTs < SENSE_FLASH_MS)) return '';
|
||||
// HP-1543-02: alternate the animation identity per trip (see
|
||||
// _senseTick) — odd generations ride the base hp-sense keyframes,
|
||||
// even ones the identical hp-sense-b twin, so a rapid re-trip
|
||||
// restarts the flash instead of silently inheriting the old timeline
|
||||
return rt.gen % 2 === 0 ? 'senseflash sf2' : 'senseflash';
|
||||
}
|
||||
// OCCUPANCY/PRESENCE while 'on': a calm STATIC ring ('sensehold',
|
||||
// no animation in styles.ts) — «комната обитаема» is a state, not
|
||||
@@ -1632,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;
|
||||
@@ -1923,8 +1968,7 @@ class HouseplanCard extends LitElement {
|
||||
if (mode === 'view') {
|
||||
const snap = this._viewModeSnap;
|
||||
this._viewModeSnap = null;
|
||||
// restore only for the space the snapshot was taken in — after a space
|
||||
// switch inside the editor the saved per-space zoom already applies
|
||||
// restore the snapshot only for the space it was taken in
|
||||
if (snap && snap.space === this._space) {
|
||||
this._zoom = snap.zoom;
|
||||
this._view = null;
|
||||
@@ -1934,6 +1978,17 @@ class HouseplanCard extends LitElement {
|
||||
this._saveZoom(); // editor wheel zoom wrote itself to LS_ZOOM — put the view zoom back
|
||||
this.requestUpdate();
|
||||
});
|
||||
} else if (snap) {
|
||||
// HP-1543-01: the floor CHANGED inside the editor — the snapshot
|
||||
// belongs to the floor the editor was entered from, while _zoom still
|
||||
// carries the editor's working zoom for the floor we are exiting on.
|
||||
// Dropping the snapshot alone left that editor zoom (say 500%) on
|
||||
// screen in view mode. The per-space store was never polluted
|
||||
// (_saveZoom is view-only), so the standard centred
|
||||
// _restoreZoom() puts back this floor's saved VIEW viewport. Its rAF
|
||||
// reads _zoomBySpace, not the snapshot, so the same-tick floor-tab
|
||||
// race the view-only guard protects against stays closed.
|
||||
this._restoreZoom();
|
||||
}
|
||||
}
|
||||
this._path = [];
|
||||
@@ -1944,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;
|
||||
@@ -2078,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));
|
||||
@@ -2139,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;
|
||||
@@ -4346,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}">
|
||||
@@ -4361,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>`
|
||||
@@ -4462,9 +4775,19 @@ class HouseplanCard extends LitElement {
|
||||
const p = this.hass.states[d.primary];
|
||||
if (p?.attributes?.device_class !== 'motion') continue;
|
||||
const rt = this._senseRt.get(d.id);
|
||||
if (!rt) { this._senseRt.set(d.id, { last: p.state, flashTs: 0, timer: 0 }); continue; }
|
||||
if (!rt) { this._senseRt.set(d.id, { last: p.state, flashTs: 0, timer: 0, gen: 0 }); continue; }
|
||||
if (p.state === 'on' && rt.last === 'off') {
|
||||
rt.flashTs = Date.now();
|
||||
// HP-1543-02: a retrip BEFORE the previous flash finished kept the
|
||||
// same 'senseflash' class and the same animation-name on the same
|
||||
// pseudo-element — browsers never restart such a CSS animation, so
|
||||
// the second detection played nothing once the first one-shot had
|
||||
// ended (base opacity 0). Every trip bumps the generation;
|
||||
// _stateClass maps its parity to alternating keyframe names
|
||||
// (hp-sense / hp-sense-b in styles.ts), which is a NEW animation
|
||||
// identity and forces a fresh timeline per detection. No class is
|
||||
// ever removed mid-flash, so a lone first flash still plays whole.
|
||||
rt.gen++;
|
||||
clearTimeout(rt.timer);
|
||||
rt.timer = window.setTimeout(() => this.requestUpdate(), SENSE_FLASH_MS + 60);
|
||||
}
|
||||
@@ -5774,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')}>
|
||||
@@ -5795,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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Новый проём",
|
||||
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Room resize geometry — pure functions only (docs/RESIZE.md).
|
||||
*
|
||||
* Mechanism A: dragging a wall along its normal, shared stretches of
|
||||
* neighbours move together (T-junctions insert vertices). Mechanism B:
|
||||
* uniform scale of one room about a bbox corner. Every stop («упор») is
|
||||
* decided here so it can be unit-tested; the card only wires pointers.
|
||||
*
|
||||
* All coordinates are render units (NORM_W-scaled), same as the card's
|
||||
* space model. Nothing here touches Lit or the DOM.
|
||||
*/
|
||||
import { intersection } from 'polyclip-ts';
|
||||
import {
|
||||
polygonArea, segmentsProperlyCross, polyContainsPoly, roomsOverlap,
|
||||
} from './logic';
|
||||
|
||||
/** Minimal room dimension in centimetres (owner: «мин. габарит ~30 см»). */
|
||||
export const MIN_ROOM_CM = 30;
|
||||
|
||||
export interface RoomIn { id: string; poly: number[][] }
|
||||
/** Opening in render units: centre, wall angle (deg), full length. */
|
||||
export interface OpeningIn { id: string; x: number; y: number; length: number }
|
||||
|
||||
export interface EdgeDragPlan {
|
||||
roomId: string;
|
||||
edge: number; // edge index i: v[i] -> v[i+1]
|
||||
a: number[]; // edge endpoints BEFORE the drag
|
||||
b: number[];
|
||||
n: [number, number]; // outward unit normal (d > 0 grows the room)
|
||||
}
|
||||
|
||||
export interface EdgeDragResult {
|
||||
/** roomId -> new outline (only rooms that changed). */
|
||||
polys: Record<string, number[][]>;
|
||||
/** openingId -> new centre (only openings that travelled with the wall). */
|
||||
openings: Record<string, [number, number]>;
|
||||
/** roomId -> the moved stretches AFTER the move (for clearance/labels). */
|
||||
movedSpans: Record<string, [number[], number[]][]>;
|
||||
}
|
||||
|
||||
// ---------------- tiny vector helpers ----------------
|
||||
|
||||
const sub = (p: number[], q: number[]) => [p[0] - q[0], p[1] - q[1]];
|
||||
const add2 = (p: number[], d: number[]) => [p[0] + d[0], p[1] + d[1]];
|
||||
const dot = (p: number[], q: number[]) => p[0] * q[0] + p[1] * q[1];
|
||||
const len2d = (p: number[]) => Math.hypot(p[0], p[1]);
|
||||
|
||||
function signedArea(poly: number[][]): number {
|
||||
let s = 0;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
s += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
return s / 2;
|
||||
}
|
||||
|
||||
function pointInPoly(p: number[], poly: number[][]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const xi = poly[i][0], yi = poly[i][1], xj = poly[j][0], yj = poly[j][1];
|
||||
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function distPointToSpan(p: number[], a: number[], b: number[]): number {
|
||||
const ab = sub(b, a);
|
||||
const l2 = dot(ab, ab);
|
||||
if (l2 < 1e-12) return len2d(sub(p, a));
|
||||
let t = dot(sub(p, a), ab) / l2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return len2d(sub(p, [a[0] + ab[0] * t, a[1] + ab[1] * t]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Outward unit normal of edge i. Candidate is the +90° rotation of the edge
|
||||
* direction; a probe point decides the sign, so polygon orientation (either
|
||||
* winding survives in real configs) does not matter.
|
||||
*/
|
||||
export function edgeNormal(poly: number[][], i: number): [number, number] {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
const d = sub(b, a);
|
||||
const l = len2d(d) || 1;
|
||||
let n: [number, number] = [d[1] / l, -d[0] / l];
|
||||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
const probe = Math.max(l * 0.01, 1e-4);
|
||||
if (pointInPoly([mid[0] + n[0] * probe, mid[1] + n[1] * probe], poly)) n = [-n[0], -n[1]];
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Translate BOTH endpoints of edge i by the normal times d (docs/RESIZE.md, mechanism A). */
|
||||
export function movePolyEdge(poly: number[][], i: number, d: number, n?: [number, number]): number[][] {
|
||||
const nn = n || edgeNormal(poly, i);
|
||||
const j = (i + 1) % poly.length;
|
||||
return poly.map((p, k) => (k === i || k === j ? [p[0] + nn[0] * d, p[1] + nn[1] * d] : [...p]));
|
||||
}
|
||||
|
||||
/** Collinear overlap stretches of `poly`'s edges with segment a-b, as [p,q] pairs (pre-move). */
|
||||
export function sharedSpansWith(poly: number[][], a: number[], b: number[], eps: number): [number[], number[]][] {
|
||||
const out: [number[], number[]][] = [];
|
||||
const ab = sub(b, a);
|
||||
const L = len2d(ab);
|
||||
if (L < eps) return out;
|
||||
const u = [ab[0] / L, ab[1] / L];
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
|
||||
const off1 = Math.abs((q1[0] - a[0]) * u[1] - (q1[1] - a[1]) * u[0]);
|
||||
const off2 = Math.abs((q2[0] - a[0]) * u[1] - (q2[1] - a[1]) * u[0]);
|
||||
if (off1 > eps || off2 > eps) continue; // not collinear with a-b
|
||||
const t1 = dot(sub(q1, a), u);
|
||||
const t2 = dot(sub(q2, a), u);
|
||||
const lo = Math.max(0, Math.min(t1, t2));
|
||||
const hi = Math.min(L, Math.max(t1, t2));
|
||||
if (hi - lo > eps) out.push([[a[0] + u[0] * lo, a[1] + u[1] * lo], [a[0] + u[0] * hi, a[1] + u[1] * hi]]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Neighbour sync: translate the stretches of `poly` that coincide with segment
|
||||
* a-b by vector D, inserting vertices at partial-contact boundaries (T-junction
|
||||
* → the neighbour may become L-shaped). Returns null when nothing coincides.
|
||||
*/
|
||||
export function shiftSharedSpans(
|
||||
poly: number[][], a: number[], b: number[], D: [number, number], eps: number,
|
||||
): number[][] | null {
|
||||
const spans = sharedSpansWith(poly, a, b, eps);
|
||||
if (!spans.length) return null;
|
||||
const onSpan = (p: number[]) => spans.some(([p1, p2]) => distPointToSpan(p, p1, p2) <= eps);
|
||||
const n = poly.length;
|
||||
const out: number[][] = [];
|
||||
for (let j = 0; j < n; j++) {
|
||||
const q1 = poly[j], q2 = poly[(j + 1) % n];
|
||||
out.push(onSpan(q1) ? add2(q1, D) : [...q1]);
|
||||
const e = sub(q2, q1);
|
||||
const elen = len2d(e);
|
||||
if (elen < eps) continue;
|
||||
const u = [e[0] / elen, e[1] / elen];
|
||||
// collinear with a-b? (both endpoints on the a-b LINE)
|
||||
const abL = len2d(sub(b, a)) || 1;
|
||||
const uv = [(b[0] - a[0]) / abL, (b[1] - a[1]) / abL];
|
||||
const o1 = Math.abs((q1[0] - a[0]) * uv[1] - (q1[1] - a[1]) * uv[0]);
|
||||
const o2 = Math.abs((q2[0] - a[0]) * uv[1] - (q2[1] - a[1]) * uv[0]);
|
||||
if (o1 > eps || o2 > eps) continue;
|
||||
const tA = dot(sub(a, q1), u);
|
||||
const tB = dot(sub(b, q1), u);
|
||||
const lo = Math.max(0, Math.min(tA, tB));
|
||||
const hi = Math.min(elen, Math.max(tA, tB));
|
||||
if (hi - lo <= eps) continue;
|
||||
// interior boundaries split the edge: entering the overlap emits the static
|
||||
// point then its moved copy, leaving it emits the moved copy then the static
|
||||
if (lo > eps && lo < elen - eps) {
|
||||
const p = [q1[0] + u[0] * lo, q1[1] + u[1] * lo];
|
||||
out.push([...p], add2(p, D));
|
||||
}
|
||||
if (hi > eps && hi < elen - eps) {
|
||||
const p = [q1[0] + u[0] * hi, q1[1] + u[1] * hi];
|
||||
out.push(add2(p, D), [...p]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop consecutive duplicates and collinear middle vertices (commit-time cleanup). */
|
||||
export function simplifyPoly(poly: number[][], eps = 1e-6): number[][] {
|
||||
let pts = poly.filter((p, i) => len2d(sub(p, poly[(i + 1) % poly.length])) > eps);
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
pts = pts.filter((p, i) => {
|
||||
const prev = pts[(i - 1 + pts.length) % pts.length];
|
||||
const next = pts[(i + 1) % pts.length];
|
||||
const cross = (p[0] - prev[0]) * (next[1] - prev[1]) - (p[1] - prev[1]) * (next[0] - prev[0]);
|
||||
const span = len2d(sub(next, prev)) || 1;
|
||||
return Math.abs(cross) / span > eps; // keep only real corners
|
||||
});
|
||||
}
|
||||
return pts.length >= 3 ? pts : poly;
|
||||
}
|
||||
|
||||
/** Simple polygon: no properly crossing edges (shared walls touching is fine). */
|
||||
export function polyIsSimple(poly: number[][]): boolean {
|
||||
const n = poly.length;
|
||||
if (n < 3) return false;
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue;
|
||||
if (segmentsProperlyCross(poly[i], poly[(i + 1) % n], poly[j], poly[(j + 1) % n])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normal clearance between the moved stretches and every PARALLEL wall of the
|
||||
* same room with an overlapping projection — the «opposite wall» distance that
|
||||
* enforces the 30 cm minimum. Infinity when no opposite wall exists.
|
||||
*/
|
||||
export function minParallelClearance(
|
||||
poly: number[][], spans: [number[], number[]][], eps = 1e-6,
|
||||
): number {
|
||||
let best = Infinity;
|
||||
for (const [a, b] of spans) {
|
||||
const ab = sub(b, a);
|
||||
const L = len2d(ab);
|
||||
if (L < eps) continue;
|
||||
const u = [ab[0] / L, ab[1] / L];
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
|
||||
const e = sub(q2, q1);
|
||||
const elen = len2d(e);
|
||||
if (elen < eps) continue;
|
||||
const cosang = Math.abs((e[0] * u[0] + e[1] * u[1]) / elen);
|
||||
if (cosang < 1 - 1e-4) continue; // not parallel
|
||||
// projection overlap along the span direction
|
||||
const t1 = dot(sub(q1, a), u);
|
||||
const t2 = dot(sub(q2, a), u);
|
||||
const lo = Math.max(0, Math.min(t1, t2));
|
||||
const hi = Math.min(L, Math.max(t1, t2));
|
||||
if (hi - lo <= eps) continue;
|
||||
const d1 = Math.abs((q1[0] - a[0]) * u[1] - (q1[1] - a[1]) * u[0]);
|
||||
if (d1 <= eps) continue; // the span itself / collinear leftovers
|
||||
if (d1 < best) best = d1;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two outlines ILLEGALLY share floor area? `roomsOverlap` alone misses the
|
||||
* «slide-over» case: equal-height rectangles overlapping horizontally have all
|
||||
* their edge intersections on collinear stretches, so nothing «properly
|
||||
* crosses» and nothing is strictly inside. A real polygon intersection area
|
||||
* settles it; legal full nesting (island rooms) stays legal.
|
||||
*/
|
||||
export function illegalOverlap(a: number[][], b: number[][], eps: number): boolean {
|
||||
if (roomsOverlap(a, b, eps)) return true;
|
||||
if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
|
||||
let area = 0;
|
||||
try {
|
||||
const res = intersection(
|
||||
[[...a.map((p) => [p[0], p[1]]), [a[0][0], a[0][1]]]] as any,
|
||||
[[...b.map((p) => [p[0], p[1]]), [b[0][0], b[0][1]]]] as any,
|
||||
);
|
||||
for (const poly of res as any) if (poly?.[0]) area += polygonArea(poly[0]);
|
||||
} catch {
|
||||
return false; // a degenerate clip must not block the drag; the other stops still hold
|
||||
}
|
||||
return area > Math.max(1e-7, eps * eps);
|
||||
}
|
||||
|
||||
// ---------------- mechanism A: the full drag pipeline ----------------
|
||||
|
||||
export function planEdgeDrag(rooms: RoomIn[], roomId: string, edge: number): EdgeDragPlan | null {
|
||||
const room = rooms.find((r) => r.id === roomId);
|
||||
if (!room || !room.poly || room.poly.length < 3) return null;
|
||||
if (edge < 0 || edge >= room.poly.length) return null;
|
||||
const a = [...room.poly[edge]];
|
||||
const b = [...room.poly[(edge + 1) % room.poly.length]];
|
||||
return { roomId, edge, a, b, n: edgeNormal(room.poly, edge) };
|
||||
}
|
||||
|
||||
/** Apply the drag at distance d: own edge + every coinciding neighbour stretch. */
|
||||
export function applyEdgeDrag(
|
||||
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, d: number, eps: number,
|
||||
): EdgeDragResult {
|
||||
const D: [number, number] = [plan.n[0] * d, plan.n[1] * d];
|
||||
const res: EdgeDragResult = { polys: {}, openings: {}, movedSpans: {} };
|
||||
if (Math.abs(d) < 1e-9) return res;
|
||||
for (const r of rooms) {
|
||||
if (r.id === plan.roomId) {
|
||||
res.polys[r.id] = movePolyEdge(r.poly, plan.edge, d, plan.n);
|
||||
res.movedSpans[r.id] = [[add2(plan.a, D), add2(plan.b, D)]];
|
||||
continue;
|
||||
}
|
||||
const spans = sharedSpansWith(r.poly, plan.a, plan.b, eps);
|
||||
if (!spans.length) continue;
|
||||
const shifted = shiftSharedSpans(r.poly, plan.a, plan.b, D, eps);
|
||||
if (shifted) {
|
||||
res.polys[r.id] = shifted;
|
||||
res.movedSpans[r.id] = spans.map(([p, q]) => [add2(p, D), add2(q, D)] as [number[], number[]]);
|
||||
}
|
||||
}
|
||||
// openings ON the moving wall travel with it (docs/RESIZE.md: anchors)
|
||||
for (const o of openings) {
|
||||
if (distPointToSpan([o.x, o.y], plan.a, plan.b) <= eps) res.openings[o.id] = [o.x + D[0], o.y + D[1]];
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** An opening must sit fully on ONE wall of some room: centre on the edge, both ends within it. */
|
||||
function openingFits(o: { x: number; y: number; length: number }, polys: number[][][], eps: number): boolean {
|
||||
for (const poly of polys) {
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
|
||||
const e = sub(q2, q1);
|
||||
const elen = len2d(e);
|
||||
if (elen < eps) continue;
|
||||
const u = [e[0] / elen, e[1] / elen];
|
||||
const off = Math.abs((o.x - q1[0]) * u[1] - (o.y - q1[1]) * u[0]);
|
||||
if (off > eps) continue;
|
||||
const t = (o.x - q1[0]) * u[0] + (o.y - q1[1]) * u[1];
|
||||
if (t - o.length / 2 >= -eps && t + o.length / 2 <= elen + eps) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Openings that sit on any wall of any of the given (pre-move) outlines. */
|
||||
function openingsOnRooms(openings: OpeningIn[], polys: number[][][], eps: number): OpeningIn[] {
|
||||
return openings.filter((o) =>
|
||||
polys.some((poly) => {
|
||||
for (let j = 0; j < poly.length; j++)
|
||||
if (distPointToSpan([o.x, o.y], poly[j], poly[(j + 1) % poly.length]) <= eps) return true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export interface StopOpts {
|
||||
minDim: number; // canvas units (≈30 cm through cell_cm)
|
||||
eps: number; // collinearity epsilon (canvas units)
|
||||
}
|
||||
|
||||
/** All the stops of docs/RESIZE.md for one candidate distance. */
|
||||
export function validateEdgeDrag(
|
||||
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, d: number, opts: StopOpts,
|
||||
): boolean {
|
||||
const { minDim, eps } = opts;
|
||||
if (!Number.isFinite(d)) return false;
|
||||
if (Math.abs(d) < 1e-9) return true;
|
||||
const res = applyEdgeDrag(rooms, openings, plan, d, eps);
|
||||
const changedIds = Object.keys(res.polys);
|
||||
const newPolyOf = (r: RoomIn) => res.polys[r.id] || r.poly;
|
||||
for (const id of changedIds) {
|
||||
const r = rooms.find((x) => x.id === id)!;
|
||||
const np = res.polys[id];
|
||||
// simple + orientation preserved + not degenerate
|
||||
if (!polyIsSimple(np)) return false;
|
||||
const s0 = signedArea(r.poly), s1 = signedArea(np);
|
||||
if (Math.abs(s1) < eps || s0 * s1 <= 0) return false;
|
||||
// minimum size: normal clearance of the moved stretches; a room already
|
||||
// thinner keeps its clearance (improving is allowed, worsening is not)
|
||||
const oldSpans: [number[], number[]][] = id === plan.roomId
|
||||
? [[plan.a, plan.b]]
|
||||
: sharedSpansWith(r.poly, plan.a, plan.b, eps);
|
||||
const cOld = minParallelClearance(r.poly, oldSpans, eps);
|
||||
const cNew = minParallelClearance(np, res.movedSpans[id] || [], eps);
|
||||
if (cNew < Math.min(minDim, cOld) - eps) return false;
|
||||
// every pre-existing room relationship must SURVIVE the drag: an island
|
||||
// stays an island (a jump fully past a thin island crosses no edge, so
|
||||
// containment is checked explicitly), a nested room stays inside its
|
||||
// parent, and unrelated rooms must not start sharing area or nesting
|
||||
for (const other of rooms) {
|
||||
if (other.id === id) continue;
|
||||
const otherNew = newPolyOf(other);
|
||||
if (polyContainsPoly(r.poly, other.poly, eps)) { // our island
|
||||
if (!polyContainsPoly(np, otherNew, eps)) return false;
|
||||
continue;
|
||||
}
|
||||
if (polyContainsPoly(other.poly, r.poly, eps)) { // we are the island
|
||||
if (!polyContainsPoly(otherNew, np, eps)) return false;
|
||||
continue;
|
||||
}
|
||||
if (polyContainsPoly(np, otherNew, eps) || polyContainsPoly(otherNew, np, eps)) return false;
|
||||
if (illegalOverlap(np, otherNew, eps)) return false;
|
||||
}
|
||||
}
|
||||
// openings: everything that sat on a wall of an affected room must still fit
|
||||
const oldPolys = changedIds.map((id) => rooms.find((x) => x.id === id)!.poly);
|
||||
const allNew = rooms.map(newPolyOf);
|
||||
for (const o of openingsOnRooms(openings, oldPolys, eps * 2)) {
|
||||
const c = res.openings[o.id];
|
||||
const moved = c ? { ...o, x: c[0], y: c[1] } : o;
|
||||
if (!openingFits(moved, allNew, eps * 2)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest valid distance toward dWanted, stepping back by `step` (the grid
|
||||
* pitch, so a stopped wall still lands on the grid). 0 = the wall stays put.
|
||||
*/
|
||||
export function clampEdgeDrag(
|
||||
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, dWanted: number, step: number, opts: StopOpts,
|
||||
): number {
|
||||
if (!Number.isFinite(dWanted) || Math.abs(dWanted) < 1e-9) return 0;
|
||||
const sign = Math.sign(dWanted);
|
||||
let mag = Math.abs(dWanted);
|
||||
const s = Math.max(step, 1e-6);
|
||||
for (let guard = 0; guard < 4096 && mag > 1e-9; guard++, mag -= s) {
|
||||
const d = sign * mag;
|
||||
if (validateEdgeDrag(rooms, openings, plan, d, opts)) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------- mechanism B: the scale frame ----------------
|
||||
|
||||
export interface ScaleResult {
|
||||
poly: number[][];
|
||||
openings: Record<string, [number, number]>;
|
||||
}
|
||||
|
||||
/** Uniform scale of the room about `fixed`; exclusive openings follow, shared ones stay. */
|
||||
export function applyRoomScale(
|
||||
room: RoomIn, openings: OpeningIn[], otherPolys: number[][][], fixed: [number, number], k: number, eps: number,
|
||||
): ScaleResult {
|
||||
const scalePt = (p: number[]) => [fixed[0] + (p[0] - fixed[0]) * k, fixed[1] + (p[1] - fixed[1]) * k];
|
||||
const res: ScaleResult = { poly: room.poly.map(scalePt), openings: {} };
|
||||
for (const o of openings) {
|
||||
let on = false;
|
||||
for (let j = 0; j < room.poly.length; j++)
|
||||
if (distPointToSpan([o.x, o.y], room.poly[j], room.poly[(j + 1) % room.poly.length]) <= eps) { on = true; break; }
|
||||
if (!on) continue;
|
||||
// an opening on a wall shared with a neighbour belongs to the neighbour's
|
||||
// wall once the scale detaches ours — it stays put (docs/RESIZE.md)
|
||||
const shared = otherPolys.some((poly) => {
|
||||
for (let j = 0; j < poly.length; j++)
|
||||
if (distPointToSpan([o.x, o.y], poly[j], poly[(j + 1) % poly.length]) <= eps) return true;
|
||||
return false;
|
||||
});
|
||||
if (!shared) {
|
||||
const c = scalePt([o.x, o.y]);
|
||||
res.openings[o.id] = [c[0], c[1]];
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Stops for one candidate scale factor (neighbours are never dragged along). */
|
||||
export function validateRoomScale(
|
||||
rooms: RoomIn[], openings: OpeningIn[], roomId: string, fixed: [number, number], k: number, opts: StopOpts,
|
||||
): boolean {
|
||||
const { minDim, eps } = opts;
|
||||
if (!Number.isFinite(k) || k <= 0) return false;
|
||||
if (Math.abs(k - 1) < 1e-9) return true;
|
||||
const room = rooms.find((r) => r.id === roomId);
|
||||
if (!room) return false;
|
||||
const otherPolys = rooms.filter((r) => r.id !== roomId).map((r) => r.poly);
|
||||
const res = applyRoomScale(room, openings, otherPolys, fixed, k, eps * 2);
|
||||
const np = res.poly;
|
||||
// minimum size: the bbox side (a similarity cannot self-intersect)
|
||||
const xs = np.map((p) => p[0]), ys = np.map((p) => p[1]);
|
||||
const minSide = Math.min(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
|
||||
const xs0 = room.poly.map((p) => p[0]), ys0 = room.poly.map((p) => p[1]);
|
||||
const minSide0 = Math.min(Math.max(...xs0) - Math.min(...xs0), Math.max(...ys0) - Math.min(...ys0));
|
||||
if (minSide < Math.min(minDim, minSide0) - eps) return false;
|
||||
// the neighbour is a wall to hit: pre-existing nesting must survive,
|
||||
// everything else must not gain shared area or become nested (engulfing a
|
||||
// foreign room via scale is a stop, not a new island)
|
||||
for (const r of rooms) {
|
||||
if (r.id === roomId) continue;
|
||||
if (polyContainsPoly(room.poly, r.poly, eps)) { // our island
|
||||
if (!polyContainsPoly(np, r.poly, eps)) return false;
|
||||
continue;
|
||||
}
|
||||
if (polyContainsPoly(r.poly, room.poly, eps)) { // we are the island
|
||||
if (!polyContainsPoly(r.poly, np, eps)) return false;
|
||||
continue;
|
||||
}
|
||||
if (polyContainsPoly(np, r.poly, eps) || polyContainsPoly(r.poly, np, eps)) return false;
|
||||
if (illegalOverlap(np, r.poly, eps)) return false;
|
||||
}
|
||||
// openings of this room (moved or kept) must still fit on some wall
|
||||
const allNew = rooms.map((r) => (r.id === roomId ? np : r.poly));
|
||||
for (const o of openingsOnRooms(openings, [room.poly], eps * 2)) {
|
||||
const c = res.openings[o.id];
|
||||
const moved = c ? { ...o, x: c[0], y: c[1] } : o;
|
||||
if (!openingFits(moved, allNew, eps * 2)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Closest valid factor to kWanted (bisecting toward 1, which is always valid). */
|
||||
export function clampRoomScale(
|
||||
rooms: RoomIn[], openings: OpeningIn[], roomId: string, fixed: [number, number], kWanted: number, opts: StopOpts,
|
||||
): number {
|
||||
if (!Number.isFinite(kWanted) || kWanted <= 0) return 1;
|
||||
if (validateRoomScale(rooms, openings, roomId, fixed, kWanted, opts)) return kWanted;
|
||||
let good = 1, bad = kWanted;
|
||||
for (let i = 0; i < 28; i++) {
|
||||
const mid = (good + bad) / 2;
|
||||
if (validateRoomScale(rooms, openings, roomId, fixed, mid, opts)) good = mid;
|
||||
else bad = mid;
|
||||
}
|
||||
return good;
|
||||
}
|
||||
|
||||
// ---------------- live numbers ----------------
|
||||
|
||||
/** Room area in m² from render units via the grid scale. */
|
||||
export function areaM2(poly: number[][], gridPitch: number, cellCm: number): number {
|
||||
const cmPerUnit = cellCm / gridPitch;
|
||||
return (polygonArea(poly) * cmPerUnit * cmPerUnit) / 1e4;
|
||||
}
|
||||
|
||||
/** "12.4 m²" or "133 ft²" per the HA unit system. */
|
||||
export function formatArea(m2: number, imperial: boolean): string {
|
||||
if (imperial) return `${Math.round(m2 * 10.7639)} ft²`;
|
||||
return `${(Math.round(m2 * 10) / 10).toFixed(1)} m²`;
|
||||
}
|
||||
+39
-1
@@ -907,6 +907,13 @@ export const cardStyles = css`
|
||||
animation: hp-sense 1.1s ease-in-out 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* HP-1543-02: a rapid off→on retrip must RESTART the flash. The card
|
||||
flips the .sf2 marker on every trip (_senseTick generation parity);
|
||||
hp-sense-b duplicates hp-sense exactly — switching the animation-name
|
||||
is what makes the browser abandon the old timeline and start a fresh
|
||||
one on the same pseudo-element (a same-name animation never restarts
|
||||
while the class stays on). */
|
||||
.dev.senseflash.sf2::after { animation-name: hp-sense-b; }
|
||||
/* OCCUPANCY/PRESENCE while 'on': a calm STATIC ring, no animation —
|
||||
«комната обитаема» is a state, not an event, so it must not blink.
|
||||
Brightness matches the reduced-motion variant of the flash. */
|
||||
@@ -924,6 +931,13 @@ export const cardStyles = css`
|
||||
60% { transform: scale(1.12); opacity: 0.12; }
|
||||
100% { transform: scale(1.12); opacity: 0; }
|
||||
}
|
||||
/* identical twin of hp-sense — exists ONLY as the alternate animation
|
||||
identity for the retrip restart (HP-1543-02), keep the two in sync */
|
||||
@keyframes hp-sense-b {
|
||||
0% { transform: scale(0.9); opacity: 0.5; }
|
||||
60% { transform: scale(1.12); opacity: 0.12; }
|
||||
100% { transform: scale(1.12); opacity: 0; }
|
||||
}
|
||||
/* alarms pulse red over everything */
|
||||
.dev.alarm::after {
|
||||
content: '';
|
||||
@@ -943,7 +957,8 @@ export const cardStyles = css`
|
||||
/* the motion flash becomes a static ring for the same ~3.3s class
|
||||
window — consistent with alarm's treatment; sensehold is static
|
||||
by design already */
|
||||
.dev.senseflash::after { animation: none; opacity: 0.4; }
|
||||
.dev.senseflash::after,
|
||||
.dev.senseflash.sf2::after { animation: none; opacity: 0.4; } /* HP-1543-02: retrip re-arms the window, ring stays static */
|
||||
}
|
||||
.dev .newdot {
|
||||
position: absolute;
|
||||
@@ -1387,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);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Room resize geometry (docs/RESIZE.md): every «упор» and the T-junction
|
||||
// vertex insertion are pinned here numerically.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
edgeNormal, movePolyEdge, sharedSpansWith, shiftSharedSpans, simplifyPoly,
|
||||
polyIsSimple, minParallelClearance, planEdgeDrag, applyEdgeDrag,
|
||||
validateEdgeDrag, clampEdgeDrag, applyRoomScale, validateRoomScale,
|
||||
clampRoomScale, areaM2, formatArea, MIN_ROOM_CM,
|
||||
} from '../test-build/resize.js';
|
||||
import { roomPoly } from '../test-build/logic.js';
|
||||
|
||||
const OPTS = { minDim: 25, eps: 0.5 }; // 25 units ≈ 30 cm at cell_cm=5, pitch 1000/240
|
||||
const STEP = 5;
|
||||
|
||||
// A: 300×300 square, right edge is index 1 ((400,100)→(400,400))
|
||||
const A = () => ({ id: 'A', poly: [[100, 100], [400, 100], [400, 400], [100, 400]] });
|
||||
// R: full-height neighbour to the right — the ENTIRE wall x=400 is shared
|
||||
const R = () => ({ id: 'R', poly: [[400, 100], [700, 100], [700, 400], [400, 400]] });
|
||||
// B: shorter neighbour to the right — T-junction (covers y 100..300 only)
|
||||
const B = () => ({ id: 'B', poly: [[400, 100], [700, 100], [700, 300], [400, 300]] });
|
||||
|
||||
const closeTo = (got, want, tol = 1e-6) =>
|
||||
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
|
||||
const polyEq = (got, want, tol = 1e-6) => {
|
||||
assert.equal(got.length, want.length, `vertex count: ${JSON.stringify(got)}`);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
closeTo(got[i][0], want[i][0], tol);
|
||||
closeTo(got[i][1], want[i][1], tol);
|
||||
}
|
||||
};
|
||||
|
||||
test('edgeNormal points outward for both windings', () => {
|
||||
const a = A();
|
||||
assert.deepEqual(edgeNormal(a.poly, 1).map((v) => Math.round(v) + 0), [1, 0]); // right wall → +x
|
||||
assert.deepEqual(edgeNormal(a.poly, 3).map((v) => Math.round(v) + 0), [-1, 0]); // left wall → −x
|
||||
const ccw = [...a.poly].reverse(); // reversed winding, same square
|
||||
const n = edgeNormal(ccw, ccw.findIndex((p) => p[0] === 400 && p[1] === 400));
|
||||
assert.deepEqual(n.map((v) => Math.round(v) + 0), [1, 0]);
|
||||
});
|
||||
|
||||
test('movePolyEdge translates BOTH edge endpoints along the normal', () => {
|
||||
polyEq(movePolyEdge(A().poly, 1, 50), [[100, 100], [450, 100], [450, 400], [100, 400]]);
|
||||
polyEq(movePolyEdge(A().poly, 1, -50), [[100, 100], [350, 100], [350, 400], [100, 400]]);
|
||||
});
|
||||
|
||||
test('legacy x/y/w/h rectangles resize through roomPoly', () => {
|
||||
const poly = roomPoly({ x: 100, y: 100, w: 300, h: 300 });
|
||||
polyEq(movePolyEdge(poly, 1, 50), [[100, 100], [450, 100], [450, 400], [100, 400]]);
|
||||
});
|
||||
|
||||
test('full shared wall: the neighbour moves synchronously, no gap by construction', () => {
|
||||
const rooms = [A(), R()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
const res = applyEdgeDrag(rooms, [], plan, 50, OPTS.eps);
|
||||
polyEq(res.polys.A, [[100, 100], [450, 100], [450, 400], [100, 400]]);
|
||||
polyEq(res.polys.R, [[450, 100], [700, 100], [700, 400], [450, 400]]); // R shrank, walls still coincide
|
||||
});
|
||||
|
||||
test('T-junction: only the coinciding stretch of the neighbour moves, vertices are inserted', () => {
|
||||
const rooms = [A(), B()];
|
||||
// drag B's left wall (edge 3: (400,300)→(400,100)) 50 units INTO A
|
||||
const plan = planEdgeDrag(rooms, 'B', 3);
|
||||
assert.deepEqual(plan.n.map((v) => Math.round(v) + 0), [-1, 0]);
|
||||
const res = applyEdgeDrag(rooms, [], plan, 50, OPTS.eps);
|
||||
polyEq(res.polys.B, [[350, 100], [700, 100], [700, 300], [350, 300]]);
|
||||
// A becomes L-shaped: the shared stretch (y 100..300) caves in, the rest stays
|
||||
polyEq(res.polys.A, [[100, 100], [350, 100], [350, 300], [400, 300], [400, 400], [100, 400]]);
|
||||
});
|
||||
|
||||
test('stop: own room minimum size (~30 cm)', () => {
|
||||
const rooms = [A()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -280, OPTS), false); // width 20 < 25
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -275, OPTS), true); // width 25 — the floor
|
||||
closeTo(clampEdgeDrag(rooms, [], plan, -280, STEP, OPTS), -275);
|
||||
});
|
||||
|
||||
test('stop: the shrinking neighbour keeps its minimum size too', () => {
|
||||
const rooms = [A(), R()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, 290, OPTS), false); // R would be 10 wide
|
||||
closeTo(clampEdgeDrag(rooms, [], plan, 290, STEP, OPTS), 275); // R stays 25
|
||||
});
|
||||
|
||||
test('stop: a growing wall may touch a foreign room but never overlap it', () => {
|
||||
const F = { id: 'F', poly: [[500, 100], [700, 100], [700, 400], [500, 400]] };
|
||||
const rooms = [A(), F];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, 150, OPTS), false); // crosses F
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, 100, OPTS), true); // touching = legal shared wall
|
||||
closeTo(clampEdgeDrag(rooms, [], plan, 150, STEP, OPTS), 100);
|
||||
});
|
||||
|
||||
test('stop: islands (islandsOf) block the wall, including a jump fully past them', () => {
|
||||
const P = { id: 'P', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] };
|
||||
const I = { id: 'I', poly: [[250, 250], [350, 250], [350, 350], [250, 350]] };
|
||||
const rooms = [P, I];
|
||||
const plan = planEdgeDrag(rooms, 'P', 1); // right wall of the parent
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -200, OPTS), false); // wall at 300 cuts the island
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -280, OPTS), false); // wall at 220 — island fully outside (no edge crossing!)
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -155, OPTS), false); // wall at 345 crosses the island
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -150, OPTS), true); // wall at 350 — flush with the island is a touch, legal
|
||||
closeTo(clampEdgeDrag(rooms, [], plan, -280, STEP, OPTS), -150);
|
||||
});
|
||||
|
||||
test('openings: a door ON the moving wall travels with it', () => {
|
||||
const rooms = [A(), R()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
const ops = [{ id: 'o1', x: 400, y: 200, length: 60 }];
|
||||
const res = applyEdgeDrag(rooms, ops, plan, 50, OPTS.eps);
|
||||
assert.deepEqual(res.openings.o1, [450, 200]);
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, 50, OPTS), true);
|
||||
});
|
||||
|
||||
test('stop: a side wall cannot get too short for its opening (own room)', () => {
|
||||
const rooms = [A()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
const ops = [{ id: 'o2', x: 350, y: 100, length: 80 }]; // top wall, spans x 310..390
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, -50, OPTS), false); // top wall ends at 350 < 390
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, -10, OPTS), true); // ends exactly at 390
|
||||
closeTo(clampEdgeDrag(rooms, ops, plan, -50, STEP, OPTS), -10);
|
||||
});
|
||||
|
||||
test('stop: the neighbour’s opening anchors the drag too', () => {
|
||||
const rooms = [A(), R()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
const ops = [{ id: 'o3', x: 460, y: 100, length: 40 }]; // R’s top wall, spans x 440..480
|
||||
// the wall corner may not land INSIDE the door: at d=50 the joint (x=450)
|
||||
// would sit in the middle of the opening — that is the «упор»
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, 50, OPTS), false);
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, 40, OPTS), true); // joint exactly at the door edge
|
||||
closeTo(clampEdgeDrag(rooms, ops, plan, 50, STEP, OPTS), 40);
|
||||
// fully past the door the opening sits on the GROWN room's wall — the
|
||||
// composite wall y=100 never shortens, so this is legal by construction
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, 100, OPTS), true);
|
||||
});
|
||||
|
||||
test('scale: all vertices scale proportionally about the fixed corner', () => {
|
||||
const res = applyRoomScale(A(), [], [], [100, 100], 1.5, OPTS.eps);
|
||||
polyEq(res.poly, [[100, 100], [550, 100], [550, 550], [100, 550]]);
|
||||
});
|
||||
|
||||
test('scale stops: minimum size and the neighbour as a hard wall', () => {
|
||||
const F = { id: 'F', poly: [[500, 100], [700, 100], [700, 400], [500, 400]] };
|
||||
const rooms = [A(), F];
|
||||
assert.equal(validateRoomScale(rooms, [], 'A', [100, 100], 0.05, OPTS), false); // 15 < 25
|
||||
const kMin = clampRoomScale(rooms, [], 'A', [100, 100], 0.05, OPTS);
|
||||
closeTo(kMin * 300, 25, 0.5); // clamped at the 30 cm floor
|
||||
assert.equal(validateRoomScale(rooms, [], 'A', [100, 100], 2, OPTS), false); // overlaps F
|
||||
const kMax = clampRoomScale(rooms, [], 'A', [100, 100], 2, OPTS);
|
||||
closeTo(kMax, 400 / 300, 1e-3); // right wall lands exactly on F
|
||||
});
|
||||
|
||||
test('scale never drags the neighbour; a SHARED opening stays with the neighbour wall', () => {
|
||||
const rooms = [A(), R()];
|
||||
const shared = { id: 'os', x: 400, y: 200, length: 60 }; // on the shared wall
|
||||
const own = { id: 'oo', x: 100, y: 200, length: 60 }; // on A’s left wall only
|
||||
const res = applyRoomScale(A(), [shared, own], [R().poly], [400, 400], 0.5, OPTS.eps);
|
||||
assert.equal(res.openings.os, undefined); // stays put
|
||||
assert.deepEqual(res.openings.oo, [250, 300]); // follows the transform
|
||||
assert.equal(validateRoomScale(rooms, [shared], 'A', [400, 400], 0.5, OPTS), true);
|
||||
});
|
||||
|
||||
test('scale stop: an exclusive opening must still fit', () => {
|
||||
const rooms = [A()];
|
||||
const ops = [{ id: 'o4', x: 250, y: 100, length: 200 }]; // top wall, needs 200 units
|
||||
assert.equal(validateRoomScale(rooms, ops, 'A', [100, 100], 0.5, OPTS), false); // wall 150 < opening 200
|
||||
const k = clampRoomScale(rooms, ops, 'A', [100, 100], 0.5, OPTS);
|
||||
// the opening centre scales too: it fits while 100·(1−1.5k) ≤ ε, i.e. k ≥ 0.66
|
||||
closeTo(k, 0.66, 1e-3);
|
||||
});
|
||||
|
||||
test('shared spans + shiftSharedSpans invariants', () => {
|
||||
const spans = sharedSpansWith(B().poly, [400, 100], [400, 400], OPTS.eps);
|
||||
assert.equal(spans.length, 1);
|
||||
polyEq([spans[0][0], spans[0][1]].sort((p, q) => p[1] - q[1]), [[400, 100], [400, 300]]);
|
||||
assert.equal(shiftSharedSpans(A().poly, [900, 100], [900, 400], [10, 0], OPTS.eps), null); // nothing coincides
|
||||
});
|
||||
|
||||
test('simplifyPoly drops collinear leftovers, polyIsSimple flags a bowtie', () => {
|
||||
polyEq(simplifyPoly([[0, 0], [50, 0], [100, 0], [100, 100], [0, 100]]), [[0, 0], [100, 0], [100, 100], [0, 100]]);
|
||||
assert.equal(polyIsSimple([[0, 0], [100, 100], [100, 0], [0, 100]]), false);
|
||||
assert.equal(polyIsSimple(A().poly), true);
|
||||
});
|
||||
|
||||
test('minParallelClearance: the opposite-wall distance', () => {
|
||||
closeTo(minParallelClearance(A().poly, [[[400, 100], [400, 400]]], OPTS.eps), 300);
|
||||
// L-shape: only walls with an OVERLAPPING projection count — the x=80 wall
|
||||
// spans y 60..200 and casts no shadow on the y 0..60 span, so the opposite
|
||||
// wall is x=0 at distance 200 (the x=80 obstruction is the simplicity stop)
|
||||
const L = [[0, 0], [200, 0], [200, 60], [80, 60], [80, 200], [0, 200]];
|
||||
closeTo(minParallelClearance(L, [[[200, 0], [200, 60]]], OPTS.eps), 200);
|
||||
});
|
||||
|
||||
test('live numbers: areaM2 and formatArea', () => {
|
||||
const pitch = 1000 / 240;
|
||||
const poly = [[0, 0], [100, 0], [100, 100], [0, 100]]; // 24 cells → 120 cm a side
|
||||
closeTo(areaM2(poly, pitch, 5), 1.44, 1e-9);
|
||||
assert.equal(formatArea(1.44, false), '1.4 m²');
|
||||
assert.equal(formatArea(1.44, true), '16 ft²');
|
||||
assert.equal(MIN_ROOM_CM, 30);
|
||||
});
|
||||
|
||||
test('zero drag is always valid and clamps to zero', () => {
|
||||
const rooms = [A()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, 0, OPTS), true);
|
||||
assert.equal(clampEdgeDrag(rooms, [], plan, 0, STEP, OPTS), 0);
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src/logic.ts", "src/vacuum.ts",
|
||||
"src/resize.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts",
|
||||
|
||||
Reference in New Issue
Block a user