Files
houseplan-card/docs/specs/157-open-passage.md
T
2026-08-17 16:25:15 +03:00

685 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# #157 — тип проёма «Открытый проём»
- Issue: [#157](https://github.com/Matysh/houseplan-card/issues/157)
- Приоритет: P2
- Ветка: `issue/157-open-passage`
- Статус документа: замечания первого раунда review учтены; issue остаётся в
`S3-spec` по прямому указанию владельца
- Основание: issue владельца от 2026-08-14, аналитика от 2026-08-15 и review от
2026-08-17
## 1. Пользовательская проблема и результат
На реальном плане часто есть арка или обычный открытый дверной проём: стена
сохраняется по сторонам, но полотна двери и связанных с ним устройств нет.
Сейчас такой объект приходится изображать дверью либо виртуально открытым
участком стены. Дверь добавляет ложную створку и предлагает датчик/замок, а
виртуальный участок означает отсутствие кладки и отмечается пунктиром в
редакторе.
После #157 пользователь получает четвёртый тип обычного проёма —
`passage` / «Открытый проём». Он:
- вырезает реальный участок кладки;
- не рисует створку, дугу, пунктир или самостоятельную рамку;
- продолжает пол через существующую геометрию opening tunnel;
- всегда пропускает свет между двумя распознанными комнатами;
- не имеет датчика, замка и параметров створки;
- доступен в существующем потоке размещения и редактирования проёмов.
## 2. Подтверждённая техническая база
На момент подготовки ТЗ:
- `OpeningCfg.type` и backend schema знают только `door`, `window`, `gate`;
- Full card уже вырезает из wall body все сохранённые opening geometry и рисует
room-coloured tunnel fill;
- `renderOpeningVisibleGeometry()` имеет fallback-ветку двери, поэтому простого
расширения union недостаточно: `passage` ошибочно получил бы дверную створку;
- Plan сохраняет невидимый hitbox и hover outline отдельно от видимого символа;
этот механизм можно переиспользовать для выбора `passage`;
- свет уже считает `door`/`gate` внутренним проходом только при наличии пола с
обеих сторон и оставляет внешний проём непрозрачным;
- скрытая изометрия вырезает opening geometry из стены отдельно от построения
полотен, но fallback в `buildIsoOpeningBasis()` также превращает неизвестный
тип в дверь;
- Static card сейчас не рисует opening symbols и не подаёт opening cuts в свою
wall geometry;
- `CONFIG_SCHEMA` сохраняет неизвестные sibling-поля opening благодаря
`extra=vol.ALLOW_EXTRA`, а marker validators уже задают принятый образец
change-aware проверки «старое битое можно прочитать и пронести без изменения,
новое битое записать нельзя»;
- full-card и static-card snapshots сейчас подписываются на `contact`/`lock`
каждого проёма без проверки типа.
## 3. Нормативные продуктовые решения
1. Сохраняемый literal нового типа — только `passage`.
2. Пользовательское название:
- RU: `Открытый проём`;
- EN: `Open passage`.
3. Новый проём размещается тем же инструментом, на тех же физических стенах и
с теми же правилами snap/центрирования/измерений, что дверь.
4. Default ширины нового проёма — **90 см**, как у двери.
5. В покое `passage` не имеет собственного архитектурного символа. Видим только
физический разрыв кладки и пол внутри него.
6. В Plan допускается временный editor chrome: hover/selection outline, hitbox,
центральная точка preview и размерные подсказки. Это не часть итогового
условного обозначения и не показывается в View/Static.
7. `contact`, `lock`, `invert`, `flip_h`, `flip_v` для `passage` неприменимы.
8. При переходе существующего проёма в `passage` сохранение удаляет эти пять
известных полей. Если были непустые `contact` или `lock`, до сохранения
показывается явное предупреждение.
9. Обратный переход из сохранённого `passage` не восстанавливает удалённые
привязки или флаги.
10. Для света `passage` прозрачен только когда проба по обе стороны стены
попадает на пол комнаты. Внешний либо неопределённый край остаётся
fail-dark.
Любой неизвестный будущий тип проёма также считается непрозрачным: переход
от прежнего неявного `type !== 'window'` к явному allowlist — осознанное
ужесточение forward-совместимости ради защиты от утечки света.
11. `passage` отличается от open span наличием кладки по сторонам и физических
откосов. Open span по-прежнему означает отсутствие кладки на всём участке и
показывается пунктиром только в Plan.
12. Публичность скрытого изометрического режима не меняется.
## 4. Scope
### 4.1 Модель и backend
- расширить TypeScript union и backend enum литералом `passage`;
- добавить change-aware semantic validator неприменимых полей;
- подключить его ко всем путям записи и импорта;
- сохранить lossless round-trip незатронутых legacy/future данных.
### 4.2 Plan editor
- добавить `passage` в submenu инструмента проёмов;
- добавить четвёртый radio-вариант в диалог;
- применить default 90 см;
- скрывать binding/flip/invert controls;
- показывать предупреждение о снятии существующих привязок;
- корректно создавать, выбирать, перетаскивать, менять тип и удалять объект;
- сохранять обычный geometry history/undo contract.
### 4.3 Рендер и свет
- Full 2D Plan/View;
- Static card;
- скрытая изометрия;
- общий wall cut и opening tunnel fill;
- Glow/light barrier geometry;
- исключение stale binding-полей `passage` из runtime подписок и UI.
### 4.4 Совместимость и документация
- full/space export-import;
- downgrade note;
- RU/EN i18n;
- пользовательское руководство, LIGHT, ISOMETRIC и CONFIG-COMPATIBILITY;
- unit/backend tests, smoke и golden evidence.
## 5. Non-scope
- моделирование криволинейной арки, верхней перемычки или высоты проёма;
- выбор формы проёма, материала откосов или декоративной обналички;
- датчик присутствия либо иной entity binding для `passage`;
- изменение прозрачности по состоянию сущности;
- публичное включение изометрии;
- общая parity-задача по отображению `door`/`window`/`gate` в Static card;
- автоматическое преобразование существующих дверей или open spans;
- изменение формата open spans;
- изменение существующей семантики дверей, окон и ворот.
В этой задаче «арка» — пользовательское название открытого проёма. Геометрически
это прямоугольный полноразмерный разрыв стены в текущей 2.5D-модели.
## 6. Контракт данных
### 6.1 TypeScript
```ts
export interface OpeningCfg {
id: string;
type: 'door' | 'window' | 'gate' | 'passage';
x: number;
y: number;
angle: number;
length: number;
contact?: string | null;
lock?: string | null;
invert?: boolean;
flip_h?: boolean;
flip_v?: boolean;
}
```
Общие optional-поля остаются в интерфейсе ради совместимости чтения. Для
канонической записи `type: 'passage'` допустимы только `id`, `type`, `x`, `y`,
`angle`, `length` и неизвестные текущей версии extension-поля.
Пример канонической записи:
```json
{
"id": "o-passage-hall",
"type": "passage",
"x": 0.417,
"y": 0.286,
"angle": 90,
"length": 0.09
}
```
### 6.2 Defaults и ограничения
- `lengthCm = 90` при новом размещении;
- существующие ограничения диалога `20..600 см`, step 5 см сохраняются;
- `x`, `y`, `angle`, `length`, id generation и нормализация не меняются;
- версия модели и migration step не добавляются;
- существующие три литерала и их сериализация не меняются.
### 6.3 Канонизация из UI
При сохранении `passage` frontend:
1. записывает актуальные id/type/geometry;
2. удаляет `contact`, `lock`, `invert`, `flip_h`, `flip_v`, а не записывает
`null`/`false`;
3. при редактировании существующего opening сохраняет неизвестные sibling-поля;
4. не меняет другие openings и их порядок.
Явное редактирование `passage` может канонизировать перечисленные известные
неприменимые поля. Несвязанное сохранение не должно переписывать объект.
## 7. Backend validation и broken-read contract
### 7.1 Schema
Inline opening schema в `validation.py` принимает четвёртый enum literal. Поля
`contact`, `lock`, `invert`, `flip_h`, `flip_v` остаются syntactically readable,
чтобы загрузка и round-trip старых/будущих данных не ломались до semantic stage.
### 7.2 Semantic validator
Добавляется чистая функция, например:
```py
validate_opening_passages(
config: dict,
previous: dict | None = None,
*,
validate_all: bool = False,
) -> None
```
Она сопоставляет пространства по `space.id`, openings внутри пространства по
`opening.id` и применяет правила:
| Ситуация | Результат |
|---|---|
| Новый `passage` без запрещённых ключей | принять |
| Смена другого типа на `passage`, запрещённый ключ остался | отклонить |
| В существующем `passage` добавлен или изменён любой запрещённый ключ | отклонить |
| Неизменённый некорректный `passage` проходит с несвязанной правкой | принять без переписывания |
| Из некорректного `passage` удалены запрещённые ключи | принять |
| Full/space import содержит любой запрещённый ключ у `passage` | отклонить |
Запрещённым считается само наличие ключа, включая `contact: null` и
`flip_h: false`: каноническая новая запись не содержит неприменимых полей.
Если старый объект имел другой `type`, смена контекста на `passage` считается
новой семантикой и проверяется полностью, даже когда значение binding-поля
текстово не изменилось.
Стабильный public error code: `invalid_passage_fields`. Технический payload
содержит id пространства, id проёма и отсортированный список запрещённых
полей, но не значения entity ids. Frontend локализует ошибку человекочитаемо:
`У открытого проёма в комнате «<название>» есть недопустимые параметры: …` /
`The open passage in room “<name>” has unsupported settings: …`. Если название
комнаты отсутствует, используется id пространства; id проёма остаётся в
технических деталях, а не является единственным ориентиром в пользовательском
сообщении.
### 7.3 Точки подключения
Одна и та же проверка обязательна для:
- `houseplan/config/set` с предыдущей конфигурацией;
- `houseplan/plan/optimize` с предыдущей конфигурацией;
- merge-import с предыдущей конфигурацией;
- replace/full import с `validate_all=True`;
- space import с `validate_all=True` для входящего/перенумерованного content.
Ошибка преобразуется в существующий websocket/import error contract; partial
write запрещён.
## 8. UX редактора
### 8.1 Палитра и размещение
В submenu «Проём» добавляется пункт:
- label: `Открытый проём` / `Open passage`;
- icon: `mdi:arch`;
- id/type: `passage`;
- default: 90 см, `flipH=false`, `flipV=false`.
Порядок пунктов: окно, дверь, открытый проём, ворота. Hover/click resolver,
physical-wall eligibility, запрет на open span, center magnet, shoulder rulers и
commit flow полностью общие с остальными openings.
Preview нового типа не рисует дверной symbol. Он показывает только существующие
временные элементы размещения: центральную точку, размерные подписи и нейтральный
selection footprint. Пунктир open span и створка не используются.
### 8.2 Диалог
Диалог содержит четвёртый radio option. При `type === 'passage'`:
- поле ширины остаётся;
- contact selector скрыт;
- invert скрыт;
- lock selector скрыт;
- flip horizontal и flip vertical скрыты;
- icon диалога — `mdi:arch`;
- Save и Delete работают как для остальных openings.
Если draft содержит непустой `contact` или `lock`, после выбора `passage`
показывается видимый inline warning:
- RU: `При сохранении датчик открытия и замок будут удалены.`;
- EN: `Saving will remove the open/close sensor and lock.`
Текст может грамматически перечислять только реально заполненные поля, но смысл
и наличие предупреждения обязательны. Warning имеет `role="status"` и не
полагается только на цвет или icon.
Переключение radio само по себе не изменяет config. Пока диалог не сохранён,
скрытые значения остаются в draft: если пользователь вернулся к исходному типу,
поля снова видны. Save с `passage` удаляет их. Отдельный confirm modal не нужен:
явное предупреждение в открытом commit-диалоге удовлетворяет продуктовой
формулировке и не добавляет второй уровень подтверждения.
Если прочитан уже сохранённый некорректный `passage` со stale bindings, диалог
показывает то же предупреждение, а Save канонизирует запись. Cancel ничего не
меняет.
### 8.3 История и взаимодействие
- create/edit/type change/delete используют существующие history labels;
- drag меняет только geometry и остаётся одним history step;
- undo/redo восстанавливает целиком прежний opening, включая type и bindings;
- в Plan невидимый opening выбирается существующим hitbox и показывает hover
outline;
- в View и Static `passage` inert и не открывает info card;
- stale `contact`/`lock` у прочитанного `passage` не создают lock badge,
opening info или live entity subscription.
## 9. Нормативная матрица рендера
| Surface/state | Кладка | Пол/tunnel | Собственный symbol | Editor chrome |
|---|---|---|---|---|
| Full View | разрыв по `length` | есть | нет | нет |
| Full Plan, покой | разрыв по `length` | есть | нет | только при hover/selection |
| Full Plan, placement | будущая позиция читается по preview/размерам | preview не коммитит geometry | нет створки/пунктира | да |
| Static card | разрыв по `length` | есть | нет | нет |
| Скрытая изометрия | полноразмерный вертикальный разрыв, видимые откосы | существующий floor | нет полотна и его тени | нет |
### 9.1 Full 2D
- `renderOpeningVisibleGeometry()` обязан иметь явную ветку `passage`,
возвращающую пустую visible geometry; fallback неизвестного типа не должен
превращать его в дверь.
- `openingVisibleMetrics()` продолжает выдавать размеры hitbox/outline по длине
и толщине стены.
- wall cut и opening tunnel используют существующий общий pipeline без отдельной
декоративной рамки.
- `openingAmount('passage', ...)` всегда возвращает `1` и не читает contact.
- lock badge разрешён только для `door` и `gate`, а не по условию
`type !== 'window'`.
- `hide_openings` скрывает symbols старых типов, но не заделывает физический
разрыв `passage`.
### 9.2 Static card
Static должен показать новый тип, но #157 не меняет исторический визуал трёх
старых типов. Поэтому static wall fingerprint и wall builder получают только
нормализованные cuts с `type === 'passage'`; door/window/gate по-прежнему не
добавляют Static symbols/cuts в рамках этой задачи.
Для плана без `passage` output Static и cache fingerprint остаются прежними. При
наличии `passage` canonical wall geometry и tunnel/floor layer должны дать
чистый разрыв и пол, в том числе при default wall thickness. Никаких HTML
hit-targets или entity subscriptions для passage Static не создаёт.
### 9.3 Изометрия
- `IsoOpeningType` расширяется `passage`;
- structural basis для `passage` содержит пустой список leaves;
- opening остаётся во входных cuts wall boolean geometry;
- projection и bounds безопасно обрабатывают пустой basis;
- не рисуются panel, leaf shadow и live-state animation;
- откосы являются частью общей extrusion geometry стены;
- высота/форма арки не моделируются: cut идёт на полную текущую высоту стены.
## 10. Свет и Glow
Классификация проходов должна стать явной, а не зависеть от условия
`type !== 'window'`:
- `door`, `gate`, `passage` — кандидаты interior passage;
- `window` — не passage;
- любой неизвестный тип — opaque/fail-dark и не попадает в cuts, пока новый
literal не будет явно классифицирован и покрыт тестом;
- каждый кандидат проходит одинаковую проверку пола по обе стороны;
- только прошедшие кандидаты вычитаются из light masonry и добавляются в cuts;
- `passage` не зависит от HA state, `contact` и `invert`, даже если эти поля
присутствуют в старой битой записи;
- наружный `passage`, passage у пустой стороны или на нераспознанной геометрии
остаётся непрозрачным для light;
- sun/window rays по-прежнему используют только windows;
- ширина прозрачного участка и Glow footprint совпадают с открытой дверью той
же geometry при одинаковом floor test.
Fingerprint/cache key света должен зависеть от geometry passage так же, как от
других interior passages. Изменение type door↔passage при неизменной geometry не
обязано перестраивать barriers, если итоговая классификация та же; correctness
важнее этой оптимизации.
## 11. Import, export и совместимость
### 11.1 Текущая версия
- full export и space export сохраняют literal `passage` без remap;
- import remap меняет id пространства/opening по существующим правилам, но не
type и geometry;
- valid passage проходит full/space import;
- passage с запрещённым ключом отклоняется до commit;
- неизвестные sibling-поля сохраняются backend schema и при несвязанном
round-trip;
- оптимизация не удаляет и не превращает passage.
### 11.2 Старые конфигурации
Конфигурация без `passage` не мигрируется, не переписывается и не меняет
визуальное или runtime-поведение. Нового top-level capability flag не требуется.
### 11.3 Downgrade
Перед реализацией текущий pre-feature frontend фактически попадает для
неизвестного opening type в дверную fallback-ветку. Поэтому ожидаемый
best-effort downgrade для frontend v1.64.0:
- загрузка и рендер не падают;
- passage может выглядеть как дверь, то есть визуальная точность не гарантируется;
- старый диалог не знает radio option и не должен считаться поддерживаемым
редактором этого типа;
- старый backend со строгим enum может отклонить последующую запись всей
конфигурации, содержащей `passage`.
Эти ограничения явно записываются в `docs/CONFIG-COMPATIBILITY.md`. Реализация
обязана исполнением загрузить fixture с `passage` в собранный из git tag
`v1.64.0` bundle через demo harness и подтвердить отсутствие необработанного
исключения в консоли. Source-аудит не считается достаточным доказательством;
поддержка редактирования на старой версии не требуется.
## 12. I18n, accessibility и touch
Минимальный набор новых RU/EN ключей:
- `opening.passage`;
- `opening.passage_binding_warning`;
- при необходимости отдельный help/description key для отличия от open span.
Требования:
- RU/EN key parity test проходит;
- radio имеет обычную label association и доступен с клавиатуры;
- warning читается screen reader и не кодируется одним цветом;
- toolbar item получает локализованный accessible label через существующий
menu contract;
- новый тип не уменьшает существующие touch targets;
- невидимый passage hitbox в Plan сохраняет существующий минимум; во View его
pointer events отключены по общему contract openings.
## 13. Performance, security и observability
- отдельного runtime animation/state resolver для `passage` нет;
- stale bindings не попадают в full/static snapshot entity sets;
- wall, light и iso caches используют существующую geometry/fingerprint
архитектуру; новый unbounded cache не добавляется;
- change-aware validator работает линейно по spaces/openings с map по id;
- error не логирует entity ids и не раскрывает больше существующей config API;
- права websocket/import не меняются;
- отдельного benchmark profile не требуется; перед бетой проходит канонический
performance gate всего продукта.
## 14. Acceptance criteria
1. В toolbar и opening dialog доступен `Открытый проём` / `Open passage` с
default 90 см.
2. Для passage показывается только ширина; contact, lock, invert, flip_h,
flip_v скрыты и не записываются.
3. При смене объекта с непустым contact/lock на passage до Save виден явный
warning; Cancel сохраняет исходник, Save удаляет bindings, обратная смена
после Save их не восстанавливает.
4. Full View/Plan показывает чистый физический разрыв стены и продолжение пола,
без створки, дуги, пунктира или рамки.
5. Passage остаётся выбираемым, перемещаемым и удаляемым в Plan через временный
editor chrome, но inert в View/Static.
6. Static card показывает passage как разрыв с полом; план без passage и старые
opening types сохраняют прежний Static output.
7. Скрытая изометрия показывает полноразмерный cut и откосы, без leaf/panel
shadow и без crash.
8. Внутренний passage пропускает light/Glow независимо от state; наружный,
неопределённый passage и неизвестный будущий тип остаются fail-dark.
9. Backend принимает канонический passage и отклоняет новую/изменённую запись с
любым из пяти запрещённых ключей кодом `invalid_passage_fields`.
10. Неизменённый legacy-broken passage проходит несвязанное сохранение; его
исправление принимается; import валидирует весь входящий content.
11. Full/space export-import сохраняют type/geometry и обычный id remap; partial
write при ошибке отсутствует.
12. Конфиги с door/window/gate не мигрируют и проходят regression suite без
изменений.
13. Demo harness с реально собранным bundle `v1.64.0` загружает fixture с
passage без необработанного исключения в консоли; точный fallback и
ограничение старого backend задокументированы.
14. В `scripts/mutation-gate.mjs` зарегистрированы и проходят пять обязательных
executable mutants из §15.6; `--check` подтверждает уникальность их якорей.
15. RU/EN parity, unit/backend tests, build, smoke и новые golden evidence
проходят в предусмотренный процессом момент.
## 15. Тест-план
### 15.1 TypeScript unit
- `OpeningPlacementType`/preset: passage, 90 см, false flips;
- placement на physical wall, запрет на open span, center magnet и measurements
не расходятся с door;
- `openingAmount('passage', null/on/off/unavailable, invert)` всегда 1;
- `renderOpeningVisibleGeometry(passage)` не содержит leaf/arc/glass/gate path;
- metrics/hitbox остаются конечными и положительными;
- full renderer не создаёт lock badge/info/binding subscription для stale
passage fields;
- light: две комнаты пропускают, внешний край не пропускает, footprint равен
door при одинаковой geometry; искусственный тип `xyz` не проходит explicit
allowlist и остаётся opaque;
- Static fingerprint/output меняется для passage и не меняется для старых типов;
- iso basis passage имеет zero leaves, projection/bounds безопасны;
- RU/EN parity.
### 15.2 Backend unit
- schema принимает canonical passage и прежние три types;
- новый/сменивший тип passage с каждым запрещённым ключом, включая null/false,
отклоняется;
- одновременный список полей даёт стабильный отсортированный error;
- неизменённый broken passage + unrelated config edit принимается;
- изменение geometry при неизменных broken fields принимается как несвязанное с
этими полями изменение;
- удаление broken fields принимается;
- изменение/добавление broken field отклоняется;
- config/set и optimize вызывают validator;
- merge, full replace и space import покрыты success/failure/atomicity cases;
- valid export-import round-trip сохраняет type и geometry.
### 15.3 Smoke/manual
В реализации, а перед бетой — каноническим smoke gate:
1. создать passage через toolbar на общей толстой стене;
2. убедиться в preview без створки и в default 90 см;
3. сохранить, hover/select, перетащить, undo/redo, удалить;
4. создать дверь с contact+lock, выбрать passage, увидеть warning, Cancel;
5. повторить и Save; убедиться, что bindings удалены и не восстановились;
6. проверить Full View и Static в light/dark themes;
7. включить Glow: внутренний проход светится насквозь, наружный — нет;
8. проверить скрытую изометрию;
9. full и space export/import;
10. checkout tag `v1.64.0`, собрать его bundle, загрузить fixture с `passage`
через demo harness и автоматически подтвердить отсутствие `pageerror` и
необработанных console errors; приложить команду и лог к evidence.
### 15.4 Golden
Добавить детерминированную сцену с двумя комнатами, толстой общей стеной,
внутренним passage и источником Glow:
- Full 2D, dark theme;
- Static, dark theme;
- скрытая изометрия, dark theme;
- при необходимости отдельный exterior fail-dark кадр.
Новые baselines предпочтительнее переписывания несвязанных эталонов. Любое
изменение существующего `smoke_opening_*` baseline требует объяснения в PR/code
review evidence.
### 15.5 Команды и момент запуска
В цикле реализации:
```text
npm run typecheck
npm test
npm run build
python -m pytest tests_backend
```
Golden, browser smoke и performance выполняются перед бетой согласно release
runbook. Полный HA harness канонически выполняется в Linux CI; невозможность
локального Windows `fcntl` не считается заменой CI.
### 15.6 Исполняемый мутационный гейт
Реализация добавляет в `scripts/mutation-gate.mjs` пять реальных entries. Каждый
entry содержит стабильный id, патч `find`/`replace` на 2–5 строк с ровно одним
якорем, указанный guard и причину ожидаемого падения. Это не список для ручной
проверки: runner обязан применить патч в отдельном worktree, собрать bundle,
получить non-zero от guard и восстановить чистое состояние.
| Mutant id | Обязательный патч | Guard |
|---|---|---|
| `passage-visible-geometry-door-fallback` | удалить/обойти явную `passage`-ветку в `renderOpeningVisibleGeometry()`, чтобы снова сработал дверной fallback | `node --test test/opening-symbol.test.mjs` — отрицательный passage-case обязан обнаружить leaf/arc/gate path |
| `passage-light-classifier-removed` | исключить `passage` из явного allowlist interior passages | `node --test test/light-visibility.test.mjs` — внутренний passage перестаёт пропускать свет |
| `passage-import-validator-bypassed` | отключить вызов `validate_opening_passages` на import-пути | `node scripts/backend-test-guard.mjs invalid_passage_import` — forged full/space import с запрещённым полем обязан быть отклонён |
| `passage-static-door-cut-leak` | расширить passage-only Static cuts/fingerprint до `door`, вернув изменение старого плана | `node --test test/space-geometry.test.mjs` — fixed план без passage обязан сохранить прежние geometry и fingerprint |
| `passage-iso-door-fallback` | вернуть `passage` в дверную fallback-ветку `buildIsoOpeningBasis()` | `node --test test/iso-openings.test.mjs` — passage basis обязан иметь zero leaves |
Обязательные команды перед передачей реализации в code review:
```text
node scripts/mutation-gate.mjs --check
node scripts/mutation-gate.mjs --id=passage-visible-geometry-door-fallback
node scripts/mutation-gate.mjs --id=passage-light-classifier-removed
node scripts/mutation-gate.mjs --id=passage-import-validator-bypassed
node scripts/mutation-gate.mjs --id=passage-static-door-cut-leak
node scripts/mutation-gate.mjs --id=passage-iso-door-fallback
```
Каждый чистый guard сначала должен быть зелёным, а лог мутанта — содержать его
id, guard и ожидаемый non-zero. Ручное редактирование без runner или только
`--list`/`--check` acceptance criterion не выполняет.
## 16. План реализации
1. Расширить types/schema и добавить чистый semantic validator со всеми call
sites и backend tests.
2. Расширить placement preset, toolbar, dialog draft/save canonicalization,
warning и i18n.
3. Добавить явные passage branches в 2D symbol/amount/locks/subscriptions.
4. Подключить passage cut/tunnel к Static без изменения output старых типов.
5. Сделать light classifier явным и покрыть interior/exterior cases.
6. Добавить zero-leaf passage basis в скрытой изометрии.
7. Покрыть import/export и исполняемый downgrade fixture на bundle `v1.64.0`.
8. Зарегистрировать и исполнить пять mutation entries из §15.6.
9. Обновить документацию и release artifacts, пройти implementation gates.
## 17. Release-артефакты
При реализации #157 в том же user-visible commit обязательны:
- `docs/CHANGELOG.md`;
- `docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.ru.md` — таблица passage/open span, поток редактора и явное
пояснение принятой асимметрии Static: passage разрывает стену, а старые
door/window/gate в рамках #157 сохраняют прежний Static-визуал;
- `docs/LIGHT.md` — explicit interior-passage rule;
- `docs/ISOMETRIC.md` — zero-leaf/full-height-cut поведение скрытого режима;
- `docs/CONFIG-COMPATIBILITY.md` — enum, broken-read validator и downgrade;
- при необходимости `docs/TESTING.md` — новая smoke/golden сцена;
- новые reviewed golden baselines и ссылка на evidence в issue/PR.
Коммит продуктового кода должен иметь терминальные trailers:
```text
Issue: #157
User-Visible: yes
```
## 18. Риски и меры
| Риск | Мера |
|---|---|
| Fallback рисует passage как дверь | явные branches и отрицательные renderer tests |
| Невидимый объект нельзя выбрать | сохранить отдельные metrics/hitbox/hover tests |
| Stale binding продолжает влиять на UI/runtime | type allowlist для locks/info/subscriptions, tests с broken fixture |
| Строгая схема блокирует несвязанные сохранения | change-aware semantic validator вместо cross-field schema reject |
| Import обходит правило | подключить validator к обоим import flows и optimize |
| Static меняет старые планы | fingerprint/cuts только для passage, regression snapshots без passage |
| Свет утекает наружу | сохранить двусторонний floor probe и exterior test |
| Новый неизвестный opening type неявно пропускает свет | explicit allowlist, fail-dark contract и unit с типом `xyz` |
| Iso создаёт дверное полотно | zero-leaf basis и golden |
| Старый frontend портит новый тип | документировать downgrade как read-only best effort |
## 19. Откат
Код можно откатить обычным revert, но уже сохранённый literal `passage` станет
неизвестен старому backend. Поэтому безопасный rollback релиза требует либо
предварительно вернуть такие openings к `door`/`gate`, либо сохранить frontend и
backend read support для `passage`, отключив только создание в UI. Автоматически
превращать passage в дверь при rollback запрещено: это создаёт ложный symbol и
может предложить небезопасные lock bindings.
## 20. Принятые технические предположения
Следующие мелкие решения приняты без дополнительного продуктового вопроса и
могут быть изменены при реализации, если не нарушаются acceptance criteria:
- `mdi:arch` не поставляется самим card и разрешается runtime-набором MDI в HA;
до реализации его наличие проверяется в поддерживаемой минимальной версии HA
через demo/browser fixture. Если glyph отсутствует, нормативный fallback —
широко поддерживаемый `mdi:door-open`, без изменения persisted contract;
- порядок passage в submenu нормативно задан единожды в §8.1;
- inline warning достаточен, отдельный confirm modal не нужен;
- draft временно хранит скрытые bindings, чтобы смена radio до Save была
обратимой;
- unknown sibling-поля сохраняются при явном edit, запрещённые известные поля
удаляются;
- наличие запрещённого ключа, даже с null/false, неканонично для новой записи;
- Static получает только passage cuts, чтобы не расширять #157 до общей opening
parity;
- «арка» в этой версии — полноразмерный прямоугольный cut без кривой перемычки;
- отдельный performance benchmark не нужен, если общий pre-beta gate не
показывает регрессию.