Compare commits

...
Author SHA1 Message Date
claude[bot] 31d7a70e34 docs: review document for #302
Validate / docs (push) Failing after 28s
Validate / process-workflow-sync (push) Successful in 36s
Validate / provenance (push) Successful in 55s
Validate / process-gate (push) Failing after 43s
Validate / changes (push) Successful in 40s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 44s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #302
User-Visible: no
2026-08-25 15:12:23 +00:00
Codex 96318fb94f docs: refresh the screenshot fingerprint on the merged tree (#302)
Отпечаток снова сдвинут ребейзом; кадры прежние.

Issue: #302
User-Visible: no
2026-08-25 17:50:26 +03:00
Codex d3927f2e56 build: rebundle on the merged tree (#302)
Обе копии байт в байт; npm test 1304/1304 на объединённых исходниках.
Ссылка ниже — прогон, подтверждающий ревью кода, из которого бандл собран.

Issue: #302
User-Visible: no
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32859268589
2026-08-25 17:44:16 +03:00
Codex b2b2640751 test: accept the safe-resize baselines on the merged tree (#302)
Ребейз на dev столкнул две легитимные правки одной пары сцен: мой узел
(сомкнутая вершина ромба, decision №5) и plan-axis подсветку из dev. Обе
сцены пересняты на объединённом коде и осмотрены: вершина ромба сомкнута,
осевые линии dev на месте. Остальные 124 сцены не тронуты — шум `accept`
возвращён; хэши двух сцен, чьи PNG пришли из dev при ребейзе, приведены к
фактическим файлам.

Принято `npm run golden:accept -- --reviewed`; `golden:verify` после отката
шумовых — 126/126.

Issue: #302
User-Visible: no
Release: v1.68.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32859268589
2026-08-25 17:43:37 +03:00
Codex d8c7652d0e docs: AC6 names the #271 trim as the one subtraction the node keeps (#302 r2 M3)
Код честно держал адресный латеральный трим с самого решения №5, дока после
M1 описывала его верно — расходился только текст ТЗ, писавший «демонтирован
целиком». §4.5, §8.2 и AC6 приведены к фактическому контракту.

Issue: #302
User-Visible: no
2026-08-25 17:34:20 +03:00
Codex 8a82189368 chore: re-run CI for the flaky grid-scale shard (#302)
Пустой коммит: локально смок grid_scale_invariance стабильно зелёный (3/3,
darkView changed=69 при пороге 150), и тот же дифф-фон 69 воспроизводится на
чистом dev — падение шарда на прошлом прогоне похоже на средовую
вариативность раннера, а прав на rerun-failed-jobs у токена нет.

Issue: #302
User-Visible: no
2026-08-25 17:34:20 +03:00
claude[bot]andCodex 8eef4e9c5a docs: review document for #302
Issue: #302
User-Visible: no
2026-08-25 17:34:20 +03:00
Codex cc937de0b1 docs: refresh the screenshot fingerprint after the dead-layer removal (#302)
Удаление мёртвых слоёв изменило отпечаток исходников; сами кадры прежние.

Issue: #302
User-Visible: no
2026-08-25 17:34:20 +03:00
Codex d3ed299d2a refactor: fans are the whole additive node — dead support re-union removed (#302)
С адресным тримом (он не режет полосы обычных узлов) возврат саппорт-квадов в
тело стал мёртвым слоем: полный юнит-набор зелёный без него — проверено
исполнением, а не предположено. По дисциплине мутационного реестра
избыточный слой убран (fans only), его мутант `junction-supports-not-restored`
снят: у #271-узлов трим режет только ЗА пределами саппорта, возвращать
нечего. Саппорт-квады остаются экспортом `junctionNodeGeometry` — детектор и
тесты используют их как источник контрактной истины.

`npm test` 1303/1303; `golden:verify` 126/126; контракт-проба репро — 0;
`smoke_junction_holes` OK.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex 9c396935dc fix: the fans-disabled mutant guards by fan shape, not the detector (#302 r1 M2)
Контрактные пробы детектора строятся из той же junctionNodeGeometry и слепнут
вместе с мутацией; юнит «T-узел даёт два веера» — внешняя истина. Смоковый
гвард с полной сборкой оставался зелёным на сломанном коде — проверено
штатным харнесом, а не заявлено.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex ec8dc6fde1 fix: close both code-review mediums and drop the dead paper layer (#302 r1 M1,M2)
**M1.** `docs/WALL-THICKNESS.md` §3 «Junction nodes» переписан под решение №5:
полный mitre, фаска #249 в отставке, `bevelMultiWallBody` — только адресный
латеральный трим. Прежний абзац описывал отменённое утреннее решение.

**M2.** Guard мутанта `junction-fans-disabled` собирает `test-build` и бандл
перед смоком: `smoke_junction_holes` — единственный смок, импортирующий из
`test-build`, и в чистом worktree он падал `ERR_MODULE_NOT_FOUND` до
применения мутации. Ревью прав: после переякорения guard'а на смок я не
перегнал его штатным харнесом — только ручной test-build-патч, который worktree
не видит.

**Low + следствие.** `bevelMultiWallPaper` удалена как мёртвый код; следом
измерено (фикстура #197 и репро владельца — байт в байт с веерами и без), что
и `paperWithNodeCorners` бумаге ничего не даёт: footprint ∪ shell уже
покрывает каждый узел. Слой удалён целиком, бумага возвращена к rawPaper.
Осиротевший мутант `multi-wall-paper-full-origin-cut` (#261, «белый клин от
вычитающего разреза бумаги») снят с обоснованием: в бумаге не осталось ни
одного вычитания — этот класс регресса невозможен по построению.

`npm test` 1303/1303; `golden:verify` 126/126; контракт-проба репро — 0.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
claude[bot]andCodex 58c096cef1 docs: review document for #302
Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex e88abcb138 docs: list the junction tooling in the wall-thickness testing map (#302)
Заодно даёт CI прогон с валидным before-SHA: предыдущий пуш был вынужденно
форсовым после ребейза на #265, и process-gate на CI не смог вычислить
диапазон от затёртой вершины.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex 9dbdbec03d build: rebundle and mend the baseline manifest after the rebase onto #265 (#302)
Ребейз на свежий dev (#265 import seam + его эталон) слил baselines-index из
двух источников; поштучное слияние потеряло `matrixVersion: 45` и держало
хэши двух сцен, чьи PNG пришли из dev. Индекс поправлен по фактическим
файлам, `golden:verify` — 126/126 с валидным манифестом. Бандл и отпечаток
скриншотов пересобраны из объединённых исходников (`npm test` 1303/1303).

Issue: #302
User-Visible: no
Release: v1.68.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32848447191
2026-08-25 17:34:19 +03:00
Codex c179a89876 test: report raw pixel-diff numbers in the grid-scale smoke (#302)
Шард 3 упал на CI по darkViewPixelsMatch, локально смок стабильно зелёный
(3/3, changed=69 при пороге 150), и тот же фон 69 воспроизводится на чистом
dev. Смок печатал только булевы вердикты — добавлен диагностический вывод
сырых дифф-метрик, чтобы прогон CI показал фактическую величину дрейфа.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex df38c5c93b chore: re-run CI for the flaky grid-scale shard (#302)
Пустой коммит: локально смок grid_scale_invariance стабильно зелёный (3/3,
darkView changed=69 при пороге 150), и тот же дифф-фон 69 воспроизводится на
чистом dev — падение шарда на прошлом прогоне похоже на средовую
вариативность раннера, а прав на rerun-failed-jobs у токена нет.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex b6f80c56f0 test: accept the decision-#5 junction baselines
Шесть сцен, изменившихся законно при переходе на полный mitre (решение №5):
`junction-y-60-equal50-dark` (вырез исчез), `junction-acute30-mixed-dark`
(рожок фаски ушёл, остался законный торец перехода толщин 15→70),
`junction-splay10-170-dark`, `junction-owner-repro-dark` (репро владельца:
стык сомкнут полностью) и `safe-resize-handles-clamp-{light,dark}`, где
вершина ромбовидной комнаты в узле теперь сомкнута веером вместо прежнего
зазора. Остальные 120 сцен совпали; шумовая пересъёмка `accept` возвращена к
прежним байтам вместе с хэшами (практика #230).

Принято `npm run golden:accept -- --reviewed` по полному локальному
Linux-прогону; после отката шумовых verify чист (126/126). Каждая сцена
осмотрена.

Issue: #302
User-Visible: no
Release: v1.68.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32840836354
2026-08-25 17:34:19 +03:00
Codex aac501516e feat: full mitre at every node — the #249 chamfer retires (#302, decision #5)
Владелец, осмотрев первые эталоны сета, отменил дневное решение о сохранении
фаски: `junction-y-60-equal50` показывал вырез, `junction-acute30-mixed` —
торчащие углы. По визуальному сравнению трёх вариантов принято: узлы
смыкаются полным mitre, как обычное пересечение стен на чертеже.

Итоговое правило веера (одно на все случаи):

- mitre принимается, когда он В СЕКТОРЕ пары (вперёд по лучам для обычной
  пары, назад — для рефлексной: наружный угол между крайними лучами, где и
  жил вырез Y-60), в пределах классического `MITRE_LIMIT` и не дальше конца
  толстого саппорта (#271);
- рефлекс без валидного mitre замыкается плоской хордой между гранями;
- обычная пара без mitre — локальный бевел: ход по граням ограничен толстым
  саппортом, лимитом и двойной толщиной пары, чтобы хорда осталась деталью
  угла. Гигантские бевел-«бабочки» и mitre вне сектора — две реальные ошибки
  промежуточных версий, обе пойманы на сценах сета до пуша.

Слой `bevelMultiWallBody` сохранён только как АДРЕСНЫЙ латеральный трим для
узлов с вырожденно-коротким толстым саппортом (#271); все прочие узлы — чисто
аддитивные, следы трима на них исчезли. `bevelMultiWallPaper` из бумаги
удалён. Обе записи CHANGELOG приведены к финальному контракту.

Тесты: юниты §302 усилены; площадь фикстуры #197 +0.6 юнита²; мутанты
переякорены, краснота каждого проверена исполнением.

Issue: #302
User-Visible: yes
2026-08-25 17:34:19 +03:00
Codex 8af5ebefd2 test: multiwall smoke follows the strip-safe chamfer contract (#302)
Смок `smoke_multiwall_junction` держал старый контракт «клин за фаской пуст» —
его проба лежит в перекрытии двух полос узла и по strip-safe правилу #302
обязана остаться заполненной. Пропущен в первом прогоне AC9, пойман CI.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex ef22c2c6fd build: rebundle after the rebase onto #301/#303 (#302)
Ребейз на свежий dev (поиск в селекторах проёмов #301, честная подсветка
толщины #303) объединил исходники; бандл и отпечаток скриншотов пересобраны
из результата. Обе копии бандла байт в байт; `check-docs` passed; полный
`npm test` 1298/1298 и golden verify 126/126 на объединённом коде.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex a6ef664bef test: accept the #302 junction close-up baselines
Шестнадцать новых сцен стыков крупным планом (звёзды лучей: T/X/Y, острые
15°/30°, почти коллинеарные, смешанные толщины, виртуальные участки, колонна,
черновик) плюс сцена-репро владельца. Только новые файлы: все 110 существующих
сцен на этом коде прошли verify побайтно — переработка узлов не изменила ни
одну старую картинку, что и требовал AC3. Пересъёмка шумовых копий,
оставленная `accept` на прочих сценах, возвращена к прежним байтам вместе с
хэшами в индексе (практика #230).

Принято `npm run golden:accept -- --reviewed` по полному локальному
Linux-прогону (126/126), после принятия verify чист. Каждая новая сцена
осмотрена; кладка узлов сплошная, фаски #249 на месте.

Baseline-Reviewed указывает на зелёный прогон Validate этой ветки
(ревьюированное ТЗ, SHA 3b19111) — прогона CI с этими эталонами до этого пуша
не существует; job `golden` на них выполнится в прогоне этого же пуша.

Issue: #302
User-Visible: no
Release: v1.68.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32819360269
2026-08-25 17:34:19 +03:00
Codex 76d60b3e1b feat: junction nodes — detector, fans over the approved chamfer, scene set (#302)
Вторая половина переработки узлов поверх ядра из прошлого коммита:

- `junctionContractHoles` — объективный инвариант «тело ⊇ полосы ∪ веера в
  фасадной границе» как экспортная чистая функция; самопроверка на заведомо
  дырявой фикстуре входит в юниты. Первая формулировка детектора из ТЗ
  («окружено кладкой с ≥5 из 8 сторон») уточнена в §8.4 по факту измерения:
  она ложно флагует легитимный пол комнаты в острых внутренних углах.
- `junctionNodeBound` — «гладкая» фасадная граница (конверт с обычными углами)
  экспортирована: ею клиппуются куски узла и ею же пользуются тесты.
- юниты #302: веера/рефлекс/короткий толстый саппорт/детектор/репро
  end-to-end; хелпер тестов старого контракта переведён на strip-safe
  семантику по саппортам и фасадной границе.
- смок `smoke_junction_holes`: контрактные пробы считаются в node из той же
  фикстуры и проверяются в браузере по реальному `d`-пути карточки.
- сет из 16 golden-сцен стыков крупным планом (звёзды лучей: T/X/Y/острые/
  почти коллинеарные/виртуальные/колонна/черновик) + сцена-репро владельца;
  билдер сцен и `zoomCenter` в harness. Контракт сцены
  `multiwall-junction-bevel-view-dark` инвертирован по решению владельца:
  проба в перекрытии полос обязана быть ЗАПОЛНЕНА (strip-safe), а не пустой.
- семь мутантов §14, включая слепоту детектора и невозврат саппортов.
- фикстура #197: площадь кладки выросла на 0.2 юнита² — слайвер вееров вдоль
  хорд фаски; константа обновлена с комментарием.

Эталоны новых сцен идут отдельным коммитом с положенными трейлерами.

Issue: #302
User-Visible: yes
2026-08-25 17:34:19 +03:00
Codex a1361974a3 feat: junction node gets back what a chamfer must never eat (#302, wip)
Ядро переработки узлов. Слой фаски #249 (`bevelMultiWallBody`) остаётся как
утверждённый вид, но после него узел аддитивно получает обратно:

- точные саппорт-квады своих лучей (каждый ограничен собственной конечной
  длиной — обрезанный латеральный фантом #271 вернуться не может);
- по вееру на каждую пару соседних по азимуту лучей (сектор ≤ 180°; рефлексные
  секторы — внешность выпуклого угла — пропускаются), mitre в пределах лимита
  узла, иначе bevel-хорда на том же радиусе, что и хорда фаски.

Куски клиппуются «гладкой» фасадной границей (`junctionNodeBound`: конверт с
обычными углами, без узловых засечек) — узел не может отрастить новый фасад
(контракт вогнутого Split), но и не теряет секторные веера, как терял бы при
клипе по засечённому конверту. Вырожденные кольца нулевой площади, которые
polyclip оставляет на совпадающих хордах, вычищаются.

Тесты старого контракта переведены на новый: клин за фаской заполнен, если
лежит в полосах узла (острый стык — сплошная кладка, сама починка #302), и
пуст вне полос (фаска #249 как была). Хелпер и точечные тесты #249/#271/#197 и
corner-Split обновлены; площадь фикстуры #197 выросла на 0.2 юнита² — слайвер
вееров вдоль хорд фаски.

Проверено исполнением: `npm test` 1286/1286; контракт «тело ⊇ полосы ∪ веера»
на репро владельца — 0 пропаж; клинья на скриншоте исчезли.

Issue: #302
User-Visible: no
2026-08-25 17:34:18 +03:00
Codex f1c76aa415 docs: record the owner's chamfer decision and the strip-safe cut contract (#302)
Issue: #302
User-Visible: no
2026-08-25 17:34:18 +03:00
claude[bot]andCodex 3df1dcf5a3 docs: review document for #302
Issue: #302
User-Visible: no
2026-08-25 17:34:18 +03:00
Codex eef4e0d6d4 docs: spec for #302 — additive junction node material
Issue: #302
User-Visible: no
2026-08-25 17:34:18 +03:00
claude[bot] 1575b1e890 docs: review document for #304
Issue: #304
User-Visible: no
2026-08-25 14:30:36 +00:00
Matysh e603c5496f test: accept reviewed plan-axis baselines
Accepted the complete 110-scene Linux artifact from Validate run 32854408646. Five scenes record the intended new Plan-axis layer in Resize and Opening; seven already-passing frames receive the canonical below-threshold raster refresh produced by the same exact capture.

Issue: #304
User-Visible: no
Release: v1.67.0-rc.3
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32854408646
2026-08-25 17:12:33 +03:00
claude[bot] 33dcd1ce62 docs: review document for #304
Issue: #304
User-Visible: no
2026-08-25 14:09:15 +00:00
Matysh 7310ce0134 docs: refresh reviewed screenshots
Canonical Linux capture from workflow run 32854424829 at exact branch SHA a2b4d32b2d was reviewed as a complete ten-frame set.

Issue: #304
User-Visible: no
2026-08-25 16:43:20 +03:00
Matysh a2b4d32b2d fix(plan): keep wall axes across tools
Issue: #304
User-Visible: yes
2026-08-25 16:36:55 +03:00
claude[bot] 9c5e1e2749 docs: review document for #304
Issue: #304
User-Visible: no
2026-08-25 13:06:34 +00:00
Matysh 2143e88824 test: close import seam review gaps
Validate / reuse (push) Successful in 1m13s
Validate / hacs (push) Failing after 24s
Validate / hassfest (push) Failing after 33s
Validate / frontend (push) Successful in 6m16s
Validate / backend (push) Failing after 7m3s
Validate / smoke (3) (push) Failing after 18m38s
Validate / smoke (2) (push) Failing after 19m10s
Validate / golden (push) Failing after 23m1s
Validate / smoke (1) (push) Failing after 45m54s
Validate / smoke_done (push) Skipped
Validate / performance_smoke (push) Failing after 50m53s
Validate / process-workflow-sync (push) Successful in 34s
Validate / docs (push) Successful in 51s
Validate / provenance (push) Successful in 1m16s
Validate / process-gate (push) Failing after 1m16s
Validate / changes (push) Successful in 46s
Issue: #265
User-Visible: no
2026-08-25 15:16:40 +03:00
Matysh 1f83f35a66 test: accept reviewed import preview baseline
Issue: #265
User-Visible: no
Release: v1.67.0-rc.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32844271523
2026-08-25 15:12:28 +03:00
claude[bot] e7f2f2c174 docs: review document for #265
Issue: #265
User-Visible: no
2026-08-25 12:05:54 +00:00
Matysh 9f54cbab75 test: cover nested import lineage in browser
Issue: #265
User-Visible: no
2026-08-25 14:48:29 +03:00
Matysh 51810164c2 refactor: unify import reference seam
Issue: #265
User-Visible: yes
2026-08-25 14:47:04 +03:00
claude[bot] 9c0b276f43 docs: review document for #265
Issue: #265
User-Visible: no
2026-08-25 11:14:32 +00:00
Matysh 26af611af1 docs: name real i18n verification
Issue: #265
User-Visible: no
2026-08-25 14:10:48 +03:00
claude[bot] 19f536f361 docs: review document for #265
Issue: #265
User-Visible: no
2026-08-25 11:09:33 +00:00
Matysh 67bcce8efb docs: address import seam spec review
Issue: #265
User-Visible: no
2026-08-25 14:03:49 +03:00
claude[bot] ab2f37aa4d docs: review document for #265
Issue: #265
User-Visible: no
2026-08-25 11:01:14 +00:00
Matysh 2cf47fc133 docs: specify import reference seam
Issue: #265
User-Visible: no
2026-08-25 13:51:10 +03:00
claude[bot] ca58f0e3eb docs: review document for #303
Issue: #303
User-Visible: no
2026-08-25 10:05:08 +00:00
Matysh 93cd45569d docs: refresh reviewed screenshots
Issue: #303
User-Visible: no
2026-08-25 12:51:30 +03:00
Matysh 36992d68bd fix: match wall thickness hover to masonry
Issue: #303
User-Visible: yes
2026-08-25 12:49:41 +03:00
claude[bot] 0f71d869e3 docs: review document for #301
Issue: #301
User-Visible: no
2026-08-25 09:39:16 +00:00
Matysh 56dbb48868 docs: refresh reviewed screenshots
Issue: #301
User-Visible: no
2026-08-25 12:24:06 +03:00
Matysh 13219307d5 fix: keep opening picker semantics accurate
Issue: #301
User-Visible: no
2026-08-25 12:20:33 +03:00
Matysh 8b4730df05 feat: search opening entity selectors
Issue: #301
User-Visible: yes
2026-08-25 12:17:30 +03:00
claude[bot] 19332a915d docs: review document for #301
Issue: #301
User-Visible: no
2026-08-25 08:55:50 +00:00
claude[bot] fd4fc801ef docs: review document for #301
Issue: #301
User-Visible: no
2026-08-25 08:44:48 +00:00
claude[bot] 6d094ca01b docs: review document for #301
Issue: #301
User-Visible: no
2026-08-25 08:33:33 +00:00
claude[bot] 65e55872e8 docs: review document for #303
Issue: #303
User-Visible: no
2026-08-25 08:10:51 +00:00
claude[bot] 68498e526e docs: review document for #301
Issue: #301
User-Visible: no
2026-08-25 08:09:22 +00:00
Sergey Matyunin 4b8f17bae5 build: prepare v1.67.0-rc.2 candidate
Validate / process-workflow-sync (push) Successful in 45s
Validate / process-gate (push) Failing after 45s
Validate / provenance (push) Successful in 54s
Validate / changes (push) Successful in 32s
Validate / reuse (push) Successful in 57s
Validate / hacs (push) Failing after 23s
Validate / hassfest (push) Failing after 22s
Validate / frontend (push) Successful in 8m43s
Validate / backend (push) Failing after 13m35s
Validate / smoke (3) (push) Failing after 18m13s
Validate / smoke (2) (push) Failing after 18m29s
Validate / golden (push) Failing after 20m44s
Validate / smoke (1) (push) Failing after 34m31s
Validate / performance_smoke (push) Failing after 26m6s
Validate / smoke_done (push) Skipped
Validate / docs (push) Failing after 39s
Package the accepted S8 work and refresh release metadata and bundles.

Issue: #294
Issue: #296
Issue: #298
Issue: #299
Issue: #300
User-Visible: yes
2026-08-25 00:06:18 +03:00
Sergey Matyunin 136e0cb773 test: align Optimize smoke with role-aware compaction
The full pre-release smoke still expected #198's T-node fixture to compact into one record. #299 intentionally preserves the outer/shared ownership breakpoints, so the smoke now requires three canonical 22 cm role runs while retaining Preview, atomic Apply, Reload, and Undo coverage.

Issue: #299
User-Visible: no
2026-08-24 23:48:36 +03:00
Sergey Matyunin 641c4e199e test: accept reviewed #296 and #300 golden baselines
Canonical Linux artifact from Validate run 32775157799 was captured from the final combined dev SHA 8a3a115. All seven material changes were visually inspected: two hidden-wall diagnostic scenes expose axes and nodes, two resize scenes add the approved measurements and area labels, and three wall drawing/junction scenes expose the approved diagnostics. The complete 110-scenario artifact is accepted without partial replacement.

Issue: #296
Issue: #300
User-Visible: no
Release: v1.67.0-rc.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32775157799
2026-08-24 23:45:08 +03:00
Sergey Matyunin 8a3a115f42 test: accept #299 documentation screenshots
Canonical Linux capture from workflow run 32774826636 checked out exact branch SHA 5696e274fe. All ten frames were reviewed against the current accepted set: nine are byte-identical, while 06-device-editor differs by one antialiasing pixel and remains visually unchanged.

Issue: #299
User-Visible: no
2026-08-24 23:39:42 +03:00
Sergey Matyunin 5526447070 Merge issue #299 into dev
Owner-approved review exception: the external reviewer is unavailable. The exact branch SHA passed typecheck, 1287 unit tests, bundle parity, targeted browser smokes, six 24-step edit walks, late traces, mutation testing, performance comparison, and the canonical Linux documentation capture. The merge also removes one trailing blank line from the specification; product sources are unchanged from the validated branch.

Issue: #299
User-Visible: no
2026-08-24 23:39:25 +03:00
Sergey Matyunin 5696e274fe test: clear resolved wall geometry debts
Validate / docs (push) Failing after 23s
Validate / process-workflow-sync (push) Successful in 46s
Validate / provenance (push) Successful in 47s
Validate / process-gate (push) Failing after 45s
Validate / changes (push) Successful in 30s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 39s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / golden (push) Skipped
Validate / backend (push) Skipped
Validate / performance_smoke (push) Skipped
Issue: #299
User-Visible: no
2026-08-24 23:28:39 +03:00
Sergey Matyunin ba63fccbb7 fix: preserve wall ownership through compaction
Issue: #299
User-Visible: yes
2026-08-24 23:28:39 +03:00
claude[bot]andSergey Matyunin 6558728519 docs: review document for #299
Issue: #299
User-Visible: no
2026-08-24 23:28:39 +03:00
Sergey Matyunin 7b66b1e9b3 docs: specify role-aware wall compaction
Issue: #299
User-Visible: no
2026-08-24 23:28:39 +03:00
Sergey Matyunin 2805c2c8a0 test: align Escape dialog smoke with #294
The full smoke suite still expected Escape to undo one wall point. The accepted #294 contract finishes and detaches the chain while Ctrl/Cmd+Z owns undo; the dedicated wall-tool smoke already proves geometry persistence.

Issue: #294
User-Visible: no
2026-08-24 23:27:12 +03:00
Sergey Matyunin 1b7c150dda test: accept combined documentation screenshots
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 14s
Validate / frontend (push) Successful in 9m15s
Validate / backend (push) Failing after 9m31s
Validate / docs (push) Failing after 26s
Validate / process-workflow-sync (push) Successful in 35s
Validate / provenance (push) Successful in 54s
Validate / smoke (1) (push) Failing after 27m57s
Validate / changes (push) Successful in 38s
Validate / process-gate (push) Failing after 44s
Validate / smoke (2) (push) Failing after 18m11s
Validate / reuse (push) Successful in 45s
Validate / smoke (3) (push) Failing after 18m0s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 20m44s
Validate / performance_smoke (push) Failing after 29m24s
Canonical Linux capture from workflow run 32772784765 was visually inspected after integrating #296, #298, and #300. It refreshes the exact combined source fingerprint and all ten accepted frames.

Issue: #300
User-Visible: no
2026-08-24 23:16:31 +03:00
Sergey Matyunin 2523c38a4e Merge issue #300 into dev
Owner-approved review exception: the external reviewer is unavailable. The exact branch SHA passed its recorded gates; the integration keeps #296 diagnostic geometry above masonry and #300 resize measurements above wall bodies. Generated bundles were rebuilt from the combined sources.

Issue: #300
User-Visible: no
2026-08-24 23:14:34 +03:00
Sergey Matyunin 5ecfebcf67 Merge issue #298 into dev
Owner-approved review exception: the external reviewer is unavailable. Both r1 High findings were fixed, the exact branch SHA passed all recorded gates, and generated bundles were rebuilt after conflict resolution with #296.

Issue: #298
User-Visible: no
2026-08-24 23:12:16 +03:00
Sergey Matyunin e13e8ff9e9 Merge issue #296 into dev
Owner-approved review exception: the external reviewer is unavailable. The exact branch SHA passed its implementation gates and the issue records the evidence.

Issue: #296
User-Visible: no
2026-08-24 23:10:33 +03:00
Sergey Matyunin 01992a9a87 test: accept #298 documentation screenshots
Issue: #298
User-Visible: no
2026-08-24 23:05:59 +03:00
Sergey Matyunin 2d19ce04ad fix: close #298 review gaps
Issue: #298
User-Visible: no
2026-08-24 23:04:32 +03:00
Sergey Matyunin 390f77bd65 feat: улучшить измерения при Resize
Issue: #300
User-Visible: yes
2026-08-24 22:59:33 +03:00
claude[bot] d5670ad141 docs: review document for #298
Issue: #298
User-Visible: no
2026-08-24 19:54:40 +00:00
Sergey Matyunin 3cf8ed74db test: accept #296 documentation screenshots
Issue: #296
User-Visible: no
2026-08-24 22:53:07 +03:00
Sergey Matyunin b9db5d4750 fix: close #296 review gaps
Issue: #296
User-Visible: yes
2026-08-24 22:50:00 +03:00
Sergey Matyunin 2cad797d2c fix: preserve legacy resize lattice endpoints
Issue: #298
User-Visible: no
2026-08-24 22:41:14 +03:00
claude[bot] e4dfef19e1 docs: review document for #300
Issue: #300
User-Visible: no
2026-08-24 19:38:50 +00:00
claude[bot] 8d165fd1c2 docs: review document for #296
Issue: #296
User-Visible: no
2026-08-24 19:37:27 +00:00
Sergey Matyunin 23d208f30c docs: согласовать footprint кнопки с zoom
Issue: #300
User-Visible: no
2026-08-24 22:34:40 +03:00
claude[bot] e4139da3e6 docs: review document for #300
Issue: #300
User-Visible: no
2026-08-24 19:33:00 +00:00
Sergey Matyunin 3fb2edc904 fix: preserve resize wall thickness carriers
Issue: #298
User-Visible: yes
2026-08-24 22:27:05 +03:00
Sergey Matyunin 4969f46e0e docs: учесть масштаб кнопки комнаты
Issue: #300
User-Visible: no
2026-08-24 22:26:11 +03:00
claude[bot] ed28951b9d docs: review document for #300
Issue: #300
User-Visible: no
2026-08-24 19:25:23 +00:00
Sergey Matyunin 2b7c98e6bc docs: уточнить раскладку подписей Resize
Issue: #300
User-Visible: no
2026-08-24 22:18:52 +03:00
claude[bot] 8187dd9bda docs: review document for #300
Issue: #300
User-Visible: no
2026-08-24 19:15:06 +00:00
Sergey Matyuninandclaude[bot] 89ac96a8af fix: reconcile hidden walls during Optimize
Issue: #296
User-Visible: yes
2026-08-24 19:10:27 +00:00
claude[bot] 033ad11bf7 docs: review document for #296
Issue: #296
User-Visible: no
2026-08-24 19:10:27 +00:00
Sergey Matyuninandclaude[bot] f7a19a35f1 docs(spec): define hidden obstacle optimization
Issue: #296
User-Visible: no
2026-08-24 19:10:27 +00:00
claude[bot] 451932c8e9 docs: review document for #298
Issue: #298
User-Visible: no
2026-08-24 19:08:31 +00:00
Sergey Matyunin ab9a83e755 docs: спецификация подписей Resize
Issue: #300
User-Visible: no
2026-08-24 22:08:17 +03:00
Sergey Matyunin 55db16df6e docs(spec): add resize user contract
Issue: #298
User-Visible: no
2026-08-24 22:02:46 +03:00
claude[bot] 8221ad9b84 docs: review document for #298
Issue: #298
User-Visible: no
2026-08-24 19:01:30 +00:00
Sergey Matyunin 45066631ae docs(spec): define resize wall carrier contract
Issue: #298
User-Visible: no
2026-08-24 21:53:54 +03:00
Claude 4feeebc355 test: обход правок собирает test-build сам, как чистый CI и требует
Смок импортировал `../test-build/*` статически и на моей стороне работал только
потому, что каталог остался от `npm test`. На чистом Linux CI его нет: в job
`smoke` идут `npm ci` и `npm run bundle:sync`, сборки тестов там не бывает, и
смок падал на импорте до запуска браузера.

Ровно эту ошибку уже проходил `smoke_lattice_write_barrier.mjs` — там об этом и
написано в комментарии. Лечение то же: собрать `tsconfig.test.json` перед
динамическим импортом.

Проверено в чистом worktree без `test-build`: шесть прогонов, все совпадают с
таблицей KNOWN, OK.

Issue: #297
User-Visible: no
2026-08-24 21:27:38 +03:00
claude[bot] 158601d67d docs: review document for #294
Issue: #294
User-Visible: no
2026-08-24 18:25:12 +00:00
Sergey Matyuninandclaude[bot] 671d1af4c3 test: stabilize wall Escape smoke and accept docs screenshot
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
claude[bot] 67777e47c0 docs: review document for #294
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
Sergey Matyuninandclaude[bot] 953f6756bc feat: detach wall chain on Escape
Issue: #294
User-Visible: yes
2026-08-24 18:16:46 +00:00
claude[bot] 578eb5ebd3 docs: review document for #294
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
Sergey Matyuninandclaude[bot] 874838c954 docs: close wall Escape documentation gaps
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
claude[bot] 0ac92cb551 docs: review document for #294
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
Sergey Matyuninandclaude[bot] 142ec8d1c7 docs: specify wall chain detach on Escape
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
Claude 1816042a1a test: обход последовательностей правок с инвариантами после каждого жеста
Все прежние гейты проверяют снимок модели. Дефекты геометрии рождаются в
редактировании: #289, #296 и #298 прошли решётку, кладку, роли, ключи и аудит
ручек, потому что такая геометрия снимок не портит — она портит следующий жест.

demo/smoke_edit_walk.mjs расшатывает реальный план продуктовыми жестами
(_rszEdgeDown/_rszMove/_rszUp, _confirmRoomDelete, optimizePlans) по фиксированному
семени и после каждого шага судит конфиг в node. Второго представления редактора
не появляется — принцип #292.

Подшаговый шум остаётся наблюдением, а не нарушением: координата пишется девятью
знаками, 304/240 = 1.266666667, отклонение 8e-8 шага неустранимо форматом
хранения и уйдёт на этапе 1 ADR #282. Судится только «вне сетки».

Таблица KNOWN работает в обе стороны: обход падает и когда находок больше, и
когда меньше. Молча позеленевший гейт не сообщает о починке — так
partition-mt2on9ou-0 прожил в плане владельца от беты 9 до rc.1.

Новый инвариант checkHiddenObstacles: перегородка на стене комнаты, незакрытый
контур на стене комнаты, черновик, который не может стать комнатой (#296).

Найдено сразу, в пяти прогонах из шести — на первом жесте: #298 (ресайз уводит
конец записи толщины мимо решётки и мимо ребра), #299 («Оптимизировать» и
удаление комнаты сливают записи через границу роли).

Issue: #297
User-Visible: no
2026-08-24 21:14:30 +03:00
185 changed files with 16258 additions and 1694 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.67.0-rc.1"
VERSION = "1.67.0-rc.2"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
File diff suppressed because one or more lines are too long
+514 -94
View File
@@ -63,6 +63,11 @@ _LIVE_TEXT_TOKEN = re.compile(r"\{([^{}\r\n]+)\}")
_LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
_PLAN_ONLY_DASH = "—"
_IMPORT_ID_NAMESPACES = {
"space", "room", "marker", "partition", "opening", "decor", "draft", "column",
}
_MAX_IMPORT_LINEAGE_DEPTH = 16
_REPORT_EXAMPLE_LIMIT = 24
_SPACE_PLAN_FIELDS = (
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
@@ -801,8 +806,31 @@ def _unique_title(base: str, existing: list[dict[str, Any]]) -> str:
return f"{base} ({n})"
def canonical_import_root(prefix: str, value: str) -> tuple[str, int, bool]:
"""Return the bounded root of ids generated by a previous space import.
Only the exact ``<same namespace>_<stem>_<8 lowercase hex>`` envelope is
reversible. Similar user ids, other namespaces and uppercase/short hashes
remain literal. The layer count and bounded flag are shared with the
TypeScript helper through one conformance fixture. The flag reports that
another valid layer remained after the safety bound.
"""
root = str(value)
pattern = re.compile(rf"^{re.escape(prefix)}_(.+)_([0-9a-f]{{8}})$")
layers = 0
for _depth in range(_MAX_IMPORT_LINEAGE_DEPTH):
match = pattern.fullmatch(root)
if match is None:
return root, layers, False
root = match.group(1)
layers += 1
return root, layers, pattern.fullmatch(root) is not None
def _fresh(prefix: str, old: str, used: set[str]) -> str:
stem = re.sub(r"[^a-zA-Z0-9_-]+", "_", old).strip("_")[:35] or prefix
root, _layers, _bounded = canonical_import_root(prefix, old) \
if prefix in _IMPORT_ID_NAMESPACES else (old, 0, False)
stem = re.sub(r"[^a-zA-Z0-9_-]+", "_", root).strip("_")[:35] or prefix
while True:
value = f"{prefix}_{stem}_{secrets.token_hex(4)}"
if value not in used:
@@ -822,53 +850,281 @@ def _orphan_marker(key: str, manifest: dict[str, Any] | None, space_id: str, use
}
def _empty_reference_report() -> dict[str, Any]:
return {
"remapped": {"incoming": {}, "target": {}},
"collisions": {},
"preservedUnresolved": {},
"droppedIncomingLinks": {},
"boundedLineages": 0,
"examples": [],
}
def _report_reference(
report: dict[str, Any], bucket: str, category: str, owner: str, reference: str,
) -> None:
section = report.setdefault(bucket, {})
section[category] = int(section.get(category, 0)) + 1
examples = report.setdefault("examples", [])
if len(examples) < _REPORT_EXAMPLE_LIMIT:
examples.append({
"bucket": bucket, "category": category,
"owner": str(owner)[:160], "reference": str(reference)[:160],
})
def _report_remap(
report: dict[str, Any], side: str, category: str, owner: str, reference: str,
) -> None:
section = report.setdefault("remapped", {}).setdefault(side, {})
section[category] = int(section.get(category, 0)) + 1
examples = report.setdefault("examples", [])
if len(examples) < _REPORT_EXAMPLE_LIMIT:
examples.append({
"bucket": f"remapped.{side}", "category": category,
"owner": str(owner)[:160], "reference": str(reference)[:160],
})
def _record_bounded_lineage(
report: dict[str, Any], seen: set[tuple[str, str]], prefix: str, value: str,
) -> None:
_root, _layers, bounded = canonical_import_root(prefix, value)
if bounded:
seen.add((prefix, value))
report["boundedLineages"] = len(seen)
def _lineage_resolver(
prefix: str,
exact_map: dict[str, str],
live_ids: set[str],
report: dict[str, Any],
bounded_seen: set[tuple[str, str]],
) -> Callable[[str], tuple[str | None, str]]:
"""Resolve only dead exact or uniquely provable cross-generation refs."""
imported: dict[str, set[str]] = {}
live: dict[str, set[str]] = {}
for old, new in exact_map.items():
root, _layers, bounded = canonical_import_root(prefix, old)
imported.setdefault(root, set()).add(new)
if bounded:
_record_bounded_lineage(report, bounded_seen, prefix, old)
for value in live_ids:
root, _layers, bounded = canonical_import_root(prefix, value)
live.setdefault(root, set()).add(value)
if bounded:
_record_bounded_lineage(report, bounded_seen, prefix, value)
def resolve(reference: str) -> tuple[str | None, str]:
if reference in live_ids:
return None, "live"
if reference in exact_map:
return exact_map[reference], "exact"
root, _layers, bounded = canonical_import_root(prefix, reference)
if bounded:
_record_bounded_lineage(report, bounded_seen, prefix, reference)
candidates = imported.get(root, set())
if not candidates:
return None, "unrelated"
if len(candidates) == 1 and not live.get(root):
return next(iter(candidates)), "lineage"
return None, "ambiguous"
return resolve
def _repair_target_space_refs(
current_config: dict[str, Any],
current_layout: dict[str, Any],
old_space_id: str,
new_space_id: str,
old_room_ids: dict[str, str],
id_maps: dict[str, dict[str, str]],
marker_link_map: dict[str, str],
report: dict[str, Any],
bounded_seen: set[tuple[str, str]],
) -> tuple[dict[str, Any], dict[str, Any], int]:
"""Apply the exact map of this import to pre-existing orphan target refs."""
"""Repair target refs by exact map, then by one safe lineage candidate."""
config = _json_copy(current_config)
layout = _json_copy(current_layout)
if any(str(space.get("id")) == old_space_id for space in config.get("spaces") or []):
return config, layout, 0
spaces = config.get("spaces") or []
markers = config.get("markers") or []
live_spaces = {str(item.get("id")) for item in spaces if item.get("id") is not None}
live_rooms = {
str(room.get("id")) for space in spaces for room in space.get("rooms") or []
if room.get("id") is not None
}
live_partitions = {
str(item.get("id")) for space in spaces for item in space.get("partitions") or []
if item.get("id") is not None
}
live_markers = {
str(marker.get("id")) for marker in markers if marker.get("id") is not None
}
resolve_space = _lineage_resolver(
"space", id_maps.get("space", {}), live_spaces, report, bounded_seen,
)
resolve_room = _lineage_resolver(
"room", id_maps.get("room", {}), live_rooms, report, bounded_seen,
)
resolve_partition = _lineage_resolver(
"partition", id_maps.get("partition", {}), live_partitions, report, bounded_seen,
)
resolve_marker = _lineage_resolver(
"marker", id_maps.get("marker", {}), live_markers, report, bounded_seen,
)
resolve_marker_link = _lineage_resolver(
"marker", marker_link_map, live_markers, report, bounded_seen,
)
imported_space_ids = set(id_maps.get("space", {}).values())
repaired = 0
for marker in config.get("markers") or []:
if marker.get("space") == old_space_id:
marker["space"] = new_space_id
repaired += 1
room_id = marker.get("room_id")
if room_id is not None and str(room_id) in old_room_ids:
marker["room_id"] = old_room_ids[str(room_id)]
unresolved: set[tuple[str, str, str]] = set()
def replace(
owner: str, category: str, reference: Any,
resolver: Callable[[str], tuple[str | None, str]],
) -> str | None:
nonlocal repaired
if not isinstance(reference, str) or not reference:
return None
mapped, reason = resolver(reference)
if mapped is not None:
repaired += 1
_report_remap(report, "target", category, owner, reference)
return mapped
if reason == "ambiguous":
preserve_once(owner, category, reference)
return None
def preserve_once(owner: str, category: str, reference: str) -> None:
key = (owner, category, reference)
if key in unresolved:
return
unresolved.add(key)
_report_reference(
report, "preservedUnresolved", category, owner, reference,
)
def preserve_related(
owner: str, category: str, reference: Any,
resolver: Callable[[str], tuple[str | None, str]],
) -> None:
if not isinstance(reference, str) or not reference:
return
mapped, reason = resolver(reference)
if mapped is not None or reason == "ambiguous":
preserve_once(owner, category, reference)
for space in spaces:
space_id = str(space.get("id", "?"))
for room in space.get("rooms") or []:
room_id = str(room.get("id", "?"))
values = room.get("open_to")
if isinstance(values, list):
for value in values:
preserve_related(
f"{space_id}:{room_id}", "room.open_to", value, resolve_room,
)
for opening in space.get("openings") or []:
host = opening.get("host") if isinstance(opening, dict) else None
if isinstance(host, dict) and host.get("kind") == "partition":
# A partition host is space-local. An imported partition lives
# in the new independent copy, so a pre-existing opening in a
# different target space cannot be rebound to it safely.
preserve_related(
f"{space_id}:{opening.get('id', '?')}", "opening.host",
host.get("id"), resolve_partition,
)
for marker in markers:
marker_id = str(marker.get("id", "?"))
mapped = replace(marker_id, "marker.space", marker.get("space"), resolve_space)
if mapped is not None:
marker["space"] = mapped
may_rebind_room = marker.get("space") in imported_space_ids
if may_rebind_room:
mapped = replace(marker_id, "marker.room_id", marker.get("room_id"), resolve_room)
if mapped is not None:
marker["room_id"] = mapped
else:
preserve_related(
marker_id, "marker.room_id", marker.get("room_id"), resolve_room,
)
vacuum = marker.get("vacuum")
segment_map = vacuum.get("segment_map") if isinstance(vacuum, dict) else None
if isinstance(segment_map, dict):
for key, room_id in list(segment_map.items()):
mapped = old_room_ids.get(str(room_id))
if may_rebind_room:
mapped = replace(
marker_id, "marker.vacuum.segment_map", room_id, resolve_room,
)
if mapped is not None:
segment_map[key] = mapped
else:
preserve_related(
marker_id, "marker.vacuum.segment_map", room_id, resolve_room,
)
controls = marker.get("controls")
if isinstance(controls, list):
for index, ref in enumerate(controls):
if not isinstance(ref, str) or not ref.startswith("marker:"):
continue
old_target = ref[len("marker:"):]
mapped = replace(
marker_id, "marker.controls", old_target, resolve_marker_link,
)
if mapped is not None:
segment_map[key] = mapped
repaired += 1
controls[index] = "marker:" + mapped
else:
# A duplicate may have been virtualised or otherwise lost
# light-source semantics. Such a marker remains a valid
# layout owner, but it is not a valid controls target.
preserve_related(
marker_id, "marker.controls", old_target, resolve_marker,
)
badge = marker.get("value_badge")
source = badge.get("source") if isinstance(badge, dict) else None
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
ref = source.get("ref")
old_target = ref[len("marker:"):] \
if isinstance(ref, str) and ref.startswith("marker:") else None
mapped = replace(
marker_id, "marker.value_badge", old_target, resolve_marker_link,
)
if mapped is not None:
source["ref"] = "marker:" + mapped
else:
preserve_related(
marker_id, "marker.value_badge", old_target, resolve_marker,
)
# Rewrite ownership before rekeying labels. A valid destination wins a
# collision inside the target; the imported document itself is overlaid
# later and has final priority for its own label position.
for position in layout.values():
if isinstance(position, dict) and position.get("s") == old_space_id:
position["s"] = new_space_id
repaired += 1
for old_room_id, new_room_id in old_room_ids.items():
old_key = "rl_" + old_room_id
if old_key not in layout:
# Destination wins collisions. Rekey only proven dead plan-owned keys;
# opaque HA owners remain literal.
for key in list(layout):
position = layout[key]
if isinstance(position, dict):
mapped_space = replace(key, "layout.space", position.get("s"), resolve_space)
if mapped_space is not None:
position["s"] = mapped_space
new_key: str | None = None
category = ""
if key.startswith("rl_"):
if isinstance(position, dict) and position.get("s") in imported_space_ids:
mapped_room = replace(key, "layout.room_label", key[3:], resolve_room)
if mapped_room is not None:
new_key, category = "rl_" + mapped_room, "layout.room_label"
else:
preserve_related(key, "layout.room_label", key[3:], resolve_room)
elif key not in live_markers:
mapped_marker = replace(key, "layout.marker", key, resolve_marker)
if mapped_marker is not None:
new_key, category = mapped_marker, "layout.marker"
if new_key is None or new_key == key:
continue
new_key = "rl_" + new_room_id
if new_key not in layout:
layout[new_key] = layout[old_key]
del layout[old_key]
repaired += 1
if new_key in layout:
_report_reference(report, "collisions", category, key, new_key)
else:
layout[new_key] = position
del layout[key]
return config, layout, repaired
@@ -889,14 +1145,53 @@ def build_space_merge(
raise ImportFailure("invalid_config", "A space export must contain exactly one space")
space = spaces[0]
_materialize_space_background(space, "static")
reference_report = _empty_reference_report()
bounded_seen: set[tuple[str, str]] = set()
target_config = _json_copy(current_config)
target_layout = _json_copy(current_layout)
used = {
str(value)
for sp in current_config.get("spaces") or []
for value in [sp.get("id")]
} | {str(m.get("id")) for m in current_config.get("markers") or []}
for collection in (
(sp,), sp.get("rooms") or [], sp.get("room_drafts") or [],
sp.get("partitions") or [], sp.get("wall_columns") or [],
sp.get("openings") or [], sp.get("decor") or [],
)
for item in collection
if isinstance(item, dict)
for value in [item.get("id")]
if value is not None
} | {
str(m.get("id")) for m in current_config.get("markers") or []
if m.get("id") is not None
} | {
str(value)
for incoming_space in spaces
for collection in (
(incoming_space,), incoming_space.get("rooms") or [],
incoming_space.get("room_drafts") or [],
incoming_space.get("partitions") or [],
incoming_space.get("wall_columns") or [],
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
)
for item in collection
if isinstance(item, dict)
for value in [item.get("id")]
if value is not None
} | {
str(m.get("id")) for m in incoming.get("markers") or []
if m.get("id") is not None
}
old_space_id = str(space.get("id"))
_record_bounded_lineage(
reference_report, bounded_seen, "space", old_space_id,
)
new_space_id = _fresh("space", old_space_id, used)
id_map: dict[str, str] = {old_space_id: new_space_id}
id_maps: dict[str, dict[str, str]] = {
prefix: {} for prefix in _IMPORT_ID_NAMESPACES
}
id_maps["space"][old_space_id] = new_space_id
for collection, prefix in (
("rooms", "room"), ("room_drafts", "draft"), ("partitions", "partition"),
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
@@ -904,17 +1199,26 @@ def build_space_merge(
for item in space.get(collection) or []:
if isinstance(item, dict) and item.get("id") is not None:
old = str(item["id"])
_record_bounded_lineage(
reference_report, bounded_seen, prefix, old,
)
id_map[old] = _fresh(prefix, old, used)
id_maps[prefix][old] = id_map[old]
item["id"] = id_map[old]
old_room_ids = {
old: new for old, new in id_map.items() if old != old_space_id and new.startswith("room_")
}
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
current_config, current_layout, old_space_id, new_space_id, old_room_ids,
)
old_room_ids = id_maps["room"]
for room in space.get("rooms") or []:
if room.get("open_to"):
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
remapped_open_to = []
for value in room["open_to"]:
old_value = str(value)
mapped_value = old_room_ids.get(old_value, old_value)
remapped_open_to.append(mapped_value)
if mapped_value != old_value:
_report_remap(
reference_report, "incoming", "room.open_to",
str(room.get("id", "?")), old_value,
)
room["open_to"] = remapped_open_to
# Opening ownership is part of the same space-local id graph. Remap the
# nested reference together with the partition itself; otherwise the
# invariant validator correctly rejects a copied space whose host.id still
@@ -925,6 +1229,10 @@ def build_space_merge(
old_host_id = str(host.get("id"))
if old_host_id in id_map:
host["id"] = id_map[old_host_id]
_report_remap(
reference_report, "incoming", "opening.host",
str(opening.get("id", "?")), old_host_id,
)
space["id"] = new_space_id
space["title"] = _unique_title(
str(space.get("title") or old_space_id), current_config.get("spaces") or []
@@ -947,22 +1255,40 @@ def build_space_merge(
dropped_marker_links = _transfer_dropped_marker_links(document)
for marker in incoming.get("markers") or []:
old_id = str(marker.get("id"))
_record_bounded_lineage(
reference_report, bounded_seen, "marker", old_id,
)
binding = _binding_key(marker)
if binding in duplicate and duplicate_policy == "skip":
skipped.add(old_id)
continue
new_id = _fresh("marker", old_id, used)
marker_map[old_id] = new_id
id_maps["marker"][old_id] = new_id
marker["id"] = new_id
marker["space"] = new_space_id
_report_remap(
reference_report, "incoming", "marker.space", new_id, old_space_id,
)
if marker.get("room_id") is not None:
marker["room_id"] = old_room_ids.get(str(marker["room_id"]), marker["room_id"])
old_room_id = str(marker["room_id"])
marker["room_id"] = old_room_ids.get(old_room_id, marker["room_id"])
if marker["room_id"] != old_room_id:
_report_remap(
reference_report, "incoming", "marker.room_id", new_id, old_room_id,
)
vacuum = marker.get("vacuum")
if isinstance(vacuum, dict) and isinstance(vacuum.get("segment_map"), dict):
vacuum["segment_map"] = {
key: old_room_ids.get(str(value), value)
for key, value in vacuum["segment_map"].items()
}
remapped_segments = {}
for key, value in vacuum["segment_map"].items():
old_room_id = str(value)
remapped_segments[key] = old_room_ids.get(old_room_id, value)
if remapped_segments[key] != old_room_id:
_report_remap(
reference_report, "incoming", "marker.vacuum.segment_map",
new_id, old_room_id,
)
vacuum["segment_map"] = remapped_segments
if binding in duplicate and duplicate_policy == "virtual":
virtualized += 1
virtualized_targets.add(old_id)
@@ -1003,6 +1329,10 @@ def build_space_merge(
dropped_marker_links += 1
continue
remapped.append("marker:" + target)
_report_remap(
reference_report, "incoming", "marker.controls",
str(marker.get("id", "?")), old_target,
)
marker["controls"] = remapped or None
badge = marker.get("value_badge")
source = badge.get("source") if isinstance(badge, dict) else None
@@ -1012,11 +1342,30 @@ def build_space_merge(
target = None if old_target in virtualized_targets else marker_map.get(old_target)
if target:
source["ref"] = "marker:" + target
_report_remap(
reference_report, "incoming", "marker.value_badge",
str(marker.get("id", "?")), old_target,
)
else:
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
output_markers_by_id = {
str(marker.get("id")): marker for marker in output_markers
if marker.get("id") is not None
}
marker_link_map = {
old_id: new_id for old_id, new_id in marker_map.items()
if (target := output_markers_by_id.get(new_id)) is not None
and target.get("removed") is not True
and target.get("is_light") is True
}
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
current_config, current_layout, id_maps, marker_link_map,
reference_report, bounded_seen,
)
output_layout: dict[str, Any] = {}
for key, pos in (document["payload"].get("layout") or {}).items():
if key in skipped:
@@ -1042,6 +1391,16 @@ def build_space_merge(
new_key = orphan["id"]
if layout_binding in duplicate and duplicate_policy == "virtual":
virtualized += 1
if new_key != key:
_report_remap(
reference_report, "incoming", "layout.owner", new_key, key,
)
old_layout_space = str(pos.get("s", "")) if isinstance(pos, dict) else ""
if old_layout_space != new_space_id:
_report_remap(
reference_report, "incoming", "layout.space", new_key,
old_layout_space,
)
output_layout[new_key] = {**pos, "s": new_space_id}
merged_config = target_config
@@ -1079,6 +1438,8 @@ def build_space_merge(
merged_config, ensure_ascii=False, separators=(",", ":"), allow_nan=False
).encode("utf-8")) > MAX_CONFIG_BYTES:
raise ImportFailure("capacity_exceeded", "Merged configuration exceeds the store limit")
if dropped_marker_links:
reference_report["droppedIncomingLinks"]["marker.links"] = dropped_marker_links
return merged_config, merged_layout, {
"space_id": new_space_id,
"space_title": space["title"],
@@ -1088,6 +1449,10 @@ def build_space_merge(
"orphan_markers": len(output_markers) - len(marker_map),
"dropped_marker_links": dropped_marker_links,
"repaired_target_refs": repaired_target_refs,
"preserved_unresolved_refs": sum(
int(value) for value in reference_report["preservedUnresolved"].values()
),
"reference_report": reference_report,
}
@@ -1211,6 +1576,70 @@ def _missing_areas(
return sorted(incoming - available)
def _materialize_import_candidate(
document: dict[str, Any],
current_config: dict[str, Any],
current_layout: dict[str, Any],
*,
duplicate_policy: str,
same_source: bool,
content: list[dict[str, Any]],
confirmation_required: bool,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
"""Build once the normalized config/layout that the preview may apply."""
prepared = _json_copy(document)
imported_config = prepared["payload"]["config"]
if confirmation_required:
_detach_missing(imported_config, content)
if prepared["kind"] == "space":
config, layout, details = build_space_merge(
prepared, current_config, current_layout, duplicate_policy,
same_source=same_source,
)
else:
config = imported_config
_materialize_global_background(config)
model_version = prepared.get("model_version", 0)
if isinstance(model_version, int) and not isinstance(model_version, bool) \
and model_version > 0:
config["model_version"] = model_version
layout = _json_copy(prepared["payload"]["layout"])
if not same_source:
settings = config.get("settings") or {}
settings.pop("known_devices", None)
settings.pop("new_device_ids", None)
details = {
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
}
try:
config = CONFIG_SCHEMA(config)
except vol.Invalid as err:
raise ImportFailure("invalid_config", str(err)) from err
try:
layout = LAYOUT_SCHEMA(layout)
except vol.Invalid as err:
raise ImportFailure("invalid_layout", str(err)) from err
details = _json_copy(details)
details["counts"] = _counts(config, layout)
return config, layout, details
def _candidate_digest(candidate: dict[str, Any]) -> str:
"""Bind a token to both its source and the exact normalized target pair."""
return _document_digest({
"document": candidate.get("document"),
"duplicate_policy": candidate.get("duplicate_policy"),
"config_rev": candidate.get("config_rev"),
"layout_rev": candidate.get("layout_rev"),
"content": candidate.get("content"),
"confirmation_required": candidate.get("confirmation_required"),
"same_source": candidate.get("same_source"),
"target_config": candidate.get("target_config"),
"target_layout": candidate.get("target_layout"),
"details": candidate.get("details"),
})
def create_preview(
runtime: HouseplanData,
raw: bytes,
@@ -1236,16 +1665,10 @@ def create_preview(
validate_opening_passages(incoming_config, validate_all=True)
except OpeningPassageError as err:
raise ImportFailure(err.code, str(err)) from err
if document["kind"] == "space":
_merged_config, _merged_layout, details = build_space_merge(
document, current_config, current_layout, duplicate_policy,
same_source=same_source,
)
else:
if document["kind"] == "full":
dropped_marker_links = _transfer_dropped_marker_links(document)
dropped_marker_links += _drop_invalid_import_marker_links(incoming_config)
document["transfer"]["dropped_marker_links"] = dropped_marker_links
details["dropped_marker_links"] = dropped_marker_links
try:
validate_marker_controls(incoming_config, validate_all=True)
validate_marker_light_entities(incoming_config, validate_all=True)
@@ -1253,13 +1676,19 @@ def create_preview(
except MarkerControlError as err:
raise ImportFailure(err.code, str(err)) from err
content, confirmation = _content_state(document, same_source, config_root)
target_config, target_layout, details = _materialize_import_candidate(
document, current_config, current_layout,
duplicate_policy=duplicate_policy,
same_source=same_source,
content=content,
confirmation_required=confirmation,
)
token = secrets.token_urlsafe(32)
now = time.time()
candidate = {
"owner_id": owner_id,
"created": now,
"expires": now + IMPORT_PREVIEW_TTL_S,
"digest": _document_digest(document),
"document": document,
"duplicate_policy": duplicate_policy,
"config_rev": int(current_config_data.get("rev", 0)),
@@ -1267,7 +1696,11 @@ def create_preview(
"content": content,
"confirmation_required": confirmation,
"same_source": same_source,
"target_config": target_config,
"target_layout": target_layout,
"details": details,
}
candidate["digest"] = _candidate_digest(candidate)
# Opportunistic expiry and per-user oldest-first eviction.
runtime.import_previews = {
key: value for key, value in runtime.import_previews.items()
@@ -1319,7 +1752,7 @@ def get_candidate(runtime: HouseplanData, token: str, owner_id: str, *, consume:
raise ImportFailure("preview_expired", "Import preview expired")
if candidate.get("owner_id") != owner_id:
raise ImportFailure("preview_owner_mismatch", "Preview belongs to another user")
if candidate.get("digest") != _document_digest(candidate.get("document") or {}):
if candidate.get("digest") != _candidate_digest(candidate):
runtime.import_previews.pop(token, None)
raise ImportFailure("invalid_format", "Import preview candidate changed")
if consume:
@@ -1342,21 +1775,26 @@ def revalidate_candidate(
"spaces": [], "markers": [], "settings": {},
}
current_layout = current_layout_data.get("layout") or {}
details: dict[str, Any] = {}
if document["kind"] == "space":
_config, _layout, details = build_space_merge(
document, current_config, current_layout, duplicate_policy,
same_source=bool(candidate.get("same_source")),
)
if config_root is not None:
content, confirmation = _content_state(
document, bool(candidate.get("same_source")), config_root
)
candidate["content"] = content
candidate["confirmation_required"] = confirmation
target_config, target_layout, details = _materialize_import_candidate(
document, current_config, current_layout,
duplicate_policy=duplicate_policy,
same_source=bool(candidate.get("same_source")),
content=candidate.get("content") or [],
confirmation_required=bool(candidate.get("confirmation_required")),
)
candidate["duplicate_policy"] = duplicate_policy
candidate["config_rev"] = int(current_config_data.get("rev", 0))
candidate["layout_rev"] = int(current_layout_data.get("rev", 0))
candidate["target_config"] = target_config
candidate["target_layout"] = target_layout
candidate["details"] = details
candidate["digest"] = _candidate_digest(candidate)
incoming = document["payload"]
return {
"preview": {
@@ -1388,35 +1826,17 @@ def prepare_apply(
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
if candidate.get("confirmation_required") and not confirm_missing_content:
raise ImportFailure("content_confirmation_required", "Missing content must be confirmed")
document = candidate["document"]
# Detachment is defined in the exported identity space. Do it before a
# space import remaps owners to fresh ids.
imported_config = _json_copy(document["payload"]["config"])
if candidate.get("confirmation_required"):
_detach_missing(imported_config, candidate.get("content") or [])
if document["kind"] == "full":
config = imported_config
_materialize_global_background(config)
# Full exports keep the data-model version in the portable envelope so
# the payload can be validated independently. Restore it before the
# configuration is persisted; otherwise every full round-trip silently
# downgrades the stored plan to an unversioned document.
model_version = document.get("model_version", 0)
if isinstance(model_version, int) and not isinstance(model_version, bool) \
and model_version > 0:
config["model_version"] = model_version
layout = _json_copy(document["payload"]["layout"])
if not candidate.get("same_source"):
settings = config.get("settings") or {}
settings.pop("known_devices", None)
settings.pop("new_device_ids", None)
details = {"counts": _counts(config, layout)}
else:
local_document = {**document, "payload": {**document["payload"], "config": imported_config}}
config, layout, details = build_space_merge(
local_document, current_config, current_layout,
duplicate_policy or candidate.get("duplicate_policy", "skip"),
same_source=bool(candidate.get("same_source")),
)
details["counts"] = _counts(config, layout)
return CONFIG_SCHEMA(config), LAYOUT_SCHEMA(layout), details
if duplicate_policy is not None and duplicate_policy != candidate.get("duplicate_policy"):
raise ImportFailure("conflict", "Duplicate policy changed after the preview")
if candidate.get("digest") != _candidate_digest(candidate):
raise ImportFailure("invalid_format", "Import preview candidate changed")
config = candidate.get("target_config")
layout = candidate.get("target_layout")
details = candidate.get("details")
if not isinstance(config, dict) or not isinstance(layout, dict) \
or not isinstance(details, dict):
raise ImportFailure("invalid_format", "Import preview has no materialized candidate")
# The current stores are intentionally unused: revisions are checked by
# the websocket handler under the write lock before this function runs.
_ = current_config, current_layout
return _json_copy(config), _json_copy(layout), _json_copy(details)
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.67.0-rc.1"
"version": "1.67.0-rc.2"
}
+297 -35
View File
@@ -145,20 +145,257 @@ def _angle_delta_mod_180(first: float, second: float) -> float:
return abs((first - second + 90.0) % 180.0 - 90.0)
def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, target_b: list) -> bool:
"""Whether a collinear union covers one complete positive target span."""
dx, dy, length = _segment_metrics(target_a, target_b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
ranges = []
for a, b in segments:
if (_line_distance(a, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON):
continue
lo, hi = sorted((_projection(a, target_a, target_b),
_projection(b, target_a, target_b)))
lo, hi = max(0.0, lo), min(length, hi)
if hi - lo > _OPTIMIZE_REHOST_EPSILON:
ranges.append((lo, hi))
reached = 0.0
for lo, hi in sorted(ranges):
if lo > reached + _OPTIMIZE_REHOST_EPSILON:
return False
reached = max(reached, hi)
if reached >= length - _OPTIMIZE_REHOST_EPSILON:
return True
return False
_OPTIMIZE_PARTITION_KEYS = {"id", "a", "b", "cm"}
def _known_optimize_partition(partition: dict) -> bool:
return isinstance(partition, dict) and set(partition).issubset(_OPTIMIZE_PARTITION_KEYS)
def _axis_range(a: list, b: list, axis_a: list, axis_b: list) -> tuple[float, float] | None:
"""Project one positive collinear overlap onto ``axis_a -> axis_b``."""
if not (isinstance(a, list) and len(a) == 2
and isinstance(b, list) and len(b) == 2):
return None
if (_line_distance(a, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON):
return None
_, _, length = _segment_metrics(axis_a, axis_b)
lo, hi = sorted((_projection(a, axis_a, axis_b),
_projection(b, axis_a, axis_b)))
lo, hi = max(0.0, lo), min(length, hi)
return (lo, hi) if hi - lo > _OPTIMIZE_REHOST_EPSILON else None
def _axis_point(a: list, b: list, along: float) -> list[float]:
dx, dy, length = _segment_metrics(a, b)
return [float(a[0]) + dx / length * along,
float(a[1]) + dy / length * along]
def _optimize_partition_needs_proof(old_partition: dict, space: dict) -> bool:
"""Select #296 removal/splitting, not ordinary Optimize grid alignment."""
partition_id = str(old_partition.get("id", ""))
current = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if current == old_partition:
return False
if current is None:
return True
if not (_known_optimize_partition(old_partition)
and _known_optimize_partition(current)):
return False
try:
if float(current.get("cm")) != float(old_partition.get("cm")):
return False
old_a, old_b = old_partition["a"], old_partition["b"]
current_range = _axis_range(current["a"], current["b"], old_a, old_b)
_, _, old_length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
return current_range is not None and (
current_range[0] > _OPTIMIZE_REHOST_EPSILON
or current_range[1] < old_length - _OPTIMIZE_REHOST_EPSILON
)
def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
"""Independently prove every removed atom of one old partition axis."""
if not _known_optimize_partition(old_partition):
return False
try:
old_a, old_b = old_partition["a"], old_partition["b"]
old_cm = float(old_partition["cm"])
_, _, length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
if length <= _OPTIMIZE_REHOST_EPSILON or old_cm <= 0:
return False
rooms: list[tuple[str, list[tuple[list, list]]]] = []
for room in space.get("rooms") or []:
poly = _room_polygon(room)
if len(poly) < 3:
return False
rooms.append((str(room.get("id", "")), [
(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))
]))
candidate_partitions = space.get("partitions") or []
retained: list[tuple[float, float]] = []
breakpoints = [0.0, length]
def note_segment(a: list, b: list) -> tuple[float, float] | None:
projected = _axis_range(a, b, old_a, old_b)
if projected:
breakpoints.extend(projected)
return projected
for partition in candidate_partitions:
try:
projected = note_segment(partition["a"], partition["b"])
candidate_cm = float(partition["cm"])
except (KeyError, TypeError, ValueError):
return False
if projected and _known_optimize_partition(partition) and candidate_cm == old_cm:
retained.append(projected)
for _, edges in rooms:
for edge_a, edge_b in edges:
note_segment(edge_a, edge_b)
for collection in (space.get("walls") or [], space.get("open_spans") or []):
for segment in collection:
try:
note_segment(segment["a"], segment["b"])
except (KeyError, TypeError, ValueError):
return False
points = sorted(set(round(point, 12) for point in breakpoints
if -_OPTIMIZE_REHOST_EPSILON <= point
<= length + _OPTIMIZE_REHOST_EPSILON))
for lo, hi in zip(points, points[1:]):
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
continue
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
and end >= hi - _OPTIMIZE_REHOST_EPSILON
for start, end in retained):
continue
target_a, target_b = _axis_point(old_a, old_b, lo), _axis_point(old_a, old_b, hi)
owners = {room_id for room_id, edges in rooms
if _segments_cover_target(edges, target_a, target_b)}
collinear = {room_id for room_id, edges in rooms
if any(_segments_overlap_on_axis(
target_a, target_b, edge_a, edge_b
) for edge_a, edge_b in edges)}
if len(owners) not in (1, 2) or collinear != owners:
return False
if any(_segments_overlap_on_axis(
target_a, target_b, span["a"], span["b"]
) for span in space.get("open_spans") or []):
return False
if any(_segments_overlap_on_axis(
target_a, target_b, partition["a"], partition["b"]
) for partition in candidate_partitions):
return False
covering_walls = []
for wall in space.get("walls") or []:
try:
wall_a, wall_b = wall["a"], wall["b"]
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((
_segment_metrics(wall_a, wall_b)[2], float(wall["cm"]),
))
except (KeyError, TypeError, ValueError):
return False
effective_cm = (min(covering_walls, key=lambda item: item[0])[1]
if covering_walls else _DEFAULT_ROOM_WALL_CM)
if effective_cm + _OPTIMIZE_REHOST_EPSILON < old_cm:
return False
return True
def _hosted_opening_geometry(opening: dict, partition: dict) -> tuple[float, float, float, float] | None:
host = opening.get("host")
try:
if not isinstance(host, dict) or host.get("kind") != "partition":
return None
t = float(host["t"])
a, b = partition["a"], partition["b"]
dx, dy, length = _segment_metrics(a, b)
opening_length = float(opening["length"])
except (KeyError, TypeError, ValueError):
return None
if not 0 <= t <= 1 or length <= _OPTIMIZE_REHOST_EPSILON or opening_length <= 0:
return None
angle = math.degrees(math.atan2(dy, dx))
if angle >= 90:
angle -= 180
elif angle < -90:
angle += 180
return float(a[0]) + dx * t, float(a[1]) + dy * t, angle, opening_length
def _safe_optimize_residual_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Prove a hosted opening rebound to a newly-created residual id."""
old_host, new_host = old_opening.get("host"), opening.get("host")
if not (isinstance(old_host, dict) and isinstance(new_host, dict)
and old_host.get("kind") == new_host.get("kind") == "partition"):
return False
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == str(old_host.get("id", ""))), None)
new_partition = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == str(new_host.get("id", ""))), None)
if old_partition is None or new_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
ignored = {"host", "x", "y", "angle"}
if ({key: value for key, value in old_opening.items() if key not in ignored}
!= {key: value for key, value in opening.items() if key not in ignored}):
return False
old_geometry = _hosted_opening_geometry(old_opening, old_partition)
new_geometry = _hosted_opening_geometry(opening, new_partition)
if old_geometry is None or new_geometry is None:
return False
old_x, old_y, old_angle, old_length = old_geometry
new_x, new_y, new_angle, new_length = new_geometry
try:
explicit_x, explicit_y = float(opening["x"]), float(opening["y"])
explicit_angle = float(opening["angle"])
except (KeyError, TypeError, ValueError):
return False
return (
abs(old_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_length - new_length) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(old_angle, new_angle) <= 1e-7
and abs(explicit_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(explicit_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(explicit_angle, new_angle) <= 1e-7
)
def _safe_optimize_partition_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Independently prove the exact #276 partition-to-room-wall transition."""
"""Independently prove the #276/#296 partition-to-room-wall transition."""
old_host = old_opening.get("host")
if not isinstance(old_host, dict) or old_host.get("kind") != "partition":
return False
partition_id = str(old_host.get("id", ""))
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if old_partition is None or any(
str(item.get("id", "")) == partition_id
for item in space.get("partitions") or []
):
if old_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
a, b = old_partition.get("a"), old_partition.get("b")
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
@@ -167,36 +404,6 @@ def _safe_optimize_partition_rehost(
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
owners: set[str] = set()
collinear_rooms: set[str] = set()
for room in space.get("rooms") or []:
poly = _room_polygon(room)
room_id = str(room.get("id", ""))
edges = [(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))]
if any(_segment_covers(edge_a, edge_b, a, b) for edge_a, edge_b in edges):
owners.add(room_id)
if any(_segments_overlap_on_axis(a, b, edge_a, edge_b)
for edge_a, edge_b in edges):
collinear_rooms.add(room_id)
if len(owners) not in (1, 2) or collinear_rooms != owners:
return False
if any(_segments_overlap_on_axis(a, b, span["a"], span["b"])
for span in space.get("open_spans") or []):
return False
covering_walls = []
for wall in space.get("walls") or []:
wall_a, wall_b = wall.get("a"), wall.get("b")
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
continue
if _segment_covers(wall_a, wall_b, a, b):
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
if covering_walls else _DEFAULT_ROOM_WALL_CM
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
return False
ignored = {"host", "x", "y", "angle"}
old_stable = {key: value for key, value in old_opening.items() if key not in ignored}
new_stable = {key: value for key, value in opening.items() if key not in ignored}
@@ -226,6 +433,42 @@ def _safe_optimize_partition_rehost(
or along + opening_length / 2 > length + _OPTIMIZE_REHOST_EPSILON):
return False
half_dx, half_dy = dx / length * opening_length / 2, dy / length * opening_length / 2
target_a = [expected_x - half_dx, expected_y - half_dy]
target_b = [expected_x + half_dx, expected_y + half_dy]
owners: set[str] = set()
collinear_rooms: set[str] = set()
for room in space.get("rooms") or []:
poly = _room_polygon(room)
room_id = str(room.get("id", ""))
edges = [(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))]
if _segments_cover_target(edges, target_a, target_b):
owners.add(room_id)
if any(_segments_overlap_on_axis(target_a, target_b, edge_a, edge_b)
for edge_a, edge_b in edges):
collinear_rooms.add(room_id)
if len(owners) not in (1, 2) or collinear_rooms != owners:
return False
if any(_segments_overlap_on_axis(target_a, target_b, span["a"], span["b"])
for span in space.get("open_spans") or []):
return False
if any(_segments_overlap_on_axis(target_a, target_b, item["a"], item["b"])
for item in space.get("partitions") or []):
return False
covering_walls = []
for wall in space.get("walls") or []:
wall_a, wall_b = wall.get("a"), wall.get("b")
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
continue
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
if covering_walls else _DEFAULT_ROOM_WALL_CM
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
return False
for other in space.get("openings") or []:
if other is opening or str(other.get("id", "")) == str(opening.get("id", "")):
continue
@@ -294,6 +537,17 @@ def validate_partition_opening_hosts(
continue
old_host = (old or {}).get("host")
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
if (allow_optimize_rehost and old and old_space
and isinstance(old_host, dict)
and old_host.get("kind") == "partition"
and isinstance(host, dict) and host.get("kind") == "partition"
and old_host.get("id") != host.get("id")
and not _safe_optimize_residual_rehost(
space, old_space, opening, old
)):
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; residual host changed"
)
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
@@ -324,6 +578,14 @@ def validate_partition_opening_hosts(
raise PartitionOpeningJambMarginError(
space_id, opening_id, margin, margin_cm
)
if allow_optimize_rehost and old_space:
for old_partition in old_space.get("partitions") or []:
if (_optimize_partition_needs_proof(old_partition, space)
and not _safe_optimize_partition_delta(space, old_partition)):
raise PartitionOpeningHostError(
f"space={space_id}; partition={old_partition.get('id', '')}; "
"optimize delta unproved"
)
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
@@ -511,6 +511,8 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
"counts": details.get("counts", {}),
"space_id": details.get("space_id"),
"repaired_target_refs": details.get("repaired_target_refs", 0),
"preserved_unresolved_refs": details.get("preserved_unresolved_refs", 0),
"reference_report": details.get("reference_report", {}),
"can_undo": kind == "full",
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+68 -50
View File
@@ -1,17 +1,19 @@
{
"schema": 1,
"matrixVersion": 44,
"acceptedAt": "2026-08-24T16:12:22.415Z",
"sourceFingerprint": "7a687db9aadb86ee4af8374678c06f3697fb98935ea9bfa3df7b2cd0b652543a",
"matrixVersion": 45,
"acceptedAt": "2026-08-25T14:41:01.859Z",
"sourceFingerprint": "8bca2e9a29df5fd509825fbfe2a790d7aa71c0f775867abf3328e3d609fa900d",
"chromium": "151.0.7922.34",
"scenarios": {
"coincident-partition-before-dark": "c0e38cfff096725cf1eb5dc91e005b2af983172d04e6573075db635275cabca3",
"coincident-partition-thin-dark": "3a33ebf94e6c19b0079cf66975180817146d68ae97d1f9ff4a26711f4ca54903",
"coincident-partition-thick-dark": "33f9296e765f4ea9922cb3b13d11b50306709ba4fcf3114d351eda1833399870",
"coincident-partition-virtual-dark": "84969a05c442549dac0bcbb7e507ee77967e42f75d0fdf3c8e8fbe2170c65834",
"hidden-wall-diagnostics-plan-light": "6470ed8b09b21144ec1399389e9a57774e977a6473b8eaa7de7e9b5df3273f21",
"hidden-wall-diagnostics-plan-dark": "da6e725ddfad7c807408a710b78090dc222b56dcee484a29baa68b5452fc44e5",
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
"split-corner-wall-thick-dark": "309b8fe8a206f4f3fc50f6a27fc762a933f245b778e7b1f9a6348b66f39b2c39",
"split-corner-wall-thick-dark": "f7864a6b2ab58586540f836183c9e59f09f9f57165ec0e0b9cd8444ada71153c",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"isometric-geometry-view-dark": "43ad006844a78b502b36aeeb82d82669532e06d2c798de928a5207acd24bd5f9",
"isometric-geometry-view-light": "0fcb10ffdc0baa9006d1c2f0406ebf19e71ba9feda180979a51b74166d7200d2",
@@ -19,53 +21,53 @@
"isometric-no-borders-dark": "c105382d883bbdae8562b756ccbf47ac1fcf46675c6aa7654289856223a3ebf0",
"isometric-touch-kiosk-dark": "be122ceec8114374ba384f51d5d9287e498a97354865c5a9f91d505ca18d8640",
"isometric-large-warm-remount-dark": "d76e3334bf80bff265b1e8aa6770c5eff5df6fbfebeb4703b04d048c4252eb68",
"geometry-view-dark-fit": "30985326ad907fac4b93f848860e5b6c7737f24e8281706c3f69a189477f932f",
"geometry-view-light-fit": "310cca616bf1fc5fda4f1e8d58e80ddf2fe2662d088829e22eef7b9e6b5ddd93",
"geometry-view-dark-fit": "1b80d2d0fe84cfe15f1fa29ccc2a2dcc66b4c5e62d1722c06c00442c78979be0",
"geometry-view-light-fit": "91caf6621af00eb0cd16589e2fc79976cdca1e8467d0e86151ecbc46948612d9",
"room-label-parity-view-dark": "7f1da43466a13f82a7595dddabe6938ed974debfb14c8f40e729e273a53ac4ff",
"room-label-parity-plan-dark": "5f976d5743d68a27b594e1a5ddf45e5ad3708f5c703409e5cefb7c96d3048096",
"room-label-parity-view-light": "ee3d303fb2b329c356a4354d355c29694130a38d4812ffbfefbe861f5d3f9860",
"room-label-parity-plan-light": "d32b538499a031d4310b243cc9ef43610f6cd67aabfde7cd01b59c88b0363ec5",
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
"day-cycle-dawn-dark": "b01d9add378afb47912da10f6327d43177d4bbb0b42cb4323395fd01bf2fe8f5",
"day-cycle-day-dark": "97db91bd4daf41d903cc968d70a5a4ab7af7e332b60b7316e339f490e08fa953",
"day-cycle-dusk-dark": "a856187898fc425a48dd165f5ec01f0f50b78043b382f708dbffd810bfdbf043",
"day-cycle-night-dark": "b863260250d739fd369140356e195a64d3ac58478ab2c1e43415abd8f96a7fbd",
"geometry-plan-editor-dark": "a1ca4219a1e7b2c4749b72edad87cd0bdee793390cd6efe972aca1d41d0d2478",
"safe-resize-handles-clamp-light": "298670cc94642968e58c45560597e8277c405cf61c2a8b5e08f1544612a6ed4b",
"safe-resize-handles-clamp-dark": "64f32f2038238f423ba3cc1ed618bf9d32704c8161042751d447c3f884e37e4a",
"space-tab-drop-before-light": "86d8159ac6191980f2ac91b137c3947219da45fef81bc2b358442958eda9d0f8",
"space-tab-drop-after-dark": "2e2bde063f9fa34284beb27457f1542e8b6085033be67fd0dc1923b81c6af1bf",
"plan-snap-endpoint-light": "78225caf0522f69c294b299212a717260c31e9620bdefdc970dc84730ae06e17",
"plan-snap-line-gaps-dark": "61bac139592fd292a8e9ded541dc4b98912779f415f371108c2d99a18c7d9d57",
"wall-junctions-plan-preview-light": "1482b01209a796f93ebc23e231b33fe869ee96b6f46d1fb2d32ccf677a94e602",
"wall-junctions-plan-t-dark": "40f0b57e2b713df28c2769ba44931b165aaa669d4c0b3dd97d562e904292d449",
"day-cycle-dawn-dark": "3f32bc1ed042475516f7e239aedd7fee11717b00fa2103ae9f1231e006d64015",
"day-cycle-day-dark": "1a0f53c948ae669c7ab4e1dcb931c11e7c878974862637dd1f596011876a7ed4",
"day-cycle-dusk-dark": "ec95e99ac3c7e4d6affa2f7944759727251dc4f1b62e2855e421a7e48f7280eb",
"day-cycle-night-dark": "cc49dc53dabb63b7dd800eaa65c8748972fcf3c1abd948b34d1e0f973e63f0d2",
"geometry-plan-editor-dark": "fd8d067daa3b2a4e572dea9644486e6324318e8ea4e5f43e3df2069a05389a88",
"safe-resize-handles-clamp-light": "ed705040357cad043095baf12d02cc69abdc3db27ff40066a6979be85d035f77",
"safe-resize-handles-clamp-dark": "9d81a45f3acac787cde1b9eb74a858020fbad09670b8ff5b081869b5eca73b82",
"space-tab-drop-before-light": "401dded3d7fdff3e823d46eac1b3cef2d6e0eb05f48b5ea289cdd8ecd941dee0",
"space-tab-drop-after-dark": "a851621040e14f911b1080488f2c75e3d9bc0d5fef74fb5d0081f80b023ff0a2",
"plan-snap-endpoint-light": "db967076cd9fd50c2a2a1337e4ecbf4ef67cb7f800401f6227497c75415951d5",
"plan-snap-line-gaps-dark": "ff1b6bbf2e1b4272bb2f0e96d92136aa6960ee24acef1b63b4478e54441a4b58",
"wall-junctions-plan-preview-light": "fa81fa6c6dba92578a4beee5ed29f407443a3bc61affa25c44fcfefab0c7070c",
"wall-junctions-plan-t-dark": "f8a517d316a99ca9ee5b4ff56f515be15d440adfc551bbdaaa5e9d9a411a79bc",
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
"junction-patch-resilience-plan-dark": "5dac2f2f81c801b0d9b1c0ee786f1f531906c80ddeec36f9c91b524468c13608",
"junction-patch-resilience-view-dark": "efe6e3ebd0e08e936bfa79886d5f55185198cf770a5d2473160cf2335be46340",
"junction-patch-resilience-plan-dark": "57ea7522c438e52292f226f975bb112930976b6e072b72322180d52ec0277f4f",
"junction-patch-resilience-view-dark": "1f0df26ed72bcc5bef06a8366d7043ad40b4a2bb744fa5a64248c29eae968424",
"wall-union-isolation-view-light": "291c045758be2814be88d12ab46bdc3d12cb99a03dbbcc9a1539c2c9a5931edf",
"wall-union-isolation-view-dark": "616fc6cc7144c9596ae52421027914e222f30f26de45215fa7e7b6175ca78543",
"multiwall-junction-bevel-view-dark": "70f7f0e7c60beb33853fc26445b6901d48299c2c92ddc41f6e1e2cfb4cac2320",
"multiwall-junction-bevel-view-dark": "e226d023fbab0502eb747395d43a46bb56bcf2be037ba4f9b7ad74ba406d9671",
"orthogonal-strip-cell-5-view-dark": "9b8e4bd22cbf1a82ff48f052822967c61179290a3234ae48ef547642329b5a90",
"orthogonal-strip-cell-1-view-dark": "44e245db41ab9a4e575cefbf7656055dba7e27fc62c2b1215445517a9ea81386",
"wall-key-roundtrip-view-dark": "28895aa02eb995f976b26a2d2b3675954611641b60910f40427b15a95ef53e3e",
"orthogonal-strip-cell-1-view-dark": "e59e51ad3645144161459c438a07420730c4dcfd2c33bd2b18e8c3e57ea84515",
"wall-key-roundtrip-view-dark": "df46b4e209f98b97958748097ddc646ddc64d8285c2651762de71ec2456ac690",
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
"opening-placement-door-thick-wall-dark": "59469954c1ea5a2e79df7eea1c287228998c0bfe1b3dc43869d553f25be78222",
"opening-placement-passage-thick-wall-dark": "f3efc2bafbc472f18042f26a896870a4256aa2ea843d887f8b33301001a947f0",
"opening-placement-passage-thick-wall-light": "e5f6024151eebb328f211793bd2d346703d627524bdff931c61ce13b5a5fa7f4",
"opening-placement-door-thick-wall-dark": "1157139aac7325b40e3869b8c3b5444509b4bc24a415ddf6f8d5402624f3a04a",
"opening-placement-passage-thick-wall-dark": "6d4a9a953b70401ca11cd6ce96c2e0e0781bbfff71e758dc57958a4163e2712d",
"opening-placement-passage-thick-wall-light": "b004b1395652c8a8eef5f5002b471ecf8f2f0347f158a14a056ff2b7382e4ea3",
"opening-symbol-room-wall-light": "562fed297210ef542de608e384c2d344dddd193a5b43615930c9f76faaf53b42",
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
"opening-symbol-flip-pairs-light": "33364f51fb65e49bfdd6c41d6e0c53542c89fadc45a3c6e5adc590144c4676ea",
"isometric-opening-symbol-parity-dark": "302daf5d90d4af5ef90036cc10e957e6fc909ea56f9f2af938ab850d4f608e34",
"geometry-devices-editor-dark": "9053fd940e5f2a27f0062b159e4b4debd3d4f47d5cd5bd81c0b154ecc0f4639b",
"geometry-decor-editor-dark": "736e289cf16adb5fa5e58e7db7d75475e16065b58039bbdc0cec402f4256f390",
"tray-wide-selection-en": "dab331bc075c9742b4aecb9feb7de01a30601416d7dcaeb78f50252af0d58a5d",
"tray-wide-tool-ru": "0623ff17c4c22d338d9d317f348b2e181658cf0fe3275a6dc6b80d4077ec3b68",
"tray-medium-group-en": "03410227827c034035245bf84b4a283ac72f0da3925d3a4b6873f1bb1992f057",
"tray-medium-selection-ru": "ec9c28eb81cf2b5106254422d17e0adc58ba22a720db4f4832c669ccd0c84723",
"tray-narrow-palette-en": "10f4786a9e6020a8cb1c92b4b1eab299a50ae46eea7a9360b6af8cf675b4c943",
"tray-narrow-tool-ru": "2c2a886b70cda7b02bbd663ee915fd1e68f87587b85c653496784084883bcc7b",
"geometry-diagonal-45-opening-dark": "1bc04b80e39234e98b98dbcc8eba554ee77d3c5dd3de0adfa140fa24410711f8",
"geometry-devices-editor-dark": "4b91bdc00082e33d2529ea3cf7ce7af36383a84838f4a2c909463df24483b47b",
"geometry-decor-editor-dark": "8b03bc87d2353cb6ef8fb4e2dec3f0d3be85de903ac9346171ea4aaa31170a66",
"tray-wide-selection-en": "d140cf6a51c634a38d2e3661c4b61835d63673174fd934b85f9da0db4fa20d1a",
"tray-wide-tool-ru": "806ff7c5ae3d2e708a4682833af7dc28a88001ef09e4ca564c91a5e6023b297d",
"tray-medium-group-en": "5a1adbaa64d2b73f294003e2752a79008f7ec313ab41dd0514b6b38918b8c00e",
"tray-medium-selection-ru": "53c7ea5099772fd14c2e27ad414c4d57d87c020866992b7a0b0dd62a22fbeb7e",
"tray-narrow-palette-en": "5bc029bb7a4b077a45abc0c18f54360f781caa3684befb63cfa714efcfaf7edd",
"tray-narrow-tool-ru": "32e27435ec9f0ce6db2e2800ffd71b6a5fcb1ab7dde17454a3ba7dda680b8a45",
"geometry-diagonal-45-opening-dark": "a0167f3028cd8ef8283c92ae7e803940cf0eb0db45f1b137108a4e009741a5df",
"openings-thick-wall-dark": "c45bae26ec0b4f719f72e5b92e3b8e03260d7bda4b3182d4c563b50f656e68a1",
"openings-filled-tunnel-dark": "b916d3ce48bba7487ed4b548bf659b610243ebcde07206c21fff65867792d0d1",
"decor-over-opaque-hover-light": "553d279db44aa0a4a6855496036b0f6ddda9570cef133e09a30f29f41b7d0c31",
@@ -90,7 +92,23 @@
"lighting-temp-glow-room-override-dark": "bebfc27f460cb75e32ab14076a01e3b9729763d7cecd3f457c1b696ed3e57da3",
"lighting-manual-auto-spill-overlap-dark": "471e39397361832a171f0309302be7d8ba4310d628e337478e23da44dc455ef8",
"hover-over-glow-dark": "6581433024060f773e00211d60eac7b6daffe442031ff076f861918419c71930",
"hover-nested-room-dark": "675170204c1d98b891a3e06d921e4264538c4949e5d1b274efa3ef708afef169",
"hover-nested-room-dark": "f05ba332a127b56c00a6a94eac727b168ed34cc3f0355f8b783827f7bf55714c",
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
"junction-x-45-alternating-dark": "56a85069baafd6e7af921389b55b916b74cc5d76df908782013b94c2b5b87752",
"junction-star5-equal15-dark": "1b9714dffa3f37b9f1e8d848244c4585d38539b74fe582e8d974e2b9e8fdd01a",
"junction-y-60-equal50-dark": "f90d46cfe79a95bb3ec73253d0b7f77948164e90c49561d79b1e0db655cc0847",
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
"junction-acute15-equal50-dark": "3327caf8bc1ccddb317aa7c4c330e09c098122a4f6d2e4df64502cdc033cb2e4",
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
"junction-owner-repro-dark": "23ae216c2637b8ee9ea8f852002693f74d64021a66d4494e2086a53db8ca6520",
"large-house-zoom-040-dark": "df1a5515089b5e470ae550ce6da31a301be5079d3f7bb9130baad99d3d7c641e",
"large-house-zoom-250-dark": "88c0d22686fd9ef314866d0275a5ae785781c3d671e257b2c5f790f816cd65ee",
"large-house-warm-remount-dark": "9a61d668c5fbc61bb370c043771cee91707b3d40378cf89f81199882820bf95c",
@@ -100,17 +118,17 @@
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
"device-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
"decor-color-popover-desktop-en": "0a33f2a4ad6614bccbce45fbda1eaa505551e479b1eaada28ddcaf435a83f74f",
"decor-color-popover-desktop-en": "8ad229fd3d433f969dc6d09c7677e9949d4f62503196bffbf17030386f7c7511",
"general-color-popover-desktop-en": "91c3b7ebae5146c62d172d1ce10ae9e375ed955f23e9dc860976373a334d8be5",
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
"space-room-color-popover-desktop-ru": "a7b2c78d8b178eb1947937f9aebe2b02d67a0f6fed3b2a69a7bf0d7e24df50ac",
"backup-full-preview-desktop-en": "252f4f687f188645f0b22a49f03c63725aefc6bda5ff2e930a33b8ac729544a8",
"backup-plan-only-export-desktop-en": "d76da7b84f46499b8323c920e464e1ff52893dee17db08001badbdb1a09829c1",
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
"optimize-preflight-dialog-dark-en": "93461a3d95208aa798dd93d5500003127dcad8e7993f7bdc6b7f8f1a95389d73",
"optimize-preflight-dialog-light-ru": "de43f205fdf16d290603543a02ced9492d1b5a35e089fa6023c98efdf32ed7c3",
"optimize-orphan-references-dark-en": "ce2116ffefd64e6b38921c3afd2a5d9bd9329e8a2aec102eb42a032d23a7d000",
"optimize-orphan-references-light-ru": "5a4152df98a34e724b25c0d331fd691fa63eaba753bd4a9c5b173a454c148ecd",
"space-room-color-popover-desktop-ru": "d66d068a1b403f88a5bd3c57f4a9e55c336767c533ddfe3ae251cc4f9da40a74",
"backup-full-preview-desktop-en": "c9fe8ca1cf63b9ed98e2d517a2c06639d044d460011d958573e70c6b22bd7a2f",
"backup-plan-only-export-desktop-en": "c48b3ccdd7d85d36f132c2d7b4c5dba94a1027e3e3f2ef495b4eb89a23f8ca33",
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
"optimize-preflight-dialog-dark-en": "6e0706b951db73c7d699173d9cd0cd3cd8857238e3d7e95bc38eeb3691f907b6",
"optimize-preflight-dialog-light-ru": "a96af4b4428297d2c672f0d0e8f376e81c27765ecc941781a2d3873a054e2ec3",
"optimize-orphan-references-dark-en": "cdf4eb8c79a9961a1e961a4eed4107d4d849ebc5b922ad88ce07befee6b65a9a",
"optimize-orphan-references-light-ru": "c07151ac896e43bef6150128cbef8a5798ee113083961ed8eaf97113a0b56e1e",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+147 -4
View File
@@ -2,6 +2,9 @@ import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
import { readFileSync } from 'node:fs';
const junctionArtifactsFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
));
const junctionPatchFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
@@ -83,6 +86,13 @@ export function prepareGoldenFixture(scenario) {
space.settings = {
fill_mode: 'none', show_borders: true, show_names: true,
};
if (scenario.hiddenWallDiagnostics) {
space.room_drafts = [{
id: 'hidden-saved-chain',
points: [[0, 0], [0, 1]],
segments: [{ cm: 15 }],
}];
}
if (state !== 'before') {
delete space.partitions;
space.walls[0].cm = state === 'thin' ? 10 : state === 'thick' ? 30 : 20;
@@ -217,6 +227,75 @@ export function prepareGoldenFixture(scenario) {
partitions: [], room_drafts: [], wall_columns: [], decor: [],
});
}
if (scenario.junctionNode) {
// #302: a close-up star node. Pie-slice rooms around the centre; every
// arm is a shared edge carrying its own thickness; a virtual arm is the
// same edge released by an open span.
const spec = scenario.junctionNode;
const C = [0.5, 0.5];
const R = 0.3;
const arms = spec.arms
.map((arm) => ({ ...arm, rad: (arm.deg * Math.PI) / 180 }))
.sort((a, b) => a.deg - b.deg);
const endOf = (arm) => [
C[0] + Math.cos(arm.rad) * R, C[1] + Math.sin(arm.rad) * R,
];
const rooms = [];
for (let index = 0; index < arms.length; index++) {
const a = arms[index];
const b = arms[(index + 1) % arms.length];
const sweep = ((b.deg - a.deg + 360) % 360) || 360;
const arc = [];
const steps = Math.max(1, Math.ceil(sweep / 60));
for (let step = 0; step <= steps; step++) {
const rad = ((a.deg + (sweep * step) / steps) * Math.PI) / 180;
arc.push([C[0] + Math.cos(rad) * R, C[1] + Math.sin(rad) * R]);
}
rooms.push({
id: `junction-slice-${index}`, name: `S${index}`, area: null,
poly: [C.map((v) => v), ...arc],
});
}
const walls = arms.map((arm) => {
const end = endOf(arm);
return { key: fixtureWallKey(C, end), a: [...C], b: [...end], cm: arm.cm };
});
const space = {
id: scenario.space, title: 'Junction node', view_box: [0, 0, 1, 1],
cell_cm: 5,
rooms, walls,
settings: { fill_mode: 'none', show_borders: true, show_names: false },
};
const virtual = arms.filter((arm) => arm.virtual);
if (virtual.length) {
space.open_spans = virtual.map((arm) => {
const end = endOf(arm);
return { a: [...C], b: [...end] };
});
}
if (spec.column) {
space.wall_columns = [{
id: 'junction-column', center: [...C], shape: 'circle', size_cm: 40,
}];
}
if (spec.draft) {
space.room_drafts = [{
id: 'junction-draft',
points: [[0.5, 0.5], [0.75, 0.62]],
segments: [{ cm: 15 }],
}];
}
fixture.config.spaces.push(space);
}
if (scenario.junctionArtifacts) {
fixture.config.spaces.push({
...structuredClone(junctionArtifactsFixture),
id: scenario.space,
title: 'Junction artifacts repro',
view_box: [0, 0, 1, 1],
settings: { fill_mode: 'none', show_borders: true, show_names: false },
});
}
if (scenario.junctionPatchResilience) {
if (!Array.isArray(scenario.retainedWedgeProbe)
|| scenario.retainedWedgeProbe.length !== 2
@@ -255,7 +334,7 @@ export function prepareGoldenFixture(scenario) {
const contract = scenario.multiWallJunction;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (!validPoint(contract.node) || !validPoint(contract.discardedWedgeProbe)
if (!validPoint(contract.node) || !validPoint(contract.retainedOverlapProbe)
|| !Number.isInteger(contract.rays) || contract.rays < 3
|| !Number.isInteger(contract.enclosedHoles) || contract.enclosedHoles < 0) {
throw new Error(`invalid golden multiWallJunction: ${scenario.id}`);
@@ -668,6 +747,43 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await settleMode(card);
}
// #304: the two existing hidden-wall golden scenes also guard the static
// architectural overlay in every Plan tool. Finish in draw so the reviewed
// pixels stay stable; a missing/changed tool projection fails before capture.
if (scenario.hiddenWallDiagnostics) {
const tools = [
'select', 'draw', 'column', 'merge', 'split', 'resize',
'opening', 'boundary', 'wallthick', 'delroom',
];
const snapshot = () => {
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
if (!overlay || overlay.getAttribute('pointer-events') !== 'none') return null;
return JSON.stringify({
lines: [...overlay.querySelectorAll('.plan-snap-line')].map((line) => [
line.getAttribute('data-key'), line.getAttribute('x1'), line.getAttribute('y1'),
line.getAttribute('x2'), line.getAttribute('y2'),
]),
endpoints: [...overlay.querySelectorAll('.plan-snap-node[data-kind="endpoint"]')]
.map((node) => [node.getAttribute('data-key'), node.getAttribute('cx'), node.getAttribute('cy')]),
});
};
let expected = null;
for (const tool of tools) {
card._tool = tool;
card._clearPlanSnapHover();
card.requestUpdate();
await card.updateComplete;
const current = snapshot();
if (expected == null) expected = current;
if (!current || current !== expected) {
throw new Error(`golden plan-axis parity failed for ${tool}: ${scenario.id}`);
}
}
card._tool = 'draw';
card.requestUpdate();
await card.updateComplete;
await frame();
}
if (scenario.wallUnionIsolation) {
const result = card._wallUnionGeometry?.();
const paths = [...card.renderRoot.querySelectorAll('[data-hp="wall"]')];
@@ -684,11 +800,11 @@ export async function prepareGoldenScenario(page, scenario) {
}
}
if (scenario.multiWallJunction) {
const { node, discardedWedgeProbe, enclosedHoles } = scenario.multiWallJunction;
const { node, retainedOverlapProbe, enclosedHoles } = scenario.multiWallJunction;
const wall = card.renderRoot.querySelector('[data-hp="wall"]');
const at = (point) => new DOMPoint(point[0] * 1000, point[1] * card._spaceH);
if (!wall?.isPointInFill?.(at(node))
|| wall.isPointInFill(at(discardedWedgeProbe))) {
|| !wall.isPointInFill(at(retainedOverlapProbe))) {
throw new Error(`golden multi-wall bevel contract failed: ${scenario.id}`);
}
// A pixel threshold missed the reported triangles because they occupy a
@@ -841,7 +957,8 @@ export async function prepareGoldenScenario(page, scenario) {
await until(() => card._renderProjection === 'iso');
}
if (Number.isFinite(scenario.zoom)) {
card._applyView(scenario.zoom, 500, 500);
const [zx, zy] = Array.isArray(scenario.zoomCenter) ? scenario.zoomCenter : [500, 500];
card._applyView(scenario.zoom, zx, zy);
card.requestUpdate();
await card.updateComplete;
}
@@ -930,6 +1047,16 @@ export async function prepareGoldenScenario(page, scenario) {
if (!card._rszPreview || !card._rszLive?.length || !(delta > 0 && delta < 250)) {
throw new Error(`golden safe Resize opening clamp did not render: ${scenario.id}`);
}
const resizeLengths = card._rszLive.filter((label) => label.kind === 'length');
const resizeAreas = card._rszLive.filter((label) => label.kind === 'area');
const measuredEdges = card.renderRoot.querySelectorAll('[data-hp="resize-measured-edge"]');
const areaLabels = card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]');
const leaders = card.renderRoot.querySelectorAll('[data-hp="resize-area-leader"]');
if (resizeLengths.length !== 2 || measuredEdges.length !== 2
|| resizeAreas.length !== card._rszDrag.plan.roomIds.length
|| areaLabels.length !== resizeAreas.length || leaders.length !== resizeAreas.length) {
throw new Error(`golden Resize measurement contract is incomplete: ${scenario.id}`);
}
}
if (scenario.openingPreview) {
const { type, pointer } = scenario.openingPreview;
@@ -1176,6 +1303,22 @@ export async function prepareGoldenScenario(page, scenario) {
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
counts: { spaces: 1, rooms: 4, markers: full ? 12 : 0, layout: full ? 15 : 4 },
duplicates: 0,
repaired_target_refs: full ? 0 : 3,
preserved_unresolved_refs: full ? 0 : 1,
reference_report: full ? {} : {
remapped: {
incoming: { 'layout.space': 4, 'room.open_to': 1 },
target: { 'marker.space': 3 },
},
collisions: {},
preservedUnresolved: { 'marker.room_id': 1 },
droppedIncomingLinks: {},
boundedLineages: 0,
examples: [{
bucket: 'preservedUnresolved', category: 'marker.room_id',
owner: 'legacy-thermostat', reference: 'room_old-generation_deadbeef',
}],
},
confirmation_required: full,
content: full
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
+60 -2
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 44;
export const GOLDEN_MATRIX_VERSION = 45;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -165,6 +165,14 @@ const decorLayerProbes = {
};
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'hidden-wall-diagnostics-plan-light', fixture: 'visual',
space: 'golden-coincident-partition', coincidentPartition: 'before',
hiddenWallDiagnostics: true, mode: 'plan', theme: 'light',
viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'hidden-wall-diagnostics-plan-dark', fixture: 'visual',
space: 'golden-coincident-partition', coincidentPartition: 'before',
hiddenWallDiagnostics: true, mode: 'plan', theme: 'dark',
viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'coincident-partition-before-dark', fixture: 'visual',
space: 'golden-coincident-partition', coincidentPartition: 'before',
mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
@@ -298,7 +306,10 @@ export const GOLDEN_SCENARIOS = Object.freeze([
space: 'golden-multiwall-junction', mode: 'view',
multiWallJunction: {
node: [0.329166667, 0.141666667], rays: 3,
discardedWedgeProbe: [0.3303808442725, 0.1488560107825],
// #302: the chamfer is strip-safe — this point sits in the overlap of
// two incident strips and must stay FILLED (it used to be discarded by
// the old subtractive bevel, which is exactly the hole class fixed).
retainedOverlapProbe: [0.3303808442725, 0.1488560107825],
// Product contract (#272): a bounded exterior bevel is allowed, but no
// empty component may remain trapped inside the continuous masonry.
enclosedHoles: 0,
@@ -563,6 +574,53 @@ export const GOLDEN_SCENARIOS = Object.freeze([
hoverRoom: 'light-right', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'hover-nested-room-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
hoverRoom: 'geo-nested', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
// --- #302: junction close-ups. One node per scene, the node fills the
// frame; the same scenes carry the objective no-holes detector in tests.
...[
['junction-t-90-equal15-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
['junction-t-90-bar50-leg15-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 90, cm: 15 }, { deg: 180, cm: 50 }] }],
['junction-t-90-bar70-leg15-dark', { arms: [
{ deg: 0, cm: 70 }, { deg: 90, cm: 15 }, { deg: 180, cm: 70 }] }],
['junction-x-90-equal15-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 },
{ deg: 270, cm: 15 }] }],
['junction-x-90-mixed-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 50 }, { deg: 180, cm: 15 },
{ deg: 270, cm: 70 }] }],
['junction-x-45-alternating-dark', { arms: [
{ deg: 45, cm: 15 }, { deg: 135, cm: 50 }, { deg: 225, cm: 15 },
{ deg: 315, cm: 50 }] }],
['junction-star5-equal15-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 72, cm: 15 }, { deg: 144, cm: 15 },
{ deg: 216, cm: 15 }, { deg: 288, cm: 15 }] }],
['junction-y-60-equal50-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 60, cm: 50 }, { deg: 120, cm: 50 }] }],
['junction-acute30-mixed-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 30, cm: 70 }, { deg: 180, cm: 15 }] }],
['junction-acute15-equal50-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 15, cm: 50 }, { deg: 180, cm: 50 }] }],
['junction-splay10-170-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 10, cm: 15 }, { deg: 180, cm: 50 }] }],
['junction-t-virtual-arm-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15, virtual: true },
{ deg: 180, cm: 15 }] }],
['junction-x-virtual-through-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15, virtual: true },
{ deg: 180, cm: 15 }, { deg: 270, cm: 15, virtual: true }] }],
['junction-column-node-dark', { column: true, arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
['junction-draft-end-node-dark', { draft: true, arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
].map(([id, junctionNode]) => ({
id, fixture: 'visual', space: `golden-${id}`, mode: 'view', junctionNode,
zoom: 4, theme: 'dark', viewport: { width: 900, height: 900 }, ...stage,
})),
{ id: 'junction-owner-repro-dark', fixture: 'visual',
space: 'golden-junction-owner-repro', mode: 'view', junctionArtifacts: true,
zoom: 6, zoomCenter: [329.17, 141.67], theme: 'dark',
viewport: { width: 900, height: 900 }, ...stage },
{ id: 'large-house-zoom-040-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
zoom: 0.4, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'large-house-zoom-250-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
+21 -1
View File
@@ -82,6 +82,18 @@ const result = await page.evaluate(async () => {
counts: { spaces: 1, rooms: 2, markers: 0, layout: 2 },
bindings: { device: 0, entity: 0, virtual: 0, active: 0, disabled: 0, missing: 0 },
duplicates: 0, confirmation_required: false, content: [],
repaired_target_refs: 2, preserved_unresolved_refs: 1,
reference_report: {
remapped: {
incoming: { 'layout.space': 2 }, target: { 'marker.space': 2 },
},
collisions: {}, preservedUnresolved: { 'marker.room_id': 1 },
droppedIncomingLinks: {}, boundedLineages: 0,
examples: [{
bucket: 'preservedUnresolved', category: 'marker.room_id',
owner: 'legacy-marker', reference: 'room_old_deadbeef',
}],
},
},
expectedConfigRev: 1, expectedLayoutRev: 2, duplicatePolicy: 'skip',
confirmMissing: false, busy: false, error: '',
@@ -93,6 +105,11 @@ const result = await page.evaluate(async () => {
noDuplicatePolicy: !root().querySelector('.backupchoices'),
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
<= root().querySelector('hp-dialog').clientWidth,
hasReferenceReport: !!root().querySelector('.backupdetails')
&& root().querySelector('.backupdetails')?.textContent
.includes(card._t('backup.import_details')),
warnsWithoutDiscarding: root().querySelector('.backupwarn')?.textContent
.includes(card._t('backup.preserved_unresolved_hint')) === true,
};
return {
group, actions, keyboardImport, exportRadios, exportWarning,
@@ -112,6 +129,9 @@ checkAll(result, {
planOnlyResetForFull: true,
planOnlyRequest: { kind: 'space', planOnly: true, space: 'f1' },
importSafe: { danger: true, disabledUntilConfirmed: true, noHorizontalOverflow: true },
planOnlyPreview: { visible: true, noDuplicatePolicy: true, noHorizontalOverflow: true },
planOnlyPreview: {
visible: true, noDuplicatePolicy: true, noHorizontalOverflow: true,
hasReferenceReport: true, warnsWithoutDiscarding: true,
},
});
await finish(browser, result);
+327
View File
@@ -0,0 +1,327 @@
// Обход последовательностей правок: инварианты после каждого жеста (#297).
//
// Зачем это отдельно от всех прежних гейтов. Решётка и шум (#283), непрерывность
// кладки (#285/#286), смешанные роли (#287), ключи стен (#259), аудит ручек
// (#292) — каждый берёт ГОТОВЫЙ план и что-то в нём измеряет. А дефекты
// геометрии рождаются не в хранении, а в РЕДАКТИРОВАНИИ: #289 (ресайз наружного
// ребра создал запись со смешанной ролью), #290, #296 (после удаления комнаты
// остались перегородка поверх стены и черновик из двух точек, и обе ручки
// ресайза выключились). Ни один снимок этого не показывает: такая геометрия
// ничего не портит в модели, она портит следующий жест.
//
// Поэтому здесь план не проверяется, а РАСШАТЫВАЕТСЯ: продуктовыми жестами, в
// продуктовом карте, по детерминированному семени, с проверкой инвариантов
// после каждого шага. Второе представление редактора при этом не появляется —
// жесты идут через `_rszEdgeDown/_rszMove/_rszUp` и `_confirmRoomDelete`, то
// есть через тот самый код, который выполняется у пользователя.
//
// Найденное нарушение печатается вместе с семенем и полной цепочкой жестов,
// поэтому воспроизводится одной командой:
// node demo/smoke_edit_walk.mjs --seed 7 --plan real-plan-second-floor.json
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launch, checkAll, finish } from './serve.mjs';
// `model-invariants.mjs` и оптимизатор — скомпилированный продуктовый код, а не
// вторая модель. Значит смок обязан собрать тот же `test-build`, который делает
// `npm test`: на чистом Linux CI этого каталога нет, и опора на оставшийся от
// разработчика падает до запуска браузера. Ровно эту ошибку уже проходил
// `smoke_lattice_write_barrier.mjs` — здесь она повторена по той же причине.
const root = fileURLToPath(new URL('..', import.meta.url));
execFileSync(process.execPath, [
resolve(root, 'node_modules/typescript/bin/tsc'), '-p', 'tsconfig.test.json',
], { cwd: root, stdio: 'inherit' });
execFileSync(process.execPath, [resolve(root, 'scripts/fix-test-build.mjs')], {
cwd: root, stdio: 'inherit',
});
const { optimizePlans } = await import('../test-build/plan-optimizer.js');
const {
checkHiddenObstacles, checkMixedRoleRecords, checkWallKeys, checkReferences,
checkPhysicalGeometry, latticeProfile, readModel,
} = await import('../scripts/model-invariants.mjs');
const arg = (name, fallback) => {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
};
/**
* Известный долг фикстур, а не «должно быть ноль».
*
* `real-plan-second-floor.json` содержит перегородку `partition-mt2on9ou-0`,
* лежащую на наружной стене трёх комнат. #296 научил явный Optimize безопасно
* удалять этот объект, но исходная raw-фикстура намеренно не переписана: обход
* обязан отделять её исходный долг от НОВЫХ нарушений каждого жеста.
*/
const PLANS = [
{ file: 'real-plan-second-floor.json', debt: 1 },
{ file: 'real-plan-first-floor.json', debt: 0 },
];
/**
* Что обход находит СЕГОДНЯ. Не «допустимо», а «заведено и ждёт починки».
*
* Таблица работает в обе стороны: обход падает и когда находок стало больше, и
* когда стало меньше. Второе — не придирчивость. Молча позеленевший гейт не
* сообщает о починке, и долг перестаёт быть виден; ровно так исчез из вида
* `partition-mt2on9ou-0`, проживший в плане владельца от беты 9 до rc.1.
* Починили — обновить строку в этой таблице тем же коммитом.
*/
// #298 убрал off-grid/carrier producer Resize, а #299 — mixed-role producer
// Optimize/Delete-room. Поэтому все шесть 24-шаговых обходов теперь обязаны
// завершаться без нового нарушения; исходный fixture debt учтён отдельно выше.
const KNOWN = {};
const STEPS = Number(arg('--steps', 24));
const SEEDS = arg('--seed') ? [Number(arg('--seed'))] : [1, 2, 3];
/** mulberry32: короткий, воспроизводимый, без зависимостей. */
const rng = (seed) => () => {
seed = (seed + 0x6D2B79F5) | 0;
let t = seed;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const configOf = (file) => readModel(readFileSync(
new URL(`../test/fixtures/${file}`, import.meta.url), 'utf8',
));
/**
* Полный набор инвариантов по одному конфигу. Судит node, не страница.
*
* Про решётку судится ТОЛЬКО «вне сетки», а подшаговый шум остаётся
* наблюдением. Причина измерена, а не выбрана: координата пишется девятью
* знаками после запятой, и `304/240 = 1.2666666666…` в этой записи становится
* `1.266666667` — отклонение 8e-8 шага. Такой шум неустраним никаким исправлением
* жеста, он свойство формата хранения и уйдёт на этапе 1 ADR #282. Гейт,
* падающий на неустранимом, отключат в первую неделю.
*/
const inspect = (config, layout = {}) => {
const notes = [];
const lattice = latticeProfile({ config, layout });
return {
violations: [
...checkReferences({ config, layout }, { notes }),
...checkHiddenObstacles(config),
...checkMixedRoleRecords(config),
...checkWallKeys(config, { notes }),
...checkPhysicalGeometry(config),
],
noise: lattice.noise,
offGrid: lattice.offGrid,
};
};
/** Виды нарушений, которых стало больше, чем было в исходной фикстуре. */
const countKinds = (violations) => {
const counts = new Map();
for (const violation of violations) {
counts.set(violation.kind, (counts.get(violation.kind) || 0) + 1);
}
return counts;
};
const beyondBaseline = (violations, baseline) => {
const now = countKinds(violations);
return [...now]
.filter(([kind, count]) => count > (baseline.get(kind) || 0))
.map(([kind]) => kind)
.sort();
};
const { page, browser } = await launch();
const out = {};
const failures = [];
/** Установить конфиг в карту и включить разметку с инструментом ресайза. */
const install = (config) => page.evaluate(async (config) => {
const card = window.__card;
card._serverCfg = JSON.parse(JSON.stringify(config));
card._cfgEpoch = (card._cfgEpoch || 0) + 1;
card._modelCache = null;
card._space = config.spaces[0].id;
card._setMode?.('plan');
card._markup = true;
card._tool = 'resize';
card.requestUpdate();
await card.updateComplete;
card._fitAll?.();
card.requestUpdate();
await card.updateComplete;
return true;
}, config);
/** Перечислить ручки ресайза так, как их видит рендер слоя. */
const handles = () => page.evaluate(() => {
const card = window.__card;
const snap = card._rszSnapshot();
const rooms = card._rszRooms();
const list = [];
for (const room of rooms) {
for (let edge = 0; edge < room.poly.length; edge++) {
const a = room.poly[edge], b = room.poly[(edge + 1) % room.poly.length];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < card._gridPitch) continue;
const resolution = card._rszResolution(room.id, edge, snap);
list.push({
roomId: room.id, edge, enabled: !!resolution.enabled,
reason: resolution.enabled ? null : resolution.reason,
mid: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
});
}
}
return list;
});
/**
* Перетащить ручку на `steps` шагов решётки.
*
* Смещение задаётся в клиентских координатах, поэтому масштаб «клиент → SVG»
* измеряется у самой карты: подставлять свои цифры значило бы завести второе
* представление её геометрии.
*/
const dragHandle = (roomId, edge, mid, steps) => page.evaluate(
async ({ roomId, edge, mid, steps }) => {
const card = window.__card;
const at = (x, y) => card._svgPoint({ clientX: x, clientY: y });
const origin = at(0, 0), unitX = at(100, 0), unitY = at(0, 100);
const perClientX = (unitX[0] - origin[0]) / 100;
const perClientY = (unitY[1] - origin[1]) / 100;
if (!(Math.abs(perClientX) > 1e-9) || !(Math.abs(perClientY) > 1e-9)) return 'нет масштаба';
// Клиентская точка, попадающая в середину ребра.
const clientX = (mid[0] - origin[0]) / perClientX;
const clientY = (mid[1] - origin[1]) / perClientY;
const ev = (x, y) => ({
pointerId: 77, clientX: x, clientY: y,
target: card._stageEl, preventDefault() {}, stopPropagation() {},
});
card._rszEdgeDown(ev(clientX, clientY), roomId, edge);
if (!card._rszDrag) return 'жест не начался';
const plan = card._rszDrag.plan;
const shift = card._gridPitch * steps;
const dx = plan.n[0] * shift / perClientX;
const dy = plan.n[1] * shift / perClientY;
card._rszMove(ev(clientX + dx, clientY + dy));
const moved = !!card._rszDrag?.moved;
card._rszUp(ev(clientX + dx, clientY + dy));
card.requestUpdate();
await card.updateComplete;
return moved ? null : 'предпросмотр не сдвинулся';
}, { roomId, edge, mid, steps },
);
/** Удалить комнату с сохранением стен — путь, который и родил #296. */
const deleteRoom = (roomId) => page.evaluate(async (roomId) => {
const card = window.__card;
card._roomDeleteDialog = { roomId, name: roomId };
card._confirmRoomDelete(true);
card.requestUpdate();
await card.updateComplete;
return null;
}, roomId);
const currentConfig = () => page.evaluate(() => JSON.parse(JSON.stringify(window.__card._serverCfg)));
for (const plan of PLANS) {
const source = configOf(plan.file);
for (const seed of SEEDS) {
const random = rng(seed);
const trace = [];
let config = JSON.parse(JSON.stringify(source.config));
let broke = null;
const baseline = inspect(config);
baseline.counts = countKinds(baseline.violations);
if (baseline.violations.length !== plan.debt) {
failures.push({
plan: plan.file, seed, step: -1,
action: `объявленный долг фикстуры ${plan.debt}`
+ `, фактический ${baseline.violations.length}`,
violations: baseline.violations, trace: [],
});
out[`debt_${plan.file.replace(/[^a-z0-9]+/gi, '_')}`] = false;
continue;
}
await install(config);
for (let step = 0; step < STEPS && !broke; step++) {
const list = await handles();
const enabled = list.filter((handle) => handle.enabled);
const roll = random();
let action = null;
if (roll < 0.75 && enabled.length) {
const handle = enabled[Math.floor(random() * enabled.length)];
const steps = (random() < 0.5 ? -1 : 1) * (1 + Math.floor(random() * 4));
const note = await dragHandle(handle.roomId, handle.edge, handle.mid, steps);
action = `ресайз ${handle.roomId}#${handle.edge} на ${steps}${note ? ` (${note})` : ''}`;
} else if (roll < 0.9) {
const rooms = [...new Set(list.map((handle) => handle.roomId))];
if (!rooms.length) continue;
const roomId = rooms[Math.floor(random() * rooms.length)];
await deleteRoom(roomId);
action = `удаление комнаты ${roomId} с сохранением стен`;
} else {
const before = await currentConfig();
const result = optimizePlans(before, {}, {}, {});
await install(result.config);
action = `оптимизация (изменений: ${result.report.total})`;
}
trace.push(action);
config = await currentConfig();
const state = inspect(config);
const found = [...state.violations];
if (state.offGrid > baseline.offGrid) {
found.push({
kind: 'off_lattice_coordinate', owner: 'config',
reference: `вне сетки ${baseline.offGrid} → ${state.offGrid}`
+ ` (подшаговый шум ${baseline.noise} → ${state.noise})`,
detail: 'жест записал координату мимо решётки',
});
}
const fresh = beyondBaseline(found, baseline.counts);
if (fresh.length) broke = { step, action, violations: found, kinds: fresh };
}
const key = `${plan.file}:${seed}`;
const known = KNOWN[key] || null;
const label = `${plan.file.replace('real-plan-', '').replace('.json', '')}_seed${seed}`;
const asExpected = known
? !!broke && broke.step === known.step
&& JSON.stringify(broke.kinds) === JSON.stringify([...known.kinds].sort())
: !broke;
out[`walk_${label.replace(/[^a-z0-9]+/gi, '_')}`] = asExpected;
if (!asExpected) {
failures.push({
plan: plan.file, seed, trace,
step: broke ? broke.step : -1,
action: broke
? broke.action
: `находок нет, а объявлено: шаг ${known.step}, ${known.kinds.join(', ')}`
+ ' — если это починка, обновите KNOWN тем же коммитом',
violations: broke ? broke.violations : [],
});
mkdirSync(new URL('../artifacts/', import.meta.url), { recursive: true });
writeFileSync(
new URL(`../artifacts/edit-walk-${label}.json`, import.meta.url),
JSON.stringify(config, null, 1),
);
}
}
}
if (failures.length) {
console.error('\nОбход нашёл нарушения:');
for (const failure of failures) {
console.error(`\n ${failure.plan}, семя ${failure.seed}, шаг ${failure.step}: ${failure.action}`);
for (const violation of failure.violations.slice(0, 8)) {
console.error(` ${violation.kind} · ${violation.owner} · ${violation.reference}`);
}
console.error(' цепочка жестов:');
for (const [index, action] of failure.trace.entries()) {
console.error(` ${index}. ${action}`);
}
console.error(` конфиг сохранён: artifacts/edit-walk-${failure.plan}-seed${failure.seed}.json`);
}
}
checkAll(out);
await finish(browser, out);
+4 -2
View File
@@ -76,9 +76,11 @@ const res = await page.evaluate(async () => {
c._openSettingsDialog(); c._infoCard = c._devices[0]; await c.updateComplete;
await esc(); out.stacked = !c._infoCard && !!c._settingsDialog;
await esc(); out.stacked2 = !c._settingsDialog;
// Esc в разметке по-прежнему откатывает точку, а не только диалоги
// #294: once no dialog remains, Escape is still routed to the Plan tool.
// It finishes and detaches the accepted chain while keeping Walls armed;
// Ctrl/Cmd+Z, not Escape, owns one-point undo.
c._setMode('plan'); c._tool = 'draw'; c._path = [[1, 2], [3, 4]]; await c.updateComplete;
await esc(); out.undoPointStillWorks = c._path.length === 1;
await esc(); out.wallChainDetaches = c._path.length === 0 && c._tool === 'draw';
return out;
});
checkAll(res);
+7
View File
@@ -391,6 +391,13 @@ const isoDarkDiff = await pixelDiff(isoDark.reference.pixels, isoDark.detailed.p
const pixelEquivalent = (diff) => diff.sameSize
&& diff.changed <= 150 && diff.maxDelta <= 40 && diff.meanDelta <= 0.05;
// Diagnostic only: the raw diff numbers behind every pixel verdict, so a CI
// failure reports HOW FAR a pair drifted instead of a bare boolean (#302).
console.error('pixel-diffs', JSON.stringify({
flat: flatDiff, static: staticDiff, plan: planDiff, darkView: darkViewDiff,
devices: devicesDiff, background: backgroundDiff,
isoLight: isoLightDiff, isoDark: isoDarkDiff,
}));
const out = {
referenceFactorIsOne: referenceView.metrics.factor === 1,
detailedFactorIsFive: detailedView.metrics.factor === 5,
+104
View File
@@ -0,0 +1,104 @@
// Issue #302: the rendered wall body of the owner's repro covers the junction
// contract — every support strip and sector fan, clipped to the facade bound.
//
// The units own the geometry rules; this smoke owns the wiring: the same
// probes that the pure pipeline must cover are checked against the actual
// `d` attribute the card renders, so a regression anywhere between the
// geometry pass and the DOM turns it red.
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
import {
wallBodiesGeometry, wallIntervals, buildMultiWallNodeMap,
junctionNodeGeometry, junctionNodeBound, MITRE_LIMIT,
} from '../test-build/wall-thickness.js';
import { GRID_PITCH, GRID_STEP_N, NORM_W } from '../test-build/space-geometry.js';
const fixture = JSON.parse(readFileSync(
new URL('../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
));
const rooms = fixture.rooms.map((room) => ({
...room, poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
const map = buildMultiWallNodeMap(
wallIntervals(rooms, fixture.walls, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W)
.filter((iv) => !iv.open && iv.half > 0),
GRID_STEP_N * NORM_W * 0.04 * 4, NORM_W,
);
const bound = junctionNodeBound(
rooms, fixture.walls, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W, map,
);
const corners = junctionNodeGeometry(map);
const inPolygon = (points, x, y) => {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const [xi, yi] = points[i], [xj, yj] = points[j];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
};
const inGeometry = (geom, x, y) => {
let inside = false;
for (const poly of geom || []) for (const ring of poly || []) {
if (inPolygon(ring, x, y)) inside = !inside;
}
return inside;
};
// Contract probes around every node: strip or fan, inside the facade bound.
const probes = [];
for (const node of map.nodes) {
const radius = MITRE_LIMIT * node.halfDepth + node.halfDepth;
const step = GRID_PITCH * 0.25;
for (let dx = -radius; dx <= radius; dx += step) {
for (let dy = -radius; dy <= radius; dy += step) {
const x = node.point[0] + dx, y = node.point[1] + dy;
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
const rx = x - node.point[0], ry = y - node.point[1];
const along = rx * ray.u[0] + ry * ray.u[1];
if (along < 0 || along > support.length) return false;
return Math.abs(rx * ray.u[1] - ry * ray.u[0]) <= support.halfDepth - step * 0.25;
}));
const inFan = !inStrip && corners.fans.some((fan) => inPolygon(fan, x, y));
if (!inStrip && !inFan) continue;
if (bound && !inGeometry(bound, x, y)) continue;
probes.push([x, y]);
}
}
}
if (probes.length < 500) {
console.error(`FAIL: contract probe set degenerated to ${probes.length} points`);
process.exit(1);
}
const { page, browser } = await launch({ width: 1200, height: 1000 }, 1);
const res = await page.evaluate(async ({ sp, probes: probeList }) => {
const out = {};
const c = window.__card;
const settle = async () => {
for (let i = 0; i < 4; i++) await new Promise((r) => requestAnimationFrame(r));
await c.updateComplete;
};
sp.id = c._space;
c._serverCfg.spaces[c._serverCfg.spaces.findIndex((s) => s.id === c._space)] = sp;
c._cfgEpoch++; c.requestUpdate(); await settle();
await new Promise((r) => setTimeout(r, 600));
const united = c._wallUnionGeometry();
out.geometryProduced = !!united?.d;
const path = new Path2D(united.d);
const g = document.createElement('canvas').getContext('2d');
const holes = probeList.filter(([x, y]) =>
!g.isPointInPath(path, x, y, united.fillRule || 'evenodd'));
out.probes = probeList.length;
out.holes = holes.length;
out.holeSample = holes.slice(0, 5).map((p) => p.map((v) => +v.toFixed(1)));
out.rendered = !!c.shadowRoot.querySelector('.wallbody');
return out;
}, { sp: { ...JSON.parse(readFileSync(new URL('../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8')), rooms: fixture.rooms, title: 'Repro', view_box: [0, 0, 1, 1], settings: { show_borders: true } }, probes });
const verdict = {
geometryProduced: res.geometryProduced,
rendered: res.rendered,
enoughProbes: res.probes >= 500,
noContractHoles: res.holes === 0,
};
if (res.holes) console.error('дыры:', res.holeSample, 'из', res.holes);
checkAll(verdict);
await finish(browser, verdict);
+5 -2
View File
@@ -107,12 +107,15 @@ const result = await page.evaluate(async (source) => {
const canonical = card._wallUnionGeometry();
const node = new DOMPoint(source.node[0] * 1000, source.node[1] * 1000);
// Midpoint between the R-bounded straight bevel and the old 1.80×H mitre.
const discardedWedge = new DOMPoint(330.3808442725, 148.8560107825);
// #302: this point lies in the OVERLAP of two incident strips, and the
// chamfer is strip-safe now — it must stay filled (it used to be discarded,
// which is exactly the hole class #302 removed).
const retainedOverlap = new DOMPoint(330.3808442725, 148.8560107825);
out.fixtureLoaded = card._spaceModel()?.rooms.length === 2
&& card._spaceWalls.length === 7;
out.planUsesCanonicalPath = !!planD && canonical?.d === planD;
out.nodeRemainsFilled = !!path?.isPointInFill(node);
out.excessWedgeIsEmpty = path && !path.isPointInFill(discardedWedge);
out.stripOverlapRetained = !!path?.isPointInFill(retainedOverlap);
out.planHasNoEnclosedHoles = enclosedSvgHoles(path, node) === 0;
out.paperRemainsSolid = !!canonical?.paperD
&& !!root().querySelector('.hp-paper');
+129
View File
@@ -0,0 +1,129 @@
// #301: contacts and locks in the real opening dialog are searchable by both
// friendly name and entity_id without losing the resolver's priority order.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settle = async () => {
c.requestUpdate();
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
};
const search = async (kind, query) => {
const input = sr().querySelector(`[data-opening-panel="${kind}"] .opening-entity-search`);
input.value = query;
input.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await settle();
};
const rows = (kind) => [...sr().querySelectorAll(
`[data-opening-panel="${kind}"] .opening-entity-candidate[data-opening-entity]:not([data-opening-entity=""])`,
)];
c.hass = {
...c.hass,
states: {
...c.hass.states,
'binary_sensor.hp301_z_door': {
entity_id: 'binary_sensor.hp301_z_door', state: 'off',
attributes: { friendly_name: 'Prioritycheck Z door', device_class: 'door' },
},
'binary_sensor.hp301_a_motion': {
entity_id: 'binary_sensor.hp301_a_motion', state: 'off',
attributes: { friendly_name: 'Prioritycheck A motion', device_class: 'motion' },
},
'binary_sensor.hp301_window': {
entity_id: 'binary_sensor.hp301_window', state: 'off',
attributes: { friendly_name: 'Salon Contact', device_class: 'window' },
},
'lock.hp301_front': {
entity_id: 'lock.hp301_front', state: 'locked',
attributes: { friendly_name: 'HP301 Front Lock' },
},
},
};
const space = c._serverCfg.spaces.find((item) => item.id === 'f1');
space.openings = [{
id: 'hp301-door', type: 'door', x: 0.22, y: 0.14, angle: 0, length: 0.09,
}];
c._cfgEpoch++;
c._setMode('plan');
await settle();
const opening = c._openingsR.find((item) => item.type === 'door');
if (!opening) return { realDoorFixtureExists: false };
c._editOpening(opening);
await settle();
const result = {
realDoorFixtureExists: true,
bothPickersShown: sr().querySelectorAll('.opening-entity-drop').length === 2,
panelsInitiallyClosed: !sr().querySelector('.opening-entity-panel'),
};
sr().querySelector('[data-opening-picker="contact"]').click();
await settle();
result.openDoesNotForceFocus = !sr().querySelector('.opening-entity-search').matches(':focus');
await search('contact', 'prioritycheck');
const ordered = rows('contact');
result.contactOrderPreserved = ordered.length === 2
&& ordered[0].dataset.openingEntity === 'binary_sensor.hp301_z_door'
&& ordered[1].dataset.openingEntity === 'binary_sensor.hp301_a_motion';
result.rowsExplainEntityId = ordered.every((row) =>
row.querySelector('.cs')?.textContent === row.dataset.openingEntity);
await search('contact', 'binary_sensor.hp301_window');
const byId = rows('contact');
result.searchByEntityId = byId.length === 1
&& byId[0].querySelector('.cl')?.textContent === 'Salon Contact';
byId[0].click();
await settle();
result.contactSelectedAndClosed = c._openingDialog.contact === 'binary_sensor.hp301_window'
&& !sr().querySelector('[data-opening-panel="contact"]');
result.closedButtonExplainsEntityId = sr().querySelector('[data-opening-picker="contact"] .ref')
?.textContent === 'binary_sensor.hp301_window';
sr().querySelector('[data-opening-picker="contact"]').click();
await settle();
await search('contact', 'no such hp301 entity');
const contactPanel = sr().querySelector('[data-opening-panel="contact"]');
const firstChoice = contactPanel.querySelector('.candlist > .opening-entity-candidate');
result.emptyStateKeepsNoneFirst = firstChoice?.dataset.openingEntity === ''
&& !!contactPanel.querySelector('.opening-entity-empty');
firstChoice.click();
await settle();
result.noneClearsContact = c._openingDialog.contact === ''
&& !sr().querySelector('[data-opening-panel="contact"]');
sr().querySelector('[data-opening-picker="contact"]').click();
await settle();
await search('contact', 'hp301_window');
rows('contact')[0].click();
await settle();
sr().querySelector('[data-opening-picker="lock"]').click();
await settle();
await search('lock', 'front lock');
const lockRows = rows('lock');
result.lockSearchWorks = lockRows.length === 1
&& lockRows[0].dataset.openingEntity === 'lock.hp301_front';
lockRows[0].click();
await settle();
result.lockSelectedAndClosed = c._openingDialog.lock === 'lock.hp301_front'
&& !sr().querySelector('[data-opening-panel="lock"]');
sr().querySelector('.dialog-action-commit .btn.on').click();
await settle();
const saved = c._curSpaceCfg.openings.find((item) => item.id === opening.id);
result.savedSameFields = saved?.contact === 'binary_sensor.hp301_window'
&& saved?.lock === 'lock.hp301_front';
result.dialogClosedAfterSave = !c._openingDialog;
c._editOpening(c._openingsR.find((item) => item.id === opening.id));
c._openingDialog = { ...c._openingDialog, type: 'passage' };
await settle();
result.passageStillHasNoBindingPickers = !sr().querySelector('.opening-entity-drop');
c._openingDialog = null;
return result;
});
await finish(browser, checkAll(out));
+21 -12
View File
@@ -1,5 +1,6 @@
// #198: explicit Optimize removes only a proven isolated wall-thickness
// micro-interval. Exercise the production bundle's Preview → Apply → Undo flow.
// #198/#299: explicit Optimize removes only a proven isolated wall-thickness
// micro-interval without compacting across a physical wall-owner role boundary.
// Exercise the production bundle's Preview → Apply → Reload → Undo flow.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 });
@@ -40,6 +41,19 @@ const out = await page.evaluate(async () => {
}],
markers: [], settings: {},
};
const expectedRuns = [
[0.8, 203 / 240],
[203 / 240, 213 / 240],
[213 / 240, 0.95],
];
const hasRoleAwareCanonicalRuns = (walls) => {
if (walls.length !== expectedRuns.length || walls.some((wall) => wall.cm !== 22)) return false;
const runs = walls.map((wall) => {
if (!wall.a || !wall.b || wall.a[1] !== canonicalY || wall.b[1] !== canonicalY) return null;
return [Math.min(wall.a[0], wall.b[0]), Math.max(wall.a[0], wall.b[0])];
}).filter(Boolean).sort((left, right) => left[0] - right[0]);
return JSON.stringify(runs) === JSON.stringify(expectedRuns);
};
const clone = (value) => JSON.parse(JSON.stringify(value));
let serverConfig = clone(original), serverLayout = {}, backup = null;
const sent = [];
@@ -71,10 +85,9 @@ const out = await page.evaluate(async () => {
card._openAlignDialog(); await card.updateComplete;
const preview = card._alignDialog;
result.previewOffersChange = !!preview?.changed
&& preview.report.canonicalized === 1 && preview.report.wallsMerged === 2
&& preview.config.spaces[0].walls.length === 1
&& preview.config.spaces[0].walls[0].cm === 22;
result.previewOffersRoleAwareChange = !!preview?.changed
&& preview.report.canonicalized === 1 && preview.report.wallsMerged === 0
&& hasRoleAwareCanonicalRuns(preview.config.spaces[0].walls);
result.previewDoesNotWrite = sent.length === 0
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
result.previewIsVisible = !!card.renderRoot.querySelector('hp-dialog .alignmsg');
@@ -87,16 +100,12 @@ const out = await page.evaluate(async () => {
await card._runAlignToGrid(); await card.updateComplete;
const appliedWalls = card._serverCfg.spaces[0].walls;
result.applyUsesOneAtomicWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
result.applyStoresOneUniformRun = appliedWalls.length === 1 && appliedWalls[0].cm === 22
&& JSON.stringify(appliedWalls[0].a) === JSON.stringify([0.8, canonicalY])
&& JSON.stringify(appliedWalls[0].b) === JSON.stringify([0.95, canonicalY]);
result.applyStoresRoleAwareRuns = hasRoleAwareCanonicalRuns(appliedWalls);
result.applyEnablesUndo = card._canOptimizeUndo === true;
await card._loadFromServer(); await card.updateComplete;
const reloadedWalls = card._serverCfg.spaces[0].walls;
result.reloadKeepsCanonicalRun = reloadedWalls.length === 1 && reloadedWalls[0].cm === 22
&& JSON.stringify(reloadedWalls[0].a) === JSON.stringify([0.8, canonicalY])
&& JSON.stringify(reloadedWalls[0].b) === JSON.stringify([0.95, canonicalY]);
result.reloadKeepsRoleAwareRuns = hasRoleAwareCanonicalRuns(reloadedWalls);
await card._undoPlanOptimization(); await card.updateComplete;
result.undoUsesServerSnapshot = sent.filter((type) => type === 'houseplan/plan/optimize_undo').length === 1;
+11 -1
View File
@@ -15,7 +15,8 @@ const out = await page.evaluate(async () => {
}, {
// Keep `home` non-final so this fixture proves the normal blocker; the
// final-space safe-detach exception is covered by #113's lifecycle smoke.
id: 'other', title: 'Other', view_box: [0, 0, 1, 1], cell_cm: 5, rooms: [],
id: 'space_space_other_deadbeef_cafebabe', title: 'Other',
view_box: [0, 0, 1, 1], cell_cm: 5, rooms: [],
}],
markers: [
{
@@ -28,6 +29,10 @@ const out = await page.evaluate(async () => {
id: 'home-blocker', binding: 'virtual', space: 'home', name: 'Home blocker',
},
{ id: 'removed-marker', binding: 'virtual', removed: true, space: 'gone', name: 'Old marker' },
{
id: 'lineage-marker', binding: 'virtual',
space: 'space_other_11111111', name: 'Nested lineage marker',
},
],
settings: { known_devices: ['removed-auto-device'] },
};
@@ -38,6 +43,7 @@ const out = await page.evaluate(async () => {
'removed-marker': { s: 'gone', x: 0.3, y: 0.4 },
'removed-auto-device': { s: 'gone', x: 0.4, y: 0.5 },
'lg_light.removed_group': { s: 'gone', x: 0.5, y: 0.6 },
'lineage-marker': { s: 'space_other_11111111', x: 0.7, y: 0.8 },
};
let serverConfig = clone(original);
let serverLayout = clone(originalLayout);
@@ -114,6 +120,10 @@ const out = await page.evaluate(async () => {
&& preview.layout['removed-marker'] === undefined
&& preview.layout['removed-auto-device'] === undefined
&& preview.layout['lg_light.removed_group'] === undefined;
const lineageMarker = preview?.config.markers.find((marker) => marker.id === 'lineage-marker');
result.nestedImportLineageRepairsToOneCanonicalOwner = lineageMarker?.space
=== 'space_space_other_deadbeef_cafebabe'
&& preview.layout['lineage-marker']?.s === 'space_space_other_deadbeef_cafebabe';
const details = previewBody?.querySelector('details.optimize-details');
result.idsExistOnlyInClosedDetails = details && details.open === false
&& details.textContent.includes('opaque_owner') && details.textContent.includes('gone');
+115 -2
View File
@@ -228,12 +228,125 @@ const out = await page.evaluate(async () => {
partitions: card._curSpaceCfg.partitions,
}) === gestureGeometry;
card._curSpaceCfg.partitions.push({
id: 'hidden-partition', a: [0.1, 0.1], b: [0.5, 0.1], cm: 15,
});
card._modelCache = null;
card._cfgEpoch++;
card._activateMarkupTool('select');
await update();
result.otherPlanToolsHaveNoOverlay = !overlay();
const diagnostic = root().querySelector('[data-hp="hidden-wall-diagnostic"]');
result.otherPlanToolsKeepFullOverlayAndHiddenDiagnostic = !!overlay() && !!diagnostic
&& diagnostic.querySelectorAll('.hidden-wall-line').length === 1
&& diagnostic.querySelectorAll('.hidden-wall-node').length === 2;
const editorVirtualWalls = root().querySelector('.openwalls');
result.hiddenDiagnosticAboveAllWallBodies = !!diagnostic && !!wallBodies
&& !!(wallBodies.compareDocumentPosition(diagnostic) & Node.DOCUMENT_POSITION_FOLLOWING)
&& (!editorVirtualWalls
|| !!(editorVirtualWalls.compareDocumentPosition(diagnostic) & Node.DOCUMENT_POSITION_FOLLOWING));
result.hiddenDiagnosticIsPointerTransparent = diagnostic?.getAttribute('pointer-events') === 'none'
&& getComputedStyle(diagnostic).pointerEvents === 'none';
card._activateMarkupTool('draw');
await update();
const drawDiagnostic = root().querySelector('[data-hp="hidden-wall-diagnostic"]');
const drawSnap = overlay();
result.drawToolKeepsHiddenDiagnosticAndSnapOverlay = !!drawDiagnostic && !!drawSnap
&& drawDiagnostic.querySelectorAll('.hidden-wall-line').length === 1
&& drawDiagnostic.querySelectorAll('.hidden-wall-node').length === 2;
result.hiddenDiagnosticPaintsBeforeTransientSnap = !!(
drawDiagnostic.compareDocumentPosition(drawSnap) & Node.DOCUMENT_POSITION_FOLLOWING
);
// #304 regression fixture: one room plus two independent walls which overlap
// its left/bottom axes and continue beyond them. The six source endpoints are
// the exact topology from the field report, reduced to deterministic data.
const parityCfg = {
model_version: 7,
spaces: [{
id: 'axis-parity', title: 'Axis parity', cell_cm: 5, view_box: [0, 0, 1, 0.75],
rooms: [{
id: 'room', name: 'Room', area: null,
poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.6], [0.2, 0.6]],
}],
room_drafts: [], openings: [], open_spans: [],
partitions: [
{ id: 'vertical', a: [0.2, 0.05], b: [0.2, 0.6], cm: 20 },
{ id: 'horizontal', a: [0.2, 0.6], b: [0.9, 0.6], cm: 20 },
],
settings: { fill_mode: 'none', show_borders: true, show_names: false },
}],
markers: [], settings: {},
};
card._serverCfg = JSON.parse(JSON.stringify(parityCfg));
card._layout = {};
card._space = 'axis-parity';
card._modelCache = null;
card._frame = null;
card._activeDraftId = null;
card._path = [];
card._cfgEpoch++;
card._setMode('plan');
await update();
const staticSnapshot = () => {
const current = overlay();
return JSON.stringify({
lines: [...current.querySelectorAll('.plan-snap-line')].map((line) => [
line.getAttribute('data-key'), line.getAttribute('data-source-kind'),
line.getAttribute('x1'), line.getAttribute('y1'),
line.getAttribute('x2'), line.getAttribute('y2'),
]),
endpoints: [...current.querySelectorAll('.plan-snap-node[data-kind="endpoint"]')]
.map((node) => [
node.getAttribute('data-key'), node.getAttribute('cx'), node.getAttribute('cy'),
]),
});
};
const tools = [
'select', 'draw', 'column', 'merge', 'split', 'resize',
'opening', 'boundary', 'wallthick', 'delroom',
];
let expectedSnapshot = null;
let everyToolMatches = true;
let everyToolIsPassive = true;
let everyToolPaintsAboveWalls = true;
let thicknessHasSixNodes = false;
for (const tool of tools) {
card._tool = tool;
card._clearPlanSnapHover();
await update();
const current = overlay();
const bodies = root().querySelector('.wallbodies');
const snapshot = current ? staticSnapshot() : null;
if (expectedSnapshot == null) expectedSnapshot = snapshot;
everyToolMatches &&= !!current && snapshot === expectedSnapshot;
everyToolIsPassive &&= current?.getAttribute('pointer-events') === 'none'
&& getComputedStyle(current).pointerEvents === 'none'
&& !current.querySelector('.plan-snap-node[data-active="true"]');
everyToolPaintsAboveWalls &&= !!bodies && !!current
&& !!(bodies.compareDocumentPosition(current) & Node.DOCUMENT_POSITION_FOLLOWING);
if (tool === 'wallthick') {
thicknessHasSixNodes = current.querySelectorAll(
'.plan-snap-node[data-kind="endpoint"]',
).length === 6 && current.querySelectorAll('.plan-snap-line').length === 6;
}
}
result.allPlanToolsShareStaticAxesAndNodes = everyToolMatches;
result.allPlanToolOverlaysStayPointerTransparent = everyToolIsPassive;
result.allPlanToolOverlaysPaintAboveWallBodies = everyToolPaintsAboveWalls;
result.thicknessShowsCompleteSixNodeFixture = thicknessHasSixNodes;
card._setMode('devices');
await update();
const devicesHaveNoPlanOverlay = !overlay();
card._setMode('decor');
await update();
const decorHasNoPlanOverlay = !overlay();
card._setMode('view');
await update();
result.viewHasNoOverlay = !overlay();
result.nonPlanModesHaveNoOverlay = devicesHaveNoPlanOverlay && decorHasNoPlanOverlay
&& !overlay()
&& !root().querySelector('[data-hp="hidden-wall-diagnostic"]');
return result;
});
+8 -5
View File
@@ -70,16 +70,19 @@ const res = await page.evaluate(async () => {
out.noCentrelineSpan = !!spans
&& !spans.some((s) => Math.abs(s - 300) < 0.5 || Math.abs(s - 400) < 0.5);
// The dragged-edge bubble, called the way `_rszLive` calls it.
// The two useful side-wall bubbles, called the way `_rszLive` calls them.
c._rszSel = 'r233';
c._rszDrag = { rooms: c._spaceModel().rooms.map((r) => ({ id: r.id, poly: r.poly })) };
await upd();
const drag = c._rszEdgeLabels({ polys: { r233: render } }, { roomId: 'r233', edge: 0 });
const dragLens = drag.filter((l) => !l.area).map((l) => num(l.text));
out.dragLabelsThreeEdges = dragLens.length === 3;
const drag = c._rszEdgeLabels(
{ polys: { r233: render } },
{ roomId: 'r233', edge: 0, roomIds: ['r233'], edgeByRoom: { r233: 0 } },
);
const dragLens = drag.filter((l) => l.kind === 'length').map((l) => num(l.text));
out.dragLabelsTwoSideEdges = dragLens.length === 2;
out.dragLabelsInner = dragLens.every((v) => Math.abs(v - 2.85) < 0.02 || Math.abs(v - 3.85) < 0.02);
out.dragLabelsNotCentreline = !dragLens.some((v) => Math.abs(v - 3) < 0.005 || Math.abs(v - 4) < 0.005);
const dragArea = drag.find((l) => l.area);
const dragArea = drag.find((l) => l.kind === 'area');
out.dragAreaStillInner = !!dragArea && Math.abs(num(dragArea.text) - 11) < 0.06;
out.legacyScaleLabelsAbsent = typeof c._rszScaleLabels === 'undefined';
+146
View File
@@ -0,0 +1,146 @@
// #300: the production Resize gesture renders two side-wall measurements,
// matching highlights and one area badge per owner beside the moving wall.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const card = window.__card;
const out = {};
const update = async () => {
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const rectRoom = (id, x0, y0, x1, y1) => ({
id, name: id, area: null,
poly: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
.map(([x, y]) => [x / 1000, y / 1000]),
});
card._setMode('plan');
card._tool = 'resize';
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
space.rooms = [
rectRoom('label-left', 200, 100, 300, 500),
rectRoom('label-right', 300, 100, 400, 500),
];
space.openings = [];
delete space.walls;
delete space.open_spans;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
card._rszEligibilityCache = null;
await update();
const handle = [...card.renderRoot.querySelectorAll('.rszhandle')]
.find((node) => Math.abs(Number(node.getAttribute('cx')) - 300) < 1
&& Math.abs(Number(node.getAttribute('cy')) - 300) < 1
&& node.getAttribute('aria-disabled') === 'false');
const stage = card.renderRoot.querySelector('.stage');
const stageRect = stage.getBoundingClientRect();
const svg = stage.querySelector('svg');
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
const screen = (x, y) => [
stageRect.left + ((x - vx) / vw) * stageRect.width,
stageRect.top + ((y - vy) / vh) * stageRect.height,
];
const [sx, sy] = screen(300, 300);
const [tx] = screen(325, 300);
const dispatch = (type, x, y) => handle?.dispatchEvent(new PointerEvent(type, {
bubbles: true, cancelable: true, pointerId: 300, pointerType: 'mouse',
clientX: x, clientY: y, buttons: type === 'pointerup' ? 0 : 1,
}));
dispatch('pointerdown', sx, sy);
dispatch('pointermove', tx, sy);
await update();
const live = card._rszLive || [];
const lengths = live.filter((label) => label.kind === 'length');
const areas = live.filter((label) => label.kind === 'area');
out.dragStarted = !!card._rszDrag;
out.twoLengths = lengths.length === 2;
out.movingWallLengthAbsent = lengths.every((label) =>
Math.abs(label.edge.a[0] - label.edge.b[0]) > 1);
out.twoMeasuredEdges = card.renderRoot.querySelectorAll(
'[data-hp="resize-measured-edge"]',
).length === 2;
out.twoAreas = card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]').length === 2;
out.twoLeaders = card.renderRoot.querySelectorAll('[data-hp="resize-area-leader"]').length === 2;
out.oppositeSides = new Set(areas.map((label) => label.placement.side)).size === 2;
out.gearVisible = card.renderRoot.querySelectorAll('[data-hp="room-settings"]').length === 2;
const areaRects = [...card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]')]
.map((label) => label.getBoundingClientRect());
out.areasDoNotOverlap = areaRects.length === 2 && (
areaRects[0].right <= areaRects[1].left || areaRects[0].left >= areaRects[1].right
|| areaRects[0].bottom <= areaRects[1].top || areaRects[0].top >= areaRects[1].bottom
);
out.gearAvoided = areas.every((area) => {
const label = card.renderRoot.querySelector(
`[data-hp="resize-area-label"][data-room="${area.roomId}"]`,
);
const gear = card.renderRoot.querySelector(
`[data-hp="room-settings"][data-room="${area.roomId}"]`,
);
if (!label || !gear) return false;
const a = label.getBoundingClientRect();
const b = gear.getBoundingClientRect();
return a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom;
});
out.tangentAvoidanceUsed = areas.some((label) => Math.abs(label.placement.tangentOffsetPx) > 0);
dispatch('pointerup', tx, sy);
await update();
out.cleanedAfterCommit = card._rszLive === null
&& card.renderRoot.querySelectorAll('[data-hp^="resize-"]').length === 0;
// Repeat the actual browser gesture at a non-default zoom. The footprint is
// recomputed from current view.w/iconCqw rather than reusing the first frame.
space.rooms = [
rectRoom('label-left', 200, 100, 300, 500),
rectRoom('label-right', 300, 100, 400, 500),
];
card._rszEligibilityCache = null;
card._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 2);
await update();
const zoomHandle = [...card.renderRoot.querySelectorAll('.rszhandle')]
.find((node) => Math.abs(Number(node.getAttribute('cx')) - 300) < 1
&& Math.abs(Number(node.getAttribute('cy')) - 300) < 1
&& node.getAttribute('aria-disabled') === 'false');
const zoomStageRect = stage.getBoundingClientRect();
const zoomSvg = stage.querySelector('svg');
const [zvx, zvy, zvw, zvh] = zoomSvg.getAttribute('viewBox').split(' ').map(Number);
const zoomScreen = (x, y) => [
zoomStageRect.left + ((x - zvx) / zvw) * zoomStageRect.width,
zoomStageRect.top + ((y - zvy) / zvh) * zoomStageRect.height,
];
const [zsx, zsy] = zoomScreen(300, 300);
const [ztx] = zoomScreen(325, 300);
const zoomDispatch = (type, x, y) => zoomHandle?.dispatchEvent(new PointerEvent(type, {
bubbles: true, cancelable: true, pointerId: 301, pointerType: 'mouse',
clientX: x, clientY: y, buttons: type === 'pointerup' ? 0 : 1,
}));
zoomDispatch('pointerdown', zsx, zsy);
zoomDispatch('pointermove', ztx, zsy);
await update();
const zoomAreas = [...card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]')];
out.nonDefaultZoomActive = card._zoom > 1 && zoomAreas.length === 2;
out.gearAvoidedAtNonDefaultZoom = zoomAreas.every((label) => {
const gear = card.renderRoot.querySelector(
`[data-hp="room-settings"][data-room="${label.getAttribute('data-room')}"]`,
);
if (!gear) return false;
const a = label.getBoundingClientRect();
const b = gear.getBoundingClientRect();
return a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom;
});
zoomDispatch('pointercancel', ztx, zsy);
await update();
out.cleanedAfterZoomCancel = card._rszLive === null;
return out;
});
checkAll(result);
await finish(browser);
+47
View File
@@ -96,6 +96,53 @@ const res = await page.evaluate(async () => {
rooms: sp().rooms, walls: sp().walls, openings: sp().openings,
}) === before;
}
// #298: an affected key-only record has no endpoints with which to prove a
// partial mapping. The handle remains eligible, but the runtime candidate
// must fail closed before preview, history and config/set.
sp().rooms = [
{
id: 'legacy-left', name: 'Legacy left',
poly: [[0.08, 0.12], [0.50, 0.12], [0.50, 0.55], [0.08, 0.55]],
},
{
id: 'legacy-right', name: 'Legacy right',
poly: [[0.50, 0.12], [0.92, 0.12], [0.92, 0.55], [0.50, 0.55]],
},
];
sp().walls = [{ key: '0.250000,0.120833@0.0000', cm: 22 }];
sp().openings = [];
delete sp().open_spans;
await upd();
const legacyBefore = JSON.stringify({ rooms: sp().rooms, walls: sp().walls });
const legacyHistoryBefore = c._geometryHistory?.size || 0;
const originalCallWS = c.hass.callWS.bind(c.hass);
let legacyWrites = 0;
c.hass.callWS = async (message) => {
if (message.type === 'houseplan/config/set') legacyWrites++;
return originalCallWS(message);
};
const legacyHandle = [...sr().querySelectorAll('.rszhandle')].find((entry) =>
entry.getAttribute('aria-disabled') === 'false'
&& Math.abs(+entry.getAttribute('cx') - 500) < 0.5
&& Math.abs(+entry.getAttribute('cy') - 335) < 0.5);
out.legacyHandleEnabled = !!legacyHandle;
if (legacyHandle) {
const x = +legacyHandle.getAttribute('cx');
const y = +legacyHandle.getAttribute('cy');
pointer('pointerdown', legacyHandle, x, y);
pointer('pointermove', legacyHandle, x + c._gridPitch, y);
await c.updateComplete;
out.legacyPreviewRejected = !!c._rszDrag && !c._rszPreview;
pointer('pointerup', legacyHandle, x + c._gridPitch, y);
await c.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 650));
out.legacyNoHistory = (c._geometryHistory?.size || 0) === legacyHistoryBefore;
out.legacyNoConfigWrite = legacyWrites === 0;
out.legacySourceUnchanged = JSON.stringify({ rooms: sp().rooms, walls: sp().walls })
=== legacyBefore;
}
return out;
});
+98 -11
View File
@@ -9,6 +9,24 @@ const result = await page.evaluate(async () => {
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const space = () => card._serverCfg.spaces[0];
const near = (actual, expected) => Math.abs(actual - expected) <= 1e-9;
const keyDown = async (key, init = {}) => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key, bubbles: true, cancelable: true, ...init,
}));
await update();
};
const clickStage = async (x, y) => {
const stage = root().querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
stage.dispatchEvent(new MouseEvent('click', {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true, cancelable: true,
}));
await update();
};
const resetGeometry = async (rooms = []) => {
const current = space();
current.rooms = JSON.parse(JSON.stringify(rooms));
@@ -24,9 +42,14 @@ const result = await page.evaluate(async () => {
card._wallFaceBatch = null;
card._roomDialog = false;
card._tool = 'draw';
card._cursorPt = null;
card._suppressClick = false;
card._geometryHistory.clear();
delete card._resumeDraftBySpace[card._space];
card._clearPlanSnapHover();
await update();
};
const draw = async (points) => {
const draw = async (points, cms = []) => {
card._tool = 'draw';
card._path = [[...points[0]]];
card._activeDraftId = null;
@@ -34,7 +57,7 @@ const result = await page.evaluate(async () => {
for (let i = 1; i < points.length; i++) {
const before = card._path.map((point) => [...point]);
card._path = [...card._path, [...points[i]]];
card._draftSegmentCms = [...card._draftSegmentCms, card._drawWallCm];
card._draftSegmentCms = [...card._draftSegmentCms, cms[i - 1] ?? card._drawWallCm];
card._persistActiveDraftSegment();
card._offerWallFaces(before);
await update();
@@ -74,6 +97,64 @@ const result = await page.evaluate(async () => {
&& space().partitions.every((partition, index) => partition.cm === cms[index]);
out.finishedChainDoesNotResume = !card._activeDraftId && card._path.length === 0;
// #294: Escape owns the registered window keyboard path, finishes exactly
// like a tool change and leaves Walls armed for an independent next chain.
await resetGeometry();
await draw([[100, 100], [300, 100], [300, 250]], [12, 23]);
await keyDown('Escape');
const escapedPartitions = space().partitions || [];
out.escapeFinishesWithoutDeletingSegments = card._tool === 'draw'
&& card._path.length === 0 && !card._activeDraftId && !space().room_drafts
&& !card._resumeDraftBySpace[card._space]
&& escapedPartitions.length === 2
&& escapedPartitions.some((part) => part.cm === 12)
&& escapedPartitions.some((part) => part.cm === 23)
&& card._geometryHistory.undoName === card._t('history.wall_chain_finish')
&& !root().querySelector('.active-axis') && !root().querySelector('.active-vertex');
const escapedGeometry = JSON.stringify(space());
const escapedHistory = card._geometryHistory.undoName;
await keyDown('Escape');
out.repeatedEscapeAfterFinishIsNoop = JSON.stringify(space()) === escapedGeometry
&& card._geometryHistory.undoName === escapedHistory
&& card._tool === 'draw' && card._path.length === 0;
await clickStage(500, 500);
out.nextClickStartsIndependentChain = card._path.length === 1
&& near(card._path[0][0], 500) && near(card._path[0][1], 500)
&& !space().room_drafts && (space().partitions || []).length === 2;
await clickStage(700, 500);
const independentDraft = space().room_drafts?.[0];
out.secondClickCreatesOnlyIndependentSegment = card._path.length === 2
&& independentDraft?.points?.length === 2
&& near(independentDraft.points[0][0], 0.5) && near(independentDraft.points[0][1], 0.5)
&& near(independentDraft.points[1][0], 0.7) && near(independentDraft.points[1][1], 0.5)
&& (space().partitions || []).length === 2;
await resetGeometry();
const singlePointGeometry = JSON.stringify(space());
const singlePointHistory = card._geometryHistory.undoName;
await clickStage(200, 200);
const onePointWasArmed = card._path.length === 1 && !space().room_drafts;
await keyDown('Escape');
out.escapeClearsOnlyTransientFirstPoint = onePointWasArmed
&& card._tool === 'draw' && card._path.length === 0 && !card._activeDraftId
&& JSON.stringify(space()) === singlePointGeometry
&& card._geometryHistory.undoName === singlePointHistory;
await resetGeometry();
await draw([[100, 100], [300, 100], [300, 250]], [12, 23]);
const rejectedPath = JSON.stringify(card._path);
const rejectedDraft = JSON.stringify(space().room_drafts);
const rejectedHistory = card._geometryHistory.undoName;
const finishWallChain = card._finishWallChain;
card._finishWallChain = () => false;
await keyDown('Escape');
card._finishWallChain = finishWallChain;
out.rejectedFinishKeepsActiveDraft = JSON.stringify(card._path) === rejectedPath
&& JSON.stringify(space().room_drafts) === rejectedDraft
&& card._geometryHistory.undoName === rejectedHistory
&& card._activeDraftId === space().room_drafts?.[0]?.id
&& !(space().partitions?.length);
await resetGeometry();
await draw([[100, 100], [300, 100], [300, 300], [100, 300], [100, 100]]);
out.closedFaceOpensDialog = card._roomDialog && card._wallFaceBatch?.candidates.length === 1;
@@ -93,19 +174,25 @@ const result = await page.evaluate(async () => {
out.cancelKeepsWholeTerminalDraft = !card._roomDialog && !card._wallFaceBatch
&& JSON.stringify(space().room_drafts) === draftBeforeCancel
&& card._path.length === 5;
card._onKey(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true,
}));
await update();
out.escapeUndoContractStillRemovesLastSegment = space().room_drafts?.[0]?.segments.length === 3
await keyDown('z', { code: 'KeyZ', ctrlKey: true });
out.ctrlZContractStillRemovesLastSegment = space().room_drafts?.[0]?.segments.length === 3
&& card._path.length === 4;
await resetGeometry();
await draw([[500, 100], [700, 100], [700, 300], [500, 300], [500, 100]]);
const dialogDraft = JSON.stringify(space().room_drafts);
await keyDown('Escape');
out.firstEscapeOnlyCancelsRoomDialog = !card._roomDialog && !card._wallFaceBatch
&& JSON.stringify(space().room_drafts) === dialogDraft
&& card._path.length === 5 && !(space().partitions?.length);
await keyDown('Escape');
out.secondEscapeFinishesRestoredDraft = !space().room_drafts
&& card._path.length === 0 && !card._activeDraftId
&& space().partitions?.length === 4 && card._tool === 'draw';
await resetGeometry();
await draw([[100, 450], [300, 450], [300, 650], [100, 650], [100, 450]]);
card._onKey(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true,
}));
await update();
await keyDown('z', { code: 'KeyZ', ctrlKey: true });
out.ctrlZInQueueRemovesTerminalPoint = !card._roomDialog && !card._wallFaceBatch
&& card._path.length === 4 && space().room_drafts?.[0]?.segments.length === 3;
+87
View File
@@ -0,0 +1,87 @@
// #303: the Thickness-tool hover fill follows physical wall centimetres while
// its deliberately generous pointer hit radius remains unchanged.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 860 }, 1);
const result = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const settle = async (structural = false) => {
if (structural) {
card._cfgEpoch++;
card._modelCache = null;
card._frame = null;
card._wallUnionCache = null;
}
card.requestUpdate();
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
await frame();
};
const currentSpace = () => card._serverCfg.spaces.find((space) => space.id === card._space);
const scanWidth = (path, y, fromX, toX, step = 0.01) => {
if (!path?.isPointInFill) return NaN;
let first = NaN;
let last = NaN;
for (let x = fromX; x <= toX; x += step) {
if (!path.isPointInFill(new DOMPoint(x, y))) continue;
if (!Number.isFinite(first)) first = x;
last = x;
}
return Number.isFinite(first) && Number.isFinite(last) ? last - first + step : NaN;
};
const closeRelative = (actual, expected, tolerance = 0.02) =>
Number.isFinite(actual) && Number.isFinite(expected) && expected > 0
&& Math.abs(actual - expected) / expected <= tolerance;
const space = currentSpace();
space.cell_cm = 30;
space.settings = { ...(space.settings || {}), show_borders: true };
delete space.walls;
delete space.openings;
delete space.open_spans;
for (const room of space.rooms || []) delete room.open_to;
card._setMode('plan');
card._tool = 'wallthick';
await settle(true);
const wallAxis = [50, 250];
card._wallThickClick(wallAxis);
await settle();
const dialogOpened = !!card._wallDialog;
card._wallDialog = { ...card._wallDialog, value: '50' };
card._wallThickApply(false);
await settle(true);
card._tool = 'wallthick';
card._cursorPt = wallAxis;
await settle();
const body = root().querySelector('[data-hp="wall"]');
const hover = root().querySelector('.wallthick-hover');
const bodyWidth = scanWidth(body, wallAxis[1], 25, 75);
const hoverWidth = scanWidth(hover, wallAxis[1], 25, 75);
const expectedWidth = (50 / 30) * card._gridPitch;
const axisHit = card._wallThickHit(wallAxis);
const dx = axisHit ? axisHit.b[0] - axisHit.a[0] : 0;
const dy = axisHit ? axisHit.b[1] - axisHit.a[1] : 0;
const length = Math.hypot(dx, dy);
const midpoint = axisHit
? [(axisHit.a[0] + axisHit.b[0]) / 2, (axisHit.a[1] + axisHit.b[1]) / 2]
: wallAxis;
const farHit = length > 0 && card._wallThickHit([
midpoint[0] - (dy / length) * card._gridPitch * 5,
midpoint[1] + (dx / length) * card._gridPitch * 5,
]);
return {
dialogOpened,
bodyAndHoverRendered: !!body && !!hover,
bodyIsPhysicalWidth: closeRelative(bodyWidth, expectedWidth),
hoverMatchesWallWithinTwoPercent: closeRelative(hoverWidth, bodyWidth),
generousHitAreaUnchanged: !!farHit,
};
});
await finish(browser, checkAll(result));
+607 -478
View File
File diff suppressed because one or more lines are too long
+30 -7
View File
@@ -540,6 +540,12 @@ The controller rebuilds every live candidate from one immutable
`SpaceGeometryState`. `rekeyWallsAfterMove()` and
`rekeyOpenSpansAfterMove()` map exact wall-owned records into that overlay;
partitions, drafts, columns, decor and plan transform stay byte-equivalent.
Wall rekey has a production-only fixed-topology mode: rigid moving edges
translate all breakpoints, while length-changing side edges move only proven
old-vertex → new-vertex endpoints. Before the overlay is accepted, the union of
collinear room/partition carriers must cover every new exact wall record and no
new lattice/carrier violation may appear. Historical invalid records are
compared as an exact multiset rather than repaired during an unrelated Resize.
The renderer's canonical wall/floor result for the final preview cfg epoch is
the pointerup preflight result. Success copies that exact overlay once and
records one Undo/save; there is no commit-time simplify/degrade/reconstruction.
@@ -548,6 +554,13 @@ scale helpers remain pure-test history only and are tree-shaken from the
production interaction path. Exact `a/b` wall endpoints remain identity and
the quantised midpoint/direction `key` remains only a compatibility index.
Live measurement layout is isolated in pure `src/resize-labels.ts` (#300).
The controller supplies the accepted candidate, current view, cached stage
size and the room gear's `iconCqw()`-derived footprint. It produces exactly two
side-wall highlights/lengths plus one area/leader per affected room. The SVG
ink sits above wall bodies and below openings/handles; HTML labels are
pointer-inert. No `getBoundingClientRect()` enters the pointer path.
Near-axis geometry has one shared classifier in `src/near-axis.ts` (#290).
Walls applies it after architectural/grid resolution and before hover/commit,
moving only the free endpoint. Resize validates that its fixed-topology output
@@ -557,6 +570,14 @@ reuses ordinary opening projection and wall/open-span rekeying. Unique physical
count, maximum centimetres and skipped candidates stay separate from ordinary
grid movement; no load/save migration invokes this repair.
`normalizeWallIntervals()` compacts atomic real-wall intervals only when both
their centimetre thickness and ownership signature match (#299). The signature
is `outer(A)` or the stable sorted pair `shared(A,B)`; an outer/shared transition,
a change of shared pair, or ambiguous multi-owner geometry is a hard breakpoint.
Explicit Optimize and the room-deletion transaction call this same normalizer,
so neither path can create one saved record whose physical role changes halfway
through its exact span. Ambiguous ownership fails closed per atom.
All physical-geometry writers share the same transaction boundary (#278).
`checkSpacePhysicalGeometry()` validates the exact candidate through canonical
wall and floor builders before history or save. A failed or degraded candidate
@@ -567,14 +588,16 @@ colour and other presentation edits bypass this structural check, allowing an
old degraded plan to be exported or corrected without a background migration.
`reconcileCoincidentPartitions()` is an explicit-Optimize-only structural
canonicalizer (#276). It consumes canonical shared wall intervals and the
canonicalizer (#276/#296). It consumes canonical room-wall intervals and the
partition-opening compatibility resolver; it does not implement a second
nearest-wall model. An independent wall is removed only after an exact
endpoint-to-endpoint, two-room, uniform-solid proof. Its hosted openings are
materialised onto the ordinary room wall, and `max(roomCm, partitionCm)` keeps
the original centred physical union envelope. Unknown partition semantics,
partial/composite matches, overlapping openings and adjacent independent
bodies fail closed. The candidate then crosses the existing whole-plan
nearest-wall model. A source axis is atomized at solid interval and opening
boundaries. Exact one-owner outer or two-owner shared spans may be absorbed;
ambiguous spans are recombined into deterministic residual partitions and keep
their hosted openings. Converted openings are materialised onto ordinary room
walls, and `max(roomCm, partitionCm)` keeps the original centred physical union
envelope. Saved drafts use a separate all-or-nothing full-coverage proof.
Unknown partition semantics, gaps, overlapping openings and adjacent
independent bodies fail closed. The candidate then crosses the existing whole-plan
geometry preflight and one atomic Optimize write/Undo boundary. No render or
ordinary save path invokes this pass, so `PLAN_MODEL_VERSION` remains unchanged.
`OptimizeDependencies` is a narrow test/benchmark seam: production uses the
+14 -1
View File
@@ -497,7 +497,9 @@ orchestrator. It converts only legacy fields with an exact lossless
mapping, materialises legacy `open_to`, calls the grid projection,
rekeys exact wall/open-span endpoints onto the moved rooms, merges
touching virtual spans per room pair, compacts consecutive real-wall
intervals of equal thickness and stamps `model_version`. Unknown fields
intervals only when their thickness and physical ownership both match, and
stamps `model_version`. Outer/shared transitions and changes of shared-room
pair remain exact breakpoints even at equal thickness. Unknown fields
are preserved and every pass is idempotent.
The explicit pass also repairs pre-existing near-axis room walls, saved wall
@@ -683,6 +685,17 @@ current anchor is excluded to prevent zero-length segments. The static geometry
is cached by structural editor state; pointer movement changes at most the
single active candidate and never writes config, layout or storage.
A separate diagnostic projection is present throughout the Plan editor (#296),
including tools other than **Walls**. For every saved draft or independent wall
segment with a positive exact collinear overlap against another wall, it keeps
that source segment's complete axis and original endpoints visible. It is
painted after every real and virtual wall body and before openings, selection
chrome and transient previews. The layer is `pointer-events:none`,
`aria-hidden`, absent from View and cached by structural revision; it neither
deduplicates source identities nor participates in the architectural snap
resolver above. The 1 CSS px non-scaling axis and physical 5 cm nodes therefore
diagnose an otherwise invisible Resize blocker without changing any hit target.
## Planar wall faces
Every completed Walls segment is first persisted in the active `room_drafts`
+59
View File
@@ -2,6 +2,65 @@
## Unreleased
- Wall junctions no longer leave white wedges and notches. A node where three
or more walls meet is now completed with sector fans and the exact strips of
its rays: a node closes with a full mitre, like an ordinary wall
intersection on a drawing — the old junction chamfers are gone. The
no-holes invariant is checked automatically on a set of 16
close-up junction scenes and on the plan from the original report
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
- Every Plan editor tool now shows the same complete set of wall axes and
structural endpoints. Switching from Walls to Thickness no longer hides room
axes, while each tool keeps its own hints and live preview
([#304](https://github.com/Matysh/houseplan-card/issues/304)).
- Re-importing an already imported space no longer grows nested internal-ID
prefixes. Preview and Apply now share one immutable candidate, safely repair
only uniquely proven references to earlier copies, and preserve ambiguous
references without guessing or deletion; the import dialog exposes the
reference details ([#265](https://github.com/Matysh/houseplan-card/issues/265)).
- The wall-thickness tool now highlights a real wall at its actual physical
width instead of inflating thin walls according to the plan's grid size.
Zero-thickness walls keep a visible, scale-independent highlight and the
generous pointer hit area is unchanged
([#303](https://github.com/Matysh/houseplan-card/issues/303)).
- Contact sensors and locks in the opening properties dialog can now be
searched by friendly name or full entity ID. The selector keeps door/window
candidates prioritised and shows the entity ID beside every result
([#301](https://github.com/Matysh/houseplan-card/issues/301)).
## v1.67.0-rc.2 — 2026-08-25
- Hidden independent wall sections and saved wall chains now keep their axes
and original endpoints visible above all masonry in every Plan tool. “Optimize
plans” can absorb every exactly covered section into one or more consecutive
room walls, retain any unproven residual and its openings, and remove a saved
chain only when all of it is redundant. This clears real invisible Resize
blockers without shrinking walls or deleting unfinished work. The server
independently proves the complete removed axis, including walls without
openings, and the Optimize report names redundant saved chains separately
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
- Resize no longer proportionally shifts thickness-record endpoints on
neighbouring walls. Fixed-topology moves now preserve unrelated wall records,
keep new endpoints on the plan grid and their real carriers, and reject an
ambiguous candidate before it can damage a different wall
([#298](https://github.com/Matysh/houseplan-card/issues/298)).
- Resize now labels and highlights only the two side walls being measured,
removes the redundant length from the moving wall, and shows each affected
room's live area beside its own side of that wall. Shared and narrow rooms
keep both areas visible with leader lines, without covering each other or the
room-settings button
([#300](https://github.com/Matysh/houseplan-card/issues/300)).
- “Optimize plans” and room deletion no longer combine an equal wall thickness
across a shared-to-outer boundary or between different pairs of rooms. The
saved wall profile keeps each physical role at its exact breakpoint, avoiding
a record that changes meaning halfway through its span
([#299](https://github.com/Matysh/houseplan-card/issues/299)).
- While drawing Walls, `Esc` now finishes all accepted segments as independent
walls and releases the last point without deleting geometry or leaving the
tool. The next click starts a new chain; `Ctrl/Cmd+Z` remains the shortcut
that removes the last point and segment
([#294](https://github.com/Matysh/houseplan-card/issues/294)).
## v1.67.0-rc.1 — 2026-08-24
- Republished the reviewed beta.10 candidate under an RC identifier so HACS
+59
View File
@@ -8,6 +8,65 @@
## Не выпущено
- Стыки стен больше не оставляют белых клиньев и зазубрин. Узел, где сходятся
три и больше стен, теперь достраивается угловыми веерами и точными полосами
своих лучей: узел смыкается полным mitre, как обычное пересечение стен на
чертеже, — прежние фаски на стыках ушли. Инвариант «дыр нет»
проверяется автоматически на сете из 16 крупноплановых сцен стыков и на
плане из исходного сообщения
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
- Во всех инструментах редактора Плана теперь отображается один и тот же полный
набор осевых линий и узлов стен. При переходе из «Стены» в «Толщина» оси
комнат больше не исчезают, а подсказки и предпросмотр активного инструмента
остаются независимыми ([#304](https://github.com/Matysh/houseplan-card/issues/304)).
- Повторный импорт уже импортированного пространства больше не наращивает
служебные префиксы внутренних ID. Предпросмотр и применение теперь используют
один и тот же неизменяемый кандидат, безопасно восстанавливают единственные
доказанные ссылки на прошлые копии и сохраняют неоднозначные ссылки без
догадок или удаления; подробности доступны прямо в диалоге импорта
([#265](https://github.com/Matysh/houseplan-card/issues/265)).
- Инструмент толщины стен теперь подсвечивает реальную стену по её фактической
физической ширине, не раздувая тонкие стены из-за размера клетки плана.
Стены без толщины сохраняют видимую, независимую от масштаба подсветку, а
широкая зона попадания указателя не изменилась
([#303](https://github.com/Matysh/houseplan-card/issues/303)).
- Контактные датчики и замки в свойствах проёма теперь можно искать по имени
или полному `entity_id`. Селектор сохраняет приоритет дверных и оконных
сущностей и показывает идентификатор рядом с каждым результатом
([#301](https://github.com/Matysh/houseplan-card/issues/301)).
## v1.67.0-rc.2 — 2026-08-25
- Оси и исходные узлы скрытых независимых стен и сохранённых цепочек теперь
видны поверх всей кладки во всех инструментах Плана. «Оптимизировать планы» умеет
поглощать каждый точно покрытый участок одной или несколькими соседними
стенами комнат, сохраняет недоказанный остаток вместе с его проёмами и удаляет
цепочку только при полной избыточности. Это устраняет реальные невидимые
блокеры Resize, не уменьшая толщину стен и не удаляя незавершённую работу.
Сервер независимо доказывает всю удаляемую ось, включая стены без проёмов,
а отчёт Optimize отдельно называет избыточные сохранённые цепочки
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
- Resize больше не сдвигает пропорционально концы записей толщины соседних
стен. Fixed-topology перенос сохраняет нетронутые записи, оставляет новые
концы на сетке и реальных границах стены, а неоднозначный результат отклоняет
до того, как он повредит другую стену
([#298](https://github.com/Matysh/houseplan-card/issues/298)).
- Resize теперь подписывает и подсвечивает только две измеряемые боковые стены,
не показывает лишнюю длину на перемещаемой стене и ставит живую площадь каждой
затронутой комнаты с её стороны. Для общих и узких комнат обе площади остаются
видимыми с выносными линиями и не перекрывают друг друга или кнопку настроек
комнаты ([#300](https://github.com/Matysh/houseplan-card/issues/300)).
- «Оптимизировать планы» и удаление комнаты больше не склеивают одинаковую
толщину через границу общей и наружной стены или между разными парами комнат.
Профиль стены сохраняет каждую физическую роль до её точной границы, поэтому
одна запись больше не меняет смысл посередине своего участка
([#299](https://github.com/Matysh/houseplan-card/issues/299)).
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
покидая инструмент. Следующий клик начинает новую цепочку, а `Ctrl/Cmd+Z`
по-прежнему удаляет последнюю точку и отрезок
([#294](https://github.com/Matysh/houseplan-card/issues/294)).
## v1.67.0-rc.1 — 2026-08-24
- Проверенный кандидат beta.10 переопубликован с RC-идентификатором, чтобы HACS
+9 -9
View File
@@ -1,18 +1,18 @@
<!-- release: v1.67.0-rc.1 -->
<!-- release: v1.67.0-rc.2 -->
## Основное
- Многостенные стыки больше не вырезают длинные участки соседних стен: кладка остаётся непрерывной даже рядом с коротким лучом узла.
- Resize сохраняет роли и толщины общих стен, действительно двигает активную ручку, а для недоступных стен показывает конкретную понятную причину.
- Почти осевые стены и невидимый координатный шум теперь исправляются предсказуемо: Walls сохраняет точную ось, а «Оптимизировать планы» и общий барьер записи приводят старые значения к канонической сетке.
- «Оптимизировать планы» удаляет доказанно избыточную скрытую геометрию, не затрагивая незавершённую работу, и сохраняет границы роли общей и наружной стены.
- Resize сохраняет записи толщины соседних стен и отклоняет неоднозначное изменение до повреждения геометрии.
- Режим Resize показывает только относящиеся к жесту размеры, подсветку боковых стен и площадь каждой затронутой комнаты.
- Мелкие исправления и улучшения.
## Highlights
- Multi-wall junctions no longer cut long sections out of neighbouring walls: masonry stays continuous even beside a short node ray.
- Resize preserves shared-wall roles and thicknesses, enabled handles actually move, and unavailable walls now expose a specific human-readable reason.
- Nearly axial walls and invisible coordinate noise are repaired predictably: Walls stores an exact axis, while Optimize plans and the common write barrier converge older values onto the canonical grid.
- “Optimize plans” removes proven-redundant hidden geometry without touching unfinished work and preserves shared-to-outer wall role boundaries.
- Resize preserves neighbouring wall-thickness records and rejects ambiguous changes before they can damage geometry.
- Resize mode shows only gesture-relevant dimensions, side-wall highlights and the area of every affected room.
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.67.0-rc.1/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.67.0-rc.1/docs/CHANGELOG.md)
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.67.0-rc.2/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.67.0-rc.2/docs/CHANGELOG.md)
+39 -1
View File
@@ -136,12 +136,45 @@ wall degradation or second geometry reconstruction. Failure, Esc,
`pointercancel`, `lostpointercapture`, pinch and tool exit discard the overlay
with zero Undo entries and zero writes.
## Live measurements
During an accepted drag, Resize labels only the two side walls whose clear
lengths change. Each length has a matching accent highlight on that exact wall;
the moving wall no longer repeats its own length under the pointer.
The clean-floor area of every affected room is anchored beside its side of the
moving wall. An outer wall therefore shows one area and an exactly shared wall
shows two, on opposite sides. A short leader keeps ownership explicit. A label
is never hidden or clipped merely because the room is narrow: it may leave the
room outline, but two labels may not overlap.
`src/resize-labels.ts` performs placement from the accepted preview, current
view and cached stage size. It accounts for the zoom-dependent room-settings
button and shifts a conflicting area label along the wall. The production
pointer path performs no DOM measurement; browser smoke compares the actual
post-render rectangles at default and non-default zoom.
## Thickness, virtual spans and openings
`rekeyWallsAfterMove()` and `rekeyOpenSpansAfterMove()` map the immutable
`rekeyWallsAfterMoveChecked()` and `rekeyOpenSpansAfterMove()` map the immutable
snapshot to the fixed-topology candidate. Physical centimetre values and open
span count must survive; the production geometry check is fail-closed.
Safe Resize uses endpoint correspondence, not the historical affine mapping.
Every breakpoint follows a moving wall by one rigid translation. On a side wall
whose length changes, only the old topology endpoint moves to its paired new
vertex; an interior thickness breakpoint stays on its physical boundary rather
than keeping a proportional fraction of the new edge. Unrelated exact records
remain byte-equivalent. The candidate then proves that every new exact record
is lattice-safe and continuously covered by room-wall carriers. An
unchanged historical endpoint may remain readable even when its record changes
around it, but Resize cannot add or replace it with a different violation.
Key-only legacy records move only when their key identifies one whole changed
edge with one destination. An affected partial/ambiguous midpoint returns an
explicit rejected result; preview, history and persistence remain untouched.
The generic array-only helper retains its old affine fallback outside Safe
Resize for compatibility with historical pure transforms.
When the two owners of a shared moving seam split one physically continuous
side-wall record at their meeting point, the mapped atoms are joined back only
if their endpoints still meet exactly and their directions remain collinear.
@@ -189,10 +222,15 @@ building the preview frame itself.
blocks a zero-range handle, Optimize safely rehosts its windows and removes
the blocker, then the same production Resize gesture changes exactly two
rooms;
- `test/optimize-hidden-obstacles.test.mjs`: a real plan's composite hidden
independent walls and wholly redundant saved chain are removed by explicit
Optimize, while partial/free residuals remain and the post-Optimize Resize
audit loses only the proven `duplicate-physical-wall` blockers (#296);
- `demo/benchmark_safe_resize.mjs`: same-run pointer and cached pointerup budgets;
- `demo/benchmark_safe_resize_render.mjs`: warm 20-room/80-handle layer p95
and exactly one geometry snapshot per rendered frame;
- mutation gate: eligibility, third-room, topology, side ownership, jamb,
fixed-topology wall endpoint mapping,
pointer displacement/capture, shared-seam coalescing, preview rejection and
commit-preflight bypass mutants.
+3 -3
View File
@@ -17,12 +17,12 @@ change must pass through a published beta/RC before stable. Stable release
commits are promotion-only (versions, generated bundles and release/changelog
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
## Snapshot (2026-08-24)
## Snapshot (2026-08-25)
| Item | State |
|---|---|
| Version | **v1.67.0-rc.1 candidate** everywhere (manifest, const.py, package.json, CARD_VERSION) — HACS-discoverable continuation of the reviewed beta.10 candidate after beta.9 |
| Current local cycle | v1.67.0-beta.9 is published. Current `dev` preserves neighbouring masonry beyond short multi-wall rays (#288), prevents Resize from changing shared/outer wall roles (#289), straightens only confirmed near-axis defects (#290), canonicalizes invisible coordinate noise at every write boundary (#291), audits every real-plan Resize handle with human-readable blockers (#292), and restores the active real pointer gesture plus Undo (#293). v1.67.0-rc.1 packages these reviewed S8 changes under the HACS-discoverable identifier required after beta.9 and remains gated by the full exact-SHA Validate before publication. |
| Version | **v1.67.0-rc.2 candidate** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Current local cycle | v1.67.0-rc.1 is published. Current `dev` makes `Esc` detach an accepted Walls chain without deleting it (#294), removes only proven-redundant hidden wall geometry during Optimize (#296), preserves neighbouring thickness records through Resize (#298), keeps wall-thickness records split at shared/outer role boundaries (#299), and limits Resize labels, highlights and room-area readouts to the geometry affected by the gesture (#300). v1.67.0-rc.2 packages these S8 changes. The next candidate also keeps the complete wall-axis/endpoint overlay identical across every Plan tool (#304) after normal issue review. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+30
View File
@@ -55,6 +55,17 @@
`resize-preview-reject-silent` и
`safe-resize-commit-preflight-bypassed` обязаны красить соответствующие
unit/production smoke guards.
- [ ] Fixed-topology wall records (#298): moving-wall breakpoints translate
rigidly, side-wall interior endpoints never scale proportionally,
the exact first-floor 49→52 gesture ends on 17/52/57/101, unrelated
records remain byte-equivalent, and a full-span carrier/lattice proof
rejects gaps before preview. Key-only legacy records move only by one
whole-edge identity; partial midpoint ambiguity produces no preview,
history or config write [unit: `wall-thickness.test.mjs`; auto:
`smoke_resize_pointer_real_plan`, `smoke_resize_wall_thickness`, six
`smoke_edit_walk` runs; mutation:
`safe-resize-wall-endpoints-affine-scaled`,
`safe-resize-legacy-midpoint-fail-open`].
## Decor composition order (#231)
@@ -512,6 +523,10 @@ directory's README.
- [ ] Opening binding/registry-less/lock-action smokes preserve the secure
no-toggle-on-plan invariant while checking compact Light/Dark
locked/unlocked/unknown shell/core presentation.
- [ ] `node demo/smoke_opening_entity_search.mjs` checks the real opening
dialog: contact and lock search by friendly name/entity ID, preserved
contact priority, visible IDs, persistent **none** option and unchanged
`opening.contact`/`opening.lock` storage.
- [ ] Unit presentation coverage compares marker LQI colour with the shared
continuous `lqiColor()` across former 40/41 and 179/180 boundaries; bands
remain semantic metadata only.
@@ -2043,6 +2058,14 @@ require hands on real hardware — they remain for the human pass.
become partly shared and partly outer
[unit: resize.test + fixture 289-mixed-role-resize; auto:
smoke_room_resize; mutation: safe-resize-side-ownership-bypassed]
- [ ] Wall compaction preserves physical ownership (#299): equal thickness on
`shared(A,B) -> outer(A)` and `shared(A,B) -> shared(A,C)` remains split
at the exact role breakpoint, while equal neighbouring atoms inside one
role still compact. Optimize on `real-plan-first-floor.json` is immutable,
invariant-clean and idempotent; real-plan edit-walk seeds 1 and 3 exercise
Optimize and Delete-room/Keep-walls without producing a mixed-role record
[unit: wall-thickness + plan-optimizer; auto: smoke_edit_walk seeds 1/3;
mutation: wall-compaction-owner-role-bypassed]
- [ ] Live badges while dragging: lengths of the dragged wall + both
adjacent walls, and the m² area at the room centre; dragging a shared
wall shows BOTH areas; all numbers update continuously
@@ -2767,6 +2790,13 @@ require hands on real hardware — they remain for the human pass.
the whole wall; click opens the cm/in field; empty/0 clears; Esc closes
without applying; «Apply to all walls of this room» fills every allowed
edge [auto: smoke_wall_thickness]
- [ ] **Hover width follows wall centimetres (#303)**: on a 30 cm cell a
50 cm wall's hover fill matches the masonry within 2%; a zero-thickness
wall keeps the same physical visual minimum across cell sizes, while the
pointer still hits at five grid pitches from the axis
[unit: grid-scale.test.mjs; auto: smoke_wallthick_hover_width;
mutations: wallthick-hover-floor-back,
wallthick-zero-strip-not-visual, wallthick-hit-narrowed]
- [ ] **Hatched body, clean-floor area**: after setting thickness a `.wallbody`
path appears; room-card and tooltip m² both decrease to the same inner-
contour area
+48 -14
View File
@@ -204,7 +204,7 @@ objects; each space keeps its local View viewport.
| Zoom and pan | Wheel; drag empty space; `−`/`+` | Pinch; drag; double-tap resets kiosk | Available but precision is not guaranteed | — |
| Change space | Click a tab | Tap; kiosk swipe at 1:1 | Tap a tab | — |
| Device | Click/double-click per mode | Tap; safe actions equal desktop | Drag/properties are best effort | `Esc` closes the top surface |
| Draw or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` cancels the operation |
| Walls drawing or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` finishes a Walls chain or cancels the current precise drag |
| Editor history | Undo/Redo controls | Not applicable | Controls may work; no gesture guarantee | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` |
| Kiosk sizing | — | Hold empty space for 3 seconds | — | — |
@@ -213,8 +213,10 @@ Any manual kiosk operation pauses auto-cycle for 60 seconds.
### Cancel and undo
- `Esc` cancels an unfinished path, current drag/resize/rotation, or the top
dialog without undoing an already committed action.
- `Esc` finishes an active Walls chain without deleting its accepted segments.
In Split and other tools it cancels the unfinished path, current
drag/resize/rotation, or the top dialog without undoing an already committed
action.
- `Ctrl/Cmd+Z` undoes an editor command; redo is `Ctrl/Cmd+Shift+Z` or `Ctrl+Y`.
- Undo/Redo stores up to 50 named commands for Plan and Background.
@@ -298,6 +300,11 @@ House Plan offers them from smallest to largest. Save creates that room, Keep
as walls rejects only that candidate, and Cancel restores the whole draft with
no partial rooms.
While drawing an open chain, `Esc` finishes all accepted segments as ordinary
independent walls and keeps **Walls** selected; the next click starts a new
chain. `Ctrl/Cmd+Z` instead removes the last accepted point and segment. Pan,
pinch and `pointercancel` neither finish the chain nor add geometry.
Existing segment endpoints and lines appear above walls while drawing. An
endpoint grows when the next click will join it. A point on a line shows where
the click will create a valid junction.
@@ -323,9 +330,9 @@ Other operations edit existing geometry:
|---|---|
| Merge | Joins adjacent rooms; a dialog chooses the surviving identity, name and area |
| Split | Cuts a room from one wall to another; the larger part keeps the original room |
| Resize | Moves one eligible horizontal/vertical wall without changing room topology. Live labels report **inner** dimensions — the clear distance between wall faces, the number a tape measure gives — and the area of each affected room |
| Resize | Moves one eligible horizontal/vertical wall without changing room topology. Live labels report the two changing **inner** side-wall dimensions, highlight those walls, and place each affected room's area beside its side of the moving wall |
| Thickness | Changes one physical span or every wall of a room |
| Delete room | Deletes only the selected room after confirmation |
| Delete room | Deletes the room after choosing whether its exclusive physical walls remain; shared walls always remain |
![Selected partition and its Plan context tray](images/05-plan-context-tray.png)
@@ -352,6 +359,9 @@ It also stops where extending or shortening an adjacent wall would turn shared
material into outer material (or the reverse), so one saved thickness never
silently serves both roles. If neither direction has even one safe grid step,
the handle explains that only part of a shared wall cannot be moved.
Resize also preserves every unrelated wall exactly: changing the length of a
neighbouring wall cannot shift a thickness boundary to an invented off-grid
point. An ambiguous candidate is rejected instead of damaging another wall.
Partial shared walls, diagonal walls and walls overlapped by an independent
partition/draft/column keep a dimmed handle with an explanatory tooltip and
cannot start a drag. The former corner scale frame was removed. An ordinary
@@ -359,6 +369,11 @@ opening on the moving wall follows it once; a side-wall opening stops the
moving masonry at its physical jamb. Release creates one Undo step, while Esc
or an interrupted pointer writes nothing.
During the drag, the moving wall itself has no redundant length badge. An outer
wall shows one area badge; a shared wall shows two on opposite sides, each with
a short leader. In a narrow room the area stays visible and may extend outside
the room rather than overlap another area or the room-settings button.
### HA area binding
One HA area may be bound to one room. The binding drives automatic device
@@ -672,7 +687,10 @@ pull intentional off-grid or diagonal geometry to a node. Current ordinary
edits apply the same invisible boundary automatically, so the noise cannot
return after a later room, opening, decor or marker-position save.
Equal neighbouring wall-thickness fragments are compacted. Optimize may also
Equal neighbouring wall-thickness fragments are compacted only while they have
the same physical role: one outer room or the same pair of shared rooms. A
shared-to-outer transition or a change of shared-room pair stays as an exact
breakpoint even when the thickness is equal. Optimize may also
remove a different-thickness fragment shorter than half a grid step when equal
pieces of the same straight wall prove the replacement. This includes a
fragment touching exactly one room T-junction: the junction and perpendicular
@@ -687,14 +705,24 @@ change is canceled before Undo history or server storage is touched and the
card reports that the wall geometry could not be built safely. Titles, colours,
markers and other non-geometry settings remain editable.
When an old plan contains an independent wall exactly on top of one complete
solid boundary shared by two rooms, Optimize can replace the duplicate with
the single room wall. Doors, windows and gates hosted by that independent wall
stay in place and keep their contact/lock settings. The resulting thickness is
the wider original thickness, so the visible masonry does not shrink during
maintenance; use **Thickness** afterwards to choose a new value or **Boundary**
to make the shared wall virtual. Partial, composite and ambiguous overlaps are
left unchanged for manual correction.
When an old plan contains an independent wall exactly on top of solid room
masonry, Optimize can absorb each proven covered section even when consecutive
room-wall intervals form the cover. Free or ambiguous residual sections remain
independent walls with stable identities. Doors, windows and gates stay in
place: each is reattached to the room wall or to the retained residual that
still hosts it. The resulting thickness is the wider original thickness, so
visible masonry does not shrink. A saved unfinished wall chain is removed only
when every one of its segments is fully redundant; a free, partly covered or
thicker chain remains byte-for-byte unchanged. The report counts absorbed
independent-wall sections and removed whole chains, not source records.
Every Plan editor tool draws room, independent-wall and saved-chain centre axes
and endpoint nodes through the same layer above wall bodies. An independent
wall or saved chain hidden under other masonry additionally retains its source
diagnostic axis and nodes. These pointer-transparent layers do not change
snapping or selection and are absent outside the Plan editor. The diagnostic
disappears after Apply only when the corresponding independent geometry was
safely absorbed or removed.
Old positions are classified before Apply. A position whose room label, device
or light-group owner is proven absent is removed automatically and counted by a
@@ -728,6 +756,12 @@ assigns new internal IDs and adds the copy without replacing global settings.
When that exact import map matches orphan references already present in the
target, the preview reports them and Apply restores them together with the
space.
Re-importing a copy still creates an independent space, but no longer grows
nested service prefixes in internal IDs. Preview and **Add space** use the same
prepared candidate. **Import reference details** reports links updated inside
the copy and in the existing plan. If more than one target is possible, House
Plan preserves the reference instead of guessing and recommends running
**Optimize plans** after the import.
Internal uploaded files are not embedded in JSON; an import to another HA
instance must explicitly detach those links.
+46 -10
View File
@@ -220,7 +220,7 @@ desktop: для точного рисования, Resize, модификато
| Масштаб и панорама | Колесо; drag пустого места; кнопки `−`/`+` | Щипок; drag; двойной тап сбрасывает киоск | Работает, но точность не гарантируется | — |
| Переключение пространства | Клик по вкладке | Клик; в киоске свайп при масштабе 1:1 | Клик по вкладке | — |
| Устройство | Клик/двойной клик по правилам режима | Tap; безопасные действия как на desktop | Drag и свойства — best effort | `Esc` закрывает верхнюю поверхность |
| Рисование и точный drag | Полный контракт | Не применяется | Best effort; для Resize и точных точек используйте мышь | `Shift` меняет магнит/угол; `Esc` отменяет операцию |
| Рисование стен и точный drag | Полный контракт | Не применяется | Best effort; для Resize и точных точек используйте мышь | `Shift` меняет магнит/угол; `Esc` завершает цепочку стен или отменяет текущий точный drag |
| История редактора | Кнопки Undo/Redo | Не применяется | Кнопки могут работать, жест не гарантирован | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` |
| Киоск-размеры | — | Удерживать пустое место 3 секунды | — | — |
@@ -238,8 +238,9 @@ desktop: для точного рисования, Resize, модификато
| Контекст | `Esc` | `Ctrl+Z` / `Cmd+Z` | `Ctrl+Shift+Z` / `Ctrl+Y` |
|---|---|---|---|
| Незавершённый Draw/Split | Убирает последнюю точку или выходит из инструмента | Сначала убирает незавершённую точку | — |
| Любой инструмент редактора плана | Отменяет текущий жест/выбор | Отменяет последнюю именованную геометрическую операцию | Повторяет отменённую операцию |
| Активная цепочка «Стены» | Завершает принятые отрезки как независимые стены, оставляя инструмент активным | Убирает последнюю принятую точку и отрезок | — |
| Незавершённый Split | Убирает последнюю точку или выходит из инструмента | Сначала убирает незавершённую точку | — |
| Любой другой инструмент редактора плана | Отменяет текущий жест/выбор | Отменяет последнюю именованную геометрическую операцию | Повторяет отменённую операцию |
| Подложка | Отменяет незавершённое рисование или возвращает состояние до начала активного move/resize/rotate | Отменяет последнюю именованную операцию декора или картинки | Повторяет отменённую операцию |
| Текстовое поле | Закрывает верхний диалог | Обычная отмена текста браузером | Обычный повтор текста браузером |
@@ -409,9 +410,12 @@ T-соединение входит в проходящую стену без в
Отмена ничего не записывает, подтверждённое исправление можно отменить обычным
Undo оптимизации.
Каждый законченный отрезок цепочки сохраняется сразу. Esc/Ctrl+Z удаляет
последнюю точку. Pan, pinch и `pointercancel` ничего не завершают и не добавляют.
После перезагрузки сохранённый draft можно продолжить кликом по его концу.
Каждый законченный отрезок цепочки сохраняется сразу. `Esc` завершает принятые
отрезки как независимые стены, оставляет инструмент **Стены** активным и
отцепляется от последней точки; следующий клик начинает новую цепочку.
`Ctrl/Cmd+Z` вместо этого удаляет последнюю точку и отрезок. Pan, pinch и
`pointercancel` ничего не завершают и не добавляют. После перезагрузки
сохранённый draft можно продолжить кликом по его концу.
### Связь с HA-зоной
@@ -434,7 +438,7 @@ Undo оптимизации.
|---|---|
| Объединить | Склеивает соседние комнаты; в диалоге выбирается сохраняемая идентичность, имя и HA-зона |
| Split | Делит комнату путём от одной стены до другой; большая часть сохраняет исходную комнату |
| Resize | Двигает одну допустимую горизонтальную/вертикальную стену без изменения топологии комнат. Подписи показывают **внутренние** размеры — расстояние между гранями стен, которое даёт рулетка, — и площадь каждой затронутой комнаты |
| Resize | Двигает одну допустимую горизонтальную/вертикальную стену без изменения топологии комнат. Подписи показывают два меняющихся **внутренних** размера боковых стен, подсвечивают эти стены и ставят площадь каждой затронутой комнаты с её стороны перемещаемой стены |
| Толщина | Задаёт толщину выбранному участку или всем стенам комнаты |
| Удалить комнату | Открывает выбор: удалить комнату и оставить её физические стены либо удалить комнату вместе с ними; общие стены остаются всегда |
@@ -443,7 +447,9 @@ Undo оптимизации.
толщиной, а их двери/окна перепривязываются к ним. Виртуальные и нулевые участки
не превращаются в кладку. При варианте **«Удалить комнату и стены»** удаляются
только проёмы, принадлежавшие эксклюзивной стене этой комнаты; общая стена,
явная совпадающая перегородка и их проёмы сохраняются.
явная совпадающая перегородка и их проёмы сохраняются. Оставшиеся записи
толщины нормализуются уже по новому составу комнат: одинаковая толщина не
склеивается через переход «общая ↔ наружная» или между разными парами комнат.
![Выбранная перегородка и её контекстная панель](images/05-plan-context-tray.png)
@@ -482,6 +488,14 @@ Resize показывает живые длины и чистую площадь
стены, не вставляет вершины на частичной общей границе и не меняет больше двух
комнат. Завершённый drag становится одним именованным шагом общего
50-командного Undo/Redo; Esc, pointercancel и потеря захвата ничего не записывают.
Границы толщины соседних стен при этом остаются на своих реальных точках сетки:
Resize не пересчитывает их пропорционально новой длине. Неоднозначный результат
отклоняется целиком, а не повреждает стену в другом месте плана.
У самой перемещаемой стены больше нет дублирующей подписи длины. У внешней
стены показывается одна площадь, у общей — две на противоположных сторонах, с
короткими выносными линиями. В узкой комнате площадь остаётся видимой и может
выйти за контур, но не перекрывает вторую площадь или кнопку настроек комнаты.
Прямая стена, нарисованная в несколько кликов, сохраняется одной перегородкой:
соседние отрезки одинаковой толщины и направления срастаются сразу после
@@ -614,6 +628,11 @@ YAML-сущность без `unique_id` и строки в Entity Registry: п
передаёт её точное состояние, она так же управляет проёмом и значком замка в
Просмотре. Явно отключённая, потерянная или отсутствующая сущность не работает.
В списках контактных датчиков и замков нажмите поле выбора и введите часть
отображаемого имени либо `entity_id`. Под каждым результатом показан полный
идентификатор. Пункт **«— нет —»** всегда остаётся первым и очищает привязку;
поиск не меняет исходный приоритет дверных и оконных датчиков.
### Поведение в разных режимах
| Режим | Клик по проёму | Перетаскивание |
@@ -1415,8 +1434,8 @@ show_signal: true
| Декор и мебель | Положение и размеры округляются к сетке |
| Устройства и подписи комнат | Позиции округляются к сетке |
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, если с обеих сторон находятся участки одной толщины той же прямой стены. Допустим один T-узел комнаты: он и перпендикулярная стена не двигаются. Два топологических узла или граница проёма защищают участок |
| Перегородки | Коллинеарные соседние отрезки одинаковой толщины сращиваются в одну запись; узел сохраняется, если в нём сходится третья стена, стена комнаты, колонна или конец черновика. Если отдельная перегородка точно endpoint-to-endpoint совпадает с одной сплошной общей стеной двух комнат, она преобразуется в эту стену, а размещённые на ней проёмы перепривязываются без сдвига и потери датчиков. Сохраняется большая исходная толщина. Частичные и неоднозначные совпадения не меняются |
| Стены | Одинаковые соседние участки толщины объединяются только в одной физической роли: наружная стена одной комнаты либо общая стена той же пары комнат. Переход «общая ↔ наружная» и смена пары комнат остаются точными границами даже при одинаковой толщине. Изолированный участок другой толщины короче половины шага сетки также схлопывается, если с обеих сторон находятся участки одной толщины той же прямой стены. Допустим один T-узел комнаты: он и перпендикулярная стена не двигаются. Два топологических узла или граница проёма защищают участок |
| Перегородки и сохранённые цепочки | Коллинеарные соседние отрезки одинаковой толщины сращиваются; топологический узел сохраняется. Каждый участок отдельной стены, точно покрытый одной или несколькими соседними сплошными стенами комнат, поглощается ими, а недоказанные остатки и их проёмы сохраняются. Проёмы на поглощённой части перепривязываются без сдвига и потери датчиков; сохраняется большая исходная толщина. Незавершённая цепочка удаляется только целиком и только если каждый её сегмент полностью избыточен; свободная, частично покрытая или более толстая цепочка не меняется |
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку; настройки маркера сохраняются |
| Забытые позиции | Позиции доказанно отсутствующих подписей комнат, устройств и групповых меток удаляются и считаются по понятным категориям |
@@ -1479,6 +1498,15 @@ show_signal: true
обычным способом, а общую стену — сделать виртуальной. До явного подтверждения
предпросмотра и для неполного/неоднозначного совпадения ничего не меняется.
Отчёт считает поглощённые максимальные участки независимых стен, а не число
исходных записей, и отдельно — только целиком удалённые сохранённые цепочки.
Во всех инструментах редактора Плана оси и конечные узлы комнат, независимых
стен и сохранённых цепочек рисуются одним и тем же слоем поверх тел стен. Для
независимой стены или цепочки, скрытой под другой кладкой, дополнительно
сохраняется её исходная диагностическая ось и узлы. Эти слои не перехватывают
указатель, не меняют привязку и отсутствуют вне редактора Плана; после Apply
диагностика исчезает только вместе с доказанно избыточной геометрией.
<!-- docs-section: multiple-cards -->
## 20. Хранение, совместная работа и резервные копии
@@ -1498,6 +1526,14 @@ show_signal: true
предпросмотр показывает их количество, а применение восстанавливает их
вместе с пространством.
Повторный импорт копии остаётся независимым, но не наращивает служебные
префиксы внутренних ID. Предпросмотр и кнопка «Добавить» работают с одним и тем
же подготовленным вариантом. В блоке **Подробности восстановления ссылок**
видно, сколько ссылок обновлено внутри копии и в существующем плане. Если
подходят несколько возможных целей, House Plan ничего не угадывает: ссылка
сохраняется без изменений, диалог сообщает об этом и рекомендует после импорта
запустить **Оптимизировать планы**.
Для текущего пространства можно включить **Только планировка**. Такой файл
сохраняет комнаты, стены, проёмы, декор, трансформации подложки, позиции и
выбранный масштаб подписей комнат, но удаляет реальные и виртуальные маркеры,

Some files were not shown because too many files have changed in this diff Show More