mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Волна 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
350 lines
20 KiB
Markdown
350 lines
20 KiB
Markdown
# 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 сохранён.
|