mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 14:19:04 +00:00
feat(card): LED strips in View — lazy chunk, stripe, linear light (#780)
Stage 3a of #780. - src/led-strip-gate.ts is the only initial-graph foothold (ТЗ §13.1): which markers a space shows as a strip (active and bound), the half-length anchor that replaces their icon position, and one page-wide load of the lazy led-strip-runtime chunk (fingerprint handshake, a hashed-URL retry on the next explicit entry, never in a loop). - The card: a represented marker takes no auto slot, draws no icon, casts no round pool and is placed at the anchor; two call sites render the stripe layer and the linear field from the chunk. The space model carries the stored strips untouched; scaling, validation and geometry are in the chunk. - src/led-strip-runtime.ts: the stripe is two strokes of one derived path (outline #383838 t, core t/2, round joins and caps, t = 0.08/0.12 D), white off, white core + field on with Glow, source-colour core without Glow, grey dashed unavailable without a field. The hit path takes the card's own device handlers (one action path) with radius max(22 px, t/2) and the nearest stripe as owner. The linear field is the exact distance field with the shared falloff: opaque grey bands of a luminance mask per piece, pieces joined by lighten (the maximum), each piece clipped to what its own emitters see, so a hidden part never lights through another part's visibility; buried emitters emit nothing; a failed clip is dark. A bounded per-space cache (50) counts geometry rebuilds. - The initial View graph had 501 B of headroom. The furniture library copy (furn.*, 104 keys × 4 languages, editor-only) moves into a new lazy `tools` namespace (#627 mechanism) that the editor runtime awaits; the initial View graph is 298 686 B gzip with the LED gate in it (−1 879 B vs the base). Tests: test/led-strip-runtime.test.mjs (6: representation, gate anchor = geometry anchor, falloff, states and radius, per-piece clipping and buried strips, bounded cache), bundle and i18n fixtures for the LED chunk and the fourth namespace. Issue: #780 User-Visible: no
This commit is contained in:
@@ -469,6 +469,7 @@ export const NAMESPACE_ENGLISH_CONSUMERS = {
|
||||
settings: ['lazyEditorFiles', 'lazyOnboardingFiles'],
|
||||
support: ['lazyEditorFiles'],
|
||||
topology: ['lazyEditorFiles'],
|
||||
tools: ['lazyEditorFiles'],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -775,7 +776,7 @@ export function assertBundleBudget(
|
||||
const namespaceLocales = manifest.lazyNamespaceLocaleFiles;
|
||||
if (!Array.isArray(namespaceLocales) || namespaceLocales.length !== NAMESPACE_LOCALE_CHUNKS.length) {
|
||||
throw new Error(`lazy namespace locale graph has ${namespaceLocales?.length ?? 0} files,`
|
||||
+ ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology × ru/de/fr)`);
|
||||
+ ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology/tools × ru/de/fr)`);
|
||||
}
|
||||
for (const [graph, label] of [
|
||||
['initialViewFiles', 'initial View graph'],
|
||||
|
||||
@@ -12,13 +12,14 @@ const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
|
||||
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__';
|
||||
|
||||
/**
|
||||
* #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per
|
||||
* namespace × language. Their loader modules are the recognition marker for
|
||||
* the manifest graph and the target of each second-attempt retry token.
|
||||
*/
|
||||
export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology']
|
||||
export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology', 'tools']
|
||||
.flatMap((namespace) => ['ru', 'de', 'fr'].map((language) => ({
|
||||
namespace,
|
||||
language,
|
||||
@@ -114,6 +115,10 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
// #661: the moon's math, art and template — loaded only at night.
|
||||
: modules.some((id) => id.endsWith('/src/moon-runtime.ts'))
|
||||
? 'moon'
|
||||
// #780: LED strip geometry, light field and hit paths — loaded
|
||||
// only when a shown space has an active strip.
|
||||
: modules.some((id) => id.endsWith('/src/led-strip-runtime.ts'))
|
||||
? 'led'
|
||||
// #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'))
|
||||
@@ -159,6 +164,8 @@ 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-'));
|
||||
// #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')
|
||||
@@ -177,6 +184,7 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
const lazyFurnitureArt = graphFrom(furnitureArtRoots);
|
||||
const lazyPdf = graphFrom(pdfRoots);
|
||||
const lazyMoon = graphFrom(moonRoots);
|
||||
const lazyLed = graphFrom(ledRoots);
|
||||
const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots);
|
||||
const sum = (paths) => [...paths]
|
||||
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
|
||||
@@ -207,6 +215,8 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
lazyPdfGzipBytes: sum(lazyPdf),
|
||||
lazyMoonFiles: [...lazyMoon].sort(),
|
||||
lazyMoonGzipBytes: sum(lazyMoon),
|
||||
lazyLedFiles: [...lazyLed].sort(),
|
||||
lazyLedGzipBytes: sum(lazyLed),
|
||||
lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(),
|
||||
lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale),
|
||||
files: files.map(({ _role, ...file }) => file),
|
||||
@@ -276,6 +286,9 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted');
|
||||
if (!pdf) throw new Error('PDF export runtime chunk was not emitted');
|
||||
if (!moon) throw new Error('moon runtime chunk was not emitted');
|
||||
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 namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => {
|
||||
const chunk = chunks.find((candidate) => Object.keys(candidate.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith(entry.module)));
|
||||
@@ -292,6 +305,7 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
let frenchReplacements = 0;
|
||||
let pdfReplacements = 0;
|
||||
let moonReplacements = 0;
|
||||
let ledReplacements = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
|
||||
@@ -341,6 +355,12 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
moonReplacements += chunk.code.split(MOON_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(MOON_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
if (chunk.code.includes(LED_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), led.fileName);
|
||||
if (!asset.startsWith('.')) asset = `./${asset}`;
|
||||
ledReplacements += chunk.code.split(LED_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(LED_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);
|
||||
@@ -351,9 +371,9 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
}
|
||||
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|
||||
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
|
||||
|| pdfReplacements !== 1 || moonReplacements !== 1) {
|
||||
|| pdfReplacements !== 1 || moonReplacements !== 1 || ledReplacements !== 1) {
|
||||
throw new Error('lazy retry URL placeholder counts are '
|
||||
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}, expected 1/1/1/1/1/1/1/1`);
|
||||
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}, expected 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.
|
||||
|
||||
@@ -361,7 +361,8 @@ export const SMOKE_LINKS = [
|
||||
symbols: [
|
||||
'namespaceLanguageRuntime', 'composeLanguageRuntimes', 'surfaceLanguageRuntime',
|
||||
'SETTINGS_LANGUAGE_RUNTIME', 'SUPPORT_LANGUAGE_RUNTIME', 'TOPOLOGY_LANGUAGE_RUNTIME',
|
||||
'EDITOR_LANGUAGE_RUNTIME', 'ONBOARDING_LANGUAGE_RUNTIME', 'settingsT', 'supportT', 'topologyT',
|
||||
'TOOLS_LANGUAGE_RUNTIME', 'EDITOR_LANGUAGE_RUNTIME', 'ONBOARDING_LANGUAGE_RUNTIME',
|
||||
'settingsT', 'supportT', 'topologyT', 'toolsT',
|
||||
],
|
||||
smokes: ['smoke_lazy_admin_locale.mjs', 'smoke_dialog_segments_i18n.mjs'],
|
||||
because: 'the ru/de/fr namespace dictionaries are separate lazy chunks: only the production '
|
||||
|
||||
@@ -96,16 +96,6 @@ function isDecorBoxKind(value: unknown): value is DecorBoxKind {
|
||||
&& (DECOR_BOX_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function scalarPoints(value: unknown): void {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const point of value) {
|
||||
if (!Array.isArray(point)) continue;
|
||||
for (let index = 0; index < Math.min(2, point.length); index++) {
|
||||
point[index] = canonicalizeNumber(point[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scalarFields(item: JsonRecord, names: readonly string[]): void {
|
||||
for (const name of names) {
|
||||
if (Object.prototype.hasOwnProperty.call(item, name)) {
|
||||
@@ -395,7 +385,11 @@ export function canonicalizeConfigGeometryInPlace<T>(config: T): T {
|
||||
|
||||
// #780: LED strips snap to physical wall faces like stairs: JSON noise
|
||||
// only, a face contact never moves onto a lattice node.
|
||||
for (const strip of records(space.led_strips)) scalarPoints(strip.points);
|
||||
for (const strip of records(space.led_strips)) {
|
||||
for (const point of Array.isArray(strip.points) ? strip.points : []) {
|
||||
if (Array.isArray(point)) scalarFields(point as unknown as JsonRecord, ['0', '1']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const marker of records(root.markers)) scalarFields(marker, ['angle']);
|
||||
|
||||
+22
-18
@@ -11,7 +11,8 @@ import {
|
||||
import { GENERATED_FURNITURE_MENU } from './furniture-menu-art.generated';
|
||||
import { canonicalFurnitureId } from './furniture-id';
|
||||
import type { HouseplanEditorHostPort } from './houseplan-editor-runtime';
|
||||
import type { I18nKey } from './i18n';
|
||||
import { langOf, type I18nKey } from './i18n';
|
||||
import { toolsT, type ToolsI18nKey } from './i18n/tools';
|
||||
import { CANVAS_LIMIT, NORM_W, clampCanvasN } from './space-geometry';
|
||||
import type { DecorShape } from './editors/decor/types';
|
||||
import { decorCmToUnits } from './editors/decor/geometry';
|
||||
@@ -41,6 +42,11 @@ export class DecorImageEditor<Snapshot> {
|
||||
private readonly hooks: DecorImageEditorHooks<Snapshot>,
|
||||
) {}
|
||||
|
||||
/** #780: furniture names live in the lazy `tools` dictionary. */
|
||||
private _tool(key: ToolsI18nKey): string {
|
||||
return toolsT(langOf(this.host.hass, this.host._config?.language), key);
|
||||
}
|
||||
|
||||
public place(raw: number[], pointerType = 'mouse'): void {
|
||||
const asset = this.host._decorImagePalette;
|
||||
const sp = this.host._curSpaceCfg;
|
||||
@@ -299,7 +305,7 @@ export class DecorImageEditor<Snapshot> {
|
||||
stroke-linejoin="round"></path></svg>` as unknown as TemplateResult;
|
||||
return html`<div class="furnpalette" @pointerdown=${(event: Event) => event.stopPropagation()}>
|
||||
<div class="furnhd">
|
||||
<ha-icon icon="mdi:sofa-outline"></ha-icon>${this.host._t('furn.title')}
|
||||
<ha-icon icon="mdi:sofa-outline"></ha-icon>${this._tool('furn.title')}
|
||||
<span class="spacer"></span>
|
||||
<button class="btn furnclose" title=${this.host._t('btn.close')}
|
||||
@click=${() => {
|
||||
@@ -320,54 +326,52 @@ export class DecorImageEditor<Snapshot> {
|
||||
this.hooks.furnShiftDetach();
|
||||
this.host._furnCategory = null;
|
||||
}}>
|
||||
<ha-icon icon="mdi:arrow-left"></ha-icon>${this.host._t('furn.back_to_categories')}
|
||||
<ha-icon icon="mdi:arrow-left"></ha-icon>${this._tool('furn.back_to_categories')}
|
||||
</button>
|
||||
<div class="furngroup" data-category=${category.id}>
|
||||
${this.host._t(`furn.cat_${category.id}` as I18nKey)}
|
||||
${this._tool(`furn.cat_${category.id}` as ToolsI18nKey)}
|
||||
</div>
|
||||
<div class="furnrow furnvariants">
|
||||
${variants.map((symbol) => html`<button
|
||||
class="furnitem ${pal?.symbol === symbol.id ? 'on' : ''}" data-symbol=${symbol.id}
|
||||
title=${this.host._t(`furn.sym_${symbol.id}` as I18nKey)}
|
||||
title=${this._tool(`furn.sym_${symbol.id}` as ToolsI18nKey)}
|
||||
@click=${() => this.hooks.furnPick(symbol.id)}>
|
||||
${preview(symbol.id)}<span>${this.host._t(`furn.sym_${symbol.id}` as I18nKey)}</span>
|
||||
${preview(symbol.id)}<span>${this._tool(`furn.sym_${symbol.id}` as ToolsI18nKey)}</span>
|
||||
</button>`)}
|
||||
</div>
|
||||
` : FURNITURE_GROUPS.map((group) => html`
|
||||
<div class="furngroup" data-group=${group}>${this.host._t(`furn.group_${group}` as I18nKey)}</div>
|
||||
<div class="furngroup" data-group=${group}>${this._tool(`furn.group_${group}` as ToolsI18nKey)}</div>
|
||||
<div class="furnrow furncategories">
|
||||
${categories.filter((item) => item.group === group).map((item) => html`<button
|
||||
class="furnitem furncategory" data-category=${item.id}
|
||||
title=${this.host._t(`furn.cat_${item.id}` as I18nKey)}
|
||||
title=${this._tool(`furn.cat_${item.id}` as ToolsI18nKey)}
|
||||
@click=${() => {
|
||||
this.hooks.clearFurniturePreview();
|
||||
this.host._furnPalette = null;
|
||||
this.hooks.furnShiftDetach();
|
||||
this.host._furnCategory = item.id;
|
||||
}}>
|
||||
${categoryPreview(item)}<span>${this.host._t(`furn.cat_${item.id}` as I18nKey)}</span>
|
||||
${categoryPreview(item)}<span>${this._tool(`furn.cat_${item.id}` as ToolsI18nKey)}</span>
|
||||
</button>`)}
|
||||
</div>`)}
|
||||
</div>
|
||||
${pal ? html`<div class="furnsize">
|
||||
<label>${this.host._t('furn.width')}<span class="furnunit">${unit}</span></label>
|
||||
<label>${this._tool('furn.width')}<span class="furnunit">${unit}</span></label>
|
||||
<input class="namein furnw" type="number" min="0.01" step="0.05"
|
||||
.value=${String(this.hooks.furnFieldValue(pal.w))}
|
||||
@input=${(event: Event) => (this.host._furnPalette = {
|
||||
...pal,
|
||||
w: this.hooks.furnFieldToCm(Number((event.target as HTMLInputElement).value)),
|
||||
})} />
|
||||
<label>${this.host._t('furn.depth')}<span class="furnunit">${unit}</span></label>
|
||||
<label>${this._tool('furn.depth')}<span class="furnunit">${unit}</span></label>
|
||||
<input class="namein furnh" type="number" min="0.01" step="0.05"
|
||||
.value=${String(this.hooks.furnFieldValue(pal.h))}
|
||||
@input=${(event: Event) => (this.host._furnPalette = {
|
||||
...pal,
|
||||
h: this.hooks.furnFieldToCm(Number((event.target as HTMLInputElement).value)),
|
||||
})} />
|
||||
<span class="furnhint">${this.host._t('furn.place_hint')}</span>
|
||||
</div>` : html`<div class="furnsize"><span class="furnhint">${this.host._t(
|
||||
'furn.pick_hint',
|
||||
)}</span></div>`}
|
||||
<span class="furnhint">${this._tool('furn.place_hint')}</span>
|
||||
</div>` : html`<div class="furnsize"><span class="furnhint">${this._tool('furn.pick_hint')}</span></div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -429,7 +433,7 @@ export class DecorImageEditor<Snapshot> {
|
||||
dismiss-on-scrim @hp-close=${() => (this.host._decorShapeDialog = null)}>
|
||||
<div class="body">
|
||||
${dialog.kind === 'furniture' ? html`
|
||||
<label>${this.host._t('furn.symbol')}</label>
|
||||
<label>${this._tool('furn.symbol')}</label>
|
||||
<select class="namein"
|
||||
@change=${(event: Event) => (this.host._decorShapeDialog = {
|
||||
...dialog, symbol: (event.target as HTMLSelectElement).value,
|
||||
@@ -439,10 +443,10 @@ export class DecorImageEditor<Snapshot> {
|
||||
symbols: furnitureOfGroup(category.group as FurnitureGroup)
|
||||
.filter((symbol) => symbol.category === category.id),
|
||||
})).filter((entry) => entry.symbols.length).map(({ category, symbols }) => html`
|
||||
<optgroup label=${`${this.host._t(`furn.group_${category.group}` as I18nKey)} · ${this.host._t(`furn.cat_${category.id}` as I18nKey)}`}>
|
||||
<optgroup label=${`${this._tool(`furn.group_${category.group}` as ToolsI18nKey)} · ${this._tool(`furn.cat_${category.id}` as ToolsI18nKey)}`}>
|
||||
${symbols.map((symbol) => html`<option value=${symbol.id}
|
||||
?selected=${symbol.id === canonicalFurnitureId(dialog.symbol || '')}>
|
||||
${this.host._t(`furn.sym_${symbol.id}` as I18nKey)}
|
||||
${this._tool(`furn.sym_${symbol.id}` as ToolsI18nKey)}
|
||||
</option>`)}
|
||||
</optgroup>`)}
|
||||
</select>` : dialog.kind === 'image' ? html`
|
||||
|
||||
+21
-5
@@ -6,6 +6,7 @@
|
||||
* 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas.
|
||||
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
|
||||
*/
|
||||
import { ledAnchor, ledRuntime, ledStripsByMarker } from './led-strip-gate';
|
||||
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues, type PropertyDeclaration } from 'lit';
|
||||
import { cache as litCache } from 'lit/directives/cache.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
@@ -5229,7 +5230,8 @@ export class HouseplanCard extends LitElement {
|
||||
// HA-disabled saved markers stay in the roster: they render nowhere
|
||||
// outside the service ghost, but reserve their old auto-grid slot so
|
||||
// temporary deactivation cannot shuffle visible neighbours.
|
||||
const ds = this._devices.filter((d) => d.area === r.area && d.space === s.id);
|
||||
const leds = ledStripsByMarker(s);
|
||||
const ds = this._devices.filter((d) => d.area === r.area && d.space === s.id && !leds.has(d.id));
|
||||
if (!ds.length) continue;
|
||||
const b = this._roomBounds(r);
|
||||
const pad = Math.min(b.w, b.h) * 0.1;
|
||||
@@ -5260,6 +5262,9 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
private _livePos(d: DevItem): { x: number; y: number } {
|
||||
const s = this._spaceModelById(d.space);
|
||||
// #780: a marker shown as an LED strip is placed at the strip's anchor.
|
||||
const led = ledStripsByMarker(s).get(d.id);
|
||||
if (led) return ledAnchor(led.points, NORM_W);
|
||||
const saved = this._areaRelocationIds.has(d.id) ? undefined : this._layout[d.id];
|
||||
if (saved) {
|
||||
if (this._norm) {
|
||||
@@ -10389,8 +10394,11 @@ export class HouseplanCard extends LitElement {
|
||||
});
|
||||
const spots: GlowSpot[] = [];
|
||||
const seenSourceKeys = new Set<string>();
|
||||
// #780: a marker shown as an LED strip has a linear field, not a pool.
|
||||
const leds = ledStripsByMarker(space);
|
||||
for (const candidate of candidates) {
|
||||
const { key, pos } = candidate;
|
||||
if (leds.has(key.slice(space.id.length + 1))) continue;
|
||||
seenSourceKeys.add(key);
|
||||
// Invalid placement inside any opaque body must remain dark. The
|
||||
// masonry geometry already contains the exact passage cuts, so a valid
|
||||
@@ -10453,7 +10461,8 @@ export class HouseplanCard extends LitElement {
|
||||
pruneGlowSources(
|
||||
this._glowRuntimeState, this._glowRuntimeHost, space.id, seenSourceKeys,
|
||||
);
|
||||
if (!spots.length) return svg`` as unknown as TemplateResult;
|
||||
const ledField = this._ledRt(space)?.renderLedFieldFor(this as never, space, disp.glow) ?? nothing;
|
||||
if (!spots.length) return svg`${ledField}` as unknown as TemplateResult;
|
||||
// Per-room Glow overrides are visual clips only. The transport calculation
|
||||
// above still crosses a disabled room, but no base/pool pixels are painted
|
||||
// there. For the common all-enabled case this extra clip is omitted.
|
||||
@@ -10486,13 +10495,18 @@ export class HouseplanCard extends LitElement {
|
||||
const feather = resolveGlowFeather(
|
||||
this._glowRuntimeState, perUnit, cameraStill,
|
||||
);
|
||||
return renderGlowPools({
|
||||
return svg`${renderGlowPools({
|
||||
spots,
|
||||
enabledClip,
|
||||
feather: feather.feather,
|
||||
featherEnabled: feather.enabled,
|
||||
screenBlend: this._glowScreenBlend,
|
||||
});
|
||||
})}${ledField}` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
/** #780: the lazy LED chunk when the space shows a strip (ТЗ §13.1). */
|
||||
private _ledRt(space: SpaceModel) {
|
||||
return ledStripsByMarker(space).size ? ledRuntime(space.id, () => this.requestUpdate()) : null;
|
||||
}
|
||||
|
||||
private _renderSettingsDialog(): TemplateResult {
|
||||
@@ -10699,7 +10713,8 @@ export class HouseplanCard extends LitElement {
|
||||
// (ghosted); everywhere else the flag removes them from sight — but not
|
||||
// from the build, so room LQI still counts them (docs/FILTERING.md)
|
||||
const showGhosts = this._mode === 'devices' && this._showAll;
|
||||
const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
|
||||
const leds = ledStripsByMarker(space);
|
||||
const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts) && !leds.has(d.id));
|
||||
const deviceSnapshot = this._renderDeviceSnapshot;
|
||||
const disp = this._spaceDisplayForRender();
|
||||
const roomFills = this._resolvedRoomFills(space, disp);
|
||||
@@ -11132,6 +11147,7 @@ export class HouseplanCard extends LitElement {
|
||||
: isoLayers && !isoLayers.floorSymbols
|
||||
? nothing
|
||||
: this._renderOpenings(disp)}
|
||||
${this._ledRt(space)?.renderLedLayerFor(this as never, space, disp.glow, view) ?? nothing}
|
||||
${this._renderWallThickUi()}
|
||||
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
|
||||
${''/* editor chrome, not plan content: the backdrop frame sits on
|
||||
|
||||
@@ -1116,110 +1116,6 @@
|
||||
"decor.image_in_use": "Vor dem Löschen alle platzierten Kopien entfernen.",
|
||||
"decor.image_delete_title": "Bilddatei löschen?",
|
||||
"decor.image_delete_message": "„{name}“ aus House Plan löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"furn.title": "Möbelbibliothek",
|
||||
"furn.symbol": "Symbol",
|
||||
"furn.group_furniture": "Möbel",
|
||||
"furn.group_appliance": "Geräte",
|
||||
"furn.group_sanitary": "Sanitär",
|
||||
"furn.group_other": "Sonstiges",
|
||||
"furn.width": "Breite",
|
||||
"furn.depth": "Tiefe",
|
||||
"furn.back_to_categories": "Alle Kategorien",
|
||||
"furn.pick_hint": "Wählen Sie eine Kategorie und dann ein Symbol.",
|
||||
"furn.place_hint": "Klicken Sie auf den Plan — das Element wird an der nächsten Wand platziert. Mit Shift wird es frei platziert.",
|
||||
"furn.cat_air_conditioner": "Klimaanlagen",
|
||||
"furn.cat_armchair": "Sessel",
|
||||
"furn.cat_bathtub": "Badewannen",
|
||||
"furn.cat_bed": "Betten",
|
||||
"furn.cat_bidet": "Bidets",
|
||||
"furn.cat_boiler": "Warmwasserbereiter",
|
||||
"furn.cat_chair": "Stühle",
|
||||
"furn.cat_coffee_table": "Couchtische",
|
||||
"furn.cat_computer": "Computer",
|
||||
"furn.cat_cooktop": "Kochfelder",
|
||||
"furn.cat_dining_table": "Esstische",
|
||||
"furn.cat_dishwasher": "Geschirrspüler",
|
||||
"furn.cat_dryer": "Wäschetrockner",
|
||||
"furn.cat_exercise": "Fitnessgerät",
|
||||
"furn.cat_fireplace": "Kamine",
|
||||
"furn.cat_fridge": "Kühlschränke",
|
||||
"furn.cat_hood": "Dunstabzugshaube",
|
||||
"furn.cat_kitchen_cabinet": "Küchenschränke",
|
||||
"furn.cat_kitchen_sink": "Küchenspülen",
|
||||
"furn.cat_nightstand": "Schränke",
|
||||
"furn.cat_oven": "Backofen",
|
||||
"furn.cat_plant": "Pflanzen",
|
||||
"furn.cat_rug": "Teppiche",
|
||||
"furn.cat_shelving": "Regale",
|
||||
"furn.cat_shower": "Duschen",
|
||||
"furn.cat_sink": "Waschbecken",
|
||||
"furn.cat_sofa": "Sofas",
|
||||
"furn.cat_stairs": "Treppen",
|
||||
"furn.cat_toilet": "Toiletten",
|
||||
"furn.cat_tv": "Fernseher",
|
||||
"furn.cat_wardrobe": "Kleiderschränke",
|
||||
"furn.cat_washer": "Waschmaschinen",
|
||||
"furn.cat_work_table": "Schreibtische",
|
||||
"furn.sym_exercise": "Fitnessgerät",
|
||||
"furn.sym_computer": "Computer",
|
||||
"furn.sym_hood": "Dunstabzugshaube",
|
||||
"furn.sym_oven": "Backofen",
|
||||
"furn.sym_sofa": "Zweisitzer-Sofa",
|
||||
"furn.sym_sofa_three_seat": "Dreisitzer-Sofa",
|
||||
"furn.sym_sofa_corner_right": "Ecksofa rechts",
|
||||
"furn.sym_armchair": "Polstersessel",
|
||||
"furn.sym_armchair_office": "Bürostuhl",
|
||||
"furn.sym_coffee_table": "Rechteckiger Couchtisch",
|
||||
"furn.sym_coffee_table_round": "Runder Couchtisch",
|
||||
"furn.sym_coffee_table_oval": "Ovaler Couchtisch",
|
||||
"furn.sym_coffee_table_rounded": "Abgerundeter Couchtisch",
|
||||
"furn.sym_table_dining": "Rechteckiger Esstisch",
|
||||
"furn.sym_table_round": "Rundtisch",
|
||||
"furn.sym_table_dining_oval": "Ovaler Esstisch",
|
||||
"furn.sym_table_dining_rounded": "Abgerundeter Esstisch",
|
||||
"furn.sym_chair": "Stuhl",
|
||||
"furn.sym_chair_bar": "Barhocker",
|
||||
"furn.sym_desk": "Rechteckiger Schreibtisch",
|
||||
"furn.sym_desk_corner": "Eckschreibtisch",
|
||||
"furn.sym_bed_double": "Doppelbett",
|
||||
"furn.sym_bed_single": "Einzelbett",
|
||||
"furn.sym_nightstand": "Nachttisch",
|
||||
"furn.sym_cabinet_tv": "TV-Schrank",
|
||||
"furn.sym_cabinet_shoe": "Schuhschrank",
|
||||
"furn.sym_cabinet_sink": "Waschtischunterschrank",
|
||||
"furn.sym_wardrobe": "Kleiderschrank",
|
||||
"furn.sym_bookshelf": "Bücherschrank",
|
||||
"furn.sym_wall_unit": "Schrankwand",
|
||||
"furn.sym_kitchen_floor": "Küchen-Unterschrank",
|
||||
"furn.sym_kitchen_floor_corner": "Küchen-Eckunterschrank",
|
||||
"furn.sym_kitchen_wall": "Küchen-Hängeschrank",
|
||||
"furn.sym_kitchen_wall_corner": "Küchen-Eckhängeschrank",
|
||||
"furn.sym_shelf_floor": "Standregal",
|
||||
"furn.sym_shelf_wall": "Wandregal",
|
||||
"furn.sym_fridge": "Kühlschrank",
|
||||
"furn.sym_stove": "Vierflammiges Kochfeld",
|
||||
"furn.sym_cooktop_two": "Zweiflammiges Kochfeld",
|
||||
"furn.sym_dishwasher": "Geschirrspüler",
|
||||
"furn.sym_washer": "Waschmaschine",
|
||||
"furn.sym_dryer": "Wäschetrockner",
|
||||
"furn.sym_tv": "Fernseher mit Standfuß",
|
||||
"furn.sym_tv_wall": "Wandfernseher",
|
||||
"furn.sym_ac": "Klimaanlage",
|
||||
"furn.sym_water_heater": "Wassererhitzer",
|
||||
"furn.sym_toilet": "Stand-WC",
|
||||
"furn.sym_toilet_built_in": "Wand-WC",
|
||||
"furn.sym_bathtub": "Rechteckige Badewanne",
|
||||
"furn.sym_bathtub_corner": "Eckbadewanne",
|
||||
"furn.sym_shower": "Dusche",
|
||||
"furn.sym_sink": "Waschbecken",
|
||||
"furn.sym_kitchen_sink": "Einzel-Küchenspüle",
|
||||
"furn.sym_kitchen_sink_double": "Doppel-Küchenspüle",
|
||||
"furn.sym_bidet": "Stand-Bidet",
|
||||
"furn.sym_bidet_built_in": "Wand-Bidet",
|
||||
"furn.sym_stairs": "Treppe",
|
||||
"furn.sym_fireplace": "Kamin",
|
||||
"furn.sym_plant": "Pflanze",
|
||||
"furn.sym_rug": "Teppich",
|
||||
"common.yes": "Ja",
|
||||
"common.no": "Nein",
|
||||
"vac.diag_source": "Quelle",
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
import { SETTINGS_LANGUAGE_RUNTIME } from './settings';
|
||||
import { SUPPORT_LANGUAGE_RUNTIME } from './support';
|
||||
import { TOPOLOGY_LANGUAGE_RUNTIME } from './topology';
|
||||
import { TOOLS_LANGUAGE_RUNTIME } from './tools';
|
||||
import { surfaceLanguageRuntime } from './namespace-language';
|
||||
|
||||
export const EDITOR_LANGUAGE_RUNTIME = surfaceLanguageRuntime([
|
||||
SETTINGS_LANGUAGE_RUNTIME, SUPPORT_LANGUAGE_RUNTIME, TOPOLOGY_LANGUAGE_RUNTIME,
|
||||
TOOLS_LANGUAGE_RUNTIME,
|
||||
]);
|
||||
|
||||
@@ -1116,110 +1116,6 @@
|
||||
"decor.image_in_use": "Remove every placed copy before deleting this image.",
|
||||
"decor.image_delete_title": "Delete image file?",
|
||||
"decor.image_delete_message": "Delete “{name}” from House Plan? This cannot be undone.",
|
||||
"furn.title": "Furniture library",
|
||||
"furn.symbol": "Symbol",
|
||||
"furn.group_furniture": "Furniture",
|
||||
"furn.group_appliance": "Appliances",
|
||||
"furn.group_sanitary": "Plumbing",
|
||||
"furn.group_other": "Other",
|
||||
"furn.width": "Width",
|
||||
"furn.depth": "Depth",
|
||||
"furn.back_to_categories": "All categories",
|
||||
"furn.pick_hint": "Choose a category, then a symbol.",
|
||||
"furn.place_hint": "Click on the plan — the piece lands against the nearest wall. Shift places it free.",
|
||||
"furn.cat_air_conditioner": "Air conditioners",
|
||||
"furn.cat_armchair": "Armchairs",
|
||||
"furn.cat_bathtub": "Bathtubs",
|
||||
"furn.cat_bed": "Beds",
|
||||
"furn.cat_bidet": "Bidets",
|
||||
"furn.cat_boiler": "Water heaters",
|
||||
"furn.cat_chair": "Chairs",
|
||||
"furn.cat_coffee_table": "Coffee tables",
|
||||
"furn.cat_computer": "Computer",
|
||||
"furn.cat_cooktop": "Cooktops",
|
||||
"furn.cat_dining_table": "Dining tables",
|
||||
"furn.cat_dishwasher": "Dishwashers",
|
||||
"furn.cat_dryer": "Dryers",
|
||||
"furn.cat_exercise": "Exercise equipment",
|
||||
"furn.cat_fireplace": "Fireplaces",
|
||||
"furn.cat_fridge": "Refrigerators",
|
||||
"furn.cat_hood": "Cooker hood",
|
||||
"furn.cat_kitchen_cabinet": "Kitchen cabinets",
|
||||
"furn.cat_kitchen_sink": "Kitchen sinks",
|
||||
"furn.cat_nightstand": "Cabinets",
|
||||
"furn.cat_oven": "Oven",
|
||||
"furn.cat_plant": "Plants",
|
||||
"furn.cat_rug": "Rugs",
|
||||
"furn.cat_shelving": "Shelving",
|
||||
"furn.cat_shower": "Showers",
|
||||
"furn.cat_sink": "Sinks",
|
||||
"furn.cat_sofa": "Sofas",
|
||||
"furn.cat_stairs": "Stairs",
|
||||
"furn.cat_toilet": "Toilets",
|
||||
"furn.cat_tv": "Televisions",
|
||||
"furn.cat_wardrobe": "Wardrobes",
|
||||
"furn.cat_washer": "Washing machines",
|
||||
"furn.cat_work_table": "Desks",
|
||||
"furn.sym_exercise": "Exercise equipment",
|
||||
"furn.sym_computer": "Computer",
|
||||
"furn.sym_hood": "Cooker hood",
|
||||
"furn.sym_oven": "Oven",
|
||||
"furn.sym_sofa": "Two-seat sofa",
|
||||
"furn.sym_sofa_three_seat": "Three-seat sofa",
|
||||
"furn.sym_sofa_corner_right": "Right sectional sofa",
|
||||
"furn.sym_armchair": "Soft armchair",
|
||||
"furn.sym_armchair_office": "Office chair",
|
||||
"furn.sym_coffee_table": "Rectangular coffee table",
|
||||
"furn.sym_coffee_table_round": "Round coffee table",
|
||||
"furn.sym_coffee_table_oval": "Oval coffee table",
|
||||
"furn.sym_coffee_table_rounded": "Rounded coffee table",
|
||||
"furn.sym_table_dining": "Rectangular dining table",
|
||||
"furn.sym_table_round": "Round table",
|
||||
"furn.sym_table_dining_oval": "Oval dining table",
|
||||
"furn.sym_table_dining_rounded": "Rounded dining table",
|
||||
"furn.sym_chair": "Chair",
|
||||
"furn.sym_chair_bar": "Bar stool",
|
||||
"furn.sym_desk": "Rectangular desk",
|
||||
"furn.sym_desk_corner": "Corner desk",
|
||||
"furn.sym_bed_double": "Double bed",
|
||||
"furn.sym_bed_single": "Single bed",
|
||||
"furn.sym_nightstand": "Bedside table",
|
||||
"furn.sym_cabinet_tv": "TV cabinet",
|
||||
"furn.sym_cabinet_shoe": "Shoe cabinet",
|
||||
"furn.sym_cabinet_sink": "Sink cabinet",
|
||||
"furn.sym_wardrobe": "Wardrobe",
|
||||
"furn.sym_bookshelf": "Bookcase",
|
||||
"furn.sym_wall_unit": "Wall unit",
|
||||
"furn.sym_kitchen_floor": "Kitchen floor module",
|
||||
"furn.sym_kitchen_floor_corner": "Kitchen floor corner module",
|
||||
"furn.sym_kitchen_wall": "Kitchen wall module",
|
||||
"furn.sym_kitchen_wall_corner": "Kitchen wall corner module",
|
||||
"furn.sym_shelf_floor": "Floor shelving unit",
|
||||
"furn.sym_shelf_wall": "Wall shelf",
|
||||
"furn.sym_fridge": "Fridge",
|
||||
"furn.sym_stove": "Four-burner cooktop",
|
||||
"furn.sym_cooktop_two": "Two-burner cooktop",
|
||||
"furn.sym_dishwasher": "Dishwasher",
|
||||
"furn.sym_washer": "Washing machine",
|
||||
"furn.sym_dryer": "Tumble dryer",
|
||||
"furn.sym_tv": "TV on stand",
|
||||
"furn.sym_tv_wall": "Wall-mounted TV",
|
||||
"furn.sym_ac": "Air conditioner",
|
||||
"furn.sym_water_heater": "Water heater",
|
||||
"furn.sym_toilet": "Floor-standing toilet",
|
||||
"furn.sym_toilet_built_in": "Built-in toilet",
|
||||
"furn.sym_bathtub": "Rectangular bathtub",
|
||||
"furn.sym_bathtub_corner": "Corner bathtub",
|
||||
"furn.sym_shower": "Shower",
|
||||
"furn.sym_sink": "Washbasin",
|
||||
"furn.sym_kitchen_sink": "Single kitchen sink",
|
||||
"furn.sym_kitchen_sink_double": "Double kitchen sink",
|
||||
"furn.sym_bidet": "Floor-standing bidet",
|
||||
"furn.sym_bidet_built_in": "Built-in bidet",
|
||||
"furn.sym_stairs": "Stairs",
|
||||
"furn.sym_fireplace": "Fireplace",
|
||||
"furn.sym_plant": "Plant",
|
||||
"furn.sym_rug": "Rug",
|
||||
"common.yes": "Yes",
|
||||
"common.no": "No",
|
||||
"vac.diag_source": "Source",
|
||||
|
||||
@@ -1116,110 +1116,6 @@
|
||||
"decor.image_in_use": "Supprimez toutes les copies placées avant ce fichier.",
|
||||
"decor.image_delete_title": "Supprimer le fichier image ?",
|
||||
"decor.image_delete_message": "Supprimer « {name} » de House Plan ? Cette action est irréversible.",
|
||||
"furn.title": "Bibliothèque de mobilier",
|
||||
"furn.symbol": "Symbole",
|
||||
"furn.group_furniture": "Mobilier",
|
||||
"furn.group_appliance": "Électroménager",
|
||||
"furn.group_sanitary": "Sanitaires",
|
||||
"furn.group_other": "Autre",
|
||||
"furn.width": "Largeur",
|
||||
"furn.depth": "Profondeur",
|
||||
"furn.back_to_categories": "Toutes les catégories",
|
||||
"furn.pick_hint": "Choisissez un symbole, puis cliquez sur le plan.",
|
||||
"furn.place_hint": "Cliquez sur le plan — le meuble se place contre le mur le plus proche. Maj permet un placement libre.",
|
||||
"furn.cat_air_conditioner": "Climatiseurs",
|
||||
"furn.cat_armchair": "Fauteuils",
|
||||
"furn.cat_bathtub": "Baignoires",
|
||||
"furn.cat_bed": "Lits",
|
||||
"furn.cat_bidet": "Bidets",
|
||||
"furn.cat_boiler": "Chauffe-eau",
|
||||
"furn.cat_chair": "Chaises",
|
||||
"furn.cat_coffee_table": "Tables basses",
|
||||
"furn.cat_computer": "Ordinateur",
|
||||
"furn.cat_cooktop": "Plaques de cuisson",
|
||||
"furn.cat_dining_table": "Tables à manger",
|
||||
"furn.cat_dishwasher": "Lave-vaisselle",
|
||||
"furn.cat_dryer": "Sèche-linge",
|
||||
"furn.cat_exercise": "Équipement de sport",
|
||||
"furn.cat_fireplace": "Cheminées",
|
||||
"furn.cat_fridge": "Réfrigérateurs",
|
||||
"furn.cat_hood": "Hotte",
|
||||
"furn.cat_kitchen_cabinet": "Meubles de cuisine",
|
||||
"furn.cat_kitchen_sink": "Éviers de cuisine",
|
||||
"furn.cat_nightstand": "Meubles",
|
||||
"furn.cat_oven": "Four",
|
||||
"furn.cat_plant": "Plantes",
|
||||
"furn.cat_rug": "Tapis",
|
||||
"furn.cat_shelving": "Étagères",
|
||||
"furn.cat_shower": "Douches",
|
||||
"furn.cat_sink": "Lavabos",
|
||||
"furn.cat_sofa": "Canapés",
|
||||
"furn.cat_stairs": "Escaliers",
|
||||
"furn.cat_toilet": "Toilettes",
|
||||
"furn.cat_tv": "Téléviseurs",
|
||||
"furn.cat_wardrobe": "Armoires",
|
||||
"furn.cat_washer": "Lave-linge",
|
||||
"furn.cat_work_table": "Bureaux",
|
||||
"furn.sym_exercise": "Équipement de sport",
|
||||
"furn.sym_computer": "Ordinateur",
|
||||
"furn.sym_hood": "Hotte",
|
||||
"furn.sym_oven": "Four",
|
||||
"furn.sym_sofa": "Canapé",
|
||||
"furn.sym_sofa_three_seat": "Canapé trois places",
|
||||
"furn.sym_sofa_corner_right": "Canapé d'angle droit",
|
||||
"furn.sym_armchair": "Fauteuil",
|
||||
"furn.sym_armchair_office": "Chaise de bureau",
|
||||
"furn.sym_coffee_table": "Table basse",
|
||||
"furn.sym_coffee_table_round": "Table basse ronde",
|
||||
"furn.sym_coffee_table_oval": "Table basse ovale",
|
||||
"furn.sym_coffee_table_rounded": "Table basse arrondie",
|
||||
"furn.sym_table_dining": "Table à manger",
|
||||
"furn.sym_table_round": "Table ronde",
|
||||
"furn.sym_table_dining_oval": "Table à manger ovale",
|
||||
"furn.sym_table_dining_rounded": "Table à manger arrondie",
|
||||
"furn.sym_chair": "Chaise",
|
||||
"furn.sym_chair_bar": "Tabouret de bar",
|
||||
"furn.sym_desk": "Bureau",
|
||||
"furn.sym_desk_corner": "Bureau d'angle",
|
||||
"furn.sym_bed_double": "Lit double",
|
||||
"furn.sym_bed_single": "Lit simple",
|
||||
"furn.sym_nightstand": "Table de chevet",
|
||||
"furn.sym_cabinet_tv": "Meuble TV",
|
||||
"furn.sym_cabinet_shoe": "Meuble à chaussures",
|
||||
"furn.sym_cabinet_sink": "Meuble sous lavabo",
|
||||
"furn.sym_wardrobe": "Armoire",
|
||||
"furn.sym_bookshelf": "Bibliothèque",
|
||||
"furn.sym_wall_unit": "Élément mural",
|
||||
"furn.sym_kitchen_floor": "Module de cuisine bas",
|
||||
"furn.sym_kitchen_floor_corner": "Module de cuisine bas d'angle",
|
||||
"furn.sym_kitchen_wall": "Module de cuisine haut",
|
||||
"furn.sym_kitchen_wall_corner": "Module de cuisine haut d'angle",
|
||||
"furn.sym_shelf_floor": "Étagère au sol",
|
||||
"furn.sym_shelf_wall": "Étagère murale",
|
||||
"furn.sym_fridge": "Réfrigérateur",
|
||||
"furn.sym_stove": "Cuisinière",
|
||||
"furn.sym_cooktop_two": "Plaque à deux feux",
|
||||
"furn.sym_dishwasher": "Lave-vaisselle",
|
||||
"furn.sym_washer": "Lave-linge",
|
||||
"furn.sym_dryer": "Sèche-linge",
|
||||
"furn.sym_tv": "Téléviseur",
|
||||
"furn.sym_tv_wall": "Téléviseur mural",
|
||||
"furn.sym_ac": "Climatiseur",
|
||||
"furn.sym_water_heater": "Chauffe-eau",
|
||||
"furn.sym_toilet": "Toilettes",
|
||||
"furn.sym_toilet_built_in": "Toilettes suspendues",
|
||||
"furn.sym_bathtub": "Baignoire",
|
||||
"furn.sym_bathtub_corner": "Baignoire d'angle",
|
||||
"furn.sym_shower": "Douche",
|
||||
"furn.sym_sink": "Lavabo",
|
||||
"furn.sym_kitchen_sink": "Évier de cuisine",
|
||||
"furn.sym_kitchen_sink_double": "Évier de cuisine double",
|
||||
"furn.sym_bidet": "Bidet",
|
||||
"furn.sym_bidet_built_in": "Bidet suspendu",
|
||||
"furn.sym_stairs": "Escalier",
|
||||
"furn.sym_fireplace": "Cheminée",
|
||||
"furn.sym_plant": "Plante",
|
||||
"furn.sym_rug": "Tapis",
|
||||
"common.yes": "Oui",
|
||||
"common.no": "Non",
|
||||
"vac.diag_source": "Source",
|
||||
|
||||
@@ -1116,110 +1116,6 @@
|
||||
"decor.image_in_use": "Перед удалением файла удалите все размещённые копии.",
|
||||
"decor.image_delete_title": "Удалить файл изображения?",
|
||||
"decor.image_delete_message": "Удалить «{name}» из House Plan? Это действие нельзя отменить.",
|
||||
"furn.title": "Библиотека мебели",
|
||||
"furn.symbol": "Символ",
|
||||
"furn.group_furniture": "Мебель",
|
||||
"furn.group_appliance": "Техника",
|
||||
"furn.group_sanitary": "Сантехника",
|
||||
"furn.group_other": "Прочее",
|
||||
"furn.width": "Ширина",
|
||||
"furn.depth": "Глубина",
|
||||
"furn.back_to_categories": "Все категории",
|
||||
"furn.pick_hint": "Выберите категорию, затем символ.",
|
||||
"furn.place_hint": "Кликните по плану — предмет встанет к ближайшей стене. Shift — свободно.",
|
||||
"furn.cat_air_conditioner": "Кондиционеры",
|
||||
"furn.cat_armchair": "Кресла",
|
||||
"furn.cat_bathtub": "Ванны",
|
||||
"furn.cat_bed": "Кровати",
|
||||
"furn.cat_bidet": "Биде",
|
||||
"furn.cat_boiler": "Бойлеры",
|
||||
"furn.cat_chair": "Стулья",
|
||||
"furn.cat_coffee_table": "Журнальные столы",
|
||||
"furn.cat_computer": "Компьютер",
|
||||
"furn.cat_cooktop": "Варочные панели",
|
||||
"furn.cat_dining_table": "Обеденные столы",
|
||||
"furn.cat_dishwasher": "Посудомоечные машины",
|
||||
"furn.cat_dryer": "Сушильные машины",
|
||||
"furn.cat_exercise": "Тренажёр",
|
||||
"furn.cat_fireplace": "Камины",
|
||||
"furn.cat_fridge": "Холодильники",
|
||||
"furn.cat_hood": "Вытяжка",
|
||||
"furn.cat_kitchen_cabinet": "Кухонные шкафы",
|
||||
"furn.cat_kitchen_sink": "Кухонные мойки",
|
||||
"furn.cat_nightstand": "Тумбы",
|
||||
"furn.cat_oven": "Духовка",
|
||||
"furn.cat_plant": "Растения",
|
||||
"furn.cat_rug": "Ковры",
|
||||
"furn.cat_shelving": "Стеллажи",
|
||||
"furn.cat_shower": "Душевые",
|
||||
"furn.cat_sink": "Раковины",
|
||||
"furn.cat_sofa": "Диваны",
|
||||
"furn.cat_stairs": "Лестницы",
|
||||
"furn.cat_toilet": "Унитазы",
|
||||
"furn.cat_tv": "Телевизоры",
|
||||
"furn.cat_wardrobe": "Шкафы",
|
||||
"furn.cat_washer": "Стиральные машины",
|
||||
"furn.cat_work_table": "Рабочие столы",
|
||||
"furn.sym_exercise": "Тренажёр",
|
||||
"furn.sym_computer": "Компьютер",
|
||||
"furn.sym_hood": "Вытяжка",
|
||||
"furn.sym_oven": "Духовка",
|
||||
"furn.sym_sofa": "Диван двухместный",
|
||||
"furn.sym_sofa_three_seat": "Диван трёхместный",
|
||||
"furn.sym_sofa_corner_right": "Угловой диван, правый",
|
||||
"furn.sym_armchair": "Кресло мягкое",
|
||||
"furn.sym_armchair_office": "Кресло офисное",
|
||||
"furn.sym_coffee_table": "Журнальный стол, прямоугольный",
|
||||
"furn.sym_coffee_table_round": "Журнальный стол, круглый",
|
||||
"furn.sym_coffee_table_oval": "Журнальный стол, овальный",
|
||||
"furn.sym_coffee_table_rounded": "Журнальный стол, скруглённый",
|
||||
"furn.sym_table_dining": "Обеденный стол, прямоугольный",
|
||||
"furn.sym_table_round": "Круглый стол",
|
||||
"furn.sym_table_dining_oval": "Обеденный стол, овальный",
|
||||
"furn.sym_table_dining_rounded": "Обеденный стол, скруглённый",
|
||||
"furn.sym_chair": "Стул",
|
||||
"furn.sym_chair_bar": "Барный стул",
|
||||
"furn.sym_desk": "Рабочий стол, прямоугольный",
|
||||
"furn.sym_desk_corner": "Рабочий стол, угловой",
|
||||
"furn.sym_bed_double": "Двуспальная кровать",
|
||||
"furn.sym_bed_single": "Односпальная кровать",
|
||||
"furn.sym_nightstand": "Тумба прикроватная",
|
||||
"furn.sym_cabinet_tv": "Тумба под телевизор",
|
||||
"furn.sym_cabinet_shoe": "Тумба для обуви",
|
||||
"furn.sym_cabinet_sink": "Тумба под раковину",
|
||||
"furn.sym_wardrobe": "Шкаф гардеробный",
|
||||
"furn.sym_bookshelf": "Шкаф книжный",
|
||||
"furn.sym_wall_unit": "Шкаф-стенка",
|
||||
"furn.sym_kitchen_floor": "Кухонный напольный модуль",
|
||||
"furn.sym_kitchen_floor_corner": "Кухонный напольный угловой модуль",
|
||||
"furn.sym_kitchen_wall": "Кухонный навесной модуль",
|
||||
"furn.sym_kitchen_wall_corner": "Кухонный навесной угловой модуль",
|
||||
"furn.sym_shelf_floor": "Стеллаж напольный",
|
||||
"furn.sym_shelf_wall": "Полка настенная",
|
||||
"furn.sym_fridge": "Холодильник",
|
||||
"furn.sym_stove": "Варочная панель, 4 конфорки",
|
||||
"furn.sym_cooktop_two": "Варочная панель, 2 конфорки",
|
||||
"furn.sym_dishwasher": "Посудомоечная машина",
|
||||
"furn.sym_washer": "Стиральная машина",
|
||||
"furn.sym_dryer": "Сушильная машина",
|
||||
"furn.sym_tv": "Телевизор на подставке",
|
||||
"furn.sym_tv_wall": "Телевизор на кронштейне",
|
||||
"furn.sym_ac": "Кондиционер",
|
||||
"furn.sym_water_heater": "Бойлер",
|
||||
"furn.sym_toilet": "Унитаз напольный",
|
||||
"furn.sym_toilet_built_in": "Унитаз встроенный",
|
||||
"furn.sym_bathtub": "Ванна прямоугольная",
|
||||
"furn.sym_bathtub_corner": "Ванна угловая",
|
||||
"furn.sym_shower": "Душ",
|
||||
"furn.sym_sink": "Раковина",
|
||||
"furn.sym_kitchen_sink": "Кухонная мойка одинарная",
|
||||
"furn.sym_kitchen_sink_double": "Кухонная мойка двойная",
|
||||
"furn.sym_bidet": "Биде напольное",
|
||||
"furn.sym_bidet_built_in": "Биде встроенное",
|
||||
"furn.sym_stairs": "Лестница",
|
||||
"furn.sym_fireplace": "Камин",
|
||||
"furn.sym_plant": "Растение",
|
||||
"furn.sym_rug": "Ковёр",
|
||||
"common.yes": "Да",
|
||||
"common.no": "Нет",
|
||||
"vac.diag_source": "Источник",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Lazy dictionary of the plan editors' tools (#780): the furniture library and
|
||||
* the LED strip tool. Приём #627: строки инструментов администратора живут в
|
||||
* ленивом графе редактора, а не в первом кадре View — их перенос из основных
|
||||
* словарей освобождает стартовый граф под LED-ленты (ТЗ #780 §13.1).
|
||||
*
|
||||
* English is static (the synchronous fallback); ru/de/fr are separate lazy
|
||||
* chunks. The editor runtime waits for this runtime before it paints.
|
||||
*/
|
||||
import { subst } from '../logic';
|
||||
import type { Lang } from './registry';
|
||||
import en from './tools/en.json' with { type: 'json' };
|
||||
import { namespaceLanguageRuntime } from './namespace-language';
|
||||
|
||||
export type ToolsI18nKey = keyof typeof en;
|
||||
|
||||
const retryUrl = (asset: string): string => new URL(`${asset}?retry`, import.meta.url).href;
|
||||
|
||||
export const TOOLS_LANGUAGE_RUNTIME = namespaceLanguageRuntime(en, {
|
||||
ru: (attempt) => (attempt === 0 ? import('./tools/tools-ru')
|
||||
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_TOOLS_RU_RETRY_ASSET__'))),
|
||||
de: (attempt) => (attempt === 0 ? import('./tools/tools-de')
|
||||
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_TOOLS_DE_RETRY_ASSET__'))),
|
||||
fr: (attempt) => (attempt === 0 ? import('./tools/tools-fr')
|
||||
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_TOOLS_FR_RETRY_ASSET__'))),
|
||||
});
|
||||
|
||||
export function toolsT(
|
||||
lang: Lang, key: ToolsI18nKey, vars?: Record<string, string | number>,
|
||||
): string {
|
||||
return subst(TOOLS_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key] ?? key, vars);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"furn.title": "Möbelbibliothek",
|
||||
"furn.symbol": "Symbol",
|
||||
"furn.group_furniture": "Möbel",
|
||||
"furn.group_appliance": "Geräte",
|
||||
"furn.group_sanitary": "Sanitär",
|
||||
"furn.group_other": "Sonstiges",
|
||||
"furn.width": "Breite",
|
||||
"furn.depth": "Tiefe",
|
||||
"furn.back_to_categories": "Alle Kategorien",
|
||||
"furn.pick_hint": "Wählen Sie eine Kategorie und dann ein Symbol.",
|
||||
"furn.place_hint": "Klicken Sie auf den Plan — das Element wird an der nächsten Wand platziert. Mit Shift wird es frei platziert.",
|
||||
"furn.cat_air_conditioner": "Klimaanlagen",
|
||||
"furn.cat_armchair": "Sessel",
|
||||
"furn.cat_bathtub": "Badewannen",
|
||||
"furn.cat_bed": "Betten",
|
||||
"furn.cat_bidet": "Bidets",
|
||||
"furn.cat_boiler": "Warmwasserbereiter",
|
||||
"furn.cat_chair": "Stühle",
|
||||
"furn.cat_coffee_table": "Couchtische",
|
||||
"furn.cat_computer": "Computer",
|
||||
"furn.cat_cooktop": "Kochfelder",
|
||||
"furn.cat_dining_table": "Esstische",
|
||||
"furn.cat_dishwasher": "Geschirrspüler",
|
||||
"furn.cat_dryer": "Wäschetrockner",
|
||||
"furn.cat_exercise": "Fitnessgerät",
|
||||
"furn.cat_fireplace": "Kamine",
|
||||
"furn.cat_fridge": "Kühlschränke",
|
||||
"furn.cat_hood": "Dunstabzugshaube",
|
||||
"furn.cat_kitchen_cabinet": "Küchenschränke",
|
||||
"furn.cat_kitchen_sink": "Küchenspülen",
|
||||
"furn.cat_nightstand": "Schränke",
|
||||
"furn.cat_oven": "Backofen",
|
||||
"furn.cat_plant": "Pflanzen",
|
||||
"furn.cat_rug": "Teppiche",
|
||||
"furn.cat_shelving": "Regale",
|
||||
"furn.cat_shower": "Duschen",
|
||||
"furn.cat_sink": "Waschbecken",
|
||||
"furn.cat_sofa": "Sofas",
|
||||
"furn.cat_stairs": "Treppen",
|
||||
"furn.cat_toilet": "Toiletten",
|
||||
"furn.cat_tv": "Fernseher",
|
||||
"furn.cat_wardrobe": "Kleiderschränke",
|
||||
"furn.cat_washer": "Waschmaschinen",
|
||||
"furn.cat_work_table": "Schreibtische",
|
||||
"furn.sym_exercise": "Fitnessgerät",
|
||||
"furn.sym_computer": "Computer",
|
||||
"furn.sym_hood": "Dunstabzugshaube",
|
||||
"furn.sym_oven": "Backofen",
|
||||
"furn.sym_sofa": "Zweisitzer-Sofa",
|
||||
"furn.sym_sofa_three_seat": "Dreisitzer-Sofa",
|
||||
"furn.sym_sofa_corner_right": "Ecksofa rechts",
|
||||
"furn.sym_armchair": "Polstersessel",
|
||||
"furn.sym_armchair_office": "Bürostuhl",
|
||||
"furn.sym_coffee_table": "Rechteckiger Couchtisch",
|
||||
"furn.sym_coffee_table_round": "Runder Couchtisch",
|
||||
"furn.sym_coffee_table_oval": "Ovaler Couchtisch",
|
||||
"furn.sym_coffee_table_rounded": "Abgerundeter Couchtisch",
|
||||
"furn.sym_table_dining": "Rechteckiger Esstisch",
|
||||
"furn.sym_table_round": "Rundtisch",
|
||||
"furn.sym_table_dining_oval": "Ovaler Esstisch",
|
||||
"furn.sym_table_dining_rounded": "Abgerundeter Esstisch",
|
||||
"furn.sym_chair": "Stuhl",
|
||||
"furn.sym_chair_bar": "Barhocker",
|
||||
"furn.sym_desk": "Rechteckiger Schreibtisch",
|
||||
"furn.sym_desk_corner": "Eckschreibtisch",
|
||||
"furn.sym_bed_double": "Doppelbett",
|
||||
"furn.sym_bed_single": "Einzelbett",
|
||||
"furn.sym_nightstand": "Nachttisch",
|
||||
"furn.sym_cabinet_tv": "TV-Schrank",
|
||||
"furn.sym_cabinet_shoe": "Schuhschrank",
|
||||
"furn.sym_cabinet_sink": "Waschtischunterschrank",
|
||||
"furn.sym_wardrobe": "Kleiderschrank",
|
||||
"furn.sym_bookshelf": "Bücherschrank",
|
||||
"furn.sym_wall_unit": "Schrankwand",
|
||||
"furn.sym_kitchen_floor": "Küchen-Unterschrank",
|
||||
"furn.sym_kitchen_floor_corner": "Küchen-Eckunterschrank",
|
||||
"furn.sym_kitchen_wall": "Küchen-Hängeschrank",
|
||||
"furn.sym_kitchen_wall_corner": "Küchen-Eckhängeschrank",
|
||||
"furn.sym_shelf_floor": "Standregal",
|
||||
"furn.sym_shelf_wall": "Wandregal",
|
||||
"furn.sym_fridge": "Kühlschrank",
|
||||
"furn.sym_stove": "Vierflammiges Kochfeld",
|
||||
"furn.sym_cooktop_two": "Zweiflammiges Kochfeld",
|
||||
"furn.sym_dishwasher": "Geschirrspüler",
|
||||
"furn.sym_washer": "Waschmaschine",
|
||||
"furn.sym_dryer": "Wäschetrockner",
|
||||
"furn.sym_tv": "Fernseher mit Standfuß",
|
||||
"furn.sym_tv_wall": "Wandfernseher",
|
||||
"furn.sym_ac": "Klimaanlage",
|
||||
"furn.sym_water_heater": "Wassererhitzer",
|
||||
"furn.sym_toilet": "Stand-WC",
|
||||
"furn.sym_toilet_built_in": "Wand-WC",
|
||||
"furn.sym_bathtub": "Rechteckige Badewanne",
|
||||
"furn.sym_bathtub_corner": "Eckbadewanne",
|
||||
"furn.sym_shower": "Dusche",
|
||||
"furn.sym_sink": "Waschbecken",
|
||||
"furn.sym_kitchen_sink": "Einzel-Küchenspüle",
|
||||
"furn.sym_kitchen_sink_double": "Doppel-Küchenspüle",
|
||||
"furn.sym_bidet": "Stand-Bidet",
|
||||
"furn.sym_bidet_built_in": "Wand-Bidet",
|
||||
"furn.sym_stairs": "Treppe",
|
||||
"furn.sym_fireplace": "Kamin",
|
||||
"furn.sym_plant": "Pflanze",
|
||||
"furn.sym_rug": "Teppich"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"furn.title": "Furniture library",
|
||||
"furn.symbol": "Symbol",
|
||||
"furn.group_furniture": "Furniture",
|
||||
"furn.group_appliance": "Appliances",
|
||||
"furn.group_sanitary": "Plumbing",
|
||||
"furn.group_other": "Other",
|
||||
"furn.width": "Width",
|
||||
"furn.depth": "Depth",
|
||||
"furn.back_to_categories": "All categories",
|
||||
"furn.pick_hint": "Choose a category, then a symbol.",
|
||||
"furn.place_hint": "Click on the plan — the piece lands against the nearest wall. Shift places it free.",
|
||||
"furn.cat_air_conditioner": "Air conditioners",
|
||||
"furn.cat_armchair": "Armchairs",
|
||||
"furn.cat_bathtub": "Bathtubs",
|
||||
"furn.cat_bed": "Beds",
|
||||
"furn.cat_bidet": "Bidets",
|
||||
"furn.cat_boiler": "Water heaters",
|
||||
"furn.cat_chair": "Chairs",
|
||||
"furn.cat_coffee_table": "Coffee tables",
|
||||
"furn.cat_computer": "Computer",
|
||||
"furn.cat_cooktop": "Cooktops",
|
||||
"furn.cat_dining_table": "Dining tables",
|
||||
"furn.cat_dishwasher": "Dishwashers",
|
||||
"furn.cat_dryer": "Dryers",
|
||||
"furn.cat_exercise": "Exercise equipment",
|
||||
"furn.cat_fireplace": "Fireplaces",
|
||||
"furn.cat_fridge": "Refrigerators",
|
||||
"furn.cat_hood": "Cooker hood",
|
||||
"furn.cat_kitchen_cabinet": "Kitchen cabinets",
|
||||
"furn.cat_kitchen_sink": "Kitchen sinks",
|
||||
"furn.cat_nightstand": "Cabinets",
|
||||
"furn.cat_oven": "Oven",
|
||||
"furn.cat_plant": "Plants",
|
||||
"furn.cat_rug": "Rugs",
|
||||
"furn.cat_shelving": "Shelving",
|
||||
"furn.cat_shower": "Showers",
|
||||
"furn.cat_sink": "Sinks",
|
||||
"furn.cat_sofa": "Sofas",
|
||||
"furn.cat_stairs": "Stairs",
|
||||
"furn.cat_toilet": "Toilets",
|
||||
"furn.cat_tv": "Televisions",
|
||||
"furn.cat_wardrobe": "Wardrobes",
|
||||
"furn.cat_washer": "Washing machines",
|
||||
"furn.cat_work_table": "Desks",
|
||||
"furn.sym_exercise": "Exercise equipment",
|
||||
"furn.sym_computer": "Computer",
|
||||
"furn.sym_hood": "Cooker hood",
|
||||
"furn.sym_oven": "Oven",
|
||||
"furn.sym_sofa": "Two-seat sofa",
|
||||
"furn.sym_sofa_three_seat": "Three-seat sofa",
|
||||
"furn.sym_sofa_corner_right": "Right sectional sofa",
|
||||
"furn.sym_armchair": "Soft armchair",
|
||||
"furn.sym_armchair_office": "Office chair",
|
||||
"furn.sym_coffee_table": "Rectangular coffee table",
|
||||
"furn.sym_coffee_table_round": "Round coffee table",
|
||||
"furn.sym_coffee_table_oval": "Oval coffee table",
|
||||
"furn.sym_coffee_table_rounded": "Rounded coffee table",
|
||||
"furn.sym_table_dining": "Rectangular dining table",
|
||||
"furn.sym_table_round": "Round table",
|
||||
"furn.sym_table_dining_oval": "Oval dining table",
|
||||
"furn.sym_table_dining_rounded": "Rounded dining table",
|
||||
"furn.sym_chair": "Chair",
|
||||
"furn.sym_chair_bar": "Bar stool",
|
||||
"furn.sym_desk": "Rectangular desk",
|
||||
"furn.sym_desk_corner": "Corner desk",
|
||||
"furn.sym_bed_double": "Double bed",
|
||||
"furn.sym_bed_single": "Single bed",
|
||||
"furn.sym_nightstand": "Bedside table",
|
||||
"furn.sym_cabinet_tv": "TV cabinet",
|
||||
"furn.sym_cabinet_shoe": "Shoe cabinet",
|
||||
"furn.sym_cabinet_sink": "Sink cabinet",
|
||||
"furn.sym_wardrobe": "Wardrobe",
|
||||
"furn.sym_bookshelf": "Bookcase",
|
||||
"furn.sym_wall_unit": "Wall unit",
|
||||
"furn.sym_kitchen_floor": "Kitchen floor module",
|
||||
"furn.sym_kitchen_floor_corner": "Kitchen floor corner module",
|
||||
"furn.sym_kitchen_wall": "Kitchen wall module",
|
||||
"furn.sym_kitchen_wall_corner": "Kitchen wall corner module",
|
||||
"furn.sym_shelf_floor": "Floor shelving unit",
|
||||
"furn.sym_shelf_wall": "Wall shelf",
|
||||
"furn.sym_fridge": "Fridge",
|
||||
"furn.sym_stove": "Four-burner cooktop",
|
||||
"furn.sym_cooktop_two": "Two-burner cooktop",
|
||||
"furn.sym_dishwasher": "Dishwasher",
|
||||
"furn.sym_washer": "Washing machine",
|
||||
"furn.sym_dryer": "Tumble dryer",
|
||||
"furn.sym_tv": "TV on stand",
|
||||
"furn.sym_tv_wall": "Wall-mounted TV",
|
||||
"furn.sym_ac": "Air conditioner",
|
||||
"furn.sym_water_heater": "Water heater",
|
||||
"furn.sym_toilet": "Floor-standing toilet",
|
||||
"furn.sym_toilet_built_in": "Built-in toilet",
|
||||
"furn.sym_bathtub": "Rectangular bathtub",
|
||||
"furn.sym_bathtub_corner": "Corner bathtub",
|
||||
"furn.sym_shower": "Shower",
|
||||
"furn.sym_sink": "Washbasin",
|
||||
"furn.sym_kitchen_sink": "Single kitchen sink",
|
||||
"furn.sym_kitchen_sink_double": "Double kitchen sink",
|
||||
"furn.sym_bidet": "Floor-standing bidet",
|
||||
"furn.sym_bidet_built_in": "Built-in bidet",
|
||||
"furn.sym_stairs": "Stairs",
|
||||
"furn.sym_fireplace": "Fireplace",
|
||||
"furn.sym_plant": "Plant",
|
||||
"furn.sym_rug": "Rug"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"furn.title": "Bibliothèque de mobilier",
|
||||
"furn.symbol": "Symbole",
|
||||
"furn.group_furniture": "Mobilier",
|
||||
"furn.group_appliance": "Électroménager",
|
||||
"furn.group_sanitary": "Sanitaires",
|
||||
"furn.group_other": "Autre",
|
||||
"furn.width": "Largeur",
|
||||
"furn.depth": "Profondeur",
|
||||
"furn.back_to_categories": "Toutes les catégories",
|
||||
"furn.pick_hint": "Choisissez un symbole, puis cliquez sur le plan.",
|
||||
"furn.place_hint": "Cliquez sur le plan — le meuble se place contre le mur le plus proche. Maj permet un placement libre.",
|
||||
"furn.cat_air_conditioner": "Climatiseurs",
|
||||
"furn.cat_armchair": "Fauteuils",
|
||||
"furn.cat_bathtub": "Baignoires",
|
||||
"furn.cat_bed": "Lits",
|
||||
"furn.cat_bidet": "Bidets",
|
||||
"furn.cat_boiler": "Chauffe-eau",
|
||||
"furn.cat_chair": "Chaises",
|
||||
"furn.cat_coffee_table": "Tables basses",
|
||||
"furn.cat_computer": "Ordinateur",
|
||||
"furn.cat_cooktop": "Plaques de cuisson",
|
||||
"furn.cat_dining_table": "Tables à manger",
|
||||
"furn.cat_dishwasher": "Lave-vaisselle",
|
||||
"furn.cat_dryer": "Sèche-linge",
|
||||
"furn.cat_exercise": "Équipement de sport",
|
||||
"furn.cat_fireplace": "Cheminées",
|
||||
"furn.cat_fridge": "Réfrigérateurs",
|
||||
"furn.cat_hood": "Hotte",
|
||||
"furn.cat_kitchen_cabinet": "Meubles de cuisine",
|
||||
"furn.cat_kitchen_sink": "Éviers de cuisine",
|
||||
"furn.cat_nightstand": "Meubles",
|
||||
"furn.cat_oven": "Four",
|
||||
"furn.cat_plant": "Plantes",
|
||||
"furn.cat_rug": "Tapis",
|
||||
"furn.cat_shelving": "Étagères",
|
||||
"furn.cat_shower": "Douches",
|
||||
"furn.cat_sink": "Lavabos",
|
||||
"furn.cat_sofa": "Canapés",
|
||||
"furn.cat_stairs": "Escaliers",
|
||||
"furn.cat_toilet": "Toilettes",
|
||||
"furn.cat_tv": "Téléviseurs",
|
||||
"furn.cat_wardrobe": "Armoires",
|
||||
"furn.cat_washer": "Lave-linge",
|
||||
"furn.cat_work_table": "Bureaux",
|
||||
"furn.sym_exercise": "Équipement de sport",
|
||||
"furn.sym_computer": "Ordinateur",
|
||||
"furn.sym_hood": "Hotte",
|
||||
"furn.sym_oven": "Four",
|
||||
"furn.sym_sofa": "Canapé",
|
||||
"furn.sym_sofa_three_seat": "Canapé trois places",
|
||||
"furn.sym_sofa_corner_right": "Canapé d'angle droit",
|
||||
"furn.sym_armchair": "Fauteuil",
|
||||
"furn.sym_armchair_office": "Chaise de bureau",
|
||||
"furn.sym_coffee_table": "Table basse",
|
||||
"furn.sym_coffee_table_round": "Table basse ronde",
|
||||
"furn.sym_coffee_table_oval": "Table basse ovale",
|
||||
"furn.sym_coffee_table_rounded": "Table basse arrondie",
|
||||
"furn.sym_table_dining": "Table à manger",
|
||||
"furn.sym_table_round": "Table ronde",
|
||||
"furn.sym_table_dining_oval": "Table à manger ovale",
|
||||
"furn.sym_table_dining_rounded": "Table à manger arrondie",
|
||||
"furn.sym_chair": "Chaise",
|
||||
"furn.sym_chair_bar": "Tabouret de bar",
|
||||
"furn.sym_desk": "Bureau",
|
||||
"furn.sym_desk_corner": "Bureau d'angle",
|
||||
"furn.sym_bed_double": "Lit double",
|
||||
"furn.sym_bed_single": "Lit simple",
|
||||
"furn.sym_nightstand": "Table de chevet",
|
||||
"furn.sym_cabinet_tv": "Meuble TV",
|
||||
"furn.sym_cabinet_shoe": "Meuble à chaussures",
|
||||
"furn.sym_cabinet_sink": "Meuble sous lavabo",
|
||||
"furn.sym_wardrobe": "Armoire",
|
||||
"furn.sym_bookshelf": "Bibliothèque",
|
||||
"furn.sym_wall_unit": "Élément mural",
|
||||
"furn.sym_kitchen_floor": "Module de cuisine bas",
|
||||
"furn.sym_kitchen_floor_corner": "Module de cuisine bas d'angle",
|
||||
"furn.sym_kitchen_wall": "Module de cuisine haut",
|
||||
"furn.sym_kitchen_wall_corner": "Module de cuisine haut d'angle",
|
||||
"furn.sym_shelf_floor": "Étagère au sol",
|
||||
"furn.sym_shelf_wall": "Étagère murale",
|
||||
"furn.sym_fridge": "Réfrigérateur",
|
||||
"furn.sym_stove": "Cuisinière",
|
||||
"furn.sym_cooktop_two": "Plaque à deux feux",
|
||||
"furn.sym_dishwasher": "Lave-vaisselle",
|
||||
"furn.sym_washer": "Lave-linge",
|
||||
"furn.sym_dryer": "Sèche-linge",
|
||||
"furn.sym_tv": "Téléviseur",
|
||||
"furn.sym_tv_wall": "Téléviseur mural",
|
||||
"furn.sym_ac": "Climatiseur",
|
||||
"furn.sym_water_heater": "Chauffe-eau",
|
||||
"furn.sym_toilet": "Toilettes",
|
||||
"furn.sym_toilet_built_in": "Toilettes suspendues",
|
||||
"furn.sym_bathtub": "Baignoire",
|
||||
"furn.sym_bathtub_corner": "Baignoire d'angle",
|
||||
"furn.sym_shower": "Douche",
|
||||
"furn.sym_sink": "Lavabo",
|
||||
"furn.sym_kitchen_sink": "Évier de cuisine",
|
||||
"furn.sym_kitchen_sink_double": "Évier de cuisine double",
|
||||
"furn.sym_bidet": "Bidet",
|
||||
"furn.sym_bidet_built_in": "Bidet suspendu",
|
||||
"furn.sym_stairs": "Escalier",
|
||||
"furn.sym_fireplace": "Cheminée",
|
||||
"furn.sym_plant": "Plante",
|
||||
"furn.sym_rug": "Tapis"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"furn.title": "Библиотека мебели",
|
||||
"furn.symbol": "Символ",
|
||||
"furn.group_furniture": "Мебель",
|
||||
"furn.group_appliance": "Техника",
|
||||
"furn.group_sanitary": "Сантехника",
|
||||
"furn.group_other": "Прочее",
|
||||
"furn.width": "Ширина",
|
||||
"furn.depth": "Глубина",
|
||||
"furn.back_to_categories": "Все категории",
|
||||
"furn.pick_hint": "Выберите категорию, затем символ.",
|
||||
"furn.place_hint": "Кликните по плану — предмет встанет к ближайшей стене. Shift — свободно.",
|
||||
"furn.cat_air_conditioner": "Кондиционеры",
|
||||
"furn.cat_armchair": "Кресла",
|
||||
"furn.cat_bathtub": "Ванны",
|
||||
"furn.cat_bed": "Кровати",
|
||||
"furn.cat_bidet": "Биде",
|
||||
"furn.cat_boiler": "Бойлеры",
|
||||
"furn.cat_chair": "Стулья",
|
||||
"furn.cat_coffee_table": "Журнальные столы",
|
||||
"furn.cat_computer": "Компьютер",
|
||||
"furn.cat_cooktop": "Варочные панели",
|
||||
"furn.cat_dining_table": "Обеденные столы",
|
||||
"furn.cat_dishwasher": "Посудомоечные машины",
|
||||
"furn.cat_dryer": "Сушильные машины",
|
||||
"furn.cat_exercise": "Тренажёр",
|
||||
"furn.cat_fireplace": "Камины",
|
||||
"furn.cat_fridge": "Холодильники",
|
||||
"furn.cat_hood": "Вытяжка",
|
||||
"furn.cat_kitchen_cabinet": "Кухонные шкафы",
|
||||
"furn.cat_kitchen_sink": "Кухонные мойки",
|
||||
"furn.cat_nightstand": "Тумбы",
|
||||
"furn.cat_oven": "Духовка",
|
||||
"furn.cat_plant": "Растения",
|
||||
"furn.cat_rug": "Ковры",
|
||||
"furn.cat_shelving": "Стеллажи",
|
||||
"furn.cat_shower": "Душевые",
|
||||
"furn.cat_sink": "Раковины",
|
||||
"furn.cat_sofa": "Диваны",
|
||||
"furn.cat_stairs": "Лестницы",
|
||||
"furn.cat_toilet": "Унитазы",
|
||||
"furn.cat_tv": "Телевизоры",
|
||||
"furn.cat_wardrobe": "Шкафы",
|
||||
"furn.cat_washer": "Стиральные машины",
|
||||
"furn.cat_work_table": "Рабочие столы",
|
||||
"furn.sym_exercise": "Тренажёр",
|
||||
"furn.sym_computer": "Компьютер",
|
||||
"furn.sym_hood": "Вытяжка",
|
||||
"furn.sym_oven": "Духовка",
|
||||
"furn.sym_sofa": "Диван двухместный",
|
||||
"furn.sym_sofa_three_seat": "Диван трёхместный",
|
||||
"furn.sym_sofa_corner_right": "Угловой диван, правый",
|
||||
"furn.sym_armchair": "Кресло мягкое",
|
||||
"furn.sym_armchair_office": "Кресло офисное",
|
||||
"furn.sym_coffee_table": "Журнальный стол, прямоугольный",
|
||||
"furn.sym_coffee_table_round": "Журнальный стол, круглый",
|
||||
"furn.sym_coffee_table_oval": "Журнальный стол, овальный",
|
||||
"furn.sym_coffee_table_rounded": "Журнальный стол, скруглённый",
|
||||
"furn.sym_table_dining": "Обеденный стол, прямоугольный",
|
||||
"furn.sym_table_round": "Круглый стол",
|
||||
"furn.sym_table_dining_oval": "Обеденный стол, овальный",
|
||||
"furn.sym_table_dining_rounded": "Обеденный стол, скруглённый",
|
||||
"furn.sym_chair": "Стул",
|
||||
"furn.sym_chair_bar": "Барный стул",
|
||||
"furn.sym_desk": "Рабочий стол, прямоугольный",
|
||||
"furn.sym_desk_corner": "Рабочий стол, угловой",
|
||||
"furn.sym_bed_double": "Двуспальная кровать",
|
||||
"furn.sym_bed_single": "Односпальная кровать",
|
||||
"furn.sym_nightstand": "Тумба прикроватная",
|
||||
"furn.sym_cabinet_tv": "Тумба под телевизор",
|
||||
"furn.sym_cabinet_shoe": "Тумба для обуви",
|
||||
"furn.sym_cabinet_sink": "Тумба под раковину",
|
||||
"furn.sym_wardrobe": "Шкаф гардеробный",
|
||||
"furn.sym_bookshelf": "Шкаф книжный",
|
||||
"furn.sym_wall_unit": "Шкаф-стенка",
|
||||
"furn.sym_kitchen_floor": "Кухонный напольный модуль",
|
||||
"furn.sym_kitchen_floor_corner": "Кухонный напольный угловой модуль",
|
||||
"furn.sym_kitchen_wall": "Кухонный навесной модуль",
|
||||
"furn.sym_kitchen_wall_corner": "Кухонный навесной угловой модуль",
|
||||
"furn.sym_shelf_floor": "Стеллаж напольный",
|
||||
"furn.sym_shelf_wall": "Полка настенная",
|
||||
"furn.sym_fridge": "Холодильник",
|
||||
"furn.sym_stove": "Варочная панель, 4 конфорки",
|
||||
"furn.sym_cooktop_two": "Варочная панель, 2 конфорки",
|
||||
"furn.sym_dishwasher": "Посудомоечная машина",
|
||||
"furn.sym_washer": "Стиральная машина",
|
||||
"furn.sym_dryer": "Сушильная машина",
|
||||
"furn.sym_tv": "Телевизор на подставке",
|
||||
"furn.sym_tv_wall": "Телевизор на кронштейне",
|
||||
"furn.sym_ac": "Кондиционер",
|
||||
"furn.sym_water_heater": "Бойлер",
|
||||
"furn.sym_toilet": "Унитаз напольный",
|
||||
"furn.sym_toilet_built_in": "Унитаз встроенный",
|
||||
"furn.sym_bathtub": "Ванна прямоугольная",
|
||||
"furn.sym_bathtub_corner": "Ванна угловая",
|
||||
"furn.sym_shower": "Душ",
|
||||
"furn.sym_sink": "Раковина",
|
||||
"furn.sym_kitchen_sink": "Кухонная мойка одинарная",
|
||||
"furn.sym_kitchen_sink_double": "Кухонная мойка двойная",
|
||||
"furn.sym_bidet": "Биде напольное",
|
||||
"furn.sym_bidet_built_in": "Биде встроенное",
|
||||
"furn.sym_stairs": "Лестница",
|
||||
"furn.sym_fireplace": "Камин",
|
||||
"furn.sym_plant": "Растение",
|
||||
"furn.sym_rug": "Ковёр"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import dictionary from './de.json' with { type: 'json' };
|
||||
|
||||
/** #780: lazy `tools` × `de` chunk; must match the entry build before it is committed. */
|
||||
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
|
||||
export { dictionary };
|
||||
@@ -0,0 +1,6 @@
|
||||
import dictionary from './fr.json' with { type: 'json' };
|
||||
|
||||
/** #780: lazy `tools` × `fr` chunk; must match the entry build before it is committed. */
|
||||
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
|
||||
export { dictionary };
|
||||
@@ -0,0 +1,6 @@
|
||||
import dictionary from './ru.json' with { type: 'json' };
|
||||
|
||||
/** #780: lazy `tools` × `ru` chunk; must match the entry build before it is committed. */
|
||||
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
|
||||
export { dictionary };
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The LED strips' only foothold in the initial View graph (#780, ТЗ §13.1):
|
||||
* which markers a space shows as a strip, the anchor that replaces their icon
|
||||
* position, and one page-wide load of the lazy `led-strip-runtime` chunk.
|
||||
* Geometry, light and hit-testing live in the chunk.
|
||||
*
|
||||
* A strip is represented when it is active and bound; the card additionally
|
||||
* checks the marker is a live device of that space. `active: false` shapes,
|
||||
* unbound strips and an empty array never ask for the chunk. A failed load is
|
||||
* fail-dark for the strips only and is retried on the next explicit entry
|
||||
* (another space or the LED tool) — never in a loop; a chunk of another build
|
||||
* is never installed.
|
||||
*/
|
||||
import { ENTRY_BUILD_FINGERPRINT } from './editor-runtime-loader';
|
||||
import type { LedStripModel, SpaceModel } from './types';
|
||||
|
||||
type LedRuntime = typeof import('./led-strip-runtime');
|
||||
|
||||
let runtime: LedRuntime | null = null;
|
||||
let loading: Promise<unknown> | null = null;
|
||||
/** The entry that failed last (a different one may try again); '' = foreign build. */
|
||||
let failed: string | undefined;
|
||||
|
||||
/** Active, bound strips of a space by marker id. No geometry. */
|
||||
export function ledStripsByMarker(space: SpaceModel | null | undefined): Map<string, LedStripModel> {
|
||||
const out = new Map<string, LedStripModel>();
|
||||
for (const strip of space?.led_strips || []) {
|
||||
if (strip?.active !== false && strip.marker && strip.points?.length > 1) out.set(strip.marker, strip);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The point at half the polyline length (ТЗ §5), in render units (`scale` = NORM_W). */
|
||||
export function ledAnchor(points: readonly number[][], scale: number): { x: number; y: number } {
|
||||
const step = (i: number) => Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]);
|
||||
let left = 0;
|
||||
for (let i = 1; i < points.length; i++) left += step(i) / 2;
|
||||
let i = 1;
|
||||
while (i < points.length - 1 && step(i) < left) left -= step(i++);
|
||||
const t = Math.min(1, left / (step(i) || 1));
|
||||
const a = points[i - 1], b = points[i] || a;
|
||||
return { x: (a[0] + (b[0] - a[0]) * t) * scale, y: (a[1] + (b[1] - a[1]) * t) * scale };
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime when it is here; otherwise one page-wide load and `ready` when
|
||||
* it lands. `entry` names the explicit entry (space id or the LED tool).
|
||||
*/
|
||||
export function ledRuntime(entry: string, ready: () => void): LedRuntime | null {
|
||||
if (!runtime && !loading && failed !== entry && failed !== '') {
|
||||
loading = (failed === undefined
|
||||
? import('./led-strip-runtime')
|
||||
: import(/* @vite-ignore */ new URL(`__HOUSEPLAN_LED_RETRY_ASSET__?${Date.now()}`, import.meta.url).href) as Promise<LedRuntime>
|
||||
).then((module) => {
|
||||
if (module.LED_RUNTIME_FINGERPRINT === ENTRY_BUILD_FINGERPRINT) runtime = module;
|
||||
else failed = '';
|
||||
}, () => { failed = entry; }).finally(() => { loading = null; });
|
||||
}
|
||||
if (!runtime) void loading?.then(() => runtime && ready());
|
||||
return runtime;
|
||||
}
|
||||
Executable
+632
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* LED strips on the plan (#780): the lazy View chunk.
|
||||
*
|
||||
* Loaded only when a shown space has an active strip (`led-strip-gate.ts`).
|
||||
* It owns the stripe (two strokes of one derived path), the linear light field
|
||||
* and the hit path; the card keeps the device model, the actions and the
|
||||
* light state, and passes them in. Nothing here writes configuration.
|
||||
*
|
||||
* The linear 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 therefore never lights through another part's visibility (ТЗ §6), a
|
||||
* closed strip has no seam and a corner no double brightness (ТЗ §3).
|
||||
*/
|
||||
import { nothing, svg, type TemplateResult } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import {
|
||||
GLOW_FALLOFF, buildGlowClipGeometry, resolveGlowCandidates,
|
||||
type GlowCandidate, type LightBarrierScene, type LightRoomPolygon,
|
||||
} from './glow-scene';
|
||||
import { NORM_W, iconUnit } from './space-geometry';
|
||||
import { roomGlowOf, roomPoly } from './logic';
|
||||
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,
|
||||
stripHitRadiusPx, validStripPoints, visibleStripPath,
|
||||
type BodyFace, type FaceContext, type Pt,
|
||||
} from './led-strip-geometry';
|
||||
import type { DevItem, LedStripModel, RoomCfg, SpaceModel } from './types';
|
||||
|
||||
export const LED_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
|
||||
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';
|
||||
|
||||
export type LedVisualState = 'on' | 'off' | 'unavailable';
|
||||
|
||||
/** One strip as the card resolved it for this frame. */
|
||||
export interface LedStripView {
|
||||
strip: LedStripModel;
|
||||
device: DevItem;
|
||||
state: LedVisualState;
|
||||
/** Glow effectively on in the strip's room (space `glow_enabled`, room `glow`). */
|
||||
glow: boolean;
|
||||
/** Resolved light colour and per-stop alpha when on; null otherwise. */
|
||||
appearance: { c: string; alpha: number } | null;
|
||||
/** Field radius, plan units: the marker's own radius or 50 cm. */
|
||||
radius: number;
|
||||
}
|
||||
|
||||
/** Faces and the inside test of the opaque bodies, from the shared light scene. */
|
||||
export function faceContext(scene: LightBarrierScene | null, epsilon: number): FaceContext | null {
|
||||
if (!scene) return null;
|
||||
const rings = [
|
||||
...geometryAllRings(scene.masonryGeometry),
|
||||
...scene.opaqueBodies,
|
||||
];
|
||||
const faces: BodyFace[] = [];
|
||||
for (const ring of rings) {
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const a = ring[i], b = ring[(i + 1) % ring.length];
|
||||
if (a && b) faces.push({ a: [a[0], a[1]], b: [b[0], b[1]] });
|
||||
}
|
||||
}
|
||||
return {
|
||||
faces,
|
||||
inside: (point) => pointInOpaquePlanBody([point[0], point[1]], scene.masonryGeometry, scene.opaqueBodies),
|
||||
epsilon,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of one strip from the card's own light resolution (ТЗ §3, §5):
|
||||
* an unavailable source is a grey dashed stripe without a field — never an
|
||||
* unbinding; on with Glow is a white core and a field; on without Glow is a
|
||||
* core in the source colour; off is a white core.
|
||||
*/
|
||||
export function ledStripView(input: {
|
||||
strip: LedStripModel;
|
||||
device: DevItem;
|
||||
candidate: GlowCandidate | null;
|
||||
hass: { states: Record<string, { state?: unknown } | undefined> } | null | undefined;
|
||||
glow: boolean;
|
||||
defaultRadius: number;
|
||||
cellCm: number;
|
||||
gridPitch: number;
|
||||
}): LedStripView {
|
||||
const eid = input.candidate?.sourceEid || input.device.primary || '';
|
||||
const raw = eid ? input.hass?.states?.[eid]?.state : undefined;
|
||||
const unavailable = raw === 'unavailable' || raw === 'unknown'
|
||||
|| (!!eid && !!input.hass && !input.hass.states?.[eid] && !input.device.virtual);
|
||||
const own = Number(input.device.marker?.glow_radius_cm);
|
||||
const radius = Number.isFinite(own) && own > 0
|
||||
? (own / input.cellCm) * input.gridPitch
|
||||
: input.defaultRadius;
|
||||
const appearance = unavailable ? null : input.candidate?.appearance ?? null;
|
||||
return {
|
||||
strip: input.strip,
|
||||
device: input.device,
|
||||
state: unavailable ? 'unavailable' : appearance ? 'on' : 'off',
|
||||
glow: input.glow,
|
||||
appearance,
|
||||
radius,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stripe and hit path
|
||||
|
||||
export interface LedHandlers {
|
||||
click: (e: MouseEvent, d: DevItem) => void;
|
||||
keydown: (e: KeyboardEvent, d: DevItem) => void;
|
||||
contextmenu: (e: MouseEvent, d: DevItem) => void;
|
||||
pointerdown: (e: PointerEvent, d: DevItem) => void;
|
||||
pointermove: (e: PointerEvent, d: DevItem) => void;
|
||||
pointerup: (e: PointerEvent, d: DevItem) => void;
|
||||
pointercancel: (e: PointerEvent, d: DevItem) => void;
|
||||
pointerover: (e: PointerEvent, d: DevItem) => void;
|
||||
pointerleave: () => void;
|
||||
focus: (e: FocusEvent, d: DevItem) => void;
|
||||
blur: (d: DevItem) => void;
|
||||
label: (d: DevItem) => string;
|
||||
}
|
||||
|
||||
export interface LedStripeInput {
|
||||
views: readonly LedStripView[];
|
||||
/** Base device diameter D in plan units (icon_size of this card × iconUnit). */
|
||||
d: number;
|
||||
faces: FaceContext | null;
|
||||
/** CSS px per plan unit of the current camera. */
|
||||
perUnit: number;
|
||||
handlers?: LedHandlers | null;
|
||||
}
|
||||
|
||||
function stripeThickness(view: LedStripView, d: number): number {
|
||||
return (view.state === 'on' ? LED_THICKNESS_ON_D : LED_THICKNESS_OFF_D) * d;
|
||||
}
|
||||
|
||||
function stripePath(view: LedStripView, input: LedStripeInput) {
|
||||
const t = stripeThickness(view, input.d);
|
||||
return { t, path: visibleStripPath(pts(view.strip.points), input.faces, t / 2) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest stripe under a pointer (ТЗ §7): the visible derived path on
|
||||
* screen, hit radius max(22 px, t/2), ties to the stable id.
|
||||
*/
|
||||
function nearestOwner(e: Event & { clientX: number; clientY: number }, fallback: LedStripView,
|
||||
input: LedStripeInput): LedStripView {
|
||||
// Screen → plan through the hit path's own CTM: no card coordinate helper.
|
||||
const target = e.currentTarget as SVGGraphicsElement | null;
|
||||
const ctm = target?.getScreenCTM?.();
|
||||
if (!ctm || input.views.length < 2) return fallback;
|
||||
const inverse = ctm.inverse();
|
||||
const plan: Pt = [
|
||||
inverse.a * e.clientX + inverse.c * e.clientY + inverse.e,
|
||||
inverse.b * e.clientX + inverse.d * e.clientY + inverse.f,
|
||||
];
|
||||
const strips = input.views.map((view) => {
|
||||
const { t, path } = stripePath(view, input);
|
||||
return {
|
||||
id: view.strip.id,
|
||||
points: path.points.map((p) => [p[0] * input.perUnit, p[1] * input.perUnit] as Pt),
|
||||
closed: path.closed,
|
||||
thicknessPx: t * input.perUnit,
|
||||
};
|
||||
});
|
||||
const owner = stripHitOwner([plan[0] * input.perUnit, plan[1] * input.perUnit], strips);
|
||||
return input.views.find((view) => view.strip.id === owner) || fallback;
|
||||
}
|
||||
|
||||
export function renderLedStripes(input: LedStripeInput): TemplateResult {
|
||||
if (!input.views.length) return svg`` as unknown as TemplateResult;
|
||||
const h = input.handlers;
|
||||
// Smaller ids last: an exact overlap of hit paths resolves to the stable id.
|
||||
const ordered = [...input.views].sort((a, b) => (a.strip.id < b.strip.id ? 1 : a.strip.id > b.strip.id ? -1 : 0));
|
||||
return svg`<g class="led-strips" data-hp-led-strips="${ordered.length}">
|
||||
<style>
|
||||
.led-strip .led-focus { fill: none; stroke: transparent; }
|
||||
.led-strip:focus-within .led-focus { stroke: var(--primary-color, #03a9f4); }
|
||||
.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 unavailable = view.state === 'unavailable';
|
||||
const core = unavailable ? UNAVAILABLE
|
||||
: view.state === 'on' && !view.glow && view.appearance ? view.appearance.c : CORE_IDLE;
|
||||
const hitWidth = (2 * stripHitRadiusPx(t * input.perUnit)) / (input.perUnit || 1);
|
||||
const dev = view.device;
|
||||
const own = (e: MouseEvent) => nearestOwner(e, view, input).device;
|
||||
return svg`<g class="led-strip state-${view.state}" data-led-strip="${view.strip.id}"
|
||||
data-marker="${dev.id}" data-state="${view.state}" data-closed="${path.closed ? 'true' : 'false'}">
|
||||
<path class="led-outline" d="${d}" fill="none" stroke="${OUTLINE}" stroke-width="${t}"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-dasharray=${unavailable ? `${t * 2} ${t * 1.5}` : nothing}></path>
|
||||
<path class="led-core" d="${d}" fill="none" stroke="${core}" stroke-width="${t / 2}"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-dasharray=${unavailable ? `${t * 2} ${t * 1.5}` : nothing}></path>
|
||||
<path class="led-focus" d="${d}" stroke-width="${t * 1.6}" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true"></path>
|
||||
${h ? svg`<path class="led-hit" d="${d}" stroke-width="${hitWidth}"
|
||||
stroke-linecap="round" stroke-linejoin="round" pointer-events="stroke"
|
||||
role="button" tabindex="0" aria-label="${h.label(dev)}"
|
||||
data-hp="device" data-id="${dev.id}"
|
||||
@click=${(e: MouseEvent) => h.click(e, own(e))}
|
||||
@keydown=${(e: KeyboardEvent) => h.keydown(e, dev)}
|
||||
@contextmenu=${(e: MouseEvent) => h.contextmenu(e, own(e))}
|
||||
@pointerdown=${(e: PointerEvent) => h.pointerdown(e, own(e))}
|
||||
@pointermove=${(e: PointerEvent) => h.pointermove(e, own(e))}
|
||||
@pointerup=${(e: PointerEvent) => h.pointerup(e, own(e))}
|
||||
@pointercancel=${(e: PointerEvent) => h.pointercancel(e, dev)}
|
||||
@lostpointercapture=${(e: PointerEvent) => h.pointercancel(e, dev)}
|
||||
@pointerover=${(e: PointerEvent) => h.pointerover(e, own(e))}
|
||||
@pointerleave=${() => h.pointerleave()}
|
||||
@focus=${(e: FocusEvent) => h.focus(e, dev)}
|
||||
@blur=${() => h.blur(dev)}></path>` : nothing}
|
||||
</g>`;
|
||||
})}
|
||||
</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.
|
||||
|
||||
export interface LedFrameInput {
|
||||
space: SpaceModel;
|
||||
devices: readonly DevItem[];
|
||||
hass: { states: Record<string, { state?: unknown } | undefined> } & Record<string, unknown>;
|
||||
virtualLights?: VirtualLightSnapshot | null;
|
||||
defaultColor: string;
|
||||
paletteAlpha: number;
|
||||
cellCm: number;
|
||||
gridPitch: number;
|
||||
/** Card icon size, % of the space's icon unit (ТЗ §3: D = icon_size/100 × iconUnit). */
|
||||
iconPct: number;
|
||||
scene: LightBarrierScene | null;
|
||||
polygons: readonly LightRoomPolygon[];
|
||||
/** Effective Glow of a room: space `glow_enabled` and the room's `glow`. */
|
||||
glowFor: (room: RoomCfg) => boolean;
|
||||
inRoom: (point: number[], room: RoomCfg) => boolean;
|
||||
/** Include hidden/HA-disabled markers (Devices editor with "show hidden"). */
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export interface LedFrame {
|
||||
views: LedStripView[];
|
||||
faces: FaceContext | null;
|
||||
d: number;
|
||||
scene: LightBarrierScene | null;
|
||||
polygons: readonly LightRoomPolygon[];
|
||||
}
|
||||
|
||||
export function ledFrame(input: LedFrameInput): LedFrame {
|
||||
const byId = new Map(input.devices.map((device) => [device.id, device]));
|
||||
const radius = (LED_DEFAULT_RADIUS_CM / input.cellCm) * input.gridPitch;
|
||||
const candidates = new Map(resolveGlowCandidates({
|
||||
hass: input.hass,
|
||||
devices: input.devices as DevItem[],
|
||||
virtualLights: input.virtualLights,
|
||||
spaceId: input.space.id,
|
||||
defaultColor: input.defaultColor,
|
||||
paletteAlpha: input.paletteAlpha,
|
||||
defaultRadiusUnits: radius,
|
||||
cellCm: input.cellCm,
|
||||
gridPitch: input.gridPitch,
|
||||
position: () => ({ x: 0, y: 0 }),
|
||||
}).map((candidate) => [candidate.key, candidate]));
|
||||
const views: LedStripView[] = [];
|
||||
for (const stored of input.space.led_strips || []) {
|
||||
if (stored?.active === false || !stored.marker || !validStripPoints(stored.points)) continue;
|
||||
// Stored strips are normalised like the layout; the plan draws render units.
|
||||
const strip: LedStripModel = {
|
||||
...stored,
|
||||
points: stored.points.map((p) => [p[0] * NORM_W, p[1] * NORM_W]),
|
||||
};
|
||||
const device = byId.get(strip.marker as string);
|
||||
if (!device || device.space !== input.space.id) continue;
|
||||
if (device.hidden && !input.showHidden) continue;
|
||||
const anchor = stripAnchor(pts(strip.points));
|
||||
const room = anchor ? input.space.rooms.find((r) => input.inRoom(anchor, r)) : undefined;
|
||||
views.push(ledStripView({
|
||||
strip,
|
||||
device,
|
||||
candidate: candidates.get(`${input.space.id}|${device.id}`) ?? null,
|
||||
hass: input.hass,
|
||||
glow: room ? input.glowFor(room) : false,
|
||||
defaultRadius: radius,
|
||||
cellCm: input.cellCm,
|
||||
gridPitch: input.gridPitch,
|
||||
}));
|
||||
}
|
||||
const epsilon = (LED_EPSILON_CM / input.cellCm) * input.gridPitch;
|
||||
return {
|
||||
views,
|
||||
faces: faceContext(input.scene, epsilon),
|
||||
d: (input.iconPct / 100) * iconUnit(input.space),
|
||||
scene: input.scene,
|
||||
polygons: input.polygons,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The card side, kept here so the initial graph carries only two call sites
|
||||
// (ТЗ §13.1). The host is the card itself, read structurally.
|
||||
|
||||
export interface LedCardHost {
|
||||
_renderDevices: readonly DevItem[];
|
||||
_renderPlanHass: any; // any-ok: the card's HA snapshot type is internal to the card
|
||||
_virtualLights: VirtualLightSnapshot;
|
||||
_fillColors: { glow_light: { c: string; a: number } };
|
||||
_cellCm: number;
|
||||
_gridPitch: number;
|
||||
_config?: { icon_size?: number } | null;
|
||||
_mode: string;
|
||||
_showAll: boolean;
|
||||
_stageEl: HTMLElement | null;
|
||||
_lightBarriers: (space: SpaceModel, polys: { r: RoomCfg; poly: number[][] }[]) => LightBarrierScene;
|
||||
_pointInRoom: (point: number[], room: RoomCfg) => boolean;
|
||||
_clickDevice: (e: MouseEvent, d: DevItem) => void;
|
||||
_keyDevice: (e: KeyboardEvent, d: DevItem) => void;
|
||||
_ctxDevice: (e: MouseEvent, d: DevItem) => void;
|
||||
_pointerDown: (e: PointerEvent, d: DevItem) => void;
|
||||
_pointerMove: (e: PointerEvent, d: DevItem) => DevItem | null | undefined;
|
||||
_pointerUp: (e: PointerEvent, d: DevItem) => void;
|
||||
_pointerCancel: (e: PointerEvent, d: DevItem) => void;
|
||||
_showDeviceTip: (e: PointerEvent, d: DevItem) => void;
|
||||
_clearPointerHover: () => void;
|
||||
_showDeviceFocusTip: (e: FocusEvent, d: DevItem) => void;
|
||||
_hideDeviceFocusTip: (id: string) => void;
|
||||
}
|
||||
|
||||
const frames = new WeakMap<object, { key: unknown[]; frame: LedFrame }>();
|
||||
|
||||
/** The LED frame of a space for this card, rebuilt only when an input changed. */
|
||||
export function ledFrameFor(host: LedCardHost, space: SpaceModel, spaceGlow: boolean): LedFrame {
|
||||
const polygons = space.rooms.flatMap((room) => {
|
||||
const poly = roomPoly(room);
|
||||
return poly ? [{ room, poly }] : [];
|
||||
});
|
||||
const scene = polygons.length
|
||||
? host._lightBarriers(space, polygons.map(({ room, poly }) => ({ r: room, poly })))
|
||||
: null;
|
||||
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;
|
||||
const size = host._config?.icon_size ?? 2.5;
|
||||
const frame = ledFrame({
|
||||
space,
|
||||
devices: host._renderDevices,
|
||||
hass: host._renderPlanHass,
|
||||
virtualLights: host._virtualLights,
|
||||
defaultColor: host._fillColors.glow_light.c,
|
||||
paletteAlpha: host._fillColors.glow_light.a,
|
||||
cellCm: host._cellCm,
|
||||
gridPitch: host._gridPitch,
|
||||
iconPct: size > 8 ? 2.5 : size,
|
||||
scene,
|
||||
polygons,
|
||||
glowFor: (room) => roomGlowOf(spaceGlow, room),
|
||||
inRoom: (point, room) => host._pointInRoom(point, room),
|
||||
showHidden: host._mode === 'devices' && host._showAll,
|
||||
});
|
||||
frames.set(host, { key, frame });
|
||||
return frame;
|
||||
}
|
||||
|
||||
/** The stripes and hit paths; actions only in View (the Devices editor selects). */
|
||||
export function renderLedLayerFor(
|
||||
host: LedCardHost, space: SpaceModel, spaceGlow: boolean, view: { w: number },
|
||||
): TemplateResult {
|
||||
const frame = ledFrameFor(host, space, spaceGlow);
|
||||
const width = host._stageEl?.clientWidth;
|
||||
return renderLedStripes({
|
||||
views: frame.views,
|
||||
d: frame.d,
|
||||
faces: frame.faces,
|
||||
perUnit: width && view.w ? width / view.w : 1,
|
||||
handlers: host._mode === 'view' ? {
|
||||
click: (e, d) => host._clickDevice(e, d),
|
||||
keydown: (e, d) => host._keyDevice(e, d),
|
||||
contextmenu: (e, d) => host._ctxDevice(e, d),
|
||||
pointerdown: (e, d) => host._pointerDown(e, d),
|
||||
pointermove: (e, d) => {
|
||||
const owner = host._pointerMove(e, d);
|
||||
if (owner) host._showDeviceTip(e, owner);
|
||||
},
|
||||
pointerup: (e, d) => host._pointerUp(e, d),
|
||||
pointercancel: (e, d) => host._pointerCancel(e, d),
|
||||
pointerover: (e, d) => host._showDeviceTip(e, d),
|
||||
pointerleave: () => host._clearPointerHover(),
|
||||
focus: (e, d) => host._showDeviceFocusTip(e, d),
|
||||
blur: (d) => host._hideDeviceFocusTip(d.id),
|
||||
label: (d) => d.name,
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
|
||||
export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlow: boolean): TemplateResult {
|
||||
const frame = ledFrameFor(host, space, spaceGlow);
|
||||
return renderLedField({
|
||||
views: frame.views,
|
||||
scene: frame.scene,
|
||||
polygons: frame.polygons,
|
||||
faces: frame.faces,
|
||||
spaceId: space.id,
|
||||
owner: host,
|
||||
});
|
||||
}
|
||||
+3
-11
@@ -187,17 +187,9 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
...(c.shape === 'circle' ? {} : { angle: canonicalColumnAngle(c.angle) }),
|
||||
})),
|
||||
stairs: stairList(s.stairs),
|
||||
// #780: data only — no LED geometry module in the initial graph.
|
||||
led_strips: (Array.isArray(s.led_strips) ? s.led_strips : [])
|
||||
.filter((strip: any) => strip && typeof strip.id === 'string' && Array.isArray(strip.points))
|
||||
.map((strip: any) => ({
|
||||
id: strip.id,
|
||||
points: strip.points
|
||||
.filter((p: any) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]))
|
||||
.map((p: number[]) => [p[0] * NORM_W, p[1] * H]),
|
||||
marker: typeof strip.marker === 'string' && strip.marker ? strip.marker : null,
|
||||
active: strip.active !== false,
|
||||
})),
|
||||
// #780: the stored strips as they are (normalised units) — scaling,
|
||||
// validation and geometry live in the lazy LED chunk.
|
||||
led_strips: s.led_strips,
|
||||
} as SpaceModel;
|
||||
});
|
||||
}
|
||||
|
||||
+5
-5
@@ -87,19 +87,19 @@ export interface SpaceModel {
|
||||
/** Independent continuous plan objects linking this floor to another one. */
|
||||
stairs: import('./stairs').Stair[];
|
||||
/**
|
||||
* #780: LED strip shapes in render units. Geometry, light and hit-testing
|
||||
* live in the lazy `led-strip-*` modules; the model only carries the data.
|
||||
* #780: the stored LED strips, untouched (normalised units, unvalidated).
|
||||
* Scaling, validation, geometry and light live in the lazy chunk.
|
||||
*/
|
||||
led_strips?: LedStripModel[];
|
||||
}
|
||||
|
||||
/** #780: one stored LED strip shape (render units), see led_strips.py. */
|
||||
/** #780: one stored LED strip (normalised units), see led_strips.py. */
|
||||
export interface LedStripModel {
|
||||
id: string;
|
||||
points: number[][];
|
||||
marker: string | null;
|
||||
/** false — a hidden shape: the marker is shown as an ordinary icon. */
|
||||
active: boolean;
|
||||
/** Absent/true — the marker is shown as this strip; false — a hidden shape. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface PdfRef {
|
||||
|
||||
@@ -299,7 +299,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
|
||||
}
|
||||
assert.throws(() => assertBundleBudget({
|
||||
...manifest, lazyNamespaceLocaleFiles: manifest.lazyNamespaceLocaleFiles.slice(1),
|
||||
}, 1_000_000, undefined, ...lazyCeilings), /lazy namespace locale graph has 8 files, expected 9/);
|
||||
}, 1_000_000, undefined, ...lazyCeilings), /lazy namespace locale graph has 11 files, expected 12/);
|
||||
// #661 AC8: a moon pulled into the first frame (a static import leaves no
|
||||
// lazy moon graph — the builder subtracts initial) is refused by name.
|
||||
assert.throws(() => assertBundleBudget({ ...manifest, lazyMoonFiles: [] }, 1_000_000, undefined, ...lazyCeilings),
|
||||
@@ -538,7 +538,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
|
||||
+ 'new URL("__HOUSEPLAN_FR_RETRY_ASSET__", import.meta.url);'
|
||||
+ '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)', modules: {},
|
||||
+ 'new URL("__HOUSEPLAN_MOON_RETRY_ASSET__", import.meta.url);'
|
||||
+ 'new URL("__HOUSEPLAN_LED_RETRY_ASSET__", import.meta.url)', modules: {},
|
||||
},
|
||||
// #627: the lazy namespace chunk that owns the nine second-attempt tokens.
|
||||
'houseplan-assets/backdrop-pick-HASH.js': {
|
||||
@@ -578,6 +579,11 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
|
||||
type: 'chunk', fileName: 'houseplan-assets/moon-runtime-HASH.js', code: '',
|
||||
modules: { '/repo/src/moon-runtime.ts': {} },
|
||||
},
|
||||
// #780: the LED strip runtime, loaded only for a space with an active strip.
|
||||
'houseplan-assets/led-strip-runtime-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/led-strip-runtime-HASH.js', code: '',
|
||||
modules: { '/repo/src/led-strip-runtime.ts': {} },
|
||||
},
|
||||
};
|
||||
plugin.generateBundle({}, bundle);
|
||||
assert.equal(
|
||||
@@ -589,7 +595,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
|
||||
+ 'new URL("./fr-HASH.js", import.meta.url);'
|
||||
+ '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("./moon-runtime-HASH.js", import.meta.url);'
|
||||
+ 'new URL("./led-strip-runtime-HASH.js", import.meta.url)',
|
||||
);
|
||||
assert.equal(
|
||||
bundle['houseplan-assets/backdrop-pick-HASH.js'].code,
|
||||
@@ -602,7 +609,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']
|
||||
code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF', 'MOON', 'LED']
|
||||
.map((name) => `"__HOUSEPLAN_${name}_RETRY_ASSET__"`).join(';'),
|
||||
},
|
||||
...Object.fromEntries([
|
||||
@@ -611,6 +618,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'],
|
||||
].map(([name, module]) => [`houseplan-assets/${name}-HASH.js`, {
|
||||
type: 'chunk', fileName: `houseplan-assets/${name}-HASH.js`, code: '', modules: { [`/repo${module}`]: {} },
|
||||
}])),
|
||||
@@ -992,7 +1000,7 @@ const runBudgetCli = (initialViewGzipBytes) => {
|
||||
.find((marker) => marker.namespace === namespace && marker.language === 'en').text;
|
||||
writeFileSync(join(dir, 'dist/editor.js'), [
|
||||
...SUPPORT_LAZY_MARKERS.filter((marker) => marker.graph === 'lazyEditorFiles').map((marker) => marker.text),
|
||||
english('settings'), english('support'), english('topology'),
|
||||
english('settings'), english('support'), english('topology'), english('tools'),
|
||||
].join('\n'));
|
||||
writeFileSync(join(dir, 'dist/onboarding.js'), english('settings'));
|
||||
for (const entry of NAMESPACE_LOCALE_CHUNKS) {
|
||||
@@ -1191,6 +1199,6 @@ test('#627 AC3 проверка владения краснеет на кажд
|
||||
}
|
||||
// Маркеры реальных словарей не совпадают ни с одной строкой основного каталога.
|
||||
const real = namespaceLocaleMarkers();
|
||||
assert.equal(real.length, 12);
|
||||
assert.equal(new Set(real.map((entry) => entry.text)).size, 12);
|
||||
assert.equal(real.length, 16);
|
||||
assert.equal(new Set(real.map((entry) => entry.text)).size, 16);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
import { NAMESPACE_LOCALE_CHUNKS } from '../scripts/bundle-manifest.mjs';
|
||||
|
||||
const NAMESPACES = ['settings', 'support', 'topology'];
|
||||
const NAMESPACES = ['settings', 'support', 'topology', 'tools'];
|
||||
const LAZY_LANGUAGES = ['ru', 'de', 'fr'];
|
||||
const source = (path) => readFileSync(new URL(`../src/i18n/${path}`, import.meta.url), 'utf8');
|
||||
const json = (path) => JSON.parse(source(path));
|
||||
@@ -59,16 +59,19 @@ test('#627 рантайм пространства: en готов сразу, ru
|
||||
settings: await import('../test-build/i18n/settings.js'),
|
||||
support: await import('../test-build/i18n/support.js'),
|
||||
topology: await import('../test-build/i18n/topology.js'),
|
||||
tools: await import('../test-build/i18n/tools.js'),
|
||||
};
|
||||
const runtimes = {
|
||||
settings: modules.settings.SETTINGS_LANGUAGE_RUNTIME,
|
||||
support: modules.support.SUPPORT_LANGUAGE_RUNTIME,
|
||||
topology: modules.topology.TOPOLOGY_LANGUAGE_RUNTIME,
|
||||
tools: modules.tools.TOOLS_LANGUAGE_RUNTIME,
|
||||
};
|
||||
const translate = {
|
||||
settings: modules.settings.settingsT,
|
||||
support: modules.support.supportT,
|
||||
topology: modules.topology.topologyT,
|
||||
tools: modules.tools.toolsT,
|
||||
};
|
||||
for (const namespace of NAMESPACES) {
|
||||
const runtime = runtimes[namespace];
|
||||
|
||||
@@ -478,15 +478,9 @@ test('i18n: German values equal to English are explicitly reviewed', () => {
|
||||
'gs.north_letter',
|
||||
'gs.optimize_live_names',
|
||||
'gs.optimize_details',
|
||||
'furn.symbol',
|
||||
// #598: 'Plan' и 'Details' — одни и те же слова в EN и DE
|
||||
'gs.card_plan',
|
||||
'marker.card_details',
|
||||
// #593: 'Computer' одинаков в EN и DE — это немецкое слово, не пропуск перевода
|
||||
'furn.cat_computer',
|
||||
'furn.sym_computer',
|
||||
'furn.cat_sofa',
|
||||
'furn.cat_bidet',
|
||||
'vac.diag_platform',
|
||||
'vac.diag_status',
|
||||
'vac.diag_position',
|
||||
@@ -523,7 +517,6 @@ test('i18n: French values equal to English are explicitly reviewed (#371)', () =
|
||||
'decor.rect',
|
||||
'decor.image',
|
||||
'err.code',
|
||||
'furn.cat_bidet',
|
||||
'gs.about_version',
|
||||
'gs.north_letter',
|
||||
'gs.unit_m',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// #780: the lazy LED chunk and its gate, judged by results (ТЗ §3, §5, §6,
|
||||
// §13.1, §13.2; AC2, AC7, AC9, AC11, AC17 unit parts).
|
||||
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 { GLOW_FALLOFF } from '../test-build/glow-scene.js';
|
||||
import { stripAnchor } from '../test-build/led-strip-geometry.js';
|
||||
|
||||
const space = (strips) => ({ id: 's', rooms: [], led_strips: strips });
|
||||
|
||||
test('ТЗ §13.1: only an active, bound strip with geometry is represented', () => {
|
||||
const map = ledStripsByMarker(space([
|
||||
{ id: 'a', points: [[0, 0], [1, 0]], marker: 'm1' },
|
||||
{ id: 'b', points: [[0, 0], [1, 0]], marker: 'm2', active: true },
|
||||
{ id: 'c', points: [[0, 0], [1, 0]], marker: 'm3', active: false },
|
||||
{ id: 'd', points: [[0, 0], [1, 0]], marker: null },
|
||||
{ id: 'e', points: [[0, 0]], marker: 'm5' },
|
||||
]));
|
||||
assert.deepEqual([...map.keys()].sort(), ['m1', 'm2']);
|
||||
assert.equal(ledStripsByMarker(space([])).size, 0);
|
||||
assert.equal(ledStripsByMarker(null).size, 0);
|
||||
});
|
||||
|
||||
test('AC2: the gate anchor equals the geometry anchor (half length) in render units', () => {
|
||||
const cases = [
|
||||
[[0, 0], [10, 0], [10, 10]], [[0, 0], [4, 0]], [[0, 0], [9, 0], [9, 1]],
|
||||
[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]], [[0, 0], [0, 0], [4, 0], [4, 0]],
|
||||
[[1, 1], [1, 1]],
|
||||
];
|
||||
for (const points of cases) {
|
||||
const reference = stripAnchor(points);
|
||||
const gate = ledAnchor(points, 100);
|
||||
assert.ok(Math.abs(gate.x - reference[0] * 100) < 1e-9 && Math.abs(gate.y - reference[1] * 100) < 1e-9,
|
||||
`${JSON.stringify(points)}: ${JSON.stringify(gate)} vs ${reference}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ТЗ §3: the linear falloff is the shared GLOW_FALLOFF', () => {
|
||||
for (const [offset, value] of GLOW_FALLOFF) {
|
||||
assert.ok(Math.abs(falloffAt(offset / 100) - value) < 1e-12, `${offset}%`);
|
||||
}
|
||||
assert.equal(falloffAt(0), 1);
|
||||
assert.equal(falloffAt(1), 0);
|
||||
assert.equal(falloffAt(2), 0);
|
||||
// Monotonic: the field never brightens away from the strip.
|
||||
let previous = 1;
|
||||
for (let i = 0; i <= 100; i++) {
|
||||
const value = falloffAt(i / 100);
|
||||
assert.ok(value <= previous + 1e-12);
|
||||
previous = value;
|
||||
}
|
||||
});
|
||||
|
||||
const device = (extra = {}) => ({ id: 'm1', name: 'Kitchen LED', primary: 'light.led', space: 's', ...extra });
|
||||
const strip = { id: 'a', points: [[0, 0], [1, 0]], marker: 'm1' };
|
||||
|
||||
test('AC7: states — off white, on, unavailable without a field; radius 50 cm or the own one', () => {
|
||||
const base = { strip, defaultRadius: 10, cellCm: 5, gridPitch: 1, glow: true };
|
||||
const on = ledStripView({ ...base, device: device(), hass: { states: { 'light.led': { state: 'on' } } },
|
||||
candidate: { key: 's|m1', sourceEid: 'light.led', pos: { x: 0, y: 0 }, radius: 3, appearance: { c: '#ff0000', alpha: 0.5 } } });
|
||||
assert.equal(on.state, 'on');
|
||||
assert.deepEqual(on.appearance, { c: '#ff0000', alpha: 0.5 });
|
||||
assert.equal(on.radius, 10, 'the shared radius of ordinary sources does not apply: 50 cm default');
|
||||
const off = ledStripView({ ...base, device: device(), hass: { states: { 'light.led': { state: 'off' } } },
|
||||
candidate: { key: 's|m1', sourceEid: 'light.led', pos: { x: 0, y: 0 }, radius: 3, appearance: null } });
|
||||
assert.equal(off.state, 'off');
|
||||
for (const raw of ['unavailable', 'unknown']) {
|
||||
const view = ledStripView({ ...base, device: device(), hass: { states: { 'light.led': { state: raw } } },
|
||||
candidate: { key: 's|m1', sourceEid: 'light.led', pos: { x: 0, y: 0 }, radius: 3, appearance: { c: '#fff', alpha: 1 } } });
|
||||
assert.equal(view.state, 'unavailable', raw);
|
||||
assert.equal(view.appearance, null, `${raw}: no field`);
|
||||
}
|
||||
const own = ledStripView({ ...base, device: device({ marker: { glow_radius_cm: 100 } }),
|
||||
hass: { states: { 'light.led': { state: 'on' } } }, candidate: null });
|
||||
assert.equal(own.radius, 20, 'the personal radius wins (100 cm / 5 cm per cell)');
|
||||
});
|
||||
|
||||
// A scene with one opaque square body [4,6]×[4,6] and a 10×10 room floor.
|
||||
const body = [[4, 4], [6, 4], [6, 6], [4, 6]];
|
||||
const floor = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const scene = {
|
||||
occluders: body.map((p, i) => [p[0], p[1], body[(i + 1) % 4][0], body[(i + 1) % 4][1]]),
|
||||
floor: [floor], fingerprint: 'f1', masonryGeometry: [], opaqueBodies: [body],
|
||||
};
|
||||
const polygons = [{ room: { id: 'r' }, poly: floor }];
|
||||
|
||||
test('ТЗ §6: every piece is clipped to what its own emitters see; a buried strip emits nothing', () => {
|
||||
const faces = faceContext(scene, 1e-6);
|
||||
const geometry = buildFieldGeometry({ points: [[1, 1], [9, 1]], radius: 2, scene, polygons, faces, spaceId: 's' });
|
||||
assert.ok(geometry, 'a free strip has a field');
|
||||
assert.equal(geometry.pieces.length, 4, 'an 8-unit segment with r = 2 makes four pieces');
|
||||
for (const piece of geometry.pieces) assert.ok(piece.clip.length > 0);
|
||||
const buried = buildFieldGeometry({ points: [[4.5, 5], [5.5, 5]], radius: 2, scene, polygons, faces, spaceId: 's' });
|
||||
assert.equal(buried, null, 'entirely inside the body: no field');
|
||||
});
|
||||
|
||||
test('AC17: the field cache is bounded, per space, and counts geometry rebuilds', () => {
|
||||
const cache = new LedFieldCache(3);
|
||||
cache.forSpace('a');
|
||||
for (let i = 0; i < 5; i++) cache.read(`k${i}`, () => ({ pieces: [], box: { x: 0, y: 0, w: 1, h: 1 } }));
|
||||
assert.equal(cache.size, 3);
|
||||
assert.equal(cache.recomputes, 5);
|
||||
cache.read('k4', () => { throw new Error('a hit must not rebuild'); });
|
||||
assert.equal(cache.recomputes, 5);
|
||||
cache.forSpace('b');
|
||||
assert.equal(cache.size, 0, 'another space frees the previous one');
|
||||
});
|
||||
+2
-2
@@ -10,14 +10,14 @@
|
||||
"tsBuildInfoFile": "test-build/.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/i18n/language-runtime.ts", "src/i18n/namespace-language.ts", "src/i18n/settings.ts", "src/i18n/support.ts", "src/i18n/topology.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
|
||||
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/i18n/language-runtime.ts", "src/i18n/namespace-language.ts", "src/i18n/settings.ts", "src/i18n/support.ts", "src/i18n/topology.ts", "src/i18n/tools.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
|
||||
"src/device-face.ts", "src/device-hit-owner.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts", "src/summary-runtime-loader.ts", "src/card-version.ts", "src/device-area-relocation.ts",
|
||||
"src/ha-binding-status.ts",
|
||||
"src/zigbee-topology-settings.ts", "src/zigbee-topology.ts", "src/zigbee-topology-geometry.ts", "src/zigbee-topology-runtime.ts",
|
||||
"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/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/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