Волна 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
20 KiB
Issue #290 — не создавать молча почти осевые уступы
- Issue: https://github.com/Matysh/houseplan-card/issues/290
- Статус: первая редакция для внешнего ревью; канонический статус задаётся метками issue
- Тип / приоритет: bug / P2
- Оценка: пользовательская ценность 8/10; ценность для разработки 8/10; сложность 9/10; риск 9/10
- Область: Walls drawing, safe Resize output, единый near-axis classifier, explicit Optimize preview/report/Undo, room/wall/opening rekey и invariants
- Модель данных: schema не меняется; существующая геометрия меняется только после подтверждения Optimize
- Связано: #141, #173, #223, #248, #277, #279, #284,
docs/CANVAS.md,docs/RESIZE.md,docs/WALL-THICKNESS.md
1. Сценарий и подтверждённая причина
На реальном плане две комнаты хранят общее ребро от (-401, 708) до
(-85, 709) в индексах решётки: 316 шагов вдоль и ровно один поперёк. Угол
0.181315° неразличим на обычном зуме, но это законная grid geometry, поэтому
обычная канонизация и текущий Optimize её не меняют. Такой уступ уже потребовал
renderer tolerance в #279 и остаётся источником нестабильных junction inputs.
Проблема состоит из двух частей:
- Walls/Resize не имеют единого authoring-правила, запрещающего почти горизонтальный/вертикальный результат;
- Optimize не имеет явно разрешённого lossy pass и поэтому правильно считает
существующий
316×1каноническим.
2. Решения владельца
Зафиксированы 2026-08-24:
- При создании ребро в пределах допуска автоматически выравнивается по ближайшей горизонтальной/вертикальной оси. Специального modifier bypass нет.
- Существующие уступы исправляются отдельным lossy-пунктом Optimize с отчётом «Выпрямлено стен: N; максимальное перемещение: X» и только после общего подтверждения.
- Единый допуск — отклонение не более
0.25°от горизонтали или вертикали, тот же продуктовый порог, что использует устойчивый renderer #279. - Настоящие диагонали за пределами допуска не меняются.
Открытых продуктовых вопросов нет.
3. Пользовательский результат
При рисовании preview сразу показывает точную горизонталь/вертикаль, и click сохраняет именно её. Resize никогда не оставляет почти осевой side/moving edge. Для старого плана Optimize заранее показывает число исправляемых физических стен и максимальный сдвиг; Cancel оставляет план byte-equivalent, Confirm даёт одну Undo-операцию. Диагональные стены остаются диагональными.
4. Единая классификация
Один pure helper и одна экспортируемая константа являются источником для authoring, Optimize и multi-wall renderer:
NEAR_AXIS_MAX_DEGREES = 0.25;- сравнение выполняется по нормализованному отношению minor/major component,
эквивалентному
tan(0.25°); renderer может продолжать использовать эквивалентный dot/sine form для orthogonal pairs; - граница включительна;
- zero-length не классифицируется;
- exact horizontal/vertical уже canonical и не считается исправлением;
- endpoint order, winding, coordinate scale и theme не влияют на результат.
Для exact grid edges это означает: 316×1 классифицируется, 316×2 — нет.
Порог не расширяется экранным zoom/tolerance и не поглощает полный grid step
на коротком сегменте, если угол превышает 0.25°.
5. Authoring contract
5.1 Walls chain
После архитектурного endpoint/grid snap, но до hover preview и commit, free endpoint сравнивается с anchor. Если сегмент near-axis, minor coordinate free endpoint приравнивается minor coordinate anchor. Это точный grid node и один результат для hover/click.
Architectural endpoint на соседнем узле не имеет более высокого приоритета, если соединение создало бы запрещённый near-axis segment: авторская линия остаётся exact-axis, а существующий узел не объявляется соединённым. Resolver обязан показывать фактически сохраняемую точку; невидимый post-click rewrite запрещён.
Shift сохраняет своё действующее 45°-ограничение. Оно сначала выбирает exact 45° ray и потому не превращается в near-axis. Ctrl/Cmd closure применяет то же правило к closing edge; если выравнивание не замыкает exact first node, contour не объявляется закрытым автоматически.
5.2 Resize
Safe Resize #277 по-прежнему принимает только exact horizontal/vertical input с допуском на storage noise. Near-axis edge, существовавшая до задачи, не становится автоматически eligible: для неё есть Optimize.
Каждый candidate Resize строится из axis/normal plan и перед preview проходит тот же exact-axis postcondition. Любая minor-coordinate арифметическая погрешность схлопывается на исходную axis; результат в пределах 0.25° не может быть сохранён как уступ. Это не разрешает diagonal/partial-shared Resize и не меняет topology. Если postcondition потребовал бы сдвинуть unrelated vertex, candidate fail-closed вместо скрытой правки.
6. Explicit Optimize repair
6.1 Кандидаты
Lossy pass рассматривает room polygon edges, saved room drafts и independent partitions после ordinary grid alignment. Physical shared wall считается один раз независимо от двух room owners. Openings, open spans и wall thickness records не являются отдельными кандидатами: они reproject/rekey из исправленной host geometry существующим canonical pipeline.
Для near-horizontal edge рассматриваются две exact-axis цели y=a.y и
y=b.y; для near-vertical — x=a.x и x=b.x. Изменяется equivalence class
совпадающего topology endpoint во всех owners, чтобы shared centreline не
расходилась. Выбирается кандидат:
- прошедший room simplicity/orientation, ownership, opening fit и production geometry preflight;
- с меньшим максимальным физическим сдвигом;
- при равенстве — сохраняющий endpoint с большим числом incident exact edges;
- при полном равенстве — детерминированный lexicographic endpoint.
Кандидаты не каскадируют: список строится из immutable input, а конфликтующие endpoint changes объединяются только если требуют одну и ту же target node. Конфликт/невалидная цель остаётся неизменной и учитывается как skipped, а не частично записывается.
6.2 Отчёт, Confirm и Undo
OptimizeReport получает как минимум:
wallsStraightened— число уникальных physical segments;maxStraightenShiftCmи пространство, где достигнут максимум;wallsStraightenSkipped— число распознанных, но небезопасных кандидатов.
Диалог показывает отдельную строку, не смешивая lossy straightening с
coordsCanonicalized или обычным moved. Максимум — верхняя граница по всем
реально перемещаемым endpoints через cell_cm собственного пространства.
Cancel/закрытие не пишет ничего. Confirm отправляет exact preview pair одной
revision-guarded config/layout transaction; обычный Optimize Undo возвращает
предыдущую геометрию. Повторный прогон после Confirm идемпотентен.
7. Scope
Входит
- shared near-axis helper/constant и перевод #279 на него;
- Walls hover/click/closure authoring rule;
- exact-axis postcondition safe Resize;
- lossy Optimize pass/report/dialog/Undo;
- wall/open-span/opening rekey и structural preflight;
- RU/EN i18n, unit, production smoke, backend optimize transaction, mutation, targeted golden и performance;
- canonical docs и оба changelog.
Не входит
- изменение grid pitch или координатного барьера #291;
- исправление углов больше 0.25°, arbitrary vertex editor или angle dialog;
- автоматическая миграция на load/save без Optimize confirmation;
- изменение renderer bevel/junction geometry #288;
- modifier для сохранения невидимого уступа;
- auto-repair кандидата, который не проходит structural preflight.
8. Acceptance criteria
AC1. Единая boundary matrix
Pure tests покрывают exact axis, 0.181315°, mirrored/reversed варианты, ровно
0.25°, значение выше порога, 316×1, 316×2, short edges и diagonal 30°.
Authoring, Optimize и renderer #279 импортируют один threshold source; source
guard запрещает отдельные литералы 0.25 в этих classifiers.
AC2. Walls не сохраняет 316×1
Production-bundle smoke рисует сегмент с raw/grid result 316×1. Hover
rubber-band и committed draft/room показывают 316×0; exact endpoint,
segment count и thickness metadata согласованы. Отдельно проверяются ordinary
click, resumed draft, closure и existing-node snap conflict.
AC3. Resize не создаёт near-axis output
Outer и exact-shared safe drags сохраняют moving/side edges exact-axis на всех
grid nodes. Mutation, добавляющая minor component меньше порога перед commit,
либо канонизируется без unrelated changes, либо fail-closed. Pre-existing
316×1 handle остаётся disabled как diagonal до Optimize.
AC4. Optimize чинит реальный 316×1
Tracked test/fixtures/279-near-orthogonal-junction.json содержит duplicated
shared physical edge 316×1.
Preview сообщает wallsStraightened: 1, maximum одного grid step в корректных
сантиметрах и ноль double-counting owners. Confirm создаёт exact horizontal
shared edge; Cancel оставляет JSON byte-equivalent.
AC5. Related geometry сохраняется
После repair:
- обе room copies имеют exact одинаковые endpoints;
- topology vertex count/order и room ids не меняются;
- wall thickness/open spans rekey lossless;
- openings остаются на host, fit и angle корректны;
checkWallKeys,checkMixedRoleRecords, references и production geometry preflight дают ноль нарушений;- повторный Optimize
changed:falseи все straightening counters нулевые.
AC6. True diagonals не меняются
316×2, 30° room edge, diagonal partition и 45° Walls segment byte-equivalent
после preview/Confirm; они не входят в wallsStraightened/skipped.
AC7. Report является верхней границей
Multi-space fixture с разными cell_cm доказывает, что
maxStraightenShiftCm не меньше фактического движения ни одного endpoint и
правильно называет space. Shared owners не удваивают count. Unsafe candidate
не попадает в moved count и остаётся в skipped.
AC8. UI/Undo/revision safety
Dialog light/dark показывает отдельную строку lossy repair. Confirm без актуальных revisions получает conflict и не пишет partial pair; success даёт одну Undo. Reload/event/cold read совпадают с preview.
AC9. Мутанты
Обязательны мутанты:
- порог ниже
0.181315°; - strict
<вместо inclusive boundary; - bypass authoring snap;
- repair only one owner shared wall;
- считать room copies как две стены;
- применять Optimize без confirmation.
Каждый убивается соответствующим AC без повышения global golden tolerance.
AC10. Реальные планы и видимое снижение
npm run invariants проходит на real-plan-first-floor.json и
real-plan-second-floor.json до и после подтверждённого Optimize. Audit считает
почти-ортогональные topology nodes до/после без удвоения shared owners; после
Confirm их число строго уменьшается на плане с repair candidates. Принятая
владельцем строка Optimize «Выпрямлено стен: N; максимальное перемещение: X»
показывает это изменение до подтверждения, причём N равно числу уникальных
исправляемых physical walls. Повторный Optimize сообщает ноль исправлений.
Настоящие диагонали не входят ни в node count, ни в N.
Доказательство: реальный invariants runner, production-bundle Optimize smoke и exact report assertions на обеих tracked fixtures.
AC11. Локальные гейты
npm run typecheck;npm test;npm run buildи bundle parity;npm run invariants;node scripts/check-docs.mjs;- targeted Walls/Resize/Optimize smokes и mutation;
- targeted semantic golden verify.
Полные golden, smoke, performance и Linux HA harness выполняются перед beta.
9. Совместимость, touch, security и performance
Schema/model/storage version не меняются. Old frontend/backend читают исправленный обычный polygon; downgrade не требует migration. Старый near-axis план сохраняется до явного Optimize.
Plan editor desktop-first. Touch editor — best effort: near-axis rule работает для clean tap, а pinch/pan/pointercancel не создают segment и не подтверждают Optimize. View/kiosk rendering fully supported и сохраняет #279.
Новых HA actions/security boundaries нет. Authoring classifier O(1).
Optimize pass линейный по edges плюс существующий bounded preflight; candidate
evaluation не может стать global unbounded combinatorial search. Safe Resize
сохраняет p95 budgets из docs/RESIZE.md; full performance gate обязателен.
10. Риски и меры
- Автоматическое authoring-выравнивание без bypass может выбрать неверную ось рядом с порогом. Мера: inclusive boundary matrix AC1, hover/commit parity AC2 и отрицательные true-diagonal cases AC6.
- Lossy Optimize может сдвинуть только одного owner общей стены или потерять opening/thickness metadata. Мера: immutable candidate, shared equivalence class и полный набор invariants AC5/AC10.
- Repair одного уступа может каскадно создать другой либо изменить больше topology, чем показано в отчёте. Мера: кандидаты из immutable input, conflict-as-skipped, верхняя граница AC7 и идемпотентность AC5/AC10.
- Новый classifier может разойтись с renderer #279. Мера: один threshold source и source guard AC1.
11. Откат
Чистый revert implementation-коммита возвращает прежнее authoring/Optimize поведение. Schema не меняется; уже явно подтверждённая пользователем обычная polygon geometry остаётся читаемой и не требует downgrade migration.
12. Ожидаемые файлы
Product code:
- новый/существующий pure axis helper;
src/wall-thickness.ts(единый threshold import);src/houseplan-card.ts;src/resize.ts;src/align-grid.ts/src/plan-optimizer.ts;src/i18n/en.json,src/i18n/ru.json.
Backend boundary:
- websocket Optimize применяет уже существующую exact preview transaction; schema изменения не ожидаются.
Tests/evidence:
- unit для helper, align/optimizer/resize/wall-thickness;
- minimized fixture без пользовательских имён;
- production-bundle Walls/Resize/Optimize smoke;
- mutation registry, targeted golden и performance.
Документация:
docs/CANVAS.md,docs/RESIZE.md,docs/WALL-THICKNESS.md,docs/ARCHITECTURE.md,docs/USER-GUIDE.md,docs/USER-GUIDE.ru.md,docs/TESTING.md,docs/CONFIG-COMPATIBILITY.md;docs/CHANGELOG.md,docs/CHANGELOG.ru.md.
13. Release
Implementation-коммит имеет Issue: #290, User-Visible: yes и оба
changelog. Изменившиеся editor/Optimize goldens и docs screenshots принимаются
только из штатного Linux artifact после npm run bundle:sync.
14. Принятые технические предположения
- Выравнивается free endpoint, а не anchor текущего Walls segment; уже существующая geometry не сдвигается молча.
- Near-axis existing edge не становится Resize-eligible: её исправляет только подтверждённый Optimize, после чего ordinary Resize доступен по #277.
- Если обе exact-axis цели Optimize одинаково безопасны, degree/lexicographic tie-break является техническим детерминизмом, а не новым пользовательским выбором.
- Unsafe repair перечисляется как skipped; Optimize не обязан чинить structural-invalid geometry любой ценой.
- Touch editor: best effort / intentionally degraded; safety floor сохранён.