diff --git a/docs/specs/306-zero-thickness-walls.md b/docs/specs/306-zero-thickness-walls.md index be0b3f6d..69423666 100644 --- a/docs/specs/306-zero-thickness-walls.md +++ b/docs/specs/306-zero-thickness-walls.md @@ -1,9 +1,9 @@ # #306 — Нулевые стены вместо виртуальных границ - **Issue:** [#306](https://github.com/Matysh/houseplan-card/issues/306) -- **Статус документа:** готово к независимому ревью ТЗ +- **Статус документа:** актуализировано после #282; готово к независимому ревью ТЗ - **Приоритет / тип:** P1 / feature -- **Целевая версия модели:** `PLAN_MODEL_VERSION = 8` +- **Целевая версия модели:** `PLAN_MODEL_VERSION = 9` - **Пользовательское изменение:** да ## 1. Сценарий @@ -33,9 +33,13 @@ В редакторе остаётся одна модель: - `cm > 0` — стена с физическим телом; -- `cm = 0` — топологическая стена без физического тела; -- отсутствие записи `walls[]` в старом разреженном формате — legacy-физическая - осевая стена, а **не** нулевая стена. +- `cm = 0` — топологическая стена без физического тела. + +После #282 каждый contour atom уже хранится в authoritative-каталоге +`space.wall_segments[]` со стабильным `id`, точными `a/b` и явным `cm`. +Происхождение нулевого атома не сохраняется и не влияет на результат: прежняя +физическая ось без тела, бывшая виртуальная граница и новая стена, нарисованная +с толщиной 0, становятся одной и той же сущностью. Пользователь рисует и изменяет оба вида инструментами **«Стены»** и **«Толщина»**. Настройка пространства определяет, показываются ли все нулевые @@ -55,12 +59,17 @@ | `solid` | сплошная линия | блокируется | блокируются | не соединяет световые зоны | 5. Отсутствующее/неизвестное значение настройки читается как `dashed`. + Это правило применяется ко **всем** `cm:0`, включая атомы, которые #282 + создала из старых физических осей без толстого тела. В результате часть + старых планов после обновления неизбежно изменит вид или светопроницаемость; + это принятое владельцем поведение, а не ошибка миграции. 6. Нулевая стена в обоих режимах остаётся частью топологии, но не создаёт физическое тело, бумагу, тоннель проёма и вычет площади. 7. Новый проём на нулевой стене запрещён. Перевод участка с проёмом в `0` отклоняется до записи; проём автоматически не удаляется и не деактивируется. -8. Legacy `open_spans`, а при их отсутствии legacy `open_to`, мигрируют lossless - в явные атомы `cm: 0`. Простое чтение конфигурации ничего не записывает. +8. Legacy `open_spans`, а при их отсутствии legacy `open_to`, переводят + покрытые атомы v8 в `cm:0`; все остальные уже существующие `cm:0` остаются + неотличимыми от них. Простое чтение конфигурации ничего не записывает. 9. После канонической записи старые поля больше не пишутся. Downgrade на версию, не понимающую `cm:0`, не поддерживается; восстановление выполняется из backup. 10. Plan editor остаётся desktop-first. View и kiosk на touch входят в @@ -84,22 +93,28 @@ носителя и `cm`. Нельзя сливать `outer(A)` с `shared(A,B)`, разные пары shared, room-wall с independent wall или пересекать узел/проём. -### 5.2 Три состояния разреженного room-wall слоя +### 5.2 Authoritative wall model v8 -Пока не реализована целевая объектная модель #282, `space.walls[]` остаётся -разреженным слоем поверх осей комнат: +#282 уже реализовала Stage 1 модели стен: -- явная запись `cm: 1..100` — положительная толщина; -- явная запись `cm: 0` — нулевая стена; -- нет записи на интервале — legacy-физическая ось без толстого тела. +- `space.wall_segments[]` содержит **каждый** contour atom и является + authoritative для identity и толщины; +- `rooms[].wall_ids[]` ссылается на эти атомы в порядке обхода `poly`; +- `space.walls[]` — только compatibility-проекция положительных толщин и не + является источником истины; +- `partitions[]` и `room_drafts[].segments[]` сохраняют собственные стабильные + ID и явный `cm`. -Любой resolver обязан проверять **наличие явного атома**, а не подставлять `0` -из-за отсутствия записи. Функции физического тела фильтруют `cm > 0`; функции -топологии и редактора сохраняют и видят `cm = 0`. +#306 не создаёт второй каталог и не возвращает midpoint-key identity. Она +расширяет уже существующий v8-инвариант: `cm:0` получает единый пользовательский +смысл и диапазон записи `0..100` на всех segment-based write paths. Функции +физического тела фильтруют `cm > 0`; функции топологии и редактора сохраняют и +видят `cm = 0`. При замыкании draft нулевой сегмент наследует ID по lineage- +правилам #282. -Для независимых стен те же значения хранятся в `partitions[].cm` и -`room_drafts[].segments[].cm`. При замыкании draft его нулевые сегменты -переносятся в явные `walls[]` атомы созданной комнаты. +Никаких `zero_kind`, `legacy_origin`, скрытых compatibility-флагов или эвристик +по происхождению сегмента не добавляется. Два `cm:0` с одинаковой ролью и +геометрией всегда эквивалентны. ### 5.3 Единый resolver @@ -107,10 +122,11 @@ room-wall с independent wall или пересекать узел/проём. контракта) является единственной точкой для: - `resolveZeroWallMode(space): { style, transmitsLight }`; -- проекции legacy `open_spans/open_to` в runtime-атомы; +- проекции legacy `open_spans/open_to` на authoritative v8 atoms; - классификации явных нулевых атомов по роли; - получения линий световых барьеров и derived room connectivity; -- миграции runtime-проекции в канонический persisted candidate. +- миграции v8 candidate в канонический v9 document без смены stable ID там, + где carrier не разрезается. Glow и солнце не имеют собственных проверок `dashed/solid`. Оба используют результат этого resolver. Визуальный renderer получает тот же `style`, но не @@ -125,17 +141,19 @@ type ZeroWallStyle = 'dashed' | 'solid'; interface SpaceConfig { zero_wall_style?: ZeroWallStyle; // missing/unknown read fallback = dashed - walls?: Array<{ key: string; cm: number; a?: number[]; b?: number[] }>; - room_drafts?: Array<{ segments: Array<{ cm: number }> }>; + wall_segments: Array<{ id: string; a: number[]; b: number[]; cm: number }>; + walls?: Array<{ key: string; cm: number; a: number[]; b: number[] }>; + room_drafts?: Array<{ segments: Array<{ id: string; cm: number }> }>; partitions?: Array<{ id: string; a: number[]; b: number[]; cm: number }>; } ``` -Backend принимает `cm: 0..100` для `walls[]`, `room_drafts[].segments[]` и -`partitions[]`; `wall_columns[].cm` остаётся `1..150`. Новые/переписанные room -wall records обязаны иметь точные `a/b`; key-only `cm:0` новая версия не пишет. +Backend принимает `cm: 0..100` для `wall_segments[]`, +`room_drafts[].segments[]` и `partitions[]`; `wall_columns[].cm` остаётся +`1..150`. Compatibility `walls[]` генерируется только из `cm>0`, всегда имеет +точные `a/b` и никогда не содержит zero record. -`PLAN_MODEL_VERSION` повышается с `7` до `8` только при фактической канонической +`PLAN_MODEL_VERSION` повышается с `8` до `9` только при фактической канонической записи хотя бы одного затронутого пространства. Открытие карточки и неструктурные изменения версию не повышают. @@ -145,11 +163,12 @@ wall records обязаны иметь точные `a/b`; key-only `cm:0` но - временно принимаются backend/import reader для старых документов; - сохраняются без мутации при read-only загрузке; -- проецируются в runtime как `cm:0` при fallback `dashed`; +- проецируются в runtime поверх v8 catalog: покрытые интервалы имеют `cm:0`, а + остальные существующие `cm:0` не получают отдельного происхождения; - удаляются из затронутого пространства одной транзакцией после успешной канонической миграции; - никогда не создаются новым frontend; -- в экспорте канонического v8 отсутствуют; +- в экспорте канонического v9 отсутствуют; - регистрируются в `docs/CONFIG-COMPATIBILITY.md` и `scripts/config-field-registry.mjs` существующими статусами `deprecated-read` для compatibility-read и `migrate-on-write` для @@ -187,9 +206,10 @@ Unknown sibling-поля сохраняются на read/write как сейч ### 7.3 Рисование по существующей оси -Рисование `cm:0` поверх доказанного участка room wall изменяет/атомизирует этот -участок. Оно не создаёт совпадающую `partition`. Повторное рисование того же -интервала — semantic no-op и не добавляет Undo-команду. +Рисование `cm:0` поверх доказанного участка room wall изменяет/атомизирует +authoritative `wall_segments[]` через identity barrier #282. Оно не создаёт +совпадающую `partition`. Повторное рисование того же интервала — semantic no-op +и не добавляет Undo-команду. На совпадающей independent partition меняется существующий объект только если есть ровно один однозначный carrier. T/X-узел, несколько carrier или конфликт @@ -250,14 +270,15 @@ Unknown sibling-поля сохраняются на read/write как сейч ### 8.4 Нормализация и Optimize -- Общий `normalizeWallIntervals()` сохраняет явные нули и объединяет только - role-equivalent соседние атомы. +- Identity writer #282 сохраняет stable ID и явные нули; соседние атомы + объединяются только при совпадении role/owners/cm и по документированным + lineage-правилам (survivor ID детерминирован, ссылки opening остаются валидны). - Ни один helper не должен применять `clampWallCm(0) → 1`. - Optimize preview отдельно считает `legacy virtual spans migrated` и `zero-wall atoms merged`. - После Confirm все пространства мигрируют атомарно; ошибка/лимит в одном пространстве отменяет весь candidate и оставляет one-deep Optimize Undo. -- Повторный Optimize канонического v8 — byte/semantic no-op. +- Повторный Optimize канонического v9 — byte/semantic no-op. ## 9. Свет и отображение @@ -295,30 +316,42 @@ solid блокирует пересечение луча. Нулевая outer w - Hover/hit target остаётся доступным минимумом текущего wall editor и не ограничивается визуальной толщиной линии. -## 10. Алгоритм миграции v7 → v8 +## 10. Алгоритм миграции v8 → v9 Миграция pure, deterministic, idempotent и возвращает либо полный candidate, -либо typed blocker. +либо typed blocker. Документ старее v8 сначала проходит уже реализованный +identity barrier #282 и только затем этот алгоритм; отдельный второй каталог +стен не строится. -1. Валидировать rooms, walls, openings, limits и координаты без изменения input. +1. Валидировать v8 `rooms[].poly/wall_ids`, `wall_segments`, openings, limits и + координаты без изменения input. Для pre-v8 input получить полный v8 candidate + существующим `commitWallSegmentModel()`. 2. Если есть валидные `open_spans`, использовать их. Иначе построить полные shared intervals по `open_to` через существующий `sharedBoundary` resolver. -3. Нормализовать и clip spans строго к доказанным room-wall carriers. Висящий, - неоднозначный или non-finite span — blocker, не silently drop. -4. Собрать breakpoints из концов carrier, legacy spans, положительных wall - records, room ownership/role changes, nodes и opening intervals. -5. Разрезать carrier на атомы. Интервал, покрытый legacy span, получает явную - запись `cm:0`; положительный остаток сохраняет точный `cm`; legacy-физический - остаток без записи остаётся отсутствующей записью. -6. Проверить конфликт проёмов. Любое пересечение мигрируемого нулевого атома с - opening host — blocker всего пространства. -7. Role-aware merge объединяет только эквивалентные соседние нулевые атомы. -8. Удалить `space.open_spans` и `rooms[].open_to` только из готового candidate. - Если новое поле отсутствует, оставить его отсутствующим: read fallback - `dashed` уже сохраняет поведение. -9. Прогнать geometry preflight и backend schema. При превышении 500 `walls[]` - или любого лимита отказать целиком; усечение запрещено. -10. При записи установить `model_version: 8`. Повторный запуск возвращает no-op. +3. Нормализовать и clip legacy spans строго к доказанным contour carriers. + Висящий, неоднозначный или non-finite span — blocker, не silently drop. +4. Убедиться, что endpoints каждого legacy span являются границами атомов. + Если v8 catalog уже атомизирован #282, stable IDs сохраняются. Если требуется + дополнительный split, применяется lineage #282: один доказанный survivor + сохраняет исходный ID, остальные получают новые ID, а `room.wall_ids` и + wall-hosted references обновляются атомарно. +5. Каждый атом, покрытый legacy virtual span, получает `cm:0`. Положительный + остаток сохраняет точный `cm`. Любой атом, который уже имел `cm:0`, остаётся + нулевым без отдельной метки происхождения и получает тот же style/light mode. +6. Проверить проёмы на **всех** итоговых `cm:0`, а не только на бывших virtual + spans. Zero opening host — blocker всего пространства: opening и исходный v8 + документ сохраняются, молчаливое удаление запрещено. +7. Role-aware normalization объединяет только эквивалентные соседние нулевые + атомы и сохраняет валидную identity/host lineage. +8. Перегенерировать compatibility `walls[]` только из `cm>0`. Удалить + `space.open_spans` и `rooms[].open_to` только из полностью готового candidate. +9. Не добавлять `zero_wall_style`, если пользователь его не сохранял: runtime + fallback `dashed` применяется ко всем `cm:0`. Это намеренно может изменить + вид и свет старых bodyless-физических осей после обновления. +10. Прогнать geometry preflight, model invariants и backend schema. При + превышении 500 `wall_segments[]` или любого лимита отказать целиком; + усечение запрещено. +11. При записи установить `model_version: 9`. Повторный запуск возвращает no-op. ### 10.1 Когда выполняется write @@ -331,7 +364,7 @@ solid блокирует пересечение луча. Нулевая outer w - **«Оптимизировать планы»** строит preview и мигрирует все legacy-пространства одной подтверждённой транзакцией. - Full/space import старого документа проецирует и валидирует migration в - preview; Apply сохраняет canonical v8 candidate либо отказывает целиком. + preview; Apply сохраняет canonical v9 candidate либо отказывает целиком. ### 10.2 Масштаб и round-trip @@ -342,11 +375,11 @@ config/layout round-trip не восстанавливают deprecated fields. ## 11. Backend, import/export и concurrency -- `custom_components/houseplan/validation.py` принимает новый enum и `cm:0`, но - различает новый explicit zero и отсутствие record. -- Validation запрещает key-only zero on new write, zero opening host и - non-finite/negative cm; legacy document допускается только через compatibility - read/import path. +- `custom_components/houseplan/validation.py` принимает новый enum и сохраняет + существующий v8 диапазон `wall_segments[].cm = 0..100`. +- Validation v9 запрещает zero opening host, non-finite/negative cm, нарушение + catalog/room projection и canonical наличие `open_spans/open_to`; legacy + document допускается только через compatibility read/import path. - `import_export.py` включает `zero_wall_style`, умеет preview старой миграции и выдаёт локализуемый/машиночитаемый blocker code. - Config revision/CAS и атомарная config+layout запись остаются обязательными; @@ -358,17 +391,22 @@ config/layout round-trip не восстанавливают deprecated fields. ### 12.1 Forward compatibility -Старые конфигурации отображаются прежним пунктиром и пропускают свет до любой -записи. Миграция не изменяет area, positive wall thickness, openings или -координаты. Backend сохраняет compatibility-reader минимум весь релизный цикл -v1.68.x; удаление reader требует отдельного issue и данных telemetry/fixtures. +Старые `open_spans/open_to` до записи продолжают читаться. Одновременно runtime +применяет единый zero-wall mode ко всем v8 `cm:0`: при отсутствующей настройке +это `dashed`, поэтому ранее bodyless-физические оси могут стать пунктирными и +начать пропускать свет уже после обновления. Этот переход намеренно не является +lossless по визуалу/свету; координаты, stable IDs, комнаты, положительные +толщины и сами opening records не меняются. Backend сохраняет compatibility- +reader минимум весь релизный цикл v1.68.x; удаление reader требует отдельного +issue и данных telemetry/fixtures. ### 12.2 Downgrade / rollback -Старая версия клиента не понимает explicit `cm:0`, поэтому downgrade после -canonical write не поддерживается. До первой структурной записи пользователь -может сделать полный backup. После Optimize доступен существующий one-deep Undo -в текущей сессии; надёжный откат между версиями — импорт backup. +Версия до #306 понимает v8 `cm:0`, но не понимает их новую единую семантику и +может снова записать `open_spans/open_to`. Поэтому downgrade после canonical v9 +write не поддерживается. До первой структурной записи пользователь может сделать +полный backup. После Optimize доступен существующий one-deep Undo в текущей +сессии; надёжный откат между версиями — импорт backup. Релизный rollback до первой beta выполняется revert коммита. После beta нельзя возвращать старую schema в stable; исправление выпускается forward-only с @@ -383,7 +421,7 @@ canonical write не поддерживается. До первой струк fingerprint/cache включают `space id + geometry revision + zero_wall_style`. - Смена HA state не пересобирает wall atoms, connectivity или sun barriers. - Лимит записей остаётся 500; результат не truncates. -- Benchmark на large-house fixture сравнивает v7 projection и v8 canonical: +- Benchmark на large-house fixture сравнивает v8 compatibility projection и v9 canonical: p95 построения barrier/first render не должен регрессировать более чем на 10%, steady HA-state render — более чем на 5% относительно baseline ветки. - Нулевые line barriers не превращаются в полигоны, что ограничивает рост @@ -437,7 +475,8 @@ canonical write не поддерживается. До первой струк - `src/light-visibility.ts`, `src/sun.ts`, `src/iso-walls.ts` — единый light mode и visual parity; - `src/plan-optimizer.ts`, `src/plan-geometry-preflight.ts`, - `src/coordinate-canonicalization.ts` — migration v8, limits/idempotence; + `src/wall-segment-model.ts`, `src/coordinate-canonicalization.ts` — migration + v9 поверх identity barrier #282, limits/idempotence; - `custom_components/houseplan/const.py`, `validation.py`, `import_export.py`, `coordinate_canonicalization.py` — model version, schema и import/export; - `src/i18n/en.json`, `src/i18n/ru.json`; @@ -469,12 +508,16 @@ golden основной панели до/после. **Доказательство:** table-driven unit `test/wall-thickness.test.mjs` и smoke с Undo/Redo. -### AC3. Отсутствующая запись не становится нулевой +### AC3. Все `cm:0` имеют одну семантику -Legacy room edge без `walls[]` record остаётся физической осевой стеной и не -получает dashed/open semantics. Только explicit `cm:0` является нулевой стеной. +Для v8 plan с двумя нулевыми contour atoms — один получен из прежней физической +оси без тела, второй совпадает с `open_spans` — runtime и v9 migration не +различают происхождение. Оба следуют одному `zero_wall_style`; persisted +`zero_kind`/compatibility-marker отсутствует. При default `dashed` оба становятся +пунктирными и пропускают свет. -**Доказательство:** migration unit + backend round-trip regression на v7 fixture. +**Доказательство:** migration/runtime unit на смешанной v8 fixture + source- +contract, запрещающий discriminator происхождения. ### AC4. Физическая геометрия и площадь не получают тело @@ -504,15 +547,18 @@ area byte/number equal между стилями; golden solid. ### AC7. Смена настройки работает без reload Переключение `dashed ↔ solid` сразу меняет line style, Glow, sun и connectivity; -старые barrier/render caches не используются, координаты и `walls[]` не меняются. +старые barrier/render caches не используются, координаты и `wall_segments[]` не +меняются. **Доказательство:** unit fingerprint/cache test + browser smoke в одном session. -### AC8. Миграция `open_spans` lossless и idempotent +### AC8. Миграция `open_spans` сохраняет данные и idempotent Полный/частичный/соседний span, разные positive residues и shared/outer role -преобразуются в exact `cm:0` atoms; deprecated fields удаляются только в -candidate, model становится v8. Второй запуск no-op. +преобразуются в exact `cm:0` atoms; stable IDs сохраняются по lineage #282, +deprecated fields удаляются только в candidate, model становится v9. Второй +запуск no-op. Координаты/комнаты/opening records не теряются; визуальная и +световая losslessness для прежних bodyless `cm:0` намеренно не обещается. **Доказательство:** `test/zero-wall-migration.test.mjs` fixture matrix и snapshot до/после/после второго запуска. @@ -553,7 +599,8 @@ space мигрирует его в той же CAS transaction. ### AC13. Optimize и import/export атомарны Optimize preview показывает counts и мигрирует все spaces после Confirm; one-deep -Undo возвращает v7 data в сессии. Full/space v7 import создаёт v8 candidate. +Undo возвращает исходные v8/legacy data в сессии. Full/space v8 или более старый +import создаёт v9 candidate через последовательные identity и zero-wall barriers. Лимит 500, invalid span, opening conflict или revision conflict отклоняет весь candidate без truncation/partial apply. @@ -571,8 +618,9 @@ smoke `show_borders`. ### AC15. Backend и compatibility registry согласованы -Backend принимает canonical v8 `cm:0` с exact endpoints, отклоняет negative, -non-finite, key-only zero и zero opening host. Старый v7 документ читается. +Backend принимает canonical v9 `wall_segments[].cm:0` с exact endpoints, +отклоняет negative, non-finite, zero opening host и legacy virtual fields в v9. +Старый v8/pre-v8 документ читается через compatibility path. `docs/CONFIG-COMPATIBILITY.md` описывает оба deprecated поля и downgrade. **Доказательство:** backend parameterized tests + docs review. @@ -587,7 +635,7 @@ best effort и не блокирует. ### AC17. Производительность и кэши -Large-house v7 projection/v8 canonical проходят бюджеты §13; HA state tick не +Large-house v8 projection/v9 canonical проходят бюджеты §13; HA state tick не запускает atomization/barrier rebuild; style toggle запускает ровно одну инвалидацию требуемых geometry/light caches. @@ -618,7 +666,7 @@ Code-review/release gate дополнительно выполняет: - Glow и sun deterministic golden matrix; - flat/isometric/show_borders golden review; - large-house performance capture с бюджетами §13; -- полный импорт v7 full/space fixtures и v8 round-trip; +- полный импорт v8 и pre-v8 full/space fixtures и v9 round-trip; - сверку `dist/houseplan-card.js` и `custom_components/houseplan/frontend/houseplan-card.js`. @@ -650,7 +698,7 @@ Golden принимаются только через действующую pol | Glow и sun разойдутся | единый resolver, совместная матрица AC5/AC6 | | `0` превратится в `1` старым clamp | отдельный zero-aware parser + AC2/AC11 | | Atomization превысит лимит | atomic failure, no truncation | -| Старый клиент испортит v8 | документированный unsupported downgrade + backup | +| Старый клиент вернёт legacy-поля в v9 | документированный unsupported downgrade + backup | | Cache переживёт style toggle | style in fingerprint + AC7/AC17 | | Две модели продолжат писаться | source-contract запрещает production legacy writers | @@ -662,12 +710,13 @@ Golden принимаются только через действующую pol - #173 — единый инструмент «Стены»; - #199 — geometry preflight; - #224/#299 — canonical coordinates и role-aware records; -- #282 — будущая объектная модель стен. #306 не ждёт #282, но не вводит вторую - новую сущность: explicit zero является совместимым промежуточным атомом. +- #282 — реализованная обязательная основа: stable `wall_segments[].id`, + `rooms[].wall_ids` и единый structural identity barrier. #306 не дублирует и + не обходит этот writer. ### Вне скоупа -- стабильные wall ids и полный graph rewrite #282; +- Stages 2–4 ADR #282 (integer lattice, единый planar graph, closed-form junctions); - разный style/light mode у отдельных zero walls; - проёмы в нулевых стенах и «неактивные» сохранённые проёмы; - создание внешней световой зоны;