Files
houseplan-card/test/led-strip-geometry.test.mjs
T
Matysh c142e4f7b5 fix(led): preserve strip corners and feather the complete light field
Keep real end and corner emitters, robust decimal joins and wall-circle
sweep events. Render one positive-winding compound visibility clip so
Chromium cannot cancel or cut away overlapping light regions.

Add independent pixel oracles for glow falloff and wall-following tubes.
Replace the lossy fan-count limit with explicit cached-path bounds while
retaining the original timing and warm-cycle heap-growth limits.

Issue: #788
User-Visible: yes
2026-10-03 18:46:35 +03:00

395 lines
20 KiB
JavaScript
Executable File

// #780: pure LED strip geometry — anchor, derived visible path, emitters,
// placement against bodies and the hit owner (ТЗ §3, §5, §6, §7; AC2, AC5,
// AC8, AC12 unit part). Results are judged, not the source text.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
LED_HIT_MIN_CSS_PX, clampToBodies, clampVertexMove, compactPoints,
emitterSamples, isClosedStrip, polylineLength, stripAnchor, stripHitOwner,
stripHitRadiusPx, stripPieces, validStripPoints, visibleStripPath,
} from '../test-build/led-strip-geometry.js';
const close = (actual, expected, eps = 1e-9, msg = '') => {
assert.ok(Math.abs(actual - expected) <= eps, `${msg} ${actual} ≉ ${expected}`);
};
const closePt = (actual, expected, eps = 1e-9) => {
close(actual[0], expected[0], eps, 'x');
close(actual[1], expected[1], eps, 'y');
};
// A thick wall: the rectangle y ∈ [0, 1] across x ∈ [0, 10]. Its top face is
// y = 1 (free floor above), its bottom face y = 0 (free floor below).
const wall = [[0, 0], [10, 0], [10, 1], [0, 1]];
const insideRect = (r) => (p) => p[0] > r[0][0] && p[0] < r[1][0] && p[1] > r[0][1] && p[1] < r[2][1];
const ringFaces = (ring) => ring.map((a, i) => ({ a, b: ring[(i + 1) % ring.length] }));
const ctx = { faces: ringFaces(wall), inside: insideRect(wall), epsilon: 1e-5 };
test('AC2: the anchor is the point at half the polyline length', () => {
assert.deepEqual(stripAnchor([[0, 0], [10, 0], [10, 10]]), [10, 0]);
assert.deepEqual(stripAnchor([[0, 0], [4, 0]]), [2, 0]);
// Unequal segments: not the vertex mean (4.67, 0.33), not the bbox centre (5, 0.5).
closePt(stripAnchor([[0, 0], [9, 0], [9, 1]]), [5, 0]);
// A closed strip: half of the perimeter, measured from the first vertex.
closePt(stripAnchor([[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]), [4, 4]);
// Repeated points do not shift the anchor.
closePt(stripAnchor([[0, 0], [0, 0], [4, 0], [4, 0]]), [2, 0]);
});
test('AC1 mirror: shape validity matches the backend rules', () => {
assert.equal(validStripPoints([[0, 0], [1, 0]]), true);
assert.equal(validStripPoints([[0, 0]]), false);
assert.equal(validStripPoints([[0, 0], [0, 0]]), false);
assert.equal(validStripPoints([[0, 0], [1, 0], [0, 0]]), false, 'closed with two distinct vertices');
assert.equal(validStripPoints([[0, 0], [1, 0], [1, 1], [0, 0]]), true);
assert.equal(validStripPoints([[0, 0], ['1', 0]]), false);
assert.equal(validStripPoints(Array.from({ length: 51 }, (_, i) => [i, 0])), false);
assert.equal(validStripPoints(Array.from({ length: 50 }, (_, i) => [i, 0])), true);
assert.equal(isClosedStrip([[0, 0], [1, 0], [1, 1], [0, 0]]), true);
assert.equal(isClosedStrip([[0, 0], [1, 0]]), false);
assert.deepEqual(compactPoints([[0, 0], [0, 0], [1, 0]]), [[0, 0], [1, 0]]);
assert.equal(polylineLength([[0, 0], [3, 4], [3, 10]]), 11);
});
test('AC8: offset is t/2 on a thick face, 0 on free floor and on a zero wall', () => {
const t2 = 0.25;
// On the top face: shifted up into the free floor.
const top = visibleStripPath([[2, 1], [8, 1]], ctx, t2);
assert.deepEqual(top.points, [[2, 1.25], [8, 1.25]]);
// On the bottom face: shifted down — the side is the free floor, not a fixed sign.
const bottom = visibleStripPath([[8, 0], [2, 0]], ctx, t2);
assert.deepEqual(bottom.points, [[8, -0.25], [2, -0.25]]);
// Free floor: no shift.
assert.deepEqual(visibleStripPath([[2, 3], [8, 3]], ctx, t2).points, [[2, 3], [8, 3]]);
// A zero-thickness wall is no body: a strip on its axis gets no offset.
assert.deepEqual(visibleStripPath([[2, 5], [8, 5]], { faces: [{ a: [0, 5], b: [10, 5] }], inside: () => false, epsilon: 1e-5 }, t2).points,
[[2, 5], [8, 5]]);
// Near the face but outside epsilon is not "on the face".
assert.deepEqual(visibleStripPath([[2, 1.001], [8, 1.001]], ctx, t2).points, [[2, 1.001], [8, 1.001]]);
});
test('AC8: a mixed strip leaves the face continuously — no gap, the stored points stay', () => {
const t2 = 0.25;
const stored = [[2, 1], [6, 1], [6, 4]];
const before = JSON.stringify(stored);
const path = visibleStripPath(stored, ctx, t2);
// The shifted face meets the unshifted free segment on its original line:
// no out-and-back connector that would leave a round stub at the corner.
assert.deepEqual(path.points, [[2, 1.25], [6, 1.25], [6, 4]]);
for (let i = 1; i < path.points.length; i++) {
const [a, b] = [path.points[i - 1], path.points[i]];
assert.ok(Math.hypot(b[0] - a[0], b[1] - a[1]) > 0, 'no zero step');
}
assert.equal(JSON.stringify(stored), before, 'derivation never writes back');
// A segment that only partly lies on the face splits into pieces.
const pieces = stripPieces([[-4, 1], [4, 1]], ctx);
assert.equal(pieces.length, 2);
assert.equal(pieces[0].free, null);
assert.deepEqual(pieces[1].free, [0, 1]);
closePt(pieces[1].a, [0, 1]);
});
test('AC8: a closed strip closes through the same rule without a seam point', () => {
const path = visibleStripPath([[2, 3], [6, 3], [6, 6], [2, 6], [2, 3]], ctx, 0.25);
assert.equal(path.closed, true);
assert.deepEqual(path.points, [[2, 3], [6, 3], [6, 6], [2, 6]]);
});
test('#787: a four-corner strip stays rectangular across a door opening', () => {
// Opaque body faces around a door gap y=3..5 on the rectangle's right side.
const faces = [
{ a: [0, 0], b: [10, 0] }, { a: [10, 0], b: [10, 3] },
{ a: [10, 5], b: [10, 10] }, { a: [10, 10], b: [0, 10] },
{ a: [0, 10], b: [0, 0] },
];
const openingCtx = {
faces,
inside: ([x, y]) => (y > -1 && y < 0 && x > 0 && x < 10)
|| (x > 10 && x < 11 && ((y > 0 && y < 3) || (y > 5 && y < 10)))
|| (y > 10 && y < 11 && x > 0 && x < 10)
|| (x > -1 && x < 0 && y > 0 && y < 10),
epsilon: 1e-5,
};
const stored = [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]];
const before = JSON.stringify(stored);
const right = stripPieces([[10, 0], [10, 10]], openingCtx);
assert.equal(right.length, 3, 'the opening remains a separate derived piece');
assert.ok(right.every((piece) => JSON.stringify(piece.free) === JSON.stringify([-1, 0])),
'the internal opening inherits the same free side from both wall faces');
assert.deepEqual(visibleStripPath(stored, openingCtx, 0.25), {
points: [[0.25, 0.25], [9.75, 0.25], [9.75, 9.75], [0.25, 9.75]],
closed: true,
}, 'shifted wall sides meet at exact miters without steps or diagonal inserts');
assert.equal(JSON.stringify(stored), before, 'the stored four-corner contour is untouched');
const emitters = emitterSamples([[10, 0], [10, 10]], openingCtx, 0.5);
assert.ok(emitters.length > 0);
for (const point of emitters) close(point[0], 10 - openingCtx.epsilon, 1e-12,
'Glow stays on one side through the optically open doorway');
});
const rectangleFaces = (left, top, right, bottom) => {
const ring = [[left, top], [right, top], [right, bottom], [left, bottom]];
return {
faces: ringFaces(ring),
inside: ([x, y]) => x < left || x > right || y < top || y > bottom,
epsilon: 1e-5,
};
};
const loopOrders = (corners) => [corners, [...corners].reverse()].flatMap((order) =>
order.map((_, start) => {
const rotated = [...order.slice(start), ...order.slice(0, start)];
return [...rotated, rotated[0]];
}));
const sameLoopOutline = (actual, expected) => {
assert.equal(actual.length, expected.length, 'no extra connector vertices');
const cornerIndices = [];
for (const point of actual) {
const index = expected.findIndex((p) => Math.hypot(p[0] - point[0], p[1] - point[1]) < 1e-9);
assert.notEqual(index, -1, `unexpected corner ${point}`);
cornerIndices.push(index);
}
for (const point of expected) {
assert.ok(actual.some((p) => Math.hypot(p[0] - point[0], p[1] - point[1]) < 1e-9),
`missing expected corner ${point}: ${JSON.stringify(actual)}`);
}
const direction = (cornerIndices[1] - cornerIndices[0] + expected.length) % expected.length;
assert.ok(direction === 1 || direction === expected.length - 1, 'no diagonal edge');
for (let i = 1; i < cornerIndices.length; i++) {
assert.equal((cornerIndices[i] - cornerIndices[i - 1] + expected.length) % expected.length, direction,
'the whole outline keeps its cyclic order without crossing or retracing sides');
}
};
test('#788: decimal loop joins are invariant to its start and traversal direction', () => {
// Subtraction followed by addition does not reproduce .1/.2 exactly. In
// beta.6 this lost both left miters even though all sides were on a face.
const corners = [[0.1, 0.2], [10.3, 0.2], [10.3, 10.4], [0.1, 10.4]];
const context = rectangleFaces(0.1, 0.2, 10.3, 10.4);
const expected = [[0.35, 0.45], [10.05, 0.45], [10.05, 10.15], [0.35, 10.15]];
for (const stored of loopOrders(corners)) {
const before = JSON.stringify(stored);
const path = visibleStripPath(stored, context, 0.25);
assert.equal(path.closed, true);
sameLoopOutline(path.points, expected);
assert.equal(JSON.stringify(stored), before, 'saved coordinates are untouched');
}
});
test('#788: a slightly tilted free side meets shifted faces without stubs or silent straightening', () => {
// Synthetic minimal neighbour of the field report: only the left side is
// not parallel to the wall, while top/bottom/right lie exactly on faces.
const corners = [[0.14, 0.2], [0.1, 10.4], [10.3, 10.4], [10.3, 0.2]];
const context = rectangleFaces(0.1, 0.2, 10.3, 10.4);
const topX = 0.14 - (0.04 * 0.25) / 10.2;
const bottomX = 0.1 + (0.04 * 0.25) / 10.2;
const expected = [[topX, 0.45], [bottomX, 10.15], [10.05, 10.15], [10.05, 0.45]];
for (const stored of loopOrders(corners)) {
const before = JSON.stringify(stored);
const path = visibleStripPath(stored, context, 0.25);
sameLoopOutline(path.points, expected);
assert.notEqual(topX, bottomX, 'the original nonzero tilt is retained');
assert.equal(JSON.stringify(stored), before, 'rendering never snaps the saved shape');
}
});
test('#788: an unsafe almost-parallel wall/free turn keeps a bounded connector', () => {
const stored = [[2, 1], [8, 1], [2, 1.01]];
const path = visibleStripPath(stored, ctx, 0.25);
assert.equal(path.closed, false);
assert.ok(path.points.every(([x, y]) => x >= 2 && x <= 8 && y >= 1 && y <= 1.25),
'a far-away line intersection must not create a long miter spike');
assert.deepEqual(path.points, [[2, 1.25], [8, 1.25], [8, 1], [2, 1.01]]);
});
test('#788: sub-epsilon collinear subdivisions cannot create a wall/free stub', () => {
const expected = [[2, 1.25], [6, 1.25], [6, 4]];
for (const step of [0.1, 1e-3, 1e-6, 1e-10]) {
for (const stored of [
[[2, 1], [2 + step, 1], [6, 1], [6, 4]],
[[2, 1], [6, 1], [6, 1 + step], [6, 4]],
]) {
const before = JSON.stringify(stored);
for (const reversed of [false, true]) {
const path = visibleStripPath(reversed ? [...stored].reverse() : stored, ctx, 0.25);
const want = reversed ? [...expected].reverse() : expected;
assert.equal(path.closed, false);
assert.equal(path.points.length, want.length, 'a short intermediate step cannot turn into a connector');
path.points.forEach((p, i) => closePt(p, want[i]));
}
assert.equal(JSON.stringify(stored), before, 'only the visible derivation is simplified');
}
}
});
test('#788: duplicate vertices and coincident faces preserve a closed offset loop', () => {
const corners = [[0.1, 0.2], [10.3, 0.2], [10.3, 10.4], [0.1, 10.4]];
const context = rectangleFaces(0.1, 0.2, 10.3, 10.4);
// A catalog split or overlapping body may provide the same face more than
// once and in either direction. It must not alter the free side or closure.
context.faces.push(...context.faces.map(({ a, b }) => ({ a: b, b: a })));
const expected = [[0.35, 0.45], [10.05, 0.45], [10.05, 10.15], [0.35, 10.15]];
for (const order of loopOrders(corners)) {
const stored = order.flatMap((p) => [p, [...p]]);
const before = JSON.stringify(stored);
const path = visibleStripPath(stored, context, 0.25);
assert.equal(path.closed, true);
sameLoopOutline(path.points, expected);
assert.ok(path.points.every((p) => !context.inside(p)), 'the whole visible loop stays in free floor');
assert.equal(JSON.stringify(stored), before);
}
});
test('#788: a doorway split closer than the offset to a corner cannot reverse the stripe', () => {
const context = {
faces: [
{ a: [0, 0], b: [10, 0] }, { a: [10, 0], b: [10, 0.1] },
{ a: [10, 5], b: [10, 10] }, { a: [10, 10], b: [0, 10] },
{ a: [0, 10], b: [0, 0] },
],
inside: ([x, y]) => (y < 0 && x > 0 && x < 10)
|| (x > 10 && (y < 0.1 || y > 5))
|| (y > 10 && x > 0 && x < 10) || (x < 0 && y > 0 && y < 10),
epsilon: 1e-5,
};
const corners = [[0, 0], [10, 0], [10, 10], [0, 10]];
const expected = [[0.25, 0.25], [9.75, 0.25], [9.75, 9.75], [0.25, 9.75]];
for (const stored of loopOrders(corners)) {
const before = JSON.stringify(stored);
const path = visibleStripPath(stored, context, 0.25);
sameLoopOutline(path.points, expected);
assert.equal(JSON.stringify(stored), before);
}
assert.equal(stripPieces([[10, 0], [10, 10]], context).length, 3,
'physical face/gap classification still exists for emitters; only the visible line is coalesced');
});
test('#788: rotated rectangle matrix has no numerical free tails at face endpoints', () => {
let checked = 0, failed = 0;
const examples = [];
for (let n = 0; n < 1000; n++) {
const angle = (n * 0.137) % 6.28;
const cos = Math.cos(angle), sin = Math.sin(angle);
const width = 10.13 + n % 7, height = 7.89 + n % 11;
const rotate = ([x, y]) => [0.13 + x * cos - y * sin, 0.27 + x * sin + y * cos];
const corners = [[0, 0], [width, 0], [width, height], [0, height]].map(rotate);
const expected = [[0.25, 0.25], [width - 0.25, 0.25],
[width - 0.25, height - 0.25], [0.25, height - 0.25]].map(rotate);
const context = {
faces: ringFaces(corners), epsilon: 1e-5,
inside: ([x, y]) => {
const px = (x - 0.13) * cos + (y - 0.27) * sin;
const py = -(x - 0.13) * sin + (y - 0.27) * cos;
return px < 0 || px > width || py < 0 || py > height;
},
};
for (const stored of loopOrders(corners)) {
const before = JSON.stringify(stored);
const pieces = stripPieces(stored, context);
const path = visibleStripPath(stored, context, 0.25);
const correct = pieces.length === 4 && pieces.every((piece) => piece.free)
&& path.points.length === 4 && expected.every((p) =>
path.points.some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) < 1e-8));
checked++;
if (!correct) {
failed++;
if (examples.length < 3) examples.push({ n, pieces: pieces.length, visible: path.points.length });
} else sameLoopOutline(path.points, expected);
assert.equal(JSON.stringify(stored), before, 'rotation never changes stored coordinates');
}
}
assert.equal(checked, 8000, '1000 rotations, four starts, two directions');
assert.equal(failed, 0, `${failed}/${checked} rotated loops failed; examples ${JSON.stringify(examples)}`);
});
test('#788: numerical endpoint tolerance does not absorb real leading or trailing gaps', () => {
const from = [0.1, 0.2], to = [10.1, 1.2];
const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
const along = (distance) => [from[0] + (to[0] - from[0]) * distance / length,
from[1] + (to[1] - from[1]) * distance / length];
for (const gap of [1e-8, 1e-5, 0.1]) {
const context = {
faces: [{ a: along(gap), b: along(length - gap) }], epsilon: 1e-5,
inside: ([x, y]) => (to[0] - from[0]) * (y - from[1]) - (to[1] - from[1]) * (x - from[0]) < 0,
};
for (const stored of [[from, to], [to, from]]) {
const pieces = stripPieces(stored, context);
assert.equal(pieces.length, 3, `both ${gap}-unit real gaps remain separate`);
assert.equal(pieces[0].free, null);
assert.ok(pieces[1].free);
assert.equal(pieces[2].free, null);
close(Math.hypot(pieces[0].b[0] - pieces[0].a[0], pieces[0].b[1] - pieces[0].a[1]), gap, 1e-12);
close(Math.hypot(pieces[2].b[0] - pieces[2].a[0], pieces[2].b[1] - pieces[2].a[1]), gap, 1e-12);
}
}
});
test('ТЗ §6: emitters sit epsilon outward on a face, cover the length, skip buried parts', () => {
const onFace = emitterSamples([[2, 1], [8, 1]], ctx, 1);
assert.equal(onFace.length, 7, 'every vertex plus spacing ≤ 1');
for (const p of onFace) close(p[1], 1 + 1e-5, 1e-12, 'epsilon outward, never t/2');
// Long strip with many vertices: every segment contributes, none is lost.
const many = Array.from({ length: 30 }, (_, i) => [i % 2 ? 20 : 12, 3 + i]);
const samples = emitterSamples(many, ctx, 2);
for (const vertex of many) {
assert.ok(samples.some((p) => Math.hypot(p[0] - vertex[0], p[1] - vertex[1]) < 1e-9), 'vertex kept');
}
// Partly inside the wall: the buried part emits nothing.
const buried = emitterSamples([[5, 0.5], [5, 4]], ctx, 0.5);
assert.ok(buried.every((p) => !ctx.inside(p)));
assert.ok(buried.length > 0);
assert.deepEqual(emitterSamples([[2, 0.5], [8, 0.5]], ctx, 1), [], 'entirely inside: no field');
});
const bodies = { rings: [wall], inside: insideRect(wall) };
test('AC5: a new segment stops at the first face; touching and sliding are allowed', () => {
const hit = clampToBodies([5, 4], [5, -4], bodies);
assert.equal(hit.stopped, true);
closePt(hit.point, [5, 1]);
const along = clampToBodies([1, 1], [9, 1], bodies);
assert.equal(along.stopped, false, 'sliding along the face');
assert.deepEqual(along.point, [9, 1]);
const touch = clampToBodies([5, 4], [5, 1], bodies);
assert.equal(touch.stopped, false, 'ending on the face');
assert.equal(clampToBodies([5, 0.5], [5, 4], bodies), null, 'a start inside a body is refused');
// Past the wall's end: free.
assert.equal(clampToBodies([11, 4], [11, -4], bodies).stopped, false);
});
test('AC5: a fast vertex drag cannot jump the wall; neighbours are checked too', () => {
const pts = [[2, 4], [5, 4], [8, 4]];
const moved = clampVertexMove(pts, 1, [5, -4], bodies);
assert.ok(moved[1] >= 1 - 1e-9, `vertex stays above the wall: ${moved}`);
// The vertex itself may move freely, but a neighbour segment would cross.
const sideways = clampVertexMove([[2, -4], [5, 4], [8, 4]], 1, [6, 4], bodies);
assert.ok(!bodies.inside(sideways));
// A closed strip: first and last are one handle; both neighbours judged.
const ring = [[2, 4], [8, 4], [8, 8], [2, 8], [2, 4]];
const dragged = clampVertexMove(ring, 0, [2, -2], bodies);
assert.ok(dragged[1] >= 1 - 1e-9);
});
test('AC12: hit radius is max(22 px, t/2): 20 px hits, 30 px misses for a thin stripe', () => {
assert.equal(stripHitRadiusPx(6), LED_HIT_MIN_CSS_PX);
assert.equal(stripHitRadiusPx(60), 30);
const strip = { id: 'a', points: [[0, 0], [200, 0], [200, 200]], closed: false, thicknessPx: 6 };
assert.equal(stripHitOwner([100, 20], [strip]), 'a');
assert.equal(stripHitOwner([100, 30], [strip]), null);
// Round end caps: the radius applies past the free end too.
assert.equal(stripHitOwner([-20, 0], [strip]), 'a');
// Measured from the derived path, along its whole length incl. the corner.
assert.equal(stripHitOwner([215, 100], [strip]), 'a');
});
test('AC12: nearest visible stripe wins; an exact tie goes to the stable id', () => {
const a = { id: 'b-strip', points: [[0, 0], [100, 0]], closed: false, thicknessPx: 6 };
const b = { id: 'a-strip', points: [[0, 30], [100, 30]], closed: false, thicknessPx: 6 };
assert.equal(stripHitOwner([50, 10], [a, b]), 'b-strip');
assert.equal(stripHitOwner([50, 15], [a, b]), 'a-strip', 'tie → smaller id');
const loop = { id: 'loop', points: [[0, 0], [100, 0], [100, 100], [0, 100]], closed: true, thicknessPx: 6 };
assert.equal(stripHitOwner([-10, 50], [loop]), 'loop', 'the closing edge is hit');
});