Files
houseplan-card/legacy/specs/290-near-axis-authoring-and-repair.md
Claudeandclaude[bot] df46fd1c3e docs(hygiene): ТЗ выпущенных задач без живых ссылок — в legacy/specs (#682)
Волна 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
2026-09-27 22:10:46 +00:00

350 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
Проблема состоит из двух частей:
1. Walls/Resize не имеют единого authoring-правила, запрещающего почти
горизонтальный/вертикальный результат;
2. Optimize не имеет явно разрешённого lossy pass и поэтому правильно считает
существующий `316×1` каноническим.
## 2. Решения владельца
Зафиксированы 2026-08-24:
1. При создании ребро в пределах допуска автоматически выравнивается по
ближайшей горизонтальной/вертикальной оси. Специального modifier bypass нет.
2. Существующие уступы исправляются отдельным lossy-пунктом Optimize с отчётом
«Выпрямлено стен: N; максимальное перемещение: X» и только после общего
подтверждения.
3. Единый допуск — отклонение не более `0.25°` от горизонтали или вертикали,
тот же продуктовый порог, что использует устойчивый renderer #279.
4. Настоящие диагонали за пределами допуска не меняются.
Открытых продуктовых вопросов нет.
## 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 не
расходилась. Выбирается кандидат:
1. прошедший room simplicity/orientation, ownership, opening fit и production
geometry preflight;
2. с меньшим максимальным физическим сдвигом;
3. при равенстве — сохраняющий endpoint с большим числом incident exact edges;
4. при полном равенстве — детерминированный 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. Принятые технические предположения
1. Выравнивается free endpoint, а не anchor текущего Walls segment; уже
существующая geometry не сдвигается молча.
2. Near-axis existing edge не становится Resize-eligible: её исправляет только
подтверждённый Optimize, после чего ordinary Resize доступен по #277.
3. Если обе exact-axis цели Optimize одинаково безопасны, degree/lexicographic
tie-break является техническим детерминизмом, а не новым пользовательским
выбором.
4. Unsafe repair перечисляется как skipped; Optimize не обязан чинить
structural-invalid geometry любой ценой.
5. Touch editor: best effort / intentionally degraded; safety floor сохранён.