Files
houseplan-card/test/align-grid.test.mjs
2026-09-06 17:52:05 +00:00

364 lines
17 KiB
JavaScript

// «Выровнять всё по сетке» (docs/CANVAS.md §9) — the batch that the general
// settings offer as an explicit action, never as a silent migration.
import test from 'node:test';
import assert from 'node:assert/strict';
import { alignAllToGrid, snapN } from '../test-build/align-grid.js';
import { GRID_N, GRID_STEP_N } from '../test-build/space-geometry.js';
const S = GRID_STEP_N; // 1/240 of the plan width
const onGrid = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
/** A plan deliberately knocked off the grid in every direction. */
const detuned = () => ({
spaces: [{
id: 'f1', view_box: [0, 0, 1, 1],
rooms: [
// a polygon a third of a step off on every vertex
{ id: 'r1', name: 'Living', area: 'a1',
poly: [[0.2 + S / 3, 0.2], [0.5, 0.2 - S / 3], [0.5 + S / 3, 0.5], [0.2, 0.5]] },
// a rect whose far corner is off even though its origin is not
{ id: 'r2', name: 'Kitchen', area: 'a2', x: 0.5, y: 0.2, w: 0.3 + S / 3, h: 0.3 },
],
openings: [
// just off the r1 top wall, and not at a whole number of steps along it
{ id: 'o1', type: 'window', x: 0.3 + S / 3, y: 0.2 + S / 5, angle: 0, length: 0.06 },
],
decor: [
{ id: 'd1', kind: 'line', x1: 0.1 + S / 3, y1: 0.7, x2: 0.9, y2: 0.7 - S / 4 },
{ id: 'd2', kind: 'rect', x: 0.1 + S / 4, y: 0.8, w: 0.2 + S / 3, h: 0.05 },
{ id: 'd3', kind: 'text', x: 0.4 + S / 3, y: 0.9, text: 'hi' },
],
}],
markers: [], settings: {},
});
const detunedLayout = () => ({
dev1: { s: 'f1', x: 0.3 + S / 3, y: 0.3 },
dev2: { s: 'f1', x: 0.35, y: 0.35, k: 1.5 }, // already on a node
rl_r1: { s: 'f1', x: 0.25 + S / 2.5, y: 0.25 }, // a room LABEL counts too
junk: { s: 'f1' }, // no coordinates: skipped
});
test('snapN returns the exact nearest node and is idempotent', () => {
const node = 12 / GRID_N;
assert.equal(snapN(node), node);
const canonical = 112 * S;
const noisy = 0.46666666666666673;
assert.notEqual(noisy, canonical);
assert.equal(snapN(noisy), canonical, 'a one-ULP tail must not survive Optimize');
const off = node + S / 3;
assert.ok(Math.abs(snapN(off) - node) < 1e-12);
assert.equal(snapN(snapN(off)), snapN(off));
});
test('near-node report counts only coordinate values actually written to the candidate', () => {
const canonical = 112 * S;
const noisy = 0.46666666666666673;
const spaces = [{
id: 'f1', rooms: [
{ id: 'poly', poly: [[noisy, 0.2], [0.6, 0.2], [0.6, 0.4], [0.2, 0.4]] },
{ id: 'rect', x: 0, y: 0.5, w: noisy, h: 0.2 },
],
partitions: [
{ id: 'accepted', a: [noisy, 0.2], b: [0.6, 0.2], cm: 15 },
{ id: 'rejected', a: [noisy, 0.4], b: [0.6, 0.4], cm: 15 },
],
openings: [{
id: 'outside-host', type: 'door', x: 0.5, y: 0.4, angle: 0, length: 0.05,
host: { kind: 'partition', id: 'rejected', t: 2 },
}],
wall_columns: [{ id: 'column', shape: 'circle', center: [noisy, 0.5], cm: 20 }],
decor: [
{ id: 'line', kind: 'line', x1: noisy, y1: 0.7, x2: 0.6, y2: 0.7 },
{ id: 'box', kind: 'rect', x: 0, y: 0.8, w: noisy, h: 0.1 },
{ id: 'text', kind: 'text', x: noisy, y: 0.9, text: 'x' },
],
}];
const result = alignAllToGrid(spaces, { marker: { s: 'f1', x: noisy, y: 0.5 } });
assert.equal(result.report.moved, 0, 'ULP cleanup is not a visible move');
assert.equal(result.report.maxShift, 0);
assert.equal(result.report.maxShiftCm, 0);
assert.equal(result.report.coordsCanonicalized, 8);
assert.equal(result.changed, true);
assert.equal(result.spaces[0].partitions[0].a[0], canonical);
assert.equal(result.spaces[0].partitions[1].a[0], noisy,
'hostedFit=false keeps the rejected endpoints and must not count them');
assert.equal(result.spaces[0].wall_columns[0].center[0], canonical);
assert.equal(result.layout.marker.x, canonical);
});
test('ordinary off-grid movement is not duplicated in the near-node counter', () => {
const result = alignAllToGrid([], { marker: { x: 0.2 + S / 3, y: 0.2 } });
assert.equal(result.report.moved, 1);
assert.equal(result.report.coordsCanonicalized, 0);
});
test('#477 Optimize preserves continuous furniture/image transforms and excludes them from its report', () => {
const furniture = {
id: 'furniture', kind: 'furniture', asset: 'sofa',
x: 0.10123456789, y: 0.20345678901, w: 0.3123456789, h: 0.08765432109,
angle: 17.123456789, flip_h: true, future_field: { keep: true },
};
const image = {
id: 'image', kind: 'image', href: '/local/custom.png',
x: 0.40123456789, y: 0.50345678901, w: 0.1123456789, h: 0.18765432109,
angle: -12.987654321, flip_v: true, future_field: ['keep'],
};
const ordinary = {
id: 'ordinary', kind: 'rect',
x: 0.7 + S / 3, y: 0.7, w: 0.1, h: 0.1,
};
const spaces = [{ id: 'free-transform', rooms: [], decor: [
structuredClone(furniture), structuredClone(image), structuredClone(ordinary),
] }];
const result = alignAllToGrid(spaces, {});
assert.deepEqual(result.spaces[0].decor[0], furniture);
assert.deepEqual(result.spaces[0].decor[1], image);
assert.notDeepEqual(result.spaces[0].decor[2], ordinary,
'ordinary decor stays governed by the grid contract');
assert.equal(result.report.total, 1);
assert.equal(result.report.moved, 1);
assert.equal(result.report.maxSpace, 'free-transform');
});
test('alignAllToGrid puts every grid-bound element on a node', () => {
const { spaces, layout, report } = alignAllToGrid(detuned().spaces, detunedLayout());
const sp = spaces[0];
for (const p of sp.rooms[0].poly) { assert.ok(onGrid(p[0])); assert.ok(onGrid(p[1])); }
const r2 = sp.rooms[1];
assert.ok(onGrid(r2.x) && onGrid(r2.y));
assert.ok(onGrid(r2.x + r2.w) && onGrid(r2.y + r2.h)); // the FAR corner, not the size
const [l, rc, tx] = sp.decor;
assert.ok(onGrid(l.x1) && onGrid(l.y1) && onGrid(l.x2) && onGrid(l.y2));
assert.ok(onGrid(rc.x) && onGrid(rc.y) && onGrid(rc.x + rc.w) && onGrid(rc.y + rc.h));
assert.ok(onGrid(tx.x) && onGrid(tx.y));
assert.ok(onGrid(layout.dev1.x) && onGrid(layout.dev1.y));
assert.ok(onGrid(layout.rl_r1.x) && onGrid(layout.rl_r1.y));
assert.ok(report.moved > 0 && report.moved <= report.total);
});
test('an opening stays ON its wall — it is wall-bound, not node-bound', () => {
const { spaces } = alignAllToGrid(detuned().spaces, {});
const o = spaces[0].openings[0];
const wall = spaces[0].rooms[0].poly; // the aligned r1
// the top edge runs wall[0] → wall[1]; the opening must lie on that segment
const [ax, ay] = wall[0], [bx, by] = wall[1];
const t = ((o.x - ax) * (bx - ax) + (o.y - ay) * (by - ay)) / ((bx - ax) ** 2 + (by - ay) ** 2);
const px = ax + t * (bx - ax), py = ay + t * (by - ay);
assert.ok(Math.hypot(o.x - px, o.y - py) < 1e-9, 'opening is off its wall');
assert.ok(t > 0 && t < 1, 'opening slid off the end of its wall');
});
test('a hosted opening follows aligned partition identity and t', () => {
const spaces = [{
id: 'f1', cell_cm: 5, rooms: [],
partitions: [{ id: 'p1', a: [0.203, 0.2], b: [0.603, 0.2], cm: 15 }],
openings: [{
id: 'o1', type: 'door', x: 9, y: 9, angle: 77, length: 0.1,
host: { kind: 'partition', id: 'p1', t: 0.25 },
}],
}];
const { spaces: aligned } = alignAllToGrid(spaces, {});
const partition = aligned[0].partitions[0];
const opening = aligned[0].openings[0];
assert.deepEqual(opening.host, { kind: 'partition', id: 'p1', t: 0.25 });
assert.equal(opening.x, partition.a[0] + (partition.b[0] - partition.a[0]) * 0.25);
assert.equal(opening.y, partition.a[1] + (partition.b[1] - partition.a[1]) * 0.25);
assert.equal(opening.angle, 0);
});
test('alignment never shortens a host past its opening interval', () => {
const spaces = [{
id: 'f1', cell_cm: 5, rooms: [],
partitions: [{ id: 'p1', a: [0.0022, 0], b: [0.101, 0], cm: 15 }],
openings: [{
id: 'o1', type: 'door', x: 0.0516, y: 0, angle: 0, length: 0.098,
host: { kind: 'partition', id: 'p1', t: 0.5 },
}],
}];
const { spaces: aligned } = alignAllToGrid(spaces, {});
assert.deepEqual(aligned[0].partitions[0].a, [0.0022, 0]);
assert.deepEqual(aligned[0].partitions[0].b, [0.101, 0]);
assert.ok(Math.abs(aligned[0].openings[0].x - 0.0516) < 1e-12);
});
test('idempotent: a second run moves nothing and changes nothing', () => {
const first = alignAllToGrid(detuned().spaces, detunedLayout());
const second = alignAllToGrid(first.spaces, first.layout);
assert.equal(second.report.moved, 0);
assert.equal(second.report.coordsCanonicalized, 0);
assert.equal(second.changed, false);
assert.deepEqual(second.spaces, first.spaces);
assert.deepEqual(second.layout, first.layout);
});
test('an already-aligned plan reports nothing to do', () => {
const clean = {
spaces: [{ id: 'f1', rooms: [{ id: 'r1', poly: [[0.25, 0.25], [0.5, 0.25], [0.5, 0.5], [0.25, 0.5]] }] }],
};
const r = alignAllToGrid(clean.spaces, { d1: { s: 'f1', x: 0.25, y: 0.5 } });
assert.equal(r.report.moved, 0);
assert.equal(r.report.coordsCanonicalized, 0);
assert.equal(r.changed, false);
assert.ok(r.report.total >= 2); // it still LOOKED at everything
});
test('the report is honest: it counts what moved and the largest shift', () => {
const r = alignAllToGrid(detuned().spaces, detunedLayout());
// never more than half a step in each axis → at most half a step diagonally… ×√2
assert.ok(r.report.maxShift <= S * 0.71 + 1e-12, String(r.report.maxShift / S));
assert.ok(r.report.maxShift > 0);
// "junk" has no coordinates and is not counted; dev2 is on a node already
assert.equal(r.report.total, 2 /*rooms*/ + 3 /*decor*/ + 1 /*opening*/ + 3 /*layout*/);
assert.ok(r.report.moved < r.report.total);
});
test('nothing is invented: the input objects are not mutated', () => {
const cfg = detuned();
const before = JSON.stringify(cfg.spaces);
const lay = detunedLayout();
const layBefore = JSON.stringify(lay);
alignAllToGrid(cfg.spaces, lay);
assert.equal(JSON.stringify(cfg.spaces), before);
assert.equal(JSON.stringify(lay), layBefore);
});
test('a stray opening with no wall in reach is left exactly where it is', () => {
const cfg = detuned();
cfg.spaces[0].openings.push({ id: 'o2', type: 'door', x: 3.7, y: 4.2, angle: 0, length: 0.06 });
const { spaces } = alignAllToGrid(cfg.spaces, {});
const o2 = spaces[0].openings.find((o) => o.id === 'o2');
assert.equal(o2.x, 3.7);
assert.equal(o2.y, 4.2);
});
test('far-out coordinates align just as well as near ones (infinite canvas)', () => {
const cfg = { spaces: [{ id: 'f1', rooms: [
{ id: 'r1', poly: [[2.5 + S / 3, 2.2], [3.0, 2.2], [3.0, 2.6], [2.5, 2.6]] }] }] };
const { spaces, report } = alignAllToGrid(cfg.spaces, { d: { s: 'f1', x: 2.5 + S / 3, y: 2.2 } });
for (const p of spaces[0].rooms[0].poly) { assert.ok(onGrid(p[0])); assert.ok(onGrid(p[1])); }
assert.ok(report.moved >= 1);
});
// ---------------------------------------------------------------------------
// AUD-158B1-01 — the confirmation promises an exact maximum before an action
// with no undo, so the report must be an UPPER BOUND of what the run does.
// ---------------------------------------------------------------------------
/** What the run REALLY did, measured on the returned objects: the largest
* displacement of any corner / vertex / end, in the centimetres of the space
* it happened in. The report may never be smaller than this. */
const reallyMovedCm = (before, after, cell = {}) => {
const cm = (sp) => (Number(sp?.cell_cm) > 0 ? Number(sp.cell_cm) : 5) * GRID_N;
let max = 0;
const hit = (d, sp) => { if (d * cm(sp) > max) max = d * cm(sp); };
const box = (a, b, sp) => {
const dx = Math.max(Math.abs(b.x - a.x), Math.abs((b.x + b.w) - (a.x + (a.w || 0))));
const dy = Math.max(Math.abs(b.y - a.y), Math.abs((b.y + b.h) - (a.y + (a.h || 0))));
hit(Math.hypot(dx, dy), sp);
};
before.forEach((sp, i) => {
const sq = after[i];
(sp.rooms || []).forEach((r, j) => {
const q = sq.rooms[j];
if (r.poly?.length) r.poly.forEach((p, k) => hit(Math.hypot(q.poly[k][0] - p[0], q.poly[k][1] - p[1]), sp));
else if (r.x != null) box(r, q, sp);
});
(sp.decor || []).forEach((d, j) => {
const q = sq.decor[j];
if (d.kind === 'line') {
hit(Math.hypot(q.x1 - d.x1, q.y1 - d.y1), sp);
hit(Math.hypot(q.x2 - d.x2, q.y2 - d.y2), sp);
} else if (d.w != null) box(d, q, sp);
else hit(Math.hypot(q.x - d.x, q.y - d.y), sp);
});
(sp.openings || []).forEach((o, j) => {
const q = sq.openings[j];
const h = (Number(o.length) || 0) / 2, R = Math.PI / 180;
const e = (px, py, a, s) => [px + s * Math.cos(a * R) * h, py + s * Math.sin(a * R) * h];
const same = Math.max(
Math.hypot(...e(q.x, q.y, q.angle, 1).map((v, k) => v - e(o.x, o.y, o.angle, 1)[k])),
Math.hypot(...e(q.x, q.y, q.angle, -1).map((v, k) => v - e(o.x, o.y, o.angle, -1)[k])));
const flip = Math.max(
Math.hypot(...e(q.x, q.y, q.angle, -1).map((v, k) => v - e(o.x, o.y, o.angle, 1)[k])),
Math.hypot(...e(q.x, q.y, q.angle, 1).map((v, k) => v - e(o.x, o.y, o.angle, -1)[k])));
hit(Math.min(same, flip), sp);
});
});
return max;
};
test('AUD-158B1-01: the maximum is converted through the scale of ITS OWN space', () => {
const spaces = [
{ id: 'first', cell_cm: 5, rooms: [{ id: 'a', poly: [[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2]] }] },
{ id: 'second', cell_cm: 100, rooms: [{ id: 'b',
poly: [[0.2 + S / 2, 0.2], [0.3, 0.2], [0.3, 0.3], [0.2, 0.3]] }] },
];
const { report } = alignAllToGrid(spaces, {});
// half a step of a 100 cm cell is 50 cm — NOT the 2.5 cm of the first floor
assert.ok(Math.abs(report.maxShiftCm - 50) < 1e-9, String(report.maxShiftCm));
assert.equal(report.maxSpace, 'second');
});
test('AUD-158B1-01: a rect thinner than one step is measured AFTER it is widened', () => {
const spaces = [{ id: 'f1', cell_cm: 5,
rooms: [{ id: 'r', x: 0.5, y: 0.5, w: 0.001, h: 0.001 }] }];
const before = JSON.parse(JSON.stringify(spaces));
const res = alignAllToGrid(spaces, {});
const real = reallyMovedCm(before, res.spaces);
assert.ok(res.report.maxShiftCm + 1e-9 >= real,
`promised ${res.report.maxShiftCm} cm, moved ${real} cm`);
});
test('AUD-158B1-01: a rect whose X and Y errors land on opposite corners', () => {
// origin off in X only, far corner off in Y only → the mixed corner moves by
// both at once, and that diagonal is the honest maximum
const spaces = [{ id: 'f1', cell_cm: 5,
rooms: [{ id: 'r', x: 0.25 + S * 0.4, y: 0.25, w: 0.1 - S * 0.4, h: 0.1 + S * 0.4 }] }];
const before = JSON.parse(JSON.stringify(spaces));
const res = alignAllToGrid(spaces, {});
assert.ok(Math.abs(res.report.maxShift - S * 0.4 * Math.SQRT2) < 1e-12,
String(res.report.maxShift / S));
assert.ok(res.report.maxShiftCm + 1e-9 >= reallyMovedCm(before, res.spaces));
});
test('AUD-158B1-01: the promise is an upper bound for the whole detuned plan', () => {
const cfg = detuned();
const before = JSON.parse(JSON.stringify(cfg.spaces));
const res = alignAllToGrid(cfg.spaces, detunedLayout());
assert.ok(res.report.maxShiftCm + 1e-9 >= reallyMovedCm(before, res.spaces),
`promised ${res.report.maxShiftCm} cm`);
});
// ---------------------------------------------------------------------------
// AUD-158B1-02 — an opening whose only error is its angle
// ---------------------------------------------------------------------------
test('AUD-158B1-02: an angle-only correction is a change and can be applied', () => {
const spaces = [{ id: 'f1', cell_cm: 5,
rooms: [{ id: 'r1', poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
openings: [{ id: 'o', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }] }];
const res = alignAllToGrid(spaces, {});
const o = res.spaces[0].openings[0];
assert.equal(o.x, 0.35); // the centre was already right…
assert.equal(o.y, 0.2);
assert.equal(o.angle, 0); // …only the angle was wrong
assert.equal(res.changed, true, 'output differs but changed:false');
assert.equal(res.report.moved, 1);
assert.equal(res.report.rotated, 1);
assert.ok(res.report.maxShiftCm > 0); // its ENDS really do move
});
test('AUD-158B1-02: turning an opening end over end is not a move', () => {
// 180 apart is the same segment on the same wall: it counts as a correction
// (the field is rewritten) but it displaces nothing
const spaces = [{ id: 'f1', cell_cm: 5,
rooms: [{ id: 'r1', poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
openings: [{ id: 'o', type: 'window', x: 0.35, y: 0.2, angle: 180, length: 0.1 }] }];
const res = alignAllToGrid(spaces, {});
assert.equal(res.spaces[0].openings[0].angle, 0);
assert.equal(res.changed, true);
assert.equal(res.report.rotated, 1);
assert.equal(res.report.maxShiftCm, 0);
});