diff --git a/scripts/bundle-manifest.mjs b/scripts/bundle-manifest.mjs index cf238038..36f2e8b5 100644 --- a/scripts/bundle-manifest.mjs +++ b/scripts/bundle-manifest.mjs @@ -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. diff --git a/src/led-strip-field.ts b/src/led-strip-field.ts new file mode 100644 index 00000000..d2fafc02 --- /dev/null +++ b/src/led-strip-field.ts @@ -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(); + 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(); +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(); +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`` as unknown as TemplateResult; +} + diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts index 4de28c22..be513d01 100755 --- a/src/led-strip-runtime.ts +++ b/src/led-strip-runtime.ts @@ -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 | 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 + ).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 { ` 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(); - 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(); -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(); -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`` 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 | null; + requestUpdate(): void; } const frames = new WeakMap(); @@ -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; } diff --git a/src/space-render.ts b/src/space-render.ts index 47ce9773..866e7404 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -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 diff --git a/test/bundle-assets.test.mjs b/test/bundle-assets.test.mjs index 2abc8b38..6306f732 100644 --- a/test/bundle-assets.test.mjs +++ b/test/bundle-assets.test.mjs @@ -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}`]: {} }, }])), diff --git a/test/led-strip-runtime.test.mjs b/test/led-strip-runtime.test.mjs index c438130c..64432f29 100644 --- a/test/led-strip-runtime.test.mjs +++ b/test/led-strip-runtime.test.mjs @@ -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'; diff --git a/tsconfig.test.json b/tsconfig.test.json index 0fd18cb8..029a1bc0 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -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",