mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Рисование прямой стены в несколько кликов оставляло по записи на каждый отрезок. Швы невидимы, пока их не тронешь: выделение хватает кусок, перетаскивание ломает стену пополам, толщина задаётся пофрагментно. У стен комнат этого давно нет — `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
213 lines
10 KiB
JavaScript
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);
|
|
});
|