Files
houseplan-card/test/wall-merge.test.mjs
2026-09-06 17:38:47 +03:00

242 lines
11 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,
spaceMergeGeometry,
} 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 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);
});
// --- 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);
});
test('issue 229 space geometry keeps rooms in the coordinates partitions use', () => {
// Both callers used to build this themselves and both rescaled the polygon,
// so a junction on a room side was never found (CODE-REVIEW-229-r1 High-1,
// r2 Medium-1). One function now, checked in the units both paths share.
const space = {
rooms: [
{ id: 'r1', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
{ id: 'r2', x: 0.6, y: 0.1, w: 0.2, h: 0.2 },
{ id: 'broken' },
],
wall_columns: [{ id: 'c1', center: [0.7, 0.7] }],
};
const geometry = spaceMergeGeometry(space);
assert.deepEqual(geometry.roomPolygons[0], [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]]);
assert.deepEqual(geometry.roomPolygons[1][0], [0.6, 0.1], 'x/y/w/h rooms come through too');
assert.equal(geometry.roomPolygons.length, 2, 'a room without geometry is skipped');
assert.deepEqual(geometry.columns, space.wall_columns);
});
test('issue 229 a junction on a room side survives, through the shared geometry', () => {
const partitions = [seg('p1', 100, 500, 300, 500), seg('p2', 300, 500, 500, 500)];
const space = { rooms: [{ id: 'r1', x: 100, y: 100, w: 400, h: 400 }] };
const result = mergeCollinearPartitions(partitions, {
pitch: PITCH, geometry: spaceMergeGeometry(space),
});
assert.equal(result.merged, 0, 'the middle of the room side holds the node');
assert.equal(result.partitions.length, 2);
});