This commit is contained in:
Matysh
2026-08-01 15:48:24 +03:00
24 changed files with 1971 additions and 144 deletions
+2 -1
View File
@@ -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
View File
@@ -26,7 +26,8 @@
**Главное**
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
}
+31
View File
@@ -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');
+240
View File
@@ -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 });
+47
View File
@@ -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
+98 -42
View File
File diff suppressed because one or more lines are too long
+47
View File
@@ -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
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
+33
View File
@@ -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`
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -24,6 +24,10 @@ import {
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
} from './logic';
import {
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
simplifyPoly, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
} from './resize';
import { ContentSigner } from './signing';
import {
Affine, applyAffine, solveAffine, affineResidual, readVacTelemetry, isVacSourceState,
@@ -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>
+3
View File
@@ -35,6 +35,9 @@
"markup.add": "Add",
"markup.merge": "Merge",
"markup.split": "Split",
"markup.resize": "Resize",
"title.markup_resize": "Resize rooms: drag a wall handle; click a room for a corner scale frame",
"markup.hint_resize": "drag a wall handle · click a room — corner frame · Esc cancels a drag · Ctrl+Z — undo a step",
"markup.opening": "Opening",
"title.markup_opening": "Doors & windows: click a wall to place, click an opening to edit",
"opening.new": "New opening",
+3
View File
@@ -35,6 +35,9 @@
"markup.add": "Добавить",
"markup.merge": "Объединить",
"markup.split": "Разделить",
"markup.resize": "Размер",
"title.markup_resize": "Изменение размера комнат: тяните ручку стены; клик по комнате — угловая рамка масштаба",
"markup.hint_resize": "тяните ручку стены · клик по комнате — угловая рамка · Esc отменяет перетаскивание · Ctrl+Z — отмена шага",
"markup.opening": "Проём",
"title.markup_opening": "Двери и окна: клик по стене — добавить, клик по проёму — редактировать",
"opening.new": "Новый проём",
+499
View File
@@ -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
View File
@@ -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);
+210
View File
@@ -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);
});
+1
View File
@@ -9,6 +9,7 @@
},
"include": [
"src/logic.ts", "src/vacuum.ts",
"src/resize.ts",
"src/rules.ts",
"src/devices.ts",
"src/types.ts",