diff --git a/demo/benchmark_led_strips.mjs b/demo/benchmark_led_strips.mjs index c96b67a7..d2b8fc4f 100644 --- a/demo/benchmark_led_strips.mjs +++ b/demo/benchmark_led_strips.mjs @@ -43,6 +43,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { launch } from './serve.mjs'; import { makeLedStripsFixture } from './performance/led-strips-fixture.mjs'; +import { ledChunkRequestName } from './performance/led-chunk-request.mjs'; const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); const warmupOnly = process.argv.includes('--warmup-only'); @@ -165,8 +166,8 @@ async function sample() { await page.emulateMedia({ reducedMotion: 'reduce' }); const requests = []; page.on('request', (request) => { - const name = request.url().replace(/.*\//, '').replace(/\?.*$/, ''); - if (/^led-strip-(runtime|field|editor)-/.test(name)) requests.push(name.replace(/-[^-]+\.js$/, '')); + const name = ledChunkRequestName(request.url()); + if (name) requests.push(name); }); try { await page.evaluate(installLedLifecycleTracker); diff --git a/demo/performance/led-chunk-request.mjs b/demo/performance/led-chunk-request.mjs new file mode 100644 index 00000000..c4fbf4b3 --- /dev/null +++ b/demo/performance/led-chunk-request.mjs @@ -0,0 +1,7 @@ +/** Stable LED chunk identity for request accounting, independent of its hash. */ +export function ledChunkRequestName(url) { + const name = url.replace(/[?#].*$/, '').replace(/.*\//, ''); + // Rollup's base64url hashes may themselves contain '-' (even first). + // Capture the known prefix instead of removing a last-hyphen suffix. + return /^(led-strip-(?:runtime|field|editor))-[\w-]+\.js$/.exec(name)?.[1] ?? null; +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7aa9ce27..3e2190c8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,9 @@ 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)). +- Reduced redundant LED geometry work when moving the camera and computing + strip lighting, without thinning the light sources or changing their falloff + ([#788](https://github.com/Matysh/houseplan-card/issues/788)). ## v1.79.0-beta.6 — 2026-10-03 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index dbd2ab90..b734d51c 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -12,6 +12,9 @@ координатами и небольшим наклоном, вырезы внутри свечения и резкие края вместо плавного затухания на концах и снаружи острых углов ([#788](https://github.com/Matysh/houseplan-card/issues/788)). +- Уменьшены повторные вычисления геометрии LED-лент при движении камеры и + расчёте свечения — без прореживания источников света и изменения растушёвки + ([#788](https://github.com/Matysh/houseplan-card/issues/788)). ## v1.79.0-beta.6 — 2026-10-03 diff --git a/docs/LIGHT.md b/docs/LIGHT.md index e7884608..ba82d9ec 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -159,6 +159,11 @@ invisible — the plan simply keeps lighting through a wall or closed door that now exists. Unrelated HA updates preserve the signature and hit the bounded barrier cache. The combined fingerprint, plus source position and radius, keys the per-source region cache (`_glowClipCache`). +Top-level LED-strip source data is excluded from this architectural fingerprint: +editing an emitter does not change masonry. The full card, static card and +accepted resize artifacts use the same tag; every other raw field, scale and +bound opening state retains its invalidation semantics. LED shape/radius keys +still invalidate the strip's own field. The masonry boolean receives room walls after passage cuts plus the cached joined independent body set. Its outer/hole rings are the authoritative @@ -480,6 +485,11 @@ only the geometry differs: 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. + The physical tube/hit path is reused across camera updates with exact point, + face-context and thickness invalidation; old frame/disconnect entries are + evicted. Before exact emitter-circle clipping, a conservative strip-wide + bounding box excludes only barriers that cannot reach any emitter disc. + This broad phase preserves the exact visibility path strings and all sources. `smoke_led_strip_field` compares rasterised production-field pixels with an independent distance/falloff oracle across three radii, both path directions diff --git a/src/glow-scene.ts b/src/glow-scene.ts index 93fa3227..1476f928 100644 --- a/src/glow-scene.ts +++ b/src/glow-scene.ts @@ -214,6 +214,19 @@ export function resolveGlowCandidates(input: { return result; } +/** LED emitters do not change masonry; keep both shared-geometry tags aligned. */ +export function lightGeometryFingerprint( + rawSpaceConfig: unknown, cellCm: number, gridPitch: number, +): string { + let geometry = rawSpaceConfig; + if (geometry && typeof geometry === 'object' && !Array.isArray(geometry)) { + const raw = geometry as Record; + geometry = Object.fromEntries(Object.keys(raw) + .filter((key) => key !== 'led_strips').map((key) => [key, raw[key]])); + } + return contentFingerprint([geometry, cellCm, gridPitch]); +} + /** * Resolve the part of a barrier revision that changes with an architectural * opening. The fingerprint is intentionally available before structural work. @@ -226,9 +239,9 @@ export function resolveLightBarrierRevision(input: { gridPitch: number; openingAmount: (opening: GeometryOpeningProjection) => number; }): LightBarrierRevision { - const geometryFingerprint = contentFingerprint([ + const geometryFingerprint = lightGeometryFingerprint( input.rawSpaceConfig, input.cellCm, input.gridPitch, - ]); + ); const polygons = input.space.rooms.flatMap((room) => { const poly = roomPoly(room); return poly ? [{ room, poly }] : []; diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 74ae11b4..ebdc79d2 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -180,8 +180,8 @@ import { buildGlowClipGeometry, buildLightBarrierScene, createGlowRuntimeState, disposeGlowRuntime, forgetGlowSource, forgetGlowSpace, glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools, resolveGlowCandidates, resolveGlowFeather, - resolveLightBarrierRevision, transitionGlowSource, warnGlowGeometryFallback, - writeGlowClip, + lightGeometryFingerprint, resolveLightBarrierRevision, transitionGlowSource, + warnGlowGeometryFallback, writeGlowClip, type GlowRuntimeHost, type GlowRuntimeState, type GlowSpot, } from './glow-scene'; import { @@ -8788,7 +8788,7 @@ export class HouseplanCard extends LitElement { this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras, ); if (value) Object.defineProperty(value, 'sourceFingerprint', { - value: contentFingerprint([this._curSpaceCfg, this._cellCm, this._gridPitch]), + value: lightGeometryFingerprint(this._curSpaceCfg, this._cellCm, this._gridPitch), enumerable: false, }); const entry = { key: unionKey, value }; diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index d953cc0c..dd755eaa 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -228,6 +228,7 @@ import { renderBackdropGuard, renderPlanBackdropGuard, stagePlanFile, uploadPlan import { CommandStack } from './command-stack'; import type { DeviceLayout, DevicePositionState } from './device-position-history'; import { contentFingerprint } from './visual-continuity'; +import { lightGeometryFingerprint } from './glow-scene'; import { PointerModalityController } from './pointer-modality'; import { type ResolvedDevicePresentation } from './device-presentation'; import { type FiniteActivityRuntime } from './activity-runtime'; @@ -3196,7 +3197,7 @@ public _rszAcceptPreview( if (!projected) return; const key = `${this.host._floorKey(this.host._space)}|${preview.sp.rooms.length}`; Object.defineProperty(projected, 'sourceFingerprint', { - value: contentFingerprint([preview.sp, this.host._cellCm, this.host._gridPitch]), + value: lightGeometryFingerprint(preview.sp, this.host._cellCm, this.host._gridPitch), enumerable: false, }); const entry = { key, value: projected }; diff --git a/src/led-strip-field.ts b/src/led-strip-field.ts index da92fa54..cd3c6b86 100644 --- a/src/led-strip-field.ts +++ b/src/led-strip-field.ts @@ -136,6 +136,27 @@ const pointsKey = (points: readonly number[][]): string => */ const LED_ARC_STEPS = 12; +/** + * Conservative broad phase, once per strip rather than once per emitter. + * Every emitter disc is inside this expanded box, so a segment wholly beyond + * any one side cannot reach any fan. Keep crossing/touching segments in their + * original order and coordinates; the exact circle clip below is unchanged. + * Bounds use the actual emitters, including their wall-normal displacement. + */ +const fieldOccluders = ( + emitters: readonly Pt[], radius: number, segments: LightBarrierScene['occluders'], +): LightBarrierScene['occluders'] => { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of emitters) { + minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]); + maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]); + } + minX -= radius; minY -= radius; maxX += radius; maxY += radius; + return segments.filter((s) => s && s.length >= 4 + && !((s[0] < minX && s[2] < minX) || (s[0] > maxX && s[2] > maxX) + || (s[1] < minY && s[3] < minY) || (s[1] > maxY && s[3] > maxY))); +}; + /** * A long wall can cross the radius without having either endpoint in it. * Make its exact circle intersections sweep events. Without them the last @@ -209,9 +230,9 @@ const visibilityPath = (center: Pt, radius: number, ring: readonly number[][]): * clip active (#785). The floor itself is one clip of the whole field layer * (`fieldFloor`). */ -function fans(emitters: readonly Pt[], radius: number, scene: LightBarrierScene): string[] { +function fans(emitters: readonly Pt[], radius: number, occluders: LightBarrierScene['occluders']): string[] { return emitters.flatMap((p) => { - const near = circleSegments(p, radius, scene.occluders); + const near = circleSegments(p, radius, 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); @@ -254,6 +275,7 @@ export function buildFieldGeometry(input: { 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' : ''}`; const emitters = emitterSamples(path, input.faces, r / 4); + const occluders = fieldOccluders(emitters, r, input.scene.occluders); const pieces: FieldPiece[] = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; // Keep at most five actual emitters in each retained visibility path. The @@ -261,7 +283,7 @@ export function buildFieldGeometry(input: { for (let i = 0; i < emitters.length; i += 5) { const piece = emitters.slice(i, i + 5); let clip: string[]; - try { clip = fans(piece, r, input.scene); } catch { continue; } // fail-dark for this batch + try { clip = fans(piece, r, occluders); } catch { continue; } // fail-dark for this batch if (!clip.length) continue; pieces.push({ clip: clip.join(' '), sourceCount: clip.length }); for (const p of piece) { diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts index a7edd15b..b011451d 100755 --- a/src/led-strip-runtime.ts +++ b/src/led-strip-runtime.ts @@ -211,9 +211,38 @@ function stripeThickness(d: number): number { return LED_THICKNESS_D * d; } -function stripePath(view: LedStripView, input: LedStripeInput) { - const t = stripeThickness(input.d); - return { t, path: visibleStripPath(pts(view.strip.points), input.faces, t / 2) }; +interface StripePath { + t: number; + path: ReturnType; + d: string; +} + +// The derived strip objects belong to the current frame, never a global id. +// Weak keys cannot retain old cards/spaces; the frame lifecycle also evicts +// its entries explicitly. Face contexts are immutable scene derivations. +const stripePaths = new WeakMap(); + +/** Camera changes affect hit width, not the physical stripe or its SVG path. */ +export function ledStripePath(strip: LedStripModel, faces: FaceContext | null, diameter: number): StripePath { + const t = stripeThickness(diameter); + const hit = stripePaths.get(strip); + if (hit && hit.faces === faces && hit.epsilon === faces?.epsilon && hit.value.t === t + && hit.points.length === strip.points.length + && hit.points.every((point, i) => point[0] === strip.points[i][0] && point[1] === strip.points[i][1])) { + return hit.value; + } + // An exact snapshot also catches an in-place point edit; identity alone + // would leave the tube and hit path stale until the next frame replacement. + const points = pts(strip.points); + const path = visibleStripPath(points, faces, t / 2); + const value = { t, path, d: pathD(path) }; + stripePaths.set(strip, { faces, epsilon: faces?.epsilon, points, value }); + return value; } /** @@ -232,7 +261,7 @@ function nearestOwner(e: Event & { clientX: number; clientY: number }, fallback: inverse.b * e.clientX + inverse.d * e.clientY + inverse.f, ]; const strips = input.views.map((view) => { - const { t, path } = stripePath(view, input); + const { t, path } = ledStripePath(view.strip, input.faces, input.d); return { id: view.strip.id, points: path.points.map((p) => [p[0] * input.perUnit, p[1] * input.perUnit] as Pt), @@ -261,8 +290,7 @@ export function renderLedStripes(input: LedStripeInput): TemplateResult { .led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; } ${repeat(ordered, (view) => view.strip.id, (view) => { - const { t, path } = stripePath(view, input); - const d = pathD(path); + const { t, path, d } = ledStripePath(view.strip, input.faces, input.d); const unavailable = view.state === 'unavailable'; const core = unavailable ? UNAVAILABLE : view.state === 'on' && !view.glow && view.appearance ? view.appearance.c : CORE_IDLE; @@ -443,6 +471,7 @@ const frames = new WeakMap(); /** Disconnect (ТЗ §13.2, r1 M5): the frame and the field caches of this card are released. */ export function releaseLed(owner: object): void { + for (const view of frames.get(owner)?.frame.views ?? []) stripePaths.delete(view.strip); frames.delete(owner); field?.releaseLedField(owner); } @@ -467,6 +496,7 @@ export function ledFrameFor(host: LedCardHost, space: SpaceModel, spaceGlow: boo const key = [space, host._renderDevices, host._renderPlanHass, spaceGlow, scene, host._mode, host._showAll]; const hit = frames.get(host); if (hit && hit.key.every((value, i) => value === key[i])) return hit.frame; + for (const view of hit?.frame.views ?? []) stripePaths.delete(view.strip); const size = host._config?.icon_size ?? 2.5; const frame = ledFrame({ space, diff --git a/src/space-render.ts b/src/space-render.ts index 086a6e32..75649768 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -68,8 +68,8 @@ import { cachedStairRenderGeometry, stairOutline, stairStyleVars } from './stair import { buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace, glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools, - resolveGlowCandidates, resolveGlowFeather, resolveLightBarrierRevision, - transitionGlowSource, warnGlowGeometryFallback, writeGlowClip, + resolveGlowCandidates, resolveGlowFeather, lightGeometryFingerprint, + resolveLightBarrierRevision, transitionGlowSource, warnGlowGeometryFallback, writeGlowClip, type GlowRuntimeHost, type GlowRuntimeState, type GlowSpot, type LightBarrierScene, } from './glow-scene'; @@ -453,7 +453,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null { // #375: keep the full-card fingerprint on the geometry even though #373 // needs to build it before choosing the static card frame. if (built) Object.defineProperty(built, 'sourceFingerprint', { - value: contentFingerprint([spCfg, cellCm, GRID_PITCH]), + value: lightGeometryFingerprint(spCfg, cellCm, GRID_PITCH), enumerable: false, }); return built; diff --git a/test/glow-scene.test.mjs b/test/glow-scene.test.mjs index 6c09f80a..3e72bd13 100644 --- a/test/glow-scene.test.mjs +++ b/test/glow-scene.test.mjs @@ -2,16 +2,108 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import { - createGlowRuntimeState, disposeGlowRuntime, readGlowClip, + buildLightBarrierScene, createGlowRuntimeState, disposeGlowRuntime, + lightGeometryFingerprint, readGlowClip, resolveGlowCandidates, resolveLightBarrierRevision, transitionGlowSource, writeGlowClip, } from '../test-build/glow-scene.js'; +import { contentFingerprint } from '../test-build/visual-continuity.js'; +import { wallBodiesGeometry, wallKey } from '../test-build/wall-thickness.js'; const square = (id, x0, x1) => ({ id, poly: [[x0, 0], [x1, 0], [x1, 100], [x0, 100]], }); +test('light geometry excludes only LED sources and never visits or mutates their data', () => { + const geometry = Object.freeze({ + id: 's', rooms: Object.freeze([square('room', 0, 100)]), + walls: [], wall_segments: [], openings: [], open_spans: [], + partitions: [], wall_columns: [], wall_style: 'zero', + future_geometry_key: { x: 1 }, + }); + const expected = contentFingerprint([geometry, 5, 20]); + assert.equal(lightGeometryFingerprint(geometry, 5, 20), expected); + const raw = Object.freeze(Object.defineProperty({ ...geometry }, 'led_strips', { + enumerable: true, + get() { throw new Error('LED source data must not enter a masonry hash'); }, + })); + assert.equal(lightGeometryFingerprint(raw, 5, 20), expected); + assert.equal(raw.rooms, geometry.rooms, 'the caller-owned geometry is not cloned or mutated'); + for (const led_strips of [[], [{ id: 'a', points: [[0, 0], [100, 1]] }], + [{ id: 'b', active: false, entity: 'light.changed', points: [[0, 1]] }]]) { + assert.equal(lightGeometryFingerprint({ ...geometry, led_strips }, 5, 20), expected); + } + for (const key of Object.keys(geometry)) { + assert.notEqual(lightGeometryFingerprint({ ...geometry, [key]: ['changed'] }, 5, 20), expected, + `all non-LED raw keys still invalidate, including ${key}`); + } + assert.notEqual(lightGeometryFingerprint(geometry, 10, 20), expected); + assert.notEqual(lightGeometryFingerprint(geometry, 5, 40), expected); + for (const invalid of [null, undefined, false, 0, 's', [], [{ led_strips: [1] }]]) { + assert.equal(lightGeometryFingerprint(invalid, 5, 20), contentFingerprint([invalid, 5, 20]), + 'non-object and array inputs keep their prior fingerprint semantics'); + } + const mutable = { rooms: [square('room', 0, 100)], led_strips: [] }; + const before = lightGeometryFingerprint(mutable, 5, 20); + mutable.rooms[0].poly[0][0] = 1; + assert.notEqual(lightGeometryFingerprint(mutable, 5, 20), before, + 'in-place architectural edits must not reuse stale masonry'); +}); + +test('LED-only edits retain the aligned shared-masonry recut fast path', () => { + const space = { + id: 's', rooms: [square('left', 0, 100), square('right', 100, 200)], + partitions: [], room_drafts: [], wall_columns: [], + }; + const unique = new Map(); + for (const room of space.rooms) for (let index = 0; index < room.poly.length; index++) { + const a = room.poly[index], b = room.poly[(index + 1) % room.poly.length]; + const key = wallKey(a, b, 1); + if (!unique.has(key)) unique.set(key, { key, a, b, cm: 20 }); + } + const walls = [...unique.values()]; + const raw = { ...space, walls, led_strips: [{ id: 'led', points: [[0, 0], [100, 0]] }] }; + const sharedWallGeometry = wallBodiesGeometry(space.rooms, walls, [], [], 1, 5, 5, 1, []); + assert.equal(sharedWallGeometry.status, 'ok'); + Object.defineProperty(sharedWallGeometry, 'sourceFingerprint', { + value: lightGeometryFingerprint(raw, 5, 5), enumerable: false, + }); + const revisionFor = (rawSpaceConfig) => resolveLightBarrierRevision({ + rawSpaceConfig, space, openings: [], cellCm: 5, gridPitch: 5, openingAmount: () => 0, + }); + const revision = revisionFor(raw); + raw.led_strips[0].points[1][1] = 90; + const edited = revisionFor(raw); + assert.equal(edited.geometryFingerprint, sharedWallGeometry.sourceFingerprint); + assert.equal(edited.fingerprint, revision.fingerprint); + + let wallIterations = 0; + Object.defineProperty(walls, Symbol.iterator, { value() { + wallIterations++; + return Array.prototype[Symbol.iterator].call(this); + } }); + const input = { + space, revision: edited, walls, + zeroWalls: { contour: [], barriers: [], transmissive: [] }, + wallKeyPitch: 1, cellCm: 5, gridPitch: 5, coordScale: 1, + physicalBodies: () => [], + }; + const reused = buildLightBarrierScene({ ...input, sharedWallGeometry }); + assert.equal(wallIterations, 1, 'matching source tag avoids the second wall rebuild traversal'); + assert.ok(reused.masonryGeometry.length); + wallIterations = 0; + const rebuilt = buildLightBarrierScene(input); + assert.equal(wallIterations, 2, 'a scene without shared geometry must rebuild masonry'); + assert.deepEqual(reused, rebuilt, 'recut and uncached rebuild retain identical light geometry'); + const changedRevision = revisionFor({ ...raw, wall_style: 'changed' }); + wallIterations = 0; + buildLightBarrierScene({ + ...input, revision: changedRevision, sharedWallGeometry, + }); + assert.equal(wallIterations, 2, 'architectural changes reject the old shared-masonry tag'); +}); + test('shared light revision admits only floor-to-floor architectural passages', () => { const space = { id: 's', rooms: [square('left', 0, 100), square('right', 100, 200)], diff --git a/test/golden-matrix.test.mjs b/test/golden-matrix.test.mjs index 946308fb..4594ed68 100644 --- a/test/golden-matrix.test.mjs +++ b/test/golden-matrix.test.mjs @@ -1044,7 +1044,7 @@ test('a light source paints exactly one region: the floor it can see', () => { // edited in place, and a stale barrier set lights straight through a wall. assert.doesNotMatch(glow, /_cfgEpoch/); assert.match(glow, - /const geometryFingerprint = contentFingerprint\(\[\s*input\.rawSpaceConfig, input\.cellCm, input\.gridPitch/); + /const geometryFingerprint = lightGeometryFingerprint\(\s*input\.rawSpaceConfig, input\.cellCm, input\.gridPitch/); assert.match(glow, /fingerprint: contentFingerprint\(\[geometryFingerprint, openingStateSignature\]\)/); assert.match(glow, diff --git a/test/led-chunk-request.test.mjs b/test/led-chunk-request.test.mjs new file mode 100644 index 00000000..6f4521c0 --- /dev/null +++ b/test/led-chunk-request.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ledChunkRequestName } from '../demo/performance/led-chunk-request.mjs'; + +test('#788: LED request accounting recognises names independently of hash delimiters', () => { + for (const kind of ['runtime', 'field', 'editor']) { + for (const hash of ['abc123', '-abc123', 'abc-def', 'abc_def', 'a-b_c-d']) { + const name = `led-strip-${kind}-${hash}.js`; + for (const url of [name, `http://localhost:8123/dist/${name}`, + `http://localhost:8123/dist/${name}?retry=1`, `http://localhost:8123/dist/${name}?from=/path`, + `http://localhost:8123/dist/${name}#module`]) { + assert.equal(ledChunkRequestName(url), `led-strip-${kind}`, url); + } + } + } +}); + +test('#788: LED request accounting ignores non-chunks without hiding editor loads', () => { + for (const name of ['led-strip-geometry-abc.js', 'prefix-led-strip-field-abc.js', + 'led-strip-field-abc.js.map', 'led-strip-field-abc.css', 'led-strip-field-.js', + 'led-strip-field.js', 'houseplan-card.js']) { + assert.equal(ledChunkRequestName(`http://localhost:8123/dist/${name}`), null, name); + } + assert.equal(ledChunkRequestName('http://localhost:8123/dist/led-strip-editor-a-b_c.js'), 'led-strip-editor'); +}); diff --git a/test/led-strip-path-cache.test.mjs b/test/led-strip-path-cache.test.mjs new file mode 100644 index 00000000..2479913c --- /dev/null +++ b/test/led-strip-path-cache.test.mjs @@ -0,0 +1,88 @@ +// #788: camera renders reuse tube geometry without freezing live state or edits. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ledFrameFor, ledStripePath, releaseLed } from '../test-build/led-strip-runtime.js'; +import { LED_THICKNESS_D, pathD, visibleStripPath } from '../test-build/led-strip-geometry.js'; + +const strip = () => ({ id: 'led', marker: 'lamp', points: [[2, 1], [6, 1], [6, 4]] }); +const wallFaces = () => ({ + faces: [{ a: [0, 1], b: [10, 1] }], + inside: ([x, y]) => x > 0 && x < 10 && y < 1, + epsilon: 1e-5, +}); +const fresh = (shape, faces, diameter) => { + const t = LED_THICKNESS_D * diameter; + const path = visibleStripPath(shape.points, faces, t / 2); + return { t, path, d: pathD(path) }; +}; + +test('#788: repeated camera reads skip tube classification and reuse identical SVG geometry', () => { + const shape = strip(), faces = wallFaces(); + let classifications = 0; + faces.near = () => { classifications++; return faces.faces; }; + const cached = ledStripePath(shape, faces, 4); + assert.equal(classifications, 1); + for (let step = 0; step < 100; step++) assert.equal(ledStripePath(shape, faces, 4), cached); + assert.equal(classifications, 1, 'camera reads must not rerun face classification'); + assert.deepEqual(cached, fresh(shape, faces, 4), 'cached outline and hit geometry equal the uncached derivation'); +}); + +test('#788: face context, epsilon, diameter and exact point edits invalidate the tube cache', () => { + const shape = strip(); + let faces = wallFaces(), diameter = 4; + let previous = ledStripePath(shape, faces, diameter); + const changed = () => { + const value = ledStripePath(shape, faces, diameter); + assert.notEqual(value, previous); + assert.deepEqual(value, fresh(shape, faces, diameter)); + previous = value; + }; + diameter = 8; + changed(); + faces = null; + changed(); + faces = wallFaces(); + changed(); + faces.epsilon *= 2; + changed(); + shape.points[1][0] = 7; + changed(); + shape.points = [[1, 2], [8, 2], [8, 5]]; + changed(); + shape.points.push([9, 6]); + changed(); + const replacement = { ...shape, points: shape.points.map((point) => [...point]) }; + assert.notEqual(ledStripePath(replacement, faces, diameter), previous, 'same id in a new frame owns a new weak cache key'); +}); + +const ownerFixture = () => ({ + isConnected: true, + _renderDevices: [{ id: 'lamp', name: 'Lamp', primary: 'light.led', entities: ['light.led'], + space: 's', marker: { id: 'lamp', binding: 'device:lamp' } }], + _renderPlanHass: { states: { 'light.led': { state: 'on', attributes: {} } } }, + _fillColors: { glow_light: { c: '#ffd27b', a: 0.7 } }, + _cellCm: 5, _gridPitch: 1000 / 240, _config: { icon_size: 3.4 }, + _mode: 'view', _showAll: false, + _pointInRoom: () => false, +}); +const spaceFixture = () => ({ id: 's', vb: [0, 0, 1000, 1000], rooms: [], + led_strips: [{ id: 'led', marker: 'lamp', points: [[0.1, 0.1], [0.4, 0.1]] }] }); + +test('#788: new HA frames keep live state and evict the old frame geometry; disconnect releases it', () => { + const owner = ownerFixture(), space = spaceFixture(); + const first = ledFrameFor(owner, space, true); + const firstStrip = first.views[0].strip; + const firstPath = ledStripePath(firstStrip, first.faces, first.d); + assert.equal(ledFrameFor(owner, space, true), first, 'unchanged camera inputs reuse the frame'); + assert.equal(ledStripePath(firstStrip, first.faces, first.d), firstPath); + owner._renderPlanHass = { states: { 'light.led': { state: 'off', attributes: {} } } }; + const second = ledFrameFor(owner, space, true); + assert.notEqual(second, first); + assert.equal(second.views[0].state, 'off', 'geometry caching must not freeze the device state'); + assert.notEqual(ledStripePath(firstStrip, first.faces, first.d), firstPath, 'old frame entry was explicitly evicted'); + const secondStrip = second.views[0].strip; + const secondPath = ledStripePath(secondStrip, second.faces, second.d); + assert.deepEqual(secondPath, firstPath, 'state changes preserve the physical tube'); + releaseLed(owner); + assert.notEqual(ledStripePath(secondStrip, second.faces, second.d), secondPath, 'disconnect explicitly evicts current frame entries'); +}); diff --git a/test/led-strip-runtime.test.mjs b/test/led-strip-runtime.test.mjs index 91baa457..a323ef6b 100644 --- a/test/led-strip-runtime.test.mjs +++ b/test/led-strip-runtime.test.mjs @@ -6,7 +6,8 @@ import { ledAnchor, ledStripsByMarker } from '../test-build/led-strip-gate.js'; import { faceContext, ledFrame, ledStripView, stripRoom } from '../test-build/led-strip-runtime.js'; import { LED_FIELD_BANDS, LedFieldCache, buildFieldGeometry, falloffAt } from '../test-build/led-strip-field.js'; import { GLOW_FALLOFF } from '../test-build/glow-scene.js'; -import { stripAnchor } from '../test-build/led-strip-geometry.js'; +import { compactPoints, emitterSamples, stripAnchor } from '../test-build/led-strip-geometry.js'; +import { visibilityPolygon } from '../test-build/light-visibility.js'; const space = (strips) => ({ id: 's', rooms: [], led_strips: strips }); @@ -242,6 +243,114 @@ test('#788: a wall crossing the radius contributes exact circle-intersection eve } }); +// Frozen pre-broad-phase reference: visit EVERY scene segment for EVERY +// emitter, then use the same public visibility sweep. Exact emitted strings +// (not only fan counts/bounds) must survive the candidate-culling speedup. +const unfilteredFans = (emitters, radius, segments) => { + const coord = (value) => String(Math.round(value * 10_000) / 10_000 || 0); + return emitters.flatMap((p) => { + const clipped = []; + for (const s of segments) { + if (!s || s.length < 4) continue; + const dx = s[2] - s[0], dy = s[3] - s[1], len2 = dx * dx + dy * dy; + if (!(len2 > 0)) continue; + const ox = s[0] - p[0], oy = s[1] - p[1]; + const cross = ox * dy - oy * dx, distance2 = cross * cross / len2; + if (distance2 >= radius * radius) continue; + const center = -(ox * dx + oy * dy) / len2; + const span = Math.sqrt((radius * radius - distance2) / len2); + const lo = Math.max(0, center - span), hi = Math.min(1, center + span); + if (hi <= lo) continue; + clipped.push([s[0] + lo * dx, s[1] + lo * dy, s[0] + hi * dx, s[1] + hi * dy]); + } + const r = coord(radius); + if (!clipped.length) { + const left = coord(p[0] - radius), right = coord(p[0] + radius), y = coord(p[1]); + return [`M${left} ${y} A${r} ${r} 0 1 1 ${right} ${y} A${r} ${r} 0 1 1 ${left} ${y} Z`]; + } + const ring = visibilityPolygon(p, radius, clipped, 12); + if (ring.length < 3) return []; + const onRadius = (point) => Math.abs(Math.hypot(point[0] - p[0], point[1] - p[1]) - radius) + <= Math.max(1e-9, radius * 1e-7); + let path = `M${coord(ring[0][0])} ${coord(ring[0][1])}`; + for (let i = 1; i <= ring.length; i++) { + const previous = ring[i - 1], point = ring[i % ring.length]; + path += onRadius(previous) && onRadius(point) + ? ` A${r} ${r} 0 0 1 ${coord(point[0])} ${coord(point[1])}` + : ` L${coord(point[0])} ${coord(point[1])}`; + } + return [`${path} Z`]; + }); +}; + +test('#788: strip-wide broad phase preserves every unfiltered fan byte for byte', () => { + const faces = { + faces: [{ a: [0, 0], b: [8, 0] }], inside: ([, y]) => y < 0, epsilon: 0.5, + }; + const cases = [ + { points: [[350, 350], [585, 450]], radius: 25, + // Both endpoints are outside the field box, but this wall crosses it. + segments: [[595, 100, 595, 700], [-1000, -1000, -900, -900], [590, 470, 610, 450]] }, + { points: [[350, 350], [585, 450]], radius: 50, + segments: [[595, 700, 595, 100], [600, 100, 600, 700], [0, 900, 1000, 900]] }, + { points: [[0, 0], [8, 0]], radius: 1, faces, + // This edge is outside the STORED path box, but inside the displaced + // emitters' expanded box; an input-point bound would lose its shadow. + segments: [[-20, 1.49, 20, 1.49], [-20, -100, 20, -100]] }, + { points: [[0, 0], [2.49, 0]], radius: 2, + // Boundary tangencies, near-tangencies and degenerate segments. + segments: [[4.49, -20, 4.49, 20], [-20, 2, 20, 2], [-2, -20, -2, 20], + [-20, 2 - 1e-10, 20, 2 - 1e-10], [1, 1, 1, 1], [-20, -2 - 1e-10, 20, -2 - 1e-10]] }, + { points: [[0.13, 0.29], [8.37, 1.26], [8.9, 6.31], [0.32, 7.19], [0.13, 0.29]], radius: 2, + segments: [[-100, 3, 100, 3], [-100, 4, 100, 4], [4, -100, 4, 100], [400, 400, 401, 401]] }, + { points: [[0, 0], [2.1, 0], [0.2, 0.55], [2.4, 0.8]], radius: 2, + segments: [[-10, 2.5, 10, 2.5], [3, -10, 3, 10], [-20, -20, -10, -10]] }, + ]; + // Deterministic coverage of varied origins, radii, segment directions and + // local/far obstacles. No timing assertions or nondeterministic randomness. + let seed = 788; + const random = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 2 ** 32); + for (let i = 0; i < 18; i++) { + const x = (random() - 0.5) * 10_000, y = (random() - 0.5) * 10_000; + const radius = [0.05, 2, 25][i % 3]; + const points = Array.from({ length: 4 }, () => [x + random() * radius * 4, y + random() * radius * 4]); + const segments = Array.from({ length: 12 }, (_, j) => { + const scale = j % 3 ? radius * 8 : radius * 1000; + return [x + (random() - 0.5) * scale, y + (random() - 0.5) * scale, + x + (random() - 0.5) * scale, y + (random() - 0.5) * scale]; + }); + cases.push({ points, radius, segments }); + } + for (const [index, fixture] of cases.entries()) { + for (const points of [fixture.points, [...fixture.points].reverse()]) { + const emitters = emitterSamples(compactPoints(points), fixture.faces ?? null, fixture.radius / 4); + const expected = unfilteredFans(emitters, fixture.radius, fixture.segments); + const actual = buildFieldGeometry({ points, radius: fixture.radius, + scene: { ...scene, occluders: fixture.segments }, polygons: [], faces: fixture.faces ?? null, spaceId: 's' }); + assert.deepEqual(actual?.pieces.flatMap(pieceFans) ?? [], expected, `fixture ${index}: ${JSON.stringify(points)}`); + } + } +}); + +test('#788: distant occluders are examined once per strip, not once per emitter', () => { + const points = [[0, 0], [80, 0]], radius = 2; + let farCoordinateReads = 0; + const far = Array.from({ length: 64 }, (_, i) => new Proxy([1000 + i, -100, 1000 + i, 100], { + get(target, property, receiver) { + if (/^[0-3]$/.test(String(property))) farCoordinateReads++; + return Reflect.get(target, property, receiver); + }, + })); + const local = [[40, 1, 50, 1]]; + const geometry = buildFieldGeometry({ points, radius, + scene: { ...scene, occluders: [...far, ...local] }, polygons: [], faces: null, spaceId: 's' }); + assert.deepEqual(geometry.pieces.flatMap(pieceFans), unfilteredFans(emitterSamples(points, null, radius / 4), radius, local)); + assert.ok(farCoordinateReads <= far.length * 8, + `${farCoordinateReads} coordinate reads: a distant segment must not enter per-emitter clipping`); + assert.ok(geometry.pieces.reduce((n, piece) => n + piece.sourceCount, 0) > 150, + 'candidate culling must not thin or drop emitter fans'); +}); + test('#788: a residual run retains the true free endpoint in both directions', () => { const freeScene = { ...scene, occluders: [], fingerprint: 'free-endpoints' }; for (const radius of [0.2, 2, 20]) { diff --git a/test/space-render-caches.test.mjs b/test/space-render-caches.test.mjs index 80a499fd..5dde1df6 100755 --- a/test/space-render-caches.test.mjs +++ b/test/space-render-caches.test.mjs @@ -41,15 +41,23 @@ test('#375 AC1: the glow scene passes input.devices without a spread', () => { }); // #375 AC2: the static path attaches the same sourceFingerprint tag the full -// card attaches, from the same triple buildLightBarrierRevision fingerprints — +// card attaches, through the same lightGeometryFingerprint helper — // proven the same way the full card's recut wiring is proven // (performance-contract.test.mjs): by pinning the source. test('#375 AC2: static wall geometry carries the recut fingerprint tag', () => { const render = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8'); assert.match(render, - /Object\.defineProperty\(built, 'sourceFingerprint', \{\s*\n\s*value: contentFingerprint\(\[spCfg, cellCm, GRID_PITCH\]\),/, - 'the tag must be the exact revision triple (rawSpaceConfig=spCfg, cellCm, gridPitch=GRID_PITCH)'); + /Object\.defineProperty\(built, 'sourceFingerprint', \{\s*\n\s*value: lightGeometryFingerprint\(spCfg, cellCm, GRID_PITCH\),/, + 'the tag must use the same geometry projection and scale as the light revision'); + const fullCard = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8'); + assert.match(fullCard, + /Object\.defineProperty\(value, 'sourceFingerprint', \{\s*\n\s*value: lightGeometryFingerprint\(this\._curSpaceCfg, this\._cellCm, this\._gridPitch\),/, + 'the full-card tag must share that projection and scale as well'); + const editor = readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8'); + assert.match(editor, + /Object\.defineProperty\(projected, 'sourceFingerprint', \{\s*\n\s*value: lightGeometryFingerprint\(preview\.sp, this\.host\._cellCm, this\.host\._gridPitch\),/, + 'accepted resize artifacts must retain the same fast-path tag'); assert.ok( render.indexOf("Object.defineProperty(built, 'sourceFingerprint'") < render.indexOf('const contentFrame ='),