mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 05:41:34 +00:00
Preserve exact light fields while culling provably distant occluders, reusing physical tube paths across camera updates and excluding LED emitter data from aligned architectural fingerprints. Cover output equivalence, in-place invalidation, resize tags and hyphenated benchmark chunk hashes. Issue: #788 User-Visible: yes
481 lines
26 KiB
JavaScript
481 lines
26 KiB
JavaScript
// #780: the lazy LED chunk and its gate, judged by results (ТЗ §3, §5, §6,
|
||
// §13.1, §13.2; AC2, AC7, AC9, AC11, AC17 unit parts).
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { ledAnchor, ledStripsByMarker } from '../test-build/led-strip-gate.js';
|
||
import { faceContext, ledFrame, ledStripView, stripRoom } from '../test-build/led-strip-runtime.js';
|
||
import { LED_FIELD_BANDS, LedFieldCache, buildFieldGeometry, falloffAt } from '../test-build/led-strip-field.js';
|
||
import { GLOW_FALLOFF } from '../test-build/glow-scene.js';
|
||
import { compactPoints, emitterSamples, stripAnchor } from '../test-build/led-strip-geometry.js';
|
||
import { visibilityPolygon } from '../test-build/light-visibility.js';
|
||
|
||
const space = (strips) => ({ id: 's', rooms: [], led_strips: strips });
|
||
|
||
test('ТЗ §13.1: only an active, bound strip with geometry is represented', () => {
|
||
const map = ledStripsByMarker(space([
|
||
{ id: 'a', points: [[0, 0], [1, 0]], marker: 'm1' },
|
||
{ id: 'b', points: [[0, 0], [1, 0]], marker: 'm2', active: true },
|
||
{ id: 'c', points: [[0, 0], [1, 0]], marker: 'm3', active: false },
|
||
{ id: 'd', points: [[0, 0], [1, 0]], marker: null },
|
||
{ id: 'e', points: [[0, 0]], marker: 'm5' },
|
||
]));
|
||
assert.deepEqual([...map.keys()].sort(), ['m1', 'm2']);
|
||
assert.equal(ledStripsByMarker(space([])).size, 0);
|
||
assert.equal(ledStripsByMarker(null).size, 0);
|
||
});
|
||
|
||
test('AC2: the gate anchor equals the geometry anchor (half length) in render units', () => {
|
||
const cases = [
|
||
[[0, 0], [10, 0], [10, 10]], [[0, 0], [4, 0]], [[0, 0], [9, 0], [9, 1]],
|
||
[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]], [[0, 0], [0, 0], [4, 0], [4, 0]],
|
||
[[1, 1], [1, 1]],
|
||
];
|
||
for (const points of cases) {
|
||
const reference = stripAnchor(points);
|
||
const gate = ledAnchor(points, 100);
|
||
assert.ok(Math.abs(gate.x - reference[0] * 100) < 1e-9 && Math.abs(gate.y - reference[1] * 100) < 1e-9,
|
||
`${JSON.stringify(points)}: ${JSON.stringify(gate)} vs ${reference}`);
|
||
}
|
||
});
|
||
|
||
test('ТЗ §3: the linear falloff is the shared GLOW_FALLOFF', () => {
|
||
for (const [offset, value] of GLOW_FALLOFF) {
|
||
assert.ok(Math.abs(falloffAt(offset / 100) - value) < 1e-12, `${offset}%`);
|
||
}
|
||
assert.equal(falloffAt(0), 1);
|
||
assert.equal(falloffAt(1), 0);
|
||
assert.equal(falloffAt(2), 0);
|
||
// Monotonic: the field never brightens away from the strip.
|
||
let previous = 1;
|
||
for (let i = 0; i <= 100; i++) {
|
||
const value = falloffAt(i / 100);
|
||
assert.ok(value <= previous + 1e-12);
|
||
previous = value;
|
||
}
|
||
});
|
||
|
||
test('#784: the continuous field has enough bands to avoid visible gradient steps', () => {
|
||
assert.ok(LED_FIELD_BANDS >= 32, `${LED_FIELD_BANDS} bands are visibly discrete on wide fields`);
|
||
const fraction = 0.5;
|
||
const band = Math.floor((1 - fraction) * LED_FIELD_BANDS);
|
||
const midpoint = 1 - (band + 0.5) / LED_FIELD_BANDS;
|
||
const exact = falloffAt(fraction);
|
||
assert.ok(Math.abs(falloffAt(midpoint) - exact) / exact <= 0.1,
|
||
`${LED_FIELD_BANDS} bands must approximate r/2 within 10%`);
|
||
});
|
||
|
||
const device = (extra = {}) => ({ id: 'm1', name: 'Kitchen LED', primary: 'light.led', space: 's', ...extra });
|
||
const strip = { id: 'a', points: [[0, 0], [1, 0]], marker: 'm1' };
|
||
|
||
test('#784/AC7: states — off white, on, unavailable without a field; radius 30 cm or the own one', () => {
|
||
const base = { strip, defaultRadius: 6, cellCm: 5, gridPitch: 1, glow: true };
|
||
const on = ledStripView({ ...base, device: device(), hass: { states: { 'light.led': { state: 'on' } } },
|
||
candidate: { key: 's|m1', sourceEid: 'light.led', pos: { x: 0, y: 0 }, radius: 3, appearance: { c: '#ff0000', alpha: 0.5 } } });
|
||
assert.equal(on.state, 'on');
|
||
assert.deepEqual(on.appearance, { c: '#ff0000', alpha: 0.5 });
|
||
assert.equal(on.radius, 6, 'the shared radius of ordinary sources does not apply: 30 cm default');
|
||
const off = ledStripView({ ...base, device: device(), hass: { states: { 'light.led': { state: 'off' } } },
|
||
candidate: { key: 's|m1', sourceEid: 'light.led', pos: { x: 0, y: 0 }, radius: 3, appearance: null } });
|
||
assert.equal(off.state, 'off');
|
||
for (const raw of ['unavailable', 'unknown']) {
|
||
const view = ledStripView({ ...base, device: device(), hass: { states: { 'light.led': { state: raw } } },
|
||
candidate: { key: 's|m1', sourceEid: 'light.led', pos: { x: 0, y: 0 }, radius: 3, appearance: { c: '#fff', alpha: 1 } } });
|
||
assert.equal(view.state, 'unavailable', raw);
|
||
assert.equal(view.appearance, null, `${raw}: no field`);
|
||
}
|
||
const own = ledStripView({ ...base, device: device({ marker: { glow_radius_cm: 100 } }),
|
||
hass: { states: { 'light.led': { state: 'on' } } }, candidate: null });
|
||
assert.equal(own.radius, 20, 'the personal radius wins (100 cm / 5 cm per cell)');
|
||
});
|
||
|
||
// A scene with one opaque square body [4,6]×[4,6] and a 10×10 room floor.
|
||
const body = [[4, 4], [6, 4], [6, 6], [4, 6]];
|
||
const floor = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||
const scene = {
|
||
occluders: body.map((p, i) => [p[0], p[1], body[(i + 1) % 4][0], body[(i + 1) % 4][1]]),
|
||
floor: [floor], fingerprint: 'f1', masonryGeometry: [], opaqueBodies: [body],
|
||
};
|
||
const polygons = [{ room: { id: 'r' }, poly: floor }];
|
||
const pieceFans = (piece) => typeof piece.clip === 'string'
|
||
? piece.clip.match(/M[^M]+/g)?.map((d) => d.trim()) ?? [] : piece.clip;
|
||
|
||
test('ТЗ §6: every piece is clipped to what its own emitters see; a buried strip emits nothing', () => {
|
||
const faces = faceContext(scene, 1e-6);
|
||
const geometry = buildFieldGeometry({ points: [[1, 1], [9, 1]], radius: 2, scene, polygons, faces, spaceId: 's' });
|
||
assert.ok(geometry, 'a free strip has a field');
|
||
assert.equal(geometry.pieces.length, 4, 'an 8-unit segment with r = 2 makes four pieces');
|
||
assert.equal((geometry.d.match(/M/g) || []).length, 1,
|
||
'visibility/cache pieces do not split the painted path');
|
||
for (const piece of geometry.pieces) {
|
||
assert.ok(piece.sourceCount >= 2, 'free pieces retain filled visibility fans for the shared clip');
|
||
assert.ok(pieceFans(piece).every((d) => /\bA2 2\b/.test(d) && !/\bL/.test(d)),
|
||
'an unobstructed fan is an exact SVG disc, not a visible polygon');
|
||
}
|
||
// Passing 0.5 below the body: the pieces near it are clipped to their own fans.
|
||
const near = buildFieldGeometry({ points: [[0.5, 3.5], [9.5, 3.5]], radius: 1, scene, polygons, faces, spaceId: 's' });
|
||
const clipped = near.pieces.filter((piece) => /\bL/.test(piece.clip));
|
||
assert.ok(clipped.length >= 2 && clipped.length < near.pieces.length, `${clipped.length} of ${near.pieces.length}`);
|
||
for (const piece of clipped) {
|
||
assert.ok(piece.clip.length > 0);
|
||
assert.ok(/\bA1 1\b/.test(piece.clip),
|
||
'unblocked parts of a clipped fan retain exact circular arcs');
|
||
// No fan vertex lies inside the body: light never passes into or through it.
|
||
for (const d of pieceFans(piece)) {
|
||
for (const [, x, y] of d.matchAll(/[ML]([-\d.e]+) ([-\d.e]+)/g)) {
|
||
assert.ok(!(+x > 4 + 1e-6 && +x < 6 - 1e-6 && +y > 4 + 1e-6 && +y < 6 - 1e-6), `${x},${y}`);
|
||
}
|
||
}
|
||
}
|
||
const buried = buildFieldGeometry({ points: [[4.5, 5], [5.5, 5]], radius: 2, scene, polygons, faces, spaceId: 's' });
|
||
assert.equal(buried, null, 'entirely inside the body: no field');
|
||
});
|
||
|
||
test('#784: a corner and a closed strip remain one painted path', () => {
|
||
const faces = faceContext(scene, 1e-6);
|
||
const corner = buildFieldGeometry({ points: [[1, 1], [9, 1], [9, 9]], radius: 2,
|
||
scene, polygons, faces, spaceId: 's' });
|
||
assert.ok(corner && corner.pieces.length > 1);
|
||
assert.equal((corner.d.match(/M/g) || []).length, 1);
|
||
assert.match(corner.d, /L9 1 L9 9$/);
|
||
const closed = buildFieldGeometry({ points: [[1, 1], [3, 1], [3, 3], [1, 3], [1, 1]], radius: 1,
|
||
scene, polygons, faces, spaceId: 's' });
|
||
assert.ok(closed);
|
||
assert.equal((closed.d.match(/M/g) || []).length, 1);
|
||
assert.match(closed.d, / Z$/);
|
||
});
|
||
|
||
test('#785: a mixed free/wall polyline keeps visibility for every piece', () => {
|
||
const faces = faceContext(scene, 1e-6);
|
||
const mixed = buildFieldGeometry({ points: [[1, 1], [8, 1], [10, 1], [10, 7]], radius: 2,
|
||
scene, polygons, faces, spaceId: 's' });
|
||
assert.ok(mixed && mixed.pieces.length > 3);
|
||
assert.equal(mixed.pieces.every((piece) => piece.clip.length > 0), true,
|
||
'free pieces use filled discs and blocked pieces use visibility polygons');
|
||
assert.equal(mixed.pieces.some((piece) => piece.sourceCount >= 4), true,
|
||
'the long free run retains several overlapping visibility discs');
|
||
});
|
||
|
||
test('#786: reversing a free strip keeps two equally smooth circular end fans', () => {
|
||
const freeScene = { ...scene, occluders: [], fingerprint: 'free' };
|
||
const forward = buildFieldGeometry({ points: [[1, 2], [9, 3]], radius: 2,
|
||
scene: freeScene, polygons, faces: null, spaceId: 's' });
|
||
const reverse = buildFieldGeometry({ points: [[9, 3], [1, 2]], radius: 2,
|
||
scene: freeScene, polygons, faces: null, spaceId: 's' });
|
||
for (const geometry of [forward, reverse]) {
|
||
assert.ok(geometry);
|
||
const fans = geometry.pieces.flatMap(pieceFans);
|
||
assert.ok(fans.length >= 2);
|
||
assert.ok(fans.every((d) => (d.match(/\bA2 2\b/g) || []).length === 2));
|
||
assert.ok(fans.every((d) => !/\bL/.test(d)), 'no order-dependent polygon chord at either end');
|
||
}
|
||
});
|
||
|
||
// A free fan's centre follows from its exact two-arc disc. This checks the
|
||
// generated coverage, not the sampler's implementation or a source regex.
|
||
const discCenters = (geometry, radius) => geometry.pieces.flatMap(pieceFans).map((d) => {
|
||
const start = /^M([-\d.e]+) ([-\d.e]+) A/.exec(d);
|
||
assert.ok(start, `expected a free-space disc: ${d}`);
|
||
return [Number(start[1]) + radius, Number(start[2])];
|
||
});
|
||
const hasCenter = (centers, point, epsilon = 1e-4) =>
|
||
centers.some((center) => Math.hypot(center[0] - point[0], center[1] - point[1]) < epsilon);
|
||
|
||
// Sample the emitted circular SVG arcs and measure the resulting ring. The
|
||
// sign is an observable geometry property: nonzero clipping unions rings of
|
||
// the same winding, but subtracts a negative disc from a positive blocked fan.
|
||
const fanSignedArea = (d) => {
|
||
const tokens = d.match(/[MLAZ]|-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/g);
|
||
const points = [];
|
||
let i = 0;
|
||
while (i < tokens.length) {
|
||
const command = tokens[i++];
|
||
if (command === 'M' || command === 'L') {
|
||
points.push([Number(tokens[i++]), Number(tokens[i++])]);
|
||
} else if (command === 'A') {
|
||
const radius = Number(tokens[i++]);
|
||
assert.equal(Number(tokens[i++]), radius, 'the field uses circular arcs');
|
||
i++; // axis rotation does not affect a circle
|
||
const large = Number(tokens[i++]), sweep = Number(tokens[i++]);
|
||
const end = [Number(tokens[i++]), Number(tokens[i++])];
|
||
const start = points.at(-1), dx = (start[0] - end[0]) / 2, dy = (start[1] - end[1]) / 2;
|
||
const distance2 = dx * dx + dy * dy;
|
||
if (distance2 < 1e-20) continue;
|
||
const k = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, (radius * radius - distance2) / distance2));
|
||
const center = [(start[0] + end[0]) / 2 + k * dy, (start[1] + end[1]) / 2 - k * dx];
|
||
const a = Math.atan2(start[1] - center[1], start[0] - center[0]);
|
||
const b = Math.atan2(end[1] - center[1], end[0] - center[0]);
|
||
let delta = ((b - a) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI);
|
||
if (!sweep) delta -= 2 * Math.PI;
|
||
const steps = Math.max(1, Math.ceil(Math.abs(delta) / (Math.PI / 24)));
|
||
for (let step = 1; step < steps; step++) {
|
||
const angle = a + delta * step / steps;
|
||
points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]);
|
||
}
|
||
points.push(end);
|
||
} else assert.equal(command, 'Z');
|
||
}
|
||
return points.reduce((area, point, index) => {
|
||
const next = points[(index + 1) % points.length];
|
||
return area + point[0] * next[1] - point[1] * next[0];
|
||
}, 0) / 2;
|
||
};
|
||
|
||
test('#788: free discs and wall-limited fans have additive winding in a shared clip', () => {
|
||
const geometry = buildFieldGeometry({ points: [[0.5, 3.5], [9.5, 3.5]], radius: 1,
|
||
scene, polygons, faces: faceContext(scene, 1e-6), spaceId: 's' });
|
||
const fans = geometry.pieces.flatMap(pieceFans);
|
||
assert.ok(fans.some((d) => d.includes(' L')), 'fixture includes blocked fans');
|
||
assert.ok(fans.some((d) => !d.includes(' L')), 'fixture includes free discs');
|
||
for (const fan of fans) assert.ok(fanSignedArea(fan) > 0, 'all subpaths add coverage instead of cancelling it');
|
||
});
|
||
|
||
test('#788: a wall crossing the radius contributes exact circle-intersection events', () => {
|
||
const wallScene = { ...scene, occluders: [[595, 100, 595, 700]], fingerprint: 'long-wall' };
|
||
const geometry = buildFieldGeometry({ points: [[350, 350], [585, 450]], radius: 50,
|
||
scene: wallScene, polygons: [], faces: null, spaceId: 's' });
|
||
const endpointFan = geometry.pieces.flatMap(pieceFans).at(-1);
|
||
// The true endpoint is (585,450), 10 units from the wall. Its disc meets
|
||
// that wall at y=450±sqrt(50²−10²), not at an arbitrary 30-degree ray.
|
||
const intersections = [...endpointFan.matchAll(/(?:[ML]|A[-\d.e]+ [-\d.e]+ \d \d \d )595 ([-\d.e]+)/g)]
|
||
.map((match) => Number(match[1]));
|
||
for (const y of [450 - Math.sqrt(2400), 450 + Math.sqrt(2400)]) {
|
||
assert.ok(intersections.some((at) => Math.abs(at - y) < 1e-4), `missing wall/radius event at y=${y}`);
|
||
}
|
||
});
|
||
|
||
// Frozen pre-broad-phase reference: visit EVERY scene segment for EVERY
|
||
// emitter, then use the same public visibility sweep. Exact emitted strings
|
||
// (not only fan counts/bounds) must survive the candidate-culling speedup.
|
||
const unfilteredFans = (emitters, radius, segments) => {
|
||
const coord = (value) => String(Math.round(value * 10_000) / 10_000 || 0);
|
||
return emitters.flatMap((p) => {
|
||
const clipped = [];
|
||
for (const s of segments) {
|
||
if (!s || s.length < 4) continue;
|
||
const dx = s[2] - s[0], dy = s[3] - s[1], len2 = dx * dx + dy * dy;
|
||
if (!(len2 > 0)) continue;
|
||
const ox = s[0] - p[0], oy = s[1] - p[1];
|
||
const cross = ox * dy - oy * dx, distance2 = cross * cross / len2;
|
||
if (distance2 >= radius * radius) continue;
|
||
const center = -(ox * dx + oy * dy) / len2;
|
||
const span = Math.sqrt((radius * radius - distance2) / len2);
|
||
const lo = Math.max(0, center - span), hi = Math.min(1, center + span);
|
||
if (hi <= lo) continue;
|
||
clipped.push([s[0] + lo * dx, s[1] + lo * dy, s[0] + hi * dx, s[1] + hi * dy]);
|
||
}
|
||
const r = coord(radius);
|
||
if (!clipped.length) {
|
||
const left = coord(p[0] - radius), right = coord(p[0] + radius), y = coord(p[1]);
|
||
return [`M${left} ${y} A${r} ${r} 0 1 1 ${right} ${y} A${r} ${r} 0 1 1 ${left} ${y} Z`];
|
||
}
|
||
const ring = visibilityPolygon(p, radius, clipped, 12);
|
||
if (ring.length < 3) return [];
|
||
const onRadius = (point) => Math.abs(Math.hypot(point[0] - p[0], point[1] - p[1]) - radius)
|
||
<= Math.max(1e-9, radius * 1e-7);
|
||
let path = `M${coord(ring[0][0])} ${coord(ring[0][1])}`;
|
||
for (let i = 1; i <= ring.length; i++) {
|
||
const previous = ring[i - 1], point = ring[i % ring.length];
|
||
path += onRadius(previous) && onRadius(point)
|
||
? ` A${r} ${r} 0 0 1 ${coord(point[0])} ${coord(point[1])}`
|
||
: ` L${coord(point[0])} ${coord(point[1])}`;
|
||
}
|
||
return [`${path} Z`];
|
||
});
|
||
};
|
||
|
||
test('#788: strip-wide broad phase preserves every unfiltered fan byte for byte', () => {
|
||
const faces = {
|
||
faces: [{ a: [0, 0], b: [8, 0] }], inside: ([, y]) => y < 0, epsilon: 0.5,
|
||
};
|
||
const cases = [
|
||
{ points: [[350, 350], [585, 450]], radius: 25,
|
||
// Both endpoints are outside the field box, but this wall crosses it.
|
||
segments: [[595, 100, 595, 700], [-1000, -1000, -900, -900], [590, 470, 610, 450]] },
|
||
{ points: [[350, 350], [585, 450]], radius: 50,
|
||
segments: [[595, 700, 595, 100], [600, 100, 600, 700], [0, 900, 1000, 900]] },
|
||
{ points: [[0, 0], [8, 0]], radius: 1, faces,
|
||
// This edge is outside the STORED path box, but inside the displaced
|
||
// emitters' expanded box; an input-point bound would lose its shadow.
|
||
segments: [[-20, 1.49, 20, 1.49], [-20, -100, 20, -100]] },
|
||
{ points: [[0, 0], [2.49, 0]], radius: 2,
|
||
// Boundary tangencies, near-tangencies and degenerate segments.
|
||
segments: [[4.49, -20, 4.49, 20], [-20, 2, 20, 2], [-2, -20, -2, 20],
|
||
[-20, 2 - 1e-10, 20, 2 - 1e-10], [1, 1, 1, 1], [-20, -2 - 1e-10, 20, -2 - 1e-10]] },
|
||
{ points: [[0.13, 0.29], [8.37, 1.26], [8.9, 6.31], [0.32, 7.19], [0.13, 0.29]], radius: 2,
|
||
segments: [[-100, 3, 100, 3], [-100, 4, 100, 4], [4, -100, 4, 100], [400, 400, 401, 401]] },
|
||
{ points: [[0, 0], [2.1, 0], [0.2, 0.55], [2.4, 0.8]], radius: 2,
|
||
segments: [[-10, 2.5, 10, 2.5], [3, -10, 3, 10], [-20, -20, -10, -10]] },
|
||
];
|
||
// Deterministic coverage of varied origins, radii, segment directions and
|
||
// local/far obstacles. No timing assertions or nondeterministic randomness.
|
||
let seed = 788;
|
||
const random = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 2 ** 32);
|
||
for (let i = 0; i < 18; i++) {
|
||
const x = (random() - 0.5) * 10_000, y = (random() - 0.5) * 10_000;
|
||
const radius = [0.05, 2, 25][i % 3];
|
||
const points = Array.from({ length: 4 }, () => [x + random() * radius * 4, y + random() * radius * 4]);
|
||
const segments = Array.from({ length: 12 }, (_, j) => {
|
||
const scale = j % 3 ? radius * 8 : radius * 1000;
|
||
return [x + (random() - 0.5) * scale, y + (random() - 0.5) * scale,
|
||
x + (random() - 0.5) * scale, y + (random() - 0.5) * scale];
|
||
});
|
||
cases.push({ points, radius, segments });
|
||
}
|
||
for (const [index, fixture] of cases.entries()) {
|
||
for (const points of [fixture.points, [...fixture.points].reverse()]) {
|
||
const emitters = emitterSamples(compactPoints(points), fixture.faces ?? null, fixture.radius / 4);
|
||
const expected = unfilteredFans(emitters, fixture.radius, fixture.segments);
|
||
const actual = buildFieldGeometry({ points, radius: fixture.radius,
|
||
scene: { ...scene, occluders: fixture.segments }, polygons: [], faces: fixture.faces ?? null, spaceId: 's' });
|
||
assert.deepEqual(actual?.pieces.flatMap(pieceFans) ?? [], expected, `fixture ${index}: ${JSON.stringify(points)}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('#788: distant occluders are examined once per strip, not once per emitter', () => {
|
||
const points = [[0, 0], [80, 0]], radius = 2;
|
||
let farCoordinateReads = 0;
|
||
const far = Array.from({ length: 64 }, (_, i) => new Proxy([1000 + i, -100, 1000 + i, 100], {
|
||
get(target, property, receiver) {
|
||
if (/^[0-3]$/.test(String(property))) farCoordinateReads++;
|
||
return Reflect.get(target, property, receiver);
|
||
},
|
||
}));
|
||
const local = [[40, 1, 50, 1]];
|
||
const geometry = buildFieldGeometry({ points, radius,
|
||
scene: { ...scene, occluders: [...far, ...local] }, polygons: [], faces: null, spaceId: 's' });
|
||
assert.deepEqual(geometry.pieces.flatMap(pieceFans), unfilteredFans(emitterSamples(points, null, radius / 4), radius, local));
|
||
assert.ok(farCoordinateReads <= far.length * 8,
|
||
`${farCoordinateReads} coordinate reads: a distant segment must not enter per-emitter clipping`);
|
||
assert.ok(geometry.pieces.reduce((n, piece) => n + piece.sourceCount, 0) > 150,
|
||
'candidate culling must not thin or drop emitter fans');
|
||
});
|
||
|
||
test('#788: a residual run retains the true free endpoint in both directions', () => {
|
||
const freeScene = { ...scene, occluders: [], fingerprint: 'free-endpoints' };
|
||
for (const radius of [0.2, 2, 20]) {
|
||
for (const angle of [0, 0.37, 1.2]) {
|
||
const points = [[0, 0], [1.245 * radius * Math.cos(angle), 1.245 * radius * Math.sin(angle)]];
|
||
for (const path of [points, [...points].reverse()]) {
|
||
const geometry = buildFieldGeometry({ points: path, radius, scene: freeScene,
|
||
polygons: [], faces: null, spaceId: 's' });
|
||
const centers = discCenters(geometry, radius);
|
||
for (const endpoint of points) {
|
||
assert.ok(hasCenter(centers, endpoint), `r=${radius}, angle=${angle}: missing endpoint ${endpoint}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
test('#788: acute outer turns retain their vertex fan independently of sampling cuts', () => {
|
||
const freeScene = { ...scene, occluders: [], fingerprint: 'acute-vertices' };
|
||
for (const sign of [-1, 1]) {
|
||
const points = [[0, 0], [2.1, 0], [0.2, sign * 0.55], [2.4, sign * 0.8]];
|
||
for (const path of [points, [...points].reverse()]) {
|
||
const geometry = buildFieldGeometry({ points: path, radius: 2, scene: freeScene,
|
||
polygons: [], faces: null, spaceId: 's' });
|
||
const centers = discCenters(geometry, 2);
|
||
for (const vertex of points) assert.ok(hasCenter(centers, vertex), `missing turn ${vertex}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('#788: reversing and rotating a closed path preserves the complete visibility fan set', () => {
|
||
const freeScene = { ...scene, occluders: [], fingerprint: 'stable-samples' };
|
||
const vertices = [[0.13, 0.29], [8.37, 1.26], [8.9, 6.31], [0.32, 7.19]];
|
||
const fanSet = (points) => {
|
||
const geometry = buildFieldGeometry({ points, radius: 2, scene: freeScene,
|
||
polygons: [], faces: null, spaceId: 's' });
|
||
return [...new Set(geometry.pieces.flatMap(pieceFans))].sort();
|
||
};
|
||
const reference = fanSet([...vertices, vertices[0]]);
|
||
for (let offset = 0; offset < vertices.length; offset++) {
|
||
const rotated = [...vertices.slice(offset), ...vertices.slice(0, offset)];
|
||
for (const path of [rotated, [...rotated].reverse()]) {
|
||
assert.deepEqual(fanSet([...path, path[0]]), reference);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('#788: radius-sized visibility runs keep the full wall-opening normal context', () => {
|
||
const freeScene = { ...scene, occluders: [], fingerprint: 'opening-context' };
|
||
const faces = {
|
||
faces: [{ a: [0, 0], b: [3, 0] }, { a: [5, 0], b: [8, 0] }],
|
||
inside: ([x, y]) => y < 0 && (x <= 3 || x >= 5),
|
||
epsilon: 0.001,
|
||
};
|
||
for (const points of [[[0, 0], [8, 0]], [[8, 0], [0, 0]]]) {
|
||
const geometry = buildFieldGeometry({ points, radius: 1, scene: freeScene,
|
||
polygons: [], faces, spaceId: 's' });
|
||
const centers = discCenters(geometry, 1);
|
||
const inOpening = centers.filter(([x]) => x > 3 && x < 5);
|
||
assert.ok(inOpening.length > 0);
|
||
assert.ok(inOpening.every(([, y]) => Math.abs(y - faces.epsilon) < 1e-6),
|
||
'a cache/run boundary cannot put emitters back on the unshifted wall axis');
|
||
}
|
||
});
|
||
|
||
test('AC17: the field cache is bounded, per space, and counts geometry rebuilds', () => {
|
||
const cache = new LedFieldCache(3);
|
||
cache.forSpace('a');
|
||
for (let i = 0; i < 5; i++) cache.read(`k${i}`, () => ({ pieces: [], box: { x: 0, y: 0, w: 1, h: 1 } }));
|
||
assert.equal(cache.size, 3);
|
||
assert.equal(cache.recomputes, 5);
|
||
cache.read('k4', () => { throw new Error('a hit must not rebuild'); });
|
||
assert.equal(cache.recomputes, 5);
|
||
cache.forSpace('b');
|
||
assert.equal(cache.size, 0, 'another space frees the previous one');
|
||
});
|
||
|
||
test('#784/AC9: the frame gives every strip the 30 cm default, not the shared radius; unbound strips have no view', () => {
|
||
const lamp = { id: 'm1', name: 'Lamp', primary: 'light.led', entities: ['light.led'], space: 's', marker: { id: 'm1', binding: 'device:m1' } };
|
||
const frame = ledFrame({
|
||
space: { id: 's', vb: [0, 0, 1000, 1000], rooms: [], led_strips: [
|
||
{ id: 'a', points: [[0.1, 0.1], [0.4, 0.1]], marker: 'm1' },
|
||
{ id: 'b', points: [[0.1, 0.3], [0.4, 0.3]], marker: null },
|
||
] },
|
||
devices: [lamp],
|
||
hass: { states: { 'light.led': { state: 'on', attributes: {} } } },
|
||
defaultColor: '#ffd27b', paletteAlpha: 0.7, cellCm: 5, gridPitch: 1000 / 240, iconPct: 3.4,
|
||
scene: null, polygons: [], glowFor: () => true, inRoom: () => false, showHidden: false,
|
||
});
|
||
assert.equal(frame.views.length, 1, 'the unbound strip is not a View strip');
|
||
assert.ok(Math.abs(frame.views[0].radius - (30 / 5) * (1000 / 240)) < 1e-9, `radius ${frame.views[0].radius}`);
|
||
});
|
||
|
||
test('AC2/r1 M2: an explicit valid room_id wins over the anchor room; a stale one falls back', () => {
|
||
const rooms = [{ id: 'A' }, { id: 'B' }];
|
||
const inA = (point, room) => room.id === 'A' && point[0] < 500;
|
||
const frameWith = (roomId) => ledFrame({
|
||
space: { id: 's', vb: [0, 0, 1000, 1000], rooms, led_strips: [
|
||
// Anchor (half length) at x = 250: geometrically inside A.
|
||
{ id: 'a', points: [[0.1, 0.1], [0.4, 0.1]], marker: 'm1' },
|
||
] },
|
||
devices: [{ id: 'm1', name: 'Lamp', primary: 'light.led', entities: ['light.led'], space: 's',
|
||
marker: { id: 'm1', binding: 'device:m1', ...(roomId === undefined ? {} : { room_id: roomId }) } }],
|
||
hass: { states: { 'light.led': { state: 'on', attributes: {} } } },
|
||
defaultColor: '#ffd27b', paletteAlpha: 0.7, cellCm: 5, gridPitch: 1000 / 240, iconPct: 3.4,
|
||
scene: null, polygons: [], glowFor: (room) => room.id === 'B', inRoom: inA, showHidden: false,
|
||
});
|
||
assert.equal(frameWith(undefined).views[0].glow, false, 'no room_id: the anchor room A decides (Glow off)');
|
||
assert.equal(frameWith('B').views[0].glow, true, 'explicit room_id B wins over the geometric A');
|
||
assert.equal(frameWith('Z').views[0].glow, false, 'a stale room_id falls back to the geometry');
|
||
assert.equal(stripRoom(rooms, 'B', [250, 100], inA)?.id, 'B');
|
||
assert.equal(stripRoom(rooms, null, [250, 100], inA)?.id, 'A');
|
||
assert.equal(stripRoom(rooms, null, null, inA), undefined);
|
||
});
|
||
|
||
test('AC17/r1 M5: a released owner retains nothing; the stats count visibility entries and fans', async () => {
|
||
const { ledFieldCache, ledFieldStats, releaseLedField } = await import('../test-build/led-strip-field.js');
|
||
const owner = {};
|
||
const cache = ledFieldCache(owner);
|
||
cache.forSpace('a');
|
||
cache.read('k1', () => ({ d: 'M0 0 L1 1', pieces: [{ clip: 'M0 0 Z M1 1 Z', sourceCount: 2 }], box: { x: 0, y: 0, w: 1, h: 1 } }));
|
||
cache.read('k2', () => null);
|
||
assert.deepEqual(ledFieldStats(owner), { visibility: 2, sources: 2, visibilityPaths: 1, pathChars: 22, recomputes: 2 });
|
||
releaseLedField(owner);
|
||
assert.deepEqual(ledFieldStats(owner), { visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, recomputes: 0 });
|
||
assert.notEqual(ledFieldCache(owner), cache, 'a new mount starts a new cache');
|
||
});
|