Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
30 KiB
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:
HouseplanCard._openAlignDialog()вызывает чистыйoptimizePlans(this._serverCfg, this._layout);OptimizeResultсодержит exactconfig/layout, отчёт иchanged, но не состояние канонической geometry;_renderAlignDialog()показывает отчёт только по структурным счётчикам;_runAlignToGrid()отправляет сохранённый candidate вhouseplan/plan/optimize;- backend проверяет permission, size, expected revisions, schema, marker/light/ opening semantics и наличие файлов, после чего crash-resumably записывает два Store и создаёт one-deep snapshot;
- ни 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:
- отказ любого пространства блокирует весь whole-plan Optimize;
- уже деградированное пространство также блокирует запись, даже когда candidate не выглядит хуже исходника;
- preview показывает понятное сообщение и названия пространств, не раскрывает exception/polyclip details и не показывает Apply.
Эти решения являются продуктовым контрактом, а не техническими предположениями.
5. Scope
В задачу входят:
- чистый production-equivalent preflight exact config candidate по всем пространствам;
- единая подготовка geometry inputs для preflight и production renderer либо общий чистый helper, исключающий два расходящихся алгоритма;
- проверка room masonry/paper вместе с независимыми partitions, незаконченными drafts, columns, обычными и hosted-partition openings;
- различение structural failure (
null/exception) и корректного пустого результата; - fail-closed preview и Apply: один failure блокирует всю операцию и ноль WS writes;
- понятное RU/EN сообщение с детерминированным списком пространств;
- привязка preflight result к exact candidate fingerprint и отсутствие повторной проверки при неизменном candidate;
- сохранение нынешней одной backend-транзакции и one-deep Undo для успешного candidate;
- unit, production-bundle smoke, visual state, mutation и large-house performance evidence;
- пользовательская, архитектурная, тестовая и 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 Код
- Добавить pure module
src/plan-geometry-preflight.ts(имя может быть изменено ревьюером) с per-space preparation/check и result/fingerprint types. - Вынести из
houseplan-card.tsтолько необходимую production input projection в pure helpers либо переиспользовать существующие resolvers напрямую; renderer и preflight должны сходиться на одной функции, а не копиях условий. - Расширить private
_alignDialogpreflight result и integration в_openAlignDialog,_runAlignToGrid,_renderAlignDialog. - Добавить RU/EN i18n keys для failure, hint, fallback name и
moresuffix. - 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-openingstests остаются зелёными; 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 сохраняются:
- Pure module/result/type names не являются публичным API.
- Fingerprint использует существующий
contentFingerprint, а не новый hash. - No-op candidate не проверяется: Apply отсутствует, значит опасной записи нет.
- Preflight result хранит statuses/reasons, но не polygon geometry, чтобы dialog не становился вторым render cache.
- Backend endpoint не требует декларативного
preflight_passedполя: без возможности исполнить polyclip оно не добавляет доказательства и ломает старые clients. - Performance budget относится к geometry helper отдельно от уже существующего
optimizePlans(); same-run baseline отделяет новый overhead от polygon engine. space.title → id → Space N— единственный display-name fallback.- Targeted file/fixture names могут меняться без изменения доказательной матрицы AC.