mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 06:38:57 +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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user