diff --git a/docs/specs/186-partition-opening-jamb-margin.md b/docs/specs/186-partition-opening-jamb-margin.md new file mode 100644 index 00000000..16b8096d --- /dev/null +++ b/docs/specs/186-partition-opening-jamb-margin.md @@ -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 физической толщины. diff --git a/docs/specs/README.md b/docs/specs/README.md index b083da62..2a840489 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -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