docs: specify lossless wall resize

Issue: #253
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-23 11:19:47 +03:00
parent 6dd59a1925
commit d9f7861c05
2 changed files with 401 additions and 0 deletions
+400
View File
@@ -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<key>`; специальный мутант |
| Разрез создаёт 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.
+1
View File
@@ -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