Files
houseplan-card/docs/specs/199-optimize-geometry-preflight.md
2026-08-22 16:08:04 +03:00

30 KiB
Raw Permalink Blame History

Issue #199 — geometry preflight перед записью Optimize

  • Issue: https://github.com/Matysh/houseplan-card/issues/199
  • Тип / приоритет: tech-debt / P1
  • Оценка: пользовательская ценность 8/10; ценность для разработки 9/10; сложность 7/10; риск 8/10
  • Трек: обычный
  • Область: explicit whole-plan Optimize, canonical physical geometry, preview/Apply, RU/EN i18n, unit/smoke/performance
  • Модель данных: без изменений и миграции
  • Связано: #197, #198, #223, #224, #229, docs/CANVAS.md §9.5, docs/WALL-THICKNESS.md, docs/TOUCH-SUPPORT.md

1. Сценарий и персона

Персона: домашний администратор, который обслуживает старый или импортированный план через «Общие настройки → Оптимизировать планы».

Поверхность и момент: администратор открывает preview Optimize и собирается подтвердить единое изменение всех пространств. Один из maintenance-pass создаёт candidate, для которого production geometry builder не может построить кладку либо бумагу хотя бы одного пространства.

Задача поддерживает J6 из docs/SCOPE.md: долговечный редактируемый план не должен становиться непригодным после штатной операции обслуживания. Она также закрывает safety floor docs/TOUCH-SUPPORT.md: editor-действие не имеет права молча записать повреждённую геометрию. View/киоск не получают нового control, но защищаются от результата опасной записи.

2. Что человек увидит до и после

До: preview показывает обычный структурный отчёт и разрешает Apply, даже если candidate уже не строится тем же geometry engine, которым затем рисуется план. Ошибка становится видна только после записи; one-deep Undo исчезает после следующего изменения.

После: если хотя бы одно пространство candidate не проходит production- эквивалентную проверку, preview называет проблемные пространства, сообщает, что планы не изменены, и не показывает кнопку «Оптимизировать». Ни config, ни layout не записываются.

3. Подтверждённая проблема

Текущий путь на origin/dev:

  1. HouseplanCard._openAlignDialog() вызывает чистый optimizePlans(this._serverCfg, this._layout);
  2. OptimizeResult содержит exact config/layout, отчёт и changed, но не состояние канонической geometry;
  3. _renderAlignDialog() показывает отчёт только по структурным счётчикам;
  4. _runAlignToGrid() отправляет сохранённый candidate в houseplan/plan/optimize;
  5. backend проверяет permission, size, expected revisions, schema, marker/light/ opening semantics и наличие файлов, после чего crash-resumably записывает два Store и создаёт one-deep snapshot;
  6. ни frontend preview/Apply, ни Python endpoint не вызывают wallBodiesGeometry()/floorFootprintGeometry() для candidate.

#197 доказал цену этого разрыва: один junction patch делал wallBodiesGeometry() === null после Optimize, а пользователь обнаруживал исчезнувшую кладку уже после записи. #197 исправил конкретный patch-loop, но не общий барьер. #198/#229 меняют отдельные optimizer-pass, #223 очищает координаты внутри Optimize, #224 канонизирует координаты на общих write barriers. Ни одна из этих задач не проверяет итоговую renderability candidate, поэтому #199 не является дубликатом.

4. Решения владельца

Владелец принял defaults 2026-08-22:

  1. отказ любого пространства блокирует весь whole-plan Optimize;
  2. уже деградированное пространство также блокирует запись, даже когда candidate не выглядит хуже исходника;
  3. preview показывает понятное сообщение и названия пространств, не раскрывает exception/polyclip details и не показывает Apply.

Эти решения являются продуктовым контрактом, а не техническими предположениями.

5. Scope

В задачу входят:

  1. чистый production-equivalent preflight exact config candidate по всем пространствам;
  2. единая подготовка geometry inputs для preflight и production renderer либо общий чистый helper, исключающий два расходящихся алгоритма;
  3. проверка room masonry/paper вместе с независимыми partitions, незаконченными drafts, columns, обычными и hosted-partition openings;
  4. различение structural failure (null/exception) и корректного пустого результата;
  5. fail-closed preview и Apply: один failure блокирует всю операцию и ноль WS writes;
  6. понятное RU/EN сообщение с детерминированным списком пространств;
  7. привязка preflight result к exact candidate fingerprint и отсутствие повторной проверки при неизменном candidate;
  8. сохранение нынешней одной backend-транзакции и one-deep Undo для успешного candidate;
  9. unit, production-bundle smoke, visual state, mutation и large-house performance evidence;
  10. пользовательская, архитектурная, тестовая и release-документация.

