Compare commits

...
Author SHA1 Message Date
Cursor AgentandMatysh f4e5ce6822 chore: add Cursor Cloud dev setup notes and ignore backend venv
- Add AGENTS.md documenting the card + integration + demo harness,
  and non-obvious cloud caveats (Python 3.13 backend venv for HA-harness
  tests, fresh-bundle copy before smoke, demo render nudge, known
  pixel-precision smoke).
- Ignore .venv-backend/ (Python 3.13 venv provisioned by the update script).

Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 04:45:11 +00:00
Matysh 46f20fe4e1 v1.58.0
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 2m50s
Validate / backend (push) Failing after 8m29s
Validate / smoke (push) Failing after 21m33s
2026-08-04 15:10:46 +03:00
Matysh 1108b2bc14 v1.58.0: backdrop transform, paper by rooms, align-to-grid fixes 2026-08-04 15:04:55 +03:00
22 changed files with 628 additions and 91 deletions
+1
View File
@@ -4,3 +4,4 @@ test-build/
*.log
__pycache__/
.pytest_cache/
.venv-backend/
+19
View File
@@ -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.
+4 -1
View File
@@ -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
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
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.
![Space creation dialog](docs/images/03-space-dialog.png)
+2 -1
View File
@@ -104,6 +104,7 @@ House Plan показывает ваш умный дом так, как он в
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -187,7 +188,7 @@ title: План дома
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст всегда квадратный; картинка сохраняет свои пропорции и центрируется внутри него.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
![Диалог создания пространства](docs/images/03-space-dialog.png)
+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.58.0-beta.1"
VERSION = "1.58.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.58.0-beta.1"
"version": "1.58.0"
}
+44
View File
@@ -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
+16 -14
View File
File diff suppressed because one or more lines are too long
+27 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
+113
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
+2
View File
@@ -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"
+2
View File
@@ -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}"
+122
View File
@@ -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);
});