docs: adapt zero-wall spec to model v8

Issue: #306
User-Visible: no
This commit is contained in:
Matysh
2026-08-26 12:55:27 +03:00
parent 73cf8ed01a
commit 799289e73b
+136 -87
View File
@@ -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;
- проёмы в нулевых стенах и «неактивные» сохранённые проёмы;
- создание внешней световой зоны;