mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
refactor(card): the LED linear field is its own lazy chunk (#780)
ТЗ §13.1: the static card with light_pools:false must not load the linear field. led-strip-field.ts (2.1 KB gzip) is a dynamic import of the LED runtime (5.0 KB gzip), loaded only when a strip is on in a Glow room with a light scene, with the same fingerprint handshake and hashed-URL retry (manifest role led-field, retry token counted). Issue: #780 User-Visible: no
This commit is contained in:
@@ -13,6 +13,7 @@ const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__
|
||||
const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
|
||||
const MOON_RETRY_ASSET_TOKEN = '__HOUSEPLAN_MOON_RETRY_ASSET__';
|
||||
const LED_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_RETRY_ASSET__';
|
||||
const LED_FIELD_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_FIELD_RETRY_ASSET__';
|
||||
|
||||
/**
|
||||
* #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per
|
||||
@@ -119,6 +120,10 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
// only when a shown space has an active strip.
|
||||
: modules.some((id) => id.endsWith('/src/led-strip-runtime.ts'))
|
||||
? 'led'
|
||||
// #780: the linear field, loaded by the LED runtime only when a
|
||||
// strip is on in a Glow room with a light scene.
|
||||
: modules.some((id) => id.endsWith('/src/led-strip-field.ts'))
|
||||
? 'led-field'
|
||||
// #474: designer furniture artwork — its own lazy chunk, shared by
|
||||
// the editor (static import) and the View runtime (dynamic).
|
||||
: modules.some((id) => id.endsWith('/src/furniture-plan-art.generated.ts'))
|
||||
@@ -164,8 +169,13 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
|| path.includes('pdf-export-'));
|
||||
const moonRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'moon'
|
||||
|| path.includes('moon-runtime-'));
|
||||
const ledRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'led'
|
||||
|| path.includes('led-strip-runtime-'));
|
||||
// #780: the field chunk is a dynamic import of the LED chunk, not of the
|
||||
// initial graph, so it is found by its module, like namespace dictionaries.
|
||||
const ledRoots = [
|
||||
...dynamicRoots.filter((path) => byPath.get(path)?._role === 'led'
|
||||
|| path.includes('led-strip-runtime-')),
|
||||
...files.filter((file) => file._role === 'led-field').map((file) => file.path),
|
||||
];
|
||||
// #627: namespace dictionaries are dynamic imports of LAZY chunks, never of
|
||||
// the initial graph, so they are found by their module, not as a root.
|
||||
const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale')
|
||||
@@ -289,6 +299,9 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
const led = chunks.find((chunk) => Object.keys(chunk.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-runtime.ts')));
|
||||
if (!led) throw new Error('LED strip runtime chunk was not emitted');
|
||||
const ledField = chunks.find((chunk) => Object.keys(chunk.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-field.ts')));
|
||||
if (!ledField) throw new Error('LED strip field chunk was not emitted');
|
||||
const namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => {
|
||||
const chunk = chunks.find((candidate) => Object.keys(candidate.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith(entry.module)));
|
||||
@@ -306,6 +319,7 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
let pdfReplacements = 0;
|
||||
let moonReplacements = 0;
|
||||
let ledReplacements = 0;
|
||||
let ledFieldReplacements = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
|
||||
@@ -361,6 +375,12 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
ledReplacements += chunk.code.split(LED_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(LED_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
if (chunk.code.includes(LED_FIELD_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), ledField.fileName);
|
||||
if (!asset.startsWith('.')) asset = `./${asset}`;
|
||||
ledFieldReplacements += chunk.code.split(LED_FIELD_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(LED_FIELD_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
for (const entry of namespaceLocales) {
|
||||
if (!chunk.code.includes(entry.token)) continue;
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), entry.chunk.fileName);
|
||||
@@ -371,9 +391,10 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
}
|
||||
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|
||||
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
|
||||
|| pdfReplacements !== 1 || moonReplacements !== 1 || ledReplacements !== 1) {
|
||||
|| pdfReplacements !== 1 || moonReplacements !== 1 || ledReplacements !== 1
|
||||
|| ledFieldReplacements !== 1) {
|
||||
throw new Error('lazy retry URL placeholder counts are '
|
||||
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}, expected 1/1/1/1/1/1/1/1/1`);
|
||||
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}/${ledFieldReplacements}, expected 1/1/1/1/1/1/1/1/1/1`);
|
||||
}
|
||||
// #627: the same strict rule for every namespace × language token —
|
||||
// exactly one second-attempt URL each, never zero and never two.
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* The linear light field of LED strips (#780, ТЗ §3, §6): its own lazy chunk.
|
||||
*
|
||||
* Loaded by the LED runtime only when a strip is on in a Glow room and the
|
||||
* card has a light scene — the static card with `light_pools: false` never
|
||||
* loads it (ТЗ §13.1).
|
||||
*
|
||||
* The field is the exact distance field of the strip with the shared falloff:
|
||||
* every piece of the strip paints opaque grey bands of a luminance mask (round
|
||||
* caps and joins, so one piece never doubles itself), pieces meet through
|
||||
* `mix-blend-mode: lighten` — the maximum, i.e. the nearest piece — and each
|
||||
* piece is clipped to the floor its own emitters can see. A hidden part never
|
||||
* lights through another part's visibility, a closed strip has no seam and a
|
||||
* corner no double brightness.
|
||||
*/
|
||||
import { svg, type TemplateResult } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import {
|
||||
GLOW_FALLOFF, buildGlowClipGeometry, type LightBarrierScene, type LightRoomPolygon,
|
||||
} from './glow-scene';
|
||||
import {
|
||||
compactPoints, emitterSamples, isClosedStrip, type FaceContext, type Pt,
|
||||
} from './led-strip-geometry';
|
||||
import type { LedStripView } from './led-strip-runtime';
|
||||
|
||||
export const LED_FIELD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
|
||||
/** Bands of the luminance field: 16 steps keep a band under 7 % of the range. */
|
||||
export const LED_FIELD_BANDS = 16;
|
||||
|
||||
const pts = (points: readonly number[][]): Pt[] => points.map((p) => [p[0], p[1]] as Pt);
|
||||
|
||||
interface FieldPiece {
|
||||
d: string;
|
||||
clip: string[];
|
||||
}
|
||||
|
||||
interface FieldGeometry {
|
||||
pieces: FieldPiece[];
|
||||
box: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
/** Bounded per-space cache: shape × radius × barrier revision (ТЗ §13.2). */
|
||||
export class LedFieldCache {
|
||||
private entries = new Map<string, FieldGeometry | null>();
|
||||
private space = '';
|
||||
constructor(private readonly limit = 50) {}
|
||||
get size(): number { return this.entries.size; }
|
||||
/** Recompute counter for the performance witness: geometry, not paint. */
|
||||
recomputes = 0;
|
||||
forSpace(spaceId: string): void {
|
||||
if (spaceId !== this.space) { this.entries.clear(); this.space = spaceId; }
|
||||
}
|
||||
read(key: string, build: () => FieldGeometry | null): FieldGeometry | null {
|
||||
if (this.entries.has(key)) {
|
||||
const value = this.entries.get(key) ?? null;
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, value);
|
||||
return value;
|
||||
}
|
||||
this.recomputes += 1;
|
||||
const value = build();
|
||||
this.entries.set(key, value);
|
||||
while (this.entries.size > this.limit) {
|
||||
const oldest = this.entries.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.entries.delete(oldest);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
clear(): void { this.entries.clear(); this.space = ''; }
|
||||
}
|
||||
|
||||
const pointsKey = (points: readonly number[][]): string =>
|
||||
points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join(';');
|
||||
|
||||
/**
|
||||
* Pieces of a strip for the field: stored segments cut to at most the radius,
|
||||
* each with the union of what its own emitters see (ТЗ §6). A piece whose
|
||||
* emitters are all inside a body emits nothing; a failed clip makes that piece
|
||||
* dark (fail-dark), never an unclipped field.
|
||||
*/
|
||||
export function buildFieldGeometry(input: {
|
||||
points: readonly number[][];
|
||||
radius: number;
|
||||
scene: LightBarrierScene;
|
||||
polygons: readonly LightRoomPolygon[];
|
||||
faces: FaceContext | null;
|
||||
spaceId: string;
|
||||
}): FieldGeometry | null {
|
||||
const path = compactPoints(pts(input.points));
|
||||
if (path.length < 2 || !(input.radius > 0)) return null;
|
||||
const r = input.radius;
|
||||
const pieces: FieldPiece[] = [];
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const a = path[i - 1], b = path[i];
|
||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
const count = Math.max(1, Math.ceil(len / r));
|
||||
for (let k = 0; k < count; k++) {
|
||||
const p0: Pt = [a[0] + ((b[0] - a[0]) * k) / count, a[1] + ((b[1] - a[1]) * k) / count];
|
||||
const p1: Pt = [a[0] + ((b[0] - a[0]) * (k + 1)) / count, a[1] + ((b[1] - a[1]) * (k + 1)) / count];
|
||||
const samples = emitterSamples([p0, p1], input.faces, r / 4);
|
||||
if (!samples.length) continue;
|
||||
const clip: string[] = [];
|
||||
let failed = false;
|
||||
for (const source of samples) {
|
||||
try {
|
||||
const lit = buildGlowClipGeometry({
|
||||
spaceId: input.spaceId,
|
||||
source: { x: source[0], y: source[1] },
|
||||
radius: r,
|
||||
scene: input.scene,
|
||||
polygons: input.polygons,
|
||||
onBoundsFailure: () => { failed = true; },
|
||||
}).lit;
|
||||
clip.push(...lit);
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (failed || !clip.length) continue;
|
||||
pieces.push({ d: `M${p0[0]} ${p0[1]} L${p1[0]} ${p1[1]}`, clip });
|
||||
minX = Math.min(minX, p0[0], p1[0]); minY = Math.min(minY, p0[1], p1[1]);
|
||||
maxX = Math.max(maxX, p0[0], p1[0]); maxY = Math.max(maxY, p0[1], p1[1]);
|
||||
}
|
||||
}
|
||||
if (!pieces.length) return null;
|
||||
return { pieces, box: { x: minX - r, y: minY - r, w: maxX - minX + 2 * r, h: maxY - minY + 2 * r } };
|
||||
}
|
||||
|
||||
/** The shared falloff at a relative distance 0…1 (GLOW_FALLOFF, linear between stops). */
|
||||
export function falloffAt(fraction: number): number {
|
||||
const at = Math.max(0, Math.min(1, fraction)) * 100;
|
||||
for (let i = 1; i < GLOW_FALLOFF.length; i++) {
|
||||
const [o0, v0] = GLOW_FALLOFF[i - 1];
|
||||
const [o1, v1] = GLOW_FALLOFF[i];
|
||||
if (at <= o1) return v0 + ((v1 - v0) * (at - o0)) / Math.max(1e-9, o1 - o0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const grey = (value: number): string => {
|
||||
const level = Math.round(Math.max(0, Math.min(1, value)) * 255);
|
||||
return `rgb(${level},${level},${level})`;
|
||||
};
|
||||
|
||||
/** Stable DOM ids per strip: a re-render must not rebuild masks and clips. */
|
||||
const fieldIds = new Map<string, number>();
|
||||
const fieldId = (spaceId: string, stripId: string): number => {
|
||||
const key = `${spaceId}|${stripId}`;
|
||||
let id = fieldIds.get(key);
|
||||
if (id == null) {
|
||||
id = fieldIds.size + 1;
|
||||
fieldIds.set(key, id);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
export interface LedFieldInput {
|
||||
views: readonly LedStripView[];
|
||||
scene: LightBarrierScene | null;
|
||||
polygons: readonly LightRoomPolygon[];
|
||||
faces: FaceContext | null;
|
||||
spaceId: string;
|
||||
/** The card that owns the bounded cache (one per card, gone with it). */
|
||||
owner: object;
|
||||
}
|
||||
|
||||
const fieldCaches = new WeakMap<object, LedFieldCache>();
|
||||
export function ledFieldCache(owner: object): LedFieldCache {
|
||||
let cache = fieldCaches.get(owner);
|
||||
if (!cache) {
|
||||
cache = new LedFieldCache();
|
||||
fieldCaches.set(owner, cache);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* The linear fields of the strips that are on in a Glow room. Fade uses the
|
||||
* shared spot transition (`.glow-spot`, GLOW_FADE_MS) — no animation system of
|
||||
* its own; an off strip keeps its node at opacity 0, so a fade-out completes
|
||||
* and leaves no residual light.
|
||||
*/
|
||||
export function renderLedField(input: LedFieldInput): TemplateResult {
|
||||
if (!input.scene) return svg`` as unknown as TemplateResult;
|
||||
const cache = ledFieldCache(input.owner);
|
||||
cache.forSpace(input.spaceId);
|
||||
const scene = input.scene;
|
||||
const fields = input.views.flatMap((view) => {
|
||||
if (!view.glow || view.state === 'unavailable') return [];
|
||||
const key = `${view.strip.id}|${pointsKey(view.strip.points)}|${view.radius.toFixed(5)}|${scene.fingerprint}`;
|
||||
const geometry = cache.read(key, () => buildFieldGeometry({
|
||||
points: view.strip.points,
|
||||
radius: view.radius,
|
||||
scene,
|
||||
polygons: input.polygons,
|
||||
faces: input.faces,
|
||||
spaceId: input.spaceId,
|
||||
}));
|
||||
return geometry ? [{ view, geometry }] : [];
|
||||
});
|
||||
if (!fields.length) return svg`` as unknown as TemplateResult;
|
||||
const bands = Array.from({ length: LED_FIELD_BANDS }, (_, k) => {
|
||||
const outer = 1 - k / LED_FIELD_BANDS;
|
||||
const inner = 1 - (k + 1) / LED_FIELD_BANDS;
|
||||
return { half: outer, value: falloffAt((outer + inner) / 2) };
|
||||
});
|
||||
return svg`<g class="led-fields" pointer-events="none" aria-hidden="true">
|
||||
${repeat(fields, ({ view }) => view.strip.id, ({ view, geometry }) => {
|
||||
const id = `${fieldId(input.spaceId, view.strip.id)}`;
|
||||
const r = view.radius;
|
||||
const on = view.state === 'on' && !!view.appearance;
|
||||
const box = geometry.box;
|
||||
const closed = isClosedStrip(pts(view.strip.points));
|
||||
return svg`<g class="glow-spot led-field ${on ? '' : 'is-leaving'}" data-led-field="${view.strip.id}"
|
||||
data-pieces="${geometry.pieces.length}" data-closed="${closed ? 'true' : 'false'}">
|
||||
<defs>
|
||||
${geometry.pieces.map((piece, k) => svg`<clipPath id="hp-led-clip-${id}-${k}">
|
||||
${piece.clip.map((d) => svg`<path d="${d}" clip-rule="evenodd"></path>`)}
|
||||
</clipPath>`)}
|
||||
<mask id="hp-led-mask-${id}" maskUnits="userSpaceOnUse"
|
||||
x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
|
||||
color-interpolation="sRGB" style="mask-type:luminance">
|
||||
<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" fill="black"></rect>
|
||||
<g style="isolation:isolate">
|
||||
${geometry.pieces.map((piece, k) => svg`<g clip-path="url(#hp-led-clip-${id}-${k})"
|
||||
style="mix-blend-mode:lighten">
|
||||
${bands.map((band) => svg`<path d="${piece.d}" fill="none" stroke="${grey(band.value)}"
|
||||
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
|
||||
</g>`)}
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect class="glow-pool led-pool" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
|
||||
fill="${view.appearance?.c ?? 'transparent'}" fill-opacity="${(view.appearance?.alpha ?? 0).toFixed(4)}"
|
||||
mask="url(#hp-led-mask-${id})"></rect>
|
||||
</g>`;
|
||||
})}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
+40
-221
@@ -17,9 +17,10 @@
|
||||
import { nothing, svg, type TemplateResult } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import {
|
||||
GLOW_FALLOFF, buildGlowClipGeometry, resolveGlowCandidates,
|
||||
resolveGlowCandidates,
|
||||
type GlowCandidate, type LightBarrierScene, type LightRoomPolygon,
|
||||
} from './glow-scene';
|
||||
import { ENTRY_BUILD_FINGERPRINT } from './editor-runtime-loader';
|
||||
import { NORM_W, iconUnit } from './space-geometry';
|
||||
import { roomGlowOf, roomPoly } from './logic';
|
||||
import { ISO_ICON_SCALE, ISO_TILE, isoEdgeColor, isoTileShadow } from './iso-tiles';
|
||||
@@ -28,7 +29,7 @@ import type { VirtualLightSnapshot } from './virtual-light-state';
|
||||
import { geometryAllRings, pointInOpaquePlanBody } from './physical-geometry';
|
||||
import {
|
||||
LED_DEFAULT_RADIUS_CM, LED_EPSILON_CM, LED_THICKNESS_OFF_D, LED_THICKNESS_ON_D,
|
||||
compactPoints, emitterSamples, isClosedStrip, pathD, stripAnchor, stripHitOwner,
|
||||
pathD, stripAnchor, stripHitOwner,
|
||||
stripHitRadiusPx, validStripPoints, visibleStripPath,
|
||||
type BodyFace, type FaceContext, type Pt,
|
||||
} from './led-strip-geometry';
|
||||
@@ -36,10 +37,35 @@ import type { DevItem, LedStripModel, RoomCfg, SpaceModel } from './types';
|
||||
|
||||
export const LED_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
|
||||
type LedField = typeof import('./led-strip-field');
|
||||
let field: LedField | null = null;
|
||||
let fieldLoading: Promise<unknown> | null = null;
|
||||
let fieldFailed: string | undefined;
|
||||
|
||||
/**
|
||||
* The field chunk (ТЗ §13.1): loaded only when a strip is on in a Glow room
|
||||
* with a light scene. Same contract as the gate: one load, fingerprint
|
||||
* handshake, a hashed-URL retry on the next entry, never in a loop.
|
||||
*/
|
||||
export function ledField(entry: string, ready: () => void): LedField | null {
|
||||
if (!field && !fieldLoading && fieldFailed !== entry && fieldFailed !== '') {
|
||||
fieldLoading = (fieldFailed === undefined
|
||||
? import('./led-strip-field')
|
||||
: import(/* @vite-ignore */ new URL(`__HOUSEPLAN_LED_FIELD_RETRY_ASSET__?${Date.now()}`, import.meta.url).href) as Promise<LedField>
|
||||
).then((module) => {
|
||||
if (module.LED_FIELD_FINGERPRINT === ENTRY_BUILD_FINGERPRINT) field = module;
|
||||
else fieldFailed = '';
|
||||
}, () => { fieldFailed = entry; }).finally(() => { fieldLoading = null; });
|
||||
}
|
||||
if (!field) void fieldLoading?.then(() => field && ready());
|
||||
return field;
|
||||
}
|
||||
|
||||
const fieldWanted = (views: readonly LedStripView[]): boolean =>
|
||||
views.some((view) => view.glow && view.state === 'on');
|
||||
|
||||
const pts = (points: readonly number[][]): Pt[] => points.map((p) => [p[0], p[1]] as Pt);
|
||||
|
||||
/** Bands of the luminance field: 16 steps keep a band under 7 % of the range. */
|
||||
export const LED_FIELD_BANDS = 16;
|
||||
const OUTLINE = '#383838';
|
||||
const CORE_IDLE = '#FFFFFF';
|
||||
const UNAVAILABLE = '#9e9e9e';
|
||||
@@ -259,220 +285,6 @@ export function renderLedStripes(input: LedStripeInput): TemplateResult {
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linear light field
|
||||
|
||||
interface FieldPiece {
|
||||
d: string;
|
||||
clip: string[];
|
||||
}
|
||||
|
||||
interface FieldGeometry {
|
||||
pieces: FieldPiece[];
|
||||
box: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
/** Bounded per-space cache: shape × radius × barrier revision (ТЗ §13.2). */
|
||||
export class LedFieldCache {
|
||||
private entries = new Map<string, FieldGeometry | null>();
|
||||
private space = '';
|
||||
constructor(private readonly limit = 50) {}
|
||||
get size(): number { return this.entries.size; }
|
||||
/** Recompute counter for the performance witness: geometry, not paint. */
|
||||
recomputes = 0;
|
||||
forSpace(spaceId: string): void {
|
||||
if (spaceId !== this.space) { this.entries.clear(); this.space = spaceId; }
|
||||
}
|
||||
read(key: string, build: () => FieldGeometry | null): FieldGeometry | null {
|
||||
if (this.entries.has(key)) {
|
||||
const value = this.entries.get(key) ?? null;
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, value);
|
||||
return value;
|
||||
}
|
||||
this.recomputes += 1;
|
||||
const value = build();
|
||||
this.entries.set(key, value);
|
||||
while (this.entries.size > this.limit) {
|
||||
const oldest = this.entries.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.entries.delete(oldest);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
clear(): void { this.entries.clear(); this.space = ''; }
|
||||
}
|
||||
|
||||
const pointsKey = (points: readonly number[][]): string =>
|
||||
points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join(';');
|
||||
|
||||
/**
|
||||
* Pieces of a strip for the field: stored segments cut to at most the radius,
|
||||
* each with the union of what its own emitters see (ТЗ §6). A piece whose
|
||||
* emitters are all inside a body emits nothing; a failed clip makes that piece
|
||||
* dark (fail-dark), never an unclipped field.
|
||||
*/
|
||||
export function buildFieldGeometry(input: {
|
||||
points: readonly number[][];
|
||||
radius: number;
|
||||
scene: LightBarrierScene;
|
||||
polygons: readonly LightRoomPolygon[];
|
||||
faces: FaceContext | null;
|
||||
spaceId: string;
|
||||
}): FieldGeometry | null {
|
||||
const path = compactPoints(pts(input.points));
|
||||
if (path.length < 2 || !(input.radius > 0)) return null;
|
||||
const r = input.radius;
|
||||
const pieces: FieldPiece[] = [];
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const a = path[i - 1], b = path[i];
|
||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
const count = Math.max(1, Math.ceil(len / r));
|
||||
for (let k = 0; k < count; k++) {
|
||||
const p0: Pt = [a[0] + ((b[0] - a[0]) * k) / count, a[1] + ((b[1] - a[1]) * k) / count];
|
||||
const p1: Pt = [a[0] + ((b[0] - a[0]) * (k + 1)) / count, a[1] + ((b[1] - a[1]) * (k + 1)) / count];
|
||||
const samples = emitterSamples([p0, p1], input.faces, r / 4);
|
||||
if (!samples.length) continue;
|
||||
const clip: string[] = [];
|
||||
let failed = false;
|
||||
for (const source of samples) {
|
||||
try {
|
||||
const lit = buildGlowClipGeometry({
|
||||
spaceId: input.spaceId,
|
||||
source: { x: source[0], y: source[1] },
|
||||
radius: r,
|
||||
scene: input.scene,
|
||||
polygons: input.polygons,
|
||||
onBoundsFailure: () => { failed = true; },
|
||||
}).lit;
|
||||
clip.push(...lit);
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (failed || !clip.length) continue;
|
||||
pieces.push({ d: `M${p0[0]} ${p0[1]} L${p1[0]} ${p1[1]}`, clip });
|
||||
minX = Math.min(minX, p0[0], p1[0]); minY = Math.min(minY, p0[1], p1[1]);
|
||||
maxX = Math.max(maxX, p0[0], p1[0]); maxY = Math.max(maxY, p0[1], p1[1]);
|
||||
}
|
||||
}
|
||||
if (!pieces.length) return null;
|
||||
return { pieces, box: { x: minX - r, y: minY - r, w: maxX - minX + 2 * r, h: maxY - minY + 2 * r } };
|
||||
}
|
||||
|
||||
/** The shared falloff at a relative distance 0…1 (GLOW_FALLOFF, linear between stops). */
|
||||
export function falloffAt(fraction: number): number {
|
||||
const at = Math.max(0, Math.min(1, fraction)) * 100;
|
||||
for (let i = 1; i < GLOW_FALLOFF.length; i++) {
|
||||
const [o0, v0] = GLOW_FALLOFF[i - 1];
|
||||
const [o1, v1] = GLOW_FALLOFF[i];
|
||||
if (at <= o1) return v0 + ((v1 - v0) * (at - o0)) / Math.max(1e-9, o1 - o0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const grey = (value: number): string => {
|
||||
const level = Math.round(Math.max(0, Math.min(1, value)) * 255);
|
||||
return `rgb(${level},${level},${level})`;
|
||||
};
|
||||
|
||||
/** Stable DOM ids per strip: a re-render must not rebuild masks and clips. */
|
||||
const fieldIds = new Map<string, number>();
|
||||
const fieldId = (spaceId: string, stripId: string): number => {
|
||||
const key = `${spaceId}|${stripId}`;
|
||||
let id = fieldIds.get(key);
|
||||
if (id == null) {
|
||||
id = fieldIds.size + 1;
|
||||
fieldIds.set(key, id);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
export interface LedFieldInput {
|
||||
views: readonly LedStripView[];
|
||||
scene: LightBarrierScene | null;
|
||||
polygons: readonly LightRoomPolygon[];
|
||||
faces: FaceContext | null;
|
||||
spaceId: string;
|
||||
/** The card that owns the bounded cache (one per card, gone with it). */
|
||||
owner: object;
|
||||
}
|
||||
|
||||
const fieldCaches = new WeakMap<object, LedFieldCache>();
|
||||
export function ledFieldCache(owner: object): LedFieldCache {
|
||||
let cache = fieldCaches.get(owner);
|
||||
if (!cache) {
|
||||
cache = new LedFieldCache();
|
||||
fieldCaches.set(owner, cache);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* The linear fields of the strips that are on in a Glow room. Fade uses the
|
||||
* shared spot transition (`.glow-spot`, GLOW_FADE_MS) — no animation system of
|
||||
* its own; an off strip keeps its node at opacity 0, so a fade-out completes
|
||||
* and leaves no residual light.
|
||||
*/
|
||||
export function renderLedField(input: LedFieldInput): TemplateResult {
|
||||
if (!input.scene) return svg`` as unknown as TemplateResult;
|
||||
const cache = ledFieldCache(input.owner);
|
||||
cache.forSpace(input.spaceId);
|
||||
const scene = input.scene;
|
||||
const fields = input.views.flatMap((view) => {
|
||||
if (!view.glow || view.state === 'unavailable') return [];
|
||||
const key = `${view.strip.id}|${pointsKey(view.strip.points)}|${view.radius.toFixed(5)}|${scene.fingerprint}`;
|
||||
const geometry = cache.read(key, () => buildFieldGeometry({
|
||||
points: view.strip.points,
|
||||
radius: view.radius,
|
||||
scene,
|
||||
polygons: input.polygons,
|
||||
faces: input.faces,
|
||||
spaceId: input.spaceId,
|
||||
}));
|
||||
return geometry ? [{ view, geometry }] : [];
|
||||
});
|
||||
if (!fields.length) return svg`` as unknown as TemplateResult;
|
||||
const bands = Array.from({ length: LED_FIELD_BANDS }, (_, k) => {
|
||||
const outer = 1 - k / LED_FIELD_BANDS;
|
||||
const inner = 1 - (k + 1) / LED_FIELD_BANDS;
|
||||
return { half: outer, value: falloffAt((outer + inner) / 2) };
|
||||
});
|
||||
return svg`<g class="led-fields" pointer-events="none" aria-hidden="true">
|
||||
${repeat(fields, ({ view }) => view.strip.id, ({ view, geometry }) => {
|
||||
const id = `${fieldId(input.spaceId, view.strip.id)}`;
|
||||
const r = view.radius;
|
||||
const on = view.state === 'on' && !!view.appearance;
|
||||
const box = geometry.box;
|
||||
const closed = isClosedStrip(pts(view.strip.points));
|
||||
return svg`<g class="glow-spot led-field ${on ? '' : 'is-leaving'}" data-led-field="${view.strip.id}"
|
||||
data-pieces="${geometry.pieces.length}" data-closed="${closed ? 'true' : 'false'}">
|
||||
<defs>
|
||||
${geometry.pieces.map((piece, k) => svg`<clipPath id="hp-led-clip-${id}-${k}">
|
||||
${piece.clip.map((d) => svg`<path d="${d}" clip-rule="evenodd"></path>`)}
|
||||
</clipPath>`)}
|
||||
<mask id="hp-led-mask-${id}" maskUnits="userSpaceOnUse"
|
||||
x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
|
||||
color-interpolation="sRGB" style="mask-type:luminance">
|
||||
<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" fill="black"></rect>
|
||||
<g style="isolation:isolate">
|
||||
${geometry.pieces.map((piece, k) => svg`<g clip-path="url(#hp-led-clip-${id}-${k})"
|
||||
style="mix-blend-mode:lighten">
|
||||
${bands.map((band) => svg`<path d="${piece.d}" fill="none" stroke="${grey(band.value)}"
|
||||
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
|
||||
</g>`)}
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect class="glow-pool led-pool" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
|
||||
fill="${view.appearance?.c ?? 'transparent'}" fill-opacity="${(view.appearance?.alpha ?? 0).toFixed(4)}"
|
||||
mask="url(#hp-led-mask-${id})"></rect>
|
||||
</g>`;
|
||||
})}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One frame for the card: the views of the shown space, its faces and scene.
|
||||
|
||||
@@ -583,6 +395,7 @@ export interface LedCardHost {
|
||||
_hideDeviceFocusTip: (id: string) => void;
|
||||
_renderProjection: string;
|
||||
_isoLightFloors: ReadonlySet<string> | null;
|
||||
requestUpdate(): void;
|
||||
}
|
||||
|
||||
const frames = new WeakMap<object, { key: unknown[]; frame: LedFrame }>();
|
||||
@@ -665,7 +478,9 @@ export function renderLedLayerFor(
|
||||
|
||||
export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlow: boolean): TemplateResult {
|
||||
const frame = ledFrameFor(host, space, spaceGlow);
|
||||
return renderLedField({
|
||||
const module = frame.scene && fieldWanted(frame.views) ? ledField(space.id, () => host.requestUpdate()) : null;
|
||||
if (!module) return svg`` as unknown as TemplateResult;
|
||||
return module.renderLedField({
|
||||
views: frame.views,
|
||||
scene: frame.scene,
|
||||
polygons: frame.polygons,
|
||||
@@ -702,6 +517,8 @@ export interface StaticLedInput {
|
||||
bodies: { masonryGeometry: unknown; opaqueBodies: number[][][] };
|
||||
perUnit: number;
|
||||
owner: object;
|
||||
/** Re-render when the field chunk lands. */
|
||||
ready: () => void;
|
||||
}
|
||||
|
||||
export function renderStaticLed(input: StaticLedInput): TemplateResult {
|
||||
@@ -731,7 +548,9 @@ export function renderStaticLed(input: StaticLedInput): TemplateResult {
|
||||
occluders: [], floor: [], fingerprint: '',
|
||||
masonryGeometry: input.bodies.masonryGeometry, opaqueBodies: input.bodies.opaqueBodies,
|
||||
}, (LED_EPSILON_CM / input.cellCm) * input.gridPitch);
|
||||
return svg`${input.scene && input.live ? renderLedField({
|
||||
views, scene: input.scene, polygons, faces, spaceId: input.space.id, owner: input.owner,
|
||||
const module = input.scene && input.live && fieldWanted(views)
|
||||
? ledField(input.space.id, input.ready) : null;
|
||||
return svg`${module ? module.renderLedField({
|
||||
views, scene: input.scene as LightBarrierScene, polygons, faces, spaceId: input.space.id, owner: input.owner,
|
||||
}) : nothing}${renderLedStripes({ views, d: frame.d, faces, perUnit: input.perUnit, handlers: null })}` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
@@ -999,6 +999,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
gridPitch: GRID_PITCH, iconPct, glowFor: (room) => !!o.lightPools && roomGlowOf(disp.glow, room),
|
||||
inRoom: (point, room) => pointInPolygon(point, roomPoly(room) || []), live: o.liveStates !== false,
|
||||
scene: lightScene, perUnit: pxPerUnit, owner: o.glowRuntime?.state || space,
|
||||
ready: () => o.moonHost?.requestUpdate(),
|
||||
bodies: { masonryGeometry: canonicalWallGeometry?.components.flatMap((c) => c.geom) || [], opaqueBodies: extras },
|
||||
}) ?? nothing : nothing}
|
||||
${wallUnion
|
||||
|
||||
@@ -539,7 +539,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
|
||||
+ 'new URL("__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__", import.meta.url);'
|
||||
+ 'new URL("__HOUSEPLAN_PDF_RETRY_ASSET__", import.meta.url);'
|
||||
+ 'new URL("__HOUSEPLAN_MOON_RETRY_ASSET__", import.meta.url);'
|
||||
+ 'new URL("__HOUSEPLAN_LED_RETRY_ASSET__", import.meta.url)', modules: {},
|
||||
+ 'new URL("__HOUSEPLAN_LED_RETRY_ASSET__", import.meta.url);'
|
||||
+ 'new URL("__HOUSEPLAN_LED_FIELD_RETRY_ASSET__", import.meta.url)', modules: {},
|
||||
},
|
||||
// #627: the lazy namespace chunk that owns the nine second-attempt tokens.
|
||||
'houseplan-assets/backdrop-pick-HASH.js': {
|
||||
@@ -584,6 +585,10 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
|
||||
type: 'chunk', fileName: 'houseplan-assets/led-strip-runtime-HASH.js', code: '',
|
||||
modules: { '/repo/src/led-strip-runtime.ts': {} },
|
||||
},
|
||||
'houseplan-assets/led-strip-field-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/led-strip-field-HASH.js', code: '',
|
||||
modules: { '/repo/src/led-strip-field.ts': {} },
|
||||
},
|
||||
};
|
||||
plugin.generateBundle({}, bundle);
|
||||
assert.equal(
|
||||
@@ -596,7 +601,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
|
||||
+ 'new URL("./furniture-plan-art.generated-HASH.js", import.meta.url);'
|
||||
+ 'new URL("./pdf-export-HASH.js", import.meta.url);'
|
||||
+ 'new URL("./moon-runtime-HASH.js", import.meta.url);'
|
||||
+ 'new URL("./led-strip-runtime-HASH.js", import.meta.url)',
|
||||
+ 'new URL("./led-strip-runtime-HASH.js", import.meta.url);'
|
||||
+ 'new URL("./led-strip-field-HASH.js", import.meta.url)',
|
||||
);
|
||||
assert.equal(
|
||||
bundle['houseplan-assets/backdrop-pick-HASH.js'].code,
|
||||
@@ -609,7 +615,7 @@ test('#627 namespace retry tokens stay strict: exactly one each, every chunk emi
|
||||
const bundle = {
|
||||
'houseplan-assets/houseplan-card.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js', modules: {},
|
||||
code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF', 'MOON', 'LED']
|
||||
code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF', 'MOON', 'LED', 'LED_FIELD']
|
||||
.map((name) => `"__HOUSEPLAN_${name}_RETRY_ASSET__"`).join(';'),
|
||||
},
|
||||
...Object.fromEntries([
|
||||
@@ -618,7 +624,7 @@ test('#627 namespace retry tokens stay strict: exactly one each, every chunk emi
|
||||
['iso-scene-render', '/src/iso-scene-render.ts'], ['de', '/src/i18n/de.ts'],
|
||||
['fr', '/src/i18n/fr.ts'], ['furniture-plan-art.generated', '/src/furniture-plan-art.generated.ts'],
|
||||
['pdf-export', '/src/pdf/pdf-export.ts'], ['moon-runtime', '/src/moon-runtime.ts'],
|
||||
['led-strip-runtime', '/src/led-strip-runtime.ts'],
|
||||
['led-strip-runtime', '/src/led-strip-runtime.ts'], ['led-strip-field', '/src/led-strip-field.ts'],
|
||||
].map(([name, module]) => [`houseplan-assets/${name}-HASH.js`, {
|
||||
type: 'chunk', fileName: `houseplan-assets/${name}-HASH.js`, code: '', modules: { [`/repo${module}`]: {} },
|
||||
}])),
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ledAnchor, ledStripsByMarker } from '../test-build/led-strip-gate.js';
|
||||
import {
|
||||
LedFieldCache, buildFieldGeometry, falloffAt, faceContext, ledStripView,
|
||||
} from '../test-build/led-strip-runtime.js';
|
||||
import { faceContext, ledStripView } from '../test-build/led-strip-runtime.js';
|
||||
import { 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';
|
||||
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"src/radar-model.ts", "src/radar-editor.ts", "src/radar-geometry.ts", "src/radar-setup.ts", "src/radar-render.ts",
|
||||
"src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts", "src/vacuum-route-edit.ts",
|
||||
"src/sun.ts", "src/moon.ts", "src/moon-gate.ts", "src/moon-runtime.ts", "src/moon-art.generated.ts",
|
||||
"src/light-visibility.ts", "src/led-strip-geometry.ts", "src/led-strip-gate.ts", "src/led-strip-runtime.ts", "src/glow-scene.ts", "src/space-render.ts",
|
||||
"src/light-visibility.ts", "src/led-strip-geometry.ts", "src/led-strip-gate.ts", "src/led-strip-runtime.ts", "src/led-strip-field.ts", "src/glow-scene.ts", "src/space-render.ts",
|
||||
"src/resize.ts", "src/resize-labels.ts", "src/resize-controller.ts", "src/wall-record-preservation.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts", "src/device-inbox.ts",
|
||||
|
||||
Reference in New Issue
Block a user