Compare commits

...
Author SHA1 Message Date
claude[bot] aaa696cf38 docs: review document for #266
Issue: #266
User-Visible: no
2026-08-25 23:26:58 +00:00
Codex 7fd0ae4b2d docs: refresh the screenshot fingerprint after the cascade fix (#266)
Кадры прежние — golden и смоки подтверждают пиксельную идентичность.

Issue: #266
User-Visible: no
2026-08-26 02:04:17 +03:00
claude[bot] fd4bf0e95e docs: review document for #266
Issue: #266
User-Visible: no
2026-08-25 22:57:49 +00:00
Codex 0b782ee714 fix: host-gated groups belong to their owner surface; split mixed media wrappers (#266)
CI-шард смока поймал сдвиг каскада, невидимый golden-набору:
smoke_device_icon_design — alert-shell стал серым, dark-unavailable core
светлым. Причина: классификатор считал ведущий :host(...) владельцем
селектора и уносил гейтнутые группы устройств в base — ВПЕРЁД их поверхности,
меняя победителя при равной специфичности. Теперь :host-префикс — гейт, а не
владелец: владелец — первый значимый токен после него; смешанные @media
режутся на последовательные по-зонные копии обёртки (reduced-motion обёрток
стало 12 поверх тех же правил 10 исходных — юнит заякорен на факт, сверка
scope-ключом доказывает, что ни одно правило обёртку не потеряло). Два
мутантных якоря вернулись в devices.styles.ts; исключение юнита
непересечения опустело.

Гейты: refactor-proof diff пуст · golden 129/129 без переприёмки · смоки
device_icon_design, plan_snap_overlay, preloader OK · npm test 1318/0.

Issue: #266
User-Visible: no
2026-08-26 01:55:00 +03:00
claude[bot] 0d62a48b1e docs: review document for #266
Issue: #266
User-Visible: no
2026-08-25 22:44:15 +00:00
Codex 3a49b14274 docs: spec #266 revision 3 — arithmetic of AC2 and the true reduced-motion count
Issue: #266
User-Visible: no
2026-08-26 01:34:21 +03:00
Codex 0d9320fe1d refactor: extract the base styles and finish the aggregator (#266 slice 5/5)
52 блока host/переменных/кросс-поверхностных групп → src/styles/base.styles.ts;
styles.ts — 19-строчный сборщик [base, plan, devices, chrome, dialogs] с
задокументированным контрактом порядка каскада. Два оставшихся мутантных
якоря (:host-гейт ховера устройств) переадресованы на base.styles.ts.

Юниты инвариантов (test/styles-split.test.mjs): состав и порядок сборщика;
непересечение (scope+селектор) между файлами — единственное именованное
исключение: кросс-поверхностная группа «:host(...) .dev:hover, .dev:focus-
visible» живёт в base по §1.1; выживание медиа-обёрток — 2 forced-colors и
10 prefers-reduced-motion (в ТЗ и ревью фигурировали 8 — фактический счёт по
исходнику 10, юнит держит точное число). fix-test-build научился точке в
имени модуля (styles/base.styles → .js). ARCHITECTURE.md — раздел Styles.

Refactor-proof diff (scope-ключ) пуст; golden 129/129 без переприёмки; смоки
plan_snap_overlay/preloader OK; npm test 1318/0; бандл 1 291 440 → 1 291 458
(+18 байт).

Issue: #266
User-Visible: no
2026-08-26 01:34:20 +03:00
Codex 55f7126afd refactor: extract the plan surface styles (#266 slice 4/5)
250 блоков сцены плана (stage, стены/оси/снап, декор, iso, resize ink) →
src/styles/plan.styles.ts, склейка [inline, plan, devices, chrome, dialogs].
Refactor-proof diff пуст; golden 129/129; обязательные смоки медиа-обёрток
(spec §7) smoke_plan_snap_overlay (forced-colors) и smoke_preloader
(reduced-motion) — OK; npm test 1315/0.

Issue: #266
User-Visible: no
2026-08-26 01:34:20 +03:00
Codex bf52524de3 refactor: extract the devices surface styles (#266 slice 3/5)
82 блока устройств/маркеров/пылесосов → src/styles/devices.styles.ts, склейка
[inline, devices, chrome, dialogs]. Три мутантных якоря (.dev .valtext,
--dev-size, capsule radius) переадресованы на новый файл. Refactor-proof diff
пуст; golden 129/129; npm test 1315/0.

Issue: #266
User-Visible: no
2026-08-26 01:34:20 +03:00
Codex 7d094fd2ea refactor: extract the dialogs surface styles (#266 slice 2/5)
271 блок диалогов/форм/кнопок/пикеров → src/styles/dialogs.styles.ts, склейка
[inline, chrome, dialogs]. Контрактные тесты, которые греппят CSS-исходник,
переведены на хелпер readAllStylesSource (styles.ts + все импортированные
файлы поверхностей) — greps видят весь лист на любом состоянии сплита.
Refactor-proof diff пуст; golden 129/129; npm test 1315/0.

Issue: #266
User-Visible: no
2026-08-26 01:34:20 +03:00
Codex c684f178cb refactor: extract the chrome surface styles (#266 slice 1/5)
66 блоков тулбаров/вкладок/меню → src/styles/chrome.styles.ts; остальное
инлайном в прежнем порядке, склейка [inline, chrome]. Refactor-proof diff
пуст; golden 129/129 без переприёмки; npm test зелёный.

Issue: #266
User-Visible: no
2026-08-26 01:34:20 +03:00
Codex cbdc248987 chore: add the #266 styles splitter and refactor-proof diff tools
styles-split.mjs — механический генератор слайсов (зоны в порядке финального
сборщика, инлайн-остаток промежуточных состояний сохраняет относительные
позиции финала); styles-diff.mjs — нормализованное множество правил с ключом
«полный путь вложенности + селектор» для доказательства refactor-only.

Issue: #266
User-Visible: no
2026-08-26 01:34:20 +03:00
claude[bot] 34335d1e83 docs: review document for #266
Issue: #266
User-Visible: no
2026-08-25 21:49:34 +00:00
Codex afb6e151e5 docs: spec #266 revision 2 — scope-keyed diff, media-wrapper gates, real size caps
Issue: #266
User-Visible: no
2026-08-26 00:44:00 +03:00
claude[bot] 5c466b6c01 docs: review document for #266
Issue: #266
User-Visible: no
2026-08-25 21:37:23 +00:00
Codex 4c93f28e73 docs: spec for #266 styles split
Issue: #266
User-Visible: no
2026-08-26 00:27:21 +03:00
Codex 30698d6ec4 fix: exempt pipeline review documents from the branch-name gate rule (#305)
Правило 2 локального process-gate блокировало пуш issue-ветки после того, как
конвейер «Привести ветку к dev» вносил в неё свежую историю dev с чужим
коммитом «docs: review document for #NNN» — локальный origin/dev автора
отставал и не вычитал его из диапазона, а нарушение в истории не чинится
следующим коммитом (единственный выход был --no-verify по §12/17).

Исключение доказуемое и fail-closed: точный subject документа ревью И дифф
только в docs/reviews/ (files обязателен — ownCommits теперь читаются с
filesOf). Любой код рядом с документом или пустой список файлов возвращают
правило 2 в строй; юниты фиксируют оба контура.

Issue: #305
User-Visible: no
2026-08-26 00:24:22 +03:00
claude[bot] b1b8fb9fab docs: review document for #310
Validate / smoke (3) (push) Failing after 23m19s
Validate / smoke (2) (push) Failing after 35m58s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 27m55s
Validate / docs (push) Successful in 26s
Validate / performance_smoke (push) Failing after 36m23s
Validate / process-workflow-sync (push) Successful in 41s
Validate / provenance (push) Successful in 55s
Validate / process-gate (push) Failing after 1m3s
Validate / changes (push) Successful in 47s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 54s
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 9m1s
Validate / smoke (1) (push) Failing after 23m17s
Issue: #310
User-Visible: no
2026-08-25 21:00:46 +00:00
Codex 89ac6024bc test: cover the short-support pair trim and record true baseline provenance (#310)
Юнит по ТЗ §8 (риск №3): у пары с коротким толстым саппортом клин ограничен
min(2·halfDepth, длина стены), сеточный контракт чист; интерференция с
латеральным тримом #271 структурно невозможна (карта узлов требует ≥3
канонических лучей — узел-пара в неё не попадает), что зафиксировано в
комментарии теста.

Поправка происхождения эталона по находке CODE-REVIEW-310-r1 (High): трейлер
Baseline-Reviewed коммита 0e6fbfb0 ошибочно указывал на прогон ветки #309;
подтверждающий прогон этого кода и эталона — Validate на 0e6fbfb0 (ссылка
ниже), golden в нём зелёный на Linux CI.

Issue: #310
User-Visible: no
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32894391916
2026-08-25 23:50:49 +03:00
claude[bot] 2e3874f10d docs: review document for #310
Issue: #310
User-Visible: no
2026-08-25 20:47:17 +00:00
Codex 0e6fbfb00b test: accept the #310 pair-apex baseline
Пересъёмка единственной изменившейся сцены junction-309-spike-dark: полное
остриё без зубца торца. Принято npm run golden:accept -- --reviewed; шумовые
93 сцены откачены к прежним байтам; golden:verify после отката — 129/129.

Issue: #310
User-Visible: no
Release: v1.68.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32886626656
2026-08-25 23:16:19 +03:00
Codex de0867b001 fix: restore the full pair apex and trim the poking butt end (#310)
Узел ровно двух лучей снова закрывается полным mitre — стены сходятся в
точку, фаска #309 остаётся только веерам узлов ≥3 лучей. Настоящий зубец
убран: pairButtEndTrimWedges возвращает адресный клин — часть тела стены
снаружи наружной грани соседа и не дальше 2·halfDepth от узла — который
physicalBodyParts и превью вычитают из тела до разрезов проёмов. Это второе
адресное вычитание конвейера узлов рядом с латеральным тримом #271.

Узлы-двойки невидимы детектору #302 (карта требует ≥3 лучей): контракт «без
дыр» для них закрыт парным сеточным юнитом (кладка = полосы ∪ патч − клинья)
на spike-узле фикстуры владельца и синтетике. 3 новых мутанта, краснота
каждого проверена исполнением; парный юнит #309 переписан под полное остриё.

Issue: #310
User-Visible: yes
2026-08-25 23:16:19 +03:00
claude[bot] 9ec3a77ffa docs: review document for #310
Issue: #310
User-Visible: no
2026-08-25 20:10:38 +00:00
Codex 8d41651b1e docs: spec #310 revision 2 — grid contract for two-ray nodes per review r1
Issue: #310
User-Visible: no
2026-08-25 23:05:07 +03:00
claude[bot] 0d328f6c0c docs: review document for #310
Issue: #310
User-Visible: no
2026-08-25 19:59:44 +00:00
Codex c8d56b6c4d docs: spec for #310 pair apex and butt-end trim
Issue: #310
User-Visible: no
2026-08-25 22:46:25 +03:00
claude[bot] f2460dc81d docs: review document for #309
Validate / reuse (push) Successful in 1m2s
Validate / hacs (push) Failing after 16s
Validate / hassfest (push) Failing after 16s
Validate / backend (push) Failing after 4m31s
Validate / frontend (push) Successful in 4m46s
Validate / golden (push) Failing after 17m34s
Validate / smoke (3) (push) Failing after 17m56s
Validate / smoke (2) (push) Failing after 18m18s
Validate / smoke (1) (push) Failing after 18m53s
Validate / smoke_done (push) Skipped
Validate / performance_smoke (push) Failing after 18m46s
Validate / docs (push) Successful in 27s
Validate / process-workflow-sync (push) Successful in 47s
Validate / process-gate (push) Failing after 59s
Validate / provenance (push) Successful in 1m5s
Validate / changes (push) Successful in 43s
Issue: #309
User-Visible: no
2026-08-25 19:23:54 +00:00
claude[bot] a3a75ccbf3 docs: review document for #309
Issue: #309
User-Visible: no
2026-08-25 19:18:44 +00:00
Codex 6c1534f170 test: pin the golden matrix guard to version 46 (#309)
CI-падение sun-ray guard: тест фиксирует номер матрицы, #309 поднял его
тремя junction-teeth сценами. Полный npm test — 1309/0.

Issue: #309
User-Visible: no
2026-08-25 21:55:39 +03:00
Codex e85bae70f0 test: accept the #309 junction-teeth baselines
Принято npm run golden:accept -- --reviewed; шумовые 92 сцены откачены к
прежним байтам, индекс несёт matrixVersion 46 и sha256 трёх новых сцен;
golden:verify после отката — 129/129.

Issue: #309
User-Visible: no
Release: v1.68.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32882555609
2026-08-25 21:50:28 +03:00
Codex 101cf70952 fix: cap junction mitres at the visual limit and drop foreign-sector pair patches (#309)
Вылет mitre ограничен VISUAL_MITRE_LIMIT = 1.5·max(h): длиннее — плоская
фаска перпендикулярно направлению вершины (chamferApex), в парных патчах и
в веерах узлов. Узлы ≥3 канонических лучей закрываются веерами
junctionNodeGeometry прямо в linearWallJoinPatches: парный патч живёт в
секторе, противоположном своей паре, и красил ступень поверх тонких полос
(крест 15/15/30/30 из отчёта владельца). Прямые углы (вылет 1.41h)
байтово прежние. Механизм #249 (MULTI_WALL_JOIN_LIMIT,
multiWallBevelCutsAt, mitre контуров комнат) не тронут.

Юниты формы на фикстуре трёх узлов владельца + контрактный детектор дыр;
4 новых мутанта, краснота каждого проверена исполнением. Матрица golden 46:
три новые сцены junction-309-{step,spike,hump}-dark.

Issue: #309
User-Visible: yes
2026-08-25 21:50:28 +03:00
claude[bot] 787891c4e7 docs: review document for #309
Issue: #309
User-Visible: no
2026-08-25 18:18:06 +00:00
Codex 2ade43520b docs: spec #309 revision 3 — explicit #249 non-scope and touch impact
Issue: #309
User-Visible: no
2026-08-25 21:14:01 +03:00
claude[bot] cee4718ffa docs: review document for #309
Issue: #309
User-Visible: no
2026-08-25 18:11:25 +00:00
Codex f0ea376aa9 docs: spec #309 revision 2 — mandatory sections per review r1
Issue: #309
User-Visible: no
2026-08-25 21:02:26 +03:00
claude[bot] bbac40eb8e docs: review document for #309
Issue: #309
User-Visible: no
2026-08-25 18:02:01 +00:00
Codex ff623cd49d docs: spec for #309 junction visual mitre limit
Issue: #309
User-Visible: no
2026-08-25 20:52:15 +03:00
claude[bot] dc68868bd8 docs: review document for #307
Issue: #307
User-Visible: no
2026-08-25 17:09:10 +00:00
Codexandclaude[bot] 1134345444 fix: keep the active chain axis and nodes above the wall bodies (#307)
Каждый клик персистит сегмент цепочки в room_drafts, и его непрозрачная
кладка рисовалась поверх markup-слоя — жёлтая ось и узлы исчезали на уже
поставленных частях. Разметка активной цепочки вынесена в свой слой между
телами стен и снап-оверлеем: ось и узлы видны, снап-геометрия не тронута,
самопривязка по-прежнему запрещена. Смок с пиксельными пробами падает на
прежнем порядке слоёв.

Issue: #307
User-Visible: yes
2026-08-25 16:53:09 +00:00
claude[bot] 729e2d05af docs: review document for #307
Issue: #307
User-Visible: no
2026-08-25 16:40:13 +00:00
Matysh 489f68eea9 build: prepare v1.67.0-rc.3 candidate
Package the reviewed S8 work and refresh release metadata and bundles.

Issue: #265
Issue: #301
Issue: #302
Issue: #303
Issue: #304
User-Visible: yes
2026-08-25 19:12:27 +03:00
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
Sergey Matyunin 8c03656412 build: continue beta.10 candidate as v1.67.0-rc.1
Issue: #291
User-Visible: yes
2026-08-24 19:24:59 +03:00
Sergey Matyunin fc81bd8a32 test: accept beta.10 lattice wall-union goldens
Issue: #291
User-Visible: no
Release: v1.67.0-beta.10
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32748758050
2026-08-24 19:13:22 +03:00
Sergey Matyunin 74d306b510 test: make lattice and Optimize smokes CI-clean
Issue: #291
User-Visible: no
2026-08-24 19:13:21 +03:00
Sergey Matyunin 80dc5860ca test: accept beta.10 docs fingerprint
Issue: #291
User-Visible: no
2026-08-24 19:05:03 +03:00
Sergey Matyunin 04110bf741 build: prepare v1.67.0-beta.10 candidate
Issue: #288
Issue: #289
Issue: #290
Issue: #291
Issue: #292
Issue: #293
User-Visible: yes
2026-08-24 19:03:02 +03:00
claude[bot] 55fed02d24 docs: review document for #292
Validate / process-workflow-sync (push) Successful in 29s
Validate / provenance (push) Successful in 39s
Validate / changes (push) Successful in 42s
Validate / process-gate (push) Failing after 45s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 43s
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
Validate / docs (push) Successful in 31s
Issue: #292
User-Visible: no
2026-08-24 15:46:52 +00:00
Sergey Matyunin 2fda6fa086 docs: accept resize audit screenshot fingerprint
Issue: #292
User-Visible: no
2026-08-24 18:25:22 +03:00
Sergey Matyunin cd88633535 fix: audit resize availability on real plans
Issue: #292
User-Visible: yes
2026-08-24 18:21:50 +03:00
claude[bot]andSergey Matyunin 314b498e12 docs: review document for #292
Issue: #292
User-Visible: no
2026-08-24 18:11:08 +03:00
Sergey Matyunin e03238ec1c docs: clarify resize reasons and rollback
Issue: #292
User-Visible: no
2026-08-24 18:11:08 +03:00
claude[bot]andSergey Matyunin 474dbd62ca docs: review document for #292
Issue: #292
User-Visible: no
2026-08-24 18:11:08 +03:00
Sergey Matyunin e1f0d2310a docs: specify resize availability audit
Issue: #292
User-Visible: no
2026-08-24 18:11:08 +03:00
claude[bot] 1d4bf9b018 docs: review document for #293
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 33s
Validate / provenance (push) Successful in 45s
Validate / changes (push) Successful in 37s
Validate / process-gate (push) Failing after 42s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 42s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / backend (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Issue: #293
User-Visible: no
2026-08-24 15:10:36 +00:00
Sergey Matyunin 0a52b6757e docs: accept screenshots after resize rebase
Issue: #293
User-Visible: no
2026-08-24 17:52:44 +03:00
Sergey Matyunin 97acb0b415 test: require resize capture past hit area
Issue: #293
User-Visible: no
2026-08-24 17:49:48 +03:00
Sergey Matyunin e2c1dbec0d fix: preserve resize undo through write repair
Issue: #293
User-Visible: yes
2026-08-24 17:46:20 +03:00
Sergey Matyunin 08dce8603d test: cover both resize commit barriers
Issue: #293
User-Visible: no
2026-08-24 17:35:45 +03:00
Sergey Matyunin c4694acea9 test: harden resize capture verification
Issue: #293
User-Visible: no
2026-08-24 17:35:45 +03:00
Sergey Matyunin 6c5163c3c9 fix: restore resize pointer gesture
Issue: #293
User-Visible: yes
2026-08-24 17:35:45 +03:00
claude[bot]andSergey Matyunin f2e8fe5e93 docs: review document for #293
Issue: #293
User-Visible: no
2026-08-24 17:35:11 +03:00
Sergey Matyunin 8750708c5b docs: add risks and rollback to resize pointer spec
Issue: #293
User-Visible: no
2026-08-24 17:35:11 +03:00
claude[bot]andSergey Matyunin e6e80934e0 docs: review document for #293
Issue: #293
User-Visible: no
2026-08-24 17:35:11 +03:00
Sergey Matyunin 8eb351baa3 docs: specify resize pointer recovery
Issue: #293
User-Visible: no
2026-08-24 17:35:11 +03:00
claude[bot] a4ef0e2088 docs: review document for #291
Validate / docs (push) Successful in 23s
Validate / process-workflow-sync (push) Successful in 39s
Validate / provenance (push) Successful in 46s
Validate / process-gate (push) Failing after 44s
Validate / changes (push) Successful in 34s
Validate / frontend (push) Skipped
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 46s
Validate / smoke (3) (push) Skipped
Validate / golden (push) Skipped
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #291
User-Visible: no
2026-08-24 14:34:43 +00:00
Sergey Matyunin 3c754729f2 docs: accept screenshots after lattice rebase
Issue: #291
User-Visible: no
2026-08-24 17:16:23 +03:00
Sergey Matyunin 3f6338889e test: prove lattice barrier across editor controllers
Issue: #291
User-Visible: no
2026-08-24 17:13:28 +03:00
claude[bot]andSergey Matyunin 35ab1dc0c3 docs: review document for #291
Issue: #291
User-Visible: no
2026-08-24 17:13:28 +03:00
Sergey Matyunin 4ccf4a3ccc fix: enforce exact lattice coordinates
Issue: #291
User-Visible: yes
2026-08-24 17:13:21 +03:00
Sergey Matyunin c8a3878568 docs: define lattice shift precision
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
claude[bot]andSergey Matyunin 0146d5787e docs: review document for #291
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
Sergey Matyunin feff436942 docs: define lattice report translations
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
claude[bot]andSergey Matyunin 374081b490 docs: review document for #291
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
Sergey Matyunin e9bf11a8e3 docs: record optimize report decision
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
Sergey Matyunin 66193c5862 docs: add product context and rollback
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
claude[bot]andSergey Matyunin 894a4e5803 docs: review document for #291
Issue: #291
User-Visible: no
2026-08-24 17:11:26 +03:00
Sergey Matyunin 1d220628ff docs: specify lattice coordinate write barrier
Issue: #291
User-Visible: no
2026-08-24 17:11:26 +03:00
claude[bot] 52c74301f9 docs: review document for #290
Validate / docs (push) Successful in 28s
Validate / process-workflow-sync (push) Successful in 36s
Validate / provenance (push) Successful in 40s
Validate / process-gate (push) Failing after 40s
Validate / changes (push) Successful in 37s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 47s
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
Validate / smoke (1) (push) Skipped
Issue: #290
User-Visible: no
2026-08-24 14:11:02 +00:00
Sergey Matyunin 01512081db test: enforce shared near-axis threshold source
Issue: #290
User-Visible: no
2026-08-24 17:02:48 +03:00
claude[bot] 1245beaaf6 docs: review document for #290
Issue: #290
User-Visible: no
2026-08-24 14:01:23 +00:00
Sergey Matyunin d33ae88da4 docs: accept screenshots after near-axis rebase
Issue: #290
User-Visible: no
2026-08-24 16:33:20 +03:00
claude[bot]andSergey Matyunin f6cb957bba docs: review document for #290
Issue: #290
User-Visible: no
2026-08-24 16:30:43 +03:00
Sergey Matyunin f44df5d05d test: reconcile near-axis resize proof
Issue: #290
User-Visible: no
2026-08-24 16:30:25 +03:00
claude[bot]andSergey Matyunin 6983f2b088 docs: review document for #290
Issue: #290
User-Visible: no
2026-08-24 16:30:25 +03:00
Sergey Matyunin f6cf2c93ae fix: scope near-axis resize validation
Keep unrelated pre-existing near-axis edges from disabling exact Resize handles, make tracked single-space fixtures visible to the invariants CLI, and add the three missing mutation gates plus real-plan coverage.

Issue: #290
User-Visible: no
2026-08-24 16:30:25 +03:00
Sergey Matyunin edf8b068ed fix: straighten near-axis wall geometry
Issue: #290
User-Visible: yes
2026-08-24 16:30:11 +03:00
claude[bot]andSergey Matyunin 6551c5d53d docs: review document for #290
Issue: #290
User-Visible: no
2026-08-24 16:29:51 +03:00
Sergey Matyunin e102f34156 docs: add real-plan proof and rollback
Issue: #290
User-Visible: no
2026-08-24 16:29:51 +03:00
claude[bot]andSergey Matyunin 7fd9ca0f6a docs: review document for #290
Issue: #290
User-Visible: no
2026-08-24 16:29:50 +03:00
Sergey Matyunin 6e1007a363 docs: specify near-axis authoring and repair
Issue: #290
User-Visible: no
2026-08-24 16:29:50 +03:00
claude[bot] 10fa0dc7fb docs: review document for #288
Validate / process-gate (push) Failing after 44s
Validate / changes (push) Successful in 29s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 1m0s
Validate / smoke_done (push) Skipped
Validate / backend (push) Skipped
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / docs (push) Failing after 39s
Validate / process-workflow-sync (push) Successful in 38s
Validate / provenance (push) Successful in 43s
Issue: #288
User-Visible: no
2026-08-24 13:14:55 +00:00
Sergey Matyunin f2c0721a9d docs: recapture screenshots after corridor integration
Issue: #288
User-Visible: no
2026-08-24 16:02:03 +03:00
claude[bot]andSergey Matyunin e8a4db7972 docs: review document for #288
Issue: #288
User-Visible: no
2026-08-24 15:57:49 +03:00
Sergey Matyunin 406e46ecb1 docs: recapture screenshots after fixture repair
Issue: #288
User-Visible: no
2026-08-24 15:57:49 +03:00
claude[bot]andSergey Matyunin 4201644d33 docs: review document for #288
Issue: #288
User-Visible: no
2026-08-24 15:57:25 +03:00
Sergey Matyunin 17086d3cf2 fix: preserve walls beyond short junction rays
Issue: #288
User-Visible: yes
2026-08-24 15:57:25 +03:00
claude[bot]andSergey Matyunin 74fdf2182c docs: review document for #288
Issue: #288
User-Visible: no
2026-08-24 15:56:48 +03:00
Sergey Matyunin 2d8ade3b2b docs: add risks and rollback to junction spec
Issue: #288
User-Visible: no
2026-08-24 15:56:48 +03:00
claude[bot]andSergey Matyunin 6f67d53665 docs: review document for #288
Issue: #288
User-Visible: no
2026-08-24 15:56:48 +03:00
Sergey Matyunin e333f0b2f1 docs: specify bounded multiwall corridor
Issue: #288
User-Visible: no
2026-08-24 15:56:48 +03:00
claude[bot] f472af88a3 docs: review document for #289
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
Validate / docs (push) Failing after 23s
Validate / process-workflow-sync (push) Successful in 37s
Validate / provenance (push) Successful in 56s
Validate / process-gate (push) Failing after 57s
Validate / changes (push) Successful in 44s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 36s
Issue: #289
User-Visible: no
2026-08-24 12:50:45 +00:00
Sergey Matyunin a7618151cc test: accept safe resize baselines
Accepted the complete Linux artifact after visual review. Only the dark and light safe-resize handle views change intentionally; all other passing scenarios keep their prior bytes.

Issue: #289
User-Visible: no
Release: v1.67.0-beta.10
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32726613816
2026-08-24 15:31:26 +03:00
Sergey Matyunin 10ca913fad test: keep resize smokes self-contained
Issue: #289
User-Visible: no
2026-08-24 15:30:23 +03:00
claude[bot]andSergey Matyunin 849b3e5f76 docs: review document for #289
Issue: #289
User-Visible: no
2026-08-24 15:19:47 +03:00
Sergey Matyunin bca3bfd52c fix: prevent mixed-role walls during resize
Issue: #289
User-Visible: yes
2026-08-24 15:19:40 +03:00
claude[bot]andSergey Matyunin 2ad20dca2b docs: review document for #289
Issue: #289
User-Visible: no
2026-08-24 15:19:20 +03:00
Sergey Matyunin 0bca6dfb14 docs: add evidence and rollback to resize spec
Issue: #289
User-Visible: no
2026-08-24 15:19:20 +03:00
claude[bot]andSergey Matyunin 8877e8c6d6 docs: review document for #289
Issue: #289
User-Visible: no
2026-08-24 15:19:20 +03:00
Sergey Matyunin 6a4e665d33 docs: specify mixed-role resize guard
Issue: #289
User-Visible: no
2026-08-24 15:19:20 +03:00
Sergey Matyunin 5272287671 test: accept fixture wall key baselines
Validate / docs (push) Failing after 32s
Validate / process-workflow-sync (push) Successful in 32s
Validate / frontend (push) Successful in 8m9s
Validate / provenance (push) Successful in 47s
Validate / process-gate (push) Successful in 40s
Validate / changes (push) Successful in 34s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 48s
Validate / smoke (1) (push) Failing after 20m38s
Validate / backend (push) Skipped
Validate / smoke (3) (push) Failing after 22m20s
Validate / smoke (2) (push) Failing after 22m32s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 22m59s
Validate / performance_smoke (push) Failing after 19m3s
Accepted the complete Linux artifact after visual review. Only the dark and light isometric geometry views change intentionally; 106 passing scenarios keep their prior bytes.

Issue: #260
User-Visible: no
Release: v1.67.0-beta.10
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32725286757
2026-08-24 15:13:41 +03:00
ClaudeandSergey Matyunin c14dcecb98 test(fixtures): write the real wall key in one place
Validate / smoke (3) (push) Failing after 18m12s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 20m2s
Validate / performance_smoke (push) Failing after 25m25s
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 32s
Validate / provenance (push) Successful in 42s
Validate / process-gate (push) Failing after 41s
Validate / changes (push) Successful in 32s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 46s
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 9m15s
Validate / smoke (1) (push) Failing after 18m47s
Validate / smoke (2) (push) Failing after 19m9s
Refresh the shared wall-key fixture contract and keep generated frontend bundles synchronized with the fingerprinted geometry fixtures.

Issue: #260
User-Visible: no
2026-08-24 15:06:06 +03:00
Claude 523190d8f4 test: one thickness record must not describe two wall roles
Validate / process-workflow-sync (push) Successful in 35s
Validate / provenance (push) Successful in 46s
Validate / changes (push) Successful in 31s
Validate / process-gate (push) Failing after 45s
Validate / reuse (push) Successful in 46s
Validate / hacs (push) Failing after 16s
Validate / hassfest (push) Failing after 18s
Validate / frontend (push) Successful in 9m19s
Validate / backend (push) Failing after 9m31s
Validate / smoke (1) (push) Failing after 17m56s
Validate / smoke (3) (push) Failing after 17m52s
Validate / smoke (2) (push) Failing after 18m9s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 23m10s
Validate / performance_smoke (push) Failing after 51m44s
Validate / docs (push) Failing after 26s
The continuity gate cannot see the defect from the owner's 66.json: masonry is
continuous there and the record agrees with what is painted. The record itself
is wrong — a partial resize left 43 steps of a former shared boundary as an
exterior wall while it kept the 20 cm of that boundary, next to 30 cm exterior
neighbours. Thickness followed the key, not the role of the edge.

A width check would not have caught it, and I built one before throwing it away.
It compares the painted body against the record, and here the two agree. On real
plans it also fires where masonry is legitimately wider — columns, junction
influence, abutting parallel walls: 76 to 82 steps measured against 4 expected,
every case legal. A gate that needs explaining half the time is noise.

The defect is expressible in a single state instead: one record whose span is
partly shared and partly exterior. Nobody sets that on purpose. Roles come from
the polygons — a stretch is shared when another room's edge covers it — so the
check needs neither a build nor product code.

Two traps found by measurement, both of which produced false positives. Count
distinct rooms rather than edges: in a corner one room owns two edges, and
counting edges called every exterior corner a shared boundary — 12 and 14 false
positives. And do not sample the endpoints: an endpoint is a node, where a wall
legitimately touches two rooms, and including them reported 95 per cent exterior
on every wall abutting a shared one.

Mutation coverage, stated honestly: the endpoint mutant is killed by the
real-plan test. The rooms-versus-edges mutant survived — once endpoints are
excluded, counting edges gives the same answer on real plans, so that choice is
not load-bearing. I removed the mutant rather than ship a surviving one, and said
so in the code.

Issue: #287
User-Visible: no
2026-08-24 13:20:34 +03:00
Claude d5659478aa test: add a second real plan to the masonry gate
The second floor covers one class: the multi-wall corridor eating a neighbouring
wall. The first floor brings what neither it nor any synthetic fixture has —
three virtual spans, two wall columns, two solid edges with no thickness record
at all, and 127 noisy coordinates.

The two plans pin opposite states, which is worth more than two plans with the
same defect: one records a known debt of 181 steps, the other records cleanliness
at zero. A regression is caught in both directions.

A declared virtual span is a declared break, not a defect, so the rule from #285
would have reddened on the first floor's own contract. Samples lying on
open_spans are now excluded: 830 of 6800 on that plan, and the remaining gap
count is zero.

The two zero-thickness solid edges are two grid steps long and covered by the
bodies of their neighbours, so the browser sees no break. Their count is
therefore pinned in the model test rather than the smoke — if it grows, or if the
neighbours stop covering them, that shows up before it becomes a hole.

Both fixtures must stay noisy: the project's synthetic models carry exactly zero
noise, which is why #258 and #248 are not reproducible on them. A profile check
asserts at least a hundred noisy coordinates each, so a future write barrier run
over the fixtures cannot quietly rob them of their purpose.

Privacy: names, ids, markers, device bindings and layout are gone; geometry
stays, coordinates unchanged, because they are the point.

Issue: #286
User-Visible: no
2026-08-24 13:10:33 +03:00
Claude c654e0ec42 test(smoke): masonry of a real plan must not break
Eight closed issues on wall junctions — #271, #272, #275, #276, #277, #278,
\#279, #280 — shipped in beta.9, and the break in the owner's real plan
survived. Every one of them was accepted on synthetic fixtures: a cell-5
mixed-depth T, a rectilinear T with three equal half-depths. On those the fixes
work.

The smoke asks the product itself, through isPointInFill on the wall path,
whether masonry exists where the model promises it. That is independent of both
resolution and the pixel-diff thresholds which miss this class: a 45-step break
on a large plan is a fraction of a per cent of the frame, well under the 0.05
per cent scene tolerance.

Measured on the shipped code: four breaks, 181 grid steps in total, 45.25 each.
That number is derivable rather than incidental — the node joins a 30 cm
exterior wall, a 30 cm spur and an arm five steps long; half-depth 15,
MITRE_LIMIT 4, corridor radius 60, and 60 − 15 = 45 steps are cut out of the
neighbouring 20 cm wall. The corridor eats the masonry, which is what #271 and
\#275 describe.

The fixture is privacy-minimised — neutral room names and ids, no device
bindings — and keeps its coordinates, because they are the point. It lives in
test/fixtures rather than demo/fixtures on purpose: sourceFingerprint hashes the
.mjs of demo/fixtures and demo/golden, so anything added there staleizes the
committed bundle and the screenshot manifest at once. Verified after this
commit: bundle still fresh, screenshots still current.

The debt is recorded as numbers and compared exactly. Better and the test asks
for the numbers to be updated, which proves the improvement; worse and it
catches the regression. Verified by execution in both directions.

Issue: #285
User-Visible: no
2026-08-24 13:02:56 +03:00
Sergey Matyunin d8696315eb test: stabilize opening distance smoke
Issue: #281
User-Visible: no
2026-08-24 11:04:17 +03:00
Sergey Matyunin ed606c0f4a docs: refresh beta 9 screenshots
Issue: #279
Issue: #280
Issue: #281
User-Visible: no
2026-08-24 10:55:56 +03:00
Sergey Matyunin 89658c20fe build: prepare v1.67.0-beta.9 candidate
Issue: #279
Issue: #280
Issue: #281
User-Visible: yes
2026-08-24 10:54:02 +03:00
Claude a988f7c6f1 test: measure how far the stored geometry sits off the lattice
Stage 0 of ADR #282. A lattice node is k/240, which has no exact binary
representation, and a stored coordinate is a float. Nobody could say how much of
a real plan is affected, and Optimize promises to remove coordinate noise
without a definition of noise that can be checked.

latticeProfile splits every coordinate of the model into three populations,
because they are three different problems: exactly on a node, near a node but
not exact, and legitimately off grid. The middle one is the defect class behind
\#258, \#279 and the non-converging Optimize; the last one is authored geometry
the current model allows and must not be called a violation.

Measured on the owner's installation: space 1 has 208 coordinates, 33.65 per
cent exactly on a node and 65.38 per cent in the noise class; space 2 has 21.23
against 78.77. The worst deviation is 8e-8 of a step — invisible, and enough to
put a wall key in the neighbouring bucket.

The counterpart is what makes it worth having: every shipped fixture has zero
noise, all of its off-grid values being authored. Our own test data therefore
cannot reproduce this class by construction, which is why the owner finds these
defects and the gates do not. A test pins that property so it cannot drift.

No violations are produced, no gate turns red, and nothing is repaired: what to
do with a vertex 8e-8 from a node is the owner's decision, and this measures its
price first.

Issue: #283
User-Visible: no
2026-08-24 10:45:48 +03:00
Sergey Matyunin d73790aaab merge: synchronize dev into issue 281
Issue: #281
User-Visible: no
2026-08-24 10:44:23 +03:00
Sergey Matyunin 07bd1a6781 fix: reconcile outer walls before resize
Issue: #281
User-Visible: yes
2026-08-24 10:43:33 +03:00
Claude 0963f5b4dd docs(adr): decide the wall geometry representation
Nine of the last ten specs are one class of defect, 23 fix commits in thirty
days, 595 tolerance mentions across ten geometry modules, one every seventh line
in resize.ts. The specs rate their own risk at 9 and 10 of 10, and two titles
show where that arrived: a nearly orthogonal T junction, and a union failure
that must merely damage less.

The ADR names three properties of the data model that produce the stream — a
float coordinate on a 1/240 step, an identity derived instead of stored, and a
wall living in two representations at once — and records four stages against
them. Stage 0 is accepted for implementation; the rest are direction, in the
same sense as #34.

Issue: #282
User-Visible: no
2026-08-24 10:41:53 +03:00
Sergey Matyunin a3e5f30a3c docs: specify honest resize eligibility
Issue: #281
User-Visible: no
2026-08-24 10:35:13 +03:00
Sergey Matyunin 38ea8a9b9e fix: validate optimize opening rehosts
Issue: #280
User-Visible: yes
2026-08-24 10:33:05 +03:00
Sergey Matyunin 93fad8cfb4 docs: specify optimize rehost validation
Issue: #280
User-Visible: no
2026-08-24 10:28:59 +03:00
Sergey Matyunin c0c30d645a fix: preserve near-orthogonal wall junctions
Issue: #279
User-Visible: yes
2026-08-24 10:27:13 +03:00
Sergey Matyunin cd9916d72a docs: specify near-orthogonal junction protection
Issue: #279
User-Visible: no
2026-08-24 10:24:03 +03:00
Sergey Matyunin 8aa7fc970c Release v1.67.0-beta.8 candidate
Package the reviewed Optimize reconciliation, safe fixed-topology Resize and wall-union isolation fixes from #276, #277 and #278 as the eighth v1.67 prerelease candidate.

Issue: #278
User-Visible: yes
2026-08-24 09:39:11 +03:00
claude[bot] bb81ede47c docs: review document for #278
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
Validate / docs (push) Failing after 31s
Validate / process-workflow-sync (push) Successful in 31s
Validate / provenance (push) Successful in 37s
Validate / process-gate (push) Failing after 38s
Validate / changes (push) Successful in 35s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 48s
Issue: #278
User-Visible: no
2026-08-24 06:35:58 +00:00
Sergey Matyunin 79285ab1d2 docs: correct wall union mutation identifiers
Issue: #278
User-Visible: no
2026-08-24 09:30:29 +03:00
claude[bot] c123757f50 docs: review document for #278
Issue: #278
User-Visible: no
2026-08-24 06:29:20 +00:00
Sergey Matyunin 495043791e test: accept wall union isolation baselines
Issue: #278
User-Visible: no
Release: v1.67.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32695276495
2026-08-24 09:04:20 +03:00
Sergey Matyunin 598e7437f2 test: isolate optimize fixture from pending geometry write
Issue: #278
User-Visible: no
2026-08-24 08:55:38 +03:00
Sergey Matyunin df1a8330f4 docs: accept wall union screenshots
Issue: #278
User-Visible: no
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32694995425
2026-08-24 08:53:04 +03:00
Sergey Matyunin 8156f80140 fix: isolate wall union failures and guard geometry writes
Issue: #278
User-Visible: yes
2026-08-24 08:48:37 +03:00
claude[bot]andSergey Matyunin 930a911a36 docs: review document for #278
Issue: #278
User-Visible: no
2026-08-24 08:48:37 +03:00
Sergey Matyunin 4dd074d9a3 docs: address wall union spec review
Issue: #278
User-Visible: no
2026-08-24 08:48:37 +03:00
claude[bot]andSergey Matyunin bdd4f38cb9 docs: review document for #278
Issue: #278
User-Visible: no
2026-08-24 08:48:37 +03:00
Sergey Matyunin 4412f905bd docs: specify wall union isolation
Issue: #278
User-Visible: no
2026-08-24 08:48:37 +03:00
claude[bot] e68b494555 docs: review document for #277
Validate / process-workflow-sync (push) Successful in 47s
Validate / docs (push) Successful in 52s
Validate / provenance (push) Successful in 45s
Validate / process-gate (push) Failing after 32s
Validate / changes (push) Successful in 32s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 43s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / golden (push) Skipped
Issue: #277
User-Visible: no
2026-08-24 05:15:13 +00:00
Sergey Matyunin a548d4f17d docs: accept Resize performance screenshots
Reviewed artifact: https://github.com/Matysh/houseplan-card/actions/runs/32691836025

Issue: #277
User-Visible: no
2026-08-24 07:58:13 +03:00
Sergey Matyunin 9c5b66c3e4 perf: fingerprint Resize handles once per frame
Issue: #277
User-Visible: no
2026-08-24 07:56:49 +03:00
claude[bot] 448ceea399 docs: review document for #277
Issue: #277
User-Visible: no
2026-08-24 04:53:17 +00:00
Sergey Matyunin 241821c40e test: accept beta.8 safe resize baselines
Accept the complete 106-frame Linux golden artifact. All 104 existing scenarios passed; six byte-only PNG refreshes have zero pixel diff, and both new safe-resize light/dark candidates were manually reviewed.

Issue: #277
User-Visible: no
Release: v1.67.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32689229827
2026-08-24 07:21:19 +03:00
Sergey Matyunin b02a37123d test: align mixed-thickness smoke with safe resize
Issue: #277
User-Visible: no
2026-08-24 07:20:25 +03:00
Sergey Matyunin 03b45f2e37 build: synchronize safe resize release bundle
Issue: #277
User-Visible: no
2026-08-24 07:13:56 +03:00
Sergey Matyunin a5a0f6da2c docs: accept safe resize screenshots
Reviewed artifact: https://github.com/Matysh/houseplan-card/actions/runs/32689026469

Issue: #277
User-Visible: no
2026-08-24 07:12:32 +03:00
Sergey Matyunin 2e22ed7938 fix: stop resize before lossy metadata rekey
Issue: #277
User-Visible: yes
2026-08-24 07:10:27 +03:00
Sergey Matyunin cd0a682ad7 test: align legacy resize smokes with safe mode
Issue: #277
User-Visible: no
2026-08-24 07:07:20 +03:00
Sergey Matyunin 6335b0e519 fix: make room resize topology-safe
Issue: #277
User-Visible: yes
2026-08-24 07:03:24 +03:00
claude[bot]andSergey Matyunin 8b227962b1 docs: review document for #277
Issue: #277
User-Visible: no
2026-08-24 06:31:15 +03:00
Sergey Matyunin 1bbd53ee52 docs: address safe resize spec review
Issue: #277
User-Visible: no
2026-08-24 06:31:15 +03:00
claude[bot]andSergey Matyunin 7be7f8cd99 docs: review document for #277
Issue: #277
User-Visible: no
2026-08-24 06:31:15 +03:00
Sergey Matyunin 9e35650470 docs: specify safe fixed-topology resize
Issue: #277
User-Visible: no
2026-08-24 06:31:15 +03:00
claude[bot] 29a0305558 docs: review document for #276
Validate / backend (push) Failing after 16m13s
Validate / smoke (1) (push) Failing after 24m11s
Validate / smoke (2) (push) Failing after 25m33s
Validate / smoke (3) (push) Failing after 16m12s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 18m2s
Validate / performance_smoke (push) Failing after 33m56s
Validate / process-workflow-sync (push) Successful in 34s
Validate / docs (push) Failing after 36s
Validate / process-gate (push) Failing after 41s
Validate / provenance (push) Successful in 42s
Validate / changes (push) Successful in 22s
Validate / hacs (push) Failing after 13s
Validate / reuse (push) Successful in 40s
Validate / hassfest (push) Failing after 17s
Validate / frontend (push) Successful in 6m56s
Issue: #276
User-Visible: no
2026-08-24 03:29:16 +00:00
Sergey Matyunin ff2f2063cf test: stabilize Optimize overhead benchmark
Use paired ABBA/BAAB batches for the p95 gate and accept the complete 104-frame Linux golden artifact. The four new #276 scenes were visually reviewed; every existing scene passed, and the nine byte changes contain zero pixels above their comparison threshold.

Issue: #276
User-Visible: no
Release: v1.67.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32684802336
2026-08-24 06:19:13 +03:00
claude[bot] b5519e997b docs: review document for #276
Issue: #276
User-Visible: no
2026-08-24 03:13:45 +00:00
Sergey Matyunin 82fe98bcd3 test: prove coincident partition acceptance
Issue: #276
User-Visible: no
2026-08-24 05:57:57 +03:00
claude[bot] 57fdfdbd83 docs: review document for #276
Issue: #276
User-Visible: no
2026-08-24 02:51:26 +00:00
Sergey Matyunin 496e79a6df docs: refresh screenshot fingerprint
Issue: #276
User-Visible: no
2026-08-24 05:32:14 +03:00
Sergey Matyunin 434995beb2 test: strengthen coincident partition mutation
Issue: #276
User-Visible: no
2026-08-24 05:29:01 +03:00
Sergey Matyunin a605611105 fix: reconcile coincident partitions during Optimize
Issue: #276
User-Visible: yes
2026-08-24 05:27:20 +03:00
claude[bot] ec315ad7c1 docs: review document for #276
Issue: #276
User-Visible: no
2026-08-24 02:11:52 +00:00
Sergey Matyunin cfc8f2114d docs: cover nested coincident partition thickness
Issue: #276
User-Visible: no
2026-08-24 05:03:58 +03:00
claude[bot] d5a0452fb5 docs: review document for #276
Issue: #276
User-Visible: no
2026-08-24 02:01:38 +00:00
Sergey Matyunin ca5166430b docs: address spec review for partition reconciliation
Issue: #276
User-Visible: no
2026-08-24 04:56:33 +03:00
claude[bot] 5c2c2578e1 docs: review document for #276
Issue: #276
User-Visible: no
2026-08-24 01:55:39 +00:00
Sergey Matyunin a06c94b967 docs: specify coincident partition reconciliation
Issue: #276
User-Visible: no
2026-08-24 04:48:13 +03:00
Sergey Matyunin 240316a639 test: accept beta.7 Linux golden baselines
Accept the complete 100-frame Linux golden artifact after manual review of every diff and all four non-passing candidate frames.

Issue: #275
User-Visible: no
Release: v1.67.0-beta.7
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32673773610
2026-08-24 02:38:19 +03:00
Sergey Matyunin 678b1996fc docs: accept beta.7 canonical screenshots
Accept the complete 10-frame Linux docs artifact captured from the exact beta.7 candidate SHA.

Reviewed artifact: https://github.com/Matysh/houseplan-card/actions/runs/32673778582

Issue: #275
User-Visible: no
2026-08-24 02:38:14 +03:00
Sergey Matyunin ca8390366f Release v1.67.0-beta.7 candidate
Package the reviewed orthogonal multi-wall strip containment fix from #275 as the seventh v1.67 prerelease candidate.

Issue: #275
User-Visible: yes
2026-08-24 02:29:41 +03:00
claude[bot] 8c4dd16754 docs: review document for #275
Issue: #275
User-Visible: no
2026-08-23 23:10:44 +00:00
Sergey Matyunin 7cef3014a2 test: keep multi-wall regression mutants executable
Issue: #275
User-Visible: no
2026-08-24 01:49:12 +03:00
Sergey Matyunin 7bcc5b285b fix: preserve orthogonal wall strips at multi-wall joins
Issue: #275
User-Visible: yes
2026-08-24 01:47:22 +03:00
claude[bot] 29f6ad2969 docs: review document for #275
Issue: #275
User-Visible: no
2026-08-23 22:26:09 +00:00
Sergey Matyunin 3bf5a1db26 docs: protect orthogonal ray strips in multi-wall joins
Issue: #275
User-Visible: no
2026-08-24 01:18:56 +03:00
claude[bot] 423c20cd23 docs: review document for #275
Issue: #275
User-Visible: no
2026-08-23 22:14:48 +00:00
Sergey Matyunin 62dd02d823 docs: constrain multi-wall containment to rectilinear nodes
Issue: #275
User-Visible: no
2026-08-24 01:04:17 +03:00
claude[bot] e04e168ac4 docs: review document for #275
Issue: #275
User-Visible: no
2026-08-23 22:02:29 +00:00
Sergey Matyunin 762e9f4b32 docs: specify multi-wall strip containment
Issue: #275
User-Visible: no
2026-08-24 00:55:02 +03:00
Sergey Matyunin 1daa852622 docs: accept beta.6 canonical screenshots
Accept the complete ten-frame Linux artifact captured from candidate 8d2a050. All five changed frames were reviewed against the committed set; they preserve the intended UI while recording the tapered #272 geometry and beta.6 source fingerprint. Run: https://github.com/Matysh/houseplan-card/actions/runs/32667107212

Issue: #272
User-Visible: no
2026-08-24 00:20:20 +03:00
Sergey Matyunin 8d2a0505e3 Release v1.67.0-beta.6 candidate
Package the reviewed S8 fixes for finite and hole-free wall junctions, topology-safe Optimize cleanup, and wireless-controller presentation parity.

Issue: #271
Issue: #272
Issue: #273
Issue: #274
User-Visible: yes
2026-08-24 00:13:35 +03:00
Sergey Matyunin b05eb68c3a perf: taper multi-wall exterior corridors
Keep the finite #272 exit as a safe subset of the square connector while removing two contour corners per bevel. The simpler canonical path preserves the zero-hole and finite-ray contracts and restores headroom in the large-house Glow gate.

Issue: #272
User-Visible: no
2026-08-24 00:10:57 +03:00
Sergey Matyunin 22d0f05650 docs: accept canonical screenshots for bevel batching
Accept the complete ten-frame Linux artifact from the exact #272 performance gate-fix source. The content matches the reviewed UI while replacing the non-canonical Windows captures that had entered dev with #274.

Issue: #272
User-Visible: no
2026-08-23 23:33:39 +03:00
Sergey Matyunin 4266b625fe perf: batch multi-wall bevel cuts
Subtract each node's combined local bevel mask once instead of traversing the large wall and paper geometry once per triangle and exterior connector. The equivalent mask keeps the reviewed #272 geometry while restoring the large-house Glow state-update budget.

Issue: #272
User-Visible: no
2026-08-23 23:29:42 +03:00
Sergey Matyunin 57a967b9a5 test(golden): accept beta.6 wall junction baselines
Accepted the complete 98-scenario Linux artifact after visual review. Intentional differences are limited to the finite degree-3 junction repair and the bounded multi-wall bevel closures; all remaining candidate bytes come from the same complete canonical capture.

Issue: #271
Issue: #272
User-Visible: no
Release: v1.67.0-beta.6
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32661228757
2026-08-23 22:57:57 +03:00
claude[bot] ad1ee9d13d docs: review document for #274
Validate / docs (push) Successful in 30s
Validate / process-workflow-sync (push) Successful in 29s
Validate / provenance (push) Successful in 38s
Validate / changes (push) Successful in 27s
Validate / process-gate (push) Failing after 33s
Validate / reuse (push) Successful in 50s
Validate / hacs (push) Failing after 16s
Validate / hassfest (push) Failing after 15s
Validate / backend (push) Failing after 9m9s
Validate / frontend (push) Successful in 9m54s
Validate / smoke (1) (push) Failing after 17m51s
Validate / smoke (3) (push) Failing after 18m23s
Validate / smoke (2) (push) Failing after 18m55s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 25m55s
Validate / performance_smoke (push) Failing after 50m54s
Issue: #274
User-Visible: no
2026-08-23 19:45:24 +00:00
Sergey Matyunin c9b8497f58 fix: keep wireless controller face consistent
Issue: #274
User-Visible: yes
2026-08-23 22:24:32 +03:00
claude[bot] 7b23035e21 docs: review document for #274
Issue: #274
User-Visible: no
2026-08-23 19:15:04 +00:00
Sergey Matyunin 9b764277db docs(spec): define wireless controller presentation parity
Issue: #274
User-Visible: no
2026-08-23 22:08:19 +03:00
claude[bot] d83b0c512f docs: review document for #272
Issue: #272
User-Visible: no
2026-08-23 18:44:54 +00:00
Sergey Matyunin 5fd4e2249a docs: refresh merged junction evidence
Issue: #272
User-Visible: no
2026-08-23 21:29:35 +03:00
Sergey Matyunin 3fedcaf1d5 fix: open multi-wall bevel holes to exterior
Issue: #272
User-Visible: yes
2026-08-23 21:23:42 +03:00
claude[bot]andSergey Matyunin a28158c041 docs: review document for #272
Issue: #272
User-Visible: no
2026-08-23 21:20:28 +03:00
Sergey Matyunin 21b04ed0c2 spec(walls): tighten multi-wall hole evidence
Issue: #272
User-Visible: no
2026-08-23 21:20:28 +03:00
claude[bot]andSergey Matyunin f3d4a79787 docs: review document for #272
Issue: #272
User-Visible: no
2026-08-23 21:20:28 +03:00
Sergey Matyunin dd4ebb7401 docs(spec): require hole-free multi-wall joins
Issue: #272
User-Visible: no
2026-08-23 21:20:28 +03:00
claude[bot] 67863d68a4 docs: review document for #271
Issue: #271
User-Visible: no
2026-08-23 18:19:52 +00:00
claude[bot]andSergey Matyunin c0b231fa06 docs: review document for #271
Issue: #271
User-Visible: no
2026-08-23 21:09:56 +03:00
Sergey Matyunin c190a698c5 docs: refresh merged finite-ray evidence
Issue: #271
User-Visible: no
2026-08-23 21:09:56 +03:00
Sergey Matyunin 8bd910ed84 test: cover finite wall beside an opening
Issue: #271
User-Visible: no
2026-08-23 21:09:56 +03:00
claude[bot]andSergey Matyunin dc94e016ca docs: review document for #271
Issue: #271
User-Visible: no
2026-08-23 21:06:20 +03:00
Sergey Matyunin e3b635aba2 fix(walls): bound multi-wall repairs to finite rays
Issue: #271
User-Visible: yes
2026-08-23 21:06:20 +03:00
claude[bot]andSergey Matyunin ffbbb217b5 docs: review document for #271
Issue: #271
User-Visible: no
2026-08-23 21:06:00 +03:00
Sergey Matyunin 154af2692f docs(spec): define finite multi-wall ray contract
Issue: #271
User-Visible: no
2026-08-23 21:06:00 +03:00
claude[bot] d68e876f78 docs: review document for #273
Issue: #273
User-Visible: no
2026-08-23 17:33:06 +00:00
Sergey Matyunin 3ab3cdc1e4 docs: record optimizer topology evidence
Issue: #273
User-Visible: no
2026-08-23 20:23:08 +03:00
Sergey Matyunin 105a8f70eb fix: collapse thickness island beside one T-node
Issue: #273
User-Visible: yes
2026-08-23 20:20:03 +03:00
claude[bot] 3e8733ad27 docs: review document for #273
Issue: #273
User-Visible: no
2026-08-23 17:10:22 +00:00
Sergey Matyunin e2de0dbcc4 spec(optimize): narrow topology island guard
Issue: #273
User-Visible: no
2026-08-23 20:03:50 +03:00
claude[bot] 6e2247aa3d docs: review document for #273
Issue: #273
User-Visible: no
2026-08-23 16:50:00 +00:00
Sergey Matyunin b5fbe633a6 docs(spec): handle topology-adjacent thickness islands
Issue: #273
User-Visible: no
2026-08-23 19:41:22 +03:00
Sergey Matyunin b86da0d419 test(golden): accept v1.67.0-beta.5 wall baselines
Accepted the complete 98-scenario Linux artifact after visual review. The intentional changes are the new wall-key round-trip regression scene and four large-house frames where #258 restores the fixture's real walls; all 93 passing candidates remain on their prior bytes.

Issue: #258
User-Visible: no
Release: v1.67.0-beta.5
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32649309598
2026-08-23 18:57:59 +03:00
Sergey Matyunin 1d0e0c5ab8 chore(release): prepare v1.67.0-beta.5
Issue: #258
Issue: #261
Issue: #262
User-Visible: yes
2026-08-23 18:40:44 +03:00
claude[bot] 3e3fb9ceaa docs: review document for #262
Issue: #262
User-Visible: no
2026-08-23 13:54:22 +00:00
Sergey Matyunin 5fa2dc2893 docs: mark child re-add ready for review
Issue: #262
User-Visible: no
2026-08-23 16:41:15 +03:00
Sergey Matyunin 2d6b6006fe docs: record canonical screenshots for child re-add
Issue: #262
User-Visible: no
2026-08-23 16:40:24 +03:00
Sergey Matyunin 8cdf6b4894 fix: restore one child of a deleted device
Issue: #262
User-Visible: yes
2026-08-23 16:32:12 +03:00
claude[bot] a0b39b626f docs: review document for #262
Issue: #262
User-Visible: no
2026-08-23 13:25:44 +00:00
Sergey Matyunin 7b3ea3769a docs: specify child entity re-add lifecycle
Issue: #262
User-Visible: no
2026-08-23 16:19:48 +03:00
claude[bot] 98dce8d0b4 docs: review document for #261
Issue: #261
User-Visible: no
2026-08-23 13:13:14 +00:00
Sergey Matyunin 0d0a9dd87a docs: record rebased implementation evidence
Issue: #261
User-Visible: no
2026-08-23 15:41:55 +03:00
Sergey Matyunin 4eda889922 chore: refresh validation after rebase
Issue: #261
User-Visible: no
2026-08-23 15:41:18 +03:00
Sergey Matyunin 2e53cb0e05 test: register and refresh junction evidence
Issue: #261
User-Visible: no
2026-08-23 15:35:39 +03:00
Sergey Matyunin 0dc6c39784 fix: retain bounded T-junction masonry
Issue: #261
User-Visible: yes
2026-08-23 15:35:27 +03:00
claude[bot]andSergey Matyunin 61b437fd3f docs: review document for #261
Issue: #261
User-Visible: no
2026-08-23 15:35:26 +03:00
Sergey Matyunin 9f6efd0c11 docs: specify multi-wall wedge repair
Issue: #261
User-Visible: no
2026-08-23 15:35:26 +03:00
Claude 18d504631c test(smoke): cover the binding picker
git grep _bindingCandidates -- test/ demo/ was empty: the function that decides
what the user is offered under Add had no test and no smoke. Tombstones were
covered from every side, the list they filter was never asked. That is how #262
reached us through a user report instead of a gate — smoke_hidden_flag assigns
binding straight into _markerDialog and bypasses the picker entirely.

Twelve checks against the real bundle: a deleted device is offered again, a
deleted plain entity is offered again behind the checkbox, the checkbox itself
is the trap (a device entity is absent with it off, present with it on, and it
starts off for a new marker), a placed binding is not duplicated, and re-adding
replaces the tombstone and leaves the picker.

The twelfth pins the known defect #262 as current behaviour: a device tombstone
still hides its child entities. Fixing it turns the check red and forces it to
be flipped, so the fix cannot pass the coverage by.

smoke-links registers only the pure tombstone helpers. The picker names itself
and is found by direct match; the helpers are not named anywhere in the
scenario. Verified by probe: touching src/devices.ts alone selects this smoke
as a registered link, and without the entry nothing would select it.

Issue: #263
User-Visible: no
2026-08-23 15:32:57 +03:00
claude[bot] 9f63163339 docs: review document for #258
Validate / docs (push) Failing after 21s
Validate / process-workflow-sync (push) Successful in 26s
Validate / changes (push) Successful in 32s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / provenance (push) Successful in 1m4s
Validate / process-gate (push) Successful in 51s
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: #258
User-Visible: no
2026-08-23 11:46:19 +00:00
Sergey Matyunin 61bf537b58 docs: correct wall-key changelog claim
Issue: #258
User-Visible: no
2026-08-23 14:40:14 +03:00
claude[bot] aa85a95b32 docs: review document for #258
Issue: #258
User-Visible: no
2026-08-23 11:38:37 +00:00
Sergey Matyunin 28eaf86662 fix: stabilize wall keys across storage round-trips
Issue: #258
User-Visible: yes
2026-08-23 14:21:36 +03:00
claude[bot]andSergey Matyunin 46a83de181 docs: review document for #258
Issue: #258
User-Visible: no
2026-08-23 14:18:10 +03:00
Sergey Matyunin b9a5f6c4e4 docs(spec): define stable wall key round-trip
Issue: #258
User-Visible: no
2026-08-23 14:18:10 +03:00
Claude 5e95a28406 test: grade the wall-key invariant by what the product does
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 1m13s
Validate / provenance (push) Failing after 1m16s
Validate / process-gate (push) Failing after 1m25s
Validate / changes (push) Successful in 1m9s
Validate / reuse (push) Successful in 58s
Validate / hacs (push) Failing after 17s
Validate / hassfest (push) Failing after 20s
Validate / frontend (push) Successful in 10m20s
Validate / backend (push) Failing after 9m54s
Validate / smoke (1) (push) Failing after 18m2s
Validate / smoke (2) (push) Failing after 17m49s
Validate / smoke (3) (push) Failing after 15m44s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 15m1s
Validate / performance_smoke (push) Failing after 13m49s
The first cut of checkWallKeys compared the stored key against endpoints
snapped to the lattice and called any mismatch a violation. Both halves were
wrong, and measurement says so: wallIntervals reports the query key for the
disputed edge as 0.887500,0.195833@1.5706, i.e. the form built from the
coordinates as stored, and the two owner configurations that differ in exactly
these keys produce byte-identical wall bodies and multi-wall node maps. The
check would have reddened a plan that renders correctly.

Graded now: a drift inside the tolerant fallback's half-pitch reach is an
observation, a key beyond it or one that does not parse as coordinates is a
violation. The threshold is expressed in grid steps with a 1e-3 slack — with a
relative 1e-6 the four identically drifted records of one plan split between
the two classes on their last bits, so the check repeated the very rounding tie
it exists to expose.

visual-matrix leaves KEY_CONTRACT_DEBT: its keys drift inside the reach and now
read as observations. large-house stays — its labels do not parse, and
wallIntervals shows all 80 solid edges resolving to zero thickness (#260).

Issue: #259
User-Visible: no
2026-08-23 14:11:22 +03:00
Claude 2970b8091b ci(process): ask the reviewer about the wall key invariant
Issue: #259
User-Visible: no
2026-08-23 13:51:37 +03:00
Claude 7b267fb632 test: check the wall key against the lattice edge
#258 lost two thickness records to a rounding tie: wallKey quantises the
midpoint with Math.round, and a wall of odd step length has its midpoint
exactly on the tie, where the exact node 83/240 and the stored 0.345833333
fall on opposite sides. The #254 invariants pass on that file — their edge
tolerance is 0.004 and the drift is 0.00417, so the check sits on its own
boundary.

checkWallKeys compares strings, and against endpoints snapped to the lattice:
keying from the raw endpoints flags the healthy state and passes the broken
one, which is what the first formulation in #258 got wrong. Measured on the
owner's before/after pair: 0 findings before, exactly the two artefact walls
after.

Two shipped fixtures write keys off the contract (#260); recorded as a number,
so the debt can neither grow nor be silently fixed.

Issue: #259
User-Visible: no
2026-08-23 13:50:37 +03:00
428 changed files with 58227 additions and 7882 deletions
+7 -4
View File
@@ -455,10 +455,13 @@ jobs:
инварианты модели (#254): `npm test` уже гоняет их на всех моделях
проекта, а на конкретной конфигурации они проверяются командой
`npm run invariants -- --config <экспорт или ответ config/get>`.
Два вопроса, на которые они отвечают, и оба уже стоили продукту
дефектов: не исчезла ли запись толщины (#253) и разрешима ли каждая
ссылка (#244, #252). Если задача меняет геометрию, а инварианты в
отчёте не названы — это непрогнанный гейт, а не мелочь.
Три вопроса, на которые они отвечают, и все три уже стоили
продукту дефектов: не исчезла ли запись толщины (#253), разрешима ли
каждая ссылка (#244, #252) и равен ли ключ записи толщины ключу
решёточного ребра (#258, #259). Последний сравнивает строки без
допусков: сдвиг ключа на один шаг решётки равен допуску первых двух,
поэтому они на нём промахиваются. Если задача меняет геометрию, а
инварианты в отчёте не названы — это непрогнанный гейт, а не мелочь.
По необходимости, и «необходимость» определяется diff'ом и AC:
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те,
+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-beta.4"
VERSION = "1.67.0-rc.3"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
@@ -1,7 +1,7 @@
"""Lossless, allow-listed canonicalisation of persisted geometry.
The frontend mirrors this module in src/coordinate-canonicalization.ts.
Keep the precision, scalar formula and field allow-list in lockstep; a shared
Keep the precision, lattice formula and field allow-list in lockstep; a shared
fixture is exercised by both runtimes.
"""
from __future__ import annotations
@@ -13,10 +13,12 @@ from typing import Any
COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
LATTICE_GRID_N = 240
LATTICE_NOISE_STEPS = 1e-4
def canonicalize_number(value: Any) -> Any:
"""Return one stable IEEE-754 representation for an allow-listed number."""
"""Return one stable IEEE-754 representation for an allow-listed scalar."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
@@ -32,6 +34,22 @@ def canonicalize_number(value: Any) -> Any:
return result
def canonicalize_lattice_coordinate(value: Any) -> Any:
"""Collapse near-node noise while preserving authored off-grid values."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
if not math.isfinite(number):
return value
scaled = number * LATTICE_GRID_N
# JavaScript Math.round: ties go toward +infinity, unlike Python round().
nearest = math.floor(scaled + 0.5)
if abs(scaled - nearest) < LATTICE_NOISE_STEPS:
result = nearest / LATTICE_GRID_N
return 0.0 if result == 0 else result
return canonicalize_number(number)
def _record(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
@@ -42,37 +60,43 @@ def _records(value: Any) -> list[dict[str, Any]]:
return [item for item in value if isinstance(item, dict)]
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
def _scalar_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_number(record[name])
def _point(value: Any) -> None:
def _lattice_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_lattice_coordinate(record[name])
def _lattice_point(value: Any) -> None:
if not isinstance(value, list):
return
for index in range(min(2, len(value))):
value[index] = canonicalize_number(value[index])
value[index] = canonicalize_lattice_coordinate(value[index])
def _points(value: Any) -> None:
def _lattice_points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
_point(point)
_lattice_point(point)
def canonicalize_position(position: Any) -> Any:
"""Canonicalise x/y in one layout record without touching its metadata."""
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
result = copy.deepcopy(position)
record = _record(result)
if record is not None:
_fields(record, ("x", "y"))
_lattice_fields(record, ("x", "y"))
return result
def canonicalize_layout_geometry(layout: Any) -> Any:
"""Canonicalise x/y in every layout record, preserving future fields."""
"""Canonicalise lattice x/y in every layout record."""
result = copy.deepcopy(layout)
record = _record(result)
if record is None:
@@ -80,7 +104,7 @@ def canonicalize_layout_geometry(layout: Any) -> Any:
for position in record.values():
item = _record(position)
if item is not None:
_fields(item, ("x", "y"))
_lattice_fields(item, ("x", "y"))
return result
@@ -92,7 +116,7 @@ def canonicalize_config_geometry(config: Any) -> Any:
return result
for space in _records(root.get("spaces")):
_fields(
_scalar_fields(
space,
(
"plan_x",
@@ -105,45 +129,48 @@ def canonicalize_config_geometry(config: Any) -> Any:
)
for room in _records(space.get("rooms")):
_fields(room, ("x", "y", "w", "h"))
_points(room.get("poly"))
_lattice_fields(room, ("x", "y", "w", "h"))
_lattice_points(room.get("poly"))
for wall in _records(space.get("walls")):
_point(wall.get("a"))
_point(wall.get("b"))
_lattice_point(wall.get("a"))
_lattice_point(wall.get("b"))
for opening in _records(space.get("openings")):
_fields(opening, ("x", "y", "angle", "length"))
_lattice_fields(opening, ("x", "y"))
_scalar_fields(opening, ("angle", "length"))
host = _record(opening.get("host"))
if host is not None:
_fields(host, ("t",))
_scalar_fields(host, ("t",))
for decor in _records(space.get("decor")):
kind = decor.get("kind")
if kind == "line":
_fields(decor, ("x1", "y1", "x2", "y2"))
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
elif kind in ("rect", "ellipse", "furniture"):
_fields(decor, ("x", "y", "w", "h", "angle"))
_lattice_fields(decor, ("x", "y", "w", "h"))
_scalar_fields(decor, ("angle",))
elif kind == "text":
_fields(decor, ("x", "y", "scale", "angle"))
_lattice_fields(decor, ("x", "y"))
_scalar_fields(decor, ("scale", "angle"))
for draft in _records(space.get("room_drafts")):
_points(draft.get("points"))
_lattice_points(draft.get("points"))
for partition in _records(space.get("partitions")):
_point(partition.get("a"))
_point(partition.get("b"))
_lattice_point(partition.get("a"))
_lattice_point(partition.get("b"))
for column in _records(space.get("wall_columns")):
_point(column.get("center"))
_lattice_point(column.get("center"))
if column.get("shape") == "square":
_fields(column, ("angle",))
_scalar_fields(column, ("angle",))
for span in _records(space.get("open_spans")):
_point(span.get("a"))
_point(span.get("b"))
_lattice_point(span.get("a"))
_lattice_point(span.get("b"))
for marker in _records(root.get("markers")):
_fields(marker, ("angle",))
_scalar_fields(marker, ("angle",))
return result
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-beta.4"
"version": "1.67.0-rc.3"
}
+434 -4
View File
@@ -5,6 +5,7 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
from __future__ import annotations
from collections import Counter
import math
import re
import voluptuous as vol
@@ -80,10 +81,416 @@ class PartitionOpeningJambMarginError(ValueError):
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
# user setting.
NORMALIZED_CANVAS_CELLS = 240.0
_DEFAULT_ROOM_WALL_CM = 15.0
_OPTIMIZE_REHOST_EPSILON = 1e-8
def _room_polygon(room: dict) -> list[list[float]]:
poly = room.get("poly")
if isinstance(poly, list) and len(poly) >= 3:
return poly
if all(key in room for key in ("x", "y", "w", "h")):
x, y = float(room["x"]), float(room["y"])
w, h = float(room["w"]), float(room["h"])
return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
return []
def _segment_metrics(a: list, b: list) -> tuple[float, float, float]:
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
return dx, dy, math.hypot(dx, dy)
def _line_distance(point: list, a: list, b: list) -> float:
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return math.inf
return abs((float(point[0]) - float(a[0])) * dy
- (float(point[1]) - float(a[1])) * dx) / length
def _projection(point: list, a: list, b: list) -> float:
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return math.inf
return ((float(point[0]) - float(a[0])) * dx
+ (float(point[1]) - float(a[1])) * dy) / length
def _segment_covers(a: list, b: list, target_a: list, target_b: list) -> bool:
"""Return whether one exact collinear segment covers the target."""
_, _, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
if (_line_distance(target_a, a, b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(target_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
return False
for point in (target_a, target_b):
along = _projection(point, a, b)
if along < -_OPTIMIZE_REHOST_EPSILON or along > length + _OPTIMIZE_REHOST_EPSILON:
return False
return True
def _segments_overlap_on_axis(a: list, b: list, other_a: list, other_b: list) -> bool:
if (_line_distance(other_a, a, b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(other_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
return False
_, _, length = _segment_metrics(a, b)
lo, hi = sorted((_projection(other_a, a, b), _projection(other_b, a, b)))
return min(length, hi) - max(0.0, lo) > _OPTIMIZE_REHOST_EPSILON
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 #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:
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):
return False
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
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}
if old_stable != new_stable:
return False
try:
t = float(old_host["t"])
x, y = float(opening["x"]), float(opening["y"])
opening_angle = float(opening["angle"])
opening_length = float(opening["length"])
except (KeyError, TypeError, ValueError):
return False
if not (0 <= t <= 1 and opening_length > 0):
return False
expected_x, expected_y = float(a[0]) + dx * t, float(a[1]) + dy * t
expected_angle = math.degrees(math.atan2(dy, dx))
if expected_angle >= 90:
expected_angle -= 180
elif expected_angle < -90:
expected_angle += 180
if (abs(x - expected_x) > _OPTIMIZE_REHOST_EPSILON
or abs(y - expected_y) > _OPTIMIZE_REHOST_EPSILON
or _angle_delta_mod_180(opening_angle, expected_angle) > 1e-7):
return False
along = t * length
if (along - opening_length / 2 < -_OPTIMIZE_REHOST_EPSILON
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
try:
center = [float(other["x"]), float(other["y"])]
other_angle = float(other["angle"])
other_length = float(other["length"])
except (KeyError, TypeError, ValueError):
continue
if (_line_distance(center, a, b) > _OPTIMIZE_REHOST_EPSILON
or _angle_delta_mod_180(other_angle, expected_angle) > 1e-7):
continue
other_along = _projection(center, a, b)
if (min(along + opening_length / 2, other_along + other_length / 2)
- max(along - opening_length / 2, other_along - other_length / 2)
> _OPTIMIZE_REHOST_EPSILON):
return False
return True
def validate_partition_opening_hosts(
config: dict, previous: dict | None = None
config: dict, previous: dict | None = None, *, allow_optimize_rehost: bool = False
) -> None:
"""Validate hosted-opening write deltas without rejecting legacy reads.
@@ -114,9 +521,13 @@ def validate_partition_opening_hosts(
opening_id = str(opening.get("id", ""))
old = old_openings.get(opening_id)
if old and old.get("host") is not None and opening.get("host") is None:
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; host removed"
)
if not (allow_optimize_rehost and old_space
and _safe_optimize_partition_rehost(
space, old_space, opening, old
)):
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; host removed"
)
host = opening.get("host")
if host is None:
continue
@@ -126,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
@@ -156,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"}
+6 -1
View File
@@ -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",
})
@@ -1624,7 +1626,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
validate_opening_passages(msg["config"], config_data.get("config"))
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
validate_partition_opening_hosts(
msg["config"], config_data.get("config"),
allow_optimize_rehost=True,
)
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
+92
View File
@@ -0,0 +1,92 @@
// #276: same-process incremental cost of coincident-partition reconciliation
// inside the complete Optimize candidate pass on the deterministic large house.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import { optimizePlans } from '../test-build/plan-optimizer.js';
const WARMUPS = 5;
const SAMPLES = 20;
const BATCH_SIZE = 10;
const RELATIVE_OVERHEAD = 0.15;
const ABSOLUTE_OVERHEAD_MS = 25;
const fixture = makeLargeHouseFixture();
const emptyReconciliation = (rawSpace, _model, walls) => ({
walls: walls || [],
partitions: Array.isArray(rawSpace?.partitions) ? rawSpace.partitions : [],
openings: Array.isArray(rawSpace?.openings) ? rawSpace.openings : [],
partitionsReconciled: 0,
openingsRehosted: 0,
});
const baseline = () => optimizePlans(fixture.config, {}, {}, {
reconcileCoincidentPartitions: emptyReconciliation,
});
const candidate = () => optimizePlans(fixture.config, {});
const timed = (operation) => {
const start = performance.now();
for (let index = 0; index < BATCH_SIZE; index++) {
const result = operation();
if (!result || !result.report) throw new Error('Optimize candidate returned no report');
}
return (performance.now() - start) / BATCH_SIZE;
};
for (let index = 0; index < WARMUPS; index++) {
baseline();
candidate();
}
const baselineTimes = [];
const candidateTimes = [];
const overheadTimes = [];
for (let index = 0; index < SAMPLES; index++) {
// ABBA/BAAB cancels first-order clock drift and balances cache/GC order;
// batching then averages timer noise before the paired p95 is computed.
const candidateFirst = index % 2 === 1;
const first = timed(candidateFirst ? candidate : baseline);
const second = timed(candidateFirst ? baseline : candidate);
const third = timed(candidateFirst ? baseline : candidate);
const fourth = timed(candidateFirst ? candidate : baseline);
const baselineMs = candidateFirst ? (second + third) / 2 : (first + fourth) / 2;
const candidateMs = candidateFirst ? (first + fourth) / 2 : (second + third) / 2;
baselineTimes.push(baselineMs);
candidateTimes.push(candidateMs);
overheadTimes.push(candidateMs - baselineMs);
}
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const summary = (values) => ({
min: Math.min(...values),
median: quantile(values, 0.5),
p95: quantile(values, 0.95),
max: Math.max(...values),
});
const baselineSummary = summary(baselineTimes);
const candidateSummary = summary(candidateTimes);
const overheadSummary = summary(overheadTimes);
const measuredOverheadP95 = Math.max(0, overheadSummary.p95);
const relativeP95 = measuredOverheadP95
/ Math.max(baselineSummary.p95, Number.EPSILON);
const pass = measuredOverheadP95 <= ABSOLUTE_OVERHEAD_MS
&& relativeP95 <= RELATIVE_OVERHEAD;
const report = {
issue: 276,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
batchSize: BATCH_SIZE,
baseline: baselineSummary,
candidate: candidateSummary,
pairedOverhead: overheadSummary,
budgets: {
relativeOverhead: RELATIVE_OVERHEAD,
absoluteOverheadMs: ABSOLUTE_OVERHEAD_MS,
},
measured: { overheadP95Ms: measuredOverheadP95, relativeP95 },
pass,
};
console.log(JSON.stringify(report, null, 2));
if (!pass) process.exitCode = 1;
@@ -0,0 +1,56 @@
#!/usr/bin/env node
// #291: same-process p95 of the complete config+layout boundary versus the
// pre-existing full-candidate clone contract. Batching makes the strict 20%
// ratio meaningful even on coarse/loaded CI timers.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
import {
canonicalizeConfigGeometry, canonicalizeLayoutGeometry,
} from '../test-build/coordinate-canonicalization.js';
const WARMUPS = 30;
const SAMPLES = 120;
const BATCH = 10;
const MAX_RATIO = 1.2;
const fixture = makeLargeHouseFixture();
const baseline = () => {
JSON.parse(JSON.stringify(fixture.config));
JSON.parse(JSON.stringify(fixture.layout || {}));
};
const candidate = () => {
canonicalizeConfigGeometry(fixture.config);
canonicalizeLayoutGeometry(fixture.layout || {});
};
const measure = (operation) => {
const started = performance.now();
for (let index = 0; index < BATCH; index++) operation();
return (performance.now() - started) / BATCH;
};
for (let index = 0; index < WARMUPS; index++) {
baseline();
candidate();
}
const baselineSamples = [];
const candidateSamples = [];
for (let index = 0; index < SAMPLES; index++) {
if (index % 2) {
candidateSamples.push(measure(candidate));
baselineSamples.push(measure(baseline));
} else {
baselineSamples.push(measure(baseline));
candidateSamples.push(measure(candidate));
}
}
const p95 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1];
const baselineP95 = p95(baselineSamples);
const candidateP95 = p95(candidateSamples);
const ratio = candidateP95 / baselineP95;
const report = {
fixture: 'large-house-v1', samples: SAMPLES, batch: BATCH,
baselineP95Ms: baselineP95, candidateP95Ms: candidateP95,
ratio, limit: MAX_RATIO, pass: ratio <= MAX_RATIO,
};
console.log(JSON.stringify(report, null, 2));
if (!report.pass) process.exitCode = 1;
+107
View File
@@ -0,0 +1,107 @@
// #277: same-run historical edge-drag baseline versus the fixed-topology
// pointer clamp, plus the exact one-space production preflight used on release.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import {
planEdgeDrag, clampEdgeDrag,
resolveSafeResize, clampSafeResize, applySafeResize,
safeResizeCachedDeltaCount,
} from '../test-build/resize.js';
import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js';
const WARMUPS = 5;
const SAMPLES = 20;
const BATCH = 25;
const POINTER_P95_MS = 16;
const POINTER_RATIO = 1.2;
const POINTER_NOISE_MS = 0.25;
const PREFLIGHT_P95_MS = 75;
const rooms = [{ id: 'active', poly: [[0, 0], [300, 0], [300, 300], [0, 300]] }];
for (let index = 0; index < 199; index++) {
const x = 1000 + (index % 20) * 500;
const y = Math.floor(index / 20) * 500;
rooms.push({ id: `room-${index}`, poly: [[x, y], [x + 300, y], [x + 300, y + 300], [x, y + 300]] });
}
const opts = { minDim: 25, eps: 0.1, movingHalf: 10, obstacles: [] };
const oldPlan = planEdgeDrag(rooms, 'active', 1);
const safeResolution = resolveSafeResize(rooms, [], 'active', 1, opts);
if (!oldPlan || !safeResolution.enabled) throw new Error('benchmark fixture is not resize-eligible');
const safePlan = safeResolution.plan;
const baselinePointer = () => clampEdgeDrag(rooms, [], oldPlan, 100, 5, opts);
const safePointer = () => {
const delta = clampSafeResize(rooms, [], safePlan, 100, 5, opts);
return applySafeResize(rooms, [], safePlan, delta);
};
const large = makeLargeHouseFixture();
const currentSpaceConfig = { ...large.config, spaces: [large.config.spaces[0]] };
const precomputeStart = performance.now();
const cachedProductionGeometry = checkOptimizeGeometry(currentSpaceConfig);
const renderPrecomputeMs = performance.now() - precomputeStart;
if (!cachedProductionGeometry.ok
|| cachedProductionGeometry.spaces.some((space) => space.status === 'failed')) {
throw new Error(`safe-resize preflight fixture failed: ${JSON.stringify(cachedProductionGeometry.spaces)}`);
}
// The final preview frame owns the expensive production union. pointerup reads
// the exact cached result for that cfg epoch; this measures commit latency, not
// the existing large-house render budget measured by benchmark:large-house.
const preflight = () => {
if (!cachedProductionGeometry.ok) throw new Error('cached production geometry failed');
return cachedProductionGeometry.fingerprint;
};
const timedBatch = (operation) => {
const start = performance.now();
for (let index = 0; index < BATCH; index++) operation();
return (performance.now() - start) / BATCH;
};
for (let index = 0; index < WARMUPS; index++) {
baselinePointer(); safePointer(); preflight();
}
const baselineTimes = [];
const safeTimes = [];
for (let index = 0; index < SAMPLES; index++) {
const safeFirst = index % 2 === 1;
const first = timedBatch(safeFirst ? safePointer : baselinePointer);
const second = timedBatch(safeFirst ? baselinePointer : safePointer);
const third = timedBatch(safeFirst ? baselinePointer : safePointer);
const fourth = timedBatch(safeFirst ? safePointer : baselinePointer);
baselineTimes.push(safeFirst ? (second + third) / 2 : (first + fourth) / 2);
safeTimes.push(safeFirst ? (first + fourth) / 2 : (second + third) / 2);
}
const preflightTimes = Array.from({ length: SAMPLES }, () => {
const start = performance.now(); preflight(); return performance.now() - start;
});
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const summary = (values) => ({
min: Math.min(...values), median: quantile(values, 0.5),
p95: quantile(values, 0.95), max: Math.max(...values),
});
const baseline = summary(baselineTimes);
const candidate = summary(safeTimes);
const commitPreflight = summary(preflightTimes);
const relativeLimit = baseline.p95 * POINTER_RATIO + POINTER_NOISE_MS;
const pass = candidate.p95 <= POINTER_P95_MS
&& candidate.p95 <= relativeLimit
&& commitPreflight.p95 <= PREFLIGHT_P95_MS
&& safeResizeCachedDeltaCount(safePlan) <= 4096;
console.log(JSON.stringify({
issue: 277,
fixture: { rooms: rooms.length, largeHouse: LARGE_HOUSE_COUNTS },
warmups: WARMUPS, samples: SAMPLES, batch: BATCH,
baseline, candidate, commitPreflight,
renderPrecomputeMs,
cacheEntries: safeResizeCachedDeltaCount(safePlan),
budgets: {
pointerP95Ms: POINTER_P95_MS, pointerRatio: POINTER_RATIO,
pointerNoiseMs: POINTER_NOISE_MS, relativeLimit,
commitPreflightP95Ms: PREFLIGHT_P95_MS, maxCacheEntries: 4096,
},
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
+78
View File
@@ -0,0 +1,78 @@
// #277: warm Resize-layer render cost on the supported large-house ceiling.
// The deterministic snapshot-call assertion catches the original regression
// even when runner timing noise happens to keep the p95 below its budget.
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import { launch } from './serve.mjs';
const WARMUPS = 3;
const SAMPLES = 20;
const RENDER_P95_MS = 25;
const fixture = makeLargeHouseFixture();
const config = { ...fixture.config, spaces: [fixture.config.spaces[0]] };
const { page, browser } = await launch();
const result = await page.evaluate(async ({ config, warmups, samples }) => {
const card = window.__card;
card._serverCfg = structuredClone(config);
card._space = config.spaces[0].id;
card._modelCache = null;
card._cfgEpoch++;
card._setMode('plan');
card._tool = 'resize';
card.requestUpdate();
await card.updateComplete;
const view = card._viewOr(card._baseVb());
const originalSnapshot = card._rszSnapshot.bind(card);
let snapshotCalls = 0;
card._rszSnapshot = () => {
snapshotCalls++;
return originalSnapshot();
};
for (let index = 0; index < warmups; index++) card._renderResizeLayer(view);
snapshotCalls = 0;
const times = [];
for (let index = 0; index < samples; index++) {
const started = performance.now();
card._renderResizeLayer(view);
times.push(performance.now() - started);
}
card._rszSnapshot = originalSnapshot;
return {
times,
snapshotCalls,
roomCount: card._rszRooms().length,
handleCount: card._rszRooms().reduce((sum, room) => sum + room.poly.length, 0),
};
}, { config, warmups: WARMUPS, samples: SAMPLES });
await browser.close();
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const render = {
min: Math.min(...result.times),
median: quantile(result.times, 0.5),
p95: quantile(result.times, 0.95),
max: Math.max(...result.times),
};
const pass = result.snapshotCalls === SAMPLES
&& result.roomCount === 20
&& result.handleCount === 80
&& render.p95 <= RENDER_P95_MS;
console.log(JSON.stringify({
issue: 277,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
roomCount: result.roomCount,
handleCount: result.handleCount,
snapshotCalls: result.snapshotCalls,
snapshotCallsPerFrame: result.snapshotCalls / SAMPLES,
render,
budgets: { renderP95Ms: RENDER_P95_MS, maxSnapshotCallsPerFrame: 1 },
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
+118
View File
@@ -0,0 +1,118 @@
// #278: component projection overhead and degraded-fixture completion budget.
import { performance } from 'node:perf_hooks';
import { readFileSync } from 'node:fs';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import { prepareSpacePhysicalGeometryInputs } from '../test-build/plan-geometry-preflight.js';
import { spaceModels } from '../test-build/space-geometry.js';
import {
polyclipToPathD, wallBodiesGeometry, wallBodiesUnionPath,
} from '../test-build/wall-thickness.js';
const WARMUPS = 3;
const SAMPLES = 50;
// One projection is sub-millisecond. Time a batch and report per-operation
// cost so scheduler/timer quantisation cannot dominate the 10% relative gate.
const VALID_BATCH = 100;
const RELATIVE_RATIO = 1.1;
const OVERHEAD_P95_MS = 20;
const DEGRADED_P95_MS = 100;
const prepare = (config) => {
const models = spaceModels(config);
return config.spaces.map((space, index) =>
prepareSpacePhysicalGeometryInputs(space, models[index]));
};
const args = (input) => [
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
];
const large = makeLargeHouseFixture();
const validInputs = prepare(large.config);
const degradedFixture = JSON.parse(readFileSync(
new URL('../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
));
const degradedInput = prepare(degradedFixture.config)[0];
const validResults = validInputs.map((input) => wallBodiesGeometry(...args(input)));
if (validResults.some((result) => result.status !== 'ok'))
throw new Error(`valid geometry: ${validResults.map((result) => result.status).join(',')}`);
const validGeometry = () => {
for (const result of validResults) {
// The previous production projection also serialized primary + paper.
if (!polyclipToPathD(result.geom) || !polyclipToPathD(result.paperGeom))
throw new Error('valid legacy projection');
}
};
const validProjection = () => {
for (const result of validResults) {
const paths = result.components.map((component) => polyclipToPathD(component.geom));
const paper = polyclipToPathD(result.paperGeom);
if (!paths.length || paths.some((path) => !path) || !paper)
throw new Error('valid component projection');
}
};
const degradedProjection = () => {
const result = wallBodiesUnionPath(...args(degradedInput));
if (!result || result.status !== 'degraded-extra' || result.paths.length !== 2)
throw new Error(`degraded projection: ${result?.status || 'null'}`);
};
const timed = (operation, iterations = 1) => {
const started = performance.now();
for (let index = 0; index < iterations; index++) operation();
return performance.now() - started;
};
const run = (operation) => {
for (let index = 0; index < WARMUPS; index++) operation();
return Array.from({ length: SAMPLES }, () => timed(operation));
};
const runPairs = () => {
for (let index = 0; index < WARMUPS; index++) {
validGeometry();
validProjection();
}
const baseline = [], candidate = [], overhead = [];
for (let index = 0; index < SAMPLES; index++) {
let baseMs, candidateMs;
if (index % 2 === 0) {
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
} else {
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
}
baseline.push(baseMs);
candidate.push(candidateMs);
overhead.push(candidateMs - baseMs);
}
return { baseline, candidate, overhead };
};
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const summary = (values) => ({
min: Math.min(...values), median: quantile(values, 0.5),
p95: quantile(values, 0.95), max: Math.max(...values),
});
const pairs = runPairs();
const baseline = summary(pairs.baseline);
const candidate = summary(pairs.candidate);
const degraded = summary(run(degradedProjection));
const relativeLimit = baseline.p95 * RELATIVE_RATIO;
const overheadP95 = quantile(pairs.overhead, 0.95);
const pass = candidate.p95 <= relativeLimit && overheadP95 <= OVERHEAD_P95_MS
&& degraded.p95 <= DEGRADED_P95_MS;
console.log(JSON.stringify({
issue: 278, fixture: LARGE_HOUSE_COUNTS, warmups: WARMUPS, samples: SAMPLES,
validBatch: VALID_BATCH,
baseline, candidate, degraded, overheadP95,
budgets: {
relativeRatio: RELATIVE_RATIO, overheadP95Ms: OVERHEAD_P95_MS,
relativeLimitP95Ms: relativeLimit, degradedP95Ms: DEGRADED_P95_MS,
},
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
+85
View File
@@ -0,0 +1,85 @@
/** Local-only visual proof for #275; external backup contents are never printed. */
import { mkdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { optimizePlans } from '../test-build/plan-optimizer.js';
import { launch } from './serve.mjs';
const input = process.argv[2];
const outputDir = resolve(process.argv[3] || '.');
const mode = process.argv.includes('--optimized') ? 'optimized' : 'raw';
const nodesArg = process.argv.find((value) => value.startsWith('--nodes='))?.slice(8) || '';
const nodes = nodesArg.split(';').filter(Boolean).map((pair) => pair.split(',').map(Number));
if (!input) {
console.error('usage: node demo/capture_wall_strip_backup.mjs <backup> <outdir> '
+ '[--optimized] [--nodes=x,y;x,y]');
process.exit(2);
}
const backup = JSON.parse(readFileSync(input, 'utf8'));
const payload = backup?.payload && typeof backup.payload === 'object' ? backup.payload : backup;
const source = {
config: payload?.config && typeof payload.config === 'object' ? payload.config : payload,
layout: payload?.layout && typeof payload.layout === 'object' ? payload.layout : {},
};
const rendered = mode === 'optimized'
? optimizePlans(source.config, source.layout)
: source;
const config = rendered.config;
const layout = rendered.layout;
if (!Array.isArray(config?.spaces) || !config.spaces.length) {
throw new Error('backup has no spaces');
}
mkdirSync(outputDir, { recursive: true });
const { page, browser } = await launch({ width: 1800, height: 1250 }, 1);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async ({ cfg, lay }) => {
const card = window.__card;
card._serverCfg = structuredClone(cfg);
card._layout = structuredClone(lay);
card._space = cfg.spaces[0].id;
card._setMode('plan');
card._tool = 'select';
card._cfgEpoch++;
card._modelCache = null;
card._frame = null;
card._wallUnionCache = null;
card._physicalBodiesCache = null;
card._lightBarrierCache = null;
card._isoGeometryCache.clear();
card.requestUpdate();
await card.updateComplete;
card._fitAll();
card.requestUpdate();
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
}, { cfg: config, lay: layout });
const fullPath = resolve(outputDir, `${mode}-full.png`);
await page.screenshot({ path: fullPath, animations: 'disabled' });
for (let index = 0; index < nodes.length; index++) {
const center = await page.evaluate(([x, y]) => {
const card = window.__card;
const svg = (card.shadowRoot || card.renderRoot).querySelector('.stage svg');
const matrix = svg?.getScreenCTM?.();
if (!matrix) return null;
const point = new DOMPoint(x * 1000, y * 1000).matrixTransform(matrix);
return { x: point.x, y: point.y };
}, nodes[index]);
if (!center) continue;
const width = 420, height = 360;
const clip = {
x: Math.max(0, Math.min(1800 - width, center.x - width / 2)),
y: Math.max(0, Math.min(1250 - height, center.y - height / 2)),
width,
height,
};
await page.screenshot({
path: resolve(outputDir, `${mode}-node-${index + 1}.png`),
clip,
animations: 'disabled',
});
}
await browser.close();
console.log(JSON.stringify({ mode, full: fullPath, crops: nodes.length }));
+10 -2
View File
@@ -3,6 +3,8 @@
* visual-regression tooling. Nothing here depends on a real HA installation.
*/
import { fixtureWallKey } from './wall-key.mjs';
const FLOOR_COUNT = 3;
const ROOMS_PER_FLOOR = 20;
const DEVICE_COUNT = 200;
@@ -213,8 +215,14 @@ export const makeLargeHouseFixture = () => {
cell_cm: 5,
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
rooms,
walls: segments.map((wall, index) => ({
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
// The key must be the real one. A label like `perf-wall-0-3` does not
// parse as coordinates, so neither the exact match nor the tolerant
// fallback in lookupWall finds the record: every solid edge of this
// fixture resolved to zero thickness and the plan carried no wall bodies
// at all, while the same fixture backs four golden scenes and all six
// performance budgets (#260).
walls: segments.map((wall) => ({
key: fixtureWallKey(wall.a, wall.b), cm: 15, a: wall.a, b: wall.b,
})),
openings: makeOpenings(floor, segments, openingCount),
partitions: makePartitions(floor, rooms, partitionCount),
+7 -16
View File
@@ -4,22 +4,13 @@ const round = (value) => Number(value.toFixed(6));
// Golden fixtures must use the same persisted wall-key contract as real plan
// data. Arbitrary labels make every configured wall look virtual to the
// renderer, which lets a visually ineffective baseline pass unnoticed.
const WALL_KEY_PITCH = 1 / 240;
export const fixtureWallKey = (a, b) => {
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
const mx = quantize((a[0] + b[0]) / 2);
const my = quantize((a[1] + b[1]) / 2);
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) { dx = 1; dy = 0; }
else { dx /= length; dy /= length; }
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
};
// renderer, which lets a visually ineffective baseline pass unnoticed — and a
// key that merely differs in precision is found only through the tolerant
// fallback in lookupWall, which is luck rather than contract (#260). The
// formula lives in one place for every fixture and is pinned to the product one
// by test/fixture-wall-key.test.mjs.
export { fixtureWallKey } from './wall-key.mjs';
import { fixtureWallKey } from './wall-key.mjs';
const uniqueEdges = (rooms) => {
const edges = new Map();
+63
View File
@@ -0,0 +1,63 @@
/**
* Ключ записи толщины стены — один на все фикстуры проекта.
*
* Копия формулы из `src/wall-thickness.ts`, и копия здесь неизбежна. Фикстуры
* обязаны оставаться без внешних импортов: бэкенд-гейт запускает их как
* `node --input-type=module --eval "import * as f from './demo/fixtures/…'"`
* в job без `npm ci` и без `test-build/` (`.github/workflows/validate.yml`,
* job `backend`), а `scripts/source-fingerprint.mjs` хеширует только `src/**`
* и `.mjs` из `demo/fixtures` и `demo/golden` — код, втянутый из `scripts/`,
* менял бы поведение фикстуры при неизменном отпечатке, на котором стоят и
* валидность golden-эталонов, и переиспользование гейтов.
*
* Поэтому файл лежит ЗДЕСЬ, внутри `demo/fixtures`: так он попадает в
* отпечаток, и так его видит одна привязка вместо трёх копий формулы.
* `test/fixture-wall-key.test.mjs` сверяет его с продуктовым `wallKey`.
*
* Ловушка, из-за которой этот файл и появился (#260): точность зависит от шага.
* При `pitch = 1/240` продукт печатает ШЕСТЬ знаков, а не четыре — фикстура с
* четырьмя расходилась с продуктом на каждой записи и находилась только через
* терпимый запас `lookupWall`. Метка вместо ключа (`perf-wall-0-3`) не
* находилась вовсе: все сплошные рёбра оставались с нулевой толщиной.
*/
/** Шаг решётки редактора в нормализованных координатах (`GRID_N = 240`). */
export const WALL_KEY_PITCH = 1 / 240;
/** Направление стены по модулю 180°: стена одна и та же с любого конца. */
const direction = (a, b) => {
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) return [1, 0];
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) return [-dx, -dy];
return [dx, dy];
};
/**
* Координата, отличающаяся от узла решётки не больше точности хранения, — это
* тот же узел (#258). Канонизация опознания, а не снап геометрии: произвольная
* точка вне решётки остаётся вне решётки. Без этого шага ничья округления на
* стене нечётной длины в шагах разводила один и тот же ключ на два.
*/
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
const canonical = (value, pitch) => {
if (!(pitch > 0) || !Number.isFinite(value)) return value;
const snapped = Math.round(value / pitch) * pitch;
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
};
export const fixtureWallKey = (a, b, pitch = WALL_KEY_PITCH) => {
const quantise = (value) => (pitch > 0 && Number.isFinite(value)
? Math.round(value / pitch) * pitch : value);
const ca = [canonical(a[0], pitch), canonical(a[1], pitch)];
const cb = [canonical(b[0], pitch), canonical(b[1], pitch)];
const mx = quantise((ca[0] + cb[0]) / 2);
const my = quantise((ca[1] + cb[1]) / 2);
const [dx, dy] = direction(ca, cb);
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
const precision = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
return `${mx.toFixed(precision)},${my.toFixed(precision)}@${bucket.toFixed(4)}`;
};
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

+118 -86
View File
@@ -1,106 +1,138 @@
{
"schema": 1,
"matrixVersion": 39,
"acceptedAt": "2026-08-23T06:49:21.230Z",
"sourceFingerprint": "f3ed53aabec5afb371295d3a681d1cfc11ff6c704cffd8325874c337983b8572",
"matrixVersion": 46,
"acceptedAt": "2026-08-25T20:12:50.448Z",
"sourceFingerprint": "e28606975682347f90f12971053825ce474085cbddd56082e867370b1039d14d",
"chromium": "151.0.7922.34",
"scenarios": {
"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": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "98d82986061569ec9309f22bc50454cf63dbcb83105550f3e0439d39bfbc0142",
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
"split-corner-wall-thick-dark": "f7864a6b2ab58586540f836183c9e59f09f9f57165ec0e0b9cd8444ada71153c",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"isometric-geometry-view-dark": "d4b3918e0597dee2e059153d98812a5035da0f82da20e43ae810b83b8e0ac965",
"isometric-geometry-view-light": "e68183fed65d263a2bd80e7600f548a4ab03c03cbfcffd2abea0e011fec0f353",
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
"isometric-large-warm-remount-dark": "b53aec1bdb466be97a21250db914b88535449321967ecc80c4bbe749a2af82b5",
"geometry-view-dark-fit": "e680793d24a575727d5b87910e824f9ba1fbdb9d7a0c6f60b8bfe839afe73a98",
"geometry-view-light-fit": "8a2fa3c2c7f28cb55fb3119b645fa68b92dbda71706642e38dba569a7f770eea",
"room-label-parity-view-dark": "b35675251b85144bca1bc15ed7bf27a4dd1eaa0bfc288e3e3b0d1cdefac79ae5",
"room-label-parity-plan-dark": "64827483632ce5b66e2ae5cdf7c6858ca7062c3499b1967e8ca31ebca0cb5af1",
"room-label-parity-view-light": "789f8f3b6211d35e70021094f2063dc9c6ac57599d9d64743a33b7ae9dd868c2",
"room-label-parity-plan-light": "0f98689d2fbc21e14a321918ab46c35a9d8463d7db68d78f3c5b00d026c9cc50",
"isometric-geometry-view-dark": "43ad006844a78b502b36aeeb82d82669532e06d2c798de928a5207acd24bd5f9",
"isometric-geometry-view-light": "0fcb10ffdc0baa9006d1c2f0406ebf19e71ba9feda180979a51b74166d7200d2",
"isometric-live-layers-dark": "8aaa914fc45780f67dd5c008a106696d21235a9acee7402f9cc8ae51a26660f3",
"isometric-no-borders-dark": "c105382d883bbdae8562b756ccbf47ac1fcf46675c6aa7654289856223a3ebf0",
"isometric-touch-kiosk-dark": "be122ceec8114374ba384f51d5d9287e498a97354865c5a9f91d505ca18d8640",
"isometric-large-warm-remount-dark": "d76e3334bf80bff265b1e8aa6770c5eff5df6fbfebeb4703b04d048c4252eb68",
"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": "aa332443ebe5e93b11c569cd4804a0995e1e76b7a5b6aa6844d001a0f6c12991",
"day-cycle-day-dark": "af1ab469144fbb3c73d99ff2817f86ec9b8704eadfaef83905cac645eb2eab07",
"day-cycle-dusk-dark": "19fdca90fbd9c902bc734b3dc4e5e50c1aa06b22a6e8a19079ad35cd86beb1fb",
"day-cycle-night-dark": "b5530a74422e577db90b340a395c0c097907244b330c535169a8e5c4edc8c24b",
"geometry-plan-editor-dark": "e937867f27e04d5c3cd6d84cbdb8fa5fc0a8ed9769358368ecee7a8850d57d1f",
"space-tab-drop-before-light": "820ebdceaa08b0319a9cf0222c24766aabb78263a7eb3d9474607724618e55d1",
"space-tab-drop-after-dark": "fae316777d79e643000eb809e9a2106fe7a4cd39f7cfe12e960286c5f88b490a",
"plan-snap-endpoint-light": "59539dd563fa4af530b6e5aa4489937eeaf36df922efd31fc12340bfb779695b",
"plan-snap-line-gaps-dark": "066ee3921eec10fb14d829735402e631f81ebfb91dd5c6e8e8cf20ee1e69c483",
"wall-junctions-plan-preview-light": "1482b01209a796f93ebc23e231b33fe869ee96b6f46d1fb2d32ccf677a94e602",
"wall-junctions-plan-t-dark": "7a7e47db083a737669d4c803b4552bbe7c8700615cde5edaea5b8c553f9345d1",
"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": "ac3e1818d3e85e8fd4e2a24695deb4461ee8b7f49a87c29528c95a352ef93a8d",
"junction-patch-resilience-view-dark": "b91397ccbb9e7d94622f15303c486146b0903c000f517e36efb7abcfa28a7a0a",
"multiwall-junction-bevel-view-dark": "ae486afba2a275d95d9a64b13210cc3b43cf01633ba2df2e2898f943e3d44869",
"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": "e226d023fbab0502eb747395d43a46bb56bcf2be037ba4f9b7ad74ba406d9671",
"orthogonal-strip-cell-5-view-dark": "9b8e4bd22cbf1a82ff48f052822967c61179290a3234ae48ef547642329b5a90",
"orthogonal-strip-cell-1-view-dark": "e59e51ad3645144161459c438a07420730c4dcfd2c33bd2b18e8c3e57ea84515",
"wall-key-roundtrip-view-dark": "df46b4e209f98b97958748097ddc646ddc64d8285c2651762de71ec2456ac690",
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
"opening-placement-door-thick-wall-dark": "6bb2211e9640800d5b3e1ad413297c7aa93a60bc13bb481b26f09a6abe122838",
"opening-placement-passage-thick-wall-dark": "d5befd61224ef98cbe340938e6db321faad354ec167cf3244923bbdafdfbfe17",
"opening-placement-passage-thick-wall-light": "950ca5d6011682942e1f216bbf93d76aac50b8f5e335993516c2bade3d62797c",
"opening-symbol-room-wall-light": "403f1c6e1c59122331fbe99019a3ebd80de4106dc6d33f5f5f33feb748229645",
"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": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
"geometry-devices-editor-dark": "209e602446ed79de7f7c5931f96e7d8dc0fdeb311083f01ef73b4df9c5d9e1b6",
"geometry-decor-editor-dark": "3a98ded3a0c13306bbb61681dec12879c9baec5e2969a6fb06188693f1de32b7",
"tray-wide-selection-en": "74b72708c49a2e3619854eb92fc3ddf1fdfe204e62df8df8a8f1ec70a9c13a90",
"tray-wide-tool-ru": "c8cd7a1f8fcc94d3280a958a0b9fac4c974f076c4d4c488baceb509441a386c5",
"tray-medium-group-en": "730268ac23f6f7c007b8a21db149b2eabee6a26c8fd054579cf705c92a842596",
"tray-medium-selection-ru": "951c3c68516f8d400dd5bb6d5eddfb1fda2b29923281c8c73da342ba55f22408",
"tray-narrow-palette-en": "405db03787009eccf3faf7dd93e0b7f557d27d66f02fc5dcb815883bf673a3b7",
"tray-narrow-tool-ru": "fce66752a474bc5a330306fad21fc42a0f863576fb73b770cb03423fcbfb6a24",
"geometry-diagonal-45-opening-dark": "41b62c9f308b5d8d2e97d4e4d26170c66f40470c11c0ed6df16d6ff7281cb49e",
"openings-thick-wall-dark": "539cdb9cb1460cd42c2e51901ea50f496e8214d02633e110e2780c3f177154db",
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
"decor-over-opaque-hover-light": "eae79026256367a4cfaa5f99393ec9c66dbf23908c50ccc0f541c139d1baeda0",
"decor-over-glow-base-dark": "72a83f08f832c219d8e0e1b6f94d689c250342aa95d30e8c1f79cf9f9548ccab",
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
"lighting-glow-sun-dark": "08e2964df6fd03abad4b9a81f1dbd0fe3812fb6b5f7908aeb027ad2953b2f88c",
"device-value-badge-positions-dark": "75ab0210d697f952b8a8977649e84308de3ce3b59c84ec93071179a1d6cb015d",
"device-icon-state-table-light": "85c785824ccafc2d5f50e834ad2d746319d64ecb292d1b9cf7fab8661d5d410d",
"device-icon-state-table-dark": "03a90523132c3dbe6c78d44d01fe89df93e2d0474f91ef84e7107e6e4bc7d337",
"device-text-shell-long-light": "97a113b5164f373b4af2922c8e71bc1f063d8c2761b72c3182697fcb1aac3cab",
"device-text-shell-long-dark": "c8fe32aced704c7e7562f0c8499183fad848a977063ab731401cc01e7f20ef38",
"opening-symbol-flip-pairs-light": "33364f51fb65e49bfdd6c41d6e0c53542c89fadc45a3c6e5adc590144c4676ea",
"isometric-opening-symbol-parity-dark": "302daf5d90d4af5ef90036cc10e957e6fc909ea56f9f2af938ab850d4f608e34",
"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",
"decor-over-glow-base-dark": "8e5b8630798e31936f8e7dd52d1cba73a3f80fd3d3217a5ad59d50b5dc12a26d",
"openings-hidden-view-dark": "d99f365376e6ece668060181ce8c3a5ebdb93ec5a8c64e08b0aef5af592c6874",
"lighting-glow-sun-dark": "8599aea80a0479442f891adb30aecd647ddf217e28e3f0da5c21b41e21d746ec",
"device-value-badge-positions-dark": "ae163e96cd976e006eacd09ebc75440d600e81c063e38da27d53000e4e51c68d",
"device-icon-state-table-light": "969879fc7baeb193639a03701eb8bacc9469f6160b8d41a318db8da83590e51d",
"device-icon-state-table-dark": "045145a87ccde3894334009c6b9edd690654fd048c55b770e09d5ed79f9a7351",
"device-text-shell-long-light": "a96d16ac0a02ed322bab949e34ff7ce59da5f5db42e02185fac5fde495591bb1",
"device-text-shell-long-dark": "f301f3aa1e1eb382018c4329db2909a8d2990f5f917bde83e3de46ff4dc7416c",
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
"lighting-fill-light-axis-split-dark": "e45d44496c83ad92f8d5bd058821e6d71c97c48a2b66c1933206535aea2655c1",
"lighting-fill-temp-axis-split-dark": "895e1e0beee38bf659b1313ed9951353a7792d2e8ae3a8620c4cf7a4f6a84a4d",
"lighting-fill-lqi-axis-split-dark": "4a029b17419aaf60feab23f7f0cb385d97353e3aec0e39ed67e9ac94f6c08029",
"lighting-temp-glow-dark": "3ce504d83fda2ba5d772c25f8cbb47c379b335addd8650f6933b58f22f5655a7",
"lighting-temp-glow-light": "e9f2db0a686a9e20bf71564c10bab826a2a473c73d572a6b67ef02a735d61e8e",
"lighting-custom-glow-dark": "94cc6135573aa9462de9e7f309dea62e236668b84970bae35d48f1aad338f8f4",
"lighting-opaque-glow-two-doorways-dark": "ee3c53a408f6b62aa6b770925fa6948934e0c1c6e73877e379695bc260b0ceac",
"lighting-custom-glow-light": "aa9477aaff4d137abfcb6d67a5cf62b91f41b3631383ee8edb7499802a5c9b87",
"lighting-temp-glow-no-sources-dark": "cd992af54d1c9dcc58bbd0b21b15b475be6f19eaabcf9ea866bfa82f205460c9",
"lighting-temp-glow-room-override-dark": "66a418829b51379d024c542f8b9a12bd42d72afa81a6bf66561d6498ac4cdb45",
"lighting-manual-auto-spill-overlap-dark": "0dc780335317d6dd577c8d5539f33223228074e1c95e936e4666f50a8db09739",
"hover-over-glow-dark": "49136f6073662482ebf49a28f8f22b698d2c20a9cb64b5667d111b9835717968",
"hover-nested-room-dark": "6ed270f62e7c1cb5e28e800cae46b4839987467bf6b2933c1af212f9b716f2fe",
"large-house-zoom-040-dark": "71847674ec42b0dac55208e9571635bee50fb20485d4e31f797df3cef5b42b37",
"large-house-zoom-250-dark": "7acd4cc3b3dfa2124f7c6839e78c9539dcb224af47a321049095cd7ff9e3de68",
"large-house-warm-remount-dark": "2532c6e438b219a2bebeab3658b184c3b6d2f5401fd485a660d96f95d32f382a",
"device-dialog-desktop-en": "a6637997458ef1def3d30ffaccb6bbc9728dbc4ba2f02400a7334915a8101bbe",
"lighting-fill-light-axis-split-dark": "6b3a4dd637c6fe7e43a131c44c4be232bd81edab417ea9af3860d82c506ad45f",
"lighting-fill-temp-axis-split-dark": "6025c6efd3ea3565c13e147db550b017eeb86da0be5dfc82a8b5bd7e623d5852",
"lighting-fill-lqi-axis-split-dark": "1ec5c18572fd5be471088286c4aa7c67c3885cb907080fcde44c3583ff794275",
"lighting-temp-glow-dark": "274bcd5a19ebbcb63fb64a5d21f838122f1916d428defcaa3c0356d8ea9809a5",
"lighting-temp-glow-light": "decc386073a1e93d8d457ec09daf83e8afc180977f5880ce56cc4834a2c203f0",
"lighting-custom-glow-dark": "32607e6001c4c8cd541cabf82142f3cbf7f2b298900a02f30c6f44ececbd5907",
"lighting-opaque-glow-two-doorways-dark": "bd9f49432c69c8ed622810e96f4708974e2024c58700b0cfa3e5ebd2edd7bc7b",
"lighting-custom-glow-light": "973ae32bb3bacbfe633041e89352a9ef1a2222b89a4792feef45c26bfa541459",
"lighting-temp-glow-no-sources-dark": "e19b7d86d5dae54f9afc0a1ef04a3848ebec283a5f60e68efdcc7380dff3b0a7",
"lighting-temp-glow-room-override-dark": "bebfc27f460cb75e32ab14076a01e3b9729763d7cecd3f457c1b696ed3e57da3",
"lighting-manual-auto-spill-overlap-dark": "471e39397361832a171f0309302be7d8ba4310d628e337478e23da44dc455ef8",
"hover-over-glow-dark": "6581433024060f773e00211d60eac7b6daffe442031ff076f861918419c71930",
"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",
"device-dialog-desktop-en": "9739886af3104c2fe3d6d3138293e9fd1a7f480f151decc26f12bb64cab69543",
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
"device-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
"decor-color-popover-mobile-ru": "fcdd138e396654e3517927c27d2062663c61860e8b9903471fa44651a4cbdefd",
"decor-color-popover-desktop-en": "46e78386308cd109e25e7b41967421dfa4fbbfb4639710c59e9cc2d88a10fde0",
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
"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": "99fbe17ab8ec383988b944f7db72eeb4e933cab18b0020350ae44befd0b9ae44",
"backup-full-preview-desktop-en": "434567d8f97cb09bf217c353bb38e3c4e3aa4c1b837c722a946d75c2c40f34af",
"backup-plan-only-export-desktop-en": "8cb234a8d71be11acc6e7bae775f409d3c4b41705072c610f13f40dde06c9c0d",
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
"optimize-preflight-dialog-dark-en": "d42cab423d2838b7592b661548f7d524d6403db5258419e635733af9ffdda685",
"optimize-preflight-dialog-light-ru": "d8262ed5bf93347c4727365aab71897dcb2f24f0c8196e4fcdcbf43b07919b5a",
"optimize-orphan-references-dark-en": "a39b2946ea69939941b9f22784111707513d4a4a951df77ed6e8893539ab69eb",
"optimize-orphan-references-light-ru": "cb21ccfa4426e5ff92a547ef4f0f4592affbda7f235f419dc84640ef40501892",
"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"
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

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: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 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.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 177 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: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 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: 134 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 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: 342 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

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