feat: a straight wall is one record, not a row of seams (#229)

Рисование прямой стены в несколько кликов оставляло по записи на каждый
отрезок. Швы невидимы, пока их не тронешь: выделение хватает кусок,
перетаскивание ломает стену пополам, толщина задаётся пофрагментно. У стен
комнат этого давно нет — `normalizeWallIntervals` схлопывает каждый сплошной
участок одной толщины. Независимые перегородки жили по другому правилу.

Новый чистый модуль `src/wall-merge.ts` даёт им то же правило:

- `mergeCollinearPartitions` сращивает соседей одинаковой толщины и
  направления до неподвижной точки, но только там, где узел никому не нужен.
  Узел остаётся, если в него приходит третья перегородка, стена комнаты
  (стороной, а не только вершиной), колонна или конец сохранённого черновика.
- Направление выжившей записи канонизируется лексикографически: иначе одна и
  та же физическая стена выходила то a→b, то b→a в зависимости от порядка
  входа, и каждый host.t вдоль неё переворачивался вместе с ней.
- `applyOpeningMoves` переносит проёмы на выжившую запись: и авторитетный
  `host`, и legacy-проекцию `x/y/angle`, которую рисует старый читатель
  конфига (docs/CONFIG-COMPATIBILITY.md, #132). Проекция здесь не кэш —
  канонизация направления разворачивает угол на 180°.

Рисование сращивает только свою цепочку и то, чего она коснулась (§8.6 ТЗ):
молча править чужие швы в стороне оно не вправе — для этого есть
«Оптимизировать планы» с предпросмотром, отчётом и отменой. Оптимизация
проходит по всему пространству без seed-ограничения и отдельной строкой
сообщает, сколько записей исчезло.

Issue: #229
User-Visible: yes
This commit is contained in:
Codex
2026-08-21 12:40:34 +03:00
parent 483c29cc39
commit e6be43b90f
17 changed files with 997 additions and 190 deletions
+79
View File
@@ -717,6 +717,85 @@ export const MUTANTS = [
replace: ' .dev:not(.unavail):hover {',
}],
},
{
id: 'partition-merge-disabled',
guard: 'node demo/smoke_wall_chain_merge.mjs',
because: 'без вызова слияния прямая, нарисованная в несколько кликов, снова хранится '
+ 'набором отрезков со швами на каждом стыке (#229)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' this._mergeSpacePartitions(sp, drawnIds);',
replace: ' void drawnIds;',
}],
},
{
id: 'partition-merge-ignores-thickness',
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
because: 'сращивание отрезков разной толщины стирает намеренный переход толщины стены',
patches: [{
file: 'src/wall-merge.ts',
find: ' if (p.cm !== q.cm) return null;',
replace: ' if (false) return null;',
}],
},
{
id: 'partition-merge-ignores-junction',
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
because: 'слияние сквозь примыкание убирает узел, который держит третья стена, '
+ 'комната, колонна или черновик — §8.2 ТЗ',
patches: [{
file: 'src/wall-merge.ts',
find: ' if (junctionAt(at, list, new Set([list[i].id, list[j].id]), options.geometry, join)) continue;',
replace: ' if (false) continue;',
}],
},
{
id: 'junction-checks-room-vertices-only',
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
because: 'поиск примыкания комнаты только по вершинам пропускает T-стык к середине '
+ 'комнатной стены — штатный случай продукта (docs/specs/141-wall-junctions.md)',
patches: [{
file: 'src/wall-merge.ts',
find: ' if (finite(a) && finite(b) && distToSegment(point, a, b) <= join) return true;',
replace: ' if (finite(a) && dist(point, a) <= join) return true; void b;',
}],
},
{
id: 'partition-merge-keeps-relative-t',
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
because: 'сохранение прежней доли вместо пересчёта двигает дверь вдоль стены: '
+ 'позиция проёма относительна длине хозяина, а она при слиянии меняется',
patches: [{
file: 'src/wall-merge.ts',
find: ' const mapped = move.base + t * move.span;',
replace: ' const mapped = t;',
}],
},
{
id: 'partition-merge-skips-materialization',
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
because: 'устаревшие x/y/angle — это то, что рисует старый читатель конфига; '
+ 'слияние канонизирует направление стены, поэтому проекция обязана '
+ 'переписаться (docs/CONFIG-COMPATIBILITY.md, #132)',
patches: [{
file: 'src/wall-merge.ts',
find: ` if (resolved) Object.assign(
opening, materializePartitionOpening(opening, resolved, ctx.coordScale),
);`,
replace: ' void resolved;',
}],
},
{
id: 'chain-merge-sweeps-whole-space',
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
because: 'слияние всего пространства при рисовании молча правит старые швы в стороне, '
+ 'минуя отчёт и отмену, которые обещаны им в «Оптимизировать планы» (§8.6 ТЗ)',
patches: [{
file: 'src/wall-merge.ts',
find: ' if (seeds && !seeds.has(list[i].id) && !seeds.has(list[j].id)) continue;',
replace: ' if (false) continue;',
}],
},
{
id: 'tab-reorder-not-persisted',
guard: 'node demo/smoke_space_tab_reorder.mjs',