diff --git a/docs/specs/253-resize-wall-thickness.md b/docs/specs/253-resize-wall-thickness.md new file mode 100644 index 00000000..2f8d6b95 --- /dev/null +++ b/docs/specs/253-resize-wall-thickness.md @@ -0,0 +1,400 @@ +# Issue #253 — Resize не теряет интервалы толщины стен + +- Дата: 2026-08-23 +- Тип: bug · приоритет P2 +- Оценка: пользовательская ценность 9/10 · ценность для разработки 8/10 · сложность 7/10 · риск 8/10 +- Issue: [#253](https://github.com/Matysh/houseplan-card/issues/253) +- Связанные задачи: [#201](https://github.com/Matysh/houseplan-card/issues/201), + [#229](https://github.com/Matysh/houseplan-card/issues/229), + [#233](https://github.com/Matysh/houseplan-card/issues/233), + [#254](https://github.com/Matysh/houseplan-card/issues/254) +- Ветка: `issue/253-resize-wall-thickness` +- Статус ТЗ: на ревью + +Канонические документы: `docs/SCOPE.md`, `docs/WALL-THICKNESS.md`, +`docs/ARCHITECTURE.md`, `docs/CANVAS.md`, `docs/CONFIG-COMPATIBILITY.md`, +`docs/UX-MODES.md`, `docs/TOUCH-SUPPORT.md`, `docs/USER-GUIDE.md`, +`docs/USER-GUIDE.ru.md`, `docs/TESTING.md`. + +## 1. Сценарий и персона + +Администратор дома уточняет готовый план в desktop Plan editor. Он выбирает +Resize и двигает стену одной комнаты — например, верхнюю стену сауны. Эта стена +является только частью более длинной физической стены, которая продолжается +вдоль соседних комнат и уже имеет настроенную толщину. + +Пользователь ожидает, что перемещённая часть кладки последует за комнатой, а +соседний неперемещённый участок останется на месте. Это сценарий J6 из +`docs/SCOPE.md`: план должен оставаться правдивым по мере изменения дома. + +Точная работа Resize является desktop-first. Общий safety floor для всех +способов ввода остаётся сильнее: pointer cancel, Esc или промежуточный preview +не могут записать либо потерять данные. + +## 2. Что человек увидит до и после + +**До:** после движения стены часть толстой стены превращается в тонкую осевую +линию; иногда исчезает и продолжение той же кладки у соседней комнаты, а около +стыков или проёмов остаются отдельные штрихованные обрезки. + +**После:** перемещённая часть стены сохраняет прежнюю толщину и следует за +комнатой, неперемещённое продолжение остаётся на прежнем месте; ни одна другая +стена не истончается и проём остаётся в своей стене. + +## 3. Проблема и подтверждённый диагноз + +`_rszApplyPreview()` на каждом pointer move восстанавливает immutable snapshot, +формирует параллельные `oldSpans/newSpans` всех изменённых комнат и вызывает +`rekeyWallsAfterMove()` для сохранённых `space.walls`. + +Текущая функция переносит запись с точными `a/b`, только если оба конца записи +лежат в допуске одного старого polygon edge. Запись 33 см из пользовательского +экспорта идёт по `y=0.4375` от `x=0.0708` до `x=0.4208`, а перемещаемое ребро +сауны заканчивается на `x=0.2042`. Поэтому запись пересекает ребро лишь частично, +не сопоставляется и не разрезается. + +После этого ей оставляется старый compatibility key. Если такой key уже занял +другой результат, `if (used.has(nk)) continue` удаляет запись без проверки +точных концов и толщины. На реальном жесте число записей уменьшается с 24 до 23, +а единственное значение 33 см исчезает целиком. Следовательно, дефект находится +в persisted wall model, а не только в SVG, допуске или проёме. + +Нарушены два действующих инварианта: + +1. единица толщины — атомарный физический интервал, а не целое ребро polygon; +2. Resize переносит точные endpoints и re-key всех затронутых интервалов в одной + транзакции без тихой потери пользовательских данных. + +## 4. Цели + +1. Сделать перенос `space.walls` lossless при полном и частичном пересечении с + перемещаемыми рёбрами. +2. Оставлять неперемещённые части длинной стены на прежнем носителе. +3. Исключить удаление разных записей только из-за одинакового округлённого key. +4. Сохранить существующие Resize preview, opening, Undo/Redo и storage contracts. +5. Закрепить реальный дефект числовым production-bundle smoke и мутационным + гейтом. + +## 5. Скоуп + +### Входит + +- lossless transform точных wall intervals по парам `oldSpans/newSpans`; +- разрез сохранённого интервала во всех точках начала/конца частичных + коллинеарных пересечений; +- перенос только покрытых частей и сохранение непокрытых остатков; +- дедупликация только геометрически одинаковых результатов одной толщины; +- deterministic key, ordering и canonical exact endpoints результата; +- edge drag и corner scale через общий helper; +- общие стены нескольких комнат, разные значения толщины и расщеплённые + интервалы; +- сценарий с проёмом на перемещаемой части; +- unit, model invariant, mutation guard, production-bundle smoke; +- документация и changelog RU/EN. + +### Не входит + +- изменение UX, handles, snap, внутренних размерных подписей или минимальных + размеров Resize; +- новое редактирование толщины, автоматический выбор толщины либо переработка + инструмента «Толщина»; +- восстановление стены, уже потерянной старой версией, без исходного экспорта; +- фоновая миграция или Optimize pass для исторически повреждённых планов; +- изменение схемы `space.walls`, Store/model version или backend validation; +- изменение `open_spans`, правила привязки/размещения проёмов и jamb margin; +- изменение геометрии стыков, штриховки или внутренней площади; +- публикация нового предупреждения пользователю либо диагностического экрана; +- полная переработка room-resize planner из `src/resize.ts`. + +## 6. Контракт интервалов + +### 6.1 Источник истины + +Для современной записи `WallEntry.a/b` являются точной геометрией; +`WallEntry.key` — совместимый производный индекс. Key не является достаточным +доказательством идентичности двух интервалов. + +Запись без валидных точных `a/b` сохраняет текущий compatibility-путь: exact +whole-edge key map, затем midpoint projection. Задача не изобретает длину +legacy-записи, которой в данных нет, но и не удаляет её при коллизии key. + +Толщина `cm` каждой выходной части равна толщине исходной записи после +существующего clamp. Resize никогда не интерполирует и не выбирает толщину. + +### 6.2 Разбиение точной записи + +Каждая точная исходная запись рассматривается независимо и проходит один +immutable transform: + +1. найти старые spans, коллинеарные записи в действующем угловом допуске; +2. вычислить точные одномерные пересечения записи с каждым span; +3. добавить границы всех непустых пересечений в partition исходного интервала; +4. для каждого полученного ненулевого fragment определить покрывающие его + преобразования по midpoint; +5. если fragment покрыт согласованным moved span, перенести оба endpoint по + линейному параметру `t` из соответствующего `oldSpan` в `newSpan`; +6. если fragment не покрыт ни одним moved span, сохранить endpoint без изменений. + +Преобразование применяется к исходной записи один раз; выход предыдущего +fragment/записи не становится входом следующего. Это запрещает накопление +ошибки и повторный перенос общей стены, встреченной в рёбрах двух комнат. + +Касание только одной точкой имеет нулевую длину и не создаёт fragment. Очень +короткий ненулевой результат подчиняется тем же epsilon/canonical rules, что и +действующие wall intervals; он не удаляется лишь из-за визуального размера. + +### 6.3 Несколько преобразований общей стены + +Один fragment может быть покрыт старым ребром обеих соседних комнат. Если их +преобразования дают одинаковые endpoint в действующем coordinate epsilon, это +одно преобразование и один результат. + +Если корректный Resize planner когда-либо передаст для одной физической части +разные назначения, helper работает fail-closed: не выбирает результат по +порядку массива, не теряет fragment и возвращает для него исходную геометрию. +Такой случай обязан быть отдельной явно красной unit-диагностикой; текущие +валидные edge drag и corner scale не должны его создавать. + +### 6.4 Канонизация и дедупликация + +Каждый точный результат строится через единый wall-entry constructor: canonical +ориентация `a/b`, clamp `cm`, key от фактических концов с текущими +`pitch/coordScale`. Nine-decimal storage canonicalization остаётся на общей +границе записи и не подменяет геометрический epsilon helper. + +Результаты объединяются только если одновременно совпадают: + +- canonical exact `a/b` в coordinate epsilon; +- `cm` после clamp. + +Совпадение одного `key` не является условием удаления. Если разные exact spans +имеют одинаковый key, обе записи сохраняются. Если exact span совпал, но `cm` +различен, ни одно значение молча не выигрывает: конфликт остаётся видимым для +инвариантов/теста и не разрешается порядком входа. + +Порядок результата deterministic: порядок исходных записей, затем fragments +вдоль canonical source interval. Повторный вызов с тем же snapshot и spans +возвращает deep-equal результат. + +## 7. Контракт Resize + +### 7.1 Preview, commit, cancel + +`_rszApplyPreview()` продолжает каждый раз читать immutable pre-drag snapshot. +Новые интервалы живут только в `_rszPreview`; `_serverCfg` не меняется на +pointer move. Pointerup фиксирует весь resize одной существующей командой +history, а Esc/pointercancel возвращает прежние rooms, openings, walls и +open_spans без storage write. + +Если polygons не изменились, wall list остаётся semantic deep-equal. Resize не +создаёт write только из-за нового порядка либо округления записей. + +### 7.2 Проёмы и виртуальные части + +`rekeyOpenSpansAfterMove()` сохраняет свой существующий контракт и порядок +вызова. Проём на перемещённом участке получает новые координаты через текущий +resize planner и остаётся на оси этой стены. Wall body вокруг opening cut +строится из перенесённых интервалов и не получает разрывов кроме самого проёма. + +Неперемещённый остаток длинной стены сохраняет свои проёмы/виртуальные части и +не следует за другой комнатой. Эта задача не переассоциирует opening между +room wall и independent partition. + +### 7.3 Реальный контрольный сценарий + +Для комнаты `room_rmr649led_dc20a18c` «Сауна» при движении верхнего ребра вниз +на шесть шагов 5-см сетки: + +- число wall records после полного жеста остаётся **24**, а не 23; +- 33 см есть на новом `y=0.4625`, `x=0.0708…0.2042`; +- 33 см остаётся на старом `y=0.4375`, `x=0.2042…0.4208`; +- вертикали 29 и 20 см заканчиваются на `y=0.4625` как в текущей корректной + части поведения; +- ни одно значение `cm`, представленное до операции, не исчезает полностью; +- reload показывает ту же кладку, Undo возвращает исходные 24 записи, Redo — + тот же исправленный результат. + +## 8. UX, accessibility и touch + +Новых контролов, сообщений и фокусируемых элементов нет. Мышь, keyboard Escape +и существующие touch/pointer события используют прежние handlers. + +Визуальная приёмка проверяет не только наличие штриховки: body стены, её ось, +внутренняя площадь и проём должны совпадать с новой physical geometry. На +масштабах 1 см/точку и legacy 5 см/точку исправление одинаково по физическому +смыслу. + +Pan, pinch и pointercancel не завершают жест и не сохраняют preview. Никакой +hover-only информации для понимания результата не требуется. + +## 9. Модель данных, compatibility и миграция + +Схема остаётся прежней: + +```ts +space.walls: Array<{ key: string; cm: number; a?: [number, number]; b?: [number, number] }> +``` + +Новых полей, model/store version и backend API нет. Existing exact records +получают lossless поведение при следующем Resize. Legacy key-only records +остаются читаемыми и не переписываются фоном. + +Исправление не выполняет read-time migration и не меняет untouched storage. +После завершённого Resize затронутые exact entries записываются обычным текущим +путём и проходят общий nine-decimal canonical writer. Старый frontend сможет +прочитать результат как обычные atomic wall records. + +Уже повреждённый план автоматически не восстанавливается: отсутствующее `cm` +нельзя достоверно вывести. Пользователь может вернуть экспорт/backup или снова +задать толщину вручную. + +## 10. i18n и документация + +Новых строк интерфейса нет. RU/EN user guides должны одинаково утверждать, что +при частичном совпадении Resize переносит перемещённую часть толщины и сохраняет +остаток. `docs/WALL-THICKNESS.md` фиксирует lossless partition/dedup contract, +а `docs/ARCHITECTURE.md` — роль exact endpoints и compatibility key. + +Оба changelog получают один пользовательский bugfix bullet со ссылкой #253 в +том же коммите, что и продуктовый код (`User-Visible: yes`). Внутренние имена +helper, key и wall records в changelog не используются. + +## 11. Критерии приёмки + +| AC | Требование | Доказательство | +|---|---|---| +| AC1 | Реальный сценарий §7.3 после edge drag оставляет 24 записи и точные две части стены 33 см на старом/новом `y`; вертикали 29/20 см движутся как раньше | production-bundle `demo/smoke_resize_wall_thickness.mjs` на минимизированной экспортной fixture + числовые assertions | +| AC2 | Точный interval при частичном коллинеарном overlap разрезается по обеим границам; покрытые fragments линейно переносятся, непокрытые сохраняются | `test/wall-thickness.test.mjs`: horizontal/vertical/reversed/diagonal table | +| AC3 | Whole-edge move, key-only legacy move и уже существующие partial-virtual tests остаются зелёными | existing + extended `wall-thickness` unit tests | +| AC4 | Два согласованных moved spans общей стены не переносят fragment дважды; conflicting transforms не выбираются по порядку и не теряют запись | permutation unit tests + mutation guard | +| AC5 | Одинаковый key у разных exact intervals не удаляет ни один; exact same geometry+cm объединяется; same geometry с разным cm не решается молча | collision/dedup unit matrix и мутант, возвращающий `used.has(key) → continue` | +| AC6 | Сценарий с opening на перемещаемой части переносит opening и оставляет непрерывное тело кладки вокруг cut; остаток стены и его geometry остаются на месте | production-bundle smoke с DOM/path + model assertions | +| AC7 | Preview не меняет `_serverCfg`; Esc/pointercancel не пишут; pointerup — одна history-команда; Undo/Redo/reload воспроизводят exact before/after | browser smoke через реальные handlers и write-spy | +| AC8 | Edge drag, corner scale, две смежные комнаты, Г-образная комната, split thickness и scale 1/5 см не теряют представленное значение `cm` | pure table + model invariant gate на before/after pairs | +| AC9 | Persisted schema/version/backend неизменны; повторный preview одного snapshot deep-equal и clean no-op не получает лишний write | compatibility/unit assertions + config round-trip | +| AC10 | RU/EN docs и оба changelog описывают пользовательский результат; bundles синхронны | `check-docs`, i18n/docs tests, bundle SHA comparison, process/provenance gates | + +## 12. План автотестов и гейтов + +### 12.1 Unit + +В `test/wall-thickness.test.mjs` добавить табличные случаи: + +- полный перенос exact interval; +- overlap в начале, конце и середине с двумя/тремя fragments; +- reversed endpoints, vertical и diagonal spans; +- несколько adjacent/equivalent old spans; +- fragment вне moved spans; +- point-only touch и degenerate span; +- одинаковый key при разных lengths/endpoints; +- exact duplicate same cm и conflict different cm; +- conflicting transforms и permutation stability; +- corner scale с изменением длины/направления; +- key-only legacy record без silent drop. + +Тесты проверяют не только count/key, но canonical exact `a/b`, `cm`, суммарное +покрытие по каждому носителю и deep equality повторного запуска. + +### 12.2 Integration / production bundle + +Новый `demo/smoke_resize_wall_thickness.mjs` обязан использовать собранный +bundle и реальные `_rszEdgeDown → _rszMove → _rszUp`, а не прямой вызов helper. +Fixture минимизируется из пользовательского экспорта и не содержит имён, +entity/device ids или других личных данных, кроме технической geometry, +нужной для дефекта. + +Smoke покрывает основной drag, commit, reload projection, Undo/Redo, Esc и +opening variant. Если полный экспорт нельзя безопасно положить в репозиторий, +фиксируется минимальный synthetic space с теми же числами и коллизией. + +### 12.3 Инварианты и мутации + +- `checkWallRecordsPreserved(before, after)` должен находить исходный broken + result и быть зелёным на исправленном; +- mutating lossless splitter в no-split и возврат key-only `continue` обязаны + краснить targeted tests/smoke; +- существующие fixture-model reference invariants остаются зелёными. + +### 12.4 Локальный гейт перед `S7-code-review` + +1. `npm run typecheck`; +2. `npm test`; +3. `npm run build` и побайтовая сверка двух tracked bundle copies; +4. `node scripts/smoke-select.mjs --base origin/dev --head HEAD`, затем все + выбранные geometry/Resize smoke; +5. targeted model-invariant before/after run; +6. `node scripts/mutation-gate.mjs --check` и запуск новых мутантов; +7. `node scripts/check-docs.mjs` после любого `src/**` diff; +8. `npm run golden:verify`, только если менялась/добавлялась raster baseline; +9. `node scripts/process-gate.mjs --range origin/dev..HEAD --issues`. + +Backend pytest не требуется без Python diff. Полные smoke/golden/performance +остаются предрелизными гейтами. Полный HA harness на Windows не заявляется. + +## 13. Производительность и безопасность + +Ожидаемая сложность helper: `O(W × E log E)`, где `W` — wall records, `E` — +затронутые room edges; сортируются только точки разбиения одного interval. +Операция работает только во время активного Resize и на уже загруженном +пространстве. Plan-wide boolean geometry, DOM measurement, registry/backend +запросы и новый render cache запрещены. + +Должны сохраняться immutable snapshot и bounded finite math. `NaN`, бесконечные +или degenerate endpoints не создают новые записи. Никакие user strings/HTML, +permissions, service calls или внешние данные не добавляются. + +## 14. Риски и защита + +| Риск | Защита | +|---|---| +| Общая стена встречается у нескольких changed rooms и движется дважды | fragment строится только из immutable source; equivalent transforms collapse; permutation tests | +| Key collision снова удаляет физически другую запись | exact geometry+cm signature вместо `Set`; специальный мутант | +| Разрез создаёт gap/overlap из-за epsilon | одномерный partition по общей source parameterization; coverage assertions | +| Corner scale и diagonal wall искажают t | линейное отображение endpoint по old/new span и diagonal table | +| Wall fix ломает opening/open span | отдельный real-handler smoke; соседний helper не меняется | +| Canonical writer создаёт повторный diff | deep-equal repeat/round-trip test и общий nine-decimal boundary | +| Исторически потерянная толщина выглядит как исправленная | документация явно не обещает восстановление старых данных | + +## 15. Откат + +Откат — один revert продуктового коммита #253 вместе с тестами, документацией, +changelog и синхронными bundles. Схема и данные не мигрируют, поэтому отдельный +rollback script не нужен. + +Откат возвращает известный риск потери толщины при Resize. Он не должен удалять +уже корректно сохранённые atomic intervals: прежняя версия умеет читать их как +обычные exact wall entries. + +## 16. Release-артефакты + +- product commit с терминальными trailers `Issue: #253` и + `User-Visible: yes`; +- RU/EN changelog bullets со ссылкой #253 в том же коммите; +- синхронные `dist/houseplan-card.js` и + `custom_components/houseplan/frontend/houseplan-card.js`; +- новый/расширенный production-bundle smoke, unit и mutation guard; +- обновлённые RU/EN user guide, `WALL-THICKNESS.md` и `ARCHITECTURE.md`; +- handoff с командами/результатами, точным branch HEAD и выбранными smoke; +- green code-review document в `docs/reviews/` после автоматического ревью; +- включение в ближайшую бету только по отдельной команде владельца. + +## 17. Принятые технические предположения + +Эти решения пользователь напрямую не наблюдает; ревьюер может свободно +оспорить их без арбитража владельца. + +1. Основной фикс живёт в чистом `rekeyWallsAfterMove()`, а не в UI handler: + один contract нужен edge drag, corner scale и optimizer callers. +2. Exact overlap вычисляется в render coordinates с текущими + `pitch/coordScale/angleClose` допусками; persisted endpoints возвращаются в + normalized coordinates единым constructor. +3. Несогласованное двойное преобразование считается нарушением planner + contract и сохраняет source fragment fail-closed; отдельный пользовательский + toast в этой задаче не вводится. +4. Разные exact fragments могут законно иметь одинаковый compatibility key; + дедупликация по key запрещена, пока exact endpoints расходятся. +5. Минимизированная fixture сохраняет только геометрию, необходимую для #253; + персональные имена/HA bindings из пользовательского экспорта не коммитятся. +6. Golden baseline не принимается только ради исправления дефекта. Если + доказательная визуальная сцена добавляется впервые, её canonical Linux + artifact проходит обычный review/accept contract. diff --git a/docs/specs/README.md b/docs/specs/README.md index 789c5092..da37316d 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -129,6 +129,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#248](https://github.com/Matysh/houseplan-card/issues/248) Идемпотентный Optimize после записи и reload | [248-optimize-idempotence.md](248-optimize-idempotence.md) | | [#251](https://github.com/Matysh/houseplan-card/issues/251) Доступность контроллера не наследуется от управляемой цели | [251-controller-target-availability.md](251-controller-target-availability.md) | | [#252](https://github.com/Matysh/houseplan-card/issues/252) Понятная и безопасная очистка забытых позиций в Optimize | [252-optimize-orphan-layout-report.md](252-optimize-orphan-layout-report.md) | +| [#253](https://github.com/Matysh/houseplan-card/issues/253) Resize не теряет интервалы толщины стен | [253-resize-wall-thickness.md](253-resize-wall-thickness.md) | ## P3