From 05d62f053abbcdf4e75fd00c9b7803cee691750d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:27:41 +0300 Subject: [PATCH] feat(card): LED strips on the static space card (#780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/led-strip-runtime.ts | 61 ++++++++++++++++++++++++++++++++++++++++ src/space-render.ts | 26 ++++++++++++++--- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts index 047603a0..0075cbf1 100755 --- a/src/led-strip-runtime.ts +++ b/src/led-strip-runtime.ts @@ -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; +} diff --git a/src/space-render.ts b/src/space-render.ts index 1275d3ad..47ce9773 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -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(); 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 { ${decorImages} ${stairShapes} ${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` ${wallUnion.paths.map((component) => svg`