From 4a9cec69438d08faa5334b92ba8426d28a9062b0 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:24:03 +0300 Subject: [PATCH] =?UTF-8?q?feat(card):=20LED=20strips=20in=20View=20?= =?UTF-8?q?=E2=80=94=20lazy=20chunk,=20stripe,=20linear=20light=20(#780)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/bundle-budget.mjs | 3 +- scripts/bundle-manifest.mjs | 26 +- scripts/smoke-links.mjs | 3 +- src/coordinate-canonicalization.ts | 16 +- src/decor-image-editor.ts | 40 +- src/houseplan-card.ts | 26 +- src/i18n/de.json | 104 ----- src/i18n/editor-language.ts | 2 + src/i18n/en.json | 104 ----- src/i18n/fr.json | 104 ----- src/i18n/ru.json | 104 ----- src/i18n/tools.ts | 32 ++ src/i18n/tools/de.json | 106 +++++ src/i18n/tools/en.json | 106 +++++ src/i18n/tools/fr.json | 106 +++++ src/i18n/tools/ru.json | 106 +++++ src/i18n/tools/tools-de.ts | 6 + src/i18n/tools/tools-fr.ts | 6 + src/i18n/tools/tools-ru.ts | 6 + src/led-strip-gate.ts | 61 +++ src/led-strip-runtime.ts | 632 +++++++++++++++++++++++++++++ src/space-geometry.ts | 14 +- src/types.ts | 10 +- test/bundle-assets.test.mjs | 22 +- test/i18n-lazy-namespaces.test.mjs | 5 +- test/i18n.test.mjs | 7 - test/led-strip-runtime.test.mjs | 110 +++++ tsconfig.test.json | 4 +- 28 files changed, 1383 insertions(+), 488 deletions(-) create mode 100644 src/i18n/tools.ts create mode 100644 src/i18n/tools/de.json create mode 100644 src/i18n/tools/en.json create mode 100644 src/i18n/tools/fr.json create mode 100644 src/i18n/tools/ru.json create mode 100644 src/i18n/tools/tools-de.ts create mode 100644 src/i18n/tools/tools-fr.ts create mode 100644 src/i18n/tools/tools-ru.ts create mode 100644 src/led-strip-gate.ts create mode 100755 src/led-strip-runtime.ts create mode 100644 test/led-strip-runtime.test.mjs diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 9e6838f6..fa3bea0d 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -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'], diff --git a/scripts/bundle-manifest.mjs b/scripts/bundle-manifest.mjs index dde69ec1..cf238038 100644 --- a/scripts/bundle-manifest.mjs +++ b/scripts/bundle-manifest.mjs @@ -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. diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index 508cc370..42a83c6a 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -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 ' diff --git a/src/coordinate-canonicalization.ts b/src/coordinate-canonicalization.ts index 69da6c78..69ae3575 100644 --- a/src/coordinate-canonicalization.ts +++ b/src/coordinate-canonicalization.ts @@ -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(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']); diff --git a/src/decor-image-editor.ts b/src/decor-image-editor.ts index 50f2b14b..8c1b68c5 100644 --- a/src/decor-image-editor.ts +++ b/src/decor-image-editor.ts @@ -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 { private readonly hooks: DecorImageEditorHooks, ) {} + /** #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 { stroke-linejoin="round">` as unknown as TemplateResult; return html`
event.stopPropagation()}>
- ${this.host._t('furn.title')} + ${this._tool('furn.title')}
- ${this.host._t(`furn.cat_${category.id}` as I18nKey)} + ${this._tool(`furn.cat_${category.id}` as ToolsI18nKey)}
${variants.map((symbol) => html``)}
` : FURNITURE_GROUPS.map((group) => html` -
${this.host._t(`furn.group_${group}` as I18nKey)}
+
${this._tool(`furn.group_${group}` as ToolsI18nKey)}
${categories.filter((item) => item.group === group).map((item) => html``)}
`)}
${pal ? html`
- + (this.host._furnPalette = { ...pal, w: this.hooks.furnFieldToCm(Number((event.target as HTMLInputElement).value)), })} /> - + (this.host._furnPalette = { ...pal, h: this.hooks.furnFieldToCm(Number((event.target as HTMLInputElement).value)), })} /> - ${this.host._t('furn.place_hint')} -
` : html`
${this.host._t( - 'furn.pick_hint', - )}
`} + ${this._tool('furn.place_hint')} +
` : html`
${this._tool('furn.pick_hint')}
`} `; } @@ -429,7 +433,7 @@ export class DecorImageEditor { dismiss-on-scrim @hp-close=${() => (this.host._decorShapeDialog = null)}>
${dialog.kind === 'furniture' ? html` - + ` : dialog.kind === 'image' ? html` diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 83e7c4c8..53d06f46 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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(); + // #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 diff --git a/src/i18n/de.json b/src/i18n/de.json index e6dac683..d4b09ef8 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -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", diff --git a/src/i18n/editor-language.ts b/src/i18n/editor-language.ts index 0ba4b5d3..1034e752 100644 --- a/src/i18n/editor-language.ts +++ b/src/i18n/editor-language.ts @@ -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, ]); diff --git a/src/i18n/en.json b/src/i18n/en.json index 55060861..dfe003a7 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 6cf61d9d..cb5422d8 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -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", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 453979ed..3fda12b2 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -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": "Источник", diff --git a/src/i18n/tools.ts b/src/i18n/tools.ts new file mode 100644 index 00000000..ead2cc24 --- /dev/null +++ b/src/i18n/tools.ts @@ -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 { + return subst(TOOLS_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key] ?? key, vars); +} diff --git a/src/i18n/tools/de.json b/src/i18n/tools/de.json new file mode 100644 index 00000000..1cdd7487 --- /dev/null +++ b/src/i18n/tools/de.json @@ -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" +} diff --git a/src/i18n/tools/en.json b/src/i18n/tools/en.json new file mode 100644 index 00000000..db49cc3d --- /dev/null +++ b/src/i18n/tools/en.json @@ -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" +} diff --git a/src/i18n/tools/fr.json b/src/i18n/tools/fr.json new file mode 100644 index 00000000..4fe7c9dc --- /dev/null +++ b/src/i18n/tools/fr.json @@ -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" +} diff --git a/src/i18n/tools/ru.json b/src/i18n/tools/ru.json new file mode 100644 index 00000000..de9f1964 --- /dev/null +++ b/src/i18n/tools/ru.json @@ -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": "Ковёр" +} diff --git a/src/i18n/tools/tools-de.ts b/src/i18n/tools/tools-de.ts new file mode 100644 index 00000000..014b3316 --- /dev/null +++ b/src/i18n/tools/tools-de.ts @@ -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 }; diff --git a/src/i18n/tools/tools-fr.ts b/src/i18n/tools/tools-fr.ts new file mode 100644 index 00000000..04e495f9 --- /dev/null +++ b/src/i18n/tools/tools-fr.ts @@ -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 }; diff --git a/src/i18n/tools/tools-ru.ts b/src/i18n/tools/tools-ru.ts new file mode 100644 index 00000000..c9620962 --- /dev/null +++ b/src/i18n/tools/tools-ru.ts @@ -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 }; diff --git a/src/led-strip-gate.ts b/src/led-strip-gate.ts new file mode 100644 index 00000000..a4187c57 --- /dev/null +++ b/src/led-strip-gate.ts @@ -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 | 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 { + const out = new Map(); + 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 + ).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; +} diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts new file mode 100755 index 00000000..047603a0 --- /dev/null +++ b/src/led-strip-runtime.ts @@ -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 } | 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` + + ${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` + + + + ${h ? svg` 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)}>` : nothing} + `; + })} + ` as unknown as TemplateResult; +} + +// --------------------------------------------------------------------------- +// Linear light field + +interface FieldPiece { + d: string; + clip: string[]; +} + +interface FieldGeometry { + pieces: FieldPiece[]; + box: { x: number; y: number; w: number; h: number }; +} + +/** Bounded per-space cache: shape × radius × barrier revision (ТЗ §13.2). */ +export class LedFieldCache { + private entries = new Map(); + private space = ''; + constructor(private readonly limit = 50) {} + get size(): number { return this.entries.size; } + /** Recompute counter for the performance witness: geometry, not paint. */ + recomputes = 0; + forSpace(spaceId: string): void { + if (spaceId !== this.space) { this.entries.clear(); this.space = spaceId; } + } + read(key: string, build: () => FieldGeometry | null): FieldGeometry | null { + if (this.entries.has(key)) { + const value = this.entries.get(key) ?? null; + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + this.recomputes += 1; + const value = build(); + this.entries.set(key, value); + while (this.entries.size > this.limit) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + return value; + } + clear(): void { this.entries.clear(); this.space = ''; } +} + +const pointsKey = (points: readonly number[][]): string => + points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join(';'); + +/** + * Pieces of a strip for the field: stored segments cut to at most the radius, + * each with the union of what its own emitters see (ТЗ §6). A piece whose + * emitters are all inside a body emits nothing; a failed clip makes that piece + * dark (fail-dark), never an unclipped field. + */ +export function buildFieldGeometry(input: { + points: readonly number[][]; + radius: number; + scene: LightBarrierScene; + polygons: readonly LightRoomPolygon[]; + faces: FaceContext | null; + spaceId: string; +}): FieldGeometry | null { + const path = compactPoints(pts(input.points)); + if (path.length < 2 || !(input.radius > 0)) return null; + const r = input.radius; + const pieces: FieldPiece[] = []; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let i = 1; i < path.length; i++) { + const a = path[i - 1], b = path[i]; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + const count = Math.max(1, Math.ceil(len / r)); + for (let k = 0; k < count; k++) { + const p0: Pt = [a[0] + ((b[0] - a[0]) * k) / count, a[1] + ((b[1] - a[1]) * k) / count]; + const p1: Pt = [a[0] + ((b[0] - a[0]) * (k + 1)) / count, a[1] + ((b[1] - a[1]) * (k + 1)) / count]; + const samples = emitterSamples([p0, p1], input.faces, r / 4); + if (!samples.length) continue; + const clip: string[] = []; + let failed = false; + for (const source of samples) { + try { + const lit = buildGlowClipGeometry({ + spaceId: input.spaceId, + source: { x: source[0], y: source[1] }, + radius: r, + scene: input.scene, + polygons: input.polygons, + onBoundsFailure: () => { failed = true; }, + }).lit; + clip.push(...lit); + } catch { + failed = true; + } + } + if (failed || !clip.length) continue; + pieces.push({ d: `M${p0[0]} ${p0[1]} L${p1[0]} ${p1[1]}`, clip }); + minX = Math.min(minX, p0[0], p1[0]); minY = Math.min(minY, p0[1], p1[1]); + maxX = Math.max(maxX, p0[0], p1[0]); maxY = Math.max(maxY, p0[1], p1[1]); + } + } + if (!pieces.length) return null; + return { pieces, box: { x: minX - r, y: minY - r, w: maxX - minX + 2 * r, h: maxY - minY + 2 * r } }; +} + +/** The shared falloff at a relative distance 0…1 (GLOW_FALLOFF, linear between stops). */ +export function falloffAt(fraction: number): number { + const at = Math.max(0, Math.min(1, fraction)) * 100; + for (let i = 1; i < GLOW_FALLOFF.length; i++) { + const [o0, v0] = GLOW_FALLOFF[i - 1]; + const [o1, v1] = GLOW_FALLOFF[i]; + if (at <= o1) return v0 + ((v1 - v0) * (at - o0)) / Math.max(1e-9, o1 - o0); + } + return 0; +} + +const grey = (value: number): string => { + const level = Math.round(Math.max(0, Math.min(1, value)) * 255); + return `rgb(${level},${level},${level})`; +}; + +/** Stable DOM ids per strip: a re-render must not rebuild masks and clips. */ +const fieldIds = new Map(); +const fieldId = (spaceId: string, stripId: string): number => { + const key = `${spaceId}|${stripId}`; + let id = fieldIds.get(key); + if (id == null) { + id = fieldIds.size + 1; + fieldIds.set(key, id); + } + return id; +}; + +export interface LedFieldInput { + views: readonly LedStripView[]; + scene: LightBarrierScene | null; + polygons: readonly LightRoomPolygon[]; + faces: FaceContext | null; + spaceId: string; + /** The card that owns the bounded cache (one per card, gone with it). */ + owner: object; +} + +const fieldCaches = new WeakMap(); +export function ledFieldCache(owner: object): LedFieldCache { + let cache = fieldCaches.get(owner); + if (!cache) { + cache = new LedFieldCache(); + fieldCaches.set(owner, cache); + } + return cache; +} + +/** + * The linear fields of the strips that are on in a Glow room. Fade uses the + * shared spot transition (`.glow-spot`, GLOW_FADE_MS) — no animation system of + * its own; an off strip keeps its node at opacity 0, so a fade-out completes + * and leaves no residual light. + */ +export function renderLedField(input: LedFieldInput): TemplateResult { + if (!input.scene) return svg`` as unknown as TemplateResult; + const cache = ledFieldCache(input.owner); + cache.forSpace(input.spaceId); + const scene = input.scene; + const fields = input.views.flatMap((view) => { + if (!view.glow || view.state === 'unavailable') return []; + const key = `${view.strip.id}|${pointsKey(view.strip.points)}|${view.radius.toFixed(5)}|${scene.fingerprint}`; + const geometry = cache.read(key, () => buildFieldGeometry({ + points: view.strip.points, + radius: view.radius, + scene, + polygons: input.polygons, + faces: input.faces, + spaceId: input.spaceId, + })); + return geometry ? [{ view, geometry }] : []; + }); + if (!fields.length) return svg`` as unknown as TemplateResult; + const bands = Array.from({ length: LED_FIELD_BANDS }, (_, k) => { + const outer = 1 - k / LED_FIELD_BANDS; + const inner = 1 - (k + 1) / LED_FIELD_BANDS; + return { half: outer, value: falloffAt((outer + inner) / 2) }; + }); + return svg`` as unknown as TemplateResult; +} + +// --------------------------------------------------------------------------- +// One frame for the card: the views of the shown space, its faces and scene. + +export interface LedFrameInput { + space: SpaceModel; + devices: readonly DevItem[]; + hass: { states: Record } & Record; + 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(); + +/** 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, + }); +} diff --git a/src/space-geometry.ts b/src/space-geometry.ts index 835cfb68..5383d602 100644 --- a/src/space-geometry.ts +++ b/src/space-geometry.ts @@ -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; }); } diff --git a/src/types.ts b/src/types.ts index b40fceba..bc2d35f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/test/bundle-assets.test.mjs b/test/bundle-assets.test.mjs index cf480faa..2abc8b38 100644 --- a/test/bundle-assets.test.mjs +++ b/test/bundle-assets.test.mjs @@ -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); }); diff --git a/test/i18n-lazy-namespaces.test.mjs b/test/i18n-lazy-namespaces.test.mjs index de3db0de..af22625a 100644 --- a/test/i18n-lazy-namespaces.test.mjs +++ b/test/i18n-lazy-namespaces.test.mjs @@ -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]; diff --git a/test/i18n.test.mjs b/test/i18n.test.mjs index ce99060b..c3325f74 100644 --- a/test/i18n.test.mjs +++ b/test/i18n.test.mjs @@ -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', diff --git a/test/led-strip-runtime.test.mjs b/test/led-strip-runtime.test.mjs new file mode 100644 index 00000000..c438130c --- /dev/null +++ b/test/led-strip-runtime.test.mjs @@ -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'); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 3e6a5bc5..0fd18cb8 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -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",