30 KiB
Issue #253 — Resize не теряет интервалы толщины стен
- Дата: 2026-08-23
- Тип: bug · приоритет P2
- Оценка: пользовательская ценность 9/10 · ценность для разработки 8/10 · сложность 7/10 · риск 8/10
- Issue: #253
- Связанные задачи: #201, #229, #233, #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, допуске или проёме.
Нарушены два действующих инварианта:
- единица толщины — атомарный физический интервал, а не целое ребро polygon;
- Resize переносит точные endpoints и re-key всех затронутых интервалов в одной транзакции без тихой потери пользовательских данных.
4. Цели
- Сделать перенос
space.wallslossless при полном и частичном пересечении с перемещаемыми рёбрами. - Оставлять неперемещённые части длинной стены на прежнем носителе.
- Исключить удаление разных записей только из-за одинакового округлённого key.
- Сохранить существующие Resize preview, opening, Undo/Redo и storage contracts.
- Закрепить реальный дефект числовым 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:
- найти старые spans, коллинеарные записи в действующем угловом допуске;
- вычислить точные одномерные пересечения записи с каждым span;
- добавить границы всех непустых пересечений в partition исходного интервала;
- для каждого полученного ненулевого fragment определить покрывающие его преобразования по midpoint;
- если fragment покрыт согласованным moved span, перенести оба endpoint по
линейному параметру
tиз соответствующегоoldSpanвnewSpan; - если 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 и миграция
Схема остаётся прежней:
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
npm run typecheck;npm test;npm run buildи побайтовая сверка двух tracked bundle copies;node scripts/smoke-select.mjs --base origin/dev --head HEAD, затем все выбранные geometry/Resize smoke;- targeted model-invariant before/after run;
node scripts/mutation-gate.mjs --checkи запуск новых мутантов;node scripts/check-docs.mjsпосле любогоsrc/**diff;npm run golden:verify, только если менялась/добавлялась raster baseline;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. Принятые технические предположения
Эти решения пользователь напрямую не наблюдает; ревьюер может свободно оспорить их без арбитража владельца.
- Основной фикс живёт в чистом
rekeyWallsAfterMove(), а не в UI handler: один contract нужен edge drag, corner scale и optimizer callers. - Exact overlap вычисляется в render coordinates с текущими
pitch/coordScale/angleCloseдопусками; persisted endpoints возвращаются в normalized coordinates единым constructor. - Несогласованное двойное преобразование считается нарушением planner contract и сохраняет source fragment fail-closed; отдельный пользовательский toast в этой задаче не вводится.
- Разные exact fragments могут законно иметь одинаковый compatibility key; дедупликация по key запрещена, пока exact endpoints расходятся.
- Минимизированная fixture сохраняет только геометрию, необходимую для #253; персональные имена/HA bindings из пользовательского экспорта не коммитятся.
- Golden baseline не принимается только ради исправления дефекта. Если доказательная визуальная сцена добавляется впервые, её canonical Linux artifact проходит обычный review/accept contract.