feat(card): LED strips on the static space card (#780)

Stage 3b of #780 (ТЗ §8). houseplan-space-card draws a represented marker
as a passive strip through the same lazy runtime: no icon, no auto slot, no
round pool, no hit path, focus or actions. The stripe uses the drawn wall
geometry for the face offset, so light_pools:false builds no barriers,
visibility or timers; the linear field appears only with light_pools:true
and Glow; with live_states:false the stripe is neutral. Strip points vote
in the content frame. Probed in the demo: plain card — one passive stripe
with the source-colour core, no field, no hit path; light_pools — the field.

Issue: #780
User-Visible: no
This commit is contained in:
claude[bot]
2026-10-02 20:54:49 +03:00
committed by Codex
parent 3a1cd7b1ad
commit 05d62f053a
2 changed files with 83 additions and 4 deletions
+61
View File
@@ -630,3 +630,64 @@ export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlo
owner: host,
});
}
// ---------------------------------------------------------------------------
// The static card (ТЗ §8): passive geometry through the same projection, no
// hover, no focus, no actions. The field only with `light_pools: true` and
// Glow; without it no barriers, visibility or timers are created — the faces
// come from the wall geometry the card already drew.
export interface StaticLedInput {
space: SpaceModel;
devices: readonly DevItem[];
hass: LedFrameInput['hass'];
virtualLights?: VirtualLightSnapshot | null;
defaultColor: string;
paletteAlpha: number;
cellCm: number;
gridPitch: number;
iconPct: number;
/** Effective Glow of a room; the card passes false for all when `light_pools` is off. */
glowFor: (room: RoomCfg) => boolean;
inRoom: (point: number[], room: RoomCfg) => boolean;
/** `live_states: false` — a neutral stripe, no state is read for it. */
live: boolean;
/** The light scene with `light_pools: true`; null otherwise. */
scene: LightBarrierScene | null;
/** The drawn masonry and independent bodies, for the face offset only. */
bodies: { masonryGeometry: unknown; opaqueBodies: number[][][] };
perUnit: number;
owner: object;
}
export function renderStaticLed(input: StaticLedInput): TemplateResult {
const polygons = input.space.rooms.flatMap((room) => {
const poly = roomPoly(room);
return poly ? [{ room, poly }] : [];
});
const frame = ledFrame({
space: input.space,
devices: input.devices,
hass: input.hass,
virtualLights: input.virtualLights,
defaultColor: input.defaultColor,
paletteAlpha: input.paletteAlpha,
cellCm: input.cellCm,
gridPitch: input.gridPitch,
iconPct: input.iconPct,
scene: input.scene,
polygons,
glowFor: input.glowFor,
inRoom: input.inRoom,
showHidden: false,
});
const views = input.live ? frame.views
: frame.views.map((view) => ({ ...view, state: 'off' as const, appearance: null, glow: false }));
const faces = faceContext({
occluders: [], floor: [], fingerprint: '',
masonryGeometry: input.bodies.masonryGeometry, opaqueBodies: input.bodies.opaqueBodies,
}, (LED_EPSILON_CM / input.cellCm) * input.gridPitch);
return svg`${input.scene && input.live ? renderLedField({
views, scene: input.scene, polygons, faces, spaceId: input.space.id, owner: input.owner,
}) : nothing}${renderLedStripes({ views, d: frame.d, faces, perUnit: input.perUnit, handlers: null })}` as unknown as TemplateResult;
}
+22 -4
View File
@@ -16,7 +16,7 @@ import {
import {
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
roomCustomFillOf, roomTempRangeOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
openingAmount, roomPoly, outlineWithout, islandsOf,
openingAmount, roomPoly, outlineWithout, islandsOf, pointInPolygon,
type ResolvedRoomFill,
} from './logic';
import {
@@ -59,6 +59,7 @@ import {
type Layout, type ContentItem, type SpaceCardFit,
} from './space-geometry';
import { resolveZeroWalls } from './zero-walls';
import { ledRuntime, ledStripsByMarker } from './led-strip-gate';
import { geometryOpenings } from './plan-geometry-preflight';
import { resolveDeviceAreaRelocations } from './device-area-relocation';
import { projectDecorImage } from './decor-assets';
@@ -309,12 +310,15 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// the same room show different Zigbee health on the two cards.
const spaceDevs = all.filter((d) => d.space === o.spaceId);
const devs = spaceDevs.filter((d) => !d.hidden);
// #780: a marker shown as an LED strip draws no icon here and takes no slot.
const leds = ledStripsByMarker(space);
const iconDevs = leds.size ? devs.filter((d) => !leds.has(d.id)) : devs;
// The auto grid is computed over the FULL roster, hidden included — the
// full card reserves grid cells for hidden devices (their ghosts keep a
// place in the device editor), so the static card must too, or the same
// visible marker with no saved position lands in different spots on the
// two cards (HP-1511-01). Rendering still draws `devs` only.
const defPos = defaultPositions(spaceDevs, space, iconPct);
const defPos = defaultPositions(spaceDevs.filter((d) => !leds.has(d.id)), space, iconPct);
// Hosted Static uses the same room-aware automatic aggregate as the full
// card. Build it once for the whole render: room labels/fills must never
@@ -340,7 +344,10 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// here, so it must not stretch the frame either (DEV-2C947-01). It keeps
// its grid cell above; the frame is presentation, the roster is not.
const placed: ContentItem[] = [];
for (const d of devs) {
for (const strip of leds.values()) for (const p of strip.points) {
placed.push({ minX: p[0] * NORM_W, minY: p[1] * NORM_W, maxX: p[0] * NORM_W, maxY: p[1] * NORM_W });
}
for (const d of iconDevs) {
const sv = o.layout[d.id];
if (sv && sv.s === o.spaceId) {
const x = sv.x * NORM_W, y = sv.y * NORM_W;
@@ -595,7 +602,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: null);
}
const planLightSources = resolvedLightSources(planHass, devs, null, o.virtualLights);
const markers = devs.map((d) => {
const markers = iconDevs.map((d) => {
const p = markerPos(d, o.layout, o.cfg, defPos, space, areaRelocationIds);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
@@ -730,6 +737,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|| wallHatchNeedsSolid(hatchStep, pxPerUnit));
const wallStroke = disp.color || '#607d8b';
let glowPools: TemplateResult | typeof nothing = nothing;
let lightScene: LightBarrierScene | null = null;
const glowRuntime = o.glowRuntime;
if (!o.lightPools || !glowRuntime || !glowEnabledRooms.length) {
if (glowRuntime) forgetGlowSpace(glowRuntime.state, glowRuntime.host, space.id);
@@ -789,8 +797,10 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
});
const seen = new Set<string>();
const spots: GlowSpot[] = [];
lightScene = scene;
for (const candidate of candidates) {
seen.add(candidate.key);
if (leds.has(candidate.key.slice(space.id.length + 1))) continue;
if (glowSourceInOpaqueBody(candidate.pos, scene)) {
forgetGlowSource(glowRuntime.state, glowRuntime.host, candidate.key);
continue;
@@ -983,6 +993,14 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
<g class="decorlayer" pointer-events="none">${decorImages}</g>
<g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>
${glowPools}
${leds.size ? ledRuntime(space.id, () => o.moonHost?.requestUpdate())?.renderStaticLed({
space, devices: spaceDevs, hass: planHass, virtualLights: o.virtualLights,
defaultColor: colors.glow_light.c, paletteAlpha: colors.glow_light.a, cellCm,
gridPitch: GRID_PITCH, iconPct, glowFor: (room) => !!o.lightPools && roomGlowOf(disp.glow, room),
inRoom: (point, room) => pointInPolygon(point, roomPoly(room) || []), live: o.liveStates !== false,
scene: lightScene, perUnit: pxPerUnit, owner: o.glowRuntime?.state || space,
bodies: { masonryGeometry: canonicalWallGeometry?.components.flatMap((c) => c.geom) || [], opaqueBodies: extras },
}) ?? nothing : nothing}
${wallUnion
? svg`<g class="wallbodies" style="--room-stroke:${wallStroke}">
${wallUnion.paths.map((component) => svg`