Files
houseplan-card/test/wall-merge.test.mjs
T
Codex e6be43b90f 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
2026-08-21 12:40:34 +03:00

213 lines
10 KiB
JavaScript

// Issue #229: a straight run of one thickness is one record; a node stays only
// where something else meets it. docs/specs/229-merge-collinear-partitions.md
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyOpeningMoves, EPS_JOIN, mergeCollinearPartitions, junctionAt, remapHostT,
} from '../test-build/wall-merge.js';
const PITCH = 10; // one grid pitch in test coordinates
const seg = (id, ax, ay, bx, by, cm = 15) => ({ id, a: [ax, ay], b: [bx, by], cm });
const merge = (list, options = {}) => mergeCollinearPartitions(list, { pitch: PITCH, ...options });
const geometryOf = (result) => result.partitions
.map((p) => `${p.a.join()}→${p.b.join()}@${p.cm}`).sort();
// --- AC1: прямая цепочка становится одной записью ----------------------------
test('issue 229 a straight chain of one thickness collapses into a single record', () => {
const result = merge([
seg('a', 0, 0, 100, 0), seg('b', 100, 0, 220, 0), seg('c', 220, 0, 300, 0),
]);
assert.equal(result.partitions.length, 1);
assert.equal(result.merged, 2);
const [only] = result.partitions;
assert.deepEqual([only.a, only.b], [[0, 0], [300, 0]]);
assert.equal(only.cm, 15);
});
test('issue 229 a corner is not a straight run', () => {
const result = merge([seg('a', 0, 0, 100, 0), seg('b', 100, 0, 100, 90)]);
assert.equal(result.partitions.length, 2);
assert.equal(result.merged, 0);
});
// --- AC2: узел с причиной остаётся -------------------------------------------
test('issue 229 a third partition at the joint keeps the node', () => {
const result = merge([
seg('a', 0, 0, 100, 0), seg('b', 100, 0, 200, 0), seg('t', 100, 0, 100, 80),
]);
assert.equal(result.merged, 0, 'a T of three walls is a junction');
});
test('issue 229 a room side keeps the node, corner or not', () => {
// The room's nearest CORNER is far away; its SIDE runs right through the
// joint. A partition meeting the middle of a room wall is an ordinary
// T-junction and must survive the merge.
// The two segments meet at (100, -100). The room's top side runs along
// y = -100 from x = 0 to x = 400, so the joint sits in the MIDDLE of that
// side — 100 and 300 units away from either corner of the room.
const room = [[[0, -100], [400, -100], [400, 300], [0, 300]]];
const chain = [seg('a', 100, -300, 100, -100), seg('b', 100, -100, 100, 0)];
assert.equal(merge(chain).merged, 1, 'nothing meets this joint yet');
const withRoom = merge(chain, { geometry: { roomPolygons: room } });
assert.equal(withRoom.merged, 0, 'the joint sits on a room side, not on a corner');
});
test('issue 229 a column or a draft end keeps the node', () => {
const chain = [seg('a', 0, 0, 100, 0), seg('b', 100, 0, 200, 0)];
assert.equal(merge(chain, {
geometry: { columns: [{ id: 'c', shape: 'circle', center: [100, 0], cm: 30 }] },
}).merged, 0);
assert.equal(merge(chain, { geometry: { draftEnds: [[100, 0]] } }).merged, 0);
});
// --- AC4: разная толщина ------------------------------------------------------
test('issue 229 different thickness is a real break', () => {
const result = merge([seg('a', 0, 0, 100, 0, 15), seg('b', 100, 0, 200, 0, 25)]);
assert.equal(result.merged, 0);
assert.equal(result.partitions.length, 2);
});
// --- AC5: допуски -------------------------------------------------------------
test('issue 229 tolerance forgives float noise and refuses a real gap', () => {
const ulp = merge([seg('a', 0, 0, 100, 0), seg('b', 100.0000000001, 0, 200, 0)]);
assert.equal(ulp.merged, 1, 'a last-bit difference is the same point');
const gap = merge([seg('a', 0, 0, 100, 0), seg('b', 100 + EPS_JOIN * PITCH * 4, 0, 200, 0)]);
assert.equal(gap.merged, 0, 'a deliberate gap stays a gap');
});
// --- AC6: детерминизм ---------------------------------------------------------
test('issue 229 the result does not depend on the order of the input', () => {
const parts = [seg('a', 0, 0, 100, 0), seg('b', 100, 0, 200, 0), seg('c', 200, 0, 300, 0)];
const forward = geometryOf(merge(parts));
const backward = geometryOf(merge([...parts].reverse()));
const shuffled = geometryOf(merge([parts[1], parts[2], parts[0]]));
assert.deepEqual(backward, forward);
assert.deepEqual(shuffled, forward);
});
// --- AC3: проём не двигается --------------------------------------------------
test('issue 229 an opening keeps its place in the plan, not its fraction', () => {
// Door in the middle of the second segment: absolute position 150.
const parts = [seg('a', 0, 0, 100, 0), seg('b', 100, 0, 200, 0)];
const result = merge(parts);
assert.equal(result.merged, 1);
const move = result.openingMoves.find((m) => m.fromId === 'b');
assert.ok(move, 'the merged host reports how to remap its openings');
const survivor = result.partitions[0];
const length = Math.hypot(survivor.b[0] - survivor.a[0], survivor.b[1] - survivor.a[1]);
const t = remapHostT(0.5, move);
const x = survivor.a[0] + (survivor.b[0] - survivor.a[0]) * t;
assert.equal(length, 200);
assert.ok(Math.abs(x - 150) < 1e-9, `door moved to ${x}`);
});
test('issue 229 an opening survives a chain of merges and a reversed survivor', () => {
// Three segments merged in two rounds; the door sits on the last one.
const result = merge([
seg('a', 300, 0, 200, 0), seg('b', 200, 0, 100, 0), seg('c', 100, 0, 0, 0),
]);
assert.equal(result.partitions.length, 1);
const survivor = result.partitions[0];
const move = result.openingMoves.find((m) => m.fromId === 'c');
assert.ok(move, 'every merged record reports a move');
assert.equal(move.toId, survivor.id, 'the move points at the record that exists');
const t = remapHostT(0.5, move); // middle of "c" — absolute x = 50
const x = survivor.a[0] + (survivor.b[0] - survivor.a[0]) * t;
assert.ok(Math.abs(x - 50) < 1e-9, `door moved to ${x}`);
});
// --- §8.6: область слияния при завершении цепочки -----------------------------
test('issue 229 finishing a chain does not sweep unrelated seams', () => {
const parts = [
seg('new1', 0, 0, 100, 0), seg('new2', 100, 0, 200, 0), // the chain just drawn
seg('old1', 0, 500, 100, 500), seg('old2', 100, 500, 200, 500), // an old seam
];
const scoped = merge(parts, { seedIds: ['new1', 'new2'] });
assert.equal(scoped.merged, 1, 'only the chain is merged');
assert.equal(scoped.partitions.length, 3);
assert.ok(scoped.partitions.some((p) => p.id === 'old1'), 'the old seam is left alone');
// The optimiser passes no seeds and sweeps everything.
assert.equal(merge(parts).merged, 2);
});
test('issue 229 a chain merges into what it was drawn onto', () => {
const result = merge(
[seg('old', 0, 0, 100, 0), seg('new', 100, 0, 200, 0)],
{ seedIds: ['new'] },
);
assert.equal(result.merged, 1, 'the seam belongs to the new chain');
});
// --- junctionAt: прямой контракт ---------------------------------------------
test('issue 229 junctionAt measures a room by its side, not by its vertices', () => {
const room = [[[0, 0], [400, 0], [400, 300], [0, 300]]];
const middle = [200, 0];
assert.equal(junctionAt(middle, [], new Set(), { roomPolygons: room }, EPS_JOIN * PITCH), true);
const away = [200, -50];
assert.equal(junctionAt(away, [], new Set(), { roomPolygons: room }, EPS_JOIN * PITCH), false);
});
test('issue 229 a door moved onto the survivor keeps its place in the plan', () => {
const partitions = [seg('p1', 0, 0, 200, 0), seg('p2', 200, 0, 400, 0)];
// Middle of p2 → absolute x = 300.
const openings = [{
id: 'door', type: 'door', x: 300, y: 0, angle: 0, length: 40,
host: { kind: 'partition', id: 'p2', t: 0.5 },
}];
const result = merge(partitions);
const moved = applyOpeningMoves(openings, result.partitions, result.openingMoves, {
coordScale: 1, cellCm: 5, gridPitch: PITCH,
});
const [wall] = result.partitions;
const [door] = openings;
assert.equal(moved, 1);
assert.equal(door.host.id, wall.id, 'the host that vanished is not referenced any more');
const x = wall.a[0] + (wall.b[0] - wall.a[0]) * door.host.t;
assert.ok(Math.abs(x - 300) < 1e-9, `door moved to ${x}`);
assert.ok(Math.abs(door.x - 300) < 1e-9, `stale projection x: ${door.x}`);
});
test('issue 229 the legacy projection turns around with the survivor', () => {
// Both pieces are stored right-to-left; the survivor is canonicalised
// left-to-right, so the angle an older reader draws from has to be rewritten.
const partitions = [seg('p1', 400, 0, 200, 0), seg('p2', 200, 0, 0, 0)];
const openings = [{
id: 'door', type: 'door', x: 300, y: 0, angle: 180, length: 40,
host: { kind: 'partition', id: 'p1', t: 0.5 },
}];
const result = merge(partitions);
const [wall] = result.partitions;
assert.deepEqual([wall.a, wall.b], [[0, 0], [400, 0]], 'survivor points one way');
applyOpeningMoves(openings, result.partitions, result.openingMoves, {
coordScale: 1, cellCm: 5, gridPitch: PITCH,
});
const [door] = openings;
const x = wall.a[0] + (wall.b[0] - wall.a[0]) * door.host.t;
assert.ok(Math.abs(x - 300) < 1e-9, `door moved to ${x}`);
assert.ok(Math.abs(door.x - 300) < 1e-9, `stale projection x: ${door.x}`);
assert.equal(door.angle, 0, 'stale projection angle');
});
test('issue 229 openings hosted elsewhere are left alone', () => {
const partitions = [seg('p1', 0, 0, 200, 0), seg('p2', 200, 0, 400, 0), seg('far', 0, 500, 200, 500)];
const openings = [
{ id: 'w', type: 'window', x: 100, y: 500, angle: 0, length: 40, host: { kind: 'partition', id: 'far', t: 0.5 } },
{ id: 'room', type: 'door', x: 10, y: 10, angle: 0, length: 40, host: { kind: 'room', id: 'r1', t: 0.5 } },
];
const result = merge(partitions);
const before = JSON.stringify(openings);
const moved = applyOpeningMoves(openings, result.partitions, result.openingMoves, {
coordScale: 1, cellCm: 5, gridPitch: PITCH,
});
assert.equal(moved, 0);
assert.equal(JSON.stringify(openings), before);
});