6. Non-scope

  • исправление geometry, из-за которой preflight стал красным;
  • частичная оптимизация только исправных пространств;
  • сравнение «до/после» и разрешение уже существующего structural failure;
  • raw-ring, centreline либо renderer-specific fallback;
  • замена polyclip-ts, общая смена tolerance/precision или новый geometry engine;
  • port polygon boolean engine в Python;
  • новый persisted field, PLAN_MODEL_VERSION, schema migration или Store;
  • фоновая проверка при обычном Save/read/render/import;
  • сохранение stack trace или исходного config в диагностике/telemetry;
  • новый ручной repair control;
  • изменение срока жизни Optimize Undo;
  • публичное включение скрытой изометрии.

Найденная preflight проблема получает отдельный bug issue с reproducer; #199 не используется как umbrella для её попутного исправления.

7. Контракт geometry preflight

7.1 Exact candidate и область проверки

checkOptimizeGeometry(config) (рабочее имя) является чистой функцией: не читает DOM/Lit/HA state, не пишет Store, не мутирует config и не зависит от активного этажа. Вход — exact OptimizeResult.config; выход содержит:

  • content fingerprint входа;
  • ordered list пространств со статусом ok | failed | not-applicable;
  • публичный failure list только из spaceId и безопасного display name;
  • внутренний bounded reason code для unit/diagnostic assertions без exception text.

Порядок совпадает с config.spaces; перестановка пространств меняет только порядок отчёта, не результат каждого пространства.

Проверяются все пространства candidate, не только активное и не только изменённые по счётчикам Optimize. Проверка запускается лишь когда OptimizeResult.changed === true: no-op preview ничего не записывает и сохраняет действующее сообщение «Все планы уже используют актуальную…».

7.2 Production-equivalent inputs

Для каждого пространства используются те же чистые источники и параметры, что production render pipeline:

  • spaceModels() и NORM_W для render coordinates;
  • persisted walls в config coordinates;
  • resolveOpenCuts() для explicit/legacy room spans;
  • ordinary room openings и валидные hosted-partition openings с тем же compat resolver;
  • wallIntervals() + partitionOpeningHasCompositeRoomWall() для решения, режет ли hosted opening совпавшую room masonry;
  • physicalBodyParts() с PartitionOpeningCut[] для partitions/drafts/columns;
  • GRID_STEP_N, cell_cm, GRID_PITCH, NORM_W;
  • wallBodiesGeometry() для общей masonry/paper geometry;
  • floorFootprintGeometry() только там, где есть хотя бы одна комната и нет уже построенного paperGeom.

Подготовка opening/physical inputs выносится в общий pure helper либо напрямую переиспользует существующие pure resolvers. Копия private card-логики с отличающимися условиями запрещена.

7.3 Успех, failure и not-applicable

Пространство успешно, когда каждый обязательный production pass, который должен быть вызван для его данных, завершился без exception и вернул не-null geometry.

  • При наличии wall records либо independent physical bodies wallBodiesGeometry() обязан вернуть object. Его документированный successful empty result не является failure сам по себе.
  • При наличии комнат должен существовать paper/floor result: paperGeom от wall pass либо floorFootprintGeometry() без него.
  • Пространство без комнат, wall records и independent bodies имеет not-applicable, а не failure: image-only/пустой новый этаж разрешён.
  • Пространство без комнат, но с валидными partitions/drafts/columns проверяет physical/masonry pass; отсутствие room floor не является ошибкой.
  • Невалидный hosted opening, который действующий compat renderer намеренно не материализует, остаётся обязанностью schema/semantic validation и не получает новую альтернативную трактовку в preflight.
  • Любой неожиданный exception внутри подготовки или boolean pass превращается в bounded failure reason; наружу и в UI exception text не выходит.

Preflight ничего не чинит, не округляет и не канонизирует сверх уже полученного Optimize candidate.

7.4 Fingerprint и повторная проверка

