diff --git a/docs/specs/298-resize-wall-thickness-carrier.md b/docs/specs/298-resize-wall-thickness-carrier.md new file mode 100644 index 00000000..668da6a8 --- /dev/null +++ b/docs/specs/298-resize-wall-thickness-carrier.md @@ -0,0 +1,352 @@ +# Issue #298 — Resize сохраняет wall records на решётке и на carrier + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/298 +- **Статус:** первая редакция для внешнего ревью; канонический статус задаётся + метками issue +- **Тип / приоритет:** bug / P1 +- **Оценка:** пользовательская ценность 10/10; ценность для разработки 10/10; + сложность 7/10; риск 8/10 +- **Область:** fixed-topology Resize, exact wall records, live preview/commit, + grid barrier, real-plan invariant smoke и mutation coverage +- **Модель данных:** schema и model version не меняются; исправляются только + записи, которые переносит новый Resize-жест +- **Связано:** #253, #277, #289, #291, #293, #297, + `docs/RESIZE.md`, `docs/WALL-THICKNESS.md`, `docs/CANVAS.md` + +## 1. Сценарий и подтверждённая причина + +Пользователь безопасно сдвигает одну стену комнаты. Moving wall едет +параллельно себе, а две соседние стены меняют длину. После жеста визуально всё +может выглядеть правдоподобно, но запись толщины другой стены получает endpoint, +которого нет ни среди вершин room polygons, ни на границе wall carrier. На +следующем редактировании это проявляется потерей кладки, ошибкой Optimize или +сломавшейся ручкой Resize. + +На обеих приложенных к issue реальных fixtures причина подтверждена в +`rekeyWallsAfterMove()`. Для точки `p` на старом ребре helper вычисляет +относительную долю `t`, затем возвращает точку с той же долей на новом ребре. +Когда fixed-topology Resize двигает только один endpoint бокового ребра, +внутренний endpoint wall record тоже пропорционально уезжает. Так `-85` +становится `-82.457`, хотя такой geometry boundary в новом polygon нет. + +Текущий commit guard проверяет только мультимножество `cm` и число open spans. +Он не доказывает, что все exact wall records лежат на carriers нового плана, +поэтому повреждённая запись сохраняется. + +## 2. Пользовательский результат + +Resize продолжает выглядеть и управляться как после #277/#293. Отличие в +сохранённых данных: перемещаются только wall endpoints, для которых существует +однозначное соответствие старой и новой topology vertex. Остальные endpoints +не интерполируются и остаются на своей физической границе. После commit все +записи толщины остаются на grid и на room-wall carriers; последующие Resize, +Optimize и рендер не получают скрытую повреждённую геометрию. + +Если lossless correspondence доказать нельзя, кандидат не сохраняется. Live +preview остаётся на последней валидной позиции, а при отсутствии валидной +позиции жест завершается существующей локализованной ошибкой Resize без config +write и Undo entry. Автоматического снапа настоящей off-grid координаты или +догадки по ближайшей стене нет. + +## 3. Fixed-topology correspondence + +### 3.1 Источник истины + +Для каждого затронутого room edge Resize уже имеет параллельную пару +`old edge → new edge` из immutable pre-drag snapshot и exact candidate. +Topology signature гарантирует одинаковое число и циклическую identity +вершин. На этой основе строится таблица соответствия: + +`old room vertex → new room vertex`. + +В таблицу входят только действительно изменившиеся vertices. Одинаковая +старая точка, принадлежащая двум room copies общей стены, обязана иметь ровно +одну новую destination. Несколько разных destinations означают конфликт и +отменяют кандидат; порядок rooms или wall records не выбирает победителя. + +### 3.2 Exact wall records + +Пара `a/b` является identity exact wall record; compatibility `key` только +пересчитывается из итогового span. + +Каждая исходная exact запись обрабатывается из immutable snapshot: + +1. запись делится на атомы в endpoints перекрывающихся затронутых old edges; +2. endpoint атома заменяется только если он равен old topology vertex из + таблицы correspondence в пределах canonical coordinate epsilon; +3. interior endpoint без vertex correspondence остаётся byte-equivalent — + относительная доля длины ребра для него не вычисляется; +4. нулевые атомы удаляются, совместимые соседние атомы с одинаковым `cm` + склеиваются только когда их endpoints точно совпали и они коллинеарны; +5. для каждого изменённого span заново строится compatibility key, а `cm` и + известные совместимые поля сохраняются. + +Длинная запись, пересекающая затронутый и незатронутый пролёты, обязана +разделиться на границе old edge: только endpoint затронутого атома следует за +вершиной. Нельзя affine-масштабировать целую запись или её внутреннюю точку. + +### 3.3 Legacy key-only records + +Legacy запись без валидных `a/b` не имеет длины и endpoints, поэтому ей нельзя +изобретать атомы. Разрешён только однозначный whole-edge fallback: старый key +точно соответствует целому изменённому edge и переносится на его новый key. +Projected-midpoint перенос по относительной доле запрещён в production Safe +Resize. Если legacy key затронут, но whole-edge соответствие неоднозначно, +кандидат fail closed и не сохраняется. Незатронутые legacy records остаются +byte-equivalent. + +Исторический generic scale/rotate helper может остаться для изолированных +pure-тестов старых преобразований, но production Safe Resize обязан вызывать +fixed-topology API. Переиспользование proportional `t` в этом path запрещено. + +## 4. Carrier и lattice preflight + +После wall rekey, но до принятия live preview, product code проверяет exact +candidate целиком. + +Для каждой exact wall entry: + +- `a` и `b` конечны и лежат на canonical grid либо отличаются не больше + действующего near-node storage epsilon; +- весь открытый span между `a` и `b` покрывается непрерывным объединением + коллинеарных room edges; недостаточно проверить только midpoint; +- нет зазора между carrier atoms и нет участка, принадлежащего только + продолжению оси за пределами стены; +- compatibility key согласован с итоговыми `a/b`; +- `cm` остаётся в допустимом диапазоне, а мультимножество исходных физических + значений толщины не теряется. + +Проверка допускает одну compact record через несколько смежных коллинеарных +room edges, но не допускает запись через разрыв. Independent partitions не +являются carrier для `space.walls`: их толщина хранится своей partition +geometry. Open spans по-прежнему проходят существующий отдельный rekey и +carrier validation. + +`LATTICE_NOISE_STEPS` из измерительного гейта отличает форматный near-node шум +от настоящего off-grid значения. Исправление не округляет авторскую +координату, удалённую от grid: такой candidate отклоняется. Уже сохранённые +старые off-grid records не мигрируют при загрузке и не меняются без жеста. + +## 5. Preview, commit и failure semantics + +1. Preview каждый раз строится из immutable pre-drag rooms/walls/open spans, + а не из предыдущего кадра. +2. Fixed-topology mapping и carrier/lattice preflight являются частью одного + candidate builder, которым пользуются preview и pointerup. +3. `_serverCfg`, history и queued save не меняются до успешного pointerup. +4. Commit принимает только уже показанный exact preview и повторно проверяет + snapshot/plan signature. Отдельного второго rekey нет. +5. Конфликт correspondence, неоднозначный legacy record либо carrier/lattice + failure отклоняет кандидат целиком. Частичная запись запрещена. +6. После runtime reject сохраняется последний валидный preview; если валидного + ненулевого preview не было, config/history byte-equivalent исходному. +7. Undo/Redo восстанавливают rooms, openings, walls и open spans целиком. + +Предсказуемый конфликт, который можно доказать в eligibility, должен сделать +handle disabled до pointer capture. Непредсказуемый runtime reject использует +уже существующее сообщение `resize.commit_failed`; новый persisted reason или +новый UX в этой задаче не вводится. + +## 6. Scope + +### Входит + +- fixed-topology rekey exact и legacy wall records; +- endpoint correspondence без proportional interpolation; +- product carrier/lattice preflight до preview/commit; +- точные регрессии обеих fixtures из #298; +- обновление `smoke_edit_walk`/`KNOWN`, mutation и performance evidence; +- canonical Resize/wall-thickness/testing docs и оба changelog. + +### Не входит + +- изменение eligibility, safe range, pointer UX или topology #277/#293; +- автоматический ремонт уже сохранённого bad plan через Optimize; +- schema migration, integer-coordinate storage либо общий ADR #282; +- смешанные shared/exterior роли #289/#299; +- изменение толщины пользователем, renderer wall union или opening geometry; +- touch parity Plan editor сверх общего safety floor. + +## 7. Acceptance criteria + +### AC1. Exact repro второго этажа + +На `real-plan-second-floor.json` production Resize выполняет указанную в issue +последовательность `room-a`, edge 2, `x=96 → 100` grid steps. Запись исходного +горизонтального span `y=304` сохраняет endpoint на существующей topology +boundary; значение `-85` не превращается в `-82.457` или другую +пропорциональную точку. + +После preview и commit нет `off_lattice_coordinate`, `wall_carrier`, wall-key, +reference и physical-geometry нарушений. Moving/shared rooms и openings +сохраняют контракты #277/#293. + +**Доказательство:** fixture-backed pure/unit regression и production-bundle +pointer smoke; assert проверяет конкретную identity записи до/после, а не +только отсутствие исключения. + +### AC2. Exact repro первого этажа + +На `real-plan-first-floor.json` последовательность `room-b`, edge 0, +`x=49 → 52` не создаёт endpoint `59.538` на wall record линии `y=155`. +Итоговые endpoints принадлежат реальным vertices/carrier boundaries +`17/52/57/101` согласно новому candidate. + +**Доказательство:** отдельный fixture-backed regression с exact endpoint, +carrier/lattice и unchanged-record assertions. + +### AC3. Untouched records действительно не меняются + +Table-driven unit покрывает non-shared wall, exact shared pair, длинную запись, +пересекающую moved и untouched spans, reversed orientation и несколько +одинаковых `cm`. Запись без overlap и без moved endpoint остаётся deep/byte +equivalent, включая key и порядок. Затронутые записи сохраняют все значения +`cm`; коллизия destinations отклоняет весь candidate. + +### AC4. Legacy compatibility не угадывается + +- unambiguous whole-edge key переезжает на новый whole-edge key; +- untouched key-only record не меняется; +- key-only midpoint на части изменившего длину edge не переносится + пропорционально и приводит к fail-closed candidate; +- exact record всегда использует `a/b`, даже если старый compatibility key + неверен. + +**Доказательство:** pure unit и production-preview reject с нулём config/history +writes. + +### AC5. Carrier preflight проверяет весь span + +Positive cases: одна room edge и непрерывная цепочка нескольких коллинеарных +room edges. Negative cases: оба endpoints на carriers, но между ними разрыв; +midpoint на продолжении за пределами edge; endpoint на independent partition; +настоящая off-grid coordinate дальше `LATTICE_NOISE_STEPS`; conflicting shared +destinations. Каждый negative candidate отклоняется до сохранения. + +### AC6. Шесть edit-walk запусков больше не несут этот долг + +Проходят: + +```text +node demo/smoke_edit_walk.mjs --seed 1 --plan real-plan-second-floor.json +node demo/smoke_edit_walk.mjs --seed 2 --plan real-plan-second-floor.json +node demo/smoke_edit_walk.mjs --seed 3 --plan real-plan-second-floor.json +node demo/smoke_edit_walk.mjs --seed 1 --plan real-plan-first-floor.json +node demo/smoke_edit_walk.mjs --seed 2 --plan real-plan-first-floor.json +node demo/smoke_edit_walk.mjs --seed 3 --plan real-plan-first-floor.json +``` + +Для результатов Resize нет `off_lattice_coordinate` и `wall_carrier`. +`KNOWN` обновляется в том же implementation-коммите только для исправленных +kinds. Независимый долг `mixed_role_record` #299 не скрывается и не считается +регрессией #298. + +### AC7. Preview/commit/Undo атомарны + +Valid pointer drag показывает exact candidate до release, затем создаёт ровно +один config write и один Undo entry. Undo возвращает byte-equivalent исходные +rooms/walls/open spans; Redo возвращает тот же exact candidate. Forced carrier +failure даёт ноль writes/history и существующую локализованную ошибку один раз. + +### AC8. Мутационный страж + +Mutation возвращает proportional `t` mapping для interior endpoint либо +отключает carrier preflight. AC1/AC2 или AC5 обязаны падать. Blanket-disable +Resize не проходит positive pointer scenario AC7 и существующие #293 smokes. + +### AC9. Производительность и локальные гейты + +Mapping строится один раз на candidate из уже ограниченного набора затронутых +edges. Нельзя добавлять глобальное pairwise сравнение всех records со всеми +room edges на каждый `pointermove` без подготовленного carrier index. p95 budget +Resize из `docs/RESIZE.md` сохраняется; если hot path меняется, targeted +benchmark сравнивается с baseline. + +Обязательны: + +- `npm run typecheck`; +- `npm test`; +- `npm run build` и bundle parity; +- `node scripts/check-docs.mjs`; +- targeted Resize pointer smoke, шесть edit-walk запусков и mutation gate. + +Полные golden, smoke, performance и Linux HA harness выполняются перед beta. + +## 8. Совместимость, touch и security + +Schema/storage/model version не меняются. Старые планы читаются без фоновой +перезаписи; исторический off-grid долг остаётся видимым до явной правки или +отдельного Optimize repair. Новый commit только запрещает Safe Resize создавать +новый долг. + +Plan editor остаётся desktop-first. Touch — best effort, но safety floor общий: +single-pointer drag не пишет invalid candidate, pinch/pan, pointercancel и lost +capture не создают config/history entries. Новых HA actions, сетевых запросов, +HTML/CSS input или security boundaries нет. + +## 9. Риски и меры + +- **Слишком широкий vertex match** может двигать соседний interior endpoint. + Мера: canonical epsilon, explicit correspondence и exact AC1–AC3. +- **Слишком узкий match** потеряет толщину moved wall. Мера: positive + non-shared/shared/long-record matrix и `checkWallRecordsPreserved`. +- **Проверка только endpoints/midpoint** пропустит разрыв carrier. Мера: full + interval coverage AC5. +- **Legacy fallback снова введёт интерполяцию.** Мера: explicit fail-closed AC4 + и mutant AC8. +- **Соседняя #299 меняет тот же `KNOWN`.** Мера: #298 удаляет только два своих + kinds и при rebase сохраняет независимые mixed-role строки. +- **Новый global validation замедлит pointermove.** Мера: подготовленный index + и benchmark AC9. + +## 10. Откат + +Откат — полный revert implementation-коммита вместе с tests/docs/`KNOWN`. +Миграция или восстановление schema не нужны. Конфиги, уже сохранённые новой +версией, используют прежнюю схему и читаются старой версией. + +## 11. Ожидаемые файлы и release artifacts + +Product code: + +- `src/wall-thickness.ts` — fixed-topology rekey; +- `src/houseplan-card.ts` — candidate integration и fail-closed preflight; +- отдельный pure carrier helper допускается, если не дублирует invariant model. + +Tests/evidence: + +- `test/wall-thickness.test.mjs` и/или targeted Resize unit; +- `demo/smoke_edit_walk.mjs`, включая точное обновление `KNOWN`; +- production-bundle Resize pointer smoke; +- mutation registry и targeted benchmark при изменении hot path. + +Документация: + +- `docs/RESIZE.md`, `docs/WALL-THICKNESS.md`, `docs/TESTING.md`; +- при изменении архитектурной границы — `docs/ARCHITECTURE.md`; +- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`. + +Визуальный дизайн не меняется, поэтому новый golden baseline не ожидается. +Если штатный docs screenshot всё же изменится, принимается только artifact +Linux workflow после визуального review и bundle sync. + +Implementation-коммит имеет terminal trailers: + +```text +Issue: #298 +User-Visible: yes +``` + +Issue не закрывается вручную: она закрывается пакетно при выпуске beta. + +## 12. Принятые технические предположения + +1. Зафиксированные в issue gestures и tracked real-plan fixtures являются + достаточным privacy-safe regression input; новые приватные данные не нужны. +2. `space.walls` относится только к room-wall carriers. Independent partitions + хранят толщину в собственном объекте и не оправдывают wall record вне room + boundary. +3. Existing `resize.commit_failed` достаточно для редкого runtime reject; + новый user-facing reason не требуется, пока eligibility не меняется. +4. Near-node storage noise из #291 отличается от настоящего repro #298; + исправление не расширяет snap tolerance. diff --git a/docs/specs/README.md b/docs/specs/README.md index 598dbac7..f8cc208e 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -77,6 +77,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#280](https://github.com/Matysh/houseplan-card/issues/280) Backend принимает доказанный Optimize rehost | [280-optimize-rehost-validation.md](280-optimize-rehost-validation.md) | | [#281](https://github.com/Matysh/houseplan-card/issues/281) Честный Resize после outer-partition reconciliation | [281-resize-zero-range.md](281-resize-zero-range.md) | | [#293](https://github.com/Matysh/houseplan-card/issues/293) Активная рукоятка Resize выполняет pointer-жест | [293-resize-pointer-noop.md](293-resize-pointer-noop.md) | +| [#298](https://github.com/Matysh/houseplan-card/issues/298) Resize сохраняет wall records на решётке и на carrier | [298-resize-wall-thickness-carrier.md](298-resize-wall-thickness-carrier.md) | ## P2