mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4e5ce6822 | ||
|
|
46f20fe4e1 | ||
|
|
1108b2bc14 |
@@ -4,3 +4,4 @@ test-build/
|
||||
*.log
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# AGENTS.md
|
||||
|
||||
House Plan is one HACS package with two parts plus a demo harness:
|
||||
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
|
||||
|
||||
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
|
||||
|
||||
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
|
||||
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
|
||||
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
|
||||
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
|
||||
@@ -34,6 +34,9 @@ right on your Lovelace dashboard.
|
||||
virtual walls and a visual decor layer, all drawn with clicks; room resize
|
||||
by dragging walls, with live lengths and areas as you drag; smart
|
||||
alignment guides and a live ruler in real meters/feet.
|
||||
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
|
||||
place and pull a corner to size it, with its real size in metres shown as
|
||||
you drag, so the drawing and the photo of your plan finally line up.
|
||||
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
|
||||
whole groups of lights (works for dumb switches and stateless remotes too).
|
||||
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
|
||||
@@ -185,7 +188,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
|
||||
|
||||

|
||||
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is always square; an image keeps its own proportions and is centred inside it.
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
|
||||
|
||||

|
||||
|
||||
|
||||
+2
-1
@@ -104,6 +104,7 @@ House Plan показывает ваш умный дом так, как он в
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
@@ -187,7 +188,7 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст всегда квадратный; картинка сохраняет свои пропорции и центрируется внутри него.
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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.58.0-beta.1"
|
||||
VERSION = "1.58.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.58.0-beta.1"
|
||||
"version": "1.58.0"
|
||||
}
|
||||
|
||||
@@ -207,6 +207,50 @@ const out = await page.evaluate(async () => {
|
||||
o.secondRunOffersNoButton = !sr().querySelector('.dialogwrap .btn.on');
|
||||
c._alignDialog = null;
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- 2c) the promise is an UPPER BOUND, in EACH space's own scale -----
|
||||
// AUD-158B1-01: one normalised maximum converted through the first space's
|
||||
// cell size promised 2.5 cm for a vertex that moved 50 cm on the floor above.
|
||||
const MULTI = { spaces: [
|
||||
{ id: 'm1', title: 'Ground', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'ma', name: 'A', area: 'living_room',
|
||||
poly: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3], [0.1, 0.3]] }] },
|
||||
{ id: 'm2', title: 'Attic', cell_cm: 100, view_box: [0, 0, 1, 1],
|
||||
// the ONLY off-grid vertex on the plan, and it is on the 100 cm floor
|
||||
rooms: [{ id: 'mb', name: 'B', area: 'kitchen',
|
||||
poly: [[0.2 + 1 / GRID_N / 2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]] }] },
|
||||
], markers: [], settings: {} };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(MULTI));
|
||||
c._layout = {};
|
||||
c._modelCache = null; c._frame = null; c._space = 'm1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._openAlignDialog(); await c.updateComplete;
|
||||
const md = c._alignDialog;
|
||||
o.alignPromiseUsesTheOwnScaleOfEachSpace = !!md && md.cm >= 50 && md.cm < 51;
|
||||
o.alignPromiseNamesTheSpaceItBelongsTo = !!md && md.where === 'Attic'
|
||||
&& (sr().querySelector('.dialogwrap .body')?.textContent || '').includes('Attic');
|
||||
c._alignDialog = null; await c.updateComplete;
|
||||
|
||||
// ---- 2d) an angle-only opening fix is offerable (AUD-158B1-02) --------
|
||||
// its centre is already on the wall; only the stored angle is wrong, and the
|
||||
// batch used to report "nothing to move" while returning a different plan
|
||||
const TURN = { spaces: [{ id: 'a1', title: 'Flat', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r1', name: 'A', area: 'living_room',
|
||||
poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
|
||||
openings: [{ id: 'oa', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }],
|
||||
}], markers: [], settings: {} };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(TURN));
|
||||
c._layout = {};
|
||||
c._modelCache = null; c._frame = null; c._space = 'a1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._openAlignDialog(); await c.updateComplete;
|
||||
const ad = c._alignDialog;
|
||||
o.angleOnlyOpeningCounts = !!ad && ad.report.moved === 1 && ad.report.rotated === 1;
|
||||
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('.dialogwrap .btn.on');
|
||||
await c._runAlignToGrid();
|
||||
await c.updateComplete;
|
||||
o.angleOnlyOpeningIsActuallyFixed = c._serverCfg.spaces[0].openings[0].angle === 0;
|
||||
|
||||
return o;
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+16
-14
File diff suppressed because one or more lines are too long
+27
-2
@@ -429,12 +429,37 @@ Guarantees, all covered by `test/align-grid.test.mjs`:
|
||||
* every grid-bound element ends on a node; a rect's FAR corner too (a
|
||||
snapped *size* on an off-grid origin leaves the other side between
|
||||
the nodes);
|
||||
* an opening ends on its wall, at whole steps along it, inside it;
|
||||
* an opening ends on its wall, at whole steps along it, inside it, and
|
||||
**with the wall's own angle** — the angle is written, so it is part of
|
||||
the diff (AUD-158B1-02: an opening already on its wall with a wrong
|
||||
angle used to be returned changed inside `changed: false`, which made
|
||||
it unfixable);
|
||||
* a stray opening with no wall within 6 steps is left exactly where it
|
||||
is rather than teleported;
|
||||
* **idempotent**: a second run reports `moved: 0`, `changed: false`, and
|
||||
returns objects deep-equal to the first run's;
|
||||
* the report's `maxShift` never exceeds half a step diagonally.
|
||||
* the report is an **upper bound**, not a sample (AUD-158B1-01).
|
||||
|
||||
### The report is a promise
|
||||
|
||||
The confirmation is the only gate in front of an action with no undo, so
|
||||
`maxShift`/`maxShiftCm` must never be smaller than what the run does:
|
||||
|
||||
* displacement is measured on the geometry **actually written back** —
|
||||
all FOUR corners of a rect, minimum-size correction included. The two
|
||||
corners nobody used to measure are exactly the two that can be worst:
|
||||
they carry the X error of one side together with the Y error of the
|
||||
other, which is √2 of either;
|
||||
* an opening is measured on its **ends**, flip-invariantly, so turning
|
||||
it in place costs what it really costs and a 180° rewrite costs
|
||||
nothing;
|
||||
* the maximum is accumulated in **centimetres**, each space through its
|
||||
own `cell_cm`, and the report names the space it belongs to. One
|
||||
normalised maximum converted through the *first* space's cell size
|
||||
promised 2.5 cm for a vertex that moved 50 cm on a 100 cm floor;
|
||||
* the dialog rounds the last tenth **up** and, on a multi-space plan,
|
||||
says which space the maximum is in; openings corrected in angle alone
|
||||
are counted on a line of their own.
|
||||
|
||||
There is no undo, and the dialog says so: the card keeps no snapshot of
|
||||
the previous geometry. Re-running the action does not undo it either —
|
||||
|
||||
+39
-4
@@ -1,10 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## v1.58.0-beta.1 — 2026-08-04
|
||||
## v1.58.0 — 2026-08-04
|
||||
|
||||
First pre-release of the 1.58 line: the backdrop picture becomes a
|
||||
movable, scalable object, the opaque plan sheet is redefined as the
|
||||
room contours, and the decor drawing tools stop stealing clicks.
|
||||
Minor release: the backdrop picture becomes a movable, scalable object,
|
||||
the opaque plan sheet is redefined as the room contours, the decor
|
||||
drawing tools stop stealing clicks, and «Align everything to the grid»
|
||||
tells the truth about what it is going to do.
|
||||
|
||||
### The backdrop picture can be moved and scaled (docs/BACKDROP.md)
|
||||
|
||||
@@ -43,6 +44,40 @@ room contours, and the decor drawing tools stop stealing clicks.
|
||||
- «Выбрать» and «Стереть» are unchanged: shapes stay clickable there,
|
||||
which is where selecting and deleting belong.
|
||||
|
||||
### «Align everything to the grid» keeps its promise (AUD-158B1-01)
|
||||
|
||||
The action has no undo, so its confirmation dialog is the only safety
|
||||
there is — and it was understating the damage.
|
||||
|
||||
- **The maximum shift is measured on the geometry that is actually
|
||||
written**, not on an intermediate one. A rect was measured by its
|
||||
origin and its far corner only — so the minimum-size correction that
|
||||
widens a too-thin box afterwards went unmeasured, and the two corners
|
||||
that carry the X error of one side together with the Y error of the
|
||||
other were never looked at at all. An ordinary box could move √2
|
||||
times further than promised; a box thinner than one grid step, much
|
||||
further.
|
||||
- **Each space is converted through its own `cell_cm`.** The dialog
|
||||
used to take one normalised maximum and multiply it by the cell size
|
||||
of the FIRST space: a plan whose ground floor is drawn at 5 cm per
|
||||
cell and whose attic is at 100 cm promised 2.5 cm for a vertex that
|
||||
moved 50 cm. The report now carries the maximum in centimetres and
|
||||
the space it belongs to, and the dialog names that space when there
|
||||
is more than one.
|
||||
- The last tenth of a centimetre is rounded **up**: the promise can
|
||||
never be smaller than the deed.
|
||||
|
||||
### An opening whose only error is its angle can now be fixed (AUD-158B1-02)
|
||||
|
||||
- A window sitting exactly on its wall but holding a wrong `angle` used
|
||||
to come back as `changed: false` — the dialog said there was nothing
|
||||
to move and offered no button, while the returned plan differed from
|
||||
the one given. Such openings could never be aligned. The angle is
|
||||
part of the diff now; the dialog counts them separately, and the
|
||||
displacement is measured on the opening's ENDS, so turning it in
|
||||
place is not free in the report either. Turning an opening end over
|
||||
end (180°) is still counted as a correction but displaces nothing.
|
||||
|
||||
## v1.57.0 — 2026-08-04
|
||||
|
||||
Minor release: the canvas becomes infinite — no more "plan size", no
|
||||
|
||||
+40
-5
@@ -6,12 +6,13 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.58.0-beta.1 — 2026-08-04
|
||||
## v1.58.0 — 2026-08-04
|
||||
|
||||
Первый пре-релиз ветки 1.58: картинка-подложка становится объектом,
|
||||
который можно двигать и масштабировать, непрозрачный лист плана
|
||||
переопределён как контуры комнат, а рисующие инструменты декора
|
||||
перестают перехватывать клик по чужой фигуре.
|
||||
Минорный релиз: картинка-подложка становится объектом, который можно
|
||||
двигать и масштабировать, непрозрачный лист плана переопределён как
|
||||
контуры комнат, рисующие инструменты декора перестают перехватывать
|
||||
клик по чужой фигуре, а «Выровнять всё по сетке» честно говорит, что
|
||||
собирается сделать.
|
||||
|
||||
### Картинку-подложку можно двигать и масштабировать (docs/BACKDROP.md)
|
||||
|
||||
@@ -49,6 +50,40 @@
|
||||
- «Выбрать» и «Стереть» не изменились: там фигуры по-прежнему
|
||||
кликабельны — выделять и удалять положено именно ими.
|
||||
|
||||
### «Выровнять всё по сетке» держит обещание (AUD-158B1-01)
|
||||
|
||||
У действия нет отмены, поэтому диалог подтверждения — единственная
|
||||
защита, а он занижал масштаб происходящего.
|
||||
|
||||
- **Максимальный сдвиг измеряется по той геометрии, которая
|
||||
записывается**, а не по промежуточной. У прямоугольника мерились
|
||||
только исходный угол и дальний — поэтому доводка до минимального
|
||||
размера, которая потом раздвигает слишком узкую коробку, в замер не
|
||||
попадала, а два угла, где ошибка по X одной стороны складывается с
|
||||
ошибкой по Y другой, не смотрели вовсе. Обычная коробка могла
|
||||
сдвинуться в √2 раза дальше обещанного, а коробка тоньше шага сетки —
|
||||
намного дальше.
|
||||
- **Каждое пространство переводится через свой `cell_cm`.** Диалог брал
|
||||
один нормализованный максимум и умножал его на размер клетки ПЕРВОГО
|
||||
пространства: на плане, где первый этаж нарисован по 5 см на клетку, а
|
||||
мансарда по 100 см, обещали 2,5 см вершине, которая уезжала на 50 см.
|
||||
Отчёт теперь несёт максимум в сантиметрах и пространство, которому он
|
||||
принадлежит, а диалог называет это пространство, если их больше
|
||||
одного.
|
||||
- Последняя десятая сантиметра округляется **вверх**: обещание не может
|
||||
оказаться меньше сделанного.
|
||||
|
||||
### Проём, у которого неверен только угол, теперь можно исправить (AUD-158B1-02)
|
||||
|
||||
- Окно, стоящее ровно на своей стене, но с неправильным `angle`,
|
||||
возвращалось с `changed: false` — диалог писал «двигать нечего» и не
|
||||
показывал кнопку, хотя возвращённый план отличался от поданного.
|
||||
Такие проёмы нельзя было выровнять никогда. Угол теперь входит в
|
||||
разницу; диалог считает такие проёмы отдельно, а смещение меряется по
|
||||
КОНЦАМ проёма, так что поворот на месте в отчёте тоже не бесплатен.
|
||||
Разворот проёма на 180° по-прежнему считается исправлением, но ничего
|
||||
не смещает.
|
||||
|
||||
## v1.57.0 — 2026-08-04
|
||||
|
||||
Минорный релиз: холст становится бесконечным — больше нет «размера
|
||||
|
||||
+2
-2
@@ -15,13 +15,13 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.58.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — the FIRST PRE-RELEASE of the new owner process: tag on `dev`, GitHub Release with `prerelease=true`, `main` untouched, no direct deploy (the home instance picks it up through HACS on the beta channel). Contents: the backdrop picture becomes a movable/scalable object (docs/BACKDROP.md — transform frame in the «Картинка-подложка» tool, even corner scaling, live size in metres, grid snap with a Shift bypass, new optional fields `plan_x`/`plan_y`/`plan_scale`, counted by "fit everything"); **NEW PAPER RULE — the opaque plan sheet is always the room contours**, the picture is drawn on top of the sheet and under the geometry (the "paper = image rectangle" branch is gone); decor drawing tools (line/rect/oval/text) and the backdrop tool no longer select the shape under the cursor — existing figures are inert there, select/erase unchanged. Last stable: v1.57.0 "the infinite canvas" |
|
||||
| Version | **v1.58.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable release, tag `v1.58.0` on `main`, GitHub Release with `prerelease=false`; **no direct deploy** — the home instance picks it up through HACS by tag. Contents: the backdrop picture becomes a movable/scalable object (docs/BACKDROP.md — transform frame in the «Картинка-подложка» tool, even corner scaling, live size in metres, grid snap with a Shift bypass, new optional fields `plan_x`/`plan_y`/`plan_scale`, counted by "fit everything"); **NEW PAPER RULE — the opaque plan sheet is always the room contours**, the picture is drawn on top of the sheet and under the geometry (the "paper = image rectangle" branch is gone); decor drawing tools (line/rect/oval/text) and the backdrop tool no longer select the shape under the cursor — existing figures are inert there, select/erase unchanged; the two P2 fixes from the beta.1 audit of «Выровнять всё по сетке» — the confirmation now promises a true upper bound measured per space in its own `cell_cm` (AUD-158B1-01), and an opening whose only error is its angle is a real change that can be applied (AUD-158B1-02). Shipped through v1.58.0-beta.1 (pre-release, 2026-08-04). Previous stable: v1.57.0 "the infinite canvas" |
|
||||
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0-beta.1 on it updates itself through HACS by tag (no scp) |
|
||||
| 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 |
|
||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Sunlight that reads on a light plan — the spec
|
||||
|
||||
Status: **approved in principle, not implemented.** Planned for the release
|
||||
after v1.58.0. Owner asked for the spec first (2026-08-04); nothing in this
|
||||
document is code yet. Builds on docs/SUN.md, which describes what ships today.
|
||||
|
||||
## The problem
|
||||
|
||||
Today a lit window casts a warm translucent wedge (docs/SUN.md). It reads
|
||||
beautifully on a dark scene — glow fill, night, a dark theme — and almost
|
||||
disappears on a light one, which is now the common case: `bg_mode: daynight`
|
||||
makes a clear day **white**, and a hand-drawn plan's paper is white too.
|
||||
|
||||
The cause is not opacity, it is physics. Painting light means adding
|
||||
luminance, and white has none left to give. Raising the alpha does not add
|
||||
contrast, it only tints the paper towards beige until the whole room looks
|
||||
dirty. Any fix has to take its contrast from something other than brightness.
|
||||
|
||||
## The model: paint the shade, not the light
|
||||
|
||||
On a light scene the wedges invert. The lit areas stay untouched paper; the
|
||||
**rest of the room** gets a light, cool veil. Contrast now comes from the
|
||||
unlit part, which has plenty of room to go darker, and the wedge becomes a
|
||||
window of clean paper inside a softly shaded room.
|
||||
|
||||
This is how architects draw insolation on white sheets, and it stays
|
||||
physically honest: a sunlit room really is brighter where the shaft lands.
|
||||
It also rhymes with the mode we already have — glow paints a dark house with
|
||||
pools of lamp light; this paints a light house with the shade between shafts.
|
||||
|
||||
Geometrically it is the same wedge polygon, used as a hole: a veil rectangle
|
||||
over the room's polygon, minus the wedges (an even-odd path, or a mask). The
|
||||
maths of `computeSunRays()` does not change at all — only what we do with the
|
||||
result.
|
||||
|
||||
## Switching between the two
|
||||
|
||||
Automatic, by the luminance of what the wedge is drawn on (the paper under
|
||||
the room, or the scene colour where a picture backdrop shows through):
|
||||
|
||||
- luminance below the threshold → **light model** (today's warm wedge);
|
||||
- above it → **shade model** (veil with wedge-shaped holes).
|
||||
|
||||
The threshold sits around mid-grey; the exact value is a visual decision to be
|
||||
made against real plans, dark and light themes, and the day/night palette.
|
||||
Whether this is fully automatic or also offered as an explicit choice is an
|
||||
open question (below).
|
||||
|
||||
## Two amplifiers, both models
|
||||
|
||||
**A rim on the wedge.** Light is invisible but its boundary is not: a hairline
|
||||
warm stroke along the two side edges of the shaft, fading out with the shaft
|
||||
itself. It costs one stroke, works on any background, and gives the shape a
|
||||
"beam" reading even when the fill is nearly transparent. In the shade model
|
||||
the same rim marks the edge of the veil hole.
|
||||
|
||||
**A mark on the window.** Independent of the wedges: a lit window gets a
|
||||
thicker warm stroke and a short arrow along the sun direction. It answers the
|
||||
question "which windows is the sun in right now" without reading the floor,
|
||||
and it survives everything the wedge does not — furniture-heavy plans, room
|
||||
fills, small kiosk scale, a wedge clipped to almost nothing by a narrow room.
|
||||
|
||||
## Colour
|
||||
|
||||
The veil is cool and desaturated (a blue-grey), never black: a black veil
|
||||
reads as a printing defect and fights the room fills. Its opacity is small —
|
||||
the target is a perceptible step, not a dimmed room; the value is to be tuned
|
||||
visually, in the same order as the wedge alpha today.
|
||||
|
||||
The light model keeps its warm ramp near the horizon (docs/SUN.md), but should
|
||||
lean on **hue rather than luminance** on medium backgrounds: an amber tint
|
||||
reads as colour even where it cannot read as brightness.
|
||||
|
||||
## What must not break
|
||||
|
||||
- **Room fills.** Temperature and LQI fills carry meaning; the veil must not
|
||||
be mistaken for them, and must not shift their perceived colour more than
|
||||
marginally. The veil goes under the fills, not over.
|
||||
- **The 3° threshold and the two-second fade** (docs/SUN.md) apply unchanged
|
||||
to whichever model is active. Crossing the luminance threshold — a theme
|
||||
switch, day turning to night — must cross-fade between models, never pop.
|
||||
- **Cloudiness** keeps dimming the effect, in the shade model by thinning the
|
||||
veil.
|
||||
- **Editors** stay neutral, as today: no sun, no day/night.
|
||||
- **The static card and kiosk** must pick the same model as the full card on
|
||||
the same plan; the choice is a pure function of the background.
|
||||
- **prefers-reduced-motion**: no cross-fade, switch instantly.
|
||||
- **Performance**: one veil path per room per render at most, memoised on the
|
||||
same key as the wedges.
|
||||
|
||||
## Open questions for the owner
|
||||
|
||||
1. Automatic switching only, or also an explicit setting «свет / тень / авто»
|
||||
in the space dialog? (Automatic is one less knob; explicit lets someone on
|
||||
a light theme keep the warm wedges if they like them.)
|
||||
2. Does the veil cover the whole room, or only the part of the room the sun
|
||||
could reach at all (the room's window-facing side)? Whole-room is simpler
|
||||
and reads as "this room is in shade"; partial is subtler.
|
||||
3. Should the window mark be always on, or follow the same «Solar rays»
|
||||
toggle?
|
||||
|
||||
## Testing notes (for when this is built)
|
||||
|
||||
Unit: the model chooser is a pure function of a colour → assert the switch at
|
||||
the threshold, both sides, plus junk input. The veil-with-holes geometry:
|
||||
holes equal the wedges, an unlit room gets a plain veil, a room with no
|
||||
exterior window gets nothing.
|
||||
|
||||
Browser: on a white paper plan a lit wedge is measurably lighter than its
|
||||
surroundings (sample pixels inside and outside the wedge — the current
|
||||
implementation would fail this, which is the point); on a dark scene nothing
|
||||
changes versus today; crossing the threshold cross-fades; room fills keep
|
||||
their identity; the window mark appears exactly on lit windows.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.58.0-beta.1",
|
||||
"version": "1.58.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.58.0-beta.1",
|
||||
"version": "1.58.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.58.0-beta.1",
|
||||
"version": "1.58.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+132
-17
@@ -27,15 +27,45 @@
|
||||
* opening is re-projected onto the nearest wall and its offset ALONG that wall
|
||||
* is snapped to the same step. On an axis-aligned wall with grid-aligned
|
||||
* corners the two rules agree exactly.
|
||||
*
|
||||
* THE REPORT IS A PROMISE, SO IT IS AN UPPER BOUND (AUD-158B1-01)
|
||||
* --------------------------------------------------------------
|
||||
* The confirmation is the only safety gate in front of an action with no undo.
|
||||
* A number that is merely typical of what happens is worse than no number at
|
||||
* all, so the maximum is measured on the geometry that is actually written
|
||||
* back. Two things used to make it a sample instead of a bound:
|
||||
*
|
||||
* * a rect was measured by its origin and its far corner only. The far corner
|
||||
* is then pushed out again by the minimum-size correction, and the other two
|
||||
* corners — the ones that carry the X error of one side together with the Y
|
||||
* error of the other — were never looked at, understating an ordinary box by
|
||||
* up to √2 and a box thinner than one step by much more.
|
||||
* * the single NORMALISED maximum was turned into centimetres by the caller,
|
||||
* through the `cell_cm` of the FIRST space. Two floors drawn at 5 cm and at
|
||||
* 100 cm per cell made that promise twenty times too small.
|
||||
*
|
||||
* So displacement is accumulated in CENTIMETRES, every space through its own
|
||||
* `cell_cm`, and the report says which space the maximum belongs to.
|
||||
*
|
||||
* AN OPENING ALSO CARRIES AN ANGLE (AUD-158B1-02)
|
||||
* ----------------------------------------------
|
||||
* The batch rewrites `angle` as well as `x`/`y`. A window whose centre is
|
||||
* already on its wall but whose stored angle is wrong is therefore a real
|
||||
* correction — yet it used to come back inside `changed:false`, so the dialog
|
||||
* said there was nothing to do and the fix could never be applied. The angle is
|
||||
* part of the diff now, and displacement is measured on the opening's ENDS, so
|
||||
* turning it is not free in the report either.
|
||||
*/
|
||||
|
||||
import { GRID_STEP_N } from './space-geometry';
|
||||
import { GRID_N, GRID_STEP_N } from './space-geometry';
|
||||
import { snapToWall } from './logic';
|
||||
|
||||
/** Anything closer than this to a node already counts as being on it. */
|
||||
const EPS = GRID_STEP_N * 1e-6;
|
||||
/** How far an opening may be from a wall and still be re-projected onto it. */
|
||||
const WALL_TOL = GRID_STEP_N * 6;
|
||||
/** What one cell is worth when a space does not say — the card's own default. */
|
||||
const DEFAULT_CELL_CM = 5;
|
||||
|
||||
/** Round a NORMALISED coordinate to the nearest grid node, idempotently.
|
||||
* A value already on a node is returned UNCHANGED (bit for bit), so a second
|
||||
@@ -53,6 +83,14 @@ export interface AlignReport {
|
||||
total: number;
|
||||
/** Largest displacement in NORMALISED units (1 = the plan's width). */
|
||||
maxShift: number;
|
||||
/** The same maximum in CENTIMETRES — every space through its own `cell_cm`,
|
||||
* because a normalised number means nothing until it meets a scale. This is
|
||||
* the number the confirmation promises, and it is an upper bound. */
|
||||
maxShiftCm: number;
|
||||
/** Which space holds that maximum (`''` when nothing moves). */
|
||||
maxSpace: string;
|
||||
/** Openings whose stored `angle` is corrected — possibly without moving. */
|
||||
rotated: number;
|
||||
}
|
||||
|
||||
export interface AlignResult {
|
||||
@@ -64,6 +102,43 @@ export interface AlignResult {
|
||||
|
||||
const dist = (ax: number, ay: number, bx: number, by: number) => Math.hypot(bx - ax, by - ay);
|
||||
|
||||
/** The displacement of an axis-aligned box: the largest of its FOUR corners,
|
||||
* measured against the box that is really written back (minimum-size
|
||||
* correction included). The worst corner combines the largest X error of
|
||||
* either side with the largest Y error of either side — which is why the two
|
||||
* that were never measured are exactly the two that can be the worst. */
|
||||
const boxShift = (
|
||||
x: number, y: number, w: number, h: number,
|
||||
nx: number, ny: number, nw: number, nh: number,
|
||||
): number => Math.hypot(
|
||||
Math.max(Math.abs(nx - x), Math.abs((nx + nw) - (x + w))),
|
||||
Math.max(Math.abs(ny - y), Math.abs((ny + nh) - (y + h))),
|
||||
);
|
||||
|
||||
/** The displacement of an opening, measured on its two ENDS so that turning it
|
||||
* in place is not free. An opening is a symmetric segment on its wall: 180°
|
||||
* apart is the same segment, so the pairing of ends that gives the smaller
|
||||
* answer is the true one — a flipped angle is a rewrite, not a move. */
|
||||
const openingShift = (
|
||||
x: number, y: number, a: number, len: number,
|
||||
nx: number, ny: number, na: number,
|
||||
): number => {
|
||||
const h = Math.max(Number(len) || 0, 0) / 2;
|
||||
const R = Math.PI / 180;
|
||||
const ux = Math.cos(a * R) * h, uy = Math.sin(a * R) * h;
|
||||
const vx = Math.cos(na * R) * h, vy = Math.sin(na * R) * h;
|
||||
const same = Math.max(dist(x + ux, y + uy, nx + vx, ny + vy),
|
||||
dist(x - ux, y - uy, nx - vx, ny - vy));
|
||||
const flip = Math.max(dist(x + ux, y + uy, nx - vx, ny - vy),
|
||||
dist(x - ux, y - uy, nx + vx, ny + vy));
|
||||
return Math.min(same, flip);
|
||||
};
|
||||
|
||||
const cellCmOf = (sp: any): number => {
|
||||
const v = Number(sp?.cell_cm);
|
||||
return v > 0 ? v : DEFAULT_CELL_CM;
|
||||
};
|
||||
|
||||
/**
|
||||
* The whole batch, as a pure function: give it the spaces and the layout, get
|
||||
* back new ones plus the report the confirmation dialog shows. Nothing here
|
||||
@@ -75,10 +150,33 @@ export function alignAllToGrid(
|
||||
): AlignResult {
|
||||
const spaces = JSON.parse(JSON.stringify(spacesIn || []));
|
||||
const layout: Record<string, any> = JSON.parse(JSON.stringify(layoutIn || {}));
|
||||
let moved = 0, total = 0, maxShift = 0;
|
||||
const note = (d: number) => { if (d > EPS) { moved++; if (d > maxShift) maxShift = d; } };
|
||||
let moved = 0, total = 0, maxShift = 0, maxShiftCm = 0, maxSpace = '', rotated = 0;
|
||||
|
||||
// a marker or a room label names its space; the scale of THAT space is the
|
||||
// one its centimetres are in
|
||||
const cellById: Record<string, number> = {};
|
||||
let cellWorst = DEFAULT_CELL_CM;
|
||||
for (const sp of spaces) {
|
||||
const c = cellCmOf(sp);
|
||||
if (sp?.id != null) cellById[String(sp.id)] = c;
|
||||
if (c > cellWorst) cellWorst = c;
|
||||
}
|
||||
|
||||
/** Record one element. `d` is normalised, `cellCm` turns it into the
|
||||
* centimetres of its own space. `forced` is for a change that is real
|
||||
* without being a displacement — an opening's angle. */
|
||||
const note = (d: number, cellCm: number, spaceId: string, forced = false): void => {
|
||||
if (!(d > EPS) && !forced) return;
|
||||
moved++;
|
||||
if (d > maxShift) maxShift = d;
|
||||
const cm = d * GRID_N * cellCm;
|
||||
if (cm > maxShiftCm) { maxShiftCm = cm; maxSpace = spaceId; }
|
||||
};
|
||||
|
||||
for (const sp of spaces) {
|
||||
const cell = cellCmOf(sp);
|
||||
const sid = sp?.id != null ? String(sp.id) : '';
|
||||
|
||||
// ---- rooms: every vertex to the nearest node ----------------------
|
||||
for (const r of sp.rooms || []) {
|
||||
total++;
|
||||
@@ -93,13 +191,14 @@ export function alignAllToGrid(
|
||||
// a rect keeps its far corner on the grid too, hence w/h are snapped
|
||||
// as corners and not as sizes (a snapped size on an off-grid origin
|
||||
// would leave the other side between the nodes).
|
||||
const x2 = snapN((r.x || 0) + (r.w || 0));
|
||||
const y2 = snapN((r.y || 0) + (r.h || 0));
|
||||
const nx = snapN(r.x), ny = snapN(r.y);
|
||||
d = Math.max(dist(r.x, r.y, nx, ny), dist(r.x + (r.w || 0), r.y + (r.h || 0), x2, y2));
|
||||
r.x = nx; r.y = ny; r.w = Math.max(GRID_STEP_N, x2 - nx); r.h = Math.max(GRID_STEP_N, y2 - ny);
|
||||
const x0 = r.x, y0 = r.y, w0 = r.w || 0, h0 = r.h || 0;
|
||||
const x2 = snapN(x0 + w0), y2 = snapN(y0 + h0);
|
||||
const nx = snapN(x0), ny = snapN(y0);
|
||||
const nw = Math.max(GRID_STEP_N, x2 - nx), nh = Math.max(GRID_STEP_N, y2 - ny);
|
||||
d = boxShift(x0, y0, w0, h0, nx, ny, nw, nh);
|
||||
r.x = nx; r.y = ny; r.w = nw; r.h = nh;
|
||||
}
|
||||
note(d);
|
||||
note(d, cell, sid);
|
||||
}
|
||||
|
||||
// ---- decor ---------------------------------------------------------
|
||||
@@ -112,15 +211,17 @@ export function alignAllToGrid(
|
||||
sh.x1 = a[0]; sh.y1 = a[1]; sh.x2 = b[0]; sh.y2 = b[1];
|
||||
} else {
|
||||
const nx = snapN(sh.x), ny = snapN(sh.y);
|
||||
d = dist(sh.x, sh.y, nx, ny);
|
||||
if (sh.w != null && sh.h != null) {
|
||||
const x2 = snapN(sh.x + sh.w), y2 = snapN(sh.y + sh.h);
|
||||
d = Math.max(d, dist(sh.x + sh.w, sh.y + sh.h, x2, y2));
|
||||
sh.w = Math.max(GRID_STEP_N, x2 - nx); sh.h = Math.max(GRID_STEP_N, y2 - ny);
|
||||
const nw = Math.max(GRID_STEP_N, x2 - nx), nh = Math.max(GRID_STEP_N, y2 - ny);
|
||||
d = boxShift(sh.x, sh.y, sh.w, sh.h, nx, ny, nw, nh);
|
||||
sh.w = nw; sh.h = nh;
|
||||
} else {
|
||||
d = dist(sh.x, sh.y, nx, ny);
|
||||
}
|
||||
sh.x = nx; sh.y = ny;
|
||||
}
|
||||
note(d);
|
||||
note(d, cell, sid);
|
||||
}
|
||||
|
||||
// ---- openings: wall-bound, snapped ALONG the (already aligned) wall --
|
||||
@@ -132,9 +233,16 @@ export function alignAllToGrid(
|
||||
const q = snapToWall([o.x, o.y], sp.rooms || [], WALL_TOL,
|
||||
{ step: GRID_STEP_N, length: Number(o.length) || 0 });
|
||||
if (!q) continue;
|
||||
const d = dist(o.x, o.y, q.x, q.y);
|
||||
// the angle is rewritten too, so it belongs in the diff: an opening
|
||||
// already on its wall with a wrong angle is a correction that must be
|
||||
// offerable, not a silent no-op (AUD-158B1-02)
|
||||
const raw = Number(o.angle);
|
||||
const turned = !(Number.isFinite(raw) && raw === q.angle);
|
||||
const d = openingShift(o.x, o.y, Number.isFinite(raw) ? raw : q.angle,
|
||||
Number(o.length) || 0, q.x, q.y, q.angle);
|
||||
o.x = q.x; o.y = q.y; o.angle = q.angle;
|
||||
note(d);
|
||||
if (turned) rotated++;
|
||||
note(d, cell, sid, turned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +255,15 @@ export function alignAllToGrid(
|
||||
const nx = snapN(p.x), ny = snapN(p.y);
|
||||
const d = dist(p.x, p.y, nx, ny);
|
||||
layout[k] = { ...p, x: nx, y: ny };
|
||||
note(d);
|
||||
// an entry whose space is gone still moves, and the promise must not
|
||||
// shrink because of it: the largest scale on the plan is the safe one
|
||||
const sid = typeof p.s === 'string' ? p.s : '';
|
||||
note(d, cellById[sid] ?? cellWorst, sid);
|
||||
}
|
||||
|
||||
return { spaces, layout, report: { moved, total, maxShift }, changed: moved > 0 };
|
||||
return {
|
||||
spaces, layout,
|
||||
report: { moved, total, maxShift, maxShiftCm, maxSpace, rotated },
|
||||
changed: moved > 0,
|
||||
};
|
||||
}
|
||||
|
||||
+26
-12
@@ -54,14 +54,14 @@ import { cardStyles } from './styles';
|
||||
import {
|
||||
fitInSquare, planRect, contentBounds, spaceModels, contentFrame, contentItems, spaceFrame,
|
||||
spaceCenter, iconUnit, iconCqw, gridLevels, itemOf, snapPt,
|
||||
MIN_ZOOM, PAN_SLACK, CANVAS_LIMIT, GRID_N, GRID_PITCH, GRID_STEP_N,
|
||||
MIN_ZOOM, PAN_SLACK, CANVAS_LIMIT, GRID_PITCH, GRID_STEP_N,
|
||||
PLAN_SCALE_MIN, PLAN_SCALE_MAX,
|
||||
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
|
||||
} from './space-geometry';
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.58.0-beta.1';
|
||||
const CARD_VERSION = '1.58.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
|
||||
@@ -410,7 +410,11 @@ class HouseplanCard extends LitElement {
|
||||
* already-computed result so the write cannot differ from the promise. */
|
||||
private _alignDialog: {
|
||||
report: AlignReport; spaces: any[]; layout: Record<string, any>;
|
||||
cm: number; busy: boolean;
|
||||
/** the promised maximum, in centimetres, ALREADY rounded up (AUD-158B1-01) */
|
||||
cm: number;
|
||||
/** the space that maximum belongs to, named only when there are several */
|
||||
where: string;
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
|
||||
private _settingsDialog: {
|
||||
@@ -5488,13 +5492,18 @@ class HouseplanCard extends LitElement {
|
||||
*/
|
||||
private _openAlignDialog = (): void => {
|
||||
if (!this._norm || !this._serverCfg) return;
|
||||
const r = alignAllToGrid(this._serverCfg.spaces || [], this._layout || {});
|
||||
// the shift in the user's own units: normalised → cells → cm, using the
|
||||
// scale of the first space that has one (they rarely differ, and the
|
||||
// number is an ORDER of magnitude, not a measurement)
|
||||
const cellCm = Number((this._serverCfg.spaces || []).find((x: any) => Number(x?.cell_cm) > 0)?.cell_cm) || 5;
|
||||
const cm = r.report.maxShift * GRID_N * cellCm;
|
||||
this._alignDialog = { report: r.report, spaces: r.spaces, layout: r.layout, cm, busy: false };
|
||||
const spaces = this._serverCfg.spaces || [];
|
||||
const r = alignAllToGrid(spaces, this._layout || {});
|
||||
// The dialog is a safety gate in front of an action with no undo, so the
|
||||
// number it shows is an UPPER BOUND and not a sample. The run already
|
||||
// measured every element in the centimetres of ITS OWN space — converting
|
||||
// one normalised maximum through the first space's `cell_cm` understated
|
||||
// a two-scale plan twentyfold (AUD-158B1-01) — and the last tenth is
|
||||
// rounded UP, so the promise can never be smaller than the deed.
|
||||
const cm = Math.ceil(r.report.maxShiftCm * 10) / 10;
|
||||
const sp = spaces.find((x: any) => x?.id != null && String(x.id) === r.report.maxSpace);
|
||||
const where = spaces.length > 1 && sp ? String(sp.title || sp.id) : '';
|
||||
this._alignDialog = { report: r.report, spaces: r.spaces, layout: r.layout, cm, where, busy: false };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -5741,9 +5750,14 @@ class HouseplanCard extends LitElement {
|
||||
? html`<p class="alignmsg">${this._t('gs.align_none')}</p>`
|
||||
: html`
|
||||
<p class="alignmsg">${this._t('gs.align_count', {
|
||||
n: String(r.moved), total: String(r.total),
|
||||
cm: (Math.round(d.cm * 10) / 10).toString(),
|
||||
n: String(r.moved), total: String(r.total), cm: String(d.cm),
|
||||
})}</p>
|
||||
${d.where
|
||||
? html`<p class="alignmsg">${this._t('gs.align_where', { s: d.where })}</p>`
|
||||
: nothing}
|
||||
${r.rotated
|
||||
? html`<p class="alignmsg">${this._t('gs.align_turned', { n: String(r.rotated) })}</p>`
|
||||
: nothing}
|
||||
<div class="rhint">${this._t('gs.align_warn')}</div>`}
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
@@ -429,6 +429,8 @@
|
||||
"gs.align_title": "Align everything to the grid",
|
||||
"gs.align_none": "Everything is already on the grid — nothing to move.",
|
||||
"gs.align_count": "{n} of {total} elements will move, by at most {cm} cm.",
|
||||
"gs.align_where": "The largest shift is in \u201c{s}\u201d.",
|
||||
"gs.align_turned": "Openings whose angle is corrected: {n}.",
|
||||
"gs.align_warn": "This rewrites the plan and the device positions in one go. There is no undo: the only way back is to move things by hand. If some elements are deliberately between the nodes, cancel.",
|
||||
"gs.align_run": "Align",
|
||||
"gs.align_done": "Aligned to the grid: {n} elements moved"
|
||||
|
||||
@@ -429,6 +429,8 @@
|
||||
"gs.align_title": "Выровнять всё по сетке",
|
||||
"gs.align_none": "Всё уже на сетке — двигать нечего.",
|
||||
"gs.align_count": "Сдвинется элементов: {n} из {total}, максимум на {cm} см.",
|
||||
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
|
||||
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
|
||||
"gs.align_warn": "Действие разом перезапишет план и позиции устройств. Отмены нет: вернуть можно только руками. Если какие-то элементы стоят между узлами намеренно — отмените.",
|
||||
"gs.align_run": "Выровнять",
|
||||
"gs.align_done": "Выровнено по сетке: сдвинуто элементов — {n}"
|
||||
|
||||
@@ -129,3 +129,125 @@ test('far-out coordinates align just as well as near ones (infinite canvas)', ()
|
||||
for (const p of spaces[0].rooms[0].poly) { assert.ok(onGrid(p[0])); assert.ok(onGrid(p[1])); }
|
||||
assert.ok(report.moved >= 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AUD-158B1-01 — the confirmation promises an exact maximum before an action
|
||||
// with no undo, so the report must be an UPPER BOUND of what the run does.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** What the run REALLY did, measured on the returned objects: the largest
|
||||
* displacement of any corner / vertex / end, in the centimetres of the space
|
||||
* it happened in. The report may never be smaller than this. */
|
||||
const reallyMovedCm = (before, after, cell = {}) => {
|
||||
const cm = (sp) => (Number(sp?.cell_cm) > 0 ? Number(sp.cell_cm) : 5) * GRID_N;
|
||||
let max = 0;
|
||||
const hit = (d, sp) => { if (d * cm(sp) > max) max = d * cm(sp); };
|
||||
const box = (a, b, sp) => {
|
||||
const dx = Math.max(Math.abs(b.x - a.x), Math.abs((b.x + b.w) - (a.x + (a.w || 0))));
|
||||
const dy = Math.max(Math.abs(b.y - a.y), Math.abs((b.y + b.h) - (a.y + (a.h || 0))));
|
||||
hit(Math.hypot(dx, dy), sp);
|
||||
};
|
||||
before.forEach((sp, i) => {
|
||||
const sq = after[i];
|
||||
(sp.rooms || []).forEach((r, j) => {
|
||||
const q = sq.rooms[j];
|
||||
if (r.poly?.length) r.poly.forEach((p, k) => hit(Math.hypot(q.poly[k][0] - p[0], q.poly[k][1] - p[1]), sp));
|
||||
else if (r.x != null) box(r, q, sp);
|
||||
});
|
||||
(sp.decor || []).forEach((d, j) => {
|
||||
const q = sq.decor[j];
|
||||
if (d.kind === 'line') {
|
||||
hit(Math.hypot(q.x1 - d.x1, q.y1 - d.y1), sp);
|
||||
hit(Math.hypot(q.x2 - d.x2, q.y2 - d.y2), sp);
|
||||
} else if (d.w != null) box(d, q, sp);
|
||||
else hit(Math.hypot(q.x - d.x, q.y - d.y), sp);
|
||||
});
|
||||
(sp.openings || []).forEach((o, j) => {
|
||||
const q = sq.openings[j];
|
||||
const h = (Number(o.length) || 0) / 2, R = Math.PI / 180;
|
||||
const e = (px, py, a, s) => [px + s * Math.cos(a * R) * h, py + s * Math.sin(a * R) * h];
|
||||
const same = Math.max(
|
||||
Math.hypot(...e(q.x, q.y, q.angle, 1).map((v, k) => v - e(o.x, o.y, o.angle, 1)[k])),
|
||||
Math.hypot(...e(q.x, q.y, q.angle, -1).map((v, k) => v - e(o.x, o.y, o.angle, -1)[k])));
|
||||
const flip = Math.max(
|
||||
Math.hypot(...e(q.x, q.y, q.angle, -1).map((v, k) => v - e(o.x, o.y, o.angle, 1)[k])),
|
||||
Math.hypot(...e(q.x, q.y, q.angle, 1).map((v, k) => v - e(o.x, o.y, o.angle, -1)[k])));
|
||||
hit(Math.min(same, flip), sp);
|
||||
});
|
||||
});
|
||||
return max;
|
||||
};
|
||||
|
||||
test('AUD-158B1-01: the maximum is converted through the scale of ITS OWN space', () => {
|
||||
const spaces = [
|
||||
{ id: 'first', cell_cm: 5, rooms: [{ id: 'a', poly: [[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2]] }] },
|
||||
{ id: 'second', cell_cm: 100, rooms: [{ id: 'b',
|
||||
poly: [[0.2 + S / 2, 0.2], [0.3, 0.2], [0.3, 0.3], [0.2, 0.3]] }] },
|
||||
];
|
||||
const { report } = alignAllToGrid(spaces, {});
|
||||
// half a step of a 100 cm cell is 50 cm — NOT the 2.5 cm of the first floor
|
||||
assert.ok(Math.abs(report.maxShiftCm - 50) < 1e-9, String(report.maxShiftCm));
|
||||
assert.equal(report.maxSpace, 'second');
|
||||
});
|
||||
|
||||
test('AUD-158B1-01: a rect thinner than one step is measured AFTER it is widened', () => {
|
||||
const spaces = [{ id: 'f1', cell_cm: 5,
|
||||
rooms: [{ id: 'r', x: 0.5, y: 0.5, w: 0.001, h: 0.001 }] }];
|
||||
const before = JSON.parse(JSON.stringify(spaces));
|
||||
const res = alignAllToGrid(spaces, {});
|
||||
const real = reallyMovedCm(before, res.spaces);
|
||||
assert.ok(res.report.maxShiftCm + 1e-9 >= real,
|
||||
`promised ${res.report.maxShiftCm} cm, moved ${real} cm`);
|
||||
});
|
||||
|
||||
test('AUD-158B1-01: a rect whose X and Y errors land on opposite corners', () => {
|
||||
// origin off in X only, far corner off in Y only → the mixed corner moves by
|
||||
// both at once, and that diagonal is the honest maximum
|
||||
const spaces = [{ id: 'f1', cell_cm: 5,
|
||||
rooms: [{ id: 'r', x: 0.25 + S * 0.4, y: 0.25, w: 0.1 - S * 0.4, h: 0.1 + S * 0.4 }] }];
|
||||
const before = JSON.parse(JSON.stringify(spaces));
|
||||
const res = alignAllToGrid(spaces, {});
|
||||
assert.ok(Math.abs(res.report.maxShift - S * 0.4 * Math.SQRT2) < 1e-12,
|
||||
String(res.report.maxShift / S));
|
||||
assert.ok(res.report.maxShiftCm + 1e-9 >= reallyMovedCm(before, res.spaces));
|
||||
});
|
||||
|
||||
test('AUD-158B1-01: the promise is an upper bound for the whole detuned plan', () => {
|
||||
const cfg = detuned();
|
||||
const before = JSON.parse(JSON.stringify(cfg.spaces));
|
||||
const res = alignAllToGrid(cfg.spaces, detunedLayout());
|
||||
assert.ok(res.report.maxShiftCm + 1e-9 >= reallyMovedCm(before, res.spaces),
|
||||
`promised ${res.report.maxShiftCm} cm`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AUD-158B1-02 — an opening whose only error is its angle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('AUD-158B1-02: an angle-only correction is a change and can be applied', () => {
|
||||
const spaces = [{ id: 'f1', cell_cm: 5,
|
||||
rooms: [{ id: 'r1', poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
|
||||
openings: [{ id: 'o', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }] }];
|
||||
const res = alignAllToGrid(spaces, {});
|
||||
const o = res.spaces[0].openings[0];
|
||||
assert.equal(o.x, 0.35); // the centre was already right…
|
||||
assert.equal(o.y, 0.2);
|
||||
assert.equal(o.angle, 0); // …only the angle was wrong
|
||||
assert.equal(res.changed, true, 'output differs but changed:false');
|
||||
assert.equal(res.report.moved, 1);
|
||||
assert.equal(res.report.rotated, 1);
|
||||
assert.ok(res.report.maxShiftCm > 0); // its ENDS really do move
|
||||
});
|
||||
|
||||
test('AUD-158B1-02: turning an opening end over end is not a move', () => {
|
||||
// 180 apart is the same segment on the same wall: it counts as a correction
|
||||
// (the field is rewritten) but it displaces nothing
|
||||
const spaces = [{ id: 'f1', cell_cm: 5,
|
||||
rooms: [{ id: 'r1', poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
|
||||
openings: [{ id: 'o', type: 'window', x: 0.35, y: 0.2, angle: 180, length: 0.1 }] }];
|
||||
const res = alignAllToGrid(spaces, {});
|
||||
assert.equal(res.spaces[0].openings[0].angle, 0);
|
||||
assert.equal(res.changed, true);
|
||||
assert.equal(res.report.rotated, 1);
|
||||
assert.equal(res.report.maxShiftCm, 0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user