# Issue #223 — Optimize канонизирует координаты без floating-point шума - Дата: 2026-08-20 - Тип: bug / maintenance canonicalisation · приоритет P1 - Оценка: пользовательская ценность 8/10 · ценность для разработки 7/10 · сложность 4/10 · риск 4/10 - Issue: [#223](https://github.com/Matysh/houseplan-card/issues/223) - Ветка: `issue/223-optimize-coordinate-canonicalization` Канонические документы: `docs/SCOPE.md`, `docs/CANVAS.md`, `docs/USER-GUIDE.ru.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/TESTING.md`. ## 1. Сценарий, персона и момент Администратор обслуживает старый либо импортированный план через явное действие «Общие настройки → Оптимизировать планы». Координаты комнат визуально уже лежат на сетке, но содержат накопленный IEEE-754 шум величиной в один или несколько ULP. Пользователь ожидает, что Optimize сохранит геометрию без видимого сдвига, запишет канонические узлы сетки и тем самым восстановит надёжную работу объединения комнат, Glow и другой downstream boolean-геометрии. ## 2. Что человек увидит до и после **До:** Optimize считает значения в пределах `EPS` уже выровненными и возвращает исходные числа бит-в-бит. В воспроизводимом плане из шести комнат preview сообщает, что изменений нет, хотя шум около `5.5e-17` сохраняется и объединение комнат может не распознать общую границу. **После:** тот же явный Optimize предлагает изменение без видимого перемещения: `сдвинуто элементов — 0`, но отдельно сообщает количество канонизированных координат. После подтверждения общие вершины имеют ровно одинаковые числовые значения, объединение комнат и Glow не зависят от прежнего ULP-шума. Повторный Optimize сообщает, что изменений нет. ## 3. Подтверждённая причина `snapN()` в `src/align-grid.ts` вычисляет ближайший узел `s`, но при `abs(s - v) <= EPS` возвращает `v`. Защитное правило было введено ради идемпотентности, однако фактически консервирует почти канонический ввод. Это противоречит обещанию явного Optimize из `docs/CANVAS.md`: maintenance-действие должно переписывать persisted-геометрию в каноническую форму. Потребительская устойчивость из #218 остаётся необходимой страховкой на чтении, но не очищает источник данных. #199 — будущая общая проверка кандидата перед записью, а не исправление данной канонизации. Задачи не являются дубликатами. ## 4. Scope - `snapN()` всегда возвращает вычисленный ближайший узел сетки для конечного числа, в том числе когда отличие не превышает `EPS`; - `alignAllToGrid()` отдельно считает канонические замены без заметного перемещения и учитывает их в `changed`; - `AlignReport` и `OptimizeReport` получают поле `coordsCanonicalized`; - preview и итоговый toast Optimize на русском и английском объясняют случай `moved = 0`, `changed = true`; - сохраняются preview/Cancel/Apply/server Undo, идемпотентность, неизвестные поля конфигурации и действующая model/schema compatibility; - добавляются unit, targeted production-bundle smoke и mutation guard; - актуализируются документы о канонизации координат. ## 5. Non-scope - автоматическая канонизация при каждом Save, импорте, чтении или рендере; - изменение шага сетки, `EPS`, координатной системы или допустимого пользовательского размещения между узлами; - замена устойчивых geometry-сравнений из #218 строгим равенством; - общая pre-apply валидация/барьер из #199; - исправление иных optimizer-pass, стен, проёмов либо миграций модели; - новая persisted-схема, model-version, backend API или HA permission; - новый control, жест, route либо предупреждение помимо существующего preview. ## 6. Контракт канонизации и отчёта Для каждого конечного нормализованного числа `v`, переданного в `snapN()`, канонический результат равен: `Math.round(v / GRID_STEP_N) * GRID_STEP_N`. Результат должен быть точно равен этому вычисленному узлу (`===`), а повторное применение не должно менять значение. `NaN` и бесконечности сохраняют прежнее поведение и возвращаются без преобразования. `coordsCanonicalized` — число отдельных координатных компонент/узлов, для которых в ходе `alignAllToGrid()` одновременно выполнено: 1. конечный результат `snapN(v)` численно отличается от входа; 2. абсолютная разница не превышает `EPS`; 3. результат действительно записан в candidate. Считается каждое место использования координаты, а не уникальное числовое значение и не объект целиком. Для прямоугольника его ближняя и дальняя стороны считаются как координатные компоненты, даже когда дальняя сторона вычислена как `x + w`. Заметное выравнивание с разницей `> EPS` остаётся только в `moved` и не дублируется в `coordsCanonicalized`. Изменение угла проёма, удаление draft и канонизация стен/спанов не входят в новый счётчик. `AlignResult.changed` истинно при `moved > 0` **или** `coordsCanonicalized > 0`; остальные существующие причины изменения сохраняют свои текущие контракты. В `optimizePlans()` новый счётчик переносится без потери в `OptimizeReport`. `maxShift`, `maxShiftCm` и `maxSpace` не растут от замен в пределах `EPS`: пользовательское обещание о физическом сдвиге остаётся честным. ## 7. UX, запись и Undo Preview остаётся существующим диалогом. В строку обслуживания добавляется отдельный показатель, а существующий счётчик `canonicalized` получает однозначное название по своей единице измерения: - RU: «обновлено пространств: {c}; устранён шум координат: {p}»; - EN: «spaces updated: {c}; noisy coordinate values removed: {p}». `c` по-прежнему означает число пространств, где переписано представление `open_spans`/`open_to`/`walls`; `p` означает число отдельных coordinate values по §6. Два разных показателя не используют один термин «канонизировано» в одном предложении. Оба выводятся в общей строке всегда, включая ноль, так же как действующие счётчики миграций/стен/виртуальных фрагментов. При единственном изменении из-за ULP-шума диалог не показывает «изменений нет»: он показывает нулевой видимый сдвиг и положительный `coordsCanonicalized`. Итоговый toast использует новый показатель в сумме обслуженных записей, поэтому после Apply не сообщает `0` обслуженных записей. Cancel не пишет candidate. Apply отправляет тот же exact candidate через существующую backend-транзакцию; Undo возвращает исходные noisy-значения в рамках действующего срока жизни резервной копии. Новых focus, keyboard, touch и screen-reader взаимодействий нет. Действующий admin-only safety floor и подтверждение сохраняются. ## 8. Данные, compatibility и миграция Persisted schema и `PLAN_MODEL_VERSION` не меняются. Числа остаются обычными JSON number; меняется только их exact representation после добровольного Optimize. Неизвестные поля, backdrop calibration, view boxes, unattached layout entries и файлы сохраняются. Входные `config` и `layout` не мутируются. Старые версии карточки читают получившиеся координаты как валидные значения на той же сетке. Фоновая миграция не запускается. Повторный `optimizePlans()` над собственным результатом возвращает глубокий эквивалент, `changed: false` и `coordsCanonicalized: 0`. ## 9. Acceptance criteria | AC | Критерий | Доказательство | |---|---|---| | AC1 | Для канонического узла с добавленным/вычтенным ULP `snapN()` возвращает ближайший вычисленный узел точно, а не исходный шум. | Focused `align-grid` unit; mutant `snapn-returns-input-near-node`. | | AC2 | Значение дальше `EPS` продолжает выравниваться как раньше; не конечные числа сохраняют прежний результат. | Boundary unit matrix. | | AC3 | Воспроизводимый fixture из шести комнат после Optimize имеет точно совпадающие общие вершины, `changed: true`, `moved: 0`, `coordsCanonicalized > 0`; union/downstream geometry успешно строится. | `plan-optimizer` regression unit на fixture из #223/#218. | | AC4 | Счётчик считает отдельные компоненты только при разнице `<= EPS`, не дублирует заметно перемещённые элементы и не увеличивает `maxShift*`. Принятая партиция и `wall_column` учитываются; у партиции с `hostedFit = false` вычисленный, но не записанный snap не учитывается и noisy endpoints сохраняются. | Units для room poly, rect/decor/layout, применённой и отклонённой partition, wall column и off-grid negative case. | | AC5 | Второй запуск над candidate возвращает `changed: false`, `coordsCanonicalized: 0` и побитово/глубоко тот же JSON. | Idempotence unit. | | AC6 | RU/EN preview разными терминами показывает «обновлённые пространства» (`canonicalized`) и «устранённый шум координат» (`coordsCanonicalized`); случай `moved=0` не превращается в «нет изменений»; итоговый toast учитывает очищенные координаты. | i18n/UI unit + targeted production-bundle browser smoke. | | AC7 | Preview не мутирует входы; Cancel ничего не пишет; Apply сохраняет exact candidate; Undo восстанавливает исходные noisy-координаты и неизвестные поля. | Optimizer immutability unit + targeted browser/backend smoke. | | AC8 | Обычные read/render/Save без явного Optimize не переписывают persisted config; устойчивость #218 остаётся зелёной. | Existing regression suite + focused negative unit. | | AC9 | Документация описывает явную exact-канонизацию и значение нового счётчика; три bundle-копии синхронны. | Docs check, bundle parity check. | | AC10 | Рабочие gates зелёные. | typecheck, unit, build, targeted smoke, mutation gate. | ## 10. План реализации и тестов В `alignAllToGrid()` вводится локальный tracked-механизм над чистым `snapN()`. Он возвращает snapped value и признак near-node replacement отдельно, а вклад в `coordsCanonicalized` подтверждается только в месте фактической записи значения в candidate. В частности, `partitions` добавляет вклад четырёх endpoints лишь в ветке `snappedLength > EPS && hostedFit`; отклонённая ветка оставляет и координаты, и счётчик без изменения. `wall_columns` и остальные безусловно записываемые grid-bound call sites подтверждают вклад сразу после присваивания. Счётчик остаётся контекстом одного maintenance-pass и не добавляет глобального состояния в базовый helper. Unit matrix расширяется в `test/align-grid.test.mjs` и `test/plan-optimizer.test.mjs`. Реальный noisy fixture должен сохранять точные исходные числа, чтобы тест падал при возврате старой ветки `return v`. Targeted smoke запускает Optimize через собранный production bundle и проверяет Preview/Cancel/Apply/Undo вместе с RU/EN строкой. Golden baseline не требуется: компоновка диалога не меняется, добавляется текстовый счётчик; перед бетой выполняется общий golden verify по процессу. Mutation entry возвращает старое поведение near-node `snapN()` либо отключает tracked increment. Чистая ветка зелёная, мутант обязан падать на focused unit. Полные golden/smoke/performance выполняются перед бетой, а в implementation- цикле — typecheck, unit, build и targeted smoke из этого ТЗ. ## 11. Риски, производительность и security | Риск | Мера | |---|---| | Счётчик вводит пользователя в заблуждение как число объектов | Точное название «координат» и семантика отдельных компонент в §6. | | Два разных счётчика выглядят одной категорией канонизации | Нейтральное «обновлено пространств» и точное «устранён шум координат» явно называют разные единицы и действия; занятый термин нормализованных координат не используется для `c`. | | Отклонённый snap партиции попадает в отчёт | Вклад подтверждается только после фактической записи; отдельный `hostedFit=false` unit. | | Near-node rewrite ошибочно считается физическим сдвигом | Раздельные counters; `moved` и `maxShift*` не растут при `<= EPS`. | | Идемпотентность нарушается из-за недвоичного шага сетки | Результат определяется тем же выражением узла; exact second-run unit для всех call sites. | | Часть координат остаётся noisy | Все grid-bound вызовы `snapN()` в batch переводятся на tracked wrapper; реальный fixture. | | Изменится обычный Save/render | Helper вызывается только в существующем explicit Optimize pipeline; AC8. | Проход остаётся линейным по уже обходившимся координатам и добавляет одну exact- проверку и сравнение на компоненту. Работы в render/state tick, новых сетевых запросов, данных третьим сторонам, HTML-инъекций или изменений HA permissions нет. Отдельный performance/security gate не требуется. ## 12. Release-артефакты и rollback Изменение пользовательское. Implementation-коммит имеет `User-Visible: yes` и включает: - `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #223; - `docs/USER-GUIDE.ru.md` — exact-канонизацию и новый показатель Optimize; - `docs/CANVAS.md` — разграничение live snapping и явного maintenance-pass; - `docs/TESTING.md` — unit/smoke/mutation coverage; - `docs/STATUS.md` — фактическую release-линию; - RU/EN i18n, unit, targeted smoke, mutation entry и три синхронные bundle- копии. Новая schema/migration, backend, screenshot baseline, performance baseline и security artifact не нужны. Rollback — revert implementation-коммита. Уже канонизированные координаты остаются валидными и визуально эквивалентными; вернуть прежние noisy-биты можно только существующим Undo Optimize либо backup. ## 13. Принятые предположения 1. `coordsCanonicalized` считает координатные компоненты/узлы, а не объекты: именно это даёт детерминированный отчёт для polygon и box call sites. 2. Near-node означает `abs(s - v) <= EPS`; граница включительна и не меняет действующую константу tolerance. 3. Существующий `canonicalized` переименовывается только в пользовательском тексте: структура и семантика report field не меняются. 4. Новый счётчик включается в существующее число «обслужено записей» итогового toast, хотя единица там исторически агрегирует разные виды обслуживания. 5. Точное имя targeted smoke и размещение tracked-механизма являются техническим решением, если AC и explicit-Optimize boundary сохраняются. 6. Продуктовых вопросов нет; эти предположения технические и могут быть свободно скорректированы на ревью без дополнительного решения владельца.