Preflight result хранит contentFingerprint(candidate.config). Dialog хранит тот же exact candidate и result.

  • Обычный Apply использует готовый зелёный result и не выполняет второй дорогой boolean pass.
  • Перед WS call код снова вычисляет дешёвый fingerprint. Совпадает — result применим. Не совпадает — preflight выполняется заново для изменившегося candidate.
  • Красный повтор переводит dialog в тот же failure state и не вызывает WS.
  • Изменение server config другим клиентом по-прежнему ловится backend revision conflict; preflight не заменяет optimistic locking.
  • Result не кладётся в глобальный render cache и не переживает закрытие dialog.

8. UX-контракт

8.1 Failure preview

При failure обычный отчёт о сдвигах/миграциях не показывается как предложение к записи. Вместо него dialog показывает:

  • RU: «Не удалось безопасно проверить геометрию следующих пространств: {spaces}{more}.»
  • RU hint: «Планы не изменены. Обновите House Plan и повторите. Если ошибка останется, приложите экспорт пространства к отчёту об ошибке.»
  • EN: Could not safely verify the geometry of the following spaces: {spaces}{more}.
  • EN hint: Plans were not changed. Update House Plan and try again. If the error persists, attach a space export to the bug report.

Display name: непустой space.title, иначе space.id, иначе локализованное «Пространство N» / Space N. Первые три имени перечисляются в config order; при большем числе добавляется локализованный suffix «и ещё N» / and N more. Lit text binding экранирует имена; HTML интерполяция запрещена.

Footer содержит только «Отмена»/закрытие. Кнопка «Оптимизировать» не рендерится, а не только получает disabled: пользователь не должен принимать failure как предупреждение, которое можно обойти.

Title, Escape, focus trap, scrim close и restore focus остаются общими для hp-dialog. Новых жестов нет. На узком/touch экране сообщение переносится и остаётся целиком доступным; editor touch parity не обещается, но safety floor блокирует запись так же, как на desktop.

8.2 No-op и success

  • changed: false: действующий gs.align_none, без preflight failure UI и без Apply.
  • changed: true, preflight green: нынешний точный отчёт, warning и Apply без текстовых/поведенческих изменений.
  • Cancel/close в любом состоянии ничего не пишет.
  • Успешный Apply по-прежнему показывает gs.align_done; Undo по-прежнему доступен до следующего edit.

9. Backend, atomicity, compatibility и security

houseplan/plan/optimize не получает новый параметр. Python не имеет и не должен получать вторую реализацию TypeScript/polyclip geometry. Preflight — защитный барьер штатной карточки, не security attestation от недоверенного клиента.

Backend сохраняет независимые гарантии:

  • admin permission и size limit;
  • schema/semantic validation;
  • expected config/layout revisions;
  • missing-plan validation;
  • intent-first config+layout commit;
  • one-deep snapshot и crash recovery.

При красном preflight endpoint не вызывается вообще, поэтому не создаются pending/backup, revisions и update events. При зелёном вызывается ровно один существующий endpoint с exact candidate; частичной записи по пространствам нет.

Старый сохранённый config читается без миграции. Новые i18n keys — единственное добавление к пользовательскому контракту. Неизвестные поля candidate, files, layout metadata и backend Undo сохраняются существующими механизмами.

Имена пространств считаются недоверенным пользовательским текстом и выводятся только через Lit escaping. Exception, geometry coordinates и config payload не логируются в browser console как часть штатного failure и не отправляются третьим сторонам.

10. Производительность

Проверка user-triggered и выполняется не чаще одного раза для одного открытого dialog/candidate. Она запрещена в:

  • render/update и HA state tick;
  • pointermove, pan, pinch и hover;
  • обычном config/layout Save;
  • переключении этажа, темы или View/Plan режима.

Базовое измерение автора ТЗ 2026-08-22 на текущем Windows checkout после npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs: прямой production wall/physical pass существующей large-house fixture (3 пространства, 60 комнат, 100 проёмов, 60 partitions, 40 columns) — median 155.9 ms, p95 162.56 ms, max 163.67 ms по 20 warm samples.

Acceptance budget для готового helper на той же fixture:

  • все 3 пространства green;
  • p95 не больше 250 ms после не менее 3 warmups и 20 samples;
  • wrapper/input-preparation overhead относительно прямого production builder того же процесса не больше 20% + 15 ms;
  • повторный Apply неизменного candidate не вызывает второй geometry pass;
  • heap/result cache не растёт после закрытия dialog.

