feat(card): the LED strip tool in the Devices editor (#780)

Stage 4 of #780 (ТЗ §4–§5). «LED strip» next to «Add» draws a chain with
clean clicks only (pan, pinch, a second finger, cancel and the synthetic
click after navigation add nothing), snaps to the grid and to physical wall
faces / zero-wall axes, stops at the first face of masonry, partitions,
columns and windows (doors, gates and passages are cut by geometry), Shift
gives 45°. Ctrl+Z removes the chain's own point first, Esc finishes, a
double click or a click on the first point (≥3 vertices) closes; fewer than
two distinct points write nothing. A finished strip opens the device picker
(lights first, taken markers explained, «New device…» binds in that
dialog's own write, «Later» keeps unbound geometry).

A selected strip shows its vertex handles (a drag re-checks both neighbours
and the whole path) and a new Devices tray branch: device settings,
bind/change, unbind, show as icon, delete. The device dialog gets the
representation section: show as strip (restores a hidden shape at once or
draws one for this marker, behind the dialog's own save/discard guard),
show as icon, unbind/delete for a hidden shape. Every geometry or
representation change is one optimistic write and one LED command of the
device history; Undo/Redo restores only its own strip record and refuses
when a newer change sits on it. A rebinding renames the link in the marker
save, a deleted marker leaves an unbound strip, a bound marker may not move
to another space. Plan/Background show strips as a passive translucent mark.

The tool, its `led` dictionary (en static, ru/de/fr lazy) and placement
geometry are a new lazy `led-strip-editor` chunk (9.5 KB gzip), loaded only
on the tool, an editable strip in the shown space or a strip device's
dialog. The initial graph gets the loader and delegation only
(src/led-strip-card.ts); the lazy editor graph +122 B, inside its ceiling.
Mutant anchors follow the moved code (marker dialog guard, static LED layer).

Issue: #780
User-Visible: no
This commit is contained in:
Codex
2026-10-02 12:13:23 +03:00
parent c7c67830f6
commit fa89d655ed
28 changed files with 1479 additions and 49 deletions
+3 -1
View File
@@ -427,6 +427,7 @@ const GRAPH_LABELS = {
lazyEditorFiles: 'lazy editor graph',
lazyOnboardingFiles: 'lazy onboarding graph',
lazyNamespaceLocaleFiles: 'lazy namespace locale graph',
lazyLedEditorFiles: 'lazy LED editor graph',
};
/**
@@ -470,6 +471,7 @@ export const NAMESPACE_ENGLISH_CONSUMERS = {
support: ['lazyEditorFiles'],
topology: ['lazyEditorFiles'],
tools: ['lazyEditorFiles'],
led: ['lazyLedEditorFiles'],
};
/**
@@ -776,7 +778,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/tools × ru/de/fr)`);
+ ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology/tools/led × ru/de/fr)`);
}
for (const [graph, label] of [
['initialViewFiles', 'initial View graph'],
+21 -3
View File
@@ -14,13 +14,14 @@ const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
const MOON_RETRY_ASSET_TOKEN = '__HOUSEPLAN_MOON_RETRY_ASSET__';
const LED_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_RETRY_ASSET__';
const LED_FIELD_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_FIELD_RETRY_ASSET__';
const LED_EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_EDITOR_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', 'tools']
export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology', 'tools', 'led']
.flatMap((namespace) => ['ru', 'de', 'fr'].map((language) => ({
namespace,
language,
@@ -124,6 +125,9 @@ export function buildBundleManifest(bundle, fingerprint) {
// strip is on in a Glow room with a light scene.
: modules.some((id) => id.endsWith('/src/led-strip-field.ts'))
? 'led-field'
// #780: the Devices-editor LED tool, loaded by the editor runtime.
: modules.some((id) => id.endsWith('/src/led-strip-editor.ts'))
? 'led-editor'
// #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'))
@@ -176,6 +180,7 @@ export function buildBundleManifest(bundle, fingerprint) {
|| path.includes('led-strip-runtime-')),
...files.filter((file) => file._role === 'led-field').map((file) => file.path),
];
const ledEditorRoots = files.filter((file) => file._role === 'led-editor').map((file) => file.path);
// #627: namespace dictionaries are dynamic imports of LAZY chunks, never of
// the initial graph, so they are found by their module, not as a root.
const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale')
@@ -195,6 +200,7 @@ export function buildBundleManifest(bundle, fingerprint) {
const lazyPdf = graphFrom(pdfRoots);
const lazyMoon = graphFrom(moonRoots);
const lazyLed = graphFrom(ledRoots);
const lazyLedEditor = graphFrom(ledEditorRoots);
const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
@@ -227,6 +233,8 @@ export function buildBundleManifest(bundle, fingerprint) {
lazyMoonGzipBytes: sum(lazyMoon),
lazyLedFiles: [...lazyLed].sort(),
lazyLedGzipBytes: sum(lazyLed),
lazyLedEditorFiles: [...lazyLedEditor].sort(),
lazyLedEditorGzipBytes: sum(lazyLedEditor),
lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(),
lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale),
files: files.map(({ _role, ...file }) => file),
@@ -302,6 +310,9 @@ export function editorRuntimeRetryUrlPlugin() {
const ledField = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-field.ts')));
if (!ledField) throw new Error('LED strip field chunk was not emitted');
const ledEditor = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-editor.ts')));
if (!ledEditor) throw new Error('LED strip editor 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)));
@@ -320,6 +331,7 @@ export function editorRuntimeRetryUrlPlugin() {
let moonReplacements = 0;
let ledReplacements = 0;
let ledFieldReplacements = 0;
let ledEditorReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
@@ -375,6 +387,12 @@ export function editorRuntimeRetryUrlPlugin() {
ledReplacements += chunk.code.split(LED_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(LED_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(LED_EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), ledEditor.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
ledEditorReplacements += chunk.code.split(LED_EDITOR_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(LED_EDITOR_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(LED_FIELD_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), ledField.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
@@ -392,9 +410,9 @@ export function editorRuntimeRetryUrlPlugin() {
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
|| pdfReplacements !== 1 || moonReplacements !== 1 || ledReplacements !== 1
|| ledFieldReplacements !== 1) {
|| ledFieldReplacements !== 1 || ledEditorReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}/${ledFieldReplacements}, expected 1/1/1/1/1/1/1/1/1/1`);
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}/${ledFieldReplacements}/${ledEditorReplacements}, expected 1/1/1/1/1/1/1/1/1/1/1`);
}
// #627: the same strict rule for every namespace × language token —
// exactly one second-attempt URL each, never zero and never two.
+7 -6
View File
@@ -6084,9 +6084,9 @@ const MUTANT_DEFINITIONS = [
patches: [{
file: 'custom_components/houseplan/validation.py',
find: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n'
+ ' ),\n canonicalize_config_geometry,\n _config_wall_segment_invariants,\n)',
+ ' ),\n canonicalize_config_geometry,\n _config_wall_segment_invariants,\n',
replace: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n'
+ ' ),\n lambda value: value,\n _config_wall_segment_invariants,\n)',
+ ' ),\n lambda value: value,\n _config_wall_segment_invariants,\n',
}],
},
{
@@ -7387,7 +7387,7 @@ const MUTANT_DEFINITIONS = [
find: ' ${passageGlowTunnels}\n'
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
+ ' ${glowPools}\n ${wallUnion',
+ ' ${glowPools}\n ${leds.size',
replace: ' ${passageGlowTunnels}\n'
+ ' ${!space.bg && !disp.showNames ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
+ ' const center = roomCenter(room);\n'
@@ -7397,7 +7397,7 @@ const MUTANT_DEFINITIONS = [
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
+ ' ${glowPools}\n'
+ ' ${wallUnion',
+ ' ${leds.size',
}],
},
{
@@ -13741,8 +13741,9 @@ const MUTANT_DEFINITIONS = [
+ 'Перевёрнутое условие закрывает «грязный» диалог молча и спрашивает у чистого',
patches: [{
file: 'src/editors/marker-dialog.ts',
find: " if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }",
replace: " if (!edit || dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }",
// #780: the guard is the dialog's shared `leave` (close and the LED representation switch).
find: " if (edit && dirty && !await this.host._confirmDanger({",
replace: " if ((!edit || !dirty) && !await this.host._confirmDanger({",
}],
},
{