v1.59.0: release stable plan editing

This commit is contained in:
Matysh
2026-08-06 17:43:53 +03:00
parent 5ca4c7e5c5
commit e7aca9c678
14 changed files with 284 additions and 101 deletions
+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.59.0-rc.2"
VERSION = "1.59.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.59.0-rc.2"
"version": "1.59.0"
}
File diff suppressed because one or more lines are too long
+19 -19
View File
File diff suppressed because one or more lines are too long
+44
View File
@@ -2,6 +2,50 @@
## Unreleased (dev)
## v1.59.0 — 2026-08-06
Stable 1.59 turns House Plan into a substantially more complete floor-plan
editor while keeping the card's live Home Assistant view coherent. It includes
every beta/RC change below and closes the line with clean-floor hover geometry
and robust wall unions.
- **Real walls, virtual boundaries and openings form one geometry system.** A
room can mix wall thicknesses, a wall can contain real and virtual stretches,
adjacent equal fragments compact again, and Resize preserves exact interval
endpoints. Partial openings, virtual T-junctions, thick-wall door tunnels and
sunlight from the inner corners of windows are handled consistently.
- **Plan edits are explicit and reversible.** Room outline, Split, Close
boundary, Merge rooms, Delete room, Resize, wall thickness and opening edits
have one result each and share a named 50-command Undo/Redo stack. Delete
removes only the selected object.
- **One mandatory grid invariant.** Rooms, openings, markers, decor, furniture
and backdrops always land on the grid or stay quantised along their wall.
Optimize plans previews and applies all current lossless migrations, repairs
legacy/off-grid data and compacts wall fragments with a safe one-step undo.
- **Editors gained production workflows.** Background objects open their
properties on double click; furniture has real dimensions and wall snapping;
live labels accept arbitrary text mixed with manually typed or picker-inserted
Home Assistant tokens; layer and object visibility controls are explicit.
- **Device feedback uses one semantic language.** Yellow means actual work,
orange means open/unlocked, alarms remain dominant, unavailable devices are
muted, motion is a short event and presence lasts while detected. The marker
plate, pulse/activity effect and source-light room fill now agree, including
covers, climate, fans, relays, media players and other working devices.
- **Rooms explain themselves in View.** Glow and toasts are pointer-transparent;
room hover and tooltips work through light pools, the tooltip reports clean
floor area, and device dialogs expose an explicit Hide/Show action. The hover
accent now follows the same inner wall faces used for area; nested-room walls
use the face exposed to the parent floor, while doors, windows and virtual
spans remain gaps.
- **Final wall-body correction.** Wall bodies are formed as the union of each
room's own outset-minus-inset ring. A parent clean-floor contour can no longer
erase half of a nested 15 cm wall or leave a white sliver at a complex
multi-wall crossing.
- **Documentation was rebuilt from the product.** The complete Russian user
guide, setting/device behaviour tables and improvement plan describe the
shipped code; obsolete audits and one-off debug material live under
`legacy/`.
## v1.59.0-rc.2 — 2026-08-06
The second release candidate makes plan editing predictable and reversible,
+48
View File
@@ -8,6 +8,54 @@
## Не выпущено (dev)
## v1.59.0 — 2026-08-06
Стабильная 1.59 превращает House Plan в существенно более полный редактор
планов, сохраняя цельное живое представление Home Assistant. В неё входят все
изменения beta/RC ниже; линейку завершают hover по границе чистого пола и
устойчивое объединение тел стен.
- **Реальные стены, виртуальные границы и проёмы работают как одна система.** В
комнате можно смешивать толщины, одна стена может состоять из реальных и
виртуальных участков, а соседние одинаковые фрагменты снова объединяются.
Resize сохраняет точные концы интервалов; частичные открытия, виртуальные
Т-стыки, дверные тоннели в толстых стенах и солнечные лучи из внутренних
углов окон обрабатываются согласованно.
- **Правки плана однозначны и обратимы.** «Контур комнаты», Split, «Закрыть
границу», «Объединить комнаты», «Удалить комнату», Resize, толщина стен и
операции с проёмами имеют по одному результату и используют общий
именованный Undo/Redo на 50 команд. Delete удаляет только выбранный объект.
- **Единый обязательный инвариант сетки.** Комнаты, проёмы, маркеры, декор,
мебель и подложка всегда попадают в сетку либо квантуются вдоль своей стены.
«Оптимизировать планы» показывает preview и применяет все актуальные
миграции без потерь, исправляет старые/off-grid данные, уплотняет фрагменты
стен и оставляет безопасную одношаговую отмену.
- **Редакторы получили рабочие пользовательские сценарии.** Свойства объектов
подложки открываются двойным кликом; мебель имеет реальные размеры и магнит
к стенам; живые надписи принимают произвольный текст вперемешку с вручную
введёнными или вставленными из picker токенами Home Assistant; управление
видимостью слоёв и объектов стало явным.
- **Индикация устройств говорит на одном семантическом языке.** Жёлтый означает
фактическую работу, оранжевый — открыто/разблокировано, тревога имеет высший
приоритет, недоступное устройство приглушается, движение является коротким
событием, а присутствие длится весь детект. Подложка маркера, пульсация и
заливка «Свет по источникам» согласованы для штор, климата, вентиляторов,
реле, медиаплееров и других работающих устройств.
- **Комната объясняет себя в Просмотре.** Glow и toast не перехватывают
указатель; hover и тултип работают сквозь световое пятно, тултип показывает
чистую площадь, а диалог устройства содержит явное действие
«Скрыть»/«Показать». Акцент hover теперь идёт по тем же внутренним граням,
что используются для площади; у вложенной комнаты выбирается грань,
обращённая к родительскому полу, а двери, окна и виртуальные участки остаются
разрывами.
- **Финальная коррекция тел стен.** Тела формируются как объединение собственного
кольца `внешний − внутренний` каждой комнаты. Чистый пол родительской комнаты
больше не может вырезать половину вложенной стены толщиной 15 см либо оставить
белую щель на сложном перекрёстке.
- **Документация заново собрана по продукту.** Полное русское руководство,
таблицы поведения настроек/устройств и план улучшений описывают выпущенный
код; устаревшие аудиты и разовые debug-материалы перенесены в `legacy/`.
## v1.59.0-rc.2 — 2026-08-06
Второй release candidate делает редактирование плана предсказуемым и
+8 -3
View File
@@ -15,11 +15,11 @@
| Item | State |
|---|---|
| Version | **v1.59.0-rc.2** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-rc.2` on **`dev`**, GitHub Release with `prerelease=true`; `main` is not touched. The rc line avoids the beta.9/beta.10 lexicographic discovery problem in HACS 2.0.x. Previous pre-release: v1.59.0-rc.1; previous stable: v1.58.0 |
| Version | **v1.59.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable release. Tag `v1.59.0`, `main` and `dev` point to the same tested release commit; GitHub Release uses `prerelease=false`. Previous pre-release: v1.59.0-rc.2; previous stable: v1.58.0 |
| Workflow | Owner's rule since 2026-08-05: ordinary fixes/features are made **locally, without tests and without commits**. Tests/build/smokes run only when the owner asks for a pre-release; then bump the version, commit/tag the tested `dev` state and publish a GitHub Release with `prerelease=true`. `main` is not touched and nothing is copied to the home instance by hand. The release workflow now reports a failed HACS-discovery check when GitHub does not return the new tag as its first prerelease |
| Workflow | Owner's rule since 2026-08-05: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets the full tests/build/smokes, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. A requested stable release gets the same full gate, then `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, 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 | rc.2 passes the local Node 22 typecheck/build, all 389 frontend tests, all 93 pure-backend tests and all 113 browser smokes. Native Windows cannot start the current HA pytest plugin (`fcntl` is Unix-only), so the exact-SHA Ubuntu Validate remains mandatory: `release.yml` withholds the asset until every matching run finishes green; GitHub Validate and the release asset are verified after the tag is pushed |
| CI | v1.59.0 passes the local Node 22 typecheck/build, all 390 frontend tests, all 93 pure-backend tests and all 113 browser smokes. Native Windows cannot start the current HA pytest plugin (`fcntl` is Unix-only), so the exact-SHA Ubuntu Validate remains mandatory: `release.yml` withholds the asset until every matching run finishes green; GitHub Validate and the release asset are verified after the tag is pushed |
| 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), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
@@ -88,6 +88,11 @@
Glow and toast overlays no longer steal room/tool pointers, View hover covers
shared thick walls, device Hide/Show is explicit, the static no-op aspect
field is gone, and the new user guide/product audit replaces archived docs.
- **v1.59.0** (2026-08-06): The stable 1.59 line includes all beta/RC work.
Room hover follows clean-floor wall faces, including nested contours and
projected opening gaps. Thick-wall rendering unions each room's own wall
ring, so one room's floor cannot erase another room's wall or leave white
slivers at complex crossings.
## Recent milestones (details in CHANGELOG.md)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.59.0-rc.2",
"version": "1.59.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.59.0-rc.2",
"version": "1.59.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.59.0-rc.2",
"version": "1.59.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+86 -16
View File
@@ -47,7 +47,8 @@ import {
degradeWalls, rekeyWallsAfterMove,
setWallThickness, setWallThicknessForRoom, cmToField, fieldToCm, wallCmToUnits,
wallEdgeBodies, wallBodiesUnionPath, paperRoomShapesWithWalls,
innerContourForRoom, openingInnerFaceOffset, applyWallThicknessToNewRoom,
innerContourForRoom, roomWallProfile, outsetContour,
openingInnerFaceOffset, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, wallIntervals, normalizeWallIntervals,
intervalCmAt, wallBodyNeedsSolid, type WallEntry,
} from './wall-thickness';
@@ -90,7 +91,7 @@ import {
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
const CARD_VERSION = '1.59.0-rc.2';
const CARD_VERSION = '1.59.0';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -5723,12 +5724,12 @@ class HouseplanCard extends LitElement {
}
/**
* View-only perimeter above real wall bodies.
* View-only clean-floor perimeter above real wall bodies.
*
* The room shape lives below the unioned wall body, so its CSS :hover stroke
* disappears precisely on thick/common walls. Draw the hovered room's real
* boundary once more after the bodies. Open (virtual) spans stay dashed in
* their own layer and are cut out here instead of being painted solid.
* The hover follows the same inner face that defines the displayed room
* area. A nested room is a hole in the hovered room, so its boundary uses the
* opposite (outward) wall face. Open spans stay dashed in their own layer;
* doors and windows remain gaps instead of being bridged by a solid accent.
*/
private _renderRoomHover(space: SpaceModel): TemplateResult {
const hover = this._hoverRoom;
@@ -5740,16 +5741,39 @@ class HouseplanCard extends LitElement {
const poly = roomPoly(room);
if (!poly) return svg`` as unknown as TemplateResult;
// A parent room also owns the walls around rooms nested inside it.
// A parent room also owns the floor-facing side of walls around rooms
// nested inside it. Keep the room together with its cached polygon so a
// hole can use the OUTER face of that nested room's wall.
const others = space.rooms
.filter((r) => r !== room)
.map((r) => roomPoly(r))
.filter((p): p is number[][] => !!p);
const contours = [poly, ...islandsOf(poly, others)];
.map((r) => ({ room: r, poly: roomPoly(r) }))
.filter((v): v is { room: RoomCfg; poly: number[][] } => !!v.poly);
const islandPolys = islandsOf(poly, others.map((v) => v.poly));
const pairs = this._openPairs();
const allOpenCuts = pairs.flatMap((p) => p.segs);
const openCuts = room.id
? pairs.filter((p) => p.a.id === room.id || p.b.id === room.id).flatMap((p) => p.segs)
: pairs.flatMap((p) => p.segs);
const walls = this._spaceWalls;
const floor = walls.length && room.id
? (innerContourForRoom(
space.rooms, room.id, walls, allOpenCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
) || poly)
: poly;
const contours: { axis: number[][]; face: number[][] }[] = [{ axis: poly, face: floor }];
for (const island of islandPolys) {
const owner = others.find((v) => v.poly === island)?.room;
let face = island;
if (walls.length && owner?.id) {
const profile = roomWallProfile(
space.rooms, owner.id, walls, allOpenCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
if (profile) face = outsetContour(profile.poly, profile.offsets) || island;
}
contours.push({ axis: island, face });
}
// A hidden opening symbol is still a physical opening: never bridge a
// doorway/window with the hover stroke merely because its symbol is off.
const openingCuts = this._openingsR.map((o) => {
@@ -5758,13 +5782,59 @@ class HouseplanCard extends LitElement {
const dy = (Math.sin(rad) * o.rlen) / 2;
return [o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy];
});
const cuts = openCuts.concat(openingCuts);
const eps = this._gridPitch * 0.02;
const d = contours.map((contour) => {
if (!cuts.length) {
return `M ${contour.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`;
// Cuts are stored on wall centrelines. Project each relevant cut onto the
// clean-floor face before trimming; otherwise a door on a thick wall would
// no longer intersect the inset hover contour.
const cutOnFace = (cut: number[], axis: number[][], face: number[][]): number[] | null => {
const cx = cut[2] - cut[0], cy = cut[3] - cut[1];
const cLen = Math.hypot(cx, cy);
if (cLen < eps) return null;
const cux = cx / cLen, cuy = cy / cLen;
const mx = (cut[0] + cut[2]) / 2, my = (cut[1] + cut[3]) / 2;
let belongs = false;
for (let i = 0; i < axis.length; i++) {
const a = axis[i], b = axis[(i + 1) % axis.length];
const ex = b[0] - a[0], ey = b[1] - a[1];
const eLen = Math.hypot(ex, ey);
if (eLen < eps || Math.abs(cux * (ey / eLen) - cuy * (ex / eLen)) > 0.05) continue;
if (distToSegment([mx, my], [a[0], a[1], b[0], b[1]]) <= eps * 4) {
belongs = true;
break;
}
}
return outlineWithout(contour, cuts, eps)
if (!belongs) return null;
let best: { a: number[]; b: number[]; d: number } | null = null;
for (let i = 0; i < face.length; i++) {
const a = face[i], b = face[(i + 1) % face.length];
const ex = b[0] - a[0], ey = b[1] - a[1];
const eLen = Math.hypot(ex, ey);
if (eLen < eps || Math.abs(cux * (ey / eLen) - cuy * (ex / eLen)) > 0.05) continue;
const d = distToSegment([mx, my], [a[0], a[1], b[0], b[1]]);
if (!best || d < best.d) best = { a, b, d };
}
if (!best) return null;
const fx = best.b[0] - best.a[0], fy = best.b[1] - best.a[1];
const fLen = Math.hypot(fx, fy) || 1;
const nx = -fy / fLen, ny = fx / fLen;
const shift = (best.a[0] - mx) * nx + (best.a[1] - my) * ny;
return [
cut[0] + nx * shift, cut[1] + ny * shift,
cut[2] + nx * shift, cut[3] + ny * shift,
];
};
const rawCuts = openCuts.concat(openingCuts);
const d = contours.map(({ axis, face }) => {
const cuts = rawCuts
.map((cut) => cutOnFace(cut, axis, face))
.filter((cut): cut is number[] => !!cut);
if (!cuts.length) {
return `M ${face.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`;
}
return outlineWithout(face, cuts, eps)
.map((sg) => `M ${sg[0]} ${sg[1]} L ${sg[2]} ${sg[3]}`)
.join(' ');
}).filter(Boolean).join(' ');
+2 -2
View File
@@ -1212,8 +1212,8 @@ export const cardStyles = css`
stroke: rgba(62, 166, 255, 0.55);
stroke-opacity: 1;
}
/* View hover is repeated above the unioned thick-wall body. The original
room stroke remains responsible for the fill and thin-wall fallback. */
/* View hover follows the clean-floor face above the unioned wall body. The
original room stroke remains responsible for fill and thin-wall fallback. */
.room-hover-outline {
fill: none;
stroke: var(--hp-accent);
+16 -18
View File
@@ -1270,8 +1270,13 @@ export function wallBodyRings(
}
/**
* Seamless wall hatch: union of all half-outsets minus union of all half-insets,
* Seamless wall hatch: union of each room's own outset-minus-inset wall ring,
* with opening slots cut as holes. One continuous body across L and T joins.
*
* Do not rewrite this as `(union outsets) - (union insets)`: subtraction does
* not distribute over union. In a nested/complex layout the clean floor of one
* room would then erase a wall owned by another room, leaving half-depth strips
* and tiny holes at junctions.
*/
export function wallBodiesUnionPath(
rooms: any[],
@@ -1284,8 +1289,7 @@ export function wallBodiesUnionPath(
coordScale = 1,
): { d: string; depthUnits: number } | null {
if (!walls?.length) return null;
const outsets: number[][][] = [];
const insets: number[][][] = [];
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
let maxDepth = 0;
for (const room of rooms || []) {
if (!room?.id) continue;
@@ -1294,26 +1298,20 @@ export function wallBodiesUnionPath(
for (const o of pr.offsets) if (o > 0) maxDepth = Math.max(maxDepth, o * 2);
const outC = outsetContour(pr.poly, pr.offsets);
const inC = insetContour(pr.poly, pr.offsets);
if (outC) outsets.push(outC);
if (inC) insets.push(inC);
if (!outC) continue;
roomRings.push({ outset: outC, inset: inC });
}
if (!outsets.length) return null;
if (!roomRings.length) return null;
const junctions = virtualJunctionPatches(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
try {
let Uout: any = closedRing(outsets[0]);
for (let i = 1; i < outsets.length; i++) {
Uout = union(Uout, closedRing(outsets[i]) as any);
}
let body: any = Uout;
if (insets.length) {
let Uin: any = closedRing(insets[0]);
for (let i = 1; i < insets.length; i++) {
Uin = union(Uin, closedRing(insets[i]) as any);
}
body = difference(Uout, Uin);
}
const bodyOf = (ring: typeof roomRings[number]): any => {
const outset: any = closedRing(ring.outset);
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
};
let body: any = bodyOf(roomRings[0]);
for (let i = 1; i < roomRings.length; i++) body = union(body, bodyOf(roomRings[i]));
// The room-ring subtraction above cannot infer a mitre between real arms
// owned by different contours at a virtual T. Add only those missing
// junction pieces, then let physical openings cut through them as usual.
+18
View File
@@ -398,6 +398,24 @@ test('wallBodiesUnionPath: single fully-thick room keeps a floor hole', () => {
assert.ok((united.d.match(/M/g) || []).length >= 2, united.d);
});
test('wallBodiesUnionPath: a parent floor never erases a nested room wall', () => {
const scale = 1000;
const rooms = [
{ id: 'parent', poly: [[100, 100], [900, 100], [900, 900], [100, 900]] },
{ id: 'nested', poly: [[300, 300], [700, 300], [700, 700], [300, 700]] },
];
let walls = applyWallThicknessToNewRoom([], rooms, 'parent', 15, pitch, [], scale);
walls = applyWallThicknessToNewRoom(walls, rooms, 'nested', 15, pitch, [], scale);
const united = wallBodiesUnionPath(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(united);
// Parent ring (outer + floor hole) and nested ring (outer + floor hole).
// The old `(union outsets) - (union insets)` formula returned only two
// subpaths here because the parent floor swallowed the nested wall entirely.
assert.ok((united.d.match(/M/g) || []).length >= 4, united.d);
});
test('paper with walls covers shared centreline; without walls matches paperRoomShapes', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] },