Отдельный benchmark script печатает fixture counts, median/p95/max, baseline и candidate timings. Если абсолютный budget нестабилен в CI, ревьюер не повышает его молча: измеряет same-run baseline и заводит отдельный performance issue; для #199 остаётся обязательным относительный budget.

11. Acceptance criteria

AC Критерий Доказательство
AC1 Exact Optimize candidate проверяется по всем пространствам теми же room/opening/wall/physical builders и параметрами, что production renderer; вход не мутируется. Focused unit + code review call-site parity table.
AC2 Валидная матрица room masonry, ordinary/hosted openings, partitions, drafts, columns, image-only и empty space даёт ok/not-applicable без false positive. Parameterized unit fixtures.
AC3 Forced wallBodiesGeometry() === null, floor failure и thrown exception дают bounded failure соответствующего пространства; successful empty geometry не считается failure. Injectable seam/mutant unit.
AC4 Failure одного из нескольких пространств возвращает deterministic ordered list и блокирует whole-plan operation; исправные пространства не записываются отдельно. Multi-space unit + browser smoke.
AC5 Уже красное исходное пространство не получает исключения «не стало хуже»: changed candidate блокируется и оставляет exact config/layout/revisions без изменений. Before/after regression unit + browser smoke.
AC6 Failure dialog выводит первые три безопасных display names, RU/EN suffix остальных и plain-language hint без exception; Apply отсутствует, Cancel/Escape/focus работают. i18n/UI unit + production-bundle smoke + dark/light golden candidate.
AC7 changed:false сохраняет прежний no-op dialog без preflight; green candidate сохраняет прежний report/Apply/toast. UI unit + existing Optimize smokes.
AC8 Green fingerprint используется ровно для exact config candidate; unchanged Apply не повторяет geometry pass, changed fingerprint rechecks и fail-closes до WS. Controlled-call-count unit.
AC9 Красный preflight делает 0 houseplan/plan/optimize calls, не меняет _serverCfg, layout, revisions, history/Undo и не создаёт events; green делает ровно один atomic call. Production-bundle smoke with WS recorder.
AC10 Успешный Apply/Undo, revision conflict и backend semantic validation остаются без изменений. Existing frontend/backend suites + targeted smoke.
AC11 Large-house helper выполняет budget §10, не попадает в render/state/pointer пути и не оставляет растущий cache. Targeted benchmark + call-count regression.
AC12 Mutation gates ловят bypass preflight, active-space-only check, acceptance null и отображение Apply при failure. scripts/mutation-gate.mjs entries, каждый caught 1/1.
AC13 RU/EN changelog, user/canvas/testing/status docs и три bundle-копии актуальны; docs fingerprint green. check-docs, bundle byte comparison, doc review.
AC14 Рабочие gates задачи зелёные. typecheck, unit, build, targeted smoke, targeted golden candidate/benchmark as required by process.

12. План реализации и тестов

12.1 Код

  1. Добавить pure module src/plan-geometry-preflight.ts (имя может быть изменено ревьюером) с per-space preparation/check и result/fingerprint types.
  2. Вынести из houseplan-card.ts только необходимую production input projection в pure helpers либо переиспользовать существующие resolvers напрямую; renderer и preflight должны сходиться на одной функции, а не копиях условий.
  3. Расширить private _alignDialog preflight result и integration в _openAlignDialog, _runAlignToGrid, _renderAlignDialog.
  4. Добавить RU/EN i18n keys для failure, hint, fallback name и more suffix.
  5. Backend endpoint и persisted schema не менять.

Предполагаемые продуктовые файлы: src/plan-geometry-preflight.ts, src/houseplan-card.ts, при необходимости общий geometry/opening helper, src/i18n/ru.json, src/i18n/en.json.

12.2 Автотесты

  • test/plan-geometry-preflight.test.mjs: AC1–AC5, AC8, call parity, immutability, empty/failure matrix;
  • существующие plan-optimizer, wall-thickness, partition-openings tests остаются зелёными;
  • demo/smoke_optimize_geometry_preflight.mjs: собранный bundle, multi-space red/green/no-op, RU/EN, zero/one WS calls, state/Undo preservation;
  • demo/benchmark_optimize_geometry_preflight.mjs: §10;
  • golden matrix: failure dialog dark/light; baselines принимаются только из полного Linux CI artifact через golden:accept -- --reviewed;
  • mutation ids: optimize-preflight-bypassed, optimize-preflight-active-space-only, optimize-preflight-accepts-null, optimize-preflight-renders-apply-on-failure либо эквивалентные точные anchors.

