From c142e4f7b5b3ffb46d3a2b88222a9d697e8144b1 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 3 Oct 2026 18:46:35 +0300 Subject: [PATCH] fix(led): preserve strip corners and feather the complete light field Keep real end and corner emitters, robust decimal joins and wall-circle sweep events. Render one positive-winding compound visibility clip so Chromium cannot cancel or cut away overlapping light regions. Add independent pixel oracles for glow falloff and wall-following tubes. Replace the lossy fan-count limit with explicit cached-path bounds while retaining the original timing and warm-cycle heap-growth limits. Issue: #788 User-Visible: yes --- demo/benchmark_led_strips.mjs | 27 ++- demo/performance/README.md | 12 +- demo/performance/budgets-led-strips.json | 5 +- demo/smoke_led_strip_field.mjs | 104 +++++++++ demo/smoke_led_strip_tube.mjs | 156 ++++++++++++++ docs/CHANGELOG.md | 5 + docs/CHANGELOG.ru.md | 5 + docs/LIGHT.md | 54 +++-- docs/STATUS.md | 1 + docs/testing-notes/mutation-browser-guards.md | 8 +- scripts/bundle-budget.mjs | 5 +- scripts/mutation-registry.mjs | 40 ++++ scripts/smoke-links.mjs | 3 +- src/led-strip-field.ts | 139 ++++++------ src/led-strip-geometry.ts | 64 +++++- src/led-strip-runtime.ts | 6 +- test/led-strip-geometry.test.mjs | 203 +++++++++++++++++- test/led-strip-runtime.test.mjs | 160 +++++++++++++- test/performance-workflow.test.mjs | 5 +- 19 files changed, 881 insertions(+), 121 deletions(-) create mode 100644 demo/smoke_led_strip_field.mjs create mode 100644 demo/smoke_led_strip_tube.mjs 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 = `${new XMLSerializer().serializeToString(source)}`; + 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 = `${body}`; + 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 }); });