docs: specify partition opening jamb margin

Issue: #186
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-19 15:33:44 +03:00
parent fb265282cc
commit 2453ec0d7f
2 changed files with 314 additions and 0 deletions
@@ -0,0 +1,313 @@
# #186 — Безопасный остаток стены у торцов партиционного проёма
Статус: готово к ревью ТЗ
Issue: [#186](https://github.com/Matysh/houseplan-card/issues/186)
Связано: [#132](https://github.com/Matysh/houseplan-card/issues/132)
## 1. Сценарий пользователя
Администратор размещает или перемещает дверь, окно, ворота либо открытый проём
в независимой стене. Редактор оставляет у каждого торца стены физически
правдоподобный участок, поэтому проём не может закончиться вровень с торцом.
## 2. До / после
**До:** frontend считает валидным любой проём, целиком находящийся между
математическими endpoints host. Backend повторяет эту границу с одним лишь
floating-point epsilon. Край проёма можно сохранить прямо на торце стены.
**После:** у обоих торцов host резервируется остаток, равный половине фактической
толщины этой стены. Новое или геометрически изменённое размещение использует одну
границу во frontend и backend. Существующая пограничная запись не исчезает, не
сдвигается сама и может без изменений пройти через постороннее сохранение.
## 3. Решения владельца
1. Jamb safety margin у каждого endpoint равен половине фактической толщины
конкретной partition.
2. Правило одинаково для `door`, `window`, `gate` и `passage`.
3. Между соседними проёмами новый зазор не вводится: остаётся только действующий
запрет пересечения интервалов.
4. Уже сохранённые проёмы, нарушающие новый предел, остаются видимыми и рабочими;
silent clamp и write-on-read запрещены.
5. Новая граница обязательна при создании, перемещении самого проёма, изменении
его длины и перепривязке. Отказ имеет локализованное объяснение.
## 4. Техническая база и единицы
Сейчас `resolvePartitionOpening()` уже принимает `jambMargin`, но default равен
нулю и production call sites ненулевое значение не передают. Placement и drag
ограничивают центр только половиной длины проёма. Backend
`_space_geometry_invariants()` также проверяет лишь попадание внутрь endpoints.
Единый физический контракт:
```text
wallDepth = wallCmToUnits(partition.cm, cellCm, gridPitch)
jambMargin = wallDepth / 2
requiredShoulder = openingLength / 2 + jambMargin
valid = along >= requiredShoulder
&& along <= hostLength - requiredShoulder
```
Равенство границе валидно с общим geometry epsilon. Для backend в хранимых
normalized units та же формула имеет вид
`partition.cm / cell_cm / 240 / 2`; литерал `240` должен быть общей именованной
константой масштаба canvas, а не новым настраиваемым параметром. При отсутствии
`cell_cm` используется действующий default 5 cm.
## 5. В scope
- pure helper/режим проверки jamb margin в `partition-openings`;
- размещение и preview на independent partition;
- drag hosted opening вдоль partition;
- изменение длины в opening dialog и explicit rebind;
- frontend commit validation и отдельная причина `does-not-fit-jamb` либо
эквивалентный typed result;
- backend semantic delta validation для config/set и optimize, а также строгая
проверка импортируемого полного документа;
- совместимое чтение и рендер уже сохранённых нарушающих записей;
- RU/EN сообщение, unit/backend/browser coverage и документация.
## 6. Не в scope
- дополнительный зазор между двумя проёмами;
- изменение формы jamb returns, symbols, tunnel, light cut либо room topology;
- новый persisted field, настройка размера запаса или migration конфига;
- автоматический перенос/уменьшение старого проёма;
- resize partition как новый жест; действующее перемещение segment остаётся
rigid translation;
- полная touch parity редактора.
## 7. Frontend-контракт
### 7.1 Два явных режима resolver
Все consumers `resolvePartitionOpening()` должны осознанно выбрать режим:
- **compat/read:** прежняя проверка `jambMargin=0`; используется для render,
cuts, symbols, hit-test, static/Iso и materialized projection уже принятого
конфига;
- **strict/write:** margin равен половине resolved depth; используется для
нового placement, direct opening drag, rebind и сохранения изменённой
geometry.
Нельзя менять default так, чтобы старый near-end opening стал orphan/fail-dark
только из-за обновления frontend. Именованный helper/policy должен исключить
копирование формулы по call sites.
### 7.2 Размещение
Для partition target resolver:
- считает target eligible, только если
`hostLength >= openingLength + 2 * physicalHalfWidth`;
- после grid/center magnet ограничивает `along` диапазоном
`[openingLength/2 + physicalHalfWidth,
hostLength - openingLength/2 - physicalHalfWidth]`;
- preview, ruler и следующий click используют один resolved candidate;
- room-wall placement сохраняет прежнюю границу без нового jamb margin;
- физически слишком короткая partition не получает preview, а click рядом с
ней показывает новое локализованное сообщение, не generic orphan.
На точной границе preview и click валидны. Размеры ruler измеряют фактические
плечи от края проёма до endpoints и потому показывают минимум, равный margin.
### 7.3 Drag и dialog
Direct drag hosted opening к торцу ограничивает центр тем же strict-диапазоном;
preview не может показать и commit не может сохранить меньше margin. Если host
слишком короток для текущей длины, drag не меняет config.
При сохранении dialog strict rule применяется, когда изменены `host.id`,
`host.t` или `length`, либо создаётся новая запись. Изменение только type,
binding, invert/flip или другого негеометрического поля у legacy near-end
opening разрешает прежнюю geometry без автоматического исправления.
Dialog различает отсутствующий host и недостаточный остаток стены. Для второго
случая показывает status banner и при попытке сохранения сообщение:
- RU: `Оставьте от края проёма до торца стены минимум {distance}`;
- EN: `Leave at least {distance} between the opening and the end of the wall`.
`{distance}` форматируется действующим `formatLength()` в системе единиц HA.
Rebind на слишком короткую partition использует ту же строку. Missing partition
продолжает использовать `opening.partition_orphan`.
### 7.4 Перемещение host
Rigid translation partition сохраняет `t`, length, thickness и относительные
плечи, поэтому не создаёт нового нарушения и совместимо переносит даже legacy
near-end opening. Изменение длины host или его `cm`, способное создать либо
усилить нарушение, должно пройти strict backend boundary; будущий frontend
resize обязан использовать тот же helper.
## 8. Backend и compatibility
Структурная `SPACE_SCHEMA` сохраняет прежний zero-margin fit check, чтобы
полученный ранее near-end config мог быть прочитан и провалидирован перед
semantic delta comparison. Новый jamb contract реализуется в semantic validator
рядом с `validate_partition_opening_hosts()` либо в его расширении.
Для обычного write с `previous`:
- новая hosted opening проверяется strict;
- существующая проверяется strict, если изменились `host.id`, `host.t`,
`length`, длина host либо `cm`;
- неизменённая relative geometry допускается, включая rigid translation обоих
endpoints на один вектор и посторонние изменения config;
- удаление opening вместе с host остаётся валидным;
- существующий downgrade guard удаления `host` сохраняется;
- overlap rule не меняется.
Полный import/restore без trusted previous рассматривает все hosted openings как
новые и проверяет strict. Отказ возвращает стабильный public code
`invalid_partition_opening_jamb_margin` (или расширенный typed reason того же
уровня), включая `space`, `opening` и требуемый margin в diagnostic message.
Backend ничего не нормализует и не переписывает.
## 9. UX-состояния
| Состояние | Результат |
|---|---|
| Новая opening на длинной partition | preview/click clamp оставляют по половине толщины у торцов |
| Partition короче `opening + thickness` | preview отсутствует, click даёт jamb guidance |
| Drag к endpoint | opening останавливается на точной допустимой границе |
| Увеличение длины в dialog нарушает margin | banner + локализованный отказ, config не меняется |
| Legacy near-end opening без geometry edit | видим, интерактивен и losslessly сохраняется |
| Legacy opening получил direct move/length/rebind | новая geometry обязана соответствовать margin |
| Два соседних opening касаются, но не пересекаются | допустимо по прежнему overlap contract |
| Missing partition | прежний orphan/fail-dark contract #132 |
## 10. Модель данных, i18n и доступность
Модель данных и schema полей не меняются. Добавляются синхронные RU/EN ключи для
jamb guidance/banner; один и тот же текст используется мышью и клавиатурным
сохранением. Banner имеет действующий `role="status"`; цвет/preview не является
единственным объяснением отказа.
**Touch editor: best effort / intentionally degraded.** Новых жестов нет.
Обязателен safety floor: tap повторно разрешает candidate, а pinch, второй
pointer и `pointercancel` не сохраняют opening. Hover parity не обещается.
## 11. Критерии приёмки
### AC1 — единая физическая граница
Для partition толщиной `D` каждый endpoint сохраняет плечо не меньше `D/2` для
door/window/gate/passage. Точная граница валидна, значение меньше неё — нет.
Frontend и backend дают одинаковый результат минимум для `cm=1/15/100`, default
и нестандартного `cell_cm`, horizontal/diagonal/reversed host.
**Доказательство:** pure unit matrix + backend parametrized tests с exact
boundary и boundary-minus-epsilon.
### AC2 — placement и drag не создают плохую geometry
Hover/click на partition clamp к strict range; слишком короткий host не создаёт
candidate. Direct drag останавливается на той же границе. Preview, rulers и
сохранённые `host.t/x/y` соответствуют одному resolved center.
**Доказательство:** `opening-placement`/`partition-openings` units + browser
smoke placement и drag у обоих endpoints.
### AC3 — dialog и rebind объясняют отказ
Недопустимое изменение length/host получает jamb-specific RU/EN status/toast с
форматированным физическим расстоянием; missing host остаётся отдельным orphan.
После отказа config/history не изменены. Rebind на валидный host проходит.
**Доказательство:** source/i18n contracts + browser smoke dialog/rebind и
отсутствия config write.
### AC4 — legacy compatibility
Сохранённый near-end opening остаётся видимым и рабочим во всех прежних
consumers, не получает silent clamp и проходит backend round-trip при
постороннем edit или rigid host translation. Direct geometry edit и full import
того же нарушения отклоняются.
**Доказательство:** frontend render/cut regression unit + backend delta/import
tests с previous и без него.
### AC5 — overlap и остальные host не меняются
Между соседними openings не появляется новый gap; пересечение по-прежнему
запрещено. Room-wall opening, orphan policy, cut/symbol/tunnel/light и delete
semantics #132 не меняются.
**Доказательство:** существующие tests #132/#157/#193 + новые negative units.
### AC6 — server parity и error code
`config/set` и optimize применяют один semantic validator. Новый/изменённый
invalid record возвращает стабильный jamb error code, а structural schema не
ломает совместимый unchanged round-trip.
**Доказательство:** backend validator tests и websocket tests обоих write paths.
## 12. План проверок
В implementation loop:
- `npm run typecheck`;
- `npm test`;
- `npm run build`;
- целевые backend tests для validation/websocket.
Перед `S7-code-review`:
- затронутый browser smoke placement/drag/dialog;
- golden capture/verify только если реализация меняет видимые pixels.
Визуальный стиль не должен меняться, поэтому новый baseline не ожидается. Если
появится новый banner в зафиксированной golden-сцене, candidate обязан пройти
обычный Linux review, а не приниматься автоматически. Полные golden, smoke и
performance остаются pre-beta gate по release runbook.
## 13. Риски и производительность
| Риск | Мера |
|---|---|
| Default strict скрывает старые openings | явные compat/read и strict/write call sites |
| Frontend/backend расходятся в масштабе | shared named formula и boundary matrix |
| Structural schema блокирует round-trip раньше delta validator | zero-margin schema + semantic comparison с previous |
| Негеометрическое редактирование требует миграции | сравнивать только relative geometry contract |
| Rigid translation ошибочно считается resize | сравнивать span, `cm`, host identity/t, а не absolute endpoints |
| Room-wall placement получает новый отступ | margin только при `partitionHost` |
| Новый gap появляется между openings | не менять `hostedOpeningIntervalsOverlap` |
Расчёт O(1) на candidate/opening; новых geometry passes, observers, animation и
network calls нет. Backend остаётся O(openings + partitions) на space при index
partition по id. Отдельный performance profile не требуется.
## 14. Откат
Откат удаляет strict jamb policy, semantic delta validator, новые i18n строки и
tests. Persisted schema не менялась, поэтому downgrade/migration не нужны;
поведение возвращается к zero-margin границе.
## 15. Release-артефакты
- user-visible implementation commit обновляет `docs/CHANGELOG.md` и
`docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.md` (либо актуальный раздел Plan editor) фиксирует минимум у
торца независимой стены;
- `docs/CONFIG-COMPATIBILITY.md` описывает delta-validation и tolerant legacy
round-trip без нового поля;
- `docs/TESTING.md` перечисляет unit/backend/smoke proof;
- синхронные `dist`, integration frontend и demo bundle;
- новых screenshots/golden baselines нет, если pixels не меняются;
- issue остаётся открытой до выпуска беты.
## 16. Принятые технические предположения
1. Rigid translation host не является geometry change относительно jamb rule:
относительные shoulders не меняются, поэтому legacy opening переносится без
принудительного ремонта.
2. Изменение `type` без изменения длины не влияет на jamb geometry и само по
себе не включает strict-проверку.
3. Имена helper, typed reason и i18n key не являются публичным API; ревьюер может
изменить их при сохранении AC и стабильного backend error code.
4. Для malformed `cm/cell_cm` сохраняется действующий schema/fail-dark contract;
#186 не вводит fallback физической толщины.
+1
View File
@@ -100,6 +100,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#174](https://github.com/Matysh/houseplan-card/issues/174) Связанный виртуальный источник следует реальному контроллеру | [174-linked-virtual-light-controller.md](174-linked-virtual-light-controller.md) |
| [#178](https://github.com/Matysh/houseplan-card/issues/178) Выбор сущности для действия «Переключить состояние» | [178-toggle-entity.md](178-toggle-entity.md) |
| [#197](https://github.com/Matysh/houseplan-card/issues/197) Один junction-патч не гасит кладку всего плана | [197-junction-patch-fail-dark.md](197-junction-patch-fail-dark.md) |
| [#186](https://github.com/Matysh/houseplan-card/issues/186) Безопасный остаток стены у торцов партиционного проёма | [186-partition-opening-jamb-margin.md](186-partition-opening-jamb-margin.md) |
## P3