mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
663 lines
39 KiB
Markdown
663 lines
39 KiB
Markdown
# Issue #282 — стабильная идентичность сегментов стен (ADR Stage 1)
|
||
|
||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/282
|
||
- **ADR:** [`docs/adr/282-wall-geometry-representation.md`](../adr/282-wall-geometry-representation.md)
|
||
- **Статус:** принято независимым ревью; Stage 1 реализован и проверен
|
||
- **Тип / приоритет:** tech-debt / P1
|
||
- **Поставляемый этап:** Stage 1 — stored identity
|
||
- **Целевая версия модели:** `PLAN_MODEL_VERSION = 8`
|
||
- **Пользовательское изменение:** да, но без нового интерфейса
|
||
|
||
## 1. Сценарий
|
||
|
||
**Персона:** администратор дома из `docs/SCOPE.md`, который уже построил план и
|
||
поддерживает его в Plan editor на desktop. **Момент:** Resize, Split, Merge,
|
||
изменение толщины, размещение проёма, импорт либо Optimize меняют геометрию
|
||
существующего плана. View, kiosk и touch-потребители затем должны увидеть тот же
|
||
дом без пропавшей толщины, переехавшего проёма или нового шва.
|
||
|
||
Задача обслуживает J6: «Keep the plan true as the home evolves». Это не новая
|
||
возможность рисования, а устранение причины, по которой уже сохранённая стена
|
||
теряет смысл после дальнейшего редактирования.
|
||
|
||
## 2. Что человек увидит до и после
|
||
|
||
**До:** после сложного изменения внешне та же стена иногда теряет толщину,
|
||
получает толщину соседнего участка либо отделяется от своего проёма. **После:**
|
||
та же операция сохраняет принадлежность толщины и проёмов стене; внешний вид и
|
||
состав кнопок не меняются.
|
||
|
||
Единственное новое видимое состояние — честный отказ от структурной записи,
|
||
если старый план невозможно преобразовать без потери данных. View продолжает
|
||
работать по compatibility reader; диалог/тост называет причину, предлагает
|
||
сначала запустить «Оптимизировать планы», а при повторном отказе — исправить
|
||
конфликтующую геометрию стен.
|
||
|
||
## 3. Проблема и подтверждённая причина
|
||
|
||
Сегодня contour wall одновременно является:
|
||
|
||
1. ребром `rooms[].poly`;
|
||
2. разреженной записью толщины `space.walls[]`, найденной по вычисленному
|
||
`wallKey` (квантованные midpoint + angle);
|
||
3. пространственно найденным носителем room opening без сохранённой ссылки.
|
||
|
||
Геометрическая операция обязана заново вывести все три связи. Пропущенный или
|
||
слегка отличающийся re-key теряет толщину (#253/#258); частичное совпадение
|
||
создаёт неоднозначную роль (#299); Split/Resize вынуждены материализовать и
|
||
переименовывать атомы до изменения контура. Канонизация координат #291 убрала
|
||
битовый noise, но не устранила вывод идентичности из изменяемой геометрии.
|
||
|
||
Independent `partitions[]` уже имеют стабильный `id`, а hosted openings уже
|
||
ссылаются на него. Stage 1 переносит этот доказанный принцип на contour walls и
|
||
на незавершённые draft segments. Renderer и вычисление junction geometry пока
|
||
не переписываются: они получают compatibility projection из новой модели.
|
||
|
||
## 4. Скоуп
|
||
|
||
В задачу входят:
|
||
|
||
- стабильный persisted `id` каждого атомарного contour segment;
|
||
- ссылки комнат на эти ID в порядке обхода контура;
|
||
- толщина на самом segment record, включая отсутствие физического тела;
|
||
- стабильные ID draft segments; существующие partition IDs сохраняются;
|
||
- wall-host для door/window/gate/passage на contour segment;
|
||
- детерминированная v7 → v8 миграция и runtime-проекция без записи на read;
|
||
- единый identity writer/barrier для Resize, Split, Merge, Delete, draw/close,
|
||
thickness, opening placement, Optimize, Undo/recovery и import/export;
|
||
- compatibility projection `rooms[].poly`, `walls[]`, legacy unhosted openings;
|
||
- backend validation, limits, import remap и fail-closed stale-client policy;
|
||
- unit/backend/invariant/mutation/smoke/golden/performance evidence;
|
||
- обновление канонической документации и обоих changelog.
|
||
|
||
## 5. Не-скоуп
|
||
|
||
- integer node indices и новый `STORAGE_VERSION` (ADR Stage 2);
|
||
- отказ от `rooms[].poly` как compatibility projection (Stage 3);
|
||
- единый persisted planar graph для room walls и partitions (Stage 3);
|
||
- closed-form junction renderer и удаление polygon booleans (Stage 4);
|
||
- новый UI, изменение жестов, новые инструменты или иная геометрия результата;
|
||
- исправление видимого дефекта конкретного junction, не вызванного identity;
|
||
- индивидуальная настройка ID либо их показ пользователю;
|
||
- автоматическое притягивание authored off-grid geometry;
|
||
- удаление legacy reader в этом релизе.
|
||
|
||
Stages 2–4 требуют отдельных issues и поставляются независимо. Закрытие #282
|
||
после Stage 1 означает завершение текущего исполнимого этапа ADR, а не отмену
|
||
оставшегося направления.
|
||
|
||
## 6. Целевая persisted-модель
|
||
|
||
### 6.1 Contour segments
|
||
|
||
В пространство добавляется полный каталог атомарных contour walls:
|
||
|
||
```ts
|
||
interface StoredWallSegment {
|
||
id: string; // stable, unique inside one space
|
||
a: [number, number];
|
||
b: [number, number];
|
||
cm: number; // 0..100; 0 = axis without a physical body
|
||
}
|
||
|
||
interface RoomCfg {
|
||
poly: number[][]; // required compatibility projection in v8
|
||
wall_ids: string[]; // one id for every consecutive poly edge
|
||
}
|
||
|
||
interface SpaceCfg {
|
||
wall_segments: StoredWallSegment[];
|
||
walls?: LegacyWallEntry[]; // generated compatibility projection
|
||
}
|
||
```
|
||
|
||
`wall_segments[]` содержит **каждый** атом contour, а не только участок с
|
||
положительной толщиной. В v8 `wall_segments[].id/a/b/cm` и
|
||
`rooms[].wall_ids[]` являются authoritative для identity и толщины.
|
||
|
||
`rooms[].poly` остаётся authoritative для формы комнаты до ADR Stage 3, но в
|
||
v8 его стороны и `wall_ids` обязаны образовывать exact согласованную пару:
|
||
|
||
- `wall_ids.length === poly.length`;
|
||
- segment с соответствующим ID совпадает с ребром `poly[i] → poly[i+1]` в
|
||
прямом либо обратном направлении;
|
||
- один ID может ссылаться из одной комнаты (`outer`) или двух комнат (`shared`);
|
||
- ссылка из трёх и более комнат, orphan segment либо дублированный ID невалидны;
|
||
- shared segment обязан иметь одни exact endpoints и один `cm` для владельцев.
|
||
|
||
Если общий участок занимает только часть длинной стороны комнаты, миграция
|
||
добавляет коллинеарные vertices и делит сторону на атомы. Площадь, winding,
|
||
silhouette и видимый SVG при этом не меняются.
|
||
|
||
### 6.2 Толщина 0
|
||
|
||
`cm: 0` в catalog означает действующее сегодняшнее состояние «контур есть,
|
||
физического wall body нет». Это техническое представление полной модели, а не
|
||
пользовательская возможность issue #306:
|
||
|
||
- Plan UI в #282 по-прежнему предлагает прежний диапазон и инструменты;
|
||
- legacy sparse interval без `walls[]` мигрирует в segment `cm: 0`;
|
||
- virtual/open boundary продолжает определяться `open_spans/open_to`, а не
|
||
одним `cm: 0`;
|
||
- body/area/opening/light semantics остаются byte/geometry-equivalent v7.
|
||
|
||
Разрешение пользователю рисовать zero-thickness walls и отказ от virtual walls
|
||
остаются в приостановленном #306 и не входят скрытым изменением сюда.
|
||
|
||
### 6.3 Independent walls и drafts
|
||
|
||
`partitions[].id` уже является стабильной identity и не мигрирует в новый
|
||
catalog до Stage 3. Его endpoints/cm остаются на partition record.
|
||
|
||
Каждая `room_drafts[].segments[]` получает `id`:
|
||
|
||
```ts
|
||
segments: Array<{ id: string; cm: number }>
|
||
```
|
||
|
||
ID сохраняется при продолжении/перемещении draft. При превращении draft в
|
||
partition либо contour соответствующий segment наследует ID, если его carrier
|
||
не был разделён. При split действуют lineage-правила §8.
|
||
|
||
### 6.4 Openings
|
||
|
||
Host становится tagged union:
|
||
|
||
```ts
|
||
type OpeningHost =
|
||
| { kind: 'partition'; id: string; t: number }
|
||
| { kind: 'wall'; id: string; t: number };
|
||
```
|
||
|
||
Новые room openings всегда сохраняют wall host. `t` направлен от stored `a` к
|
||
`b`; визуальный flip/створка от направления segment не зависят. Legacy opening
|
||
без host остаётся читаемым и получает runtime host spatial resolver. На первой
|
||
структурной записи host материализуется только при единственном доказанном
|
||
carrier; неоднозначность блокирует миграцию, а не выбирает ближайший наугад.
|
||
|
||
### 6.5 Compatibility projection `walls[]`
|
||
|
||
`walls[]` временно остаётся persisted projection для старых клиентов и
|
||
неизменённого renderer:
|
||
|
||
- генерируется централизованно из `wall_segments` (положительные `cm`, exact
|
||
`a/b`, вычисленный compatibility `key`);
|
||
- не является source of truth в v8;
|
||
- v8 backend проверяет его semantic parity с catalog, но key drift сам по себе
|
||
не делает catalog невалидным;
|
||
- новый frontend никогда не присваивает `walls[]` напрямую;
|
||
- runtime geometry получает прежний `WallEntry[]` через один adapter;
|
||
- в v7 отсутствие catalog сохраняет нынешний reader без записи.
|
||
|
||
Re-key остаётся только деталью compatibility serializer. Ни Resize, ни Split,
|
||
ни business logic не используют key как identity после v8 migration.
|
||
|
||
## 7. Атомизация и deterministic migration v7 → v8
|
||
|
||
### 7.1 Breakpoints
|
||
|
||
До вычисления breakpoint в том же local candidate и до любых v8 ID/ref
|
||
запускается существующий write-barrier #291 `canonicalizeConfigGeometry`.
|
||
Порог и правило принадлежат только ему: текущий `LATTICE_NOISE_STEPS = 1e-4`
|
||
шага сетки. Все слова `exact` ниже означают побитовое равенство **уже
|
||
канонизированных** координат; дополнительный wall-thickness epsilon и третий
|
||
допуск Stage 1 не вводит. Если канонизация или последующая атомизация
|
||
отклоняет candidate, исходный v7 document остаётся byte-equivalent.
|
||
|
||
Каждая room boundary разбивается в точках:
|
||
|
||
- vertices всех room polygons;
|
||
- начала/концы коллинеарного overlap другой комнаты;
|
||
- exact `walls[].a/b` и доказанные границы key-only legacy record;
|
||
- `open_spans` и доказанные shared `open_to` spans;
|
||
- пересечения с structural nodes, уже используемые текущим wall profile.
|
||
|
||
Opening edges не делят segment сами по себе: opening хранит `t` и ширину.
|
||
Нулевая длина, self-overlap, конфликтующие positive thickness records либо
|
||
неоднозначный key-only carrier блокируют миграцию всего пространства.
|
||
|
||
### 7.2 Толщина
|
||
|
||
Для каждого атома выбирается ровно действующий v7 resolver:
|
||
|
||
1. exact matching `walls[].a/b`;
|
||
2. exact covering record;
|
||
3. legacy key/midpoint fallback;
|
||
4. отсутствие записи → `cm: 0`.
|
||
|
||
Разные positive candidates на одном атоме — blocker, не `min/max/last wins`.
|
||
Open boundary не удаляет segment и не меняет `cm`; light/body projection
|
||
применяет open cut отдельно, как до миграции.
|
||
|
||
### 7.3 ID migration
|
||
|
||
Первоначальный ID детерминирован для повторяемой миграции одного v7 document:
|
||
|
||
```text
|
||
wall-<base32(sha256(space-id | canonical-a | canonical-b | sorted-owner-ids))[0:20]>
|
||
```
|
||
|
||
Endpoint order не влияет на hash. Collision после усечения разрешается
|
||
детерминированным `-2`, `-3` после сортировки полной digest; collision полного
|
||
digest либо duplicate resulting ID блокирует миграцию.
|
||
|
||
Это **только seed миграции**. После сохранения ID никогда не вычисляется заново
|
||
из geometry. Новые IDs создаёт один monotonic/random id factory с проверкой
|
||
уникальности в space; импорт использует общий bounded remapper.
|
||
|
||
### 7.4 Момент миграции
|
||
|
||
Read-only загрузка v7 не пишет Store и не повышает model version. Runtime строит
|
||
immutable projected catalog для View и editor selection.
|
||
|
||
Атомарная v8 materialization выполняется:
|
||
|
||
- перед первой успешной structural config mutation пространства;
|
||
- явным Optimize plans;
|
||
- при добавлении v7 space в уже v8 config;
|
||
- при full/space import candidate, если target требует v8.
|
||
|
||
Изменение только настроек устройства/пространства, layout marker position или
|
||
View state не мигрирует wall model. Успешная materialization записывает catalog,
|
||
room refs, hosted openings, compatibility projection и `model_version: 8` одной
|
||
config transaction/Undo command. Ошибка оставляет candidate byte-equivalent.
|
||
|
||
## 8. Identity lineage при операциях
|
||
|
||
ID сохраняется, когда физический/топологический carrier остаётся тем же объектом,
|
||
даже если изменились endpoints, angle, length, owner или compatibility key.
|
||
|
||
### 8.1 Move и Resize
|
||
|
||
- whole segment, перенесённый/растянутый одной доказанной операцией, сохраняет ID;
|
||
- shared segment, одинаково преобразованный обоими owners, остаётся одним ID;
|
||
- несовместимые transforms owners отклоняются preflight;
|
||
- изменение только endpoints никогда не создаёт новый ID.
|
||
|
||
### 8.2 Split одного segment
|
||
|
||
При появлении breakpoint исходный ID получает ровно один child:
|
||
|
||
1. child, содержащий старый midpoint внутри;
|
||
2. при midpoint на новой границе — child, содержащий canonical old `a`;
|
||
3. остальные children получают новые IDs в геометрическом порядке от old `a`.
|
||
|
||
Openings rehost по их physical centre/interval и получают пересчитанный `t`.
|
||
Opening, пересекающий новую границу между children с разными условиями,
|
||
блокирует операцию до записи.
|
||
|
||
### 8.3 Merge segments
|
||
|
||
Слияние разрешено только для коллинеарных соседей с одинаковыми `cm`, owner set,
|
||
open-boundary role и без semantic breakpoint. Survivor ID выбирается:
|
||
|
||
1. segment с большим количеством существующих opening hosts;
|
||
2. затем самый длинный;
|
||
3. затем лексикографически меньший ID.
|
||
|
||
Все refs/hosts переводятся на survivor с пересчитанным `t`. Остальные IDs
|
||
исчезают; tombstone не хранится, поскольку Undo содержит полный snapshot.
|
||
|
||
### 8.4 Split/Merge/Delete rooms
|
||
|
||
- смена outer ↔ shared сама по себе ID не меняет;
|
||
- segment, остающийся на границе хотя бы одной surviving room, сохраняется;
|
||
- полностью удалённый carrier удаляется только в уже подтверждённом user flow;
|
||
- Keep walls переводит contour segment в partition с тем же ID, если ID не
|
||
конфликтует с существующим partition;
|
||
- обратное включение unambiguous partition в contour сохраняет ID.
|
||
|
||
### 8.5 Draft close/cancel
|
||
|
||
Сохранённый draft segment сохраняет ID между сессиями. Незавершённый in-memory
|
||
rubber band ID не получает. Cancel не расходует persisted ID. Закрытие room
|
||
переносит IDs; atomization применяет §8.2.
|
||
|
||
### 8.6 Undo/Redo и concurrency
|
||
|
||
Undo/Redo восстанавливает exact snapshots с теми же IDs. Config revision guard
|
||
проверяется до lineage calculation и ещё раз при commit; stale revision не
|
||
может частично сохранить catalog. Concurrent edit получает обычный conflict и
|
||
перезагружает authoritative snapshot.
|
||
|
||
## 9. Writers и единый identity barrier
|
||
|
||
Новый pure-модуль (рабочее имя `src/wall-segment-model.ts`) владеет:
|
||
|
||
- v7 projection/migration;
|
||
- validation/parity;
|
||
- compatibility `walls[]` projection;
|
||
- ID lineage split/merge/rehost;
|
||
- persisted/runtime fingerprints.
|
||
|
||
Каждая structural mutation работает над local candidate, сначала пропускает
|
||
его через `canonicalizeConfigGeometry` #291, затем через
|
||
`commitWallSegmentModel(candidate)` и только после этого достигает
|
||
`_commitPhysicalGeometry`/backend write. Канонизация и identity migration входят
|
||
в одну атомарную commit-транзакцию; результат первой является единственным
|
||
координатным input второй.
|
||
Source guard перечисляет все structural writer entrances и падает, если новый
|
||
путь записывает `rooms.poly`, `walls`, `wall_segments`, `partitions`, drafts или
|
||
architectural openings мимо barrier.
|
||
|
||
Запрещено исправлять задачу добавлением ID assignments в десятки обработчиков.
|
||
Локальные контроллеры могут передать lineage hint, но один barrier доказывает и
|
||
пересобирает согласованную модель.
|
||
|
||
Backend mirror проверяет:
|
||
|
||
- schema bounds/unique IDs/references;
|
||
- geometry/ref/parity invariants;
|
||
- v8 catalog ↔ compatibility projection;
|
||
- wall/partition opening host and `t` fit;
|
||
- отсутствие direct Store writer bypass.
|
||
|
||
Backend не придумывает lineage для валидного v8 candidate; он либо принимает
|
||
его целиком, либо отклоняет.
|
||
|
||
## 10. Старые клиенты и совместимость
|
||
|
||
### 10.1 v7 data на новом клиенте
|
||
|
||
View/read работает через runtime projection. Первая structural write предлагает
|
||
тот же operation вместе с migration; успех атомарный. Export до migration
|
||
сохраняет фактический v7 document, не штампует v8.
|
||
|
||
### 10.2 v8 data на старом клиенте
|
||
|
||
Старый клиент может сохранить неизвестные поля, но не способен согласованно
|
||
обновить refs/catalog. Backend обнаруживает изменение structural projection без
|
||
соответствующего v8 catalog и отклоняет весь config write кодом
|
||
`wall_model_client_outdated`. UI нового клиента локализует его как требование
|
||
обновить/перезагрузить карточку. View/read старого клиента может использовать
|
||
compatibility `poly/walls`; безопасное редактирование старым клиентом не
|
||
обещается.
|
||
|
||
### 10.3 Import/export/duplicate
|
||
|
||
- full replace v8 сохраняет IDs после полной validation;
|
||
- space import и duplicate remap every wall/draft/partition ID and all
|
||
`wall_ids`/opening hosts одним lineage table;
|
||
- collision с target никогда не resolve-ится геометрическим совпадением;
|
||
- v7 import в v7 target сохраняется v7; в v8 target материализуется до merge;
|
||
- plan-only export/import включает catalog/refs/hosts и не оставляет dangling IDs;
|
||
- future unknown fields сохраняются на record/room/space как сейчас;
|
||
- future model version отклоняется до migration.
|
||
|
||
### 10.4 Configuration lifecycle
|
||
|
||
`wall_segments`, `rooms[].wall_ids`, `room_drafts[].segments[].id` и wall host
|
||
регистрируются в `scripts/config-field-registry.mjs` как current v8. `walls.key`
|
||
и unhosted room openings получают documented compatibility statuses, но reader
|
||
удаляется только отдельной задачей после окна поддержки.
|
||
|
||
## 11. UX, accessibility, touch и security
|
||
|
||
- кнопки, поля, значения толщины и pointer gestures не меняются;
|
||
- View/kiosk output и actions не меняются;
|
||
- hover/hit-test/selection используют ID internally, но показывают прежние ink;
|
||
- editor остаётся desktop-first; touch View acceptance блокирующий;
|
||
- screen reader/keyboard contract не расширяется;
|
||
- ID никогда не показываются в обычном UI и не попадают в toast/telemetry;
|
||
- новые HA service calls, URL/HTML или permission boundaries отсутствуют;
|
||
- failure UI не раскрывает geometry/config data, только локализованный класс
|
||
blocker и действие пользователя.
|
||
|
||
## 12. i18n
|
||
|
||
Новые RU/EN ключи (точное имя можно привести к namespace проекта):
|
||
|
||
| Key | RU | EN |
|
||
|---|---|---|
|
||
| `toast.wall_model_migration_blocked` | `Не удалось обновить модель стен: {reason}. План не изменён. Запустите «Оптимизировать планы»; если ошибка повторится, исправьте конфликтующую геометрию стен.` | `The wall model could not be updated: {reason}. The plan was not changed. Run “Optimize plans”; if the error repeats, fix the conflicting wall geometry.` |
|
||
| `toast.wall_model_client_outdated` | `Обновите карточку и перезагрузите страницу перед редактированием плана.` | `Update the card and reload the page before editing the plan.` |
|
||
| `gs.wall_segments_migrated` | `Стабилизировано сегментов стен: {n}.` | `Wall segments stabilised: {n}.` |
|
||
|
||
Reason names проходят через bounded enum и отдельные localized labels; raw IDs,
|
||
exception text и config values не интерполируются.
|
||
|
||
## 13. Лимиты и производительность
|
||
|
||
- catalog ограничен суммой допустимых room polygon sides после atomization;
|
||
hard backend cap — `MAX_ROOMS × MAX_POLY_POINTS` (текущие 200 000), а общий
|
||
import/export byte cap остаётся более строгим практическим ограничением;
|
||
- duplicate IDs, refs и parity проверяются O(V + E) через maps;
|
||
- spatial atomization migration допускает O(E log E), но не O(E²) на обычном
|
||
payload; shared-axis grouping обязано иметь spatial/axis index;
|
||
- read/render tick не мигрирует и не хэширует полный config повторно;
|
||
- runtime projection и catalog fingerprint кэшируются по config revision;
|
||
- large-house structural render p95 и editor commit p95 не регрессируют более
|
||
чем на 20% same-run baseline;
|
||
- migration 10 000 contour atoms укладывается в 500 ms p95 в Node benchmark и
|
||
не создаёт второго полного deep clone сверх transaction snapshot;
|
||
- новые limits не отклоняют ни один валидный v7 fixture до попытки migration.
|
||
|
||
## 14. Критерии приёмки
|
||
|
||
### AC1. Детерминированная lossless migration
|
||
|
||
v7 fixtures с outer/shared/partial-overlap/T/X/diagonal/open span/key-only,
|
||
exact walls и lattice-noise по обе стороны порога #291 дважды дают
|
||
byte-equivalent v8 candidate. Шум внутри порога сначала канонизируется и не
|
||
создаёт micro-segments; координата вне порога не схлопывается молча. Polygon
|
||
area, winding, wall-body path, clean floor, opening projection, Glow/sun
|
||
barriers и isometric geometry до/после равны по существующим строгим/golden
|
||
контрактам.
|
||
|
||
**Доказательство:** TS migration unit matrix + golden/static path comparison +
|
||
backend fixture parity.
|
||
|
||
### AC2. Полный и валидный catalog
|
||
|
||
Для каждого v8 space все contour atoms имеют unique stable ID; каждая poly side
|
||
имеет ровно один wall ref; owner count 1/2; orphan/dangling/duplicate/third-owner,
|
||
zero-length и conflicting thickness отклоняются. Positive legacy thickness и
|
||
thin contour сохраняют прежний вид.
|
||
|
||
**Доказательство:** frontend/backend shared valid+invalid JSON fixture.
|
||
|
||
### AC3. Identity не зависит от geometry после migration
|
||
|
||
Move/Resize/angle/length/key change whole segment сохраняют ID. Мутант,
|
||
перевычисляющий ID из endpoints после каждого commit, убивается тестом.
|
||
|
||
**Доказательство:** pure lineage tests + Resize browser smoke.
|
||
|
||
### AC4. Split/merge lineage
|
||
|
||
Split выбирает survivor по midpoint/old-a; merge — по hosts/length/id. Room refs
|
||
и hosted opening `t` обновляются атомарно. Повторение операции после Undo/Redo
|
||
возвращает exact те же IDs.
|
||
|
||
**Доказательство:** parameterized unit tests для directions/ties/openings +
|
||
Split/Merge smoke.
|
||
|
||
### AC5. Все structural operations проходят barrier
|
||
|
||
Draw/close, wall thickness, opening placement/move/delete, Resize, Split, Merge,
|
||
room Delete/Keep walls, partition conversion, Optimize, Undo/Redo/recovery и
|
||
import вызывают один identity barrier. Direct assignments не могут попасть в
|
||
persisted write.
|
||
|
||
**Доказательство:** executable source/AST guard + bypass mutant для каждого
|
||
writer family.
|
||
|
||
### AC6. Room openings hosted
|
||
|
||
Новый room opening всегда имеет `{kind:'wall', id, t}`. Legacy unique carrier
|
||
материализуется; ambiguous/missing/intersecting split блокирует transaction.
|
||
Door/window/gate/passage, flip, contact/lock и Glow tunnel не меняются.
|
||
|
||
**Доказательство:** opening unit matrix + `smoke_opening_measure`/targeted host
|
||
smoke + backend schema tests.
|
||
|
||
### AC7. Compatibility projection
|
||
|
||
v8 → legacy `poly/walls/unhosted visual input` projection даёт текущим full,
|
||
static, hidden-isometric и light consumers прежние inputs. Runtime code не ищет
|
||
business identity через `wallKey`; key генерирует только adapter/compat layer.
|
||
|
||
**Доказательство:** source guard + projection snapshots + existing geometry
|
||
suite.
|
||
|
||
### AC8. Read-only и атомарный failure
|
||
|
||
Открытие v7 config, View, kiosk, navigation и device/layout-only save не пишут
|
||
catalog/model version. Structural migration blocker оставляет config/layout/rev
|
||
byte-equivalent и сохраняет работающий View.
|
||
|
||
**Доказательство:** store call-count tests + failure fixture + browser smoke.
|
||
|
||
### AC9. Старый клиент fail-closed
|
||
|
||
Попытка изменить v8 `poly/walls/openings` без согласованного catalog отклоняется
|
||
bounded error; ни один partial Store write не происходит. Byte-equivalent
|
||
round-trip старого клиента принимается.
|
||
|
||
**Доказательство:** backend old-client write tests.
|
||
|
||
### AC10. Import/export и ID remap
|
||
|
||
Full/space/plan-only export-import, duplicate and backup restore сохраняют либо
|
||
полностью remap-ят ID/ref/host graph. Collision, dangling ref и future model
|
||
отклоняются до apply; preview counts согласованы с candidate.
|
||
|
||
**Доказательство:** HA import/export harness + shared lineage fixture.
|
||
|
||
### AC11. Limits и unknown fields
|
||
|
||
Boundary cases catalog/draft IDs/refs проходят или fail atomically. Unknown
|
||
sibling fields на config/space/room/segment/opening survive migration and
|
||
round-trip. Atomization не truncates data ради лимита.
|
||
|
||
**Доказательство:** backend validation/property tests.
|
||
|
||
### AC12. Geometry regression floor
|
||
|
||
Все тесты #197/#224/#249/#253/#258/#261/#271–#280/#288–#302 остаются зелёными.
|
||
Точный ID не является поводом удалить существующую union isolation либо
|
||
ослабить geometry preflight.
|
||
|
||
**Доказательство:** `npm test`, model invariants, named wall smokes/goldens.
|
||
|
||
### AC13. View/touch parity
|
||
|
||
Desktop View, kiosk и phone/touch fixture визуально равны pre-migration baseline;
|
||
room/device/opening taps и pinch не получают editor behaviour.
|
||
|
||
**Доказательство:** targeted Playwright view/touch smoke + golden comparison.
|
||
|
||
### AC14. Performance
|
||
|
||
Выполнены budgets §13; migration не запускается на render/live HA tick.
|
||
|
||
**Доказательство:** same-run benchmark с machine-readable result.
|
||
|
||
### AC15. Optimize UX
|
||
|
||
Optimize preview отдельно сообщает количество materialized segment IDs. Cancel
|
||
— zero writes; Confirm — одна config transaction и одна Undo; второй run no-op.
|
||
|
||
**Доказательство:** optimizer unit test + targeted dialog smoke.
|
||
|
||
### AC16. Документация и compatibility registry
|
||
|
||
Обновлены `WALL-THICKNESS.md`, `ARCHITECTURE.md`, `CONFIG-COMPATIBILITY.md`,
|
||
`CANVAS.md`, user guides RU/EN, `TESTING.md`, ADR status и config-field registry.
|
||
Термины ID/catalog не попадают в пользовательскую инструкцию кроме объяснения
|
||
автоматического обновления старого плана.
|
||
|
||
**Доказательство:** docs/config audit gates.
|
||
|
||
### AC17. Gates
|
||
|
||
Перед code review зелёные:
|
||
|
||
- `npm run typecheck`;
|
||
- `npm test`;
|
||
- `npm run build` + `npm run bundle:sync` и bundle parity;
|
||
- native Windows pure-backend subset;
|
||
- named identity/Resize/Split/Merge/opening/import smokes из AC;
|
||
- model invariants, mutation gate, config audit и migration benchmark.
|
||
|
||
Полный Linux backend, all-smoke, golden и performance_smoke остаются точным-SHA
|
||
предрелизным gate по процессу.
|
||
|
||
## 15. План реализации
|
||
|
||
1. Ввести pure catalog types, deterministic migration и shared fixtures без
|
||
подключения product writers.
|
||
2. Добавить backend schema/parity и model version, оставить read-only v7 path.
|
||
3. Подключить runtime compatibility projection; доказать render equivalence.
|
||
4. Перевести room wall selection/thickness/opening host на ID.
|
||
5. Подключить writer barrier и lineage hints по операциям, затем source guard.
|
||
6. Подключить import/export/duplicate/Undo/recovery и stale-client rejection.
|
||
7. Добавить Optimize preview/report, i18n, docs и changelog.
|
||
8. Прогнать fast gates, named smokes, build/sync; передать независимому reviewer.
|
||
|
||
Каждый промежуточный commit компилируется и не включает half-migrated writer в
|
||
default path. Feature flag в persisted config не добавляется: v8 включается
|
||
только фактом успешной materialization.
|
||
|
||
## 16. Риски и меры
|
||
|
||
| Риск | Мера |
|
||
|---|---|
|
||
| Catalog и poly расходятся | один commit barrier + backend parity + stale-client reject |
|
||
| Shared partial edge получает два ID | global atomization по axis/owners + shared fixture AC1/2 |
|
||
| Resize пересоздаёт ID | explicit lineage hints + AC3 mutant |
|
||
| Split теряет opening | deterministic child rule и physical rehost AC4/6 |
|
||
| Старый клиент портит v8 | compatibility projection для read, fail-closed structural write AC9 |
|
||
| Migration выбирает неверный key-only wall | текущий resolver без новых эвристик; ambiguity blocks |
|
||
| 200k theoretical edges дают freeze | indexed O(E log E), async preview boundary, benchmark/limits |
|
||
| Новый writer обходит model | executable source/AST guard AC5 |
|
||
| Новая identity используется для удаления старых geometry guards | explicit AC12 prohibition |
|
||
| #306 конфликтует с моделью `cm:0` | #282 не меняет UI/open-boundary semantics; #306 later rebases on catalog |
|
||
|
||
## 17. Откат
|
||
|
||
До первой v8 записи — чистый revert кода, данные v7 не менялись. После v8
|
||
materialization старый stable release может показать compatibility `poly/walls`,
|
||
но безопасное редактирование им не гарантируется.
|
||
|
||
Штатный rollback продукта:
|
||
|
||
1. отключить structural v8 writes, сохранив v8 read/projection;
|
||
2. выпустить follow-up reader hotfix;
|
||
3. не удалять catalog и IDs автоматически;
|
||
4. восстановить pre-migration config только из обычного backup/Undo,
|
||
инициированного пользователем.
|
||
|
||
Автоматический downgrade v8 → v7 запрещён: он теряет hosted wall identity и
|
||
может неоднозначно схлопнуть атомы. Revert implementation-коммита без v8 reader
|
||
после публикации также запрещён.
|
||
|
||
## 18. Release-артефакты
|
||
|
||
- implementation commit: `Issue: #282`, `User-Visible: yes`;
|
||
- оба changelog со ссылкой на #282 и формулировкой про устойчивое редактирование,
|
||
без обещания нового UI;
|
||
- generated bundle snapshots синхронизированы;
|
||
- новые golden baselines принимаются только при реальной visual delta и только
|
||
из полного Linux artifact по процессу;
|
||
- beta/RC обязательна до stable;
|
||
- issue закрывается только после успешного beta gate на exact SHA.
|
||
|
||
## 19. Принятые технические предположения (оспоримы ревьюером)
|
||
|
||
1. Stage 1 поставляется в #282; Stages 2–4 получают отдельные issues.
|
||
2. `wall_segments + room.wall_ids` — canonical identity/thickness, а `poly` и
|
||
generated `walls[]` временно остаются compatibility projection до Stage 3.
|
||
3. Independent partitions не переносятся в catalog: их ID уже stable. Draft
|
||
segment IDs добавляются, потому что draft переживает сессию и конвертируется.
|
||
4. `cm:0` нужен для полного catalog, но не открывает пользователю функционал
|
||
#306 и не заменяет `open_spans/open_to` этой задачей.
|
||
5. Migration выполняется lazy on structural write/Optimize, не на read и не на
|
||
device/layout-only save; внутри одной транзакции она всегда получает
|
||
candidate после `canonicalizeConfigGeometry` #291 с его порогом `1e-4` шага.
|
||
6. Старый client может читать projection, но structural writes в v8 fail-closed.
|
||
7. Deterministic hash используется только для первоначальной migration; после
|
||
неё geometry никогда не определяет ID.
|
||
8. Existing wall renderer и junction algorithms получают adapter projection и
|
||
не переписываются до ADR Stage 4.
|