mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
401 lines
30 KiB
Markdown
401 lines
30 KiB
Markdown
# 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`
|
||
- Статус ТЗ: одобрено (SPEC REVIEW r1, 2026-08-23)
|
||
|
||
Канонические документы: `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.
|