mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Mechanism A (wall drag along its normal, shared stretches of neighbours move together, T-junctions insert vertices) and mechanism B (corner scale frame) with every stop: min room size ~30 cm, self-intersection, foreign rooms (polyclip area check — roomsOverlap alone misses collinear slide-over), islands, opening anchors. node:test units pin each stop numerically.
211 lines
11 KiB
JavaScript
211 lines
11 KiB
JavaScript
// Room resize geometry (docs/RESIZE.md): every «упор» and the T-junction
|
||
// vertex insertion are pinned here numerically.
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
edgeNormal, movePolyEdge, sharedSpansWith, shiftSharedSpans, simplifyPoly,
|
||
polyIsSimple, minParallelClearance, planEdgeDrag, applyEdgeDrag,
|
||
validateEdgeDrag, clampEdgeDrag, applyRoomScale, validateRoomScale,
|
||
clampRoomScale, areaM2, formatArea, MIN_ROOM_CM,
|
||
} from '../test-build/resize.js';
|
||
import { roomPoly } from '../test-build/logic.js';
|
||
|
||
const OPTS = { minDim: 25, eps: 0.5 }; // 25 units ≈ 30 cm at cell_cm=5, pitch 1000/240
|
||
const STEP = 5;
|
||
|
||
// A: 300×300 square, right edge is index 1 ((400,100)→(400,400))
|
||
const A = () => ({ id: 'A', poly: [[100, 100], [400, 100], [400, 400], [100, 400]] });
|
||
// R: full-height neighbour to the right — the ENTIRE wall x=400 is shared
|
||
const R = () => ({ id: 'R', poly: [[400, 100], [700, 100], [700, 400], [400, 400]] });
|
||
// B: shorter neighbour to the right — T-junction (covers y 100..300 only)
|
||
const B = () => ({ id: 'B', poly: [[400, 100], [700, 100], [700, 300], [400, 300]] });
|
||
|
||
const closeTo = (got, want, tol = 1e-6) =>
|
||
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
|
||
const polyEq = (got, want, tol = 1e-6) => {
|
||
assert.equal(got.length, want.length, `vertex count: ${JSON.stringify(got)}`);
|
||
for (let i = 0; i < want.length; i++) {
|
||
closeTo(got[i][0], want[i][0], tol);
|
||
closeTo(got[i][1], want[i][1], tol);
|
||
}
|
||
};
|
||
|
||
test('edgeNormal points outward for both windings', () => {
|
||
const a = A();
|
||
assert.deepEqual(edgeNormal(a.poly, 1).map((v) => Math.round(v) + 0), [1, 0]); // right wall → +x
|
||
assert.deepEqual(edgeNormal(a.poly, 3).map((v) => Math.round(v) + 0), [-1, 0]); // left wall → −x
|
||
const ccw = [...a.poly].reverse(); // reversed winding, same square
|
||
const n = edgeNormal(ccw, ccw.findIndex((p) => p[0] === 400 && p[1] === 400));
|
||
assert.deepEqual(n.map((v) => Math.round(v) + 0), [1, 0]);
|
||
});
|
||
|
||
test('movePolyEdge translates BOTH edge endpoints along the normal', () => {
|
||
polyEq(movePolyEdge(A().poly, 1, 50), [[100, 100], [450, 100], [450, 400], [100, 400]]);
|
||
polyEq(movePolyEdge(A().poly, 1, -50), [[100, 100], [350, 100], [350, 400], [100, 400]]);
|
||
});
|
||
|
||
test('legacy x/y/w/h rectangles resize through roomPoly', () => {
|
||
const poly = roomPoly({ x: 100, y: 100, w: 300, h: 300 });
|
||
polyEq(movePolyEdge(poly, 1, 50), [[100, 100], [450, 100], [450, 400], [100, 400]]);
|
||
});
|
||
|
||
test('full shared wall: the neighbour moves synchronously, no gap by construction', () => {
|
||
const rooms = [A(), R()];
|
||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||
const res = applyEdgeDrag(rooms, [], plan, 50, OPTS.eps);
|
||
polyEq(res.polys.A, [[100, 100], [450, 100], [450, 400], [100, 400]]);
|
||
polyEq(res.polys.R, [[450, 100], [700, 100], [700, 400], [450, 400]]); // R shrank, walls still coincide
|
||
});
|
||
|
||
test('T-junction: only the coinciding stretch of the neighbour moves, vertices are inserted', () => {
|
||
const rooms = [A(), B()];
|
||
// drag B's left wall (edge 3: (400,300)→(400,100)) 50 units INTO A
|
||
const plan = planEdgeDrag(rooms, 'B', 3);
|
||
assert.deepEqual(plan.n.map((v) => Math.round(v) + 0), [-1, 0]);
|
||
const res = applyEdgeDrag(rooms, [], plan, 50, OPTS.eps);
|
||
polyEq(res.polys.B, [[350, 100], [700, 100], [700, 300], [350, 300]]);
|
||
// A becomes L-shaped: the shared stretch (y 100..300) caves in, the rest stays
|
||
polyEq(res.polys.A, [[100, 100], [350, 100], [350, 300], [400, 300], [400, 400], [100, 400]]);
|
||
});
|
||
|
||
test('stop: own room minimum size (~30 cm)', () => {
|
||
const rooms = [A()];
|
||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||
assert.equal(validateEdgeDrag(rooms, [], plan, -280, OPTS), false); // width 20 < 25
|
||
assert.equal(validateEdgeDrag(rooms, [], plan, -275, OPTS), true); // width 25 — the floor
|
||
closeTo(clampEdgeDrag(rooms, [], plan, -280, STEP, OPTS), -275);
|
||
});
|
||
|
||
test('stop: the shrinking neighbour keeps its minimum size too', () => {
|
||
const rooms = [A(), R()];
|
||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||
assert.equal(validateEdgeDrag(rooms, [], plan, 290, OPTS), false); // R would be 10 wide
|
||
closeTo(clampEdgeDrag(rooms, [], plan, 290, STEP, OPTS), 275); // R stays 25
|
||
});
|
||
|
||
test('stop: a growing wall may touch a foreign room but never overlap it', () => {
|
||
const F = { id: 'F', poly: [[500, 100], [700, 100], [700, 400], [500, 400]] };
|
||
const rooms = [A(), F];
|
||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||
assert.equal(validateEdgeDrag(rooms, [], plan, 150, OPTS), false); // crosses F
|
||
assert.equal(validateEdgeDrag(rooms, [], plan, 100, OPTS), true); // touching = legal shared wall
|
||
closeTo(clampEdgeDrag(rooms, [], plan, 150, STEP, OPTS), 100);
|
||
});
|
||
|
||
test('stop: islands (islandsOf) block the wall, including a jump fully past them', () => {
|
||
const P = { id: 'P', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] };
|
||
const I = { id: 'I', poly: [[250, 250], [350, 250], [350, 350], [250, 350]] };
|
||
const rooms = [P, I];
|
||
const plan = planEdgeDrag(rooms, 'P', 1); // right wall of the parent
|
||
assert.equal(validateEdgeDrag(rooms, [], plan, -200, OPTS), false); // wall at 300 cuts the island
|
||
assert.equal(validateEdgeDrag(rooms, [], plan, -280, OPTS), false); // wall at 220 — island fully outside (no edge crossing!)
|
||
assert.equal(validateEdgeDrag(rooms, [], plan, -155, OPTS), false); // wall at 345 crosses the island
|
||
assert.equal(validateEdgeDrag(rooms, [], plan, -150, OPTS), true); // wall at 350 — flush with the island is a touch, legal
|
||
closeTo(clampEdgeDrag(rooms, [], plan, -280, STEP, OPTS), -150);
|
||
});
|
||
|
||
test('openings: a door ON the moving wall travels with it', () => {
|
||
const rooms = [A(), R()];
|
||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||
const ops = [{ id: 'o1', x: 400, y: 200, length: 60 }];
|
||
const res = applyEdgeDrag(rooms, ops, plan, 50, OPTS.eps);
|
||
assert.deepEqual(res.openings.o1, [450, 200]);
|
||
assert.equal(validateEdgeDrag(rooms, ops, plan, 50, OPTS), true);
|
||
});
|
||
|
||
test('stop: a side wall cannot get too short for its opening (own room)', () => {
|
||
const rooms = [A()];
|
||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||
const ops = [{ id: 'o2', x: 350, y: 100, length: 80 }]; // top wall, spans x 310..390
|
||
assert.equal(validateEdgeDrag(rooms, ops, plan, -50, OPTS), false); // top wall ends at 350 < 390
|
||
assert.equal(validateEdgeDrag(rooms, ops, plan, -10, OPTS), true); // ends exactly at 390
|
||
closeTo(clampEdgeDrag(rooms, ops, plan, -50, STEP, OPTS), -10);
|
||
});
|
||
|
||
test('stop: the neighbour’s opening anchors the drag too', () => {
|
||
const rooms = [A(), R()];
|
||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||
const ops = [{ id: 'o3', x: 460, y: 100, length: 40 }]; // R’s top wall, spans x 440..480
|
||
// the wall corner may not land INSIDE the door: at d=50 the joint (x=450)
|
||
// would sit in the middle of the opening — that is the «упор»
|
||
assert.equal(validateEdgeDrag(rooms, ops, plan, 50, OPTS), false);
|
||
assert.equal(validateEdgeDrag(rooms, ops, plan, 40, OPTS), true); // joint exactly at the door edge
|
||
closeTo(clampEdgeDrag(rooms, ops, plan, 50, STEP, OPTS), 40);
|
||
// fully past the door the opening sits on the GROWN room's wall — the
|
||
// composite wall y=100 never shortens, so this is legal by construction
|
||
assert.equal(validateEdgeDrag(rooms, ops, plan, 100, OPTS), true);
|
||
});
|
||
|
||
test('scale: all vertices scale proportionally about the fixed corner', () => {
|
||
const res = applyRoomScale(A(), [], [], [100, 100], 1.5, OPTS.eps);
|
||
polyEq(res.poly, [[100, 100], [550, 100], [550, 550], [100, 550]]);
|
||
});
|
||
|
||
test('scale stops: minimum size and the neighbour as a hard wall', () => {
|
||
const F = { id: 'F', poly: [[500, 100], [700, 100], [700, 400], [500, 400]] };
|
||
const rooms = [A(), F];
|
||
assert.equal(validateRoomScale(rooms, [], 'A', [100, 100], 0.05, OPTS), false); // 15 < 25
|
||
const kMin = clampRoomScale(rooms, [], 'A', [100, 100], 0.05, OPTS);
|
||
closeTo(kMin * 300, 25, 0.5); // clamped at the 30 cm floor
|
||
assert.equal(validateRoomScale(rooms, [], 'A', [100, 100], 2, OPTS), false); // overlaps F
|
||
const kMax = clampRoomScale(rooms, [], 'A', [100, 100], 2, OPTS);
|
||
closeTo(kMax, 400 / 300, 1e-3); // right wall lands exactly on F
|
||
});
|
||
|
||
test('scale never drags the neighbour; a SHARED opening stays with the neighbour wall', () => {
|
||
const rooms = [A(), R()];
|
||
const shared = { id: 'os', x: 400, y: 200, length: 60 }; // on the shared wall
|
||
const own = { id: 'oo', x: 100, y: 200, length: 60 }; // on A’s left wall only
|
||
const res = applyRoomScale(A(), [shared, own], [R().poly], [400, 400], 0.5, OPTS.eps);
|
||
assert.equal(res.openings.os, undefined); // stays put
|
||
assert.deepEqual(res.openings.oo, [250, 300]); // follows the transform
|
||
assert.equal(validateRoomScale(rooms, [shared], 'A', [400, 400], 0.5, OPTS), true);
|
||
});
|
||
|
||
test('scale stop: an exclusive opening must still fit', () => {
|
||
const rooms = [A()];
|
||
const ops = [{ id: 'o4', x: 250, y: 100, length: 200 }]; // top wall, needs 200 units
|
||
assert.equal(validateRoomScale(rooms, ops, 'A', [100, 100], 0.5, OPTS), false); // wall 150 < opening 200
|
||
const k = clampRoomScale(rooms, ops, 'A', [100, 100], 0.5, OPTS);
|
||
// the opening centre scales too: it fits while 100·(1−1.5k) ≤ ε, i.e. k ≥ 0.66
|
||
closeTo(k, 0.66, 1e-3);
|
||
});
|
||
|
||
test('shared spans + shiftSharedSpans invariants', () => {
|
||
const spans = sharedSpansWith(B().poly, [400, 100], [400, 400], OPTS.eps);
|
||
assert.equal(spans.length, 1);
|
||
polyEq([spans[0][0], spans[0][1]].sort((p, q) => p[1] - q[1]), [[400, 100], [400, 300]]);
|
||
assert.equal(shiftSharedSpans(A().poly, [900, 100], [900, 400], [10, 0], OPTS.eps), null); // nothing coincides
|
||
});
|
||
|
||
test('simplifyPoly drops collinear leftovers, polyIsSimple flags a bowtie', () => {
|
||
polyEq(simplifyPoly([[0, 0], [50, 0], [100, 0], [100, 100], [0, 100]]), [[0, 0], [100, 0], [100, 100], [0, 100]]);
|
||
assert.equal(polyIsSimple([[0, 0], [100, 100], [100, 0], [0, 100]]), false);
|
||
assert.equal(polyIsSimple(A().poly), true);
|
||
});
|
||
|
||
test('minParallelClearance: the opposite-wall distance', () => {
|
||
closeTo(minParallelClearance(A().poly, [[[400, 100], [400, 400]]], OPTS.eps), 300);
|
||
// L-shape: only walls with an OVERLAPPING projection count — the x=80 wall
|
||
// spans y 60..200 and casts no shadow on the y 0..60 span, so the opposite
|
||
// wall is x=0 at distance 200 (the x=80 obstruction is the simplicity stop)
|
||
const L = [[0, 0], [200, 0], [200, 60], [80, 60], [80, 200], [0, 200]];
|
||
closeTo(minParallelClearance(L, [[[200, 0], [200, 60]]], OPTS.eps), 200);
|
||
});
|
||
|
||
test('live numbers: areaM2 and formatArea', () => {
|
||
const pitch = 1000 / 240;
|
||
const poly = [[0, 0], [100, 0], [100, 100], [0, 100]]; // 24 cells → 120 cm a side
|
||
closeTo(areaM2(poly, pitch, 5), 1.44, 1e-9);
|
||
assert.equal(formatArea(1.44, false), '1.4 m²');
|
||
assert.equal(formatArea(1.44, true), '16 ft²');
|
||
assert.equal(MIN_ROOM_CM, 30);
|
||
});
|
||
|
||
test('zero drag is always valid and clamps to zero', () => {
|
||
const rooms = [A()];
|
||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||
assert.equal(validateEdgeDrag(rooms, [], plan, 0, OPTS), true);
|
||
assert.equal(clampEdgeDrag(rooms, [], plan, 0, STEP, OPTS), 0);
|
||
});
|