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:
Matysh
2026-10-03 19:18:49 +03:00
parent 08bc58cc0e
commit 416d2fae71
17 changed files with 438 additions and 26 deletions
+15 -2
View File
@@ -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 }] : [];
+3 -3
View File
@@ -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 };
+2 -1
View File
@@ -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
View File
@@ -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) {
+36 -6
View File
@@ -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
View File
@@ -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;