mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
docs(spec): define resize wall carrier contract
Issue: #298 User-Visible: no
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user