diff --git a/demo/benchmark_led_strips.mjs b/demo/benchmark_led_strips.mjs
index 1e5572b7..c96b67a7 100644
--- a/demo/benchmark_led_strips.mjs
+++ b/demo/benchmark_led_strips.mjs
@@ -25,7 +25,9 @@
* Counters (exact, every sample): geometry/visibility recomputes over 100
* unrelated HA ticks, 100 pan/zoom steps and a colour-only change (0); the
* three LED caches of the shown space — shapes ≤ 50, visibility ≤ 50,
- * retained per-emitter fans ≤ 2500 — identical after every cycle (growth 0);
+ * compact visibility paths ≤ 2500 and retained path text ≤ 4 Mi characters
+ * (≤ 8 MiB UTF-16) — identical after every cycle (growth 0). The true fan
+ * count remains diagnostic: #788 removed lossy endpoint/vertex thinning;
* after disconnect 0 retained entries and 0 live LED timers/observers; a
* late import after disconnect restores nothing (one extra cold run with the
* runtime response delayed); LED chunk requests (none without strips, never
@@ -145,7 +147,7 @@ function pageHost() {
window.__ledStats = async (card) => {
const url = performance.getEntriesByType('resource').map((entry) => entry.name)
.find((name) => /led-strip-runtime-[^/]+\\.js/.test(name));
- if (!url) return { shapes: 0, visibility: 0, sources: 0, recomputes: 0, loaded: false };
+ if (!url) return { shapes: 0, visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, recomputes: 0, loaded: false };
const runtime = await import(url);
return { ...runtime.ledStats(card), loaded: true };
};
@@ -279,8 +281,8 @@ async function sample() {
if (strips) await until(() => ledStable());
}
await frame();
- const { shapes, visibility, sources } = await stats();
- return { shapes, visibility, sources };
+ const { shapes, visibility, sources, visibilityPaths, pathChars } = await stats();
+ return { shapes, visibility, sources, visibilityPaths, pathChars };
};
await cycle();
await gc();
@@ -295,7 +297,7 @@ async function sample() {
await sleep(600);
const afterDisconnect = await stats();
const liveAfterDisconnect = window.__ledLiveCounts();
- const key = (entry) => `${entry.shapes}/${entry.visibility}/${entry.sources}`;
+ const key = (entry) => `${entry.shapes}/${entry.visibility}/${entry.sources}/${entry.visibilityPaths}/${entry.pathChars}`;
return {
firstStableRenderMs, warmSpaceReadyMs, stateUpdateMs, panZoomMs, panZoomLongTaskMaxMs, cameraSeriesLongTaskMaxMs,
retainedHeapBytes: heapBefore == null || heapAfter == null ? null : Math.max(0, heapAfter - heapBefore),
@@ -306,10 +308,13 @@ async function sample() {
shapes: Math.max(...cycleStats.map((entry) => entry.shapes)),
visibility: Math.max(...cycleStats.map((entry) => entry.visibility)),
sources: Math.max(...cycleStats.map((entry) => entry.sources)),
+ visibilityPaths: Math.max(...cycleStats.map((entry) => entry.visibilityPaths)),
+ pathChars: Math.max(...cycleStats.map((entry) => entry.pathChars)),
cacheGrowthOverCycles: new Set(cycleStats.map(key)).size - 1,
} : null,
disconnect: {
- retained: afterDisconnect.shapes + afterDisconnect.visibility + afterDisconnect.sources,
+ retained: afterDisconnect.shapes + afterDisconnect.visibility + afterDisconnect.sources
+ + afterDisconnect.visibilityPaths + afterDisconnect.pathChars,
liveBefore: liveBeforeDisconnect,
live: liveAfterDisconnect.timers + liveAfterDisconnect.frames + liveAfterDisconnect.observers,
},
@@ -359,7 +364,7 @@ async function lateImport() {
// The chunk the card asked for, by its URL: the same module instance.
const late = await page.evaluate(async (url) => {
const runtime = url ? await import(url) : null;
- return { stats: runtime ? { ...runtime.ledStats(window.__lateCard), loaded: true } : { shapes: 0, visibility: 0, sources: 0, loaded: false },
+ return { stats: runtime ? { ...runtime.ledStats(window.__lateCard), loaded: true } : { shapes: 0, visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, loaded: false },
live: window.__ledLiveCounts() };
}, runtimeUrl);
return { ...result, ...late, held };
@@ -440,14 +445,16 @@ if (STRIPS) {
for (const key of ['recomputesOnHaTicks', 'recomputesOnCamera', 'recomputesOnColour', 'cacheGrowthOverCycles']) {
if (counters[key] !== 0) failures.push(`${key} = ${counters[key]}, expected 0`);
}
- for (const key of ['shapes', 'visibility', 'sources']) {
- if (counters[key] > caches[key]) failures.push(`${key} cache ${counters[key]} > ${caches[key]}`);
+ for (const key of ['shapes', 'visibility', 'visibilityPaths', 'pathChars']) {
+ if (!Number.isFinite(counters[key])) failures.push(`${key} cache metric missing`);
+ else if (counters[key] > caches[key]) failures.push(`${key} cache ${counters[key]} > ${caches[key]}`);
}
}
if (late) {
if (!late.held || !late.stats.loaded) failures.push('late import: the runtime response was never held or never landed');
if (late.stripesAfterLoad !== late.stripesBeforeLoad) failures.push('late import rendered the gone card');
- const retained = late.stats.shapes + late.stats.visibility + late.stats.sources;
+ const retained = late.stats.shapes + late.stats.visibility + late.stats.sources
+ + late.stats.visibilityPaths + late.stats.pathChars;
if (retained) failures.push(`late import restored ${retained} cache entries`);
if (late.live.timers + late.live.frames + late.live.observers) failures.push('late import left LED timers/observers');
} else if (!skipLateImport) failures.push('late import not measured');
diff --git a/demo/performance/README.md b/demo/performance/README.md
index 4bcb9b4e..f158aa17 100644
--- a/demo/performance/README.md
+++ b/demo/performance/README.md
@@ -442,7 +442,8 @@ scenario and of a 100-step series, one wheel step per frame — retained heap
after 20 A→B→C→A cycles 64 MiB — and on exact counters: zero field geometry
recomputes over 100 unrelated HA ticks, 100 camera steps and a colour-only
change; the three caches of the shown space (`ledStats` of the runtime chunk)
-— shapes ≤ 50, visibility ≤ 50, retained per-emitter fans ≤ 2500 — identical
+— shapes ≤ 50, visibility ≤ 50, compact visibility batches ≤ 2500 and
+retained SVG path text ≤ 4 Mi characters — identical
after every cycle; after disconnect 0 retained entries and 0 live LED
timers/frames/observers (tracked by creation stack in the page); one extra
cold run holds the runtime response, removes the card meanwhile and requires
@@ -455,4 +456,11 @@ samples after one warm-up enforced on the merged set. Base predates the
strips, so there is no relative comparison; the zero-LED View remains judged
by the relative `large-house-interaction-v1` profile. Exact-SHA Linux output
of the full performance workflow (`led-strips` matrix entry) is the gate
-evidence; a local report is diagnostic.
+evidence; a local report is diagnostic. Since #788 the actual emitter-fan
+count remains reported but is not capped by dropping required endpoints or
+corners: that old limit hid visible holes. Five fans are retained per compact
+batch; all batches render as one positive-winding clip path per strip. The
+4 Mi-character bound (at most 8 MiB UTF-16 text) explicitly measures cached
+paths, excluding the joined Lit/DOM clip string. Timing and the 64 MiB
+warm-cycle heap-growth limit are unchanged; that is not an absolute browser
+or GPU memory bound.
diff --git a/demo/performance/budgets-led-strips.json b/demo/performance/budgets-led-strips.json
index e3a5ba08..cbc43f2e 100644
--- a/demo/performance/budgets-led-strips.json
+++ b/demo/performance/budgets-led-strips.json
@@ -1,11 +1,12 @@
{
"profile": "led-strips-v1",
"issue": 780,
- "note": "ТЗ #780 §13.2: absolute limits applied to median and p95; 10x5 rests on the interaction profile's absolute ceilings, 50x50 are the acceptance bounds set in the ТЗ. The no-strip control is judged by the existing interaction profile against base. Caches of the shown space (ТЗ §13.2): 50 shapes, 50 visibility entries, 2500 retained per-emitter fans; 0 after disconnect.",
+ "note": "#780 timing/heap limits unchanged. #788: preserve every end/vertex; compact positive-winding fans into paths without discarding geometry. Bound actual retained representation: 50 shapes/entries, 2500 compact clip paths, 4 Mi SVG characters (at most 8 MiB UTF-16, one eighth of the unchanged 64 MiB heap budget). Actual fan count is still reported, never relabelled as path count. No growth/recomputes, zero after disconnect.",
"caches": {
"shapes": 50,
"visibility": 50,
- "sources": 2500
+ "visibilityPaths": 2500,
+ "pathChars": 4194304
},
"sizes": {
"10x5": {
diff --git a/demo/smoke_led_strip_field.mjs b/demo/smoke_led_strip_field.mjs
new file mode 100644
index 00000000..28074c78
--- /dev/null
+++ b/demo/smoke_led_strip_field.mjs
@@ -0,0 +1,104 @@
+/** #788: test the painted light, not SVG node counts. The card renders each
+ * synthetic strip through the ordinary config event. Rasterise its actual
+ * field DOM at several pixel densities and compare unobstructed floor pixels
+ * with the independently calculated distance to the stored polyline. This
+ * catches missing end/vertex emitters, winding cancellation and hard rims.
+ * Household exports are deliberately NOT fixtures in this public test.
+ */
+import { launch, check, finish } from './serve.mjs';
+
+const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
+const cases = [
+ { name: 'short residual end', points: [[0.25, 0.35], [0.36225, 0.35]] },
+ { name: 'diagonal residual end', points: [[0.25, 0.3], [0.341, 0.365625]] },
+ { name: 'acute outer turn', points: [[0.3, 0.25], [0.32, 0.38], [0.35, 0.25]] },
+ { name: 'reflected acute turn', points: [[0.3, 0.48], [0.32, 0.35], [0.35, 0.48]] },
+ { name: 'decimal closed loop', points: [[0.2301, 0.2202], [0.3803, 0.2202], [0.3803, 0.4204], [0.2301, 0.4204], [0.2301, 0.2202]] },
+ { name: 'intersecting path', points: [[0.3, 0.25], [0.4, 0.45], [0.3, 0.45], [0.4, 0.25]] },
+ { name: 'mixed free and blocked fans', points: [[0.35, 0.35], [0.585, 0.45]], wall: true },
+];
+const results = [];
+for (const scenario of cases) {
+ for (const radiusCm of [30, 60, 120]) {
+ for (const reversed of [false, true]) {
+ const points = reversed ? [...scenario.points].reverse() : scenario.points;
+ await page.evaluate(async ({ points, wall, radiusCm }) => {
+ await window.__hpTest.setServerConfig(cfg => ({ ...cfg,
+ spaces: [{ id: 'led-oracle', title: 'LED field oracle', cell_cm: 5,
+ rooms: [{ id: 'room', name: 'Room', poly: [[0.08, 0.08], [0.92, 0.08], [0.92, 0.72], [0.08, 0.72]] }],
+ partitions: wall ? [{ id: 'barrier', a: [0.6, 0.1], b: [0.6, 0.7], cm: 12 }] : [],
+ wall_segments: [], openings: [], decor: [], wall_columns: [],
+ settings: { glow_enabled: true, fill_mode: 'none' },
+ led_strips: [{ id: 'oracle', marker: 'oracle-light', points }] }],
+ markers: [{ id: 'oracle-light', binding: 'virtual', is_light: true,
+ space: 'led-oracle', room_id: 'room', glow_radius_cm: radiusCm,
+ glow_color: { c: '#ffffff', bri: 1 } }],
+ }));
+ }, { points, wall: scenario.wall, radiusCm });
+ await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-led-field="oracle"]'));
+ await page.waitForTimeout(550);
+ const raster = await page.evaluate(async ({ points, wall, radiusCm }) => {
+ const source = window.__card.shadowRoot.querySelector('.led-fields').cloneNode(true);
+ // Isolate geometry/falloff from colour/brightness/animation, whose
+ // user-facing lifecycle is independently covered by smoke_led_strip_glow.
+ source.querySelectorAll('.led-pool').forEach(el => {
+ el.setAttribute('fill', '#ffffff'); el.setAttribute('fill-opacity', '1');
+ });
+ const path = points.map(p => p.map(v => v * 1000));
+ const r = radiusCm / 5 * (1000 / 240); // documented physical scale, not read from implementation.
+ const minX = Math.floor(Math.min(...path.map(p => p[0])) - r - 2);
+ const minY = Math.floor(Math.min(...path.map(p => p[1])) - r - 2);
+ const w = Math.ceil(Math.max(...path.map(p => p[0])) + r + 2 - minX);
+ const h = Math.ceil(Math.max(...path.map(p => p[1])) + r + 2 - minY);
+ const distance = (x, y) => Math.min(...path.slice(1).map((b, i) => {
+ const a = path[i], dx = b[0] - a[0], dy = b[1] - a[1];
+ const t = Math.max(0, Math.min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / (dx * dx + dy * dy)));
+ return Math.hypot(x - a[0] - t * dx, y - a[1] - t * dy);
+ }));
+ // The documented shared five-stop brightness contract, not a source import.
+ const stops = [[0, 1], [0.45, 0.88], [0.70, 0.62], [0.86, 0.32], [1, 0]];
+ const expectedAt = d => {
+ const f = d / r;
+ if (f >= 1) return 0;
+ for (let i = 1; i < stops.length; i++) {
+ if (f <= stops[i][0]) {
+ const [x0, y0] = stops[i - 1], [x1, y1] = stops[i];
+ return y0 + (y1 - y0) * (f - x0) / (x1 - x0);
+ }
+ }
+ return 0;
+ };
+ const output = [];
+ for (const scale of [1, 2, 4]) {
+ const xml = ``;
+ const image = new Image();
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
+ await image.decode();
+ const canvas = document.createElement('canvas'); canvas.width = w * scale; canvas.height = h * scale;
+ const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0);
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
+ let worst = 0, bad = 0, checked = 0, witness = null;
+ for (let py = 2; py < canvas.height - 2; py += 3) {
+ for (let px = 2; px < canvas.width - 2; px += 3) {
+ const x = minX + (px + 0.5) / scale, y = minY + (py + 0.5) / scale;
+ // Left of the long opaque wall every source has clear sight. On
+ // the far side every source is occluded (no wall ends in this ROI).
+ if (wall && Math.abs(x - 595) < 2) continue;
+ const expected = wall && x > 595 ? 0 : expectedAt(distance(x, y));
+ const actual = data[(py * canvas.width + px) * 4] / 255;
+ const error = Math.abs(expected - actual);
+ checked++;
+ if (error > worst) { worst = error; witness = [x, y, expected, actual]; }
+ if (error > 0.06) bad++;
+ }
+ }
+ output.push({ scale, checked, bad, worst, witness });
+ }
+ return output;
+ }, { points, wall: scenario.wall, radiusCm });
+ for (const row of raster) check(`${scenario.name}, radius=${radiusCm}cm, reverse=${reversed}, raster=${row.scale}: smooth field and opaque wall`, row.bad, 0);
+ results.push({ name: scenario.name, radiusCm, reversed, raster });
+ }
+ }
+}
+await finish(browser, results);
diff --git a/demo/smoke_led_strip_tube.mjs b/demo/smoke_led_strip_tube.mjs
new file mode 100644
index 00000000..fe0233f0
--- /dev/null
+++ b/demo/smoke_led_strip_tube.mjs
@@ -0,0 +1,156 @@
+/** #788: browser witness for the visible tube, independent of the Glow field.
+ * Synthetic room-wall fixtures enter through the config-event facade. Expected
+ * corners come from an analytic inset/line equation below, not LED geometry.
+ * Both real SVG lengths and raster pixels protect against extra miter hooks,
+ * backtracking at short subdivisions and a notch through the open doorway.
+ */
+import { launch, check, finish } from './serve.mjs';
+import { fixtureWallKey } from './fixtures/wall-key.mjs';
+
+const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
+// All coordinates here are render units. A 12 cm wall at 5 cm/grid cell has
+// total thickness 10 units (1000/240 units/cell), hence a 5-unit inner inset.
+// This plan is smaller than the documented 1000-unit icon reference floor:
+// D=2.5%*1000=25, outline=.12D=3, centreline offset=1.5, core=1.5.
+const left = 200.1, top = 180.2, right = 500.3, bottom = 480.4;
+const offset = 1.5, outlineWidth = 3, coreWidth = 1.5;
+const scenarios = [
+ { name: 'decimal rectangle and door', angle: 0, tilt: 0, intermediate: false },
+ { name: 'sloped side and short saved subdivision', angle: 0, tilt: 0.545461, intermediate: true },
+ { name: 'rotated decimal rectangle and door', angle: 0.137, tilt: 0, intermediate: false },
+];
+const results = [];
+
+await page.evaluate(async () => {
+ const card = window.__card;
+ card.setConfig({ ...card._config, icon_size: 2.5, language: 'en' });
+ await window.__hpTest.setMode('view');
+});
+
+for (const scenario of scenarios) {
+ const cos = Math.cos(scenario.angle), sin = Math.sin(scenario.angle);
+ const rotate = ([x, y]) => [400 + (x - 400) * cos - (y - 330) * sin,
+ 330 + (x - 400) * sin + (y - 330) * cos];
+ const normalise = (point) => rotate(point).map(value => value / 1000);
+ const rooms = [
+ { id: 'tube-room', name: 'Tube room', poly: [[left - 5, top - 5], [right + 5, top - 5],
+ [right + 5, bottom + 5], [left - 5, bottom + 5]].map(normalise) },
+ { id: 'receiving-room', name: 'Receiving room', poly: [[right + 5, top - 5], [755.5, top - 5],
+ [755.5, bottom + 5], [right + 5, bottom + 5]].map(normalise) },
+ ];
+ const walls = [];
+ for (const room of rooms) room.poly.forEach((a, i) => {
+ const b = room.poly[(i + 1) % room.poly.length], key = fixtureWallKey(a, b);
+ if (!walls.some(wall => wall.key === key)) walls.push({ key, a, b, cm: 12 });
+ });
+ // The doorway begins just .1 unit from the inner corner: its first face
+ // piece is shorter than the tube's centreline offset. Both rooms exist, so
+ // this is a real optically open door, not an opaque exterior opening.
+ const door = normalise([right + 5, top + 40.1]);
+ const corners = [[left + scenario.tilt, top], [left, bottom], [right, bottom], [right, top]];
+ const source = [...corners, ...(scenario.intermediate ? [[right - 0.1, top]] : [])].map(normalise);
+ // The free left side keeps its genuine slope; intersect it analytically
+ // with y=top+offset and y=bottom-offset instead of silently straightening it.
+ const expected = [[left + scenario.tilt * (1 - offset / (bottom - top)), top + offset],
+ [left + scenario.tilt * offset / (bottom - top), bottom - offset],
+ [right - offset, bottom - offset], [right - offset, top + offset]];
+ if (!scenario.tilt) { expected[0][0] += offset; expected[1][0] += offset; }
+ const expectedCorners = expected.map(rotate);
+ const expectedLength = expectedCorners.reduce((length, a, i) => {
+ const b = expectedCorners[(i + 1) % expectedCorners.length];
+ return length + Math.hypot(b[0] - a[0], b[1] - a[1]);
+ }, 0);
+
+ // Move the stored closure seam to every node, including the short saved
+ // subdivision. The visible result must remain one four-corner tube.
+ for (let start = 0; start < source.length; start++) for (const reversed of [false, true]) {
+ const ordered = [...source.slice(start), ...source.slice(0, start)];
+ if (reversed) ordered.reverse();
+ const points = [...ordered, ordered[0]];
+ await page.evaluate(async ({ rooms, walls, door, angle, points }) => {
+ await window.__hpTest.setServerConfig(cfg => ({ ...cfg,
+ settings: { ...(cfg.settings || {}), volumetric_view: false },
+ spaces: [{ id: 'tube-oracle', title: 'Tube oracle', cell_cm: 5, view_box: [0, 0, 1, 0.7],
+ rooms, walls, wall_segments: [], partitions: [], wall_columns: [], decor: [],
+ openings: [{ id: 'tube-door', type: 'door', x: door[0], y: door[1],
+ angle: 90 + angle * 180 / Math.PI, length: 0.08 }],
+ settings: { glow_enabled: true, fill_mode: 'none', show_names: false, sun_rays: false },
+ led_strips: [{ id: 'tube', marker: 'd_light1', points }] }],
+ markers: [{ id: 'd_light1', binding: 'device:d_light1', space: 'tube-oracle',
+ room_id: 'tube-room', is_light: true, glow_radius_cm: 30 }],
+ }));
+ }, { rooms, walls, door, angle: scenario.angle, points });
+
+ let viewShape = null;
+ for (const mode of ['view', 'devices']) {
+ await page.evaluate(mode => window.__hpTest.setMode(mode), mode);
+ let onShape = null;
+ for (const state of ['on', 'off']) {
+ // Public HA state input, not a write to the card's private resolver.
+ await page.evaluate(async state => {
+ const card = window.__card, previous = card.hass.states['light.ceiling'];
+ card.hass = { ...card.hass, states: { ...card.hass.states,
+ 'light.ceiling': { ...previous, state } } };
+ await window.__hpTest.settled();
+ }, state);
+ await page.waitForFunction(state => window.__card.shadowRoot
+ .querySelector('[data-led-strip="tube"]')?.dataset.state === state, state);
+
+ const actual = await page.evaluate(async ({ expectedCorners, outlineWidth, coreWidth }) => {
+ const group = window.__card.shadowRoot.querySelector('[data-led-strip="tube"]');
+ const outline = group.querySelector('.led-outline'), core = group.querySelector('.led-core');
+ const d = outline.getAttribute('d');
+ const corners = [...d.matchAll(/[ML]\s*([-+\d.e]+)[,\s]+([-+\d.e]+)/gi)]
+ .map(match => [Number(match[1]), Number(match[2])]);
+ const expectedD = expectedCorners.map((p, i) => `${i ? 'L' : 'M'}${p[0]} ${p[1]}`).join(' ') + ' Z';
+ const minX = Math.floor(Math.min(...expectedCorners.map(p => p[0])) - 8);
+ const minY = Math.floor(Math.min(...expectedCorners.map(p => p[1])) - 8);
+ const width = Math.ceil(Math.max(...expectedCorners.map(p => p[0])) - minX + 8);
+ const height = Math.ceil(Math.max(...expectedCorners.map(p => p[1])) - minY + 8);
+ const raster = async body => {
+ const image = new Image();
+ const xml = ``;
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
+ await image.decode();
+ const canvas = document.createElement('canvas'); canvas.width = width * 2; canvas.height = height * 2;
+ const context = canvas.getContext('2d'); context.drawImage(image, 0, 0);
+ return context.getImageData(0, 0, canvas.width, canvas.height).data;
+ };
+ const expectedMarkup = ``;
+ const [painted, reference] = await Promise.all([
+ raster(new XMLSerializer().serializeToString(outline) + new XMLSerializer().serializeToString(core)),
+ raster(expectedMarkup),
+ ]);
+ let badPixels = 0;
+ for (let i = 0; i < painted.length; i += 4) {
+ if ([0, 1, 2, 3].some(channel => Math.abs(painted[i + channel] - reference[i + channel]) > 16)) badPixels++;
+ }
+ return { d, corners, closed: /Z\s*$/i.test(d), length: outline.getTotalLength(),
+ widths: [Number(outline.getAttribute('stroke-width')), Number(core.getAttribute('stroke-width'))],
+ coreD: core.getAttribute('d'), badPixels,
+ openingPresent: !!window.__card.shadowRoot.querySelector('[data-hp="opening"][data-id="tube-door"][data-kind="door"]'),
+ saved: window.__card._serverCfg.spaces[0].led_strips[0].points };
+ }, { expectedCorners, outlineWidth, coreWidth });
+
+ const label = `${scenario.name}, start=${start}, reverse=${reversed}, ${mode}, ${state}`;
+ check(`${label}: four closed visible corners`, actual.closed && actual.corners.length === 4);
+ check(`${label}: fixture includes the real mounted door`, actual.openingPresent);
+ check(`${label}: analytical corner positions`, expectedCorners.every(p =>
+ actual.corners.some(q => Math.hypot(p[0] - q[0], p[1] - q[1]) < 1e-4)));
+ check(`${label}: no extra travel from a hook, backtrack or door notch`, Math.abs(actual.length - expectedLength) < 0.003);
+ check(`${label}: expected tube pixels`, actual.badPixels, 0);
+ check(`${label}: outline/core share geometry`, actual.coreD, actual.d);
+ check(`${label}: physical outline/core widths`, actual.widths, [outlineWidth, coreWidth]);
+ check(`${label}: saved geometry is untouched`, actual.saved, points);
+ const shape = { d: actual.d, widths: actual.widths };
+ if (state === 'on') onShape = shape;
+ else check(`${label}: off keeps the on shape and thickness`, shape, onShape);
+ if (mode === 'view' && state === 'on') viewShape = shape;
+ if (mode === 'devices') check(`${label}: Devices keeps View tube geometry`, shape, viewShape);
+ results.push({ scenario: scenario.name, start, reversed, mode, state,
+ corners: actual.corners.length, badPixels: actual.badPixels, lengthError: actual.length - expectedLength });
+ }
+ }
+ }
+}
+await finish(browser, results);
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 864cc6eb..7aa9ce27 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,11 @@
## Unreleased
+- Fixed remaining LED-strip rendering defects: clean joins on fractional and
+ slightly tilted contours, continuous light without cut-out wedges, and an
+ equally soft falloff at both ends and the outside of sharp turns
+ ([#788](https://github.com/Matysh/houseplan-card/issues/788)).
+
## v1.79.0-beta.6 — 2026-10-03
- Fixed rectangular LED strips on wall faces: corners remain exact miters and
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index c6042b30..dbd2ab90 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -8,6 +8,11 @@
## Не выпущено
+- Исправлены оставшиеся дефекты LED-лент: выступы на стыках с дробными
+ координатами и небольшим наклоном, вырезы внутри свечения и резкие края
+ вместо плавного затухания на концах и снаружи острых углов
+ ([#788](https://github.com/Matysh/houseplan-card/issues/788)).
+
## v1.79.0-beta.6 — 2026-10-03
- Исправлены прямоугольные LED-ленты на гранях стен: углы теперь остаются
diff --git a/docs/LIGHT.md b/docs/LIGHT.md
index 43b55cbf..e7884608 100644
--- a/docs/LIGHT.md
+++ b/docs/LIGHT.md
@@ -420,18 +420,24 @@ only the geometry differs:
- **Radius.** 30 cm by default, independent of `settings.glow_radius_cm`; the
marker's personal `glow_radius_cm` wins.
-- **Field.** A continuous band along every segment with round free ends: grey
- luminance bands of the shared `GLOW_FALLOFF`, one piece per stretch, blended
- with `lighten` inside one isolated group, so corners and the closing of a
- loop neither seam nor double the brightness. Intensity and the 500 ms fade
+- **Field.** One continuous stroked path with round free ends: 48 grey
+ luminance bands of the shared `GLOW_FALLOFF` in one mask, so corners and the
+ closing of a loop neither seam nor double the brightness. Intensity and the 500 ms fade
are the shared `glowAlpha` / `GLOW_FADE_MS`.
-- **Visibility.** The strip is cut into consecutive pieces no longer than the
- radius; each piece is clipped to the visibility fans of its own emitters
- (the shared `visibilityPolygon` over the same barrier scene as pools; the
- fans are separate paths of one clipPath, no boolean pass per piece), and the
- whole field layer is clipped once to the floor. An unobstructed emitter uses
+- **Visibility.** Classify and sample the full polyline at radius/4 or finer,
+ retaining every actual vertex and both ends; never thin a short final run
+ or an acute corner. The continuous field is clipped to the union of the
+ emitters' visibility fans (the shared `visibilityPolygon` over the same
+ barrier scene as pools), then once to the floor. Groups of five fans are
+ retained as compact positive-winding paths and rendered as one SVG clip
+ child with explicit `clip-rule=nonzero` (several compound clip children
+ caused Chromium raster holes despite correct geometric membership);
+ discs and blocked fans have the same winding, so overlap adds visibility
+ instead of cutting holes. An unobstructed emitter uses
an exact SVG disc; a blocked fan keeps hard obstacle edges and exact circular
- arcs between them, so free ends cannot expose angular-sweep facets. Windows,
+ arcs between them. Before the sweep, barriers are clipped to the emitter's
+ radius: exact wall–circle intersections become angular events, not a chord
+ cutting away visible floor between two coarse rays. Windows,
columns,
thick walls and Solid zero walls block; doors/gates pass by their actual
opening; Dashed zero walls are transparent. Emitters on a thick face sit
@@ -442,9 +448,12 @@ only the geometry differs:
inherits their free-side normal. The visible stripe and its emitters
therefore stay on one straight line through the opening. At a genuine turn,
safely intersecting shifted sides use that bounded intersection as their
- single miter; unsafe acute angles retain the short connector. A stored
- four-corner loop consequently stays a four-corner rectangle without steps
- at openings or diagonal corner inserts (#787).
+ single miter; a free side contributes its unshifted axis to the same join.
+ Numeric endpoint tails are not gaps; redundant collinear subdivisions are
+ removed only from the derived visible path before offsetting, never from
+ the saved points. Unsafe acute angles retain the short connector. A stored
+ four-corner loop stays four-cornered without steps or protruding hooks;
+ a genuinely tilted side stays tilted (#787, #788).
- **Core.** With effective Glow (space `glow_enabled` + room `glow`) the core
stays white and the colour is the field; without Glow the core takes the
source colour and there is no field. Off: white core, no field.
@@ -461,5 +470,20 @@ only the geometry differs:
- **Laziness.** The stripe/hit/2.5D code (`led-strip-runtime`) and the field
(`led-strip-field`) are separate lazy chunks; the initial graph holds only
the presence check and the loader (`led-strip-gate`). Caches are bounded per
- space (50 shapes, 50 visibilities, 2500 retained fans) and released on space
- change and on disconnect; a chunk that lands after disconnect applies nothing.
+ space (50 shapes and 50 visibility entries) and released on space change
+ and on disconnect; a chunk that lands after disconnect applies nothing.
+ The `led-strips-v1` maximum-load witness bounds the compact representation
+ to 2500 cached path batches and 4 Mi cached characters (at most 8 MiB UTF-16,
+ excluding the joined Lit/DOM clip string), with unchanged timing and 64 MiB
+ warm-cycle heap-growth budgets (not a total browser-memory bound).
+ The actual fan count remains a separate honest
+ diagnostic. The former 2500-fan acceptance bound depended on dropping
+ required vertices/endpoints and was incompatible with the 50×50-point
+ contract; compaction now reduces object/DOM overhead, not geometric detail.
+
+`smoke_led_strip_field` compares rasterised production-field pixels with an
+independent distance/falloff oracle across three radii, both path directions
+and three raster densities: free ends, acute/reflected turns, decimal loops,
+self-crossings and an opaque wall. Unit tests pin joins, circle events and
+doorway emitter normals. The original household export is checked locally,
+not stored as a public fixture.
diff --git a/docs/STATUS.md b/docs/STATUS.md
index dc1962f4..fc89b380 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -29,6 +29,7 @@ Everything computable from the tree and git; regenerate, never edit by hand
|---|---|
| Current local cycle | **Beta v1.79.0-beta.6 candidate** — two integrated S8 issues since beta.5. LED-strip Glow has smooth free end caps, while rectangular strips keep clean corners and remain straight across doors, gates and passages (#786, #787). `main` remains on stable v1.78.0. |
| Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. |
+| LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. |
| Warm remount | Delayed editor restoration preserves the original View camera, reserves pending chrome space, and yields synchronously to mode/space navigation (#762). Header/stage dimensions are published as one settled pair. Canonical: `docs/WARM-REMOUNT.md`. |
| 2.5D View | Public since #649: the installation-wide General settings switch `settings.volumetric_view` (Display). Flat stays the default and byte-for-byte unchanged; editors and `houseplan-space-card` stay Flat. Canonical: `docs/ISOMETRIC.md`. |
| Input support | Owner's rule since 2026-08-08: View and kiosk are fully supported and release-blocking on touch; the three editors are desktop-first, touch editing is best effort. Canonical: `docs/TOUCH-SUPPORT.md`. |
diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md
index 3a3ee40f..43a6cf83 100644
--- a/docs/testing-notes/mutation-browser-guards.md
+++ b/docs/testing-notes/mutation-browser-guards.md
@@ -14,11 +14,11 @@ to prove that the Node witness actually kills it.
| --- | ---: | --- |
| Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. |
| Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. |
-| Paint, cascade and layer composition | 32 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
+| Paint, cascade and layer composition | 36 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Pointer geometry and trusted interaction | 47 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
| Custom-element and HA browser lifecycle | 101 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
-| **Total** | **226 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
+| **Total** | **230 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
## Measured effect
@@ -93,6 +93,10 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p
- `iso-theme-dark-wall-rule-returns`
- `led-badge-dropped`
- `led-core-coloured-under-glow`
+- `led-field-endpoint-dropped`
+- `led-field-disc-cancels-fan`
+- `led-field-compound-clip-children`
+- `led-field-circle-events-missing`
- `led-icon-not-suppressed`
- `led-source-stays-round-at-anchor`
- `led-static-live-ignored`
diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs
index dab4a688..bde5668b 100644
--- a/scripts/bundle-budget.mjs
+++ b/scripts/bundle-budget.mjs
@@ -675,7 +675,10 @@ export const LAZY_ONBOARDING_GZIP_CEILING = 28_395;
* its English dictionary + shared geometry 9 913 B), plus 10 %, rounded up to
* a KiB. Absolute walls: a View without strips loads neither graph.
*/
-export const LAZY_LED_GZIP_CEILING = 10 * 1024;
+// #788: retaining real end/corner emitters, stable numerical miters and exact
+// wall/radius events raises the lazy View graph to 10 353 B gzip. Give this
+// corrected representation 11 KiB; no-LED initial View is still untouched.
+export const LAZY_LED_GZIP_CEILING = 11 * 1024;
export const LAZY_LED_EDITOR_GZIP_CEILING = 11 * 1024;
/**
diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs
index c5d2eb97..7b0a4bfa 100644
--- a/scripts/mutation-registry.mjs
+++ b/scripts/mutation-registry.mjs
@@ -14296,6 +14296,46 @@ const MUTANT_DEFINITIONS = [
replace: " const shift = [0, 0];",
}],
},
+ {
+ id: 'led-field-endpoint-dropped',
+ guard: 'node demo/smoke_led_strip_field.mjs',
+ because: '#788: both true ends must fade to zero, including a short residual segment in either direction',
+ patches: [{
+ file: 'src/led-strip-field.ts',
+ find: ' const emitters = emitterSamples(path, input.faces, r / 4);',
+ replace: ' const emitters = emitterSamples(path, input.faces, r / 4).slice(0, -1);',
+ }],
+ },
+ {
+ id: 'led-field-disc-cancels-fan',
+ guard: 'node demo/smoke_led_strip_field.mjs',
+ because: '#788: positive-winding discs and blocked fans form an OR union, never subtract visible floor',
+ patches: [{
+ file: 'src/led-strip-field.ts',
+ find: '0 1 1 ${right} ${cy} A${r} ${r} 0 1 1 ${left}',
+ replace: '0 1 0 ${right} ${cy} A${r} ${r} 0 1 0 ${left}',
+ }],
+ },
+ {
+ id: 'led-field-compound-clip-children',
+ guard: 'node demo/smoke_led_strip_field.mjs',
+ because: '#788: Chromium rasterizes several compound clip children with direction-dependent bright holes',
+ patches: [{
+ file: 'src/led-strip-field.ts',
+ find: '',
+ replace: '${geometry.pieces.map((piece) => svg``)}',
+ }],
+ },
+ {
+ id: 'led-field-circle-events-missing',
+ guard: 'node demo/smoke_led_strip_field.mjs',
+ because: '#788: a long wall meeting the emitter radius must not replace visible floor with a coarse sweep chord',
+ patches: [{
+ file: 'src/led-strip-field.ts',
+ find: ' const near = circleSegments(p, radius, scene.occluders);',
+ replace: ' const near = scene.occluders;',
+ }],
+ },
{
id: 'led-emits-from-body',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs
index b0d8d4ac..002f1dc2 100644
--- a/scripts/smoke-links.mjs
+++ b/scripts/smoke-links.mjs
@@ -608,7 +608,8 @@ export const SMOKE_LINKS = [
'src/led-strip-gate.ts', 'src/led-strip-card.ts', 'src/led-strip-geometry.ts',
'src/led-strip-runtime.ts', 'src/led-strip-field.ts', 'src/led-strip-editor.ts',
],
- smokes: ['smoke_led_strip_draw.mjs', 'smoke_led_strip_bind.mjs', 'smoke_led_strip_glow.mjs'],
+ smokes: ['smoke_led_strip_draw.mjs', 'smoke_led_strip_bind.mjs', 'smoke_led_strip_glow.mjs',
+ 'smoke_led_strip_field.mjs', 'smoke_led_strip_tube.mjs'],
because: '#780: drawing, placement against walls, binding, the icon ↔ strip switch, the LED '
+ 'history, the View target, the field states and the static card are observed only as '
+ 'rendered strips and saved configs; no smoke names the lazy modules’ functions',
diff --git a/src/led-strip-field.ts b/src/led-strip-field.ts
index f783d5d7..da92fa54 100644
--- a/src/led-strip-field.ts
+++ b/src/led-strip-field.ts
@@ -6,8 +6,8 @@
* loads it (ТЗ §13.1).
*
* The field is the distance field of one continuous strip path with the shared
- * falloff. Visibility remains sampled in bounded pieces, but their visible
- * regions are unioned into one clip before the continuous path is painted.
+ * falloff. Visibility is sampled along the complete path and retained in
+ * bounded batches whose regions form one clip before the path is painted.
* That separation keeps walls opaque without exposing piece boundaries in the
* gradient at straight cuts or corners.
*/
@@ -54,9 +54,10 @@ export const LED_FIELD_BANDS = 48;
const pts = (points: readonly number[][]): Pt[] => points.map((p) => [p[0], p[1]] as Pt);
interface FieldPiece {
- d: string;
- /** Visibility fans of the piece's emitters, including full discs when nothing blocks them. */
- clip: string[];
+ /** Positive-winding union retained as one compact path, not per-emitter objects. */
+ clip: string;
+ /** Actual constituent fans; compaction never disguises or drops sources. */
+ sourceCount: number;
}
interface FieldGeometry {
@@ -102,10 +103,25 @@ export class LedFieldCache {
return value;
}
clear(): void { this.entries.clear(); this.ids.clear(); this.space = ''; }
- /** Retained visibility fans (the per-emitter source cache, ТЗ §13.2: ≤ 2500). */
+ /** Actual constituent fan count; compact path/character metrics measure retained representation. */
get sources(): number {
let n = 0;
- for (const value of this.entries.values()) for (const piece of value?.pieces || []) n += piece.clip?.length ?? 0;
+ for (const value of this.entries.values()) for (const piece of value?.pieces || []) n += piece.sourceCount;
+ return n;
+ }
+ /** Retained compact visibility path batches, composed into one SVG clip child. */
+ get visibilityPaths(): number {
+ let n = 0;
+ for (const value of this.entries.values()) n += value?.pieces.length ?? 0;
+ return n;
+ }
+ /** SVG path characters retained by the cache (at most two bytes per UTF-16 code unit). */
+ get pathChars(): number {
+ let n = 0;
+ for (const value of this.entries.values()) {
+ n += value?.d.length ?? 0;
+ for (const piece of value?.pieces || []) n += piece.clip.length;
+ }
return n;
}
}
@@ -120,11 +136,29 @@ const pointsKey = (points: readonly number[][]): string =>
*/
const LED_ARC_STEPS = 12;
-const segmentDistance = (p: Pt, s: readonly number[]): number => {
- const dx = s[2] - s[0], dy = s[3] - s[1];
- const len2 = dx * dx + dy * dy;
- const t = len2 ? Math.max(0, Math.min(1, ((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / len2)) : 0;
- return Math.hypot(p[0] - s[0] - t * dx, p[1] - s[1] - t * dy);
+/**
+ * A long wall can cross the radius without having either endpoint in it.
+ * Make its exact circle intersections sweep events. Without them the last
+ * wall hit and first free-radius hit are connected by a chord that removes
+ * a bright, genuinely visible crescent (#788).
+ */
+const circleSegments = (p: Pt, radius: number, segments: LightBarrierScene['occluders']): number[][] => {
+ const out: number[][] = [];
+ 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;
+ const 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;
+ out.push([s[0] + lo * dx, s[1] + lo * dy, s[0] + hi * dx, s[1] + hi * dy]);
+ }
+ return out;
};
/** SVG does not gain visible precision from JS's full decimal expansion. */
@@ -136,11 +170,14 @@ const coord = (value: number): string => {
const ringPath = (ring: readonly number[][]): string =>
`${ring.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' ')} Z`;
-/** Exact disc in one path: two half-circle arcs avoid a polygonal free end. */
+/**
+ * Exact disc in one path. Sweep=1 is also the winding of the angle-sorted
+ * visibility fans; overlapping subpaths must add, never cancel (#788).
+ */
const discPath = (center: Pt, radius: number): string => {
const left = coord(center[0] - radius), right = coord(center[0] + radius);
const cy = coord(center[1]), r = coord(radius);
- return `M${left} ${cy} A${r} ${r} 0 1 0 ${right} ${cy} A${r} ${r} 0 1 0 ${left} ${cy} Z`;
+ return `M${left} ${cy} A${r} ${r} 0 1 1 ${right} ${cy} A${r} ${r} 0 1 1 ${left} ${cy} Z`;
};
/**
@@ -173,12 +210,10 @@ const visibilityPath = (center: Pt, radius: number, ring: readonly number[][]):
* (`fieldFloor`).
*/
function fans(emitters: readonly Pt[], radius: number, scene: LightBarrierScene): string[] {
- const reach = radius * 1.01;
return emitters.flatMap((p) => {
- const blocked = scene.occluders.some((seg) => seg?.length >= 4
- && segmentDistance(p, seg) < reach);
- if (!blocked) return [discPath(p, radius)];
- const fan = visibilityPolygon([p[0], p[1]], radius, scene.occluders, LED_ARC_STEPS);
+ const near = circleSegments(p, radius, scene.occluders);
+ if (!near.length) return [discPath(p, radius)];
+ const fan = visibilityPolygon([p[0], p[1]], radius, near, LED_ARC_STEPS);
const path = visibilityPath(p, radius, fan);
return path ? [path] : [];
});
@@ -196,13 +231,13 @@ function fieldFloor(scene: LightBarrierScene): string[] {
}
/**
- * Pieces of a strip for the field (ТЗ §6, §13.2): the stored polyline is cut
- * into consecutive pieces no longer than the radius — short segments of a
- * dense strip share one piece, a long one is split — and every piece is
- * clipped to the union of what its own emitters see. Emitters keep every
- * vertex and the radius/4 spacing of `emitterSamples`, thinned to radius/4 on
- * dense strips; a piece whose emitters are all inside a body emits nothing; a
- * failed clip makes that piece dark, never an unclipped field.
+ * Classify and sample the complete strip before grouping visibility work.
+ * Reclassifying radius-sized runs loses the two flanking wall faces of an
+ * opening. Sampling those artificial cuts also depends on path direction,
+ * and post-thinning drops real endpoints and acute corners (#788).
+ * Every true vertex/end and the radius/4 samples therefore survive. Groups
+ * are only bounded cache/DOM batches; all their fans form one actual union.
+ * A buried emitter or a failed fan stays dark, never an unclipped field.
*/
export function buildFieldGeometry(input: {
points: readonly number[][];
@@ -218,41 +253,17 @@ export function buildFieldGeometry(input: {
const closed = isClosedStrip(path);
const visiblePath = closed ? path.slice(0, -1) : path;
const d = `${visiblePath.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' ')}${closed ? ' Z' : ''}`;
- // Consecutive pieces of at most r along the polyline.
- const runs: Pt[][] = [];
- let run: Pt[] = [path[0]];
- let left = r;
- for (let i = 1; i < path.length; i++) {
- let a = path[i - 1];
- const b = path[i];
- let len = Math.hypot(b[0] - a[0], b[1] - a[1]);
- while (len > left + 1e-12) {
- const t = left / len;
- const cut: Pt = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
- run.push(cut);
- runs.push(run);
- run = [cut];
- a = cut;
- len -= left;
- left = r;
- }
- run.push(b);
- left -= len;
- }
- if (run.length > 1) runs.push(run);
+ const emitters = emitterSamples(path, input.faces, r / 4);
const pieces: FieldPiece[] = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- for (const piece of runs) {
- const emitters: Pt[] = [];
- for (const p of emitterSamples(piece, input.faces, r / 4)) {
- const last = emitters[emitters.length - 1];
- if (!last || Math.hypot(p[0] - last[0], p[1] - last[1]) >= r / 4) emitters.push(p);
- }
- if (!emitters.length) continue;
+ // Keep at most five actual emitters in each retained visibility path. The
+ // batches manufacture no source positions and have no optical significance.
+ for (let i = 0; i < emitters.length; i += 5) {
+ const piece = emitters.slice(i, i + 5);
let clip: string[];
- try { clip = fans(emitters, r, input.scene); } catch { continue; } // fail-dark for this piece
+ try { clip = fans(piece, r, input.scene); } catch { continue; } // fail-dark for this batch
if (!clip.length) continue;
- pieces.push({ d: piece.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' '), clip });
+ pieces.push({ clip: clip.join(' '), sourceCount: clip.length });
for (const p of piece) {
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]);
@@ -343,9 +354,13 @@ export function hasLedField(owner: object): boolean {
return !!fieldLifecycles.get(owner)?.state.renderedSources.size;
}
/** The performance witness: what this owner retains right now. */
-export function ledFieldStats(owner: object): { visibility: number; sources: number; recomputes: number } {
+export function ledFieldStats(owner: object): {
+ visibility: number; sources: number; visibilityPaths: number; pathChars: number; recomputes: number;
+} {
const cache = fieldCaches.get(owner);
- return { visibility: cache?.size ?? 0, sources: cache?.sources ?? 0, recomputes: cache?.recomputes ?? 0 };
+ return { visibility: cache?.size ?? 0, sources: cache?.sources ?? 0,
+ visibilityPaths: cache?.visibilityPaths ?? 0, pathChars: cache?.pathChars ?? 0,
+ recomputes: cache?.recomputes ?? 0 };
}
export function ledFieldCache(owner: object): LedFieldCache {
let cache = fieldCaches.get(owner);
@@ -360,7 +375,9 @@ export function ledFieldCache(owner: object): LedFieldCache {
* The linear fields of the strips that are on in a Glow room. Fade uses the
* shared spot transition (`.glow-spot`, GLOW_FADE_MS) — no animation system of
* its own; an off strip keeps its node at opacity 0, so a fade-out completes
- * and leaves no residual light.
+ * and leaves no residual light. All positive-winding batches enter ONE clip
+ * child: Chromium's union of several compound clip children can cut a bright
+ * crescent even when each individual fan's mathematical membership is correct.
*/
export function renderLedField(input: LedFieldInput): TemplateResult {
if (!input.scene) return svg`` as unknown as TemplateResult;
@@ -414,7 +431,7 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
data-closed="${closed ? 'true' : 'false'}">
- ${geometry.pieces.map((piece) => svg``)}
+
[a[0] - b[0], a[1] - b[1]];
-const lerp = (a: Pt, b: Pt, t: number): [number, number] => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
+/** Arithmetic uncertainty in these coordinates, never a physical snap tolerance. */
+const roundoff = (a: Pt, b: Pt): number => Math.max(1,
+ Math.abs(a[0]), Math.abs(a[1]), Math.abs(b[0]), Math.abs(b[1])) * Number.EPSILON * 16;
+// Preserve shared vertices exactly: a + (b - a) need not equal b in floating
+// point, which used to break the two left-hand miters of decimal rectangles.
+const lerp = (a: Pt, b: Pt, t: number): [number, number] => t === 0 ? [a[0], a[1]]
+ : t === 1 ? [b[0], b[1]] : [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
function pointSegmentDistance(p: Pt, a: Pt, b: Pt): number {
const [dx, dy] = sub(b, a);
@@ -160,7 +166,16 @@ function overlapOnFace(a: Pt, b: Pt, face: BodyFace, eps: number): [number, numb
const lo = Math.max(Math.min(pa, pb), 0), hi = Math.min(Math.max(pa, pb), fLen);
if (hi - lo <= eps) return null;
const toT = (s: number) => (pb === pa ? 0 : (s - pa) / (pb - pa));
- const t0 = Math.max(0, Math.min(1, toT(lo))), t1 = Math.max(0, Math.min(1, toT(hi)));
+ // Projection and length use different floating-point operations. A face
+ // ending exactly at b can otherwise stop at t=.9999999999999998, inventing
+ // a microscopic free tail and a full t/2 connector. Canonicalise only the
+ // coordinate-scale arithmetic uncertainty; real face gaps stay untouched.
+ const tolerance = Math.max(roundoff(a, b), roundoff(face.a, face.b)) / len;
+ const endpoint = (value: number): number => {
+ const t = Math.max(0, Math.min(1, value));
+ return t <= tolerance ? 0 : 1 - t <= tolerance ? 1 : t;
+ };
+ const t0 = endpoint(toT(lo)), t1 = endpoint(toT(hi));
return t0 < t1 ? [t0, t1] : [t1, t0];
}
@@ -236,11 +251,19 @@ export function stripPieces(points: readonly Pt[], ctx: FaceContext | null): Str
function shiftedLineIntersection(
prev: StripPiece, next: StripPiece, offset: number,
): [number, number] | null {
- if (!prev.free || !next.free || !same(prev.b, next.a)) return null;
- const pa: [number, number] = [prev.a[0] + prev.free[0] * offset, prev.a[1] + prev.free[1] * offset];
- const pb: [number, number] = [prev.b[0] + prev.free[0] * offset, prev.b[1] + prev.free[1] * offset];
- const qa: [number, number] = [next.a[0] + next.free[0] * offset, next.a[1] + next.free[1] * offset];
- const qb: [number, number] = [next.b[0] + next.free[0] * offset, next.b[1] + next.free[1] * offset];
+ if (!prev.free && !next.free) return null;
+ // Adjacent derived endpoints may have arithmetic tails; this is numerical
+ // equality only, never the editor's magnet or the wall-face tolerance.
+ const tolerance = roundoff(prev.b, next.a);
+ if (dist(prev.b, next.a) > tolerance) return null;
+ // A free stretch keeps its stored line. Intersect that line with a shifted
+ // wall stretch too: an explicit connector would double back at the corner
+ // and leave a round protruding stub, even on an almost rectangular loop.
+ const pn = prev.free ?? [0, 0], qn = next.free ?? [0, 0];
+ const pa: [number, number] = [prev.a[0] + pn[0] * offset, prev.a[1] + pn[1] * offset];
+ const pb: [number, number] = [prev.b[0] + pn[0] * offset, prev.b[1] + pn[1] * offset];
+ const qa: [number, number] = [next.a[0] + qn[0] * offset, next.a[1] + qn[1] * offset];
+ const qb: [number, number] = [next.b[0] + qn[0] * offset, next.b[1] + qn[1] * offset];
const r = sub(pb, pa), s = sub(qb, qa);
const den = r[0] * s[1] - r[1] * s[0];
if (Math.abs(den) <= 1e-12) return null;
@@ -282,16 +305,35 @@ function simplifyVisiblePoints(points: Array<[number, number]>, closed: boolean)
/**
* The derived visible path (ТЗ §3): a face piece shifted `offset` along its
* free normal, a free piece unshifted. Shifted sides meeting at a real corner
- * meet at their bounded line intersection; wall/free transitions and unsafe
- * acute angles retain the short connector — no gap and no long miter spike.
+ * meet at their bounded line intersection, including a wall/free transition
+ * with one unshifted line. Parallel transitions and unsafe acute angles keep
+ * the short connector — no gap and no long miter spike.
* Closed strips close through the same rule. Shared by both strokes, the hit
* path, focus and 2.5D: one derivation, never a stored position.
*/
export function visibleStripPath(
points: readonly Pt[], ctx: FaceContext | null, offset: number,
): { points: Array<[number, number]>; closed: boolean } {
- const closed = isClosedStrip(compactPoints(points));
- const pieces = stripPieces(points, ctx);
+ const source = compactPoints(points);
+ const closed = isClosedStrip(source);
+ // Offset complete straight stretches, not arbitrary stored subdivisions.
+ // A miter can lie beyond a very short collinear step: retaining that step
+ // afterwards would make the stripe double back before continuing forward.
+ // The original vertices still belong to the saved shape and its emitters.
+ const vertices = simplifyVisiblePoints((closed ? source.slice(0, -1) : source)
+ .map((p) => [p[0], p[1]] as [number, number]), closed);
+ const pieces: StripPiece[] = [];
+ for (const piece of stripPieces(closed && vertices.length ? [...vertices, vertices[0]] : vertices, ctx)) {
+ const previous = pieces[pieces.length - 1];
+ const sameSide = previous && (previous.free && piece.free
+ ? dist(previous.free, piece.free) <= 1e-9 : previous.free === piece.free);
+ // An opening may also subdivide a straight stretch closer to its corner
+ // than t/2. Its inherited normal makes it one visible line, even though
+ // the physical face/gap pieces stay separate in the emitter derivation.
+ if (previous && sameSide && same(previous.b, piece.a)
+ && redundantCollinear(previous.a, previous.b, piece.b)) previous.b = piece.b;
+ else pieces.push(piece);
+ }
const shifted = pieces.map((piece) => {
const shift = piece.free ? [piece.free[0] * offset, piece.free[1] * offset] : [0, 0];
return {
diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts
index 29dbd472..a7edd15b 100755
--- a/src/led-strip-runtime.ts
+++ b/src/led-strip-runtime.ts
@@ -448,9 +448,11 @@ export function releaseLed(owner: object): void {
}
/** The performance witness: shapes (frame), visibility entries and retained fans of this card. */
-export function ledStats(owner: object): { shapes: number; visibility: number; sources: number; recomputes: number } {
+export function ledStats(owner: object): {
+ shapes: number; visibility: number; sources: number; visibilityPaths: number; pathChars: number; recomputes: number;
+} {
return { shapes: frames.get(owner)?.frame.views.length ?? 0,
- ...(field?.ledFieldStats(owner) ?? { visibility: 0, sources: 0, recomputes: 0 }) };
+ ...(field?.ledFieldStats(owner) ?? { visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, recomputes: 0 }) };
}
/** The LED frame of a space for this card, rebuilt only when an input changed. */
diff --git a/test/led-strip-geometry.test.mjs b/test/led-strip-geometry.test.mjs
index 601155a6..7edac9ca 100755
--- a/test/led-strip-geometry.test.mjs
+++ b/test/led-strip-geometry.test.mjs
@@ -72,8 +72,9 @@ test('AC8: a mixed strip leaves the face continuously — no gap, the stored poi
const stored = [[2, 1], [6, 1], [6, 4]];
const before = JSON.stringify(stored);
const path = visibleStripPath(stored, ctx, t2);
- // Face piece shifted, then a connector, then the free piece unshifted.
- assert.deepEqual(path.points, [[2, 1.25], [6, 1.25], [6, 1], [6, 4]]);
+ // 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');
@@ -127,6 +128,204 @@ test('#787: a four-corner strip stays rectangular across a door opening', () =>
'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');
diff --git a/test/led-strip-runtime.test.mjs b/test/led-strip-runtime.test.mjs
index 458dd96e..91baa457 100644
--- a/test/led-strip-runtime.test.mjs
+++ b/test/led-strip-runtime.test.mjs
@@ -95,6 +95,8 @@ const scene = {
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);
@@ -104,20 +106,20 @@ test('ТЗ §6: every piece is clipped to what its own emitters see; a buried st
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.clip.length >= 2, 'free pieces retain filled visibility fans for the shared clip');
- assert.ok(piece.clip.every((d) => /\bA2 2\b/.test(d) && !/\bL/.test(d)),
+ 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) => piece.clip.some((d) => /\bL/.test(d)));
+ 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(piece.clip.some((d) => /\bA1 1\b/.test(d)),
+ 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 piece.clip) {
+ 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}`);
}
@@ -148,7 +150,7 @@ test('#785: a mixed free/wall polyline keeps visibility for every piece', () =>
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.clip.length >= 4), true,
+ assert.equal(mixed.pieces.some((piece) => piece.sourceCount >= 4), true,
'the long free run retains several overlapping visibility discs');
});
@@ -160,13 +162,151 @@ test('#786: reversing a free strip keeps two equally smooth circular end fans',
scene: freeScene, polygons, faces: null, spaceId: 's' });
for (const geometry of [forward, reverse]) {
assert.ok(geometry);
- const fans = geometry.pieces.flatMap((piece) => piece.clip);
+ 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}`);
+ }
+});
+
+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');
@@ -222,10 +362,10 @@ test('AC17/r1 M5: a released owner retains nothing; the stats count visibility e
const owner = {};
const cache = ledFieldCache(owner);
cache.forSpace('a');
- cache.read('k1', () => ({ pieces: [{ d: 'M0 0', clip: ['M0 0 Z', 'M1 1 Z'] }, { d: 'M1 1', clip: [] }], box: { x: 0, y: 0, w: 1, h: 1 } }));
+ 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, recomputes: 2 });
+ assert.deepEqual(ledFieldStats(owner), { visibility: 2, sources: 2, visibilityPaths: 1, pathChars: 22, recomputes: 2 });
releaseLedField(owner);
- assert.deepEqual(ledFieldStats(owner), { visibility: 0, sources: 0, recomputes: 0 });
+ 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');
});
diff --git a/test/performance-workflow.test.mjs b/test/performance-workflow.test.mjs
index d7a5c7c5..e512937b 100644
--- a/test/performance-workflow.test.mjs
+++ b/test/performance-workflow.test.mjs
@@ -308,6 +308,7 @@ test('#780 led-strips-v1: the derived fixture converts devices without adding ic
panZoomMs: 500, panZoomLongTaskMaxMs: 150, retainedHeapBytes: 64 * 1024 * 1024, cameraSeriesLongTaskMaxMs: 150 });
assert.deepEqual(budgets.sizes['50x50'], { firstStableRenderMs: 5000, warmSpaceReadyMs: 1500, stateUpdateMs: 1500,
panZoomMs: 500, panZoomLongTaskMaxMs: 150, retainedHeapBytes: 64 * 1024 * 1024, cameraSeriesLongTaskMaxMs: 150 });
- // ТЗ §13.2: the three caches of the shown space, judged separately (r1 M5).
- assert.deepEqual(budgets.caches, { shapes: 50, visibility: 50, sources: 2500 });
+ // #788: bounded retained representation, never a lossy fan cap. The timing
+ // and warm-cycle heap-growth limits above remain the original ТЗ table.
+ assert.deepEqual(budgets.caches, { shapes: 50, visibility: 50, visibilityPaths: 2500, pathChars: 4 * 1024 * 1024 });
});