From 9e356504702da55fc30ed4e9261e22cd3bed62e7 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Mon, 24 Aug 2026 04:50:51 +0300 Subject: [PATCH] docs: specify safe fixed-topology resize Issue: #277 User-Visible: no --- docs/specs/277-safe-resize.md | 322 ++++++++++++++++++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 323 insertions(+) create mode 100644 docs/specs/277-safe-resize.md diff --git a/docs/specs/277-safe-resize.md b/docs/specs/277-safe-resize.md new file mode 100644 index 00000000..7b6fb00d --- /dev/null +++ b/docs/specs/277-safe-resize.md @@ -0,0 +1,322 @@ +# Issue #277 — безопасный Resize без изменения топологии + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/277 +- **Статус:** первая редакция для ревью; канонический статус задаётся метками issue +- **Тип / приоритет:** bug / P1 +- **Оценка:** пользовательская ценность 10/10; ценность для разработки 10/10; + сложность 9/10; риск 10/10 +- **Область:** Plan Resize, room polygons, shared boundaries, thickness/openings, + live preview, commit/Undo, geometry preflight, i18n и визуальные тесты +- **Модель данных:** schema не меняется; набор room segments остаётся неизменным +- **Связано:** #34, #199, #233, #234, #253, #264, #276, #278, + `docs/RESIZE.md`, `docs/WALL-THICKNESS.md`, `docs/TOUCH-SUPPORT.md` + +## 1. Сценарий и персона + +Домашний администратор хочет немного сдвинуть одну стену готовой комнаты, не +перерисовывая план. Текущий Resize разрешает диагональные рёбра, частичные +shared spans, вставляет вершины соседу и отдельно масштабирует комнату угловой +рамкой. На реальном плане эти широкие преобразования рассинхронизировали room +polygons, thickness records, independent partitions и hosted openings; один +результат перестал показывать всю кладку. + +После исправления Resize делает только доказуемо безопасную операцию: переносит +одну горизонтальную или вертикальную стену параллельно себе, меняет длину её +двух примыкающих стен без изменения толщины и никогда не меняет число сегментов. +Если гарантии перестают выполняться, live preview физически упирается в первую +границу безопасного диапазона либо handle остаётся disabled с понятной причиной. + +## 2. Решения владельца + +Зафиксированы 2026-08-24: + +1. Угловой scale-frame удаляется. Resize больше не масштабирует комнату целиком. +2. Двигаются только строго горизонтальные и вертикальные room walls. +3. Запрещённая стена сохраняет видимый disabled handle; hover/click/tap даёт + короткую человекопонятную причину. +4. Необщая стена меняет положение; две примыкающие перпендикулярные стены + удлиняются/укорачиваются без изменения толщины и числа segments. +5. Shared wall в начале жеста обязана совпадать endpoint-to-endpoint у двух + комнат. Неправильная форма комнаты сама по себе допустима. +6. Shared drag продолжается только пока сохраняется исходное exact + endpoint-to-endpoint соответствие. Перед углом/ступенькой, где следующий шаг + изменил бы длину moving segment или topology, preview упирается и не идёт + дальше. +7. За один жест меняются максимум две комнаты. Третья комната не вовлекается в + каскад: она становится stop/disabled obstacle. +8. Если sliding endpoint дошёл до opening на примыкающей перпендикулярной + стене, движущаяся кладка упирается в ближний косяк и не перекрывает проём. +9. Opening на самой moving wall продолжает ехать с ней, если после операции + полностью помещается. +10. Любой нарушенный инвариант отменяет жест целиком; частичной записи нет. + +Открытых продуктовых вопросов нет. + +## 3. Термины и геометрический допуск + +- **Moving edge** — выбранная сторона polygon комнаты между двумя соседними + topology vertices. +- **Side edges** — предыдущая и следующая стороны того же polygon. +- **Exact shared pair** — ровно два moving edges разных rooms с совпадающими + endpoints независимо от направления. +- **Topology** — количество, циклический порядок и identity correspondence + vertices/segments каждой затронутой комнаты. +- **Safe range** — замкнутый диапазон нормального смещения, в котором все + проверки §5–§7 выполняются. + +«Строго horizontal/vertical» допускает только численный шум хранения меньше +canonical coordinate epsilon. Это не пользовательский angle snap: заметно +наклонённая стена не округляется и остаётся disabled. Все сравнения выполняются +в production render coordinates; шаг сетки определяет итоговую позицию, но не +геометрический epsilon. + +Если математическая граница safe range лежит между grid nodes, commit получает +ближайший grid node внутри диапазона. Если следующий node схлопнул бы side edge +в ноль, стена визуально доходит до угла, но persisted candidate остаётся на +последней положительной длине не меньше действующего minimum segment tolerance. +Нулевая сторона и удаление vertex запрещены. + +## 4. Eligibility и disabled reasons + +Для каждого room edge pure resolver возвращает `enabled + plan` либо один +стабильный reason. Порядок причин детерминирован: + +1. `diagonal` — moving edge не horizontal/vertical; +2. `side-angle` — одна из side edges не перпендикулярна moving edge; +3. `duplicate-physical-wall` — ось перекрывает partition/draft/column; после + канонизации #276 точное redundant совпадение исчезает; +4. `partial-shared` — сосед касается только части moving edge; +5. `unequal-shared` — endpoints/длины пары различаются; +6. `multiple-rooms` — moving edge либо swept side geometry вовлекла бы третью + комнату; +7. `thickness-conflict` — effective thickness пары/side continuation + неоднозначна либо не может быть перенесена lossless; +8. `opening-conflict` — opening на moving edge уже не помещается даже при + нулевом смещении либо host неоднозначен; +9. `invalid-geometry` — исходный polygon/physical candidate не проходит + обязательные structural проверки. + +Enabled non-shared edge принадлежит ровно одной комнате. Enabled shared edge +принадлежит ровно двум комнатам и совпадает endpoint-to-endpoint. У обеих +комнат side edges в каждом конце лежат на одной перпендикулярной оси и имеют +совместимую физическую толщину; короткая ступенька или T-contact не переносится +каскадом, а ограничивает safe range. + +Disabled handle: + +- рисуется тем же размером и hit area, но приглушённо; +- имеет `aria-disabled="true"`, cursor `not-allowed` и локализованный label; +- hover/focus показывает tooltip/hint, click/tap — тот же текст без начала drag; +- не захватывает pointer и не создаёт Undo/save; +- reason не содержит room ids, координат или исключений. + +## 5. Преобразование non-shared wall + +1. Оба endpoint moving edge получают один snapped normal displacement. +2. Два существующих side edges сохраняют свои противоположные endpoints и + меняют только длину. Их оси не меняются. +3. Ни vertex, ни segment не добавляется, не удаляется и не переставляется. +4. Комната сохраняет orientation, positive area, simple polygon и действующий + minimum clearance; foreign rooms/islands/physical extras не пересекаются. +5. Thickness profile каждого из трёх затронутых edges переносится геометрически + и lossless. Не затронутые wall entries byte-equivalent. +6. Ordinary openings moving edge смещаются тем же вектором; openings side edges + остаются на месте и определяют stop §7. +7. Partitions, room drafts, wall columns, hosted openings и остальные комнаты + не меняются. + +Общее число room segments всего пространства до/после одинаково; геометрию +меняет ровно одна room. + +## 6. Преобразование shared wall + +1. Одна geometric moving edge представлена двумя противоположно направленными + polygon edges ровно двух rooms. Обе копии смещаются одним вектором. +2. Четыре side edges меняют длину вдоль своих исходных осей. Никаких новых + vertices, `shiftSharedSpans()`-вставок или `simplifyPoly()`-удалений нет. +3. В каждый момент preview endpoints обеих копий совпадают. Если следующий + delta потребовал бы partial span, смену длины, схлопывание side edge, T-branch + или третью room, delta clamp'ится к последнему safe положению. +4. Площади меняются только у этих двух rooms: одна растёт, другая уменьшается. + Другие room polygons byte-equivalent. +5. Shared thickness/open spans преобразуются один раз. Разные destinations, + conflicting cm либо ambiguous ownership fail closed. +6. Openings moving shared wall смещаются один раз и остаются на той же shared + boundary. Side-wall openings остаются на месте и ограничивают range. + +Если safe range после grid snap содержит только исходную позицию, handle +остаётся visible enabled для объяснимости, но попытка drag даёт stop/no-op и не +создаёт запись. + +## 7. Stops и live clamp + +Один pure range resolver заранее собирает верхнюю/нижнюю границы normal delta. +`pointermove` только clamp'ит snapped delta и проверяет exact candidate. + +Обязательные ограничения: + +1. minimum room clearance/positive area/simple orientation; +2. ближайший topology corner, после которого side edge стал бы нулевым либо + moving edge перестал endpoint-to-endpoint совпадать; +3. foreign/third room, island, partition/draft/column physical body; +4. opening на moving edge должен полностью помещаться после переноса; +5. opening на каждом perpendicular side edge: outer face движущейся стены не + входит в clear slot opening. Центр moving wall останавливается у ближнего + jamb с учётом половины собственной физической толщины; +6. никакое opening не меняет host, длину, angle или unrelated compatibility + fields; +7. canonical wall bodies/floor footprint точного preview не возвращают + structural failure. + +При нескольких stops выбирается ближайший к исходному положению. Clamp +одинаков для обеих сторон drag и не зависит от частоты/порядка pointer events. +Pinch, pan, pointercancel и lostpointercapture отменяют drag, как прежде. + +## 8. Preview, commit и Undo + +Preview строится каждый раз из immutable pre-drag snapshot и содержит точный +candidate целого пространства: rooms, openings, walls/open spans и неизменные +extras. Render не собирает candidate повторно другим путём. + +Перед pointerup commit выполняет единый transaction check: + +- touched room ids равны plan и их не больше двух; +- topology signature каждой room не изменилась; +- unrelated rooms/extras/openings byte-equivalent; +- wall/open-span preservation и destinations согласованы; +- exact production geometry preflight успешен; +- повторное применение результата является no-op. + +Failure отбрасывает overlay, показывает локализованное безопасное сообщение и +делает 0 config writes/Undo entries. Success выполняет ровно один save и одну +именованную Undo-команду. Undo/Redo восстанавливают/повторяют rooms, openings, +walls и open spans целиком. + +## 9. Удаляемое старое поведение + +- corner scale frame и четыре corner handles; +- `applyRoomScale`/`clampRoomScale` из production interaction path; +- drag диагональных edges; +- partial shared span movement; +- вставка vertices соседней комнате и commit-time `simplifyPoly`; +- вовлечение цепочки из трёх и более rooms. + +Pure helpers могут временно оставаться для backward test history, но не должны +быть reachable из bundle. Мёртвый production code удаляется в этой задаче. + +## 10. В скоупе + +- safe eligibility/range/candidate pure geometry; +- Resize controller, handles, tooltip/reason and live measures; +- fixed-topology rekey thickness/open spans and ordinary openings; +- exact commit preflight and zero-write failure; +- removal scale-frame/diagonal/partial-shared behavior; +- unit, production-bundle pointer smoke, mutation, benchmark, targeted golden; +- RU/EN i18n, user/canvas/architecture/testing docs и changelogs. + +## 11. Не входит + +- исправление/канонизация coincident partition (#276); +- generic wall union fail-safe для уже сохранённого bad plan (#278); +- произвольный vertex editor, room scale или diagonal resize replacement; +- изменение толщины, типа стены, room split/merge; +- автопочинка исходно invalid polygon; +- перемещение partitions/drafts/columns; +- touch parity редактора сверх safety floor. + +## 12. Архитектура + +1. Pure geometry/eligibility остаётся в `src/resize.ts` либо меньших pure + модулях. Controller integration согласуется с #264 и не создаёт второй + topology model. +2. Один typed `ResizePlan` содержит room ids, edge indices, fixed topology + signatures, affected wall/opening mapping, safe delta interval и reason. +3. Один `applySafeResize(snapshot, plan, delta)` используется preview и commit; + commit не вычисляет другую геометрию. +4. Thickness/open-span mapping использует canonical interval helpers #253, не + compatibility keys как identity. +5. Structural preflight переиспользует production preparation #199; локальный + simplified polygon-only check не считается достаточным. +6. No-op/failed drag не меняет `_serverCfg`, cfg epoch, history, layout или + queued saves. Mid-drag previous save по-прежнему читает committed snapshot. + +## 13. Производительность + +Eligibility всех handles мемоизируется по exact geometry fingerprint и не +пересчитывает boolean geometry на каждый render. Pointermove p95 на large-house +fixture — не более 16 ms и не более 20% хуже current edge-drag same-run baseline. +Pointerup preflight может быть дороже, но не более 75 ms p95 на fixture; это не +render/state path. Cache ограничен активным пространством и очищается при cfg +epoch/tool exit. + +## 14. Touch и accessibility + +Plan editor остаётся desktop-first. Все handles сохраняют существующую +finger-sized hit area. Disabled reason доступен focus и tap, не только hover. +Pointer capture начинается только для enabled handle. `pointercancel`, +`lostpointercapture`, pinch и app switch не коммитят. View и kiosk не получают +handles и остаются полностью поддержанными. + +## 15. Критерии приёмки + +| AC | Критерий | Доказательство | +|---|---|---| +| AC1 | Только numerically axis-aligned edge eligible; diagonal и non-perpendicular side edges disabled с точным RU/EN reason. | Unit + UI smoke. | +| AC2 | Non-shared drag меняет одну room и длины ровно двух side edges; topology/count/thickness неизменны. | Parameterized unit + invariants. | +| AC3 | Exact shared drag меняет ровно две rooms и сохраняет endpoint-to-endpoint на каждом preview delta. | Unit matrix. | +| AC4 | Partial/unequal/multiple-room/coincident-extra cases disabled; никакие vertices/segments не создаются. | Negative unit + mutation. | +| AC5 | Irregular shared rooms двигаются до первого topology corner; следующий grid step clamp'ится и не схлопывает side edge. | Focused unit + production pointer smoke. | +| AC6 | Side-wall door/window/gate ограничивает delta по физическому jamb; moving body не перекрывает slot с обеих сторон. | Geometry unit + targeted golden. | +| AC7 | Opening на moving edge едет один раз и сохраняет fit/host fields; conflict даёт no-op. | Unit + browser smoke. | +| AC8 | Preview и commit используют один candidate; pointercancel/lost capture/Esc делают 0 writes, pointerup success — 1 write/Undo. | Production-bundle smoke. | +| AC9 | Exact commit preflight ловит wall/floor failure, third-room mutation, thickness loss и topology change до save. | Injectable unit + mutation gate. | +| AC10 | Undo/Redo/reload сохраняют тот же plan, thickness/open spans/openings; repeated save canonical/no-op. | Browser smoke + round-trip unit. | +| AC11 | Corner frame отсутствует, old scale/diagonal/partial-shared handlers unreachable; View/other tools pixel-equivalent. | Source guard + golden. | +| AC12 | Disabled handle доступен mouse/keyboard/touch, reason локализован и pointer не захватывается. | a11y/i18n unit + browser smoke. | +| AC13 | Performance budgets §13 и bounded cache выполняются. | Targeted benchmark/call-count. | +| AC14 | Мутации eligibility bypass, third-room cascade, topology simplify, opening-stop bypass и commit-preflight bypass пойманы 1/1. | Mutation gate. | +| AC15 | Typecheck, unit, build, targeted smokes зелёные; tracked bundles byte-identical. | Fast gates. | +| AC16 | RU/EN changelog и RESIZE/user/canvas/architecture/testing docs описывают суженный контракт. | Docs gate + review. | + +## 16. План тестов + +- переписать `test/resize.test.mjs` под eligibility/safe-range/fixed topology; +- synthetic fixtures: non-shared rectangle/L-room, exact shared regular/irregular, + corner clamp, third room, partition duplicate, all opening types; +- `demo/smoke_safe_resize.mjs`: реальные pointerdown/move/up, disabled reasons, + preview/commit/Undo/Redo/reload и zero-write failures; +- targeted golden light/dark: enabled/disabled handles, corner/opening clamp и + final masonry; +- mutation ids из AC14; +- pointer/preflight benchmark на large-house fixture. + +Полные golden/smoke/performance и Linux HA harness выполняются перед бетой. + +## 17. Release-артефакты и rollback + +Изменение пользовательское. Implementation commit имеет `User-Visible: yes` и +обновляет: + +- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` со ссылкой #277; +- `docs/RESIZE.md` как source of truth; +- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`, `docs/CANVAS.md`; +- `docs/ARCHITECTURE.md`, `docs/TESTING.md`, `docs/STATUS.md`; +- RU/EN i18n, tests/smoke/mutations/benchmark, reviewed golden candidates; +- tracked bundles и docs screenshot fingerprint при затронутом source. + +Rollback — revert implementation commit. Schema/persisted migration нет. +Планы, безопасно сохранённые новым Resize, совместимы со старой версией. После +rollback вернётся широкий нестабильный UI, поэтому release rollback должен быть +полным и сопровождаться предупреждением не использовать Resize до обновления. + +## 18. Принятые технические предположения + +1. Причины disabled имеют стабильный приоритет §4, но внутренние enum names не API. +2. Tooltip использует общий доступный hint primitive; отдельный modal не нужен. +3. Последняя безопасная grid coordinate перед нулевой side edge считается + пользовательским «упором в угол» и не нарушает запрет изменения topology. +4. Thickness compatibility означает одинаковый физический профиль на + совпадающих continuations, а не обязательно одно число для всех четырёх + side edges; конфликт на одном атомарном участке запрещает gesture. +5. #276 реализуется раньше #277; exact redundant partition после Optimize + исчезает, а оставшийся неоднозначный extra продолжает блокировать handle. diff --git a/docs/specs/README.md b/docs/specs/README.md index 8def5629..7931fa51 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -71,6 +71,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#231](https://github.com/Matysh/houseplan-card/issues/231) Декоративный слой виден поверх заливок комнат | [231-decor-layer-order.md](231-decor-layer-order.md) | | [#243](https://github.com/Matysh/houseplan-card/issues/243) Рабочее перетаскивание вкладок и точный указатель вставки | [243-space-tab-drop-target.md](243-space-tab-drop-target.md) | | [#276](https://github.com/Matysh/houseplan-card/issues/276) Совпадающая partition становится одной общей стеной | [276-coincident-partition-reconciliation.md](276-coincident-partition-reconciliation.md) | +| [#277](https://github.com/Matysh/houseplan-card/issues/277) Безопасный Resize без изменения топологии | [277-safe-resize.md](277-safe-resize.md) | ## P2