mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 06:38:57 +00:00
Stage 5 of #780. - Import summary: «Strips left unbound after import: {n}» from the backend `unbound_led_strips` count; «Optimize plans» reports strips passing through walls per space and edits none (AC16). - Linear field for long strips (ТЗ §13.2): pieces of at most the radius along the polyline, emitters thinned to r/4, each piece clipped to the visibility fans of its own emitters as separate clipPath children (no boolean pass per piece), one floor clip for the whole layer, no fan at all where nothing blocks within the radius; a grid index of body faces and boxed inside tests; unchanged fields skip re-diffing. 50×50 on the large house: first stable frame ~1.4 s, warm space ~1.1 s locally. - led-strips-v1 profile: demo/benchmark_led_strips.mjs with the derived large-house fixture (10×5, 50×50, none), absolute limits of the ТЗ table in demo/performance/budgets-led-strips.json, exact counters (zero recomputes on HA ticks/camera/colour, ≤50 cache entries, no growth over 20 cycles); added to the full performance workflow. - Bundle: LAZY_LED_GZIP_CEILING 10 KiB, LAZY_LED_EDITOR_GZIP_CEILING 11 KiB (measured + 10 %, rounded up); overlaps with the initial and editor graphs refused; the lazy editor graph stays inside its ceiling. - Smokes smoke_led_strip_draw/bind/glow, linked in smoke-links; 13 mutants in the registry (7 browser guards in the inventory); config field registry entry `spaces[].led_strips`. - Golden: five new scenes on the `golden-led` space of the visual fixture (`ledStrips` option, the designer's four strips on #868D94), matrix v71. - Docs: LIGHT, DEVICE-PRESENTATION, USER-GUIDE (en/ru), UX-MODES, ARCHITECTURE, ISOMETRIC, CONFIG-COMPATIBILITY, TOUCH-SUPPORT, demo/stand README, performance README; docs/design/led-strips with the unchanged designer archive, two paired frames and ACCEPTANCE.md; both changelogs. Issue: #780 User-Visible: yes
341 lines
14 KiB
JavaScript
341 lines
14 KiB
JavaScript
/** Deterministic fictional scenes for HP-QA-01 golden-image coverage. */
|
|
|
|
const round = (value) => Number(value.toFixed(6));
|
|
|
|
// Golden fixtures must use the same persisted wall-key contract as real plan
|
|
// data. Arbitrary labels make every configured wall look virtual to the
|
|
// renderer, which lets a visually ineffective baseline pass unnoticed — and a
|
|
// key that merely differs in precision is found only through the tolerant
|
|
// fallback in lookupWall, which is luck rather than contract (#260). The
|
|
// formula lives in one place for every fixture and is pinned to the product one
|
|
// by test/fixture-wall-key.test.mjs.
|
|
export { fixtureWallKey } from './wall-key.mjs';
|
|
import { fixtureWallKey } from './wall-key.mjs';
|
|
|
|
const uniqueEdges = (rooms) => {
|
|
const edges = new Map();
|
|
for (const room of rooms) {
|
|
room.poly.forEach((a, index) => {
|
|
const b = room.poly[(index + 1) % room.poly.length];
|
|
const forward = `${a.join(',')}/${b.join(',')}`;
|
|
const reverse = `${b.join(',')}/${a.join(',')}`;
|
|
if (!edges.has(reverse) && !edges.has(forward)) edges.set(forward, { a, b });
|
|
});
|
|
}
|
|
return [...edges.values()];
|
|
};
|
|
|
|
const wallsFor = (prefix, rooms, thickness) => uniqueEdges(rooms).map((edge, index) => ({
|
|
key: fixtureWallKey(edge.a, edge.b),
|
|
a: edge.a,
|
|
b: edge.b,
|
|
cm: typeof thickness === 'function' ? thickness(edge, index) : thickness,
|
|
}));
|
|
|
|
const geometryRooms = [
|
|
{ id: 'geo-nw', name: 'NW', area: 'golden_geo_nw', poly: [[0.06, 0.08], [0.48, 0.08], [0.48, 0.48], [0.06, 0.48]] },
|
|
{ id: 'geo-ne', name: 'NE', area: 'golden_geo_ne', poly: [[0.48, 0.08], [0.94, 0.08], [0.94, 0.48], [0.48, 0.48]] },
|
|
{ id: 'geo-sw', name: 'SW', area: 'golden_geo_sw', poly: [[0.06, 0.48], [0.48, 0.48], [0.48, 0.92], [0.06, 0.92]] },
|
|
{ id: 'geo-se', name: 'SE', area: 'golden_geo_se', poly: [[0.48, 0.48], [0.94, 0.48], [0.94, 0.92], [0.48, 0.92]] },
|
|
{ id: 'geo-nested', name: 'Nested', area: 'golden_geo_nested',
|
|
poly: [[0.72, 0.14], [0.84, 0.26], [0.72, 0.38], [0.60, 0.26]] },
|
|
];
|
|
|
|
const lightingRooms = [
|
|
{ id: 'light-left', name: 'Light source room', area: 'golden_light_left',
|
|
poly: [[0.07, 0.10], [0.50, 0.10], [0.50, 0.88], [0.07, 0.88]] },
|
|
{ id: 'light-right', name: 'Receiving room', area: 'golden_light_right',
|
|
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
|
|
];
|
|
|
|
const applianceRooms = [
|
|
{ id: 'appliance-room', name: 'Laundry', area: 'golden_appliance',
|
|
poly: [[0.08, 0.10], [0.92, 0.10], [0.92, 0.90], [0.08, 0.90]] },
|
|
];
|
|
|
|
const geometrySpace = {
|
|
id: 'golden-geometry',
|
|
title: 'Geometry matrix',
|
|
plan_url: null,
|
|
view_box: [0, 0, 1, 1],
|
|
cell_cm: 5,
|
|
settings: {
|
|
fill_mode: 'none', show_borders: true, show_names: true,
|
|
room_color: '#2d8fce', room_opacity: 0.16,
|
|
},
|
|
rooms: geometryRooms,
|
|
walls: wallsFor('geo', geometryRooms, (edge, index) => {
|
|
const vertical = Math.abs(edge.a[0] - edge.b[0]) < 1e-9;
|
|
if (vertical && Math.abs(edge.a[0] - 0.48) < 1e-9) return 25;
|
|
return index % 4 === 0 ? 10 : 15;
|
|
}),
|
|
open_spans: [{ a: [0.48, 0.15], b: [0.48, 0.27] }],
|
|
openings: [
|
|
{ id: 'geo-window', type: 'window', x: 0.26, y: 0.08, angle: 0, length: 0.12 },
|
|
{ id: 'geo-door', type: 'door', x: 0.48, y: 0.37, angle: 90, length: 0.12 },
|
|
{ id: 'geo-gate', type: 'gate', x: 0.72, y: 0.92, angle: 0, length: 0.2 },
|
|
{ id: 'geo-diagonal-window', type: 'window', x: 0.78, y: 0.20, angle: 45, length: 0.08 },
|
|
],
|
|
partitions: [
|
|
{ id: 'geo-partition-h', a: [0.14, 0.68], b: [0.40, 0.68], cm: 12 },
|
|
{ id: 'geo-partition-v', a: [0.75, 0.56], b: [0.75, 0.82], cm: 20 },
|
|
],
|
|
wall_columns: [
|
|
{ id: 'geo-column-square', shape: 'square', center: [0.63, 0.67], cm: 35, angle: 30 },
|
|
{ id: 'geo-column-circle', shape: 'circle', center: [0.86, 0.72], cm: 40 },
|
|
],
|
|
decor: [
|
|
{ id: 'geo-axis-h', kind: 'line', x1: 0.04, y1: 0.5, x2: 0.96, y2: 0.5,
|
|
color: '#5d6a73', opacity: 0.35, width_cm: 0.8, line_style: 'dashed' },
|
|
],
|
|
};
|
|
|
|
const lightingSpace = {
|
|
id: 'golden-lighting',
|
|
title: 'Lighting matrix',
|
|
plan_url: null,
|
|
view_box: [0, 0, 1, 1],
|
|
cell_cm: 5,
|
|
settings: {
|
|
fill_mode: 'none', glow_enabled: true, show_borders: true, show_names: true,
|
|
north_deg: 0, sun_rays: true, bg_mode: 'static',
|
|
},
|
|
rooms: lightingRooms,
|
|
walls: wallsFor('light', lightingRooms, (edge) => (
|
|
Math.abs(edge.a[0] - 0.5) < 1e-9 && Math.abs(edge.b[0] - 0.5) < 1e-9 ? 25 : 15
|
|
)),
|
|
openings: [
|
|
{ id: 'light-window', type: 'window', x: 0.27, y: 0.10, angle: 0, length: 0.14 },
|
|
{ id: 'light-door', type: 'door', x: 0.50, y: 0.54, angle: 90, length: 0.15 },
|
|
{ id: 'light-gate', type: 'gate', x: 0.74, y: 0.88, angle: 0, length: 0.22 },
|
|
],
|
|
partitions: [
|
|
{ id: 'light-partition', a: [0.70, 0.22], b: [0.70, 0.70], cm: 18 },
|
|
],
|
|
wall_columns: [
|
|
{ id: 'light-column', shape: 'circle', center: [0.38, 0.64], cm: 45 },
|
|
],
|
|
decor: [],
|
|
};
|
|
|
|
const applianceSpace = {
|
|
id: 'golden-appliance',
|
|
title: 'Appliance lifecycle',
|
|
plan_url: null,
|
|
view_box: [0, 0, 1, 1],
|
|
cell_cm: 5,
|
|
settings: {
|
|
fill_mode: 'none', glow_enabled: false, show_borders: true, show_names: true,
|
|
sun_rays: false, bg_mode: 'static',
|
|
},
|
|
rooms: applianceRooms,
|
|
walls: wallsFor('appliance', applianceRooms, 15),
|
|
openings: [],
|
|
partitions: [],
|
|
wall_columns: [],
|
|
decor: [],
|
|
};
|
|
|
|
// #780: the LED strip reference scene — the designer's four strips on a
|
|
// synthetic plan with the reference floor colour #868D94: a free straight
|
|
// strip, a strip on the inner face of the bottom wall, a polyline along the
|
|
// wall faces around a corner and a closed rectangle. The colours are scene
|
|
// values of the four light sources, not a product palette.
|
|
const ledRooms = [
|
|
{ id: 'led-living', name: 'LED living', area: 'golden_led_living',
|
|
poly: [[0.06, 0.08], [0.56, 0.08], [0.56, 0.62], [0.06, 0.62]] },
|
|
{ id: 'led-studio', name: 'LED studio', area: 'golden_led_studio',
|
|
poly: [[0.56, 0.08], [0.94, 0.08], [0.94, 0.62], [0.56, 0.62]] },
|
|
];
|
|
// Inner faces of the 20 cm walls (half thickness = 10 cm = 2 cells of 1/240).
|
|
const LED_FACE = 2 / 240;
|
|
export const LED_REFERENCE_STRIPS = Object.freeze([
|
|
{ id: 'led-free', marker: 'demo-led-strip', color: [128, 213, 255],
|
|
points: [[0.14, 0.26], [0.44, 0.26]] },
|
|
{ id: 'led-wall', marker: 'demo-led-strip-wall', color: [230, 128, 255],
|
|
points: [[0.12, round(0.62 - LED_FACE)], [0.50, round(0.62 - LED_FACE)]] },
|
|
{ id: 'led-corner', marker: 'demo-led-strip-corner', color: [255, 234, 128],
|
|
points: [[0.64, round(0.08 + LED_FACE)], [round(0.94 - LED_FACE), round(0.08 + LED_FACE)],
|
|
[round(0.94 - LED_FACE), 0.42]] },
|
|
{ id: 'led-loop', marker: 'demo-led-strip-loop', color: [88, 255, 88],
|
|
points: [[0.66, 0.30], [0.84, 0.30], [0.84, 0.52], [0.66, 0.52], [0.66, 0.30]] },
|
|
]);
|
|
|
|
const ledSpace = {
|
|
id: 'golden-led',
|
|
title: 'LED strips',
|
|
plan_url: null,
|
|
view_box: [0, 0, 1, 0.7],
|
|
cell_cm: 5,
|
|
settings: {
|
|
fill_mode: 'custom', custom_fill: { c: '#868D94', a: 1 }, glow_enabled: true,
|
|
show_borders: true, show_names: false, sun_rays: false, bg_mode: 'static',
|
|
},
|
|
rooms: ledRooms,
|
|
walls: wallsFor('led', ledRooms, 20),
|
|
openings: [
|
|
{ id: 'led-door', type: 'door', x: 0.56, y: 0.53, angle: 90, length: 0.10 },
|
|
],
|
|
partitions: [],
|
|
wall_columns: [],
|
|
decor: [],
|
|
led_strips: LED_REFERENCE_STRIPS.map(({ id, marker, points }) => ({ id, marker, points })),
|
|
};
|
|
|
|
const runtime = (includeAppliance = false, includeLed = false) => {
|
|
const devices = {};
|
|
const entities = {};
|
|
const states = {
|
|
'sun.sun': {
|
|
entity_id: 'sun.sun', state: 'above_horizon',
|
|
attributes: { azimuth: 180, elevation: 24 },
|
|
},
|
|
// State-only YAML covers deliberately have no registry/device row. They
|
|
// keep the Glow opening-state golden independent from entity-registry
|
|
// discovery while exercising the same exact-reference projection as a
|
|
// user's template cover.
|
|
'cover.golden_door_closed': {
|
|
entity_id: 'cover.golden_door_closed', state: 'closed',
|
|
attributes: { friendly_name: 'Golden closed door', current_position: 0 },
|
|
},
|
|
'cover.golden_door_half': {
|
|
entity_id: 'cover.golden_door_half', state: 'open',
|
|
attributes: { friendly_name: 'Golden half-open door', current_position: 50 },
|
|
},
|
|
};
|
|
// Keep sun.sun state-only on purpose. Core/runtime entities and YAML
|
|
// entities without unique_id may have a live state without a registry row.
|
|
// The production projection must preserve them.
|
|
const layout = {};
|
|
const areas = Object.fromEntries(
|
|
[...geometryRooms, ...lightingRooms, ...(includeAppliance ? applianceRooms : [])]
|
|
.map((room) => [room.area, { area_id: room.area, name: room.name }]),
|
|
);
|
|
const add = (id, domain, area, x, y, state, attributes = {}) => {
|
|
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
|
|
devices[id] = {
|
|
id, name: `Golden ${id}`, model: `GOLDEN-${id.toUpperCase()}`, area_id: area,
|
|
identifiers: [['houseplan_golden', id]], config_entries: ['golden_entry'],
|
|
entry_type: null, via_device_id: null, disabled_by: null,
|
|
};
|
|
entities[entityId] = {
|
|
entity_id: entityId, device_id: id, platform: 'houseplan_golden',
|
|
config_entry_id: 'golden_entry', disabled_by: null,
|
|
};
|
|
states[entityId] = { entity_id: entityId, state, attributes: { friendly_name: devices[id].name, ...attributes } };
|
|
layout[id] = { s: 'golden-lighting', x: round(x), y: round(y) };
|
|
};
|
|
add('golden-light-one', 'light', 'golden_light_left', 0.20, 0.34, 'on', { rgb_color: [255, 196, 112] });
|
|
add('golden-light-two', 'light', 'golden_light_left', 0.35, 0.72, 'on', { color_temp_kelvin: 2700 });
|
|
add('golden-light-three', 'light', 'golden_light_right', 0.82, 0.30, 'off');
|
|
add('golden-presence', 'binary_sensor', 'golden_light_right', 0.82, 0.62, 'on', { device_class: 'occupancy' });
|
|
add('golden-climate', 'climate', 'golden_light_right', 0.60, 0.28, 'heat', {
|
|
current_temperature: 22.4, temperature: 23, hvac_action: 'heating',
|
|
});
|
|
add('golden-left-temperature', 'sensor', 'golden_light_left', 0.19, 0.54, '17', {
|
|
device_class: 'temperature', unit_of_measurement: '°C',
|
|
});
|
|
add('golden-right-temperature', 'sensor', 'golden_light_right', 0.81, 0.48, '29', {
|
|
device_class: 'temperature', unit_of_measurement: '°C',
|
|
});
|
|
add('golden-left-linkquality', 'sensor', 'golden_light_left', 0.34, 0.54, '35', {
|
|
unit_of_measurement: 'lqi',
|
|
});
|
|
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
|
|
unit_of_measurement: 'lqi',
|
|
});
|
|
|
|
if (includeLed) {
|
|
for (const room of ledRooms) areas[room.area] = { area_id: room.area, name: room.name };
|
|
for (const strip of LED_REFERENCE_STRIPS) {
|
|
add(strip.marker, 'light', ledRooms[strip.id === 'led-free' || strip.id === 'led-wall' ? 0 : 1].area,
|
|
0.5, 0.5, 'on', { rgb_color: strip.color, brightness: 255 });
|
|
layout[strip.marker] = { s: 'golden-led', x: 0.5, y: 0.35 };
|
|
}
|
|
}
|
|
|
|
if (includeAppliance) {
|
|
const washerId = 'golden-washer';
|
|
devices[washerId] = {
|
|
id: washerId,
|
|
name: 'Golden washing machine',
|
|
model: 'GOLDEN-WASHER-COMPOSITE',
|
|
area_id: 'golden_appliance',
|
|
identifiers: [['houseplan_golden', washerId]],
|
|
config_entries: ['golden_entry'],
|
|
entry_type: null,
|
|
via_device_id: null,
|
|
disabled_by: null,
|
|
};
|
|
const addWasherEntity = (entityId, state, attributes = {}, registry = {}) => {
|
|
entities[entityId] = {
|
|
entity_id: entityId,
|
|
device_id: washerId,
|
|
platform: 'houseplan_golden',
|
|
config_entry_id: 'golden_entry',
|
|
disabled_by: null,
|
|
...registry,
|
|
};
|
|
states[entityId] = {
|
|
entity_id: entityId,
|
|
state,
|
|
attributes: { friendly_name: registry.original_name || entityId, ...attributes },
|
|
};
|
|
};
|
|
addWasherEntity('switch.golden_washer_power', 'on', {}, { original_name: 'Power' });
|
|
addWasherEntity('switch.golden_washer_child_lock', 'off', {}, { original_name: 'Child lock' });
|
|
addWasherEntity('sensor.golden_washer_status', 'done', {}, {
|
|
original_name: 'Status', translation_key: 'status',
|
|
});
|
|
addWasherEntity('sensor.golden_washer_stage', 'Rinse', {}, { original_name: 'Stage' });
|
|
addWasherEntity('sensor.golden_washer_program', 'mixed_wash', {}, { original_name: 'Program' });
|
|
layout[washerId] = { s: 'golden-appliance', x: 0.5, y: 0.5 };
|
|
}
|
|
return { devices, entities, states, layout, areas };
|
|
};
|
|
|
|
export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
|
spaces: 2,
|
|
rooms: geometryRooms.length + lightingRooms.length,
|
|
openings: geometrySpace.openings.length + lightingSpace.openings.length,
|
|
partitions: geometrySpace.partitions.length + lightingSpace.partitions.length,
|
|
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
|
});
|
|
|
|
export const makeVisualMatrixFixture = ({ applianceLifecycle = false, ledStrips = false } = {}) => ({
|
|
config: {
|
|
spaces: [
|
|
structuredClone(geometrySpace), structuredClone(lightingSpace),
|
|
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
|
|
...(ledStrips ? [structuredClone(ledSpace)] : []),
|
|
],
|
|
// A persisted marker is part of the fixture contract for scenarios that
|
|
// override per-source Glow controls. The device/layout alone are not a
|
|
// saved marker configuration and must not be silently treated as one.
|
|
markers: [
|
|
{ id: 'golden-light-two', binding: 'device:golden-light-two' },
|
|
// #780: a strip link writes the marker's explicit space (ТЗ §5).
|
|
...(ledStrips ? LED_REFERENCE_STRIPS.map(({ marker }) => ({
|
|
id: marker, binding: `device:${marker}`, space: 'golden-led',
|
|
})) : []),
|
|
],
|
|
settings: {
|
|
glow_radius_cm: 360,
|
|
north_deg: 0,
|
|
sun_rays: true,
|
|
bg_mode: 'static',
|
|
fill_colors: {
|
|
glow_base: { c: '#1b2530', a: 0.78 },
|
|
glow_light: { c: '#ffd27b', a: 0.70 },
|
|
wall_fill: { c: '#d7d9dc', a: 1 },
|
|
},
|
|
},
|
|
},
|
|
...runtime(applianceLifecycle, ledStrips),
|
|
counts: applianceLifecycle ? {
|
|
...VISUAL_MATRIX_COUNTS,
|
|
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
|
|
rooms: VISUAL_MATRIX_COUNTS.rooms + applianceRooms.length,
|
|
} : VISUAL_MATRIX_COUNTS,
|
|
});
|