mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
perf(led): avoid redundant tube and barrier calculations
Preserve exact light fields while culling provably distant occluders, reusing physical tube paths across camera updates and excluding LED emitter data from aligned architectural fingerprints. Cover output equivalence, in-place invalidation, resize tags and hyphenated benchmark chunk hashes. Issue: #788 User-Visible: yes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-2
@@ -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<string, unknown>;
|
||||
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 }] : [];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+25
-3
@@ -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) {
|
||||
|
||||
@@ -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<typeof visibleStripPath>;
|
||||
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<LedStripModel, {
|
||||
faces: FaceContext | null;
|
||||
epsilon: number | undefined;
|
||||
points: Pt[];
|
||||
value: StripePath;
|
||||
}>();
|
||||
|
||||
/** 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; }
|
||||
</style>
|
||||
${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<object, { key: unknown[]; frame: LedFrame }>();
|
||||
|
||||
/** 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,
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 ='),
|
||||
|
||||
Reference in New Issue
Block a user