До S7-code-review выполняются fast gates и targeted smoke/benchmark из AC. Полный smoke set, golden verify и общий performance остаются pre-beta gates.

13. Риски и меры

Риск Мера
Preflight расходится с renderer Общий pure input helper + AC1 parity table; никакого упрощённого polygon check.
Проверяется только активный этаж Pure function обходит ordered config.spaces; mutant active-space-only.
False positive на пустом/virtual-only плане Явный not-applicable и successful-empty contract, parameterized AC2/AC3.
Старый failure пропускается как «не хуже» Решение владельца Q2 и AC5: любой candidate failure блокирует.
Whole-plan Apply становится частичным Ноль WS calls при любом failure; backend endpoint вызывается один раз только на green.
UI показывает внутреннюю ошибку или ломается от title Bounded reason codes, Lit escaping, список ≤3 + count.
Дорогой boolean pass тормозит обычный View Только explicit dialog, fingerprint reuse, budget и call-count tests.
Cache удерживает config/geometry Result живёт только в dialog; geometry не хранится, только statuses/fingerprint.
Старый browser bundle может вызвать endpoint без preflight Это не security boundary; пакет поставляет card+integration вместе. Backend продолжает schema/semantic guards, но не дублирует polyclip.

14. Touch и accessibility

Touch editor: best effort, но safety behavior полностью поддерживается. Проверка запускается той же кнопкой General settings; failure не допускает запись ни мышью, ни tap. Message/footer не требуют hover, помещаются в общий responsive hp-dialog, Escape/focus restore остаются desktop guarantees. Новых pointer handlers и рисков pinch/pointercancel нет. View/киоск rendering не меняется; предотвращение повреждённой записи улучшает их надёжность.

15. Release-артефакты и rollback

Изменение пользовательское. Implementation commit имеет User-Visible: yes и включает:

  • docs/CHANGELOG.md и docs/CHANGELOG.ru.md со ссылкой на #199;
  • docs/USER-GUIDE.ru.md — failure behavior Optimize и действие пользователя;
  • docs/CANVAS.md — preflight/fingerprint/whole-plan contract;
  • docs/ARCHITECTURE.md — граница frontend geometry barrier/backend atomicity;
  • docs/TESTING.md — unit/smoke/mutation/performance evidence;
  • docs/STATUS.md — фактическая release-line запись;
  • RU/EN i18n, tests, smoke, benchmark, mutation entries;
  • recapture demo/docs/capture.mjs/docs/images/screenshots.json для актуального source fingerprint при любом src/** diff;
  • три byte-identical bundle-копии;
  • dark/light golden candidate; acceptance baseline только по reviewed full Linux artifact перед beta.

Security report, schema migration, backend release note и persisted recovery artifact не требуются.

Rollback — revert implementation commit. Persisted данные не меняются новым preflight, поэтому обратной миграции нет. После rollback Optimize снова разрешит запись unchecked candidate; уже успешно оптимизированные планы и Undo snapshots остаются в прежнем формате.

16. Принятые технические предположения

Эти решения не наблюдаемы пользователем и могут быть изменены ревьюером без нового решения владельца, если AC сохраняются:

  1. Pure module/result/type names не являются публичным API.
  2. Fingerprint использует существующий contentFingerprint, а не новый hash.
  3. No-op candidate не проверяется: Apply отсутствует, значит опасной записи нет.
  4. Preflight result хранит statuses/reasons, но не polygon geometry, чтобы dialog не становился вторым render cache.
  5. Backend endpoint не требует декларативного preflight_passed поля: без возможности исполнить polyclip оно не добавляет доказательства и ломает старые clients.
  6. Performance budget относится к geometry helper отдельно от уже существующего optimizePlans(); same-run baseline отделяет новый overhead от polygon engine.
  7. space.title → id → Space N — единственный display-name fallback.
  8. Targeted file/fixture names могут меняться без изменения доказательной матрицы AC.