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
+32
View File
@@ -24,6 +24,7 @@ import {
degradeWalls, normalizeWallIntervals, rekeyWallsAfterMove, roomWallProfile,
setWallThickness, type WallEntry,
} from './wall-thickness';
import { applyOpeningMoves, mergeCollinearPartitions } from './wall-merge';
/** Bump when a new lossless maintenance pass is added. */
export const PLAN_MODEL_VERSION = 6;
@@ -50,6 +51,8 @@ export interface OptimizeReport extends AlignReport {
wallsMerged: number;
/** Touching/overlapping virtual pieces merged on the same room pair. */
spansMerged: number;
/** Collinear independent-wall records collapsed into one (#229). */
partitionsMerged: number;
}
export interface OptimizeResult {
@@ -401,6 +404,7 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
let wallsMerged = 0;
let spansMerged = 0;
let partitionsMerged = 0;
let canonicalized = 0;
for (let i = 0; i < config.spaces.length; i++) {
const before = beforeSpaces[i];
@@ -492,6 +496,33 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
wallsMerged += Math.max(0, wallParts - walls.length);
if (walls.length) space.walls = walls;
else delete space.walls;
// Independent walls drawn in several clicks: collapse the seams that have
// piled up. Drawing merges only its own chain, so this is where an older
// plan finally loses them — explicitly, with a report and an undo (#229).
const partitionMerge = mergeCollinearPartitions(space.partitions || [], {
pitch: GRID_STEP_N,
geometry: {
roomPolygons: (space.rooms || [])
.map((room: any) => roomPoly(room))
.filter((poly: number[][] | null): poly is number[][] => !!poly)
.map((poly: number[][]) => poly.map((p) => [p[0] / NORM_W, p[1] / NORM_W])),
columns: space.wall_columns || [],
draftEnds: (space.room_drafts || []).flatMap((draft: any) => {
const points = draft?.points || [];
return points.length ? [points[0], points[points.length - 1]] : [];
}),
},
});
if (partitionMerge.merged) {
partitionsMerged += partitionMerge.merged;
space.partitions = partitionMerge.partitions;
applyOpeningMoves(space.openings, space.partitions, partitionMerge.openingMoves, {
coordScale: NORM_W,
cellCm: Number(space.cell_cm) > 0 ? Number(space.cell_cm) : DEFAULT_CELL_CM,
gridPitch: GRID_PITCH,
});
}
const canonicalAfter = JSON.stringify({
spans: space.open_spans || [],
links: (space.rooms || []).map((r: any) => [r.id, r.open_to || []]),
@@ -528,6 +559,7 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
canonicalized,
wallsMerged,
spansMerged,
partitionsMerged,
},
changed,
};