Compare commits

..
Author SHA1 Message Date
Codex 3d4e5580fa build: prepare v1.68.0-beta.4 candidate
Package the two beta.3 write-blocker fixes: the model-upgrade stale-client
guard and the self-resolving zero-wall migration.

Issue: #316
Issue: #319
User-Visible: yes
2026-08-27 08:11:42 +03:00
Codex 1c598a287a fix: the room_wall_ids invariant applies to model v8 and later again (#316, review r4 H1)
The r3-M1 fix was applied as a mechanical substring replacement and flipped
BOTH model_version conditions in the file; the independent room_wall_ids
invariant (#244/#252) silently stopped checking every v9+ document. Only the
opening_host requirement is scoped to model v8 — room_wall_ids is back to
'v8 and every later version', now pinned by its first regression test
(phantom wall_ids reported on v9 and v8; executed red on the broken
comparison, green after the fix).

Issue: #316
User-Visible: no
2026-08-27 00:21:23 +03:00
Codex 05ec0a1de8 fix: the model-invariants CLI accepts the unhosted degraded opening of model v9 (#316, review r3 M1)
Since #316 §3.3 an unhosted contour opening is a valid v9 state — the
migration keeps an opening with no in-place carrier as data. The CLI still
reported it as an opening_host violation for every model_version >= 8; the
requirement now applies to model v8 documents only. The regression test is
proven able to fail on the old comparison (executed red), and the reviewer's
CLI reproduction now finishes clean.

Issue: #316
User-Visible: no
2026-08-27 00:08:15 +03:00
Codex b29472a887 test: re-pin the #316 baselines to the review-fix source
Pixels are untouched — the r1/r2 review fixes change migration data flow,
not rendering; every scenario hash stays byte-identical to the frames of the
reviewed CI run. Only the source fingerprint moves to match the fixed tree.

Issue: #316
User-Visible: no
Release: v1.68.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33000824647
2026-08-26 23:41:35 +03:00
Codex 0f36ef55d5 fix: an opening without an in-place carrier migrates unhosted (#316, review r1 H1 + r2 M2)
CODE-REVIEW-316-r1 H1: the §3.3 degraded pool picked an angle-compatible wall
at ANY distance, but the backend geometry-match invariant («wall opening
geometry must match its host») requires the host to agree with the opening's
own x/y — the migrated document was rejected by CONFIG_SCHEMA and the write
wedged again on the schema layer. The pool is removed from both migrations
(TS and the Python mirror): without an in-place eligible carrier the opening
goes straight to the unhosted degraded state, exactly the alternative the
spec's «assumed freely changeable» section reserved; the spec is revision 6.
New tests replay the reviewer's reproduction on both sides, and the frontend
test is proven able to fail by restoring the pool (executed red).

CODE-REVIEW-316-r2 M2: the schema-level host check is shared with #132
partition openings, so its unhosted relaxation is now pinned by a regression
test — a stale writer that keeps a partition-hosted opening but silently
drops its host is still rejected by validate_partition_opening_hosts.

Issue: #316
User-Visible: no
2026-08-26 23:41:35 +03:00
claude[bot]andCodex 1d92193c5d docs: review document for #316
Issue: #316
User-Visible: no
2026-08-26 23:41:35 +03:00
claude[bot] dff476ff35 docs: review document for #316
Issue: #316
User-Visible: no
2026-08-26 19:35:05 +00:00
Codex ee672dcd1a test: accept the #316 span-over-door baseline
The scene was captured by CI run 33000824647 byte-identical to the locally
reviewed frame: the door keeps its solid jambs inside the former border, the
zero run is dashed on both sides, the leaf swings into the room.

Issue: #316
User-Visible: no
Release: v1.68.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33000824647
2026-08-26 22:06:43 +03:00
Codex ffb232398a fix: the initial wall-model migration resolves every opening conflict itself (#316)
Implements spec revision 4 (green r4). §3.1 — a legacy open_spans/open_to cut
never zeroes the atom that carries an existing contour opening: the opening's
edges become atom boundaries, the door keeps its real wall and the zero run
continues on both sides. §3.2 — an ambiguous carrier resolves
deterministically: current host, then distance, thicker cm, smaller id.
§3.3 — an opening with no usable carrier persists unhosted: a valid degraded
v9 state, inert in the physics, rendered by its own x/y, kept by later writes
and re-placeable in the editor (backend schema accepts it). §3.4 — the
initial migration never throws over an opening; a post-v9 write that LOST its
carrier keeps the fail-closed opening-host refusal. The Python migration
mirror implements the same rules with byte-identical output (verified on the
span+door fixture including the segment id).

The new smoke replays #316 end to end: a conflicted space no longer blocks
drawing on an empty plan. The golden scene span-over-door-migrated-dark
renders the migrated fixture pinned byte-for-byte to the real writer; two
gate mutants revert §3.1 and §3.4 and are red by execution.

Issue: #316
User-Visible: yes
2026-08-26 22:06:43 +03:00
claude[bot]andCodex 33a5b46990 docs: review document for #316
Issue: #316
User-Visible: no
2026-08-26 21:54:19 +03:00
Codex f353be3d76 docs: spec #316 revision 4 — the golden expectation rests on the render/write boundary, not on masonry kind
Issue: #316
User-Visible: no
2026-08-26 21:54:19 +03:00
claude[bot]andCodex 295548f0fd docs: review document for #316
Issue: #316
User-Visible: no
2026-08-26 21:54:19 +03:00
Codex 0f6583b51c docs: spec #316 revision 3 — correct compatibility reference, honest golden expectation
Issue: #316
User-Visible: no
2026-08-26 21:54:19 +03:00
claude[bot]andCodex 5886069c3a docs: review document for #316
Issue: #316
User-Visible: no
2026-08-26 21:54:19 +03:00
Codex 6205c8b2d7 docs: spec #316 revision 2 — registry/compatibility/goldens sections, reachable AC3 fixture
Issue: #316
User-Visible: no
2026-08-26 21:54:19 +03:00
claude[bot]andCodex b6de84e528 docs: review document for #316
Issue: #316
User-Visible: no
2026-08-26 21:54:19 +03:00
Codex 5b7d37e911 docs: spec #316 — migration auto-resolves opening-host conflicts
Issue: #316
User-Visible: no
2026-08-26 21:54:19 +03:00
claude[bot] f3fe86371c docs: review document for #319
Issue: #319
User-Visible: no
2026-08-26 18:05:38 +00:00
Codex 3ab6fa9f8a fix: the first write of a newer wall model is not an outdated client (#319)
A stale client can only echo the stored model_version, never raise it. The
'unchanged wall catalogue' refusal now applies only when the submitted model
is not above the stored one; the first v9 write over a v8 document with an
orphan open_span/open_to legitimately drops the legacy projection without
touching the catalogue and passes. The regression pair fixture is produced
by the real writers (stored: v1.68.0-beta.2, sent: current migration) and is
pinned on the frontend byte-for-byte so it cannot drift.

Issue: #319
User-Visible: yes
2026-08-26 18:20:15 +03:00
claude[bot] 3d31d65735 docs: review document for #319
Issue: #319
User-Visible: no
2026-08-26 15:11:38 +00:00
Matysh 9ff464fe09 test: accept v1.68.0-beta.3 golden baselines
Accept the complete reviewed Linux matrix after the zero-thickness wall UI and migration changes.

Issue: #306
Release: v1.68.0-beta.3
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32963513340
User-Visible: no
2026-08-26 14:35:08 +03:00
Matysh 8bfd56eb59 build: prepare v1.68.0-beta.3 candidate
Package the reviewed zero-thickness wall model and legacy virtual-wall migration.

Issue: #306
User-Visible: yes
2026-08-26 14:28:35 +03:00
claude[bot] 26ad7651fb docs: review document for #306
Validate / process-workflow-sync (push) Successful in 1m7s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / provenance (push) Successful in 1m29s
Validate / frontend (push) Skipped
Validate / process-gate (push) Successful in 1m27s
Validate / changes (push) Successful in 1m15s
Validate / docs (push) Failing after 35s
Validate / reuse (push) Successful in 1m10s
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: #306
User-Visible: no
2026-08-26 11:14:32 +00:00
Matysh 9134d86266 docs: accept issue 306 screenshots
Source: https://github.com/Matysh/houseplan-card/actions/runs/32961287574

Issue: #306
User-Visible: no
2026-08-26 14:06:00 +03:00
Matysh f15bdc678b fix: address zero-wall review findings
Issue: #306
User-Visible: yes
2026-08-26 14:01:58 +03:00
claude[bot] db4e0ef999 docs: review document for #306
Issue: #306
User-Visible: no
2026-08-26 10:56:25 +00:00
Matysh a66fb7ef53 feat: unify zero-thickness walls
Issue: #306
User-Visible: yes
2026-08-26 13:39:52 +03:00
claude[bot]andMatysh 5e35612bdd docs: review document for #306
Issue: #306
User-Visible: no
2026-08-26 12:55:27 +03:00
Matysh fa9d9b05e9 docs: align zero-wall spec limits
Issue: #306
User-Visible: no
2026-08-26 12:55:27 +03:00
claude[bot]andMatysh 916a42e33a docs: review document for #306
Issue: #306
User-Visible: no
2026-08-26 12:55:27 +03:00
Matysh 799289e73b docs: adapt zero-wall spec to model v8
Issue: #306
User-Visible: no
2026-08-26 12:55:27 +03:00
claude[bot]andMatysh 73cf8ed01a docs: review document for #306
Issue: #306
User-Visible: no
2026-08-26 12:55:27 +03:00
Matysh 1857cb69cf docs: address zero-wall spec review
Issue: #306
User-Visible: no
2026-08-26 12:55:27 +03:00
claude[bot]andMatysh 1fcbb0de6d docs: review document for #306
Issue: #306
User-Visible: no
2026-08-26 12:55:27 +03:00
Matysh b8a9f9b289 docs: specify zero-thickness wall migration
Issue: #306
User-Visible: no
2026-08-26 12:55:27 +03:00
Codex c821dad436 build: prepare v1.68.0-beta.2 candidate
Package self-explaining preflight refusals, the universal Thickness tool
and reliable model-v8 room drawing.

Issue: #269
Issue: #295
Issue: #313
Issue: #314
User-Visible: yes
2026-08-26 11:20:47 +03:00
claude[bot] 1f6aefd2a7 docs: review document for #295
Issue: #295
User-Visible: no
2026-08-26 08:12:05 +00:00
Codex 7273a3ad9a test: re-pin the #295 baselines to the review-fix source
Pixels are untouched — the r1 fixes change diagnostics data and dialog state,
not rendering. Every scenario hash is byte-identical to the frames CI
captured and I reviewed for run 32940625718; only the source fingerprint
moves to match the fixed tree.

Issue: #295
User-Visible: no
Release: v1.68.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32940625718
2026-08-26 10:52:47 +03:00
Codex b542f44bd6 fix: preflight diagnostics hash the judged candidate and the fallback dies with its dialog (#295)
CODE-REVIEW-295-r1, both Medium findings:

M1 — _preflightDiagnostics hashed this._serverCfg, the saved config a space
export would reproduce anyway. The hash now comes from the candidate the
preflight actually judged: the report path passes r.config / d.config and the
copy button reads the dialog's own candidate. The smoke no longer masks the
difference — its dialog carries a candidate whose geometry differs from the
saved config, and swapping the two must change the reported hash.

M2 — the inline clipboard fallback survived dialog close and reopen, so a
later refusal could hand the previous refusal's JSON to a bug report. The
field now dies with its dialog: reset on open (_previewAlignDialog) and on
every close path (escape, mode reset, successful apply, hp-close, cancel).

Both regressions are pinned by execution: reverting either fix turns the
extended smoke red (fingerprintTracksCandidate / fallbackClearedOnClose),
and two new gate mutants keep it that way.

Issue: #295
User-Visible: no
2026-08-26 10:52:47 +03:00
claude[bot] 43f91a214b docs: review document for #295
Issue: #295
User-Visible: no
2026-08-26 07:47:10 +00:00
Codex 822e6223f7 test: accept the #295 preflight-dialog baselines
Both scenes were captured by CI run 32940625718 (byte-identical to run
32939996348), visually reviewed: failure reasons render as rows, the
ghost copy button is borderless per dialogs.styles.

Issue: #295
User-Visible: no
Release: v1.68.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32940625718
2026-08-26 10:28:29 +03:00
Codex 1cdd4ed6de fix: a refused geometry preflight names its reason and hands over diagnostics (#295)
Диалог «Оптимизировать» при отказе перечисляет причину по каждому
пространству (7 значений OptimizeGeometryFailureReason получили RU/EN
строки), даёт «Скопировать диагностику» — JSON-блок с origin: runtime,
версией карточки, отпечатками и классами исключений (граница приватности
checkOptimizeGeometry; privacy-тесты дополнены позитивной проверкой) — и
пишет одну структурированную запись в dev-лог (дедупликация по fingerprint).
При недоступном clipboard блок раскрывается прямо в диалоге.

Совет «обновите House Plan» больше не безусловный: websocket
houseplan/config/get теперь возвращает integration_version (бэкенд-тест),
и подсказка показывается только при реальном расхождении с версией карточки;
старый бэкенд без поля — подсказки нет.

Три новых мутанта (потеря причины в диалоге, блок без reason, отключённый
dev-лог) — краснота каждого проверена исполнением; смок
smoke_preflight_diagnostics на dev падает.

Issue: #295
User-Visible: yes
2026-08-26 10:28:29 +03:00
claude[bot]andCodex a5abfe8bd6 docs: review document for #295
Issue: #295
User-Visible: no
2026-08-26 10:28:29 +03:00
Codex 83ec6fb879 docs: spec #295 revision 2 — keep the #199 privacy boundary, real integration-version channel
Issue: #295
User-Visible: no
2026-08-26 10:28:29 +03:00
claude[bot]andCodex 2710dca7d1 docs: review document for #295
Issue: #295
User-Visible: no
2026-08-26 10:28:29 +03:00
Codex 114dcec27c docs: spec for #295 preflight failure diagnostics
Issue: #295
User-Visible: no
2026-08-26 10:28:28 +03:00
Codex 93054823c3 fix: the github-range runner throws so an orphaned BEFORE_SHA can fall back to dev
resolveValidationRange already knows how to replace a force-push-orphaned
BEFORE_SHA with origin/dev, but the CLI handed it a runner that killed the
process with exit 2 on the first cat-file instead of throwing into
gitObjectExists' catch. Every push after a mandatory issue-branch rebase
therefore painted process-gate red (runs 32939996348, 32940625718,
32942113142). The regression test drives the real CLI in a throwaway repo
with a BEFORE_SHA that no longer exists.

Issue: #315
User-Visible: no
2026-08-26 10:28:04 +03:00
claude[bot] 2364d6dd43 docs: review document for #314
Issue: #314
User-Visible: no
2026-08-26 07:09:22 +00:00
Matysh 64e9bf8f6b Cover dense v8 draft invariants
Issue: #314
User-Visible: no
2026-08-26 09:56:35 +03:00
claude[bot] 069c4867ea docs: review document for #314
Issue: #314
User-Visible: no
2026-08-26 06:43:41 +00:00
Matysh ce18012fb9 Finalize issue 314 specification status
Issue: #314
User-Visible: no
2026-08-26 09:20:39 +03:00
Matysh 0e18038c1b Fix documentation screenshot provenance
Issue: #314
User-Visible: no
2026-08-26 09:19:49 +03:00
Matysh 2347e8df88 Fix atomic model-v8 draft writes
Issue: #314
User-Visible: yes
2026-08-26 09:13:50 +03:00
claude[bot]andMatysh 5da7812099 docs: review document for #314
Issue: #314
User-Visible: no
2026-08-26 09:12:08 +03:00
Matysh 7a73bcbc2b docs: specify atomic v8 draft writes
Issue: #314
User-Visible: no
2026-08-26 09:12:08 +03:00
claude[bot] 98a0a24bd4 docs: review document for #269
Issue: #269
User-Visible: no
2026-08-26 06:10:29 +00:00
Codex 676610c0cd fix: align marker.show_entities with the user guide wording (#269)
USER-GUIDE.ru.md — канон формулировок интерфейса (AGENTS.md): флаг диалога
привязки называется «Показывать сущности», как в трёх местах гайда и в
преобладающей форме флагов самого словаря. en.json не меняется.

Issue: #269
User-Visible: yes
2026-08-26 09:04:07 +03:00
Codex 9726f2dfdb docs: spec #266 revision 4 — post-merge numbers per CODE-REVIEW-266-r2
Medium жёлтого r2 (случайно перезапущенное ревью на смерженной задаче):
AC6a приведён к факту — 12 reduced-motion обёрток (10 исходных, две
смешанные разрезаны фиксом каскада 0b782ee по зонам), сохранность обёртки
каждого правила держит scope-ключ styles-diff; AC2 — принятые wc -l числа.

Issue: #266
User-Visible: no
2026-08-26 08:52:42 +03:00
claude[bot] ccdd47a6bd docs: review document for #313
Issue: #313
User-Visible: no
2026-08-26 05:43:07 +00:00
Codex ed126d6a43 fix: keep a stored zero draft thickness and guard both thickness writers (#313)
По находкам CODE-REVIEW-313-r1:

High — резолвер больше не превращает сохранённый 0 сегмента драфта в 15:
ноль — легитимное значение (docs/WALL-THICKNESS.md §6), к дефолту 15 падает
только ОТСУТСТВУЮЩАЯ запись. Смок дополнен: hit нулевого сегмента несёт 0,
диалог показывает пустое поле, Apply без правки отказывает и не портит
данные.

Medium — гвард #278 усилен: паттерн допускает перенос строки после скобки,
и счётчик требует РОВНО ДВЕ точки коммита wall_thickness — мутация любой из
них (включая новую независимую) красит юнит; краснота второго патча мутанта
проверена изолированным исполнением.

Issue: #313
User-Visible: no
2026-08-26 08:35:58 +03:00
claude[bot] d34e56ba78 docs: review document for #313
Issue: #313
User-Visible: no
2026-08-26 05:28:07 +00:00
Codexandclaude[bot] 5e5dad277c fix: the Thickness tool serves standalone walls and saved drafts (#313)
Кандидаты _wallThickHit расширены: интервалы комнат ∪ перегородки ∪ сегменты
сохранённых драфтов (активная цепочка исключена, как в снап-геометрии); при
точном наложении побеждает независимая кладка — она владеет хит-зоной и
рисует видимое тело (решение владельца, согласовано с select и кейсом #308).
Диалог для независимой кладки без кнопки «на всю комнату»; запись — в
partition.cm / draft.segments[i].cm той же физической транзакцией с одним
Undo. Ноль/пусто для независимой кладки отклоняется существующим тостом
диапазона; switch записи — единственный шов, куда #306 повесит ветку
«ноль превращает перегородку в виртуальную стену».

Мутант wall-thickness-writer-bypasses-common-barrier расширен вторым патчем
на новую точку коммита (#278-гвард), краснота обоих проверена исполнением.
Смок smoke_wallthick_standalone: hit/диалог/запись/отказ нуля/приоритет
наложения/hover — на dev падает.

Issue: #313
User-Visible: yes
2026-08-26 05:09:48 +00:00
claude[bot] ffe0f0c788 docs: review document for #313
Issue: #313
User-Visible: no
2026-08-26 05:06:03 +00:00
Matysh a78deb7ce7 build: prepare v1.68.0-beta.1 candidate
Validate / smoke (1) (push) Failing after 25m57s
Validate / smoke (3) (push) Failing after 20m46s
Validate / docs (push) Failing after 40s
Validate / process-workflow-sync (push) Successful in 39s
Validate / golden (push) Failing after 19m23s
Validate / smoke (2) (push) Failing after 34m44s
Validate / changes (push) Successful in 1m2s
Validate / process-gate (push) Failing after 1m5s
Validate / smoke_done (push) Skipped
Validate / hacs (push) Failing after 19s
Validate / hassfest (push) Failing after 18s
Validate / performance_smoke (push) Failing after 21m41s
Validate / provenance (push) Successful in 1m10s
Validate / frontend (push) Successful in 9m51s
Validate / reuse (push) Successful in 56s
Validate / backend (push) Failing after 10m9s
Package stable wall identity, wall junction polish and active-chain visibility.

Issue: #282
Issue: #307
Issue: #309
Issue: #310
User-Visible: yes
2026-08-26 03:27:57 +03:00
Codex b4cccfdf63 fix: pin DoR to the commit era and merge only the reviewed SHA (#311, #312)
Правило 10 гейта (#311): DoR сверяется с моментом НАПИСАНИЯ кода — authorDate
коммита класса A не может предшествовать первому labeled-событию S5-ready+
из timeline issue; продвижение метки больше не прячет нарушение, ребейзы
конвейера его не смывают (authorDate переживает их). Проверка вторичная к
правилу 8: недоступный timeline — warn, правило 8 остаётся fail-closed.
LOG_FORMAT несёт authorDate третьим полем (append-совместимо).

Шаг слияния конвейера (#312): сливается только проверенный SHA — вершина
ветки сверяется с материалом ревью (допустим ровно один doc-коммит публикации
с диффом только docs/reviews/ поверх); расхождение отменяет слияние с
возвратом в S6-in-progress тем же путём, что конфликт (инвариант «метка
меняется всегда» сохранён). PROCESS.md §2.7 фиксирует правило «вердикт
привязан к SHA» и для ревьюера.

Issue: #311
Issue: #312
User-Visible: no
2026-08-26 03:15:57 +03:00
claude[bot] 7518cb2d4d docs: review document for #282
Validate / changes (push) Successful in 54s
Validate / frontend (push) Skipped
Validate / hassfest (push) Skipped
Validate / hacs (push) Skipped
Validate / reuse (push) Successful in 52s
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 21s
Validate / process-workflow-sync (push) Successful in 47s
Validate / provenance (push) Successful in 49s
Validate / process-gate (push) Failing after 54s
Issue: #282
User-Visible: no
2026-08-26 00:11:34 +00:00
Matysh f37efdad8a fix: keep wall-hosted passages open in static view
Issue: #282
User-Visible: yes
2026-08-26 02:53:19 +03:00
claude[bot] fb6cc3ca2d docs: review document for #282
Issue: #282
User-Visible: no
2026-08-25 23:49:05 +00:00
Matysh 95fa23ca79 docs: mark wall identity stage as verified
Issue: #282
User-Visible: no
2026-08-26 02:31:31 +03:00
Matysh 063026bc0b chore: retry validation after dev rebase
Issue: #282
User-Visible: no
2026-08-26 02:30:26 +03:00
Matysh ad54716d21 build: refresh wall model artifacts after dev rebase
Issue: #282
User-Visible: no
2026-08-26 02:23:57 +03:00
Matysh cfc1bb063e fix: preserve migrated wall interactions
Issue: #282
User-Visible: yes
2026-08-26 02:21:26 +03:00
Matysh 20f5c39699 fix: preserve wall-hosted openings in static view
Issue: #282
User-Visible: yes
2026-08-26 02:21:20 +03:00
claude[bot]andMatysh c75c77d571 docs: review document for #282
Issue: #282
User-Visible: no
2026-08-26 02:21:15 +03:00
Matysh 0f54daf37b fix: preserve passage validation during wall migration
Issue: #282
User-Visible: yes
2026-08-26 02:21:15 +03:00
Matysh 07402d64cc fix: preserve authored wall paths across identity barrier
Issue: #282
User-Visible: yes
2026-08-26 02:21:15 +03:00
Matysh 71ac4a6639 fix: enforce wall model barrier in backend optimize
Issue: #282
User-Visible: yes
2026-08-26 02:20:57 +03:00
Matysh fc9f513082 test: align import fixtures with wall model v8
Issue: #282
User-Visible: no
2026-08-26 02:20:57 +03:00
Matysh 3fff527e22 test: detach Windows mutation worktree dependencies safely
Issue: #282
User-Visible: no
2026-08-26 02:20:57 +03:00
Matysh e1059e2e29 fix: preserve wall identity through structural edits
Issue: #282
User-Visible: yes
2026-08-26 02:20:57 +03:00
claude[bot]andMatysh 2f155a83b9 docs: review document for #282
Issue: #282
User-Visible: no
2026-08-26 02:20:57 +03:00
Matysh ce01d095a8 docs: refresh public screenshots after rebase
Issue: #282
User-Visible: no
2026-08-26 02:20:57 +03:00
Matysh b336eee996 feat: stabilize persisted wall segment identity
Issue: #282
User-Visible: yes
2026-08-26 02:20:57 +03:00
claude[bot]andMatysh 245466d167 docs: review document for #282
Issue: #282
User-Visible: no
2026-08-26 02:20:57 +03:00
Matysh 4b414e26f7 docs: address wall identity spec review
Issue: #282
User-Visible: no
2026-08-26 02:20:57 +03:00
claude[bot]andMatysh b72b56ea45 docs: review document for #282
Issue: #282
User-Visible: no
2026-08-26 02:20:57 +03:00
Matysh 7a6331d359 docs: specify stable wall segment identity
Issue: #282
User-Visible: no
2026-08-26 02:20:57 +03:00
Codex 5f0a7f5065 docs: refresh the screenshot fingerprint after the cascade fix (#266)
Кадры прежние — golden и смоки подтверждают пиксельную идентичность; коммит
догоняет dev после мержа, прошедшего без него (пуш ветки и мерж конвейера
разошлись на один коммит).

Issue: #266
User-Visible: no
2026-08-26 02:10:59 +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
373 changed files with 42915 additions and 9686 deletions
+40 -2
View File
@@ -287,6 +287,16 @@ jobs:
echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT"
echo "ветка $BRANCH приведена к dev: $short_before -> $short_after"
# Материал ревью — конкретный SHA (#312). Вердикт применим только к
# нему: если во время ревью в ветку прилетит коммит, шаг слияния обязан
# это заметить и отказаться, а не молча увезти в dev непроверенный код.
- name: Зафиксировать SHA материала ревью
id: material
if: steps.rebase.outputs.conflict != 'true'
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "материал ревью: $(git rev-parse --short HEAD)"
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
# тоже смена метки, и автор не ждёт впустую.
@@ -701,14 +711,42 @@ jobs:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
run: |
if [ -z "$BRANCH" ]; then
echo "::error::ветки задачи нет — сливать нечего"
echo "merged=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch -q origin dev
git checkout -q -B merge-into-dev "origin/$BRANCH"
git fetch -q origin dev "$BRANCH"
# #312: сливается только проверенный код. Допустимые вершины ветки:
# сам SHA материала либо он же плюс ровно один коммит публикации
# документа ревью (дифф только docs/reviews/). Любой другой коммит —
# ветка уехала после ревью, вердикт к ней не применим: возврат в
# S6-in-progress через merged=false, как при конфликте.
actual=$(git rev-parse "origin/$BRANCH")
reviewed="$MATERIAL_SHA"
fresh=false
if [ "$actual" = "$reviewed" ]; then
fresh=true
elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \
&& [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then
fresh=true
fi
if [ "$fresh" != true ]; then
echo "merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)"
cat > /tmp/stale-verdict.md <<EOF
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
Ревью выполнялось на \\`$(git rev-parse --short "$reviewed")\\`, а вершина ветки сейчас \\`$(git rev-parse --short "$actual")\\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
Задача переведена в \\`S6-in-progress\\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \\`S7-code-review\\` — новый заход ревью проверит актуальный код.
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
exit 0
fi
git checkout -q -B merge-into-dev "$actual"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase origin/dev; then
+6
View File
@@ -194,6 +194,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
Medium **вне скоупа** — отдельный issue (#202).
- **Вердикт привязан к SHA (#312).** Все числа и факты отчёта сверяются с
`git rev-parse HEAD` непосредственно перед подведением итогов, а не с SHA,
зафиксированным в начале разбора: во время ревью в ветку может прилететь
fix-up. Серверный стопор — шаг слияния конвейера сверяет вершину ветки с
SHA материала ревью (допустим только собственный doc-коммит публикации
поверх) и при расхождении отменяет слияние с возвратом в `S6-in-progress`.
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
+2 -2
View File
@@ -46,12 +46,12 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.67.0-rc.1"
VERSION = "1.68.0-beta.4"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
# user have different compatibility lifecycles.
PLAN_MODEL_VERSION = 7
PLAN_MODEL_VERSION = 9
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
@@ -136,6 +136,10 @@ def canonicalize_config_geometry(config: Any) -> Any:
_lattice_point(wall.get("a"))
_lattice_point(wall.get("b"))
for segment in _records(space.get("wall_segments")):
_lattice_point(segment.get("a"))
_lattice_point(segment.get("b"))
for opening in _records(space.get("openings")):
_lattice_fields(opening, ("x", "y"))
_scalar_fields(opening, ("angle", "length"))
File diff suppressed because one or more lines are too long
+577 -98
View File
@@ -36,6 +36,10 @@ from .const import (
VERSION,
)
from .store import HouseplanData
from .wall_segment_model import (
WallSegmentMigrationError,
commit_wall_segment_model,
)
from .validation import (
CONFIG_SCHEMA,
LAYOUT_SCHEMA,
@@ -63,10 +67,17 @@ _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", "wall", "opening", "decor", "draft",
"draft_segment", "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",
"plan_scale", "plan_scale_x", "plan_scale_y", "plan_angle", "view_box",
"zero_wall_style",
)
_SPACE_DISPLAY_FIELDS = (
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
@@ -75,7 +86,7 @@ _SPACE_DISPLAY_FIELDS = (
"label_lqi", "label_light", "card_font_scale", "north_deg", "bg_mode",
"sun_rays",
)
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids")
_ROOM_DISPLAY_FIELDS = (
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
)
@@ -244,6 +255,7 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
]
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
("walls", ("key", "cm", "a", "b")),
("wall_segments", ("id", "a", "b", "cm")),
("room_drafts", ("id", "points", "segments")),
("partitions", ("id", "a", "b", "cm")),
("wall_columns", ("id", "shape", "center", "cm", "angle")),
@@ -257,7 +269,7 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
selected = _pick_fields(item, fields)
if name == "room_drafts" and "segments" in selected:
selected["segments"] = [
_pick_fields(segment, ("cm",))
_pick_fields(segment, ("id", "cm"))
for segment in selected.get("segments") or []
]
values.append(selected)
@@ -579,7 +591,14 @@ def parse_document(raw: bytes) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ImportFailure("invalid_format", "Missing payload")
try:
config = CONFIG_SCHEMA(_json_copy(payload.get("config")))
config_candidate = _json_copy(payload.get("config"))
# The envelope is authoritative. Export deliberately omits this field
# inside payload.config, but v8 semantic validation still has to run
# before an import token can be issued.
if model > 0:
config_candidate["model_version"] = model
config = CONFIG_SCHEMA(config_candidate)
config.pop("model_version", None)
except (vol.Invalid, TypeError, ValueError) as err:
raise ImportFailure("invalid_config", str(err)) from err
if document["kind"] == "full":
@@ -801,8 +820,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 +864,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,42 +1159,125 @@ 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_segments") 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(segment.get("id"))
for sp in current_config.get("spaces") or []
for draft in sp.get("room_drafts") or []
for segment in draft.get("segments") or []
if isinstance(segment, dict) and segment.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_segments") 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
} | {
str(segment.get("id"))
for incoming_space in spaces
for draft in incoming_space.get("room_drafts") or []
for segment in draft.get("segments") or []
if isinstance(segment, dict) and segment.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_segments", "wall"),
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
):
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,
)
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
if not isinstance(segment, dict) or segment.get("id") is None:
continue
old = str(segment["id"])
_record_bounded_lineage(
reference_report, bounded_seen, "draft_segment", old,
)
new = _fresh("draft_segment", old, used)
id_map[old] = new
id_maps["draft_segment"][old] = new
segment["id"] = new
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
if room.get("wall_ids"):
room["wall_ids"] = [
id_maps["wall"].get(str(value), str(value))
for value in room["wall_ids"]
]
# 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
# names the source partition.
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":
if isinstance(host, dict) and host.get("kind") in ("partition", "wall"):
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 +1300,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 +1374,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 +1387,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 +1436,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 +1483,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 +1494,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 +1621,84 @@ 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)
incoming_model = int(prepared.get("model_version", 0) or 0)
target_model = int(current_config.get("model_version", 0) or 0)
requires_v8 = incoming_model >= 8 or target_model >= 8
if requires_v8:
try:
# A v8 target cannot be downgraded by a v7 backup, while an
# incoming v8 graph must retain its already-persisted IDs. When
# both sides are v7 the accepted compatibility contract keeps the
# import v7 until a later structural write or explicit Optimize.
imported_config["model_version"] = incoming_model
imported_config, _ = commit_wall_segment_model(imported_config)
prepared["payload"]["config"] = imported_config
if prepared["kind"] == "space":
current_config, _ = commit_wall_segment_model(current_config)
except WallSegmentMigrationError as err:
raise ImportFailure(err.code, str(err)) from err
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
if not requires_v8 and incoming_model > 0:
config["model_version"] = incoming_model
_materialize_global_background(config)
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 +1724,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 +1735,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 +1755,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 +1811,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 +1834,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 +1885,17 @@ def prepare_apply(
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
if candidate.get("confirmation_required") and not confirm_missing_content:
raise ImportFailure("content_confirmation_required", "Missing content must be confirmed")
document = candidate["document"]
# Detachment is defined in the exported identity space. Do it before a
# space import remaps owners to fresh ids.
imported_config = _json_copy(document["payload"]["config"])
if candidate.get("confirmation_required"):
_detach_missing(imported_config, candidate.get("content") or [])
if document["kind"] == "full":
config = imported_config
_materialize_global_background(config)
# Full exports keep the data-model version in the portable envelope so
# the payload can be validated independently. Restore it before the
# configuration is persisted; otherwise every full round-trip silently
# downgrades the stored plan to an unversioned document.
model_version = document.get("model_version", 0)
if isinstance(model_version, int) and not isinstance(model_version, bool) \
and model_version > 0:
config["model_version"] = model_version
layout = _json_copy(document["payload"]["layout"])
if not candidate.get("same_source"):
settings = config.get("settings") or {}
settings.pop("known_devices", None)
settings.pop("new_device_ids", None)
details = {"counts": _counts(config, layout)}
else:
local_document = {**document, "payload": {**document["payload"], "config": imported_config}}
config, layout, details = build_space_merge(
local_document, current_config, current_layout,
duplicate_policy or candidate.get("duplicate_policy", "skip"),
same_source=bool(candidate.get("same_source")),
)
details["counts"] = _counts(config, layout)
return CONFIG_SCHEMA(config), LAYOUT_SCHEMA(layout), details
if duplicate_policy is not None and duplicate_policy != candidate.get("duplicate_policy"):
raise ImportFailure("conflict", "Duplicate policy changed after the preview")
if candidate.get("digest") != _candidate_digest(candidate):
raise ImportFailure("invalid_format", "Import preview candidate changed")
config = candidate.get("target_config")
layout = candidate.get("target_layout")
details = candidate.get("details")
if not isinstance(config, dict) or not isinstance(layout, dict) \
or not isinstance(details, dict):
raise ImportFailure("invalid_format", "Import preview has no materialized candidate")
# The current stores are intentionally unused: revisions are checked by
# the websocket handler under the write lock before this function runs.
_ = current_config, current_layout
return _json_copy(config), _json_copy(layout), _json_copy(details)
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.67.0-rc.1"
"version": "1.68.0-beta.4"
}
+620 -49
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 copy
import math
import re
@@ -77,6 +78,137 @@ class PartitionOpeningJambMarginError(ValueError):
)
class WallModelClientOutdatedError(ValueError):
"""A stale client tried to replace an already-migrated v8 document."""
code = "wall_model_client_outdated"
def _legacy_wall_model_projection(config: dict) -> dict:
"""Structural view an older client can faithfully round-trip."""
projected = copy.deepcopy(config)
projected.pop("model_version", None)
for space in projected.get("spaces") or []:
space.pop("wall_segments", None)
for room in space.get("rooms") or []:
room.pop("wall_ids", None)
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
segment.pop("id", None)
for opening in space.get("openings") or []:
host = opening.get("host")
if isinstance(host, dict) and host.get("kind") == "wall":
opening.pop("host", None)
return projected
def _catalog_coupled_wall_geometry_projection(config: dict) -> list[dict]:
"""Legacy geometry whose edit must also update the v8 contour catalogue.
Drafts, partitions and columns carry their own ids. Openings carry an
explicit v8 host and are checked against it by CONFIG_SCHEMA. None of those
independent objects requires a different contour wall_segments catalogue.
"""
result: list[dict] = []
for space in config.get("spaces") or []:
rooms = []
for room in space.get("rooms") or []:
rooms.append({key: copy.deepcopy(room.get(key)) for key in (
"id", "poly", "x", "y", "w", "h", "open_to"
) if key in room})
result.append({
"id": space.get("id"),
"rooms": rooms,
"walls": copy.deepcopy(space.get("walls") or []),
"open_spans": copy.deepcopy(space.get("open_spans") or []),
})
return result
def _wall_catalog_projection(config: dict) -> list[dict]:
"""Authoritative part a current v8 structural writer must update."""
return [{
"id": space.get("id"),
"wall_segments": copy.deepcopy(space.get("wall_segments")),
} for space in config.get("spaces") or []]
def _restore_wall_model_fields(config: dict, previous: dict) -> None:
"""Hydrate only v8 identity fields after an exact legacy round-trip."""
config["model_version"] = previous["model_version"]
old_spaces = {str(space.get("id")): space for space in previous.get("spaces") or []}
for space in config.get("spaces") or []:
old_space = old_spaces.get(str(space.get("id")))
if old_space is None:
continue
space["wall_segments"] = copy.deepcopy(old_space.get("wall_segments") or [])
old_rooms = {str(room.get("id")): room for room in old_space.get("rooms") or []}
for room in space.get("rooms") or []:
old_room = old_rooms.get(str(room.get("id")))
if old_room is not None and "wall_ids" in old_room:
room["wall_ids"] = copy.deepcopy(old_room["wall_ids"])
old_drafts = {str(draft.get("id")): draft for draft in old_space.get("room_drafts") or []}
for draft in space.get("room_drafts") or []:
old_draft = old_drafts.get(str(draft.get("id")))
if old_draft is None:
continue
old_segments = old_draft.get("segments") or []
for index, segment in enumerate(draft.get("segments") or []):
if index < len(old_segments) and old_segments[index].get("id"):
segment["id"] = old_segments[index]["id"]
old_openings = {
str(opening.get("id")): opening for opening in old_space.get("openings") or []
}
for opening in space.get("openings") or []:
old_opening = old_openings.get(str(opening.get("id")))
old_host = (old_opening or {}).get("host")
if isinstance(old_host, dict) and old_host.get("kind") == "wall":
opening["host"] = copy.deepcopy(old_host)
def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
"""Do not let omission of model_version bypass v8 semantic validation."""
try:
old_model = int((previous or {}).get("model_version", 0))
new_model = int(config.get("model_version", 0))
except (TypeError, ValueError):
return # CONFIG_SCHEMA owns malformed values.
previous = previous or {}
contour_geometry_changed = (
_catalog_coupled_wall_geometry_projection(config)
!= _catalog_coupled_wall_geometry_projection(previous)
)
if old_model >= 8 and new_model < 8:
if not contour_geometry_changed:
_restore_wall_model_fields(config, previous or {})
# Re-run semantic parity after hydration; previous identity is
# accepted only when it still matches the submitted projections.
validated = CONFIG_SCHEMA(config)
config.clear()
config.update(validated)
return
raise WallModelClientOutdatedError(
f"stored model={old_model}; submitted model={new_model}"
)
if old_model >= 8 and 8 <= new_model <= old_model and contour_geometry_changed:
# The realistic stale-client case echoes model_version and the unknown
# catalogue verbatim while changing room/contour geometry. Let the
# frontend show the dedicated reload guidance instead of a generic
# schema error. Independent drafts, partitions, columns and explicitly
# hosted openings are validated by CONFIG_SCHEMA without requiring a
# contour catalogue change (#314).
#
# A submitted model ABOVE the stored one is excluded on purpose (#319):
# a stale client can only echo the stored version, never raise it. The
# first write of a newer client legitimately drops legacy projection
# fields (an orphan open_span/open_to) without touching the catalogue;
# treating that as "outdated" wedged every structural write forever.
if _wall_catalog_projection(config) == _wall_catalog_projection(previous):
raise WallModelClientOutdatedError(
f"stored model={old_model}; unchanged wall catalogue"
)
# One normalized canvas width contains this many physical grid cells. Keep in
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
# user setting.
@@ -145,20 +277,257 @@ def _angle_delta_mod_180(first: float, second: float) -> float:
return abs((first - second + 90.0) % 180.0 - 90.0)
def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, target_b: list) -> bool:
"""Whether a collinear union covers one complete positive target span."""
dx, dy, length = _segment_metrics(target_a, target_b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
ranges = []
for a, b in segments:
if (_line_distance(a, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON):
continue
lo, hi = sorted((_projection(a, target_a, target_b),
_projection(b, target_a, target_b)))
lo, hi = max(0.0, lo), min(length, hi)
if hi - lo > _OPTIMIZE_REHOST_EPSILON:
ranges.append((lo, hi))
reached = 0.0
for lo, hi in sorted(ranges):
if lo > reached + _OPTIMIZE_REHOST_EPSILON:
return False
reached = max(reached, hi)
if reached >= length - _OPTIMIZE_REHOST_EPSILON:
return True
return False
_OPTIMIZE_PARTITION_KEYS = {"id", "a", "b", "cm"}
def _known_optimize_partition(partition: dict) -> bool:
return isinstance(partition, dict) and set(partition).issubset(_OPTIMIZE_PARTITION_KEYS)
def _axis_range(a: list, b: list, axis_a: list, axis_b: list) -> tuple[float, float] | None:
"""Project one positive collinear overlap onto ``axis_a -> axis_b``."""
if not (isinstance(a, list) and len(a) == 2
and isinstance(b, list) and len(b) == 2):
return None
if (_line_distance(a, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON):
return None
_, _, length = _segment_metrics(axis_a, axis_b)
lo, hi = sorted((_projection(a, axis_a, axis_b),
_projection(b, axis_a, axis_b)))
lo, hi = max(0.0, lo), min(length, hi)
return (lo, hi) if hi - lo > _OPTIMIZE_REHOST_EPSILON else None
def _axis_point(a: list, b: list, along: float) -> list[float]:
dx, dy, length = _segment_metrics(a, b)
return [float(a[0]) + dx / length * along,
float(a[1]) + dy / length * along]
def _optimize_partition_needs_proof(old_partition: dict, space: dict) -> bool:
"""Select #296 removal/splitting, not ordinary Optimize grid alignment."""
partition_id = str(old_partition.get("id", ""))
current = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if current == old_partition:
return False
if current is None:
return True
if not (_known_optimize_partition(old_partition)
and _known_optimize_partition(current)):
return False
try:
if float(current.get("cm")) != float(old_partition.get("cm")):
return False
old_a, old_b = old_partition["a"], old_partition["b"]
current_range = _axis_range(current["a"], current["b"], old_a, old_b)
_, _, old_length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
return current_range is not None and (
current_range[0] > _OPTIMIZE_REHOST_EPSILON
or current_range[1] < old_length - _OPTIMIZE_REHOST_EPSILON
)
def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
"""Independently prove every removed atom of one old partition axis."""
if not _known_optimize_partition(old_partition):
return False
try:
old_a, old_b = old_partition["a"], old_partition["b"]
old_cm = float(old_partition["cm"])
_, _, length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
if length <= _OPTIMIZE_REHOST_EPSILON or old_cm <= 0:
return False
rooms: list[tuple[str, list[tuple[list, list]]]] = []
for room in space.get("rooms") or []:
poly = _room_polygon(room)
if len(poly) < 3:
return False
rooms.append((str(room.get("id", "")), [
(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))
]))
candidate_partitions = space.get("partitions") or []
retained: list[tuple[float, float]] = []
breakpoints = [0.0, length]
def note_segment(a: list, b: list) -> tuple[float, float] | None:
projected = _axis_range(a, b, old_a, old_b)
if projected:
breakpoints.extend(projected)
return projected
for partition in candidate_partitions:
try:
projected = note_segment(partition["a"], partition["b"])
candidate_cm = float(partition["cm"])
except (KeyError, TypeError, ValueError):
return False
if projected and _known_optimize_partition(partition) and candidate_cm == old_cm:
retained.append(projected)
for _, edges in rooms:
for edge_a, edge_b in edges:
note_segment(edge_a, edge_b)
for collection in (space.get("walls") or [], space.get("open_spans") or []):
for segment in collection:
try:
note_segment(segment["a"], segment["b"])
except (KeyError, TypeError, ValueError):
return False
points = sorted(set(round(point, 12) for point in breakpoints
if -_OPTIMIZE_REHOST_EPSILON <= point
<= length + _OPTIMIZE_REHOST_EPSILON))
for lo, hi in zip(points, points[1:]):
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
continue
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
and end >= hi - _OPTIMIZE_REHOST_EPSILON
for start, end in retained):
continue
target_a, target_b = _axis_point(old_a, old_b, lo), _axis_point(old_a, old_b, hi)
owners = {room_id for room_id, edges in rooms
if _segments_cover_target(edges, target_a, target_b)}
collinear = {room_id for room_id, edges in rooms
if any(_segments_overlap_on_axis(
target_a, target_b, edge_a, edge_b
) for edge_a, edge_b in edges)}
if len(owners) not in (1, 2) or collinear != owners:
return False
if any(_segments_overlap_on_axis(
target_a, target_b, span["a"], span["b"]
) for span in space.get("open_spans") or []):
return False
if any(_segments_overlap_on_axis(
target_a, target_b, partition["a"], partition["b"]
) for partition in candidate_partitions):
return False
covering_walls = []
for wall in space.get("walls") or []:
try:
wall_a, wall_b = wall["a"], wall["b"]
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((
_segment_metrics(wall_a, wall_b)[2], float(wall["cm"]),
))
except (KeyError, TypeError, ValueError):
return False
effective_cm = (min(covering_walls, key=lambda item: item[0])[1]
if covering_walls else _DEFAULT_ROOM_WALL_CM)
if effective_cm + _OPTIMIZE_REHOST_EPSILON < old_cm:
return False
return True
def _hosted_opening_geometry(opening: dict, partition: dict) -> tuple[float, float, float, float] | None:
host = opening.get("host")
try:
if not isinstance(host, dict) or host.get("kind") != "partition":
return None
t = float(host["t"])
a, b = partition["a"], partition["b"]
dx, dy, length = _segment_metrics(a, b)
opening_length = float(opening["length"])
except (KeyError, TypeError, ValueError):
return None
if not 0 <= t <= 1 or length <= _OPTIMIZE_REHOST_EPSILON or opening_length <= 0:
return None
angle = math.degrees(math.atan2(dy, dx))
if angle >= 90:
angle -= 180
elif angle < -90:
angle += 180
return float(a[0]) + dx * t, float(a[1]) + dy * t, angle, opening_length
def _safe_optimize_residual_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Prove a hosted opening rebound to a newly-created residual id."""
old_host, new_host = old_opening.get("host"), opening.get("host")
if not (isinstance(old_host, dict) and isinstance(new_host, dict)
and old_host.get("kind") == new_host.get("kind") == "partition"):
return False
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == str(old_host.get("id", ""))), None)
new_partition = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == str(new_host.get("id", ""))), None)
if old_partition is None or new_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
ignored = {"host", "x", "y", "angle"}
if ({key: value for key, value in old_opening.items() if key not in ignored}
!= {key: value for key, value in opening.items() if key not in ignored}):
return False
old_geometry = _hosted_opening_geometry(old_opening, old_partition)
new_geometry = _hosted_opening_geometry(opening, new_partition)
if old_geometry is None or new_geometry is None:
return False
old_x, old_y, old_angle, old_length = old_geometry
new_x, new_y, new_angle, new_length = new_geometry
try:
explicit_x, explicit_y = float(opening["x"]), float(opening["y"])
explicit_angle = float(opening["angle"])
except (KeyError, TypeError, ValueError):
return False
return (
abs(old_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_length - new_length) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(old_angle, new_angle) <= 1e-7
and abs(explicit_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(explicit_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(explicit_angle, new_angle) <= 1e-7
)
def _safe_optimize_partition_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Independently prove the exact #276 partition-to-room-wall transition."""
"""Independently prove the #276/#296 partition-to-room-wall transition."""
old_host = old_opening.get("host")
if not isinstance(old_host, dict) or old_host.get("kind") != "partition":
return False
partition_id = str(old_host.get("id", ""))
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if old_partition is None or any(
str(item.get("id", "")) == partition_id
for item in space.get("partitions") or []
):
if old_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
a, b = old_partition.get("a"), old_partition.get("b")
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
@@ -167,36 +536,6 @@ def _safe_optimize_partition_rehost(
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
owners: set[str] = set()
collinear_rooms: set[str] = set()
for room in space.get("rooms") or []:
poly = _room_polygon(room)
room_id = str(room.get("id", ""))
edges = [(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))]
if any(_segment_covers(edge_a, edge_b, a, b) for edge_a, edge_b in edges):
owners.add(room_id)
if any(_segments_overlap_on_axis(a, b, edge_a, edge_b)
for edge_a, edge_b in edges):
collinear_rooms.add(room_id)
if len(owners) not in (1, 2) or collinear_rooms != owners:
return False
if any(_segments_overlap_on_axis(a, b, span["a"], span["b"])
for span in space.get("open_spans") or []):
return False
covering_walls = []
for wall in space.get("walls") or []:
wall_a, wall_b = wall.get("a"), wall.get("b")
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
continue
if _segment_covers(wall_a, wall_b, a, b):
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
if covering_walls else _DEFAULT_ROOM_WALL_CM
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
return False
ignored = {"host", "x", "y", "angle"}
old_stable = {key: value for key, value in old_opening.items() if key not in ignored}
new_stable = {key: value for key, value in opening.items() if key not in ignored}
@@ -226,6 +565,42 @@ def _safe_optimize_partition_rehost(
or along + opening_length / 2 > length + _OPTIMIZE_REHOST_EPSILON):
return False
half_dx, half_dy = dx / length * opening_length / 2, dy / length * opening_length / 2
target_a = [expected_x - half_dx, expected_y - half_dy]
target_b = [expected_x + half_dx, expected_y + half_dy]
owners: set[str] = set()
collinear_rooms: set[str] = set()
for room in space.get("rooms") or []:
poly = _room_polygon(room)
room_id = str(room.get("id", ""))
edges = [(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))]
if _segments_cover_target(edges, target_a, target_b):
owners.add(room_id)
if any(_segments_overlap_on_axis(target_a, target_b, edge_a, edge_b)
for edge_a, edge_b in edges):
collinear_rooms.add(room_id)
if len(owners) not in (1, 2) or collinear_rooms != owners:
return False
if any(_segments_overlap_on_axis(target_a, target_b, span["a"], span["b"])
for span in space.get("open_spans") or []):
return False
if any(_segments_overlap_on_axis(target_a, target_b, item["a"], item["b"])
for item in space.get("partitions") or []):
return False
covering_walls = []
for wall in space.get("walls") or []:
wall_a, wall_b = wall.get("a"), wall.get("b")
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
continue
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
if covering_walls else _DEFAULT_ROOM_WALL_CM
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
return False
for other in space.get("openings") or []:
if other is opening or str(other.get("id", "")) == str(opening.get("id", "")):
continue
@@ -277,22 +652,38 @@ def validate_partition_opening_hosts(
for opening in space.get("openings") or []:
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:
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"
)
old_host = (old or {}).get("host")
host = opening.get("host")
if (old and isinstance(old_host, dict)
and old_host.get("kind") == "partition"):
same_partition = (
isinstance(host, dict) and host.get("kind") == "partition"
and str(host.get("id", "")) == str(old_host.get("id", ""))
)
residual_rehost = (
isinstance(host, dict) and host.get("kind") == "partition"
and not same_partition
)
proved = same_partition or (
allow_optimize_rehost and old_space is not None
and (_safe_optimize_residual_rehost(
space, old_space, opening, old
) if residual_rehost else _safe_optimize_partition_rehost(
space, old_space, opening, old
))
)
if not proved:
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; host changed"
)
if host is None:
continue
if host.get("kind") != "partition":
continue
partition = partitions.get(str(host.get("id", "")))
if partition is None:
# SPACE_SCHEMA owns missing-host diagnostics.
continue
old_host = (old or {}).get("host")
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
ax, ay = partition["a"]
bx, by = partition["b"]
@@ -324,6 +715,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"}
@@ -648,7 +1047,11 @@ MAX_ROOMS = 400
MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_WALLS = 500
# v8 atomises room boundaries. The 2 MiB wire cap is the practical bound; this
# structural cap mirrors MAX_ROOMS * MAX_POLY_POINTS without depending on the
# later constant declaration.
MAX_WALLS = 200_000
MAX_WALL_SEGMENTS = 200_000
MAX_ROOM_DRAFTS = 200
MAX_DRAFT_SEGMENTS = 2000
MAX_PARTITIONS = 2000
@@ -813,6 +1216,10 @@ ROOM_SCHEMA = vol.All(
vol.Optional("w"): _EXTENT,
vol.Optional("h"): _EXTENT,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
vol.Optional("wall_ids"): vol.All(
[vol.All(str, vol.Length(min=1, max=64))],
vol.Length(min=3, max=MAX_POLY_POINTS),
),
},
extra=vol.ALLOW_EXTRA,
),
@@ -984,6 +1391,26 @@ WALL_SCHEMA = vol.All(
)
def _wall_segment_nonzero(value: dict) -> dict:
if value["a"] == value["b"]:
raise vol.Invalid("wall segment endpoints must differ")
return value
WALL_SEGMENT_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("a"): POINT,
vol.Required("b"): POINT,
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
_wall_segment_nonzero,
)
def _room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
@@ -999,7 +1426,10 @@ ROOM_DRAFT_SCHEMA = vol.All(
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
vol.Required("segments"): vol.All(
[vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
[vol.Schema({
vol.Optional("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
},
extra=vol.ALLOW_EXTRA)],
vol.Length(min=1, max=499),
),
@@ -1021,7 +1451,7 @@ PARTITION_SCHEMA = vol.All(
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("a"): POINT,
vol.Required("b"): POINT,
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
@@ -1063,11 +1493,22 @@ PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
extra=vol.PREVENT_EXTRA,
)
WALL_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("wall"),
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
},
extra=vol.PREVENT_EXTRA,
)
OPENING_HOST_SCHEMA = vol.Any(PARTITION_OPENING_HOST_SCHEMA, WALL_OPENING_HOST_SCHEMA)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
seen: set[str] = set()
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns", "wall_segments"):
for item in value.get(key, []):
item_id = item.get("id")
if not item_id:
@@ -1080,6 +1521,14 @@ def _space_geometry_invariants(value: dict) -> dict:
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
for draft in value.get("room_drafts", []):
for segment in draft.get("segments", []):
segment_id = segment.get("id")
if not segment_id:
continue
if segment_id in seen:
raise vol.Invalid("geometry object ids must be unique within a space")
seen.add(segment_id)
partitions = {
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
}
@@ -1088,6 +1537,8 @@ def _space_geometry_invariants(value: dict) -> dict:
host = opening.get("host")
if host is None:
continue
if host["kind"] != "partition":
continue
partition = partitions.get(host["id"])
if partition is None:
raise vol.Invalid("partition opening host must exist in the same space")
@@ -1168,7 +1619,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
vol.Optional("host"): OPENING_HOST_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)
@@ -1177,6 +1628,10 @@ SPACE_SCHEMA = vol.All(vol.Schema(
# (midpoint + direction), thickness always in centimetres. Optional —
# a space without `walls` validates and renders exactly as before.
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
vol.Optional("wall_segments"): vol.All(
[WALL_SEGMENT_SCHEMA], vol.Length(max=MAX_WALL_SEGMENTS)
),
vol.Optional("zero_wall_style"): vol.In(["dashed", "solid"]),
vol.Optional("room_drafts"): vol.All(
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
),
@@ -1305,9 +1760,124 @@ MARKER_SCHEMA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
def _canonical_segment_key(a: list, b: list) -> tuple:
"""Endpoint-order-independent exact key after the shared canonicalizer."""
pa = (round(float(a[0]), 12), round(float(a[1]), 12))
pb = (round(float(b[0]), 12), round(float(b[1]), 12))
return (pa, pb) if pa <= pb else (pb, pa)
def _config_wall_segment_invariants(value: dict) -> dict:
"""Fail closed when a v8+ writer sends stale ids or wall projections."""
try:
model = int(value.get("model_version", 0))
except (TypeError, ValueError):
raise vol.Invalid("model_version must be an integer") from None
if model < 8:
return value
for space in value.get("spaces", []):
if model >= 9:
if "open_spans" in space or any("open_to" in room for room in space.get("rooms", [])):
raise vol.Invalid("v9 config must not contain legacy open boundaries")
segments = space.get("wall_segments")
if segments is None:
raise vol.Invalid("v8+ space requires wall_segments")
by_id = {segment["id"]: segment for segment in segments}
if len(by_id) != len(segments):
raise vol.Invalid("wall segment ids must be unique")
owners: dict[str, set[str]] = {segment_id: set() for segment_id in by_id}
for room in space.get("rooms", []):
poly = room.get("poly")
wall_ids = room.get("wall_ids")
if not poly or not wall_ids or len(poly) != len(wall_ids):
raise vol.Invalid("v8+ room wall_ids must match poly edges")
room_id = room["id"]
for index, segment_id in enumerate(wall_ids):
segment = by_id.get(segment_id)
if segment is None:
raise vol.Invalid("room wall id must reference the same space")
edge_key = _canonical_segment_key(poly[index], poly[(index + 1) % len(poly)])
if edge_key != _canonical_segment_key(segment["a"], segment["b"]):
raise vol.Invalid("room wall reference geometry must match its edge")
owners[segment_id].add(room_id)
if any(len(room_ids) not in (1, 2) for room_ids in owners.values()):
raise vol.Invalid("wall segment must have one or two room owners")
expected_walls = {
_canonical_segment_key(segment["a"], segment["b"]): float(segment["cm"])
for segment in segments if float(segment["cm"]) > 0
}
actual_walls: dict[tuple, float] = {}
for wall in space.get("walls", []):
if "a" not in wall or "b" not in wall:
raise vol.Invalid("wall compatibility projection requires exact endpoints")
key = _canonical_segment_key(wall["a"], wall["b"])
if key in actual_walls:
raise vol.Invalid("wall compatibility projection must be unique")
actual_walls[key] = float(wall["cm"])
if actual_walls != expected_walls:
raise vol.Invalid("wall compatibility projection must match wall_segments")
for draft in space.get("room_drafts", []):
if any(not segment.get("id") for segment in draft.get("segments", [])):
raise vol.Invalid("v8+ draft wall segments require ids")
partitions = {item["id"]: item for item in space.get("partitions", [])}
wall_opening_intervals: dict[str, list[tuple[float, float]]] = {}
for opening in space.get("openings", []):
host = opening.get("host")
if host is None:
# #316 §3.3: a degraded migration may leave a contour opening
# unhosted. It is inert (no body/tunnel/cut) and excluded from
# the interval checks; a later edit can re-place it.
continue
if host["kind"] == "partition":
if host["id"] not in partitions:
raise vol.Invalid("partition opening host must exist in the same space")
if float(partitions[host["id"]]["cm"]) <= 0:
raise vol.Invalid("opening host must have positive thickness")
continue
segment = by_id.get(host["id"])
if segment is None:
raise vol.Invalid("wall opening host must exist in the same space")
if float(segment["cm"]) <= 0:
raise vol.Invalid("opening host must have positive thickness")
t = float(host["t"])
x = float(segment["a"][0]) + (float(segment["b"][0]) - float(segment["a"][0])) * t
y = float(segment["a"][1]) + (float(segment["b"][1]) - float(segment["a"][1])) * t
if abs(x - float(opening["x"])) > 2e-8 or abs(y - float(opening["y"])) > 2e-8:
raise vol.Invalid("wall opening geometry must match its host")
expected_angle = math.degrees(math.atan2(
float(segment["b"][1]) - float(segment["a"][1]),
float(segment["b"][0]) - float(segment["a"][0]),
))
if _angle_delta_mod_180(float(opening["angle"]), expected_angle) > 8:
raise vol.Invalid("wall opening angle must match its host")
span = ((segment["b"][0] - segment["a"][0]) ** 2
+ (segment["b"][1] - segment["a"][1]) ** 2) ** 0.5
along = t * span
length = float(opening["length"])
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("wall opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = wall_opening_intervals.setdefault(host["id"], [])
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9
for old_lo, old_hi in occupied):
raise vol.Invalid("wall openings must not overlap")
occupied.append((lo, hi))
return value
CONFIG_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional("model_version"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=1_000_000)
),
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
@@ -1342,4 +1912,5 @@ CONFIG_SCHEMA = vol.All(
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
),
canonicalize_config_geometry,
_config_wall_segment_invariants,
)
@@ -0,0 +1,684 @@
"""Deterministic persisted contour-wall identity (model v9, issues #282/#306).
This is the backend twin of ``src/wall-segment-model.ts``. Import preview is
a server-side structural writer, so it cannot depend on a browser being open
to upgrade v7 backups before validating/remapping them.
"""
from __future__ import annotations
import base64
import copy
import hashlib
import math
import uuid
from typing import Any
from .coordinate_canonicalization import canonicalize_config_geometry
WALL_SEGMENT_MODEL_VERSION = 9
GRID_STEP_N = 1 / 240
EPS = 1e-9
class WallSegmentMigrationError(ValueError):
"""The candidate cannot be upgraded without guessing ownership."""
code = "wall_model_migration_blocked"
def __init__(self, reason: str, detail: str = "") -> None:
self.reason = reason
super().__init__(f"{reason}: {detail}" if detail else reason)
def _point_key(point: list[float]) -> str:
return f"{float(point[0]):.12f},{float(point[1]):.12f}"
def _span_key(a: list[float], b: list[float]) -> str:
ka, kb = _point_key(a), _point_key(b)
return f"{ka}|{kb}" if ka < kb else f"{kb}|{ka}"
def _canonical_span(a: list[float], b: list[float]) -> tuple[list[float], list[float]]:
left, right = ([float(a[0]), float(a[1])], [float(b[0]), float(b[1])])
return (left, right) if _point_key(left) <= _point_key(right) else (right, left)
def _length(a: list[float], b: list[float]) -> float:
return math.hypot(float(b[0]) - float(a[0]), float(b[1]) - float(a[1]))
def _project_t(point: list[float], a: list[float], b: list[float]) -> float:
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
denominator = dx * dx + dy * dy
if denominator <= EPS * EPS:
return 0
return ((float(point[0]) - float(a[0])) * dx
+ (float(point[1]) - float(a[1])) * dy) / denominator
def _distance_to_segment(point: list[float], a: list[float], b: list[float]) -> float:
t = max(0.0, min(1.0, _project_t(point, a, b)))
return math.hypot(
float(point[0]) - (float(a[0]) + (float(b[0]) - float(a[0])) * t),
float(point[1]) - (float(a[1]) + (float(b[1]) - float(a[1])) * t),
)
def _collinear_overlap(
a: list[float], b: list[float], c: list[float], d: list[float], epsilon: float = EPS,
) -> float:
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
length = math.hypot(dx, dy)
if length <= epsilon:
return 0
cross_c = abs((float(c[0]) - float(a[0])) * dy
- (float(c[1]) - float(a[1])) * dx) / length
cross_d = abs((float(d[0]) - float(a[0])) * dy
- (float(d[1]) - float(a[1])) * dx) / length
if cross_c > epsilon or cross_d > epsilon:
return 0
tc, td = _project_t(c, a, b), _project_t(d, a, b)
return max(0.0, min(1.0, max(tc, td)) - max(0.0, min(tc, td))) * length
def _room_poly(room: dict[str, Any]) -> list[list[float]]:
poly = room.get("poly")
if isinstance(poly, list) and len(poly) >= 3:
return [[float(point[0]), float(point[1])] for point in poly]
if all(isinstance(room.get(key), (int, float)) and not isinstance(room.get(key), bool)
for key in ("x", "y", "w", "h")):
x, y, width, height = (float(room[key]) for key in ("x", "y", "w", "h"))
return [[x, y], [x + width, y], [x + width, y + height], [x, y + height]]
return []
def _shared_boundaries(first: list[list[float]], second: list[list[float]]) -> list[list[float]]:
result: list[list[float]] = []
epsilon = GRID_STEP_N * 0.04
for index, start in enumerate(first):
end = first[(index + 1) % len(first)]
dx, dy = end[0] - start[0], end[1] - start[1]
length = math.hypot(dx, dy)
if length < epsilon:
continue
ux, uy = dx / length, dy / length
for other_index, other_start in enumerate(second):
other_end = second[(other_index + 1) % len(second)]
tolerance = max(epsilon, length * 1e-6)
distances = [
abs((point[0] - start[0]) * uy - (point[1] - start[1]) * ux)
for point in (other_start, other_end)
]
if max(distances) > tolerance:
continue
t1 = (other_start[0] - start[0]) * ux + (other_start[1] - start[1]) * uy
t2 = (other_end[0] - start[0]) * ux + (other_end[1] - start[1]) * uy
lo, hi = max(0.0, min(t1, t2)), min(length, max(t1, t2))
if hi - lo > epsilon:
result.append([
start[0] + ux * lo, start[1] + uy * lo,
start[0] + ux * hi, start[1] + uy * hi,
])
return result
def _wall_direction(a: list[float], b: list[float]) -> tuple[float, float]:
dx, dy = b[0] - a[0], b[1] - a[1]
length = math.hypot(dx, dy)
if length < 1e-12:
return 1.0, 0.0
dx, dy = dx / length, dy / length
if dx < -1e-12 or (abs(dx) <= 1e-12 and dy < 0):
dx, dy = -dx, -dy
return dx, dy
def _js_round(value: float) -> int:
return math.floor(value + 0.5)
def _quantize(value: float, pitch: float = GRID_STEP_N) -> float:
return _js_round(value / pitch) * pitch
def _wall_key(a: list[float], b: list[float]) -> str:
midpoint_x = _quantize((float(a[0]) + float(b[0])) / 2)
midpoint_y = _quantize((float(a[1]) + float(b[1])) / 2)
dx, dy = _wall_direction(a, b)
angle = math.atan2(dy, dx)
if angle < 0:
angle += math.pi
angle = _js_round(angle * 1800) / 1800
return f"{midpoint_x:.6f},{midpoint_y:.6f}@{angle:.4f}"
def deterministic_wall_segment_id(
space_id: str, a: list[float], b: list[float], owners: list[str], salt: str = "",
) -> str:
ca, cb = _canonical_span(a, b)
seed = (f"{space_id}|{_point_key(ca)}|{_point_key(cb)}|"
f"{','.join(sorted(owners))}{salt}")
encoded = base64.b32encode(hashlib.sha256(seed.encode()).digest()).decode().lower()
return f"wall-{encoded[:20]}"
def _translated_segment_delta(
a: list[float], b: list[float], previous: dict[str, Any],
) -> tuple[float, float] | None:
def matches(pa: list[float], pb: list[float]) -> tuple[float, float] | None:
dx, dy = a[0] - pa[0], a[1] - pa[1]
if abs((b[0] - pb[0]) - dx) <= EPS and abs((b[1] - pb[1]) - dy) <= EPS:
return dx, dy
return None
return matches(previous["a"], previous["b"]) or matches(previous["b"], previous["a"])
def _atomize(
space: dict[str, Any], old: dict[str, dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
rooms = space.get("rooms") or []
room_polys = {str(room.get("id", "")): _room_poly(room) for room in rooms}
global_breaks: list[list[float]] = []
explicit_spans = space.get("open_spans") or []
legacy_segments: list[list[float]] = []
for span in explicit_spans:
if isinstance(span, dict) and isinstance(span.get("a"), list) and isinstance(span.get("b"), list):
global_breaks.extend((span["a"], span["b"]))
legacy_segments.append([
float(span["a"][0]), float(span["a"][1]),
float(span["b"][0]), float(span["b"][1]),
])
if not legacy_segments:
linked = lambda first, second: (
str(second.get("id", "")) in (first.get("open_to") or [])
or str(first.get("id", "")) in (second.get("open_to") or [])
)
for first_index, first in enumerate(rooms):
for second in rooms[first_index + 1:]:
if not linked(first, second):
continue
for shared in _shared_boundaries(
room_polys.get(str(first.get("id", "")), []),
room_polys.get(str(second.get("id", "")), []),
):
legacy_segments.append(shared)
global_breaks.extend(([shared[0], shared[1]], [shared[2], shared[3]]))
canonical_zero_segments: list[list[float]] = []
for room in rooms:
poly = room_polys.get(str(room.get("id", ""))) or []
wall_ids = room.get("wall_ids") or []
if len(wall_ids) != len(poly):
continue
for index, segment_id in enumerate(wall_ids):
previous = old.get(str(segment_id)) if isinstance(segment_id, str) else None
if previous is None or float(previous.get("cm", 0)) != 0:
continue
following = (index + 1) % len(poly)
canonical_zero_segments.append([
float(poly[index][0]), float(poly[index][1]),
float(poly[following][0]), float(poly[following][1]),
])
for segment in canonical_zero_segments:
global_breaks.extend((segment[:2], segment[2:]))
zero_segments = canonical_zero_segments + legacy_segments
# #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
# carries an existing contour opening; its edges become atom boundaries so
# the zero run continues on both sides of the opening.
legacy_era_openings: list[dict[str, Any]] = []
if legacy_segments:
for opening in space.get("openings") or []:
if not isinstance(opening, dict):
continue
host = opening.get("host")
if isinstance(host, dict) and host.get("kind") == "partition":
continue
try:
centre = [float(opening["x"]), float(opening["y"])]
angle = float(opening["angle"])
half = float(opening["length"]) / 2
except (KeyError, TypeError, ValueError):
continue
if half <= 0:
continue
# Only an opening that actually stands on a legacy cut changes the
# atomization; unrelated openings must not churn the catalogue.
if not any(
_distance_to_segment(centre, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
for cut in legacy_segments
):
continue
legacy_era_openings.append(
{"centre": centre, "angle": angle, "half": half}
)
direction = (math.cos(math.radians(angle)), math.sin(math.radians(angle)))
global_breaks.extend((
[centre[0] - direction[0] * half, centre[1] - direction[1] * half],
[centre[0] + direction[0] * half, centre[1] + direction[1] * half],
))
def _atom_carries_opening(a: list[float], b: list[float]) -> bool:
for entry in legacy_era_openings:
if not _angle_matches(a, b, entry["angle"]):
continue
if _distance_to_segment(entry["centre"], a, b) > GRID_STEP_N * 0.02:
continue
span = _length(a, b)
tc = _project_t(entry["centre"], a, b) * span
if tc + entry["half"] >= -EPS and tc - entry["half"] <= span + EPS:
return True
return False
for wall in space.get("walls") or []:
if isinstance(wall, dict) and isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
global_breaks.extend((wall["a"], wall["b"]))
atoms_by_key: dict[str, dict[str, Any]] = {}
next_rooms: list[dict[str, Any]] = []
epsilon = GRID_STEP_N * 0.04
for raw_room in rooms:
room_id = str(raw_room.get("id", ""))
original = room_polys.get(room_id) or []
if not room_id or len(original) < 3:
raise WallSegmentMigrationError("invalid-room", room_id)
breaks = list(global_breaks)
for other_id, other_poly in room_polys.items():
if other_id == room_id:
continue
for shared in _shared_boundaries(original, other_poly):
breaks.extend(([shared[0], shared[1]], [shared[2], shared[3]]))
poly: list[list[float]] = []
parents: list[int] = []
for index, a in enumerate(original):
b = original[(index + 1) % len(original)]
poly.append(list(a))
parents.append(index)
length = _length(a, b)
if length < epsilon * 2:
raise WallSegmentMigrationError("zero-length", room_id)
gap = min(0.499, epsilon * 2 / length)
positions: list[float] = []
for breakpoint in breaks:
if _distance_to_segment(breakpoint, a, b) > epsilon:
continue
t = _project_t(breakpoint, a, b)
if t <= gap or t >= 1 - gap:
continue
if any(abs(existing - t) * length <= epsilon * 2 for existing in positions):
continue
positions.append(t)
for t in sorted(positions):
poly.append([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
parents.append(index)
old_ids = raw_room.get("wall_ids") if isinstance(raw_room.get("wall_ids"), list) else []
indexed_lineage = len(old_ids) == len(original)
rigid_delta: tuple[float, float] | None = None
rigid_indexed_lineage = indexed_lineage
if rigid_indexed_lineage:
for index, a in enumerate(original):
previous = old.get(old_ids[index])
delta = _translated_segment_delta(
a, original[(index + 1) % len(original)], previous,
) if previous else None
if delta is None or (rigid_delta is not None and (
abs(delta[0] - rigid_delta[0]) > EPS
or abs(delta[1] - rigid_delta[1]) > EPS
)):
rigid_indexed_lineage = False
break
rigid_delta = delta
wall_keys: list[str] = []
for index, a in enumerate(poly):
b = poly[(index + 1) % len(poly)]
key = _span_key(a, b)
atom = atoms_by_key.setdefault(key, {
"key": key, "a": _canonical_span(a, b)[0], "b": _canonical_span(a, b)[1],
"owners": set(), "preferred": set(), "positional": set(),
"preferred_carriers": {},
"parent_keys": set(), "zero_wall": False,
})
atom["owners"].add(room_id)
if len(atom["owners"]) > 2:
raise WallSegmentMigrationError("third-owner", key)
parent_index = parents[index]
atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)]))
midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
covered_by = lambda cuts: any(
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
for cut in cuts
)
# Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom
# that carries an opening (#316 §3.1).
atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or (
covered_by(legacy_segments) and not _atom_carries_opening(a, b)
)
if indexed_lineage and isinstance(old_ids[parent_index], str) and old_ids[parent_index]:
previous = old.get(old_ids[parent_index])
if rigid_indexed_lineage or previous is None or _collinear_overlap(
a, b, previous["a"], previous["b"]
) > EPS:
atom["preferred"].add(old_ids[parent_index])
else:
atom["positional"].add(old_ids[parent_index])
atom["preferred_carriers"][old_ids[parent_index]] = {
"a": list(original[parent_index]),
"b": list(original[(parent_index + 1) % len(original)]),
}
wall_keys.append(key)
next_room = {key: copy.deepcopy(value) for key, value in raw_room.items() if key != "wall_ids"}
next_room["poly"] = poly
next_room["wall_ids"] = wall_keys
next_rooms.append(next_room)
return sorted(atoms_by_key.values(), key=lambda atom: atom["key"]), next_rooms
def _thickness(space: dict[str, Any], atom: dict[str, Any], previous: dict[str, Any] | None) -> float:
if atom.get("zero_wall"):
return 0.0
candidates: list[float] = []
query_key = _wall_key(atom["a"], atom["b"])
query_length = _length(atom["a"], atom["b"])
for wall in space.get("walls") or []:
try:
cm = float(wall.get("cm", 0))
except (TypeError, ValueError):
continue
if cm <= 0:
continue
exact_key = wall.get("key") == query_key or wall.get("key") in atom["parent_keys"]
covers = False
if isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
overlap = _collinear_overlap(atom["a"], atom["b"], wall["a"], wall["b"])
covers = overlap >= query_length - EPS
if exact_key or covers:
candidates.append(max(1.0, min(100.0, cm)))
unique = {round(value, 9) for value in candidates}
if len(unique) > 1:
raise WallSegmentMigrationError("thickness-conflict", atom["key"])
if candidates:
return candidates[0]
if previous is not None and float(previous.get("cm", 0)) > 0:
return float(previous["cm"])
return 0.0
def _non_catalog_ids(space: dict[str, Any]) -> set[str]:
result: set[str] = set()
for name in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for item in space.get(name) or []:
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]:
result.add(item["id"])
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
if isinstance(segment, dict) and isinstance(segment.get("id"), str) and segment["id"]:
result.add(segment["id"])
return result
def _fresh_wall_segment_id(used: set[str]) -> str:
for _attempt in range(1000):
segment_id = f"wall-{uuid.uuid4()}"
if segment_id not in used:
return segment_id
raise WallSegmentMigrationError("duplicate-id", "id factory exhausted")
def _assign_lineage(
space: dict[str, Any], atoms: list[dict[str, Any]], old: dict[str, dict],
initial_migration: bool,
) -> None:
old_by_key = {_span_key(segment["a"], segment["b"]): segment for segment in old.values()}
host_counts: dict[str, int] = {}
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") == "wall":
host_counts[str(host.get("id"))] = host_counts.get(str(host.get("id")), 0) + 1
proposals: dict[str, dict[str, Any]] = {}
for atom in atoms:
if len(atom["preferred"]) > 1:
raise WallSegmentMigrationError("duplicate-id", ",".join(sorted(atom["preferred"])))
preferred_id = next(iter(atom["preferred"]), None)
preferred = old.get(preferred_id) if preferred_id else None
if preferred:
proposals[atom["key"]] = preferred
continue
carrier = atom["preferred_carriers"].get(preferred_id) if preferred_id else None
if preferred_id and carrier:
proposals[atom["key"]] = {
"id": preferred_id, "a": carrier["a"], "b": carrier["b"], "cm": 0,
}
continue
if atom["key"] in old_by_key:
proposals[atom["key"]] = old_by_key[atom["key"]]
continue
overlaps = [segment for segment in old.values()
if _collinear_overlap(atom["a"], atom["b"], segment["a"], segment["b"]) > EPS]
overlaps.sort(key=lambda segment: (
-host_counts.get(str(segment["id"]), 0),
-_length(segment["a"], segment["b"]), str(segment["id"]),
))
if overlaps:
proposals[atom["key"]] = overlaps[0]
elif len(atom["positional"]) == 1:
positional = old.get(next(iter(atom["positional"])))
if positional:
proposals[atom["key"]] = positional
by_id: dict[str, list[dict[str, Any]]] = {}
for atom in atoms:
proposal = proposals.get(atom["key"])
if proposal:
by_id.setdefault(str(proposal["id"]), []).append(atom)
for segment_id, candidates in by_id.items():
old_segment = old.get(segment_id) or proposals.get(candidates[0]["key"])
if old_segment is None:
raise WallSegmentMigrationError("duplicate-id", segment_id)
midpoint = [
(old_segment["a"][0] + old_segment["b"][0]) / 2,
(old_segment["a"][1] + old_segment["b"][1]) / 2,
]
candidates.sort(key=lambda atom: (
0 if _distance_to_segment(midpoint, atom["a"], atom["b"]) <= EPS else 1,
0 if _distance_to_segment(old_segment["a"], atom["a"], atom["b"]) <= EPS else 1,
atom["key"],
))
candidates[0]["id"] = segment_id
used = _non_catalog_ids(space)
for atom in atoms:
if not atom.get("id"):
continue
if atom["id"] in used:
raise WallSegmentMigrationError("duplicate-id", atom["id"])
used.add(atom["id"])
unassigned = [atom for atom in atoms if not atom.get("id")]
if initial_migration:
seeds: list[tuple[str, str, str, dict[str, Any]]] = []
for atom in unassigned:
ca, cb = _canonical_span(atom["a"], atom["b"])
seed = (f"{space.get('id', '')}|{_point_key(ca)}|{_point_key(cb)}|"
f"{','.join(sorted(atom['owners']))}")
digest = base64.b32encode(hashlib.sha256(seed.encode()).digest()).decode().lower()
seeds.append((digest, atom["key"], seed, atom))
full_digests: dict[str, str] = {}
for digest, _key, seed, atom in sorted(seeds):
if digest in full_digests and full_digests[digest] != seed:
raise WallSegmentMigrationError("duplicate-id", digest)
full_digests[digest] = seed
base = f"wall-{digest[:20]}"
suffix, segment_id = 1, base
while segment_id in used:
suffix += 1
segment_id = f"{base}-{suffix}"
atom["id"] = segment_id
used.add(segment_id)
else:
for atom in unassigned:
atom["id"] = _fresh_wall_segment_id(used)
used.add(atom["id"])
def _angle_matches(a: list[float], b: list[float], angle: float) -> bool:
dx, dy = _wall_direction(a, b)
wall_angle = math.degrees(math.atan2(dy, dx))
difference = abs((wall_angle - angle + 90) % 180 - 90)
return difference <= 8
def _host_openings(
space: dict[str, Any], segments: list[dict[str, Any]], initial_migration: bool,
) -> None:
for opening in space.get("openings") or []:
host = opening.get("host")
if isinstance(host, dict) and host.get("kind") == "partition":
continue
try:
centre = [float(opening["x"]), float(opening["y"])]
angle, half = float(opening["angle"]), float(opening["length"]) / 2
except (KeyError, TypeError, ValueError):
if initial_migration:
# #316 §3.4: the initial migration never throws over an opening.
opening.pop("host", None)
continue
raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None
def eligible(segment: dict[str, Any]) -> bool:
if float(segment.get("cm", 0)) <= 0:
return False
t = _project_t(centre, segment["a"], segment["b"])
span = _length(segment["a"], segment["b"])
return (-EPS <= t <= 1 + EPS
and _distance_to_segment(centre, segment["a"], segment["b"])
<= GRID_STEP_N * 0.02
and _angle_matches(segment["a"], segment["b"], angle)
and half >= 0 and t * span - half >= -EPS
and t * span + half <= span + EPS)
def materialize(carrier: dict[str, Any]) -> None:
opening["host"] = {
"kind": "wall", "id": carrier["id"],
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
}
current = None
if isinstance(host, dict) and host.get("kind") == "wall":
current = next((segment for segment in segments if segment["id"] == host.get("id")), None)
candidates = [current] if current is not None and eligible(current) else [
segment for segment in segments if eligible(segment)
]
if len(candidates) == 1:
materialize(candidates[0])
continue
# #316 §3.3: an unhosted opening is a valid degraded v9 state. A later
# write keeps it, may self-heal it, and never fails over it.
if host is None and not initial_migration:
continue
if not initial_migration:
raise WallSegmentMigrationError("opening-host", str(opening.get("id", "")))
def pick(pool: list[dict[str, Any]]) -> dict[str, Any] | None:
if not pool:
return None
if current is not None and any(item is current for item in pool):
return current
return sorted(pool, key=lambda segment: (
_distance_to_segment(centre, segment["a"], segment["b"]),
-float(segment.get("cm", 0)),
str(segment.get("id", "")),
))[0]
# #316 §3.2 tie-break. No distant fallback pool (CODE-REVIEW-316-r1
# H1): a host away from the opening's own x/y would violate the
# geometry-match invariant of CONFIG_SCHEMA and wedge the write on the
# schema layer; without an in-place carrier the opening goes straight
# to the unhosted degraded state.
carrier = pick([segment for segment in segments if eligible(segment)])
if carrier is not None:
materialize(carrier)
else:
opening.pop("host", None)
def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
old: dict[str, dict[str, Any]] = {}
for segment in space.get("wall_segments") or []:
segment_id = str(segment.get("id", ""))
if not segment_id or segment_id in old:
raise WallSegmentMigrationError("duplicate-id", segment_id)
old[segment_id] = segment
atoms, rooms = _atomize(space, old)
_assign_lineage(space, atoms, old, initial_migration)
segments = []
for atom in atoms:
previous = old.get(atom["id"])
segment = copy.deepcopy(previous) if previous else {}
segment.update({
"id": atom["id"], "a": list(atom["a"]), "b": list(atom["b"]),
"cm": _thickness(space, atom, previous),
})
segments.append(segment)
id_by_key = {atom["key"]: atom["id"] for atom in atoms}
for room in rooms:
room["wall_ids"] = [id_by_key[key] for key in room["wall_ids"]]
space["rooms"] = rooms
space["wall_segments"] = segments
walls = [{
"key": _wall_key(segment["a"], segment["b"]),
"cm": segment["cm"], "a": list(segment["a"]), "b": list(segment["b"]),
} for segment in segments if float(segment["cm"]) > 0]
if walls:
space["walls"] = walls
else:
space.pop("walls", None)
space.pop("open_spans", None)
for room in space.get("rooms") or []:
room.pop("open_to", None)
draft_used = {
str(item["id"])
for name in ("rooms", "openings", "decor", "room_drafts", "partitions",
"wall_columns", "wall_segments")
for item in space.get(name) or []
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
}
for draft in space.get("room_drafts") or []:
for index, segment in enumerate(draft.get("segments") or []):
if isinstance(segment.get("id"), str) and segment["id"]:
if segment["id"] in draft_used:
raise WallSegmentMigrationError("duplicate-id", segment["id"])
draft_used.add(segment["id"])
continue
try:
a, b = draft["points"][index], draft["points"][index + 1]
except (KeyError, IndexError, TypeError):
raise WallSegmentMigrationError("zero-length", str(draft.get("id", ""))) from None
if initial_migration:
base = deterministic_wall_segment_id(
str(space.get("id", "")), a, b, [f"draft:{draft.get('id', '')}"],
)
suffix, segment["id"] = 1, base
while segment["id"] in draft_used:
suffix += 1
segment["id"] = f"{base}-{suffix}"
else:
segment["id"] = _fresh_wall_segment_id(draft_used)
draft_used.add(segment["id"])
_host_openings(space, segments, initial_migration)
return sum(1 for segment in segments if segment["id"] not in old)
def commit_wall_segment_model(config: Any) -> tuple[Any, int]:
"""Return one migrated deep copy and the count of newly assigned wall ids."""
candidate = canonicalize_config_geometry(copy.deepcopy(config))
if not isinstance(candidate, dict):
raise WallSegmentMigrationError("invalid-room")
migrated = 0
initial_migration = int(candidate.get("model_version", 0) or 0) < WALL_SEGMENT_MODEL_VERSION
for space in candidate.get("spaces") or []:
migrated += _migrate_space(space, initial_migration)
candidate["model_version"] = WALL_SEGMENT_MODEL_VERSION
return canonicalize_config_geometry(candidate), migrated
+60 -6
View File
@@ -24,6 +24,7 @@ from .const import (
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
VERSION,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
@@ -60,13 +61,19 @@ from .virtual_lights import (
)
from .registry_snapshot import import_registry_snapshot
from .projection import project_config, project_layout
from .wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION,
WallSegmentMigrationError,
commit_wall_segment_model,
)
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename,
PartitionOpeningHostError, PartitionOpeningJambMarginError,
WallModelClientOutdatedError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, valid_space_id,
validate_marker_value_badges, validate_wall_model_transition, valid_space_id,
)
@@ -511,6 +518,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",
})
@@ -1149,6 +1158,9 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
"undo_kind": _undo_kind(data, layout_data),
# #295: the card compares this against its own version to decide
# whether the «update House Plan» preflight hint can actually help.
"integration_version": VERSION,
},
)
@@ -1257,7 +1269,9 @@ def _missing_internal_attachments(
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
vol.Required("config"): CONFIG_SCHEMA,
# Semantic stale-client detection needs the stored v8 document and
# therefore runs inside the write lock before CONFIG_SCHEMA.
vol.Required("config"): dict,
vol.Optional("expected_rev"): int,
}
)
@@ -1307,6 +1321,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# the lossless controls array. Validate only edges introduced by this
# write so an unrelated edit can still round-trip a legacy broken ref.
try:
validate_wall_model_transition(msg["config"], data.get("config"))
validated_config = CONFIG_SCHEMA(msg["config"])
msg["config"].clear()
msg["config"].update(validated_config)
validate_marker_controls(msg["config"], data.get("config"))
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
@@ -1314,10 +1332,13 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
validate_partition_opening_hosts(msg["config"], data.get("config"))
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
PartitionOpeningJambMarginError, WallModelClientOutdatedError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
# An internal plan url must name a file that exists. The card can pick a
# plan and then delete it from the same dialog, and two clients can do
# the same thing in either order — the lock serialises them but says
@@ -1575,7 +1596,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize",
vol.Required("config"): CONFIG_SCHEMA,
vol.Required("config"): dict,
vol.Required("layout"): LAYOUT_SCHEMA,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
@@ -1620,6 +1641,35 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
# layout transaction. It must enforce the same marker-link semantics
# as config/set; otherwise a crafted client can persist a new cycle.
try:
validate_wall_model_transition(msg["config"], config_data.get("config"))
try:
submitted_model = int(msg["config"].get("model_version", 0) or 0)
except (TypeError, ValueError):
submitted_model = WALL_SEGMENT_MODEL_VERSION
# Optimize is an explicit structural writer and therefore the
# server-side v7 -> v8 barrier as well. Current v8 candidates are
# validated verbatim: the backend must never invent lineage for a
# graph already authored by a current client.
candidate_config = CONFIG_SCHEMA(msg["config"])
# Keep the established public validation contract ahead of the
# structural migration barrier. A malformed passage can also make
# wall-host materialisation impossible, but callers must still get
# the actionable `invalid_passage_fields` code rather than the
# generic wall-model blocker.
validate_opening_passages(candidate_config, config_data.get("config"))
if submitted_model < WALL_SEGMENT_MODEL_VERSION:
candidate_config, _ = commit_wall_segment_model(candidate_config)
validated_config = CONFIG_SCHEMA(candidate_config)
migrated_size = len(json.dumps(validated_config, separators=(",", ":")))
if migrated_size > MAX_CONFIG_BYTES:
connection.send_error(
msg["id"], "too_large",
f"Configuration is {migrated_size // 1024} KB, "
f"the limit is {MAX_CONFIG_BYTES // 1024} KB",
)
return
msg["config"].clear()
msg["config"].update(validated_config)
validate_marker_controls(msg["config"], config_data.get("config"))
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
@@ -1630,10 +1680,14 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
)
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
PartitionOpeningJambMarginError, WallModelClientOutdatedError,
WallSegmentMigrationError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
missing = await hass.async_add_executor_job(
_missing_internal_plans,
+86
View File
@@ -0,0 +1,86 @@
/**
* #314 anonymised dense-plan fixture.
*
* It preserves the structural shape of the owner's report without names,
* markers, layout, or original coordinates: 13 rooms, 44 catalogue walls,
* 24 intentional partitions, and one pre-existing two-point draft. The last
* object is known debt and must remain the only invariant finding before and
* after drawing another room.
*/
import { WALL_KEY_PITCH } from './wall-key.mjs';
export function makeV8DraftRegressionFixture() {
const step = WALL_KEY_PITCH;
const point = (x, y) => [x * step, y * step];
const segmentIds = new Map();
const wallSegments = [];
const keyOf = (a, b) => {
const left = `${a[0]},${a[1]}`;
const right = `${b[0]},${b[1]}`;
return left < right ? `${left}|${right}` : `${right}|${left}`;
};
const wallId = (a, b) => {
const key = keyOf(a, b);
let id = segmentIds.get(key);
if (!id) {
id = `wall-314-${String(wallSegments.length + 1).padStart(3, '0')}`;
segmentIds.set(key, id);
wallSegments.push({ id, a: [...a], b: [...b], cm: 15 });
}
return id;
};
const rooms = Array.from({ length: 13 }, (_, index) => {
const x0 = 4 + index * 12;
const x1 = x0 + 12;
const y0 = 4;
const y1 = 16;
const poly = [point(x0, y0), point(x1, y0), point(x1, y1)];
// Four split top edges make the catalogue population exactly 44 while
// keeping all rooms valid and non-overlapping.
if (index < 4) poly.push(point(x0 + 6, y1));
poly.push(point(x0, y1));
return {
id: `room-314-${String(index + 1).padStart(2, '0')}`,
name: `Room ${index + 1}`,
poly,
wall_ids: poly.map((a, edge) => wallId(a, poly[(edge + 1) % poly.length])),
};
});
const partitions = Array.from({ length: 24 }, (_, index) => {
const row = Math.floor(index / 8);
const x = 4 + (index % 8) * 18;
const y = 28 + row * 8;
return {
id: `partition-314-${String(index + 1).padStart(2, '0')}`,
a: point(x, y),
b: point(x + 8, y + 2),
cm: 15 + (index % 3) * 5,
};
});
if (wallSegments.length !== 44) {
throw new Error(`fixture catalogue drifted: expected 44, got ${wallSegments.length}`);
}
return {
model_version: 8,
spaces: [{
id: 'space-314-dense',
title: 'Dense regression fixture',
cell_cm: 5,
view_box: [0, 0, 400, 400],
rooms,
wall_segments: wallSegments,
partitions,
room_drafts: [{
id: 'draft-314-known-debt',
points: [point(4, 56), point(14, 64)],
segments: [{ id: 'wall-314-known-draft', cm: 15 }],
}],
}],
markers: [],
settings: {},
};
}
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

+119 -97
View File
@@ -1,117 +1,139 @@
{
"schema": 1,
"matrixVersion": 44,
"acceptedAt": "2026-08-24T16:12:22.415Z",
"sourceFingerprint": "7a687db9aadb86ee4af8374678c06f3697fb98935ea9bfa3df7b2cd0b652543a",
"matrixVersion": 47,
"acceptedAt": "2026-08-26T20:41:35.180774Z",
"sourceFingerprint": "80bd29ef9692067b706f221eaf8d62b4030e3a51b91bc9cff8d854aaee373700",
"chromium": "151.0.7922.34",
"scenarios": {
"coincident-partition-before-dark": "c0e38cfff096725cf1eb5dc91e005b2af983172d04e6573075db635275cabca3",
"coincident-partition-thin-dark": "3a33ebf94e6c19b0079cf66975180817146d68ae97d1f9ff4a26711f4ca54903",
"coincident-partition-thick-dark": "33f9296e765f4ea9922cb3b13d11b50306709ba4fcf3114d351eda1833399870",
"coincident-partition-virtual-dark": "84969a05c442549dac0bcbb7e507ee77967e42f75d0fdf3c8e8fbe2170c65834",
"hidden-wall-diagnostics-plan-light": "37ba151aa6b1ea7b1b270d461ccca348444c5eeb14a5592bf045a0e69b0fb83e",
"hidden-wall-diagnostics-plan-dark": "565d5481f744704a2a6f18658b9b6fa65e7cdbfcd11b50a2bedbc0daae2d8488",
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
"split-corner-wall-thick-dark": "309b8fe8a206f4f3fc50f6a27fc762a933f245b778e7b1f9a6348b66f39b2c39",
"split-corner-wall-thin-dark": "48062a49e12728c377a7c4352148381b1be2a5606b6792d754ae055b04c22be1",
"split-corner-wall-thick-dark": "a5d795eec4561e3a1aa9c37843fadddcb04d888d5a49e6ffacf29c17b7798568",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"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": "30985326ad907fac4b93f848860e5b6c7737f24e8281706c3f69a189477f932f",
"geometry-view-light-fit": "310cca616bf1fc5fda4f1e8d58e80ddf2fe2662d088829e22eef7b9e6b5ddd93",
"room-label-parity-view-dark": "7f1da43466a13f82a7595dddabe6938ed974debfb14c8f40e729e273a53ac4ff",
"room-label-parity-plan-dark": "5f976d5743d68a27b594e1a5ddf45e5ad3708f5c703409e5cefb7c96d3048096",
"room-label-parity-view-light": "ee3d303fb2b329c356a4354d355c29694130a38d4812ffbfefbe861f5d3f9860",
"room-label-parity-plan-light": "d32b538499a031d4310b243cc9ef43610f6cd67aabfde7cd01b59c88b0363ec5",
"isometric-geometry-view-dark": "0d8427299c77e1e3bb86f37f53128a6640b6666e0b682e5f96085c4f07a657ec",
"isometric-geometry-view-light": "a3e592448ad3ad4ff65266f1fd6f31259c01a9baf47392f6b8be9811d74f8dce",
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
"isometric-large-warm-remount-dark": "de7733e6aeef7cf7b05cca9c8f6bb4aa627fd16ab2d4198659e24ff9fa3b5bda",
"geometry-view-dark-fit": "2d96e776ea9fc8a136896b58df30abb13a8a53e6c40e5b4f4e12c6dba522380f",
"geometry-view-light-fit": "60e8a908fc1c3be3ca5c9b82bf432120d808d913f743c04683b4a2c501d84659",
"room-label-parity-view-dark": "5ff9dff2da5c747cfab83ade2a929afadcf2eb9340182862da38ab36a35cd3c1",
"room-label-parity-plan-dark": "6528f1edf07e51e470b1c05dcf5292888a14ef6e44d04f7119cfb11b06155c2b",
"room-label-parity-view-light": "789f8f3b6211d35e70021094f2063dc9c6ac57599d9d64743a33b7ae9dd868c2",
"room-label-parity-plan-light": "272dc35d85b1f07efc4c2a05c31f6adbf7d10c648e3c678e7235e35167f83a7d",
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
"day-cycle-dawn-dark": "b01d9add378afb47912da10f6327d43177d4bbb0b42cb4323395fd01bf2fe8f5",
"day-cycle-day-dark": "97db91bd4daf41d903cc968d70a5a4ab7af7e332b60b7316e339f490e08fa953",
"day-cycle-dusk-dark": "a856187898fc425a48dd165f5ec01f0f50b78043b382f708dbffd810bfdbf043",
"day-cycle-night-dark": "b863260250d739fd369140356e195a64d3ac58478ab2c1e43415abd8f96a7fbd",
"geometry-plan-editor-dark": "a1ca4219a1e7b2c4749b72edad87cd0bdee793390cd6efe972aca1d41d0d2478",
"safe-resize-handles-clamp-light": "298670cc94642968e58c45560597e8277c405cf61c2a8b5e08f1544612a6ed4b",
"safe-resize-handles-clamp-dark": "64f32f2038238f423ba3cc1ed618bf9d32704c8161042751d447c3f884e37e4a",
"space-tab-drop-before-light": "86d8159ac6191980f2ac91b137c3947219da45fef81bc2b358442958eda9d0f8",
"space-tab-drop-after-dark": "2e2bde063f9fa34284beb27457f1542e8b6085033be67fd0dc1923b81c6af1bf",
"plan-snap-endpoint-light": "78225caf0522f69c294b299212a717260c31e9620bdefdc970dc84730ae06e17",
"plan-snap-line-gaps-dark": "61bac139592fd292a8e9ded541dc4b98912779f415f371108c2d99a18c7d9d57",
"wall-junctions-plan-preview-light": "1482b01209a796f93ebc23e231b33fe869ee96b6f46d1fb2d32ccf677a94e602",
"wall-junctions-plan-t-dark": "40f0b57e2b713df28c2769ba44931b165aaa669d4c0b3dd97d562e904292d449",
"day-cycle-dawn-dark": "be3b891520637857b7f89dc88284753a15601ebae4db7db750714b9535348d33",
"day-cycle-day-dark": "1b791b8a773a5910a71338351ab12fdc36c22742a358b647ff4f295431fa4cfd",
"day-cycle-dusk-dark": "14182ad89301cb260ba21ee5077134ed408f038ea878ed4539b84f987182f910",
"day-cycle-night-dark": "407632fc54e13e6362b7e583d6e2b7bdd31b4093823846e4e630de0c8f542120",
"geometry-plan-editor-dark": "40a0083511c1ff9d0b8f1ec419af63e2a28f1f9a0ec3768387a10c89efe73905",
"safe-resize-handles-clamp-light": "f47a5bb09fa4415416d01ca5f4164014cc1d8dc7be54ea888e5399277104e94c",
"safe-resize-handles-clamp-dark": "9cb3cb83d2aea4260fdbf1bc1d4281deecedeb9c9dbb0adbed921470b9aaf397",
"space-tab-drop-before-light": "19c699fd035030318ce578fdb6427d6243cd3f1d955ea0fbf958cad385e9c7db",
"space-tab-drop-after-dark": "1f802274c3015ad6422ee9b50a8d67a412407513da09de188be3db2f7a3873e4",
"plan-snap-endpoint-light": "2185d5ec6726e07097ba1a911984a11d5ca9b460a1cd1e7b2a719255b7d0ed0b",
"plan-snap-line-gaps-dark": "276eb10f32be938640bd68a1cf837494f11cce8d10703721831360f3a1100d23",
"wall-junctions-plan-preview-light": "d92aad64d7aa8641c6743b51a7d13d9166b15c49f3d8aee17b8e61ff263a57a6",
"wall-junctions-plan-t-dark": "45a98164ff93b01fbb7f3ac936ee8636c36dd791c09ac3b160b7c7ad803696d1",
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
"junction-patch-resilience-plan-dark": "5dac2f2f81c801b0d9b1c0ee786f1f531906c80ddeec36f9c91b524468c13608",
"junction-patch-resilience-view-dark": "efe6e3ebd0e08e936bfa79886d5f55185198cf770a5d2473160cf2335be46340",
"wall-union-isolation-view-light": "291c045758be2814be88d12ab46bdc3d12cb99a03dbbcc9a1539c2c9a5931edf",
"wall-union-isolation-view-dark": "616fc6cc7144c9596ae52421027914e222f30f26de45215fa7e7b6175ca78543",
"multiwall-junction-bevel-view-dark": "70f7f0e7c60beb33853fc26445b6901d48299c2c92ddc41f6e1e2cfb4cac2320",
"orthogonal-strip-cell-5-view-dark": "9b8e4bd22cbf1a82ff48f052822967c61179290a3234ae48ef547642329b5a90",
"orthogonal-strip-cell-1-view-dark": "44e245db41ab9a4e575cefbf7656055dba7e27fc62c2b1215445517a9ea81386",
"wall-key-roundtrip-view-dark": "28895aa02eb995f976b26a2d2b3675954611641b60910f40427b15a95ef53e3e",
"junction-patch-resilience-plan-dark": "90191f637f4d1345d82c63977513524fde353c4e3f87de1a723bf7d3b1f8e91f",
"junction-patch-resilience-view-dark": "afd83241d9a1e1f19ab59c3f1e6fa7af058c3e42a7fafa5ba271ff92ccea8f91",
"wall-union-isolation-view-light": "106f8ac97b82107fed36aa4456c0b9f7a39eeb44be86864b1d4f083497cdbd6e",
"wall-union-isolation-view-dark": "914451a813546189bdc9ac22dcf158bbc822b4403080a3c2fd4c6b02bb7780a4",
"multiwall-junction-bevel-view-dark": "6b02d1d0c5af8b386791cc5764b3bc407129f6576b6d9591c64fd388a8a6865d",
"orthogonal-strip-cell-5-view-dark": "66a288f7e7fc6228c3c63f10e1df8df0656ca0cd36681d998b126b03acb70b5a",
"orthogonal-strip-cell-1-view-dark": "c23e6d0410b8f5c2b7fb0707f6bea40408730af08f9e7a0b166aa8c0a3d6c85f",
"wall-key-roundtrip-view-dark": "d7b0749d0f6c0cfa74ad4d9f76d8a7b7962fadf4e2648260b5647548a988341c",
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
"opening-placement-door-thick-wall-dark": "59469954c1ea5a2e79df7eea1c287228998c0bfe1b3dc43869d553f25be78222",
"opening-placement-passage-thick-wall-dark": "f3efc2bafbc472f18042f26a896870a4256aa2ea843d887f8b33301001a947f0",
"opening-placement-passage-thick-wall-light": "e5f6024151eebb328f211793bd2d346703d627524bdff931c61ce13b5a5fa7f4",
"opening-symbol-room-wall-light": "562fed297210ef542de608e384c2d344dddd193a5b43615930c9f76faaf53b42",
"opening-placement-door-thick-wall-dark": "c21c2af812b847e7fd9a3a5b9df832db95266f81eba3d7b48c97d2b32619e7fe",
"opening-placement-passage-thick-wall-dark": "90f9a837898169c2d9ff9ec3b44b2a3d247ea034fd84b1753519da7b0c89ebdb",
"opening-placement-passage-thick-wall-light": "dd17884027d85f5727450cd08f3fe6daaed290770dbd04fe5ce10c4c2b9d43db",
"opening-symbol-room-wall-light": "403f1c6e1c59122331fbe99019a3ebd80de4106dc6d33f5f5f33feb748229645",
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
"opening-symbol-flip-pairs-light": "33364f51fb65e49bfdd6c41d6e0c53542c89fadc45a3c6e5adc590144c4676ea",
"isometric-opening-symbol-parity-dark": "302daf5d90d4af5ef90036cc10e957e6fc909ea56f9f2af938ab850d4f608e34",
"geometry-devices-editor-dark": "9053fd940e5f2a27f0062b159e4b4debd3d4f47d5cd5bd81c0b154ecc0f4639b",
"geometry-decor-editor-dark": "736e289cf16adb5fa5e58e7db7d75475e16065b58039bbdc0cec402f4256f390",
"tray-wide-selection-en": "dab331bc075c9742b4aecb9feb7de01a30601416d7dcaeb78f50252af0d58a5d",
"tray-wide-tool-ru": "0623ff17c4c22d338d9d317f348b2e181658cf0fe3275a6dc6b80d4077ec3b68",
"tray-medium-group-en": "03410227827c034035245bf84b4a283ac72f0da3925d3a4b6873f1bb1992f057",
"tray-medium-selection-ru": "ec9c28eb81cf2b5106254422d17e0adc58ba22a720db4f4832c669ccd0c84723",
"tray-narrow-palette-en": "10f4786a9e6020a8cb1c92b4b1eab299a50ae46eea7a9360b6af8cf675b4c943",
"tray-narrow-tool-ru": "2c2a886b70cda7b02bbd663ee915fd1e68f87587b85c653496784084883bcc7b",
"geometry-diagonal-45-opening-dark": "1bc04b80e39234e98b98dbcc8eba554ee77d3c5dd3de0adfa140fa24410711f8",
"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",
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
"geometry-devices-editor-dark": "a57c8528f2075252dbc0ea11af23a6c33a3b533b4f80b43ab5eebaa918e12816",
"geometry-decor-editor-dark": "2a0d4368e49b51fc4fcf5ee72679efb7b7e094fd8b6f47682b0696ec909c423e",
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
"tray-medium-selection-ru": "bd97bee66778a65ebb849331724a307d0b089d6cdf5f3b85c6c905c413d5290f",
"tray-narrow-palette-en": "10073be9fdf658275f77643505c3523bcddce6a1890deda5b1820958b90613d1",
"tray-narrow-tool-ru": "95fa0f072efb595e1f9fe3f3287e7ce346fdee640e90dd70e4f5e5b31049ef29",
"geometry-diagonal-45-opening-dark": "ba9462322da34b20fff1ee1f950c516855dd7ac56131e13863f576e2d0ba5177",
"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",
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
"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": "675170204c1d98b891a3e06d921e4264538c4949e5d1b274efa3ef708afef169",
"large-house-zoom-040-dark": "df1a5515089b5e470ae550ce6da31a301be5079d3f7bb9130baad99d3d7c641e",
"large-house-zoom-250-dark": "88c0d22686fd9ef314866d0275a5ae785781c3d671e257b2c5f790f816cd65ee",
"large-house-warm-remount-dark": "9a61d668c5fbc61bb370c043771cee91707b3d40378cf89f81199882820bf95c",
"device-dialog-desktop-en": "9739886af3104c2fe3d6d3138293e9fd1a7f480f151decc26f12bb64cab69543",
"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": "b546f923fd31d28d2881f074e3a67c5d9d3f9450169f9118502ecafc98c4467d",
"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": "49192516aabc14e84a2da4d1361c1be57d24e5c947b199cdf8b6c55fd7318504",
"junction-star5-equal15-dark": "2fd822f73f486173700405208168300d27d4e360b17cb278c7ee7bc1cda3769e",
"junction-y-60-equal50-dark": "7a61dbfc36437674c2c2bf5f450a64797e21a124f42c0e45bfb56e3236e0b53e",
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
"junction-acute15-equal50-dark": "e7e657137e8dcf6294d400f3114497318c055405dbc0d867603514b56c42b907",
"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-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a",
"junction-owner-repro-dark": "f5407d420449ebf897f94fcfcdaaf4630e818c043d3076b090f7d1944df5296b",
"large-house-zoom-040-dark": "736af1c340646c4c1cbab3d2270f67a535995e1ea1e2abe49c91d2c396052097",
"large-house-zoom-250-dark": "3f7abdd66a7b33301275983dbf83862bd24f91c65a7d731c411ebd3ed01b381d",
"large-house-warm-remount-dark": "1c14dba8cdbc4628984091d395c1ac14faead1d43338e44aa3cb57976e519df6",
"device-dialog-desktop-en": "a6637997458ef1def3d30ffaccb6bbc9728dbc4ba2f02400a7334915a8101bbe",
"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": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
"decor-color-popover-desktop-en": "0a33f2a4ad6614bccbce45fbda1eaa505551e479b1eaada28ddcaf435a83f74f",
"general-color-popover-desktop-en": "91c3b7ebae5146c62d172d1ce10ae9e375ed955f23e9dc860976373a334d8be5",
"decor-color-popover-mobile-ru": "fcdd138e396654e3517927c27d2062663c61860e8b9903471fa44651a4cbdefd",
"decor-color-popover-desktop-en": "98d769f5393afb2bad3fb5143a562da5b8bb9a5c8049f4c707706bbcb4f99f8d",
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
"space-room-color-popover-desktop-ru": "a7b2c78d8b178eb1947937f9aebe2b02d67a0f6fed3b2a69a7bf0d7e24df50ac",
"backup-full-preview-desktop-en": "252f4f687f188645f0b22a49f03c63725aefc6bda5ff2e930a33b8ac729544a8",
"backup-plan-only-export-desktop-en": "d76da7b84f46499b8323c920e464e1ff52893dee17db08001badbdb1a09829c1",
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
"optimize-preflight-dialog-dark-en": "93461a3d95208aa798dd93d5500003127dcad8e7993f7bdc6b7f8f1a95389d73",
"optimize-preflight-dialog-light-ru": "de43f205fdf16d290603543a02ced9492d1b5a35e089fa6023c98efdf32ed7c3",
"optimize-orphan-references-dark-en": "ce2116ffefd64e6b38921c3afd2a5d9bd9329e8a2aec102eb42a032d23a7d000",
"optimize-orphan-references-light-ru": "5a4152df98a34e724b25c0d331fd691fa63eaba753bd4a9c5b173a454c148ecd",
"space-room-color-popover-desktop-ru": "946c550232ff3819bb44058eff6c5deadfccf85527c23065137ee02fa457aed9",
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
"backup-plan-only-export-desktop-en": "f3c01ae191af41fcc815d27586fe40f965ae3fddbf782553076703ce695457d6",
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
"optimize-preflight-dialog-dark-en": "0473d7f16dd830aaddbec5c33f2b423fde48e2053a1094dbab052e6a2cbad9d4",
"optimize-preflight-dialog-light-ru": "a4eb6ef9930652bd7e55fbb725fd1bcc5a0e5b71e8060dbf6f772dd62cec5d36",
"optimize-orphan-references-dark-en": "f40417298e126fa804ddafb91d62b728c86a56c5a28b4fad12b732070faa4ad6",
"optimize-orphan-references-light-ru": "2d0319f681cfbe472ad5dede05d145a995dedee577d8d816e2fe46a519e019f5",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
"span-over-door-migrated-dark": "a817f5baacf5ff80d1f236ffaa176cb6331b75423393dae5b16e54f67f37c7e0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

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

After

Width:  |  Height:  |  Size: 44 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: 332 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: 280 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: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 65 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: 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: 25 KiB

After

Width:  |  Height:  |  Size: 24 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: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 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: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 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: 161 KiB

After

Width:  |  Height:  |  Size: 160 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: 63 KiB

After

Width:  |  Height:  |  Size: 62 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: 44 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

After

Width:  |  Height:  |  Size: 356 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: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 96 KiB

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