mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
Merge dev: v1.32.0..v1.33.5 (background editor, polyline split, editor UX)
This commit is contained in:
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.31.2"
|
||||
VERSION = "1.33.5"
|
||||
|
||||
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.31.2"
|
||||
"version": "1.33.5"
|
||||
}
|
||||
|
||||
@@ -96,6 +96,29 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
|
||||
}
|
||||
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
|
||||
DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
|
||||
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
|
||||
vol.Optional("fill"): bool},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"])},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
@@ -105,6 +128,7 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||
vol.Optional("decor"): [DECOR_SCHEMA],
|
||||
vol.Optional("openings"): [
|
||||
vol.Schema(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// 1) третья вкладка есть
|
||||
const tabs = [...sr().querySelectorAll('.modetab')];
|
||||
out.threeTabs = tabs.length === 3;
|
||||
tabs[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
out.decorBar = !!sr().querySelector('.editbar.decorbar');
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
|
||||
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
|
||||
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
c._decorDraft = { kind: 'rect', a: [g * 10, g * 10], b: [g * 10, g * 10], pid: 1 };
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 20, g * 16] };
|
||||
await c.updateComplete;
|
||||
out.draftPreview = !!sr().querySelector('.ddraft');
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.rectSaved = c._curSpaceCfg.decor?.length === 1 && c._curSpaceCfg.decor[0].kind === 'rect';
|
||||
out.rectFill = c._curSpaceCfg.decor[0].fill === true && c._curSpaceCfg.decor[0].color === '#ff0000';
|
||||
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
|
||||
// 3) линия и овал
|
||||
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
c._decorDraft = { kind: 'ellipse', a: [g * 30, g * 30], b: [g * 40, g * 36], pid: 1 };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.threeShapes = c._decorList.length === 3;
|
||||
// вырожденная фигура не сохраняется
|
||||
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
out.degenerateSkipped = c._decorList.length === 3;
|
||||
// 4) надпись через диалог
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
|
||||
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
|
||||
// 5) select: перемещение сохраняет форму, drag двигает
|
||||
const rect = c._decorList.find((x) => x.kind === 'rect');
|
||||
c._decorTool = 'select';
|
||||
c._decorMove = { id: rect.id, start: [0, 0], orig: JSON.parse(JSON.stringify(rect)), pid: 7, moved: false };
|
||||
c._decorMoveUpdate({ clientX: 0, clientY: 0, }); // без смещения
|
||||
const before = { ...c._decorList.find((x) => x.id === rect.id) };
|
||||
c._decorMove.start = [0, 0];
|
||||
// сдвиг на 5 клеток по x: подделаем _svgPoint? проще напрямую:
|
||||
const m = c._decorMove; const dx5 = (g * 5) / 1000;
|
||||
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
|
||||
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
|
||||
c._decorMove = null;
|
||||
// 6) erase удаляет
|
||||
const n0 = c._decorList.length;
|
||||
c._decorTool = 'erase';
|
||||
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
|
||||
out.eraseWorks = c._decorList.length === n0 - 1;
|
||||
// 7) Esc-лестница: инструмент → select → выход
|
||||
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
|
||||
await esc(); out.escTool = c._decorTool === 'select' && c._mode === 'decor';
|
||||
await esc(); out.escExit = c._mode === 'view';
|
||||
// 8) в Просмотре фигуры видны, но inert
|
||||
const shp = sr().querySelector('.decorlayer .dshape');
|
||||
out.visibleInView = !!shp;
|
||||
out.inertInView = shp ? getComputedStyle(shp).pointerEvents === 'none' : null;
|
||||
// 9) Delete удаляет выбранное
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._decorTool = 'select'; c._decorSel = c._decorList[0].id; const n1 = c._decorList.length; await c.updateComplete;
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })); await c.updateComplete;
|
||||
out.deleteKey = c._decorList.length === n1 - 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const tabs = () => [...sr().querySelectorAll('.modetab')];
|
||||
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
|
||||
out.twoTabs = tabs().length === 2;
|
||||
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
|
||||
out.labels = tabs().map((t) => t.textContent.trim());
|
||||
out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0;
|
||||
out.startView = c._mode === 'view';
|
||||
@@ -23,7 +23,7 @@ const res = await page.evaluate(async () => {
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
out.directSwitch = c._mode === 'devices';
|
||||
out.devBar = !!sr().querySelector('.editbar.devbar');
|
||||
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length;
|
||||
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
|
||||
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
|
||||
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
|
||||
// 6) крестик на панели → Просмотр
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const grid = () => !!sr().querySelector('rect[fill="url(#hp-grid)"]');
|
||||
// Просмотр: сетки нет
|
||||
out.noGridInView = !grid();
|
||||
// все три редактора: сетка есть
|
||||
c._setMode('plan'); await c.updateComplete; out.gridInPlan = grid();
|
||||
c._setMode('devices'); await c.updateComplete; out.gridInDevices = grid();
|
||||
c._setMode('decor'); await c.updateComplete; out.gridInDecor = grid();
|
||||
await new Promise((r) => setTimeout(r, 250)); // transition .room 0.12s
|
||||
// decor: комнаты и устройства полупрозрачны
|
||||
const room = sr().querySelector('.room');
|
||||
out.roomFaded = room ? Number(getComputedStyle(room).opacity) < 0.5 : null;
|
||||
const dl = sr().querySelector('.devlayer');
|
||||
out.devsFaded = dl ? Number(getComputedStyle(dl).opacity) < 0.5 : null;
|
||||
// а декор-слой — нет
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'line', x1: 0.1, y1: 0.1, x2: 0.3, y2: 0.1, color: '#ff0000', width: 3 }];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const shp = sr().querySelector('.decorlayer .dshape');
|
||||
out.decorNotFaded = shp ? Number(getComputedStyle(shp).opacity) > 0.9 : null;
|
||||
// в других редакторах прозрачности нет
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const room2 = sr().querySelector('.room');
|
||||
out.notFadedInDevices = room2 ? Number(getComputedStyle(room2).opacity) > 0.9 : null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const room3 = sr().querySelector('.room');
|
||||
out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,31 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
// устройство без явной иконки (marker.icon пуст)
|
||||
const dev = c._devices.find((d) => !d.marker?.icon && d.icon);
|
||||
out.hasAutoDev = !!dev;
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
out.autoIconStored = c._markerDialog.autoIcon === dev.icon;
|
||||
out.iconEmpty = c._markerDialog.icon === '';
|
||||
// подсказка с авто-иконкой видна
|
||||
const hint = sr().querySelector('.iconauto');
|
||||
out.hintShown = !!hint && hint.textContent.includes(dev.icon);
|
||||
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
|
||||
// пикер (или фолбэк-инпут) получил placeholder
|
||||
const picker = sr().querySelector('ha-icon-picker');
|
||||
const inputs = [...sr().querySelectorAll('.dialog input')];
|
||||
out.placeholderSet = picker
|
||||
? picker.placeholder === dev.icon
|
||||
: inputs.some((i) => i.placeholder === dev.icon);
|
||||
// при явной иконке подсказки нет
|
||||
c._markerDialog = { ...c._markerDialog, icon: 'mdi:star' }; await c.updateComplete;
|
||||
out.noHintWhenExplicit = !sr().querySelector('.iconauto');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,46 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const near = (a, b) => a && b && Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6 && a.s === b.s;
|
||||
// подопытный: авто-устройство с сохранённой позицией
|
||||
const dev = c._devices.find((d) => !d.virtual && !d.marker && c._layout[d.id]);
|
||||
out.hasDev = !!dev;
|
||||
const pos0 = { ...c._layout[dev.id] };
|
||||
// 1) смена комнаты (та же space) → позиция не меняется
|
||||
const otherRoom = c._spaceModel(dev.space).rooms.find((r) => r.id && r.area && r.area !== dev.area);
|
||||
out.hasOtherRoom = !!otherRoom;
|
||||
c._openMarkerDialog(dev);
|
||||
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + otherRoom.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
out.stayAfterRoomChange = near(c._layout[dev.id], pos0);
|
||||
// вернуть комнату назад
|
||||
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
|
||||
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + dev.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
// 2) смена привязки device -> entity (id меняется) → позиция мигрирует
|
||||
const freeEnt = Object.keys(c.hass.states).find((e) => e.startsWith('sensor.') &&
|
||||
!c._devices.some((d) => d.id === 'lg_' + e));
|
||||
out.hasFreeEnt = !!freeEnt;
|
||||
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
|
||||
c._markerDialog = { ...c._markerDialog, binding: 'entity:' + freeEnt };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const newId = 'lg_' + freeEnt;
|
||||
out.migrated = near(c._layout[newId], pos0);
|
||||
out.oldGone = !c._layout[dev.id];
|
||||
// 3) новый виртуальный маркер по-прежнему центрируется в выбранной комнате
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual', room: c._space + '#' + room.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
|
||||
const center = c._roomCenter(room);
|
||||
const vpos = c._layout[vid];
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -14,7 +14,7 @@ const res = await page.evaluate(async () => {
|
||||
out.amberStroke = cs ? cs.stroke.includes('255, 193, 77') : null;
|
||||
out.amberFill = cs ? cs.fill.includes('255, 193, 77') : null;
|
||||
// split-выбор подсвечивается так же
|
||||
c._mergeSel = null; c._tool = 'split'; c._splitSel = { roomId: room.id }; c.requestUpdate(); await c.updateComplete;
|
||||
c._mergeSel = null; c._tool = 'split'; c._splitSel = { roomId: room.id, pts: [] }; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const el2 = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
|
||||
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'opening'; await c.updateComplete;
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const r = rooms.find((x) => x.id === 'r1');
|
||||
const poly = r.poly || [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
|
||||
const wallMid = [(poly[0][0] + poly[1][0]) / 2, (poly[0][1] + poly[1][1]) / 2];
|
||||
// 1) курсор возле стены → пунктирный призрак есть
|
||||
c._cursorPt = [wallMid[0], wallMid[1] + c._gridPitch * 0.5]; c.requestUpdate(); await c.updateComplete;
|
||||
const ghost = sr().querySelector('.opghost');
|
||||
out.ghostNearWall = !!ghost;
|
||||
if (ghost) {
|
||||
const cs = getComputedStyle(ghost);
|
||||
out.dashed = cs.strokeDasharray !== 'none' && cs.strokeDasharray !== '';
|
||||
// призрак лежит на стене (y координаты концов совпадают с y стены)
|
||||
out.onWall = Math.abs(Number(ghost.getAttribute('y1')) - poly[0][1]) < 0.5
|
||||
&& Math.abs(Number(ghost.getAttribute('y2')) - poly[0][1]) < 0.5;
|
||||
// длина = 90 см в рендер-единицах
|
||||
const len = Math.hypot(ghost.getAttribute('x2') - ghost.getAttribute('x1'), ghost.getAttribute('y2') - ghost.getAttribute('y1'));
|
||||
out.len90cm = Math.abs(len - c._cmToUnits(90)) < 0.5;
|
||||
}
|
||||
// 2) курсор далеко от стен → призрака нет
|
||||
const center = c._roomCenter(r);
|
||||
c._cursorPt = center; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostFarFromWall = !sr().querySelector('.opghost');
|
||||
// 3) в других инструментах призрака нет
|
||||
c._tool = 'draw'; c._cursorPt = [wallMid[0], wallMid[1] + 2]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostOtherTool = !sr().querySelector('.opghost');
|
||||
// 4) над существующим проёмом призрак не рисуется (там клик = редактирование)
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, openings: [{ id: 'op9', type: 'door', x: wallMid[0] / 1000, y: wallMid[1] / (1000 / (c._curSpaceCfg.aspect || 1)) * (c._curSpaceCfg.aspect ? 1 : 1), angle: 0, length: 0.09 }] })) };
|
||||
c._tool = 'opening'; c.requestUpdate(); await c.updateComplete;
|
||||
const op = c._openingsR[0];
|
||||
c._cursorPt = [op.rx, op.ry]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostOverExisting = !sr().querySelector('.opghost');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,55 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
// 1) курсоры: split до выбора комнаты — pointer, после — crosshair; merge — pointer
|
||||
c._tool = 'split'; c.requestUpdate(); await c.updateComplete;
|
||||
out.splitPickCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const room = rooms.find((r) => r.id === 'r1');
|
||||
const center = c._roomCenter(room);
|
||||
c._splitClick(center); await c.updateComplete;
|
||||
out.roomPicked = c._splitSel?.roomId === 'r1';
|
||||
out.splitCutCursor = getComputedStyle(stage()).cursor === 'crosshair';
|
||||
c._tool = 'merge'; c._splitSel = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.mergeCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
// 2) полилинейный разрез: r1 = прямоугольник? возьмём его poly и построим Г-разрез
|
||||
c._tool = 'split'; await c.updateComplete;
|
||||
c._splitClick(center);
|
||||
const poly = (() => { const r = rooms.find((x) => x.id === 'r1');
|
||||
return r.poly ? r.poly : [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]]; })();
|
||||
// старт на середине верхней стены, промежуточная внутри, конец на правой стене
|
||||
const [A, B, C2, D] = [poly[0], poly[1], poly[2], poly[3]];
|
||||
const top = [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
const right = [(B[0] + C2[0]) / 2, (B[1] + C2[1]) / 2];
|
||||
const mid = [(top[0] + right[0]) / 2 - 20, (top[1] + right[1]) / 2 + 20];
|
||||
c._splitClick(top);
|
||||
out.firstOnWall = c._splitSel?.pts.length === 1;
|
||||
c._splitClick(mid);
|
||||
out.midAdded = c._splitSel?.pts.length === 2;
|
||||
c._splitClick(right);
|
||||
out.dialogAfterCut = !!c._pendingSplit && c._roomDialog;
|
||||
out.partsPolys = c._pendingSplit ? [c._pendingSplit.mainPoly.length, c._pendingSplit.newPoly.length] : null;
|
||||
// отмена — комната целая
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
out.cancelKeepsRoom = !c._pendingSplit && rooms.length === c._spaceModel().rooms.length;
|
||||
// 3) Esc: пошаговый выход
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
c._tool = 'split'; c._splitSel = null;
|
||||
c._splitClick(center); c._splitClick(top); c._splitClick(mid); await c.updateComplete;
|
||||
await esc(); out.escDropsPoint = c._splitSel?.pts.length === 1;
|
||||
await esc(); out.escDropsFirst = c._splitSel?.pts.length === 0;
|
||||
await esc(); out.escDropsRoom = c._splitSel === null && c._tool === 'split';
|
||||
await esc(); out.escExitsTool = c._tool === 'draw';
|
||||
// merge: выбор → Esc снимает, ещё Esc — выход
|
||||
c._tool = 'merge'; c._mergeSel = 'r1'; await c.updateComplete;
|
||||
await esc(); out.escClearsMerge = c._mergeSel === null && c._tool === 'merge';
|
||||
await esc(); out.escExitsMerge = c._tool === 'draw';
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
File diff suppressed because one or more lines are too long
Vendored
+161
-19
File diff suppressed because one or more lines are too long
@@ -1,5 +1,63 @@
|
||||
# Changelog
|
||||
|
||||
## v1.33.5 — 2026-07-22
|
||||
- Editor tabs got extended tooltips explaining what each editor is for (plan
|
||||
geometry vs device icons vs visual decor).
|
||||
|
||||
## v1.33.4 — 2026-07-22
|
||||
- Editing a device no longer makes its icon jump. Changing the HA binding
|
||||
(which changes the marker id) migrates the saved position to the new id;
|
||||
changing the room within the same space keeps the icon exactly where it
|
||||
stands (previously it re-centered in the new room). Only a brand-new icon,
|
||||
or a move to a room in a different space, is centered.
|
||||
|
||||
## v1.33.3 — 2026-07-22
|
||||
- Device dialog: when no icon is set explicitly, the icon picker no longer
|
||||
looks empty — it shows the **auto-derived icon** (from the icon rules /
|
||||
device class) as a placeholder, with an "Auto: mdi:…" hint line underneath.
|
||||
Picking an explicit icon replaces it as before; clearing returns to auto.
|
||||
|
||||
## v1.33.2 — 2026-07-22
|
||||
- Removed the **Reset** button from the Device editor. It wiped the entire
|
||||
layout — positions of all devices, room cards and their scales across every
|
||||
space — behind a single confirm. Low value, high blast radius.
|
||||
|
||||
## v1.33.1 — 2026-07-22
|
||||
- The dot grid is now shown in **every editor** (Plan, Devices, Background),
|
||||
not just Plan — an instant visual cue that you are editing.
|
||||
- In the Background editor, rooms, devices, openings and labels fade to 35%
|
||||
opacity so the decor you are drawing stands out; decor itself stays fully
|
||||
opaque. Other modes are unaffected.
|
||||
|
||||
## v1.33.0 — 2026-07-22 (background editor)
|
||||
- New third mode: **Background editor**. Draw purely visual decor on the plan —
|
||||
lines, rectangles, ovals and text labels that never interact with rooms,
|
||||
devices or fills. Shapes are drag-drawn with grid snap and a live preview;
|
||||
text is placed via a small dialog (size S/M/L, color; double-click to edit).
|
||||
Toolbar: Select (move, Delete key), Erase, color, three line widths and an
|
||||
optional 25% fill for rects/ovals. Esc walks back: draft → selection →
|
||||
Select tool → View.
|
||||
- The decor layer renders **under rooms** (a true underlay), is visible in all
|
||||
modes and completely click-transparent outside the editor. Stored per space
|
||||
in the server config (`space.decor`, validated on the backend, shared across
|
||||
clients with the usual rev/optimistic locking).
|
||||
|
||||
## v1.32.1 — 2026-07-22
|
||||
- Opening tool: hovering near a wall now shows a **dashed preview** of where
|
||||
the opening would land — snapped onto the wall, default door length (90 cm),
|
||||
with a center dot. No preview far from walls or over an existing opening
|
||||
(a click there edits it instead).
|
||||
|
||||
## v1.32.0 — 2026-07-22 (split polyline, tool cursors, Esc)
|
||||
- **Split can now cut along a polyline**, not just a straight chord: start on
|
||||
a wall, click intermediate points inside the room, finish on another wall.
|
||||
The path is validated (no wall crossings, no self-intersection) and drawn
|
||||
live with vertices and a preview segment. Two clicks still work as before.
|
||||
- **Tool cursors**: Merge and delete-room show a pointer; Split shows a
|
||||
pointer while picking the room, then a crosshair while cutting.
|
||||
- **Esc walks back out of Merge/Split** step by step: last cut point → first
|
||||
point → room selection → back to the Draw tool. Merge: selection → tool.
|
||||
|
||||
## v1.31.2 — 2026-07-22
|
||||
- Plan editor: the room picked with the **Merge** tool (and the room selected
|
||||
for **Split**) is highlighted amber again. The `.outlined` markup style,
|
||||
|
||||
@@ -140,6 +140,37 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
(explicit ripple color still wins); off/white lights unchanged [auto]
|
||||
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
||||
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
||||
- [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or
|
||||
changing its room within the same space never moves the icon — the saved
|
||||
or auto position migrates to the new marker id; only a brand-new icon or
|
||||
a move to another space centers it in the target room [auto]
|
||||
- [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device
|
||||
dialog's icon picker shows the auto-derived icon as its placeholder, plus
|
||||
an "Auto: mdi:..." hint line with the icon preview; the hint disappears
|
||||
once an explicit icon is picked [auto]
|
||||
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
|
||||
add, show all, icon rules; the layout-wiping Reset is gone [auto]
|
||||
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
|
||||
Device and Background editors too (instant "I'm editing" cue), not in
|
||||
View; in the Background editor rooms/devices/openings/labels fade to 35%
|
||||
while decor shapes stay fully opaque; no fade in the other editors [auto]
|
||||
- [ ] Background editor (v1.33.0): third tab with its own toolbar (select /
|
||||
line / rect / oval / text / erase + color, width, fill, X); shapes drag-
|
||||
drawn with grid snap and live preview; degenerate shapes dropped; text
|
||||
via dialog (S/M/L, color; dblclick re-edits); Select moves (snap), Delete
|
||||
removes, Erase deletes on click; Esc: draft → selection → select tool →
|
||||
View; decor renders under rooms, visible everywhere, inert outside the
|
||||
editor; stored in space.decor (rev/lock, backend schema) [auto]
|
||||
- [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a
|
||||
wall shows a dashed 90 cm ghost snapped onto the wall (with a center
|
||||
dot); no ghost far from walls, over an existing opening (click = edit),
|
||||
or in other tools [auto]
|
||||
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
|
||||
Split shows pointer until a room is picked then crosshair; the cut can be
|
||||
a polyline — start on a wall, intermediate clicks inside the room, finish
|
||||
on a wall (path drawn live, walls/self-crossing rejected); Esc walks back:
|
||||
last cut point → room pick → back to the Draw tool (same for Merge:
|
||||
selection → tool) [auto]
|
||||
- [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the
|
||||
Merge tool (and the split-selected room) gets an amber outline + fill;
|
||||
visible over the blue markup outlines [auto]
|
||||
|
||||
+4
-1
@@ -12,7 +12,10 @@ A segmented control in the card header with three tabs; the active one is visual
|
||||
highlighted, and edit modes add a colored frame around the stage so the mode is
|
||||
obvious at a glance:
|
||||
|
||||
**[ 📐 Plan editor ] [ 🔧 Device editor ]** — View has NO tab (since v1.30.2).
|
||||
**[ 📐 Plan editor ] [ 🔧 Device editor ] [ ✏️ Background editor ]** — View has
|
||||
NO tab (since v1.30.2). The Background editor (v1.33.0) manages a purely visual
|
||||
decor layer (lines/rects/ovals/text in `space.decor`, drawn under the rooms,
|
||||
inert everywhere outside its editor).
|
||||
|
||||
- **View** is the implicit default state: no editor tab is active. It is the only
|
||||
state after every load (edit modes are never restored across reloads).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.31.2",
|
||||
"version": "1.33.5",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+433
-37
@@ -14,7 +14,7 @@ import {
|
||||
import {
|
||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside,
|
||||
snapToWall, openingAmount,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
@@ -32,7 +32,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.31.2';
|
||||
const CARD_VERSION = '1.33.5';
|
||||
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';
|
||||
@@ -86,7 +86,14 @@ class HouseplanCard extends LitElement {
|
||||
/** Interaction mode (docs/UX-MODES.md): view = display only, plan = geometry
|
||||
* editing, devices = marker placement/config. Never persisted — every load
|
||||
* starts in view. */
|
||||
private _mode: 'view' | 'plan' | 'devices' = 'view';
|
||||
private _mode: 'view' | 'plan' | 'devices' | 'decor' = 'view';
|
||||
// ---- decor (background) editor ----
|
||||
private _decorTool: 'select' | 'line' | 'rect' | 'ellipse' | 'text' | 'erase' = 'select';
|
||||
private _decorStyle: { color: string; width: number; fill: boolean } = { color: '#607d8b', width: 3, fill: false };
|
||||
private _decorDraft: { kind: 'line' | 'rect' | 'ellipse'; a: number[]; b: number[]; pid: number } | null = null;
|
||||
private _decorMove: { id: string; start: number[]; orig: any; pid: number; moved: boolean } | null = null;
|
||||
private _decorSel: string | null = null;
|
||||
private _decorTextDialog: { id?: string; x: number; y: number; text: string; size: 's' | 'm' | 'l'; color: string } | null = null;
|
||||
|
||||
/** Edit tabs are offered to admins only (hass.user missing → assume admin). */
|
||||
private get _canEdit(): boolean {
|
||||
@@ -94,6 +101,11 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Legacy alias: markup machinery is active exactly in plan mode. */
|
||||
/** Any edit mode is active (plan / devices / decor). */
|
||||
private get _editing(): boolean {
|
||||
return this._mode === 'plan' || this._mode === 'devices' || this._mode === 'decor';
|
||||
}
|
||||
|
||||
private get _markup(): boolean {
|
||||
return this._mode === 'plan';
|
||||
}
|
||||
@@ -115,7 +127,7 @@ class HouseplanCard extends LitElement {
|
||||
private _openingInfo: OpeningCfg | null = null;
|
||||
private _opDrag: { id: string; moved: boolean } | null = null;
|
||||
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
|
||||
private _splitSel: { roomId: string; a: number[] | null } | null = null; // room being cut + first wall point
|
||||
private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
|
||||
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
|
||||
private _pendingSplit: { roomId: string; mainPoly: number[][]; newPoly: number[][] } | null = null;
|
||||
private _areaSel = '';
|
||||
@@ -147,6 +159,7 @@ class HouseplanCard extends LitElement {
|
||||
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
|
||||
bindingFilter: string;
|
||||
icon: string; // '' = auto
|
||||
autoIcon: string; // the icon the rules would give — picker placeholder
|
||||
display: 'badge' | 'ripple' | 'icon_ripple' | 'value';
|
||||
rippleColor: string; // '' = accent
|
||||
rippleSize: number; // in icon diameters
|
||||
@@ -224,6 +237,11 @@ class HouseplanCard extends LitElement {
|
||||
_openingInfo: { state: true },
|
||||
_mergeDialog: { state: true },
|
||||
_splitSel: { state: true },
|
||||
_decorTool: { state: true },
|
||||
_decorStyle: { state: true },
|
||||
_decorDraft: { state: true },
|
||||
_decorSel: { state: true },
|
||||
_decorTextDialog: { state: true },
|
||||
_areaSel: { state: true },
|
||||
_nameSel: { state: true },
|
||||
_roomDialog: { state: true },
|
||||
@@ -265,6 +283,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._settingsDialog) { this._settingsDialog = null; return; }
|
||||
if (this._markerDialog) { this._markerDialog = null; return; }
|
||||
if (this._openingDialog) { this._openingDialog = null; return; }
|
||||
if (this._decorTextDialog) { this._decorTextDialog = null; return; }
|
||||
if (this._spaceDialog && !this._roomDialog) {
|
||||
// same semantics as the dialog's Cancel: an import queue is abandoned
|
||||
this._spaceDialog = null;
|
||||
@@ -273,6 +292,22 @@ class HouseplanCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this._mode === 'decor') {
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && this._decorSel &&
|
||||
!(e.target as HTMLElement)?.closest?.('input, textarea, select')) {
|
||||
e.preventDefault();
|
||||
this._decorDeleteSel();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (this._decorDraft) this._decorDraft = null;
|
||||
else if (this._decorSel) this._decorSel = null;
|
||||
else if (this._decorTool !== 'select') this._decorTool = 'select';
|
||||
else this._setMode('view');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this._markup) return;
|
||||
const undo = e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z');
|
||||
if (!undo) return;
|
||||
@@ -284,6 +319,27 @@ class HouseplanCard extends LitElement {
|
||||
if (this._tool === 'draw' && this._path.length) {
|
||||
e.preventDefault();
|
||||
this._undoPoint();
|
||||
return;
|
||||
}
|
||||
if (!undo) return;
|
||||
// Esc walks back out of merge/split: point by point, then the room pick,
|
||||
// then the tool itself (back to the neutral draw tool)
|
||||
if (this._tool === 'split') {
|
||||
e.preventDefault();
|
||||
if (this._splitSel?.pts?.length) {
|
||||
this._splitSel = { ...this._splitSel, pts: this._splitSel.pts.slice(0, -1) };
|
||||
if (!this._splitSel.pts.length) this._cursorPt = null;
|
||||
} else if (this._splitSel) {
|
||||
this._splitSel = null;
|
||||
} else {
|
||||
this._tool = 'draw';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this._tool === 'merge') {
|
||||
e.preventDefault();
|
||||
if (this._mergeSel) this._mergeSel = null;
|
||||
else this._tool = 'draw';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,6 +963,7 @@ class HouseplanCard extends LitElement {
|
||||
// do not interfere with icon dragging and markup drawing
|
||||
if (this._drag || this._markup) return;
|
||||
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
|
||||
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
|
||||
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||
const v = this._viewOr(this._spaceModel().vb);
|
||||
if (this._pointers.size === 1) {
|
||||
@@ -921,6 +978,14 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _stagePointerMove(ev: PointerEvent): void {
|
||||
if (this._decorDraft?.pid === ev.pointerId) {
|
||||
this._decorDraft = { ...this._decorDraft, b: this._snap(this._svgPoint(ev)) };
|
||||
return;
|
||||
}
|
||||
if (this._decorMove?.pid === ev.pointerId) {
|
||||
this._decorMoveUpdate(ev);
|
||||
return;
|
||||
}
|
||||
if (!this._pointers.has(ev.pointerId)) {
|
||||
this._markupMove(ev);
|
||||
return;
|
||||
@@ -961,6 +1026,15 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _stagePointerUp(ev: PointerEvent): void {
|
||||
if (this._decorDraft?.pid === ev.pointerId) {
|
||||
this._decorCommitDraft();
|
||||
return;
|
||||
}
|
||||
if (this._decorMove?.pid === ev.pointerId) {
|
||||
if (this._decorMove.moved) this._saveConfig();
|
||||
this._decorMove = null;
|
||||
return;
|
||||
}
|
||||
this._pointers.delete(ev.pointerId);
|
||||
if (this._pointers.size < 2) this._pinchStart = null;
|
||||
if (this._pointers.size === 0) {
|
||||
@@ -1025,12 +1099,6 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _resetLayout(): void {
|
||||
if (!confirm(this._t('confirm.reset_layout'))) return;
|
||||
this._layout = {};
|
||||
this._persistLayout();
|
||||
}
|
||||
|
||||
private _showToast(msg: string): void {
|
||||
this._toast = msg;
|
||||
clearTimeout(this._toastTimer);
|
||||
@@ -1082,9 +1150,9 @@ class HouseplanCard extends LitElement {
|
||||
return roomEdges(sp?.rooms || []).map((s) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
|
||||
}
|
||||
|
||||
private _setMode(mode: 'view' | 'plan' | 'devices'): void {
|
||||
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
|
||||
if (this._mode === mode) return;
|
||||
if (mode === 'plan' && !this._norm) {
|
||||
if ((mode === 'plan' || mode === 'decor') && !this._norm) {
|
||||
this._showToast(this._t('toast.markup_needs_server'));
|
||||
return;
|
||||
}
|
||||
@@ -1098,6 +1166,9 @@ class HouseplanCard extends LitElement {
|
||||
this._pendingSplit = null;
|
||||
this._selId = null;
|
||||
this._tip = null;
|
||||
this._decorDraft = null;
|
||||
this._decorSel = null;
|
||||
this._decorTool = 'select';
|
||||
}
|
||||
|
||||
|
||||
@@ -1254,6 +1325,255 @@ class HouseplanCard extends LitElement {
|
||||
return (cm / this._cellCm) * this._gridPitch;
|
||||
}
|
||||
|
||||
// ================= decor (background) layer =================
|
||||
|
||||
private get _decorList(): any[] {
|
||||
const sp = this._curSpaceCfg;
|
||||
return Array.isArray(sp?.decor) ? sp.decor : [];
|
||||
}
|
||||
|
||||
private get _decorH(): number {
|
||||
return NORM_W / (this._curSpaceCfg?.aspect || 1);
|
||||
}
|
||||
|
||||
/** Begin a decor gesture. Returns true when the event is consumed (no pan). */
|
||||
private _decorPointerDown(ev: PointerEvent): boolean {
|
||||
const t = this._decorTool;
|
||||
const onShape = (ev.target as HTMLElement).closest?.('.dshape') as SVGElement | null;
|
||||
if (onShape) return true; // the shape's own handler deals with it
|
||||
if (t === 'line' || t === 'rect' || t === 'ellipse') {
|
||||
ev.preventDefault();
|
||||
const p = this._snap(this._svgPoint(ev));
|
||||
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
|
||||
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
|
||||
return true;
|
||||
}
|
||||
if (t === 'text') {
|
||||
const p = this._snap(this._svgPoint(ev));
|
||||
this._decorTextDialog = {
|
||||
x: p[0] / NORM_W, y: p[1] / this._decorH,
|
||||
text: '', size: 'm', color: this._decorStyle.color,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
this._decorSel = null; // select/erase on empty space clears the selection
|
||||
return false; // pan is allowed
|
||||
}
|
||||
|
||||
/** Commit the dragged shape (ignore degenerate ones) and persist. */
|
||||
private _decorCommitDraft(): void {
|
||||
const d = this._decorDraft;
|
||||
this._decorDraft = null;
|
||||
if (!d) return;
|
||||
const min = this._gridPitch * 0.5;
|
||||
if (Math.hypot(d.b[0] - d.a[0], d.b[1] - d.a[1]) < min) return;
|
||||
const W = NORM_W, H = this._decorH;
|
||||
const st = this._decorStyle;
|
||||
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||||
let shape: any;
|
||||
if (d.kind === 'line') {
|
||||
shape = { id, kind: 'line', x1: d.a[0] / W, y1: d.a[1] / H, x2: d.b[0] / W, y2: d.b[1] / H,
|
||||
color: st.color, width: st.width };
|
||||
} else {
|
||||
const x = Math.min(d.a[0], d.b[0]) / W, y = Math.min(d.a[1], d.b[1]) / H;
|
||||
const w = Math.abs(d.b[0] - d.a[0]) / W, h = Math.abs(d.b[1] - d.a[1]) / H;
|
||||
shape = { id, kind: d.kind, x, y, w, h, color: st.color, width: st.width, fill: st.fill };
|
||||
}
|
||||
const sp = this._curSpaceCfg;
|
||||
sp.decor = [...this._decorList, shape];
|
||||
this._decorSel = id;
|
||||
this._saveConfig();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/** Select tool: pointerdown on a shape starts moving it. */
|
||||
private _decorShapeDown(ev: PointerEvent, shape: any): void {
|
||||
if (this._mode !== 'decor') return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (this._decorTool === 'erase') {
|
||||
const sp = this._curSpaceCfg;
|
||||
sp.decor = this._decorList.filter((x) => x.id !== shape.id);
|
||||
if (this._decorSel === shape.id) this._decorSel = null;
|
||||
this._saveConfig();
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (this._decorTool !== 'select') return;
|
||||
this._decorSel = shape.id;
|
||||
this._decorMove = {
|
||||
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
|
||||
pid: ev.pointerId, moved: false,
|
||||
};
|
||||
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
|
||||
}
|
||||
|
||||
private _decorMoveUpdate(ev: PointerEvent): void {
|
||||
const m = this._decorMove!;
|
||||
const p = this._svgPoint(ev);
|
||||
const g = this._gridPitch;
|
||||
const dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
|
||||
const dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
|
||||
if (dx || dy) m.moved = true;
|
||||
const sp = this._curSpaceCfg;
|
||||
sp.decor = this._decorList.map((x) => {
|
||||
if (x.id !== m.id) return x;
|
||||
const o = m.orig;
|
||||
if (x.kind === 'line') return { ...x, x1: o.x1 + dx, y1: o.y1 + dy, x2: o.x2 + dx, y2: o.y2 + dy };
|
||||
return { ...x, x: o.x + dx, y: o.y + dy };
|
||||
});
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/** Double click on a text shape (select tool) re-opens its dialog. */
|
||||
private _decorShapeDbl(shape: any): void {
|
||||
if (this._mode !== 'decor' || shape.kind !== 'text') return;
|
||||
this._decorTextDialog = { id: shape.id, x: shape.x, y: shape.y,
|
||||
text: shape.text, size: shape.size || 'm', color: shape.color };
|
||||
}
|
||||
|
||||
private _decorSaveText(): void {
|
||||
const d = this._decorTextDialog;
|
||||
if (!d || !d.text.trim()) { this._decorTextDialog = null; return; }
|
||||
const sp = this._curSpaceCfg;
|
||||
if (d.id) {
|
||||
sp.decor = this._decorList.map((x) => x.id === d.id
|
||||
? { ...x, text: d.text.trim(), size: d.size, color: d.color } : x);
|
||||
} else {
|
||||
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||||
sp.decor = [...this._decorList, { id, kind: 'text', x: d.x, y: d.y,
|
||||
text: d.text.trim(), size: d.size, color: d.color }];
|
||||
}
|
||||
this._decorTextDialog = null;
|
||||
this._saveConfig();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _decorDeleteSel(): void {
|
||||
if (!this._decorSel) return;
|
||||
const sp = this._curSpaceCfg;
|
||||
sp.decor = this._decorList.filter((x) => x.id !== this._decorSel);
|
||||
this._decorSel = null;
|
||||
this._saveConfig();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _renderDecorLayer(): TemplateResult {
|
||||
const W = NORM_W, H = this._decorH;
|
||||
const TXT = { s: 14, m: 20, l: 30 } as Record<string, number>;
|
||||
const editing = this._mode === 'decor';
|
||||
const shapes = this._decorList.map((sh) => {
|
||||
const cls = 'dshape' + (editing && this._decorSel === sh.id ? ' dsel' : '');
|
||||
const down = (e: PointerEvent) => this._decorShapeDown(e, sh);
|
||||
const dbl = () => this._decorShapeDbl(sh);
|
||||
if (sh.kind === 'line')
|
||||
return svg`<line class="${cls}" x1="${sh.x1 * W}" y1="${sh.y1 * H}" x2="${sh.x2 * W}" y2="${sh.y2 * H}"
|
||||
stroke="${sh.color}" stroke-width="${sh.width}" @pointerdown=${down}></line>`;
|
||||
if (sh.kind === 'rect')
|
||||
return svg`<rect class="${cls}" x="${sh.x * W}" y="${sh.y * H}" width="${sh.w * W}" height="${sh.h * H}"
|
||||
stroke="${sh.color}" stroke-width="${sh.width}"
|
||||
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></rect>`;
|
||||
if (sh.kind === 'ellipse')
|
||||
return svg`<ellipse class="${cls}" cx="${(sh.x + sh.w / 2) * W}" cy="${(sh.y + sh.h / 2) * H}"
|
||||
rx="${(sh.w / 2) * W}" ry="${(sh.h / 2) * H}" stroke="${sh.color}" stroke-width="${sh.width}"
|
||||
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></ellipse>`;
|
||||
if (sh.kind === 'text')
|
||||
return svg`<text class="${cls} dtext" x="${sh.x * W}" y="${sh.y * H}" fill="${sh.color}"
|
||||
font-size="${TXT[sh.size] || TXT.m}" @pointerdown=${down} @dblclick=${dbl}>${sh.text}</text>`;
|
||||
return nothing;
|
||||
});
|
||||
// живое превью рисуемой фигуры
|
||||
let draft: unknown = nothing;
|
||||
const d = this._decorDraft;
|
||||
if (d) {
|
||||
const st = this._decorStyle;
|
||||
if (d.kind === 'line')
|
||||
draft = svg`<line class="ddraft" x1="${d.a[0]}" y1="${d.a[1]}" x2="${d.b[0]}" y2="${d.b[1]}"
|
||||
stroke="${st.color}" stroke-width="${st.width}"></line>`;
|
||||
else {
|
||||
const x = Math.min(d.a[0], d.b[0]), y = Math.min(d.a[1], d.b[1]);
|
||||
const w = Math.abs(d.b[0] - d.a[0]), h = Math.abs(d.b[1] - d.a[1]);
|
||||
draft = d.kind === 'rect'
|
||||
? svg`<rect class="ddraft" x="${x}" y="${y}" width="${w}" height="${h}" stroke="${st.color}"
|
||||
stroke-width="${st.width}" fill="${st.fill ? st.color : 'none'}" fill-opacity="${st.fill ? 0.15 : 0}"></rect>`
|
||||
: svg`<ellipse class="ddraft" cx="${x + w / 2}" cy="${y + h / 2}" rx="${w / 2}" ry="${h / 2}"
|
||||
stroke="${st.color}" stroke-width="${st.width}" fill="${st.fill ? st.color : 'none'}" fill-opacity="${st.fill ? 0.15 : 0}"></ellipse>`;
|
||||
}
|
||||
}
|
||||
return svg`<g class="decorlayer">${shapes}${draft}</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
private _renderDecorBar(): TemplateResult {
|
||||
const tools = [
|
||||
['select', 'mdi:cursor-default-outline', 'decor.select'],
|
||||
['line', 'mdi:vector-line', 'decor.line'],
|
||||
['rect', 'mdi:rectangle-outline', 'decor.rect'],
|
||||
['ellipse', 'mdi:ellipse-outline', 'decor.ellipse'],
|
||||
['text', 'mdi:format-text', 'decor.text'],
|
||||
['erase', 'mdi:eraser', 'decor.erase'],
|
||||
] as const;
|
||||
return html`<div class="editbar decorbar">
|
||||
<ha-icon icon="mdi:draw" class="warn"></ha-icon>
|
||||
${tools.map(
|
||||
([t, ic, k]) => html`<button class="btn ${this._decorTool === t ? 'on' : ''}"
|
||||
@click=${() => { this._decorTool = t; this._decorDraft = null; }}
|
||||
title=${this._t(k)}>
|
||||
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(k)}</span>
|
||||
</button>`,
|
||||
)}
|
||||
<input type="color" class="dcolor" .value=${this._decorStyle.color}
|
||||
title=${this._t('decor.color')}
|
||||
@input=${(e: Event) => (this._decorStyle = { ...this._decorStyle, color: (e.target as HTMLInputElement).value })} />
|
||||
<select class="dwidth" title=${this._t('decor.width')}
|
||||
@change=${(e: Event) => (this._decorStyle = { ...this._decorStyle, width: Number((e.target as HTMLSelectElement).value) })}>
|
||||
${[[1.5, 'decor.w_thin'], [3, 'decor.w_mid'], [6, 'decor.w_thick']].map(
|
||||
([v, k]) => html`<option value=${v} ?selected=${this._decorStyle.width === v}>${this._t(k as any)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
<label class="dfill"><input type="checkbox" .checked=${this._decorStyle.fill}
|
||||
@change=${(e: Event) => (this._decorStyle = { ...this._decorStyle, fill: (e.target as HTMLInputElement).checked })} />
|
||||
${this._t('decor.fill')}</label>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn barclose" title=${this._t('title.close_editor')}
|
||||
@click=${() => this._setMode('view')}>
|
||||
<ha-icon icon="mdi:close"></ha-icon>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderDecorTextDialog(): TemplateResult {
|
||||
const d = this._decorTextDialog!;
|
||||
return html`<div class="menuwrap dialogwrap" @click=${() => (this._decorTextDialog = null)}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="mdi:format-text"></ha-icon>${this._t('decor.text_title')}</div>
|
||||
<div class="body">
|
||||
<label>${this._t('decor.text_label')}</label>
|
||||
<input class="namein" .value=${d.text} autofocus
|
||||
@input=${(e: Event) => (this._decorTextDialog = { ...d, text: (e.target as HTMLInputElement).value })}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this._decorSaveText(); }} />
|
||||
<label>${this._t('decor.text_size')}</label>
|
||||
<div class="radiorow">
|
||||
${(['s', 'm', 'l'] as const).map(
|
||||
(sz) => html`<label class="srcrow inline">
|
||||
<input type="radio" name="dtsize" .checked=${d.size === sz}
|
||||
@change=${() => (this._decorTextDialog = { ...d, size: sz })} />
|
||||
<span>${this._t(('decor.size_' + sz) as any)}</span>
|
||||
</label>`,
|
||||
)}
|
||||
</div>
|
||||
<label>${this._t('decor.color')}</label>
|
||||
<input type="color" .value=${d.color}
|
||||
@input=${(e: Event) => (this._decorTextDialog = { ...d, color: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${() => (this._decorTextDialog = null)}>${this._t('btn.cancel')}</button>
|
||||
<button class="btn primary" ?disabled=${!d.text.trim()} @click=${() => this._decorSaveText()}>${this._t('btn.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
|
||||
private _openingClick(raw: number[]): void {
|
||||
const eps = this._gridPitch * 1.5;
|
||||
@@ -1451,7 +1771,7 @@ class HouseplanCard extends LitElement {
|
||||
if (!this._splitSel) {
|
||||
const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r));
|
||||
if (!hit?.id) return;
|
||||
this._splitSel = { roomId: hit.id, a: null };
|
||||
this._splitSel = { roomId: hit.id, pts: [] };
|
||||
return;
|
||||
}
|
||||
const room = rooms.find((r) => r.id === this._splitSel!.roomId);
|
||||
@@ -1469,16 +1789,30 @@ class HouseplanCard extends LitElement {
|
||||
const eps = this._gridPitch * 0.02;
|
||||
const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional
|
||||
const near = closestPointOnBoundary(raw, poly);
|
||||
const pt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null;
|
||||
if (!pt || !pointOnBoundary(pt, poly, eps)) {
|
||||
const wallPt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null;
|
||||
const onWall = !!wallPt && pointOnBoundary(wallPt, poly, eps);
|
||||
const cur = this._splitSel.pts;
|
||||
if (!cur.length) {
|
||||
// the cut starts on a wall
|
||||
if (!onWall) {
|
||||
this._showToast(this._t('toast.split_pick_wall'));
|
||||
return;
|
||||
}
|
||||
if (!this._splitSel.a) {
|
||||
this._splitSel = { ...this._splitSel, a: pt };
|
||||
this._splitSel = { ...this._splitSel, pts: [wallPt!] };
|
||||
return;
|
||||
}
|
||||
const parts = splitRoom(poly, this._splitSel.a, pt, eps);
|
||||
if (!onWall) {
|
||||
// an interior click adds an intermediate vertex of the cut path
|
||||
const mid = this._snap(raw);
|
||||
if (!ptInside(mid, poly, eps)) {
|
||||
this._showToast(this._t('toast.split_pick_inside'));
|
||||
return;
|
||||
}
|
||||
this._splitSel = { ...this._splitSel, pts: [...cur, mid] };
|
||||
return;
|
||||
}
|
||||
// a wall point finishes the cut
|
||||
const parts = splitRoomPath(poly, [...cur, wallPt!], eps);
|
||||
if (!parts) {
|
||||
this._showToast(this._t('toast.split_bad_cut'));
|
||||
return;
|
||||
@@ -1500,12 +1834,32 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _markupMove(ev: MouseEvent): void {
|
||||
if (!this._markup) return;
|
||||
if (this._tool === 'opening') {
|
||||
// hover preview: where the opening would land (raw point, wall-snapped later)
|
||||
this._cursorPt = this._svgPoint(ev);
|
||||
return;
|
||||
}
|
||||
const drawing = this._tool === 'draw' && this._path.length && !this._contourClosed;
|
||||
const cutting = this._tool === 'split' && !!this._splitSel?.a;
|
||||
const cutting = this._tool === 'split' && !!this._splitSel?.pts?.length;
|
||||
if (!drawing && !cutting) return;
|
||||
this._cursorPt = this._snap(this._svgPoint(ev));
|
||||
}
|
||||
|
||||
/** Dashed hover preview of an opening: same snap and default length as the click. */
|
||||
private get _openingPreview(): { x: number; y: number; angle: number; rlen: number } | null {
|
||||
if (this._tool !== 'opening' || !this._cursorPt) return null;
|
||||
const raw = this._cursorPt;
|
||||
// an existing opening under the cursor will be edited, not added — no preview
|
||||
const eps = this._gridPitch * 1.5;
|
||||
const hit = this._openingsR.find(
|
||||
(o) => Math.hypot(raw[0] - o.rx, raw[1] - o.ry) <= Math.max(o.rlen / 2, eps),
|
||||
);
|
||||
if (hit) return null;
|
||||
const snap = snapToWall(raw, this._spaceModel().rooms, eps);
|
||||
if (!snap) return null;
|
||||
return { ...snap, rlen: this._cmToUnits(90) };
|
||||
}
|
||||
|
||||
/** Save a room with a mandatory binding to an HA area. */
|
||||
private _saveRoom(): void {
|
||||
if (!this._areaSel) return;
|
||||
@@ -1631,6 +1985,7 @@ class HouseplanCard extends LitElement {
|
||||
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
|
||||
bindingFilter: '',
|
||||
icon: d.marker?.icon || '',
|
||||
autoIcon: d.icon || '',
|
||||
display: d.marker?.display || 'badge',
|
||||
rippleColor: d.marker?.ripple_color || '',
|
||||
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3,
|
||||
@@ -1648,7 +2003,7 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
} else {
|
||||
this._markerDialog = {
|
||||
name: '', binding: 'virtual', bindingFilter: '', icon: '',
|
||||
name: '', binding: 'virtual', bindingFilter: '', icon: '', autoIcon: '',
|
||||
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
|
||||
tapAction: '', model: '',
|
||||
link: '', description: '', pdfs: [], room: '', busy: false,
|
||||
@@ -1863,11 +2218,29 @@ class HouseplanCard extends LitElement {
|
||||
// remove the previous marker (by the old id and by the new id)
|
||||
cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
|
||||
cfg.markers.push(marker);
|
||||
// position: a new icon OR the room changed → place it at the center of the room/space.
|
||||
// Write POINT-WISE (layout/update), not the whole layout — a full layout/set overwrites
|
||||
// positions changed in other windows (the v1.4.4 incident).
|
||||
// Position rule (owner's decision, v1.33.4): editing an existing icon —
|
||||
// rebinding it to another HA device/entity or to another room — must NOT
|
||||
// move it. Its current position (saved or the ephemeral auto one) is
|
||||
// migrated to the new marker id. Only two cases still center the icon:
|
||||
// a truly NEW icon, and a move to a room in a DIFFERENT space (keeping
|
||||
// the old coordinates there would be meaningless).
|
||||
// Write POINT-WISE (layout/update), not the whole layout — a full layout/set
|
||||
// overwrites positions changed in other windows (the v1.4.4 incident).
|
||||
let newPos: { s: string; x: number; y: number } | null = null;
|
||||
if (!this._layout[id] || roomChanged) {
|
||||
const targetSpace = space || prevDev?.space || this._space;
|
||||
const prevRec = oldId ? this._layout[oldId] : null;
|
||||
const prevPos = prevRec
|
||||
? { s: prevRec.s || prevDev?.space || this._space, x: prevRec.x, y: prevRec.y }
|
||||
: oldId && prevDev && this._defPos[oldId]
|
||||
? this._normPos(prevDev.space, this._defPos[oldId].x, this._defPos[oldId].y)
|
||||
: null;
|
||||
if (prevPos && prevPos.s === targetSpace) {
|
||||
// stays in place; pin it under the (possibly new) id
|
||||
if (id !== oldId || !this._layout[id] || roomChanged) {
|
||||
newPos = { s: prevPos.s, x: prevPos.x, y: prevPos.y };
|
||||
this._layout = { ...this._layout, [id]: newPos };
|
||||
}
|
||||
} else if (!this._layout[id] || roomChanged) {
|
||||
const spm = this._spaceModel(space || undefined);
|
||||
let cx = spm.vb[0] + spm.vb[2] / 2;
|
||||
let cy = spm.vb[1] + spm.vb[3] / 2;
|
||||
@@ -2460,9 +2833,9 @@ class HouseplanCard extends LitElement {
|
||||
</div>
|
||||
${this._canEdit
|
||||
? html`<div class="modes">
|
||||
${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant']] as const).map(
|
||||
${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant'], ['decor', 'mdi:draw']] as const).map(
|
||||
([m, ic]) => html`<button class="modetab ${this._mode === m ? 'active' : ''}"
|
||||
title=${this._t(('mode.' + m) as any)}
|
||||
title=${this._t(('mode.' + m + '_tip') as any)}
|
||||
@click=${() => { if (this._mode !== m) this._setMode(m); }}>
|
||||
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(('mode.' + m) as any)}</span>
|
||||
${this._mode === m
|
||||
@@ -2487,10 +2860,10 @@ class HouseplanCard extends LitElement {
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : nothing}
|
||||
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
|
||||
</div>
|
||||
|
||||
<div class="stage ${this._markup ? 'markup' : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
|
||||
style="height:calc(100dvh - 118px)"
|
||||
@click=${(e: MouseEvent) => this._markupClick(e)}
|
||||
@wheel=${(e: WheelEvent) => this._onWheel(e)}
|
||||
@@ -2500,10 +2873,14 @@ class HouseplanCard extends LitElement {
|
||||
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
|
||||
<div class="zoomwrap">
|
||||
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
|
||||
${this._markup ? this._renderMarkupDefs(vb) : nothing}
|
||||
${this._editing ? this._renderMarkupDefs(vb) : nothing}
|
||||
${this._editing && !this._markup
|
||||
? svg`<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>`
|
||||
: nothing}
|
||||
${space.bg
|
||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${this._renderDecorLayer()}
|
||||
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
||||
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
||||
@@ -2570,6 +2947,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._mergeDialog ? this._renderMergeDialog() : nothing}
|
||||
${this._openingDialog ? this._renderOpeningDialog() : nothing}
|
||||
${this._openingInfo ? this._renderOpeningInfoCard() : nothing}
|
||||
${this._decorTextDialog ? this._renderDecorTextDialog() : nothing}
|
||||
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
||||
${this._markerDialog ? this._renderMarkerDialog() : nothing}
|
||||
${this._infoCard ? this._renderInfoCard() : nothing}
|
||||
@@ -2822,7 +3200,8 @@ class HouseplanCard extends LitElement {
|
||||
if (!this._markup || !this._cursorPt) return null;
|
||||
if (this._tool === 'draw' && this._path.length && !this._contourClosed)
|
||||
return this._path[this._path.length - 1];
|
||||
if (this._tool === 'split' && this._splitSel?.a) return this._splitSel.a;
|
||||
if (this._tool === 'split' && this._splitSel?.pts?.length)
|
||||
return this._splitSel.pts[this._splitSel.pts.length - 1];
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3089,10 +3468,23 @@ class HouseplanCard extends LitElement {
|
||||
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
|
||||
: nothing}
|
||||
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
|
||||
${this._tool === 'split' && this._splitSel?.a
|
||||
? svg`<circle class="vertex first" cx="${this._splitSel.a[0]}" cy="${this._splitSel.a[1]}" r="${g * 0.22}"></circle>
|
||||
${(() => {
|
||||
const op = this._openingPreview;
|
||||
if (!op) return nothing;
|
||||
const rad = (op.angle * Math.PI) / 180;
|
||||
const dx = (Math.cos(rad) * op.rlen) / 2;
|
||||
const dy = (Math.sin(rad) * op.rlen) / 2;
|
||||
return svg`<line class="opghost" x1="${op.x - dx}" y1="${op.y - dy}"
|
||||
x2="${op.x + dx}" y2="${op.y + dy}"></line>
|
||||
<circle class="opghost-dot" cx="${op.x}" cy="${op.y}" r="${g * 0.18}"></circle>`;
|
||||
})()}
|
||||
${this._tool === 'split' && this._splitSel?.pts?.length
|
||||
? svg`${this._splitSel.pts.length > 1
|
||||
? svg`<polyline class="pathline" points="${this._splitSel.pts.map((p) => p.join(',')).join(' ')}"></polyline>`
|
||||
: nothing}
|
||||
${this._splitSel.pts.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
|
||||
${this._cursorPt
|
||||
? svg`<line class="preview" x1="${this._splitSel.a[0]}" y1="${this._splitSel.a[1]}"
|
||||
? svg`<line class="preview" x1="${this._splitSel.pts[this._splitSel.pts.length - 1][0]}" y1="${this._splitSel.pts[this._splitSel.pts.length - 1][1]}"
|
||||
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
|
||||
: nothing}`
|
||||
: nothing}
|
||||
@@ -3149,9 +3541,6 @@ class HouseplanCard extends LitElement {
|
||||
title=${this._t('title.show_all')}>
|
||||
<ha-icon icon="${this._showAll ? 'mdi:eye' : 'mdi:eye-off-outline'}"></ha-icon>${this._t('devbar.show_all')}
|
||||
</button>
|
||||
<button class="btn" @click=${this._resetLayout} title=${this._t('title.reset_layout')}>
|
||||
<ha-icon icon="mdi:backup-restore"></ha-icon>${this._t('devbar.reset')}
|
||||
</button>
|
||||
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
|
||||
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('devbar.rules')}
|
||||
</button>
|
||||
@@ -3271,10 +3660,17 @@ class HouseplanCard extends LitElement {
|
||||
<label>${this._t('marker.icon_label')}</label>
|
||||
${customElements.get('ha-icon-picker')
|
||||
? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon}
|
||||
.placeholder=${d.autoIcon || undefined}
|
||||
.fallbackPath=${undefined}
|
||||
@value-changed=${(e: any) => (this._markerDialog = { ...d, icon: e.detail.value || '' })}></ha-icon-picker>`
|
||||
: html`<input class="namein" type="text" placeholder=${this._t('marker.icon_ph')}
|
||||
: html`<input class="namein" type="text"
|
||||
placeholder=${d.autoIcon || this._t('marker.icon_ph')}
|
||||
.value=${d.icon}
|
||||
@input=${(e: Event) => (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`}
|
||||
${!d.icon && d.autoIcon
|
||||
? html`<p class="muted iconauto"><ha-icon icon=${d.autoIcon}></ha-icon>
|
||||
${this._t('marker.icon_auto', { icon: d.autoIcon })}</p>`
|
||||
: nothing}
|
||||
|
||||
<label>${this._t('marker.display_label')}</label>
|
||||
<select class="areasel"
|
||||
|
||||
+27
-6
@@ -22,7 +22,6 @@
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show all area devices (no curation)",
|
||||
"title.reset_layout": "Reset icon positions to auto layout",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
@@ -115,7 +114,6 @@
|
||||
"device.light_group": "light group",
|
||||
"device.fallback": "device",
|
||||
"device.virtual": "virtual device",
|
||||
"confirm.reset_layout": "Reset all icon positions to the auto layout?",
|
||||
"confirm.delete_room": "Delete room \"{name}\"?",
|
||||
"confirm.remove_marker": "Remove \"{name}\" from the plan?",
|
||||
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
|
||||
@@ -128,8 +126,8 @@
|
||||
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
|
||||
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
|
||||
"toast.rooms_merged": "Rooms merged into “{name}”",
|
||||
"toast.split_pick_wall": "Click a grid dot on the room’s wall",
|
||||
"toast.split_bad_cut": "The cut must be a straight line from wall to wall, inside the room",
|
||||
"toast.split_pick_wall": "Start the cut on the room’s wall",
|
||||
"toast.split_bad_cut": "The cut must run wall to wall inside the room, without crossing walls or itself",
|
||||
"merge.header": "Merge rooms",
|
||||
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
|
||||
"merge.keep": "Keep",
|
||||
@@ -251,7 +249,6 @@
|
||||
"title.close_editor": "Close editor (back to view)",
|
||||
"devbar.add": "Add",
|
||||
"devbar.show_all": "Show all",
|
||||
"devbar.reset": "Reset",
|
||||
"devbar.rules": "Icon rules",
|
||||
"space.roomcard_section": "Room card shows:",
|
||||
"space.label_temp": "Temperature",
|
||||
@@ -260,5 +257,29 @@
|
||||
"space.label_light": "Lights on/off",
|
||||
"roomcard.light_on": "On",
|
||||
"roomcard.light_off": "Off",
|
||||
"roomcard.light_partial": "{on} of {total}"
|
||||
"roomcard.light_partial": "{on} of {total}",
|
||||
"toast.split_pick_inside": "Intermediate cut points must be inside the room",
|
||||
"mode.decor": "Background editor",
|
||||
"decor.select": "Select",
|
||||
"decor.line": "Line",
|
||||
"decor.rect": "Rectangle",
|
||||
"decor.ellipse": "Oval",
|
||||
"decor.text": "Text",
|
||||
"decor.erase": "Erase",
|
||||
"decor.color": "Color",
|
||||
"decor.width": "Line width",
|
||||
"decor.w_thin": "Thin",
|
||||
"decor.w_mid": "Medium",
|
||||
"decor.w_thick": "Thick",
|
||||
"decor.fill": "Fill",
|
||||
"decor.text_title": "Text label",
|
||||
"decor.text_label": "Text",
|
||||
"decor.text_size": "Size",
|
||||
"decor.size_s": "Small",
|
||||
"decor.size_m": "Medium",
|
||||
"decor.size_l": "Large",
|
||||
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
|
||||
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
|
||||
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
|
||||
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks"
|
||||
}
|
||||
|
||||
+27
-6
@@ -22,7 +22,6 @@
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без курирования)",
|
||||
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
@@ -115,7 +114,6 @@
|
||||
"device.light_group": "группа света",
|
||||
"device.fallback": "устройство",
|
||||
"device.virtual": "виртуальное устройство",
|
||||
"confirm.reset_layout": "Сбросить позиции всех иконок к авто-раскладке?",
|
||||
"confirm.delete_room": "Удалить комнату «{name}»?",
|
||||
"confirm.remove_marker": "Убрать «{name}» с плана?",
|
||||
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
|
||||
@@ -128,8 +126,8 @@
|
||||
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
|
||||
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
|
||||
"toast.rooms_merged": "Комнаты объединены в «{name}»",
|
||||
"toast.split_pick_wall": "Кликните по узлу сетки на стене комнаты",
|
||||
"toast.split_bad_cut": "Разрез — прямая от стены до стены внутри комнаты",
|
||||
"toast.split_pick_wall": "Начните разрез на стене комнаты",
|
||||
"toast.split_bad_cut": "Разрез — от стены до стены внутри комнаты, без пересечения стен и самого себя",
|
||||
"merge.header": "Объединение комнат",
|
||||
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
|
||||
"merge.keep": "Оставить",
|
||||
@@ -251,7 +249,6 @@
|
||||
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
|
||||
"devbar.add": "Добавить",
|
||||
"devbar.show_all": "Показать все",
|
||||
"devbar.reset": "Сброс",
|
||||
"devbar.rules": "Правила иконок",
|
||||
"space.roomcard_section": "В карточке комнаты:",
|
||||
"space.label_temp": "Температура",
|
||||
@@ -260,5 +257,29 @@
|
||||
"space.label_light": "Свет вкл/выкл",
|
||||
"roomcard.light_on": "Вкл",
|
||||
"roomcard.light_off": "Выкл",
|
||||
"roomcard.light_partial": "{on} из {total}"
|
||||
"roomcard.light_partial": "{on} из {total}",
|
||||
"toast.split_pick_inside": "Промежуточные точки разреза — внутри комнаты",
|
||||
"mode.decor": "Редактор подложки",
|
||||
"decor.select": "Выбрать",
|
||||
"decor.line": "Линия",
|
||||
"decor.rect": "Прямоугольник",
|
||||
"decor.ellipse": "Овал",
|
||||
"decor.text": "Надпись",
|
||||
"decor.erase": "Стереть",
|
||||
"decor.color": "Цвет",
|
||||
"decor.width": "Толщина линии",
|
||||
"decor.w_thin": "Тонкая",
|
||||
"decor.w_mid": "Средняя",
|
||||
"decor.w_thick": "Толстая",
|
||||
"decor.fill": "Залить",
|
||||
"decor.text_title": "Надпись",
|
||||
"decor.text_label": "Текст",
|
||||
"decor.text_size": "Размер",
|
||||
"decor.size_s": "Мелкий",
|
||||
"decor.size_m": "Средний",
|
||||
"decor.size_l": "Крупный",
|
||||
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
|
||||
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
|
||||
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
|
||||
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики"
|
||||
}
|
||||
|
||||
+33
-10
@@ -310,27 +310,50 @@ function dropRepeats(pts: number[][], eps: number): number[][] {
|
||||
export function splitRoom(
|
||||
poly: number[][], a: number[], b: number[], eps = 1e-6,
|
||||
): [number[][], number[][]] | null {
|
||||
if (!poly || poly.length < 3 || samePoint(a, b, eps)) return null;
|
||||
return splitRoomPath(poly, [a, b], eps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a room along a polyline: first and last points on walls, intermediate
|
||||
* vertices strictly inside the room. A two-point path is the classic straight
|
||||
* chord. Returns the two parts, or null when the path is not a clean cut.
|
||||
*/
|
||||
export function splitRoomPath(
|
||||
poly: number[][], pts: number[][], eps = 1e-6,
|
||||
): [number[][], number[][]] | null {
|
||||
if (!poly || poly.length < 3 || !pts || pts.length < 2) return null;
|
||||
const a = pts[0];
|
||||
const b = pts[pts.length - 1];
|
||||
if (samePoint(a, b, eps)) return null;
|
||||
const ia = edgeIndexOf(poly, a, eps);
|
||||
const ib = edgeIndexOf(poly, b, eps);
|
||||
if (ia < 0 || ib < 0) return null; // an end is not on a wall
|
||||
const mids = pts.slice(1, -1);
|
||||
for (const m of mids) if (!pointStrictlyInside(m, poly, eps)) return null;
|
||||
// no path segment may cross a wall
|
||||
for (let sI = 0; sI < pts.length - 1; sI++)
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (segmentsProperlyCross(a, b, poly[i], poly[(i + 1) % poly.length])) return null; // leaves the room
|
||||
// a chord lying along a wall has its midpoint ON the outline, not inside it
|
||||
if (!pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps)) return null;
|
||||
if (segmentsProperlyCross(pts[sI], pts[sI + 1], poly[i], poly[(i + 1) % poly.length])) return null;
|
||||
// the path may not properly self-intersect
|
||||
for (let sI = 0; sI < pts.length - 1; sI++)
|
||||
for (let t = sI + 2; t < pts.length - 1; t++)
|
||||
if (segmentsProperlyCross(pts[sI], pts[sI + 1], pts[t], pts[t + 1])) return null;
|
||||
// a straight chord lying along a wall has its midpoint ON the outline, not inside
|
||||
if (pts.length === 2 && !pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps))
|
||||
return null;
|
||||
const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
|
||||
const pts: number[][] = [from];
|
||||
const acc: number[][] = [from];
|
||||
let i = (fromIdx + 1) % poly.length;
|
||||
for (let guard = 0; guard <= poly.length; guard++) {
|
||||
pts.push(poly[i]);
|
||||
acc.push(poly[i]);
|
||||
if (i === toIdx) break;
|
||||
i = (i + 1) % poly.length;
|
||||
}
|
||||
pts.push(to);
|
||||
return dropRepeats(pts, eps);
|
||||
acc.push(to);
|
||||
return dropRepeats(acc, eps);
|
||||
};
|
||||
const p1 = walk(a, ia, b, ib);
|
||||
const p2 = walk(b, ib, a, ia);
|
||||
const p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
|
||||
const p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
|
||||
if (p1.length < 3 || p2.length < 3) return null;
|
||||
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
|
||||
return [p1, p2];
|
||||
|
||||
@@ -396,6 +396,14 @@ export const cardStyles = css`
|
||||
display: inline-flex;
|
||||
}
|
||||
.roomlabel .rlm.lit { opacity: 1; }
|
||||
.iconauto {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.iconauto ha-icon { --mdc-icon-size: 18px; }
|
||||
.rlhandle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@@ -432,6 +440,68 @@ export const cardStyles = css`
|
||||
user-select: none;
|
||||
z-index: 3;
|
||||
}
|
||||
/* decor (background) layer */
|
||||
.decorlayer .dshape { pointer-events: none; }
|
||||
.stage.mode-decor .decorlayer .dshape {
|
||||
pointer-events: visiblePainted;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage.mode-decor.dtool-select .decorlayer .dshape { cursor: move; }
|
||||
.decorlayer .dsel {
|
||||
filter: drop-shadow(0 0 3px var(--hp-accent));
|
||||
}
|
||||
.decorlayer .ddraft {
|
||||
opacity: 0.75;
|
||||
stroke-dasharray: 6 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.decorlayer text {
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.stage.mode-decor {
|
||||
outline: 2px solid #26a69a;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
|
||||
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.stage.mode-decor .room, .stage.mode-decor .devlayer { pointer-events: none; }
|
||||
.stage.mode-decor .oplock { pointer-events: none; }
|
||||
/* decor mode: everything but the decor itself fades back */
|
||||
.stage.mode-decor .room,
|
||||
.stage.mode-decor .devlayer,
|
||||
.stage.mode-decor .opening,
|
||||
.stage.mode-decor .rlabel {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.decorbar .dcolor {
|
||||
width: 30px; height: 26px; padding: 0; border: none; background: none; cursor: pointer;
|
||||
}
|
||||
.decorbar .dwidth {
|
||||
font-family: inherit; font-size: 12px; border-radius: 6px;
|
||||
background: var(--hp-bg2, transparent); color: var(--hp-txt); border: 1px solid var(--hp-muted);
|
||||
padding: 3px 5px;
|
||||
}
|
||||
.decorbar .dfill {
|
||||
display: inline-flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.opghost {
|
||||
stroke: var(--hp-open, #ff9800);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 7 6;
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
.opghost-dot {
|
||||
fill: var(--hp-open, #ff9800);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
.rlabel {
|
||||
fill: var(--hp-muted);
|
||||
font-size: 15px;
|
||||
@@ -445,6 +515,12 @@ export const cardStyles = css`
|
||||
.stage.markup {
|
||||
cursor: crosshair;
|
||||
}
|
||||
/* room-picking stages: merge (both clicks) and split before a room is chosen */
|
||||
.stage.markup.tool-merge,
|
||||
.stage.markup.tool-split.pickstage,
|
||||
.stage.markup.tool-delroom {
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage.markup .room {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
splitRoomPath,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
} from '../test-build/logic.js';
|
||||
@@ -544,3 +545,38 @@ test('spaceDisplayOf: room-card metric flags default to off', () => {
|
||||
const d1 = spaceDisplayOf({ plan_url: 'x', settings: { label_temp: true, label_light: true } });
|
||||
assert.deepEqual([d1.labelTemp, d1.labelHum, d1.labelLqi, d1.labelLight], [true, false, false, true]);
|
||||
});
|
||||
|
||||
test('splitRoomPath: polyline cut of a square', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
// Г-образный разрез: верхняя стена (4,0) → внутрь (4,6) → правая стена (10,6)
|
||||
const parts = splitRoomPath(sq, [[4, 0], [4, 6], [10, 6]], 1e-6);
|
||||
assert.ok(parts);
|
||||
const [p1, p2] = parts;
|
||||
const area = (p) => Math.abs(p.reduce((a, _, i) => {
|
||||
const [x1, y1] = p[i], [x2, y2] = p[(i + 1) % p.length];
|
||||
return a + x1 * y2 - x2 * y1;
|
||||
}, 0)) / 2;
|
||||
assert.ok(Math.abs(area(p1) + area(p2) - 100) < 1e-6);
|
||||
// меньшая часть — прямоугольник 6x6 минус... фактически площадь 4*6+... проверим что обе > 0
|
||||
assert.ok(area(p1) > 0 && area(p2) > 0);
|
||||
});
|
||||
|
||||
test('splitRoomPath: two points == classic straight chord', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const parts = splitRoomPath(sq, [[5, 0], [5, 10]], 1e-6);
|
||||
assert.ok(parts);
|
||||
});
|
||||
|
||||
test('splitRoomPath rejects bad paths', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
// промежуточная точка снаружи
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [4, -3], [10, 6]], 1e-6), null);
|
||||
// сегмент пересекает стену (выходит и возвращается)
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [14, 5], [10, 6]], 1e-6), null);
|
||||
// конец не на стене
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [5, 5]], 1e-6), null);
|
||||
// самопересечение пути
|
||||
assert.equal(splitRoomPath(sq, [[2, 0], [8, 8], [8, 2], [2, 8], [0, 4]], 1e-6), null);
|
||||
// менее двух точек
|
||||
assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user