BG: drawn-plan paper follows the room contours, not their bounding box

Owner: the white backing must hug the ROOMS — an L-shaped house or detached
buildings grew a white square around the plan. Drawn plans now paper one
opaque shape per room (paperRoomShapes in logic.ts) in exactly the room's own
geometry — polygon points / rounded rect verbatim — so the union of the stack
is the paper: islands paint over their parent, open (virtual) boundaries
change nothing, and the scene bg_color / daynight sky reaches the exterior
walls, shows in the L's pocket and between buildings. Image plans keep the
backdrop-image rect (the canvas IS the paper). A live resize preview
(_rszPreview) feeds _renderCfg, so the paper moves WITH a dragged wall.
Static space-card follows the same contract. Paper is fill-only (stroke:none).

smoke_bg_color §11–13 rewritten: L-shaped + detached test rooms, paper-per-
room DOM checks, resize-preview wiring, pixel probes (acid in the pocket and
between buildings, none inside rooms); §13 injects the snapshot directly —
the module-level config-store cache made the old WS mock a no-op. Was 8 red
on the previous build, green now. docs/SUN.md + docs/TESTING.md contract
updated; unit test for paperRoomShapes.
This commit is contained in:
Matysh
2026-08-03 08:38:48 +03:00
parent a8bb145ffb
commit c024c6d75a
12 changed files with 398 additions and 261 deletions
File diff suppressed because one or more lines are too long
+93 -56
View File
@@ -143,11 +143,12 @@ const res = await page.evaluate(async () => {
checkAll(res);
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
// The scene bg_color must never bleed through the plan itself: an opaque
// rect.hp-paper sits under everything the plan draws and hugs the plan's
// extents (backdrop image rect / drawn-content bounds). The scene colour is
// visible ONLY around it. Verified computed AND by pixels against an acid
// background; the 'daynight' night dims the paper via brightness only.
// The scene bg_color must never bleed through the plan itself: opaque
// .hp-paper shapes sit under everything the plan draws. An IMAGE plan papers
// the backdrop image rect (the canvas is the paper); a DRAWN plan papers the
// ROOM CONTOURS — one shape per room, never their bounding box (section 12).
// The scene colour is visible ONLY around the paper; the 'daynight' night
// dims the paper via brightness only.
const paper = await page.evaluate(async () => {
const out = {};
const c = window.__card;
@@ -193,41 +194,79 @@ const paper = await page.evaluate(async () => {
});
Object.assign(res, paper);
// ---- 12) drawn plan (no backdrop image): white paper + pixel proof ------
// ---- 12) drawn plan: the paper follows the ROOM CONTOURS ---------------
// Owner: «белая подложка должна быть по границам комнат» — an L-shaped house
// plus a detached building must NOT grow a white bounding rectangle. One
// paper shape per room, in exactly the room's own geometry; the acid scene
// colour shows in the L's pocket and between the buildings, right up to the
// exterior walls; inside the rooms it never shows. A live resize preview
// (_rszPreview) moves the paper together with the dragged wall.
const drawn = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
sp.plan_url = ''; // hand-drawn plan now
// binary-exact fractions -> exact render coords (0.125*1000 = 125 etc.)
sp.rooms = [
{ id: 'rL', name: 'L', poly: [[0.125, 0.125], [0.625, 0.125], [0.625, 0.375], [0.375, 0.375], [0.375, 0.625], [0.125, 0.625]] },
{ id: 'rQ', name: 'Q', x: 0.75, y: 0.75, w: 0.1875, h: 0.1875 },
];
sp.openings = [];
sp.decor = [];
c._devices = []; // no icons over the pixel probes below
c._cfgRev = (c._cfgRev || 0) + 1;
c.requestUpdate();
await c.updateComplete;
const p = sr().querySelector('.stage.noplan svg rect.hp-paper');
out.drawnPaperExists = !!p;
out.drawnPaperWhite = !!p && getComputedStyle(p).fill === 'rgb(255, 255, 255)';
if (p) {
const pr = p.getBoundingClientRect();
const st = sr().querySelector('.stage').getBoundingClientRect();
out.rects = {
paper: [pr.left, pr.top, pr.width, pr.height],
stage: [st.left, st.top, st.width, st.height],
};
// the paper hugs the CONTENT — the scene colour stays visible around it
out.bgVisibleAround = pr.left - st.left > 8 || (st.left + st.width) - (pr.left + pr.width) > 8
|| pr.top - st.top > 8 || (st.top + st.height) - (pr.top + pr.height) > 8;
}
c._cfgEpoch++;
await upd();
const papers = () => [...sr().querySelectorAll('.stage.noplan svg .hp-paper')];
let ps = papers();
out.paperPerRoom = ps.length === sp.rooms.length;
out.paperShapesMatchRooms = ps.length === 2
&& ps[0].tagName.toLowerCase() === 'polygon'
&& ps[0].getAttribute('points') === sp.rooms[0].poly.map((p) => (p[0] * 1000) + ',' + (p[1] * 1000)).join(' ')
&& ps[1].tagName.toLowerCase() === 'rect';
out.drawnPaperWhite = ps.length > 0 && ps.every((p) => getComputedStyle(p).fill === 'rgb(255, 255, 255)');
out.paperNoStroke = ps.length > 0 && ps.every((p) => {
const cs = getComputedStyle(p);
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
});
// paper first, everything else on top of it
const first = sr().querySelector('.stage.noplan svg').firstElementChild;
out.paperUnderneath = !!first && first.classList.contains('hp-paper');
// live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
// must move WITH the room, not lag behind until the drop
const pv = JSON.parse(JSON.stringify(sp));
pv.rooms[0].poly = pv.rooms[0].poly.map(([x, y]) => [x === 0.625 ? 0.6875 : x, y]);
c._rszPreview = { space: c._space, sp: pv };
c._cfgEpoch++;
await upd();
out.previewMovesPaper = (papers()[0]?.getAttribute('points') || '').includes('687.5,125');
c._rszPreview = null;
c._cfgEpoch++;
await upd();
out.previewRestores = (papers()[0]?.getAttribute('points') || '').includes('625,125');
// screen coords for the pixel probes (svg user units -> viewport px)
const svgEl = sr().querySelector('.stage svg');
const ctm = svgEl.getScreenCTM();
const toScr = (x, y) => { const q = svgEl.createSVGPoint(); q.x = x; q.y = y; const r = q.matrixTransform(ctm); return [r.x, r.y]; };
out.probes = {
pocket: toScr(500, 500), // the L's notch — bg must show here
gap: toScr(687, 300), // between the two buildings
inside: [toScr(250, 250), toScr(500, 250), toScr(250, 500), toScr(843.75, 843.75)],
};
return out;
});
const { rects } = drawn;
delete drawn.rects;
const { probes } = drawn;
delete drawn.probes;
Object.assign(res, drawn);
// pixel proof: screenshot → canvas → not a single probe inside the paper
// carries the acid scene colour; right outside the paper it IS the acid colour
if (rects) {
// pixel proof: the acid scene colour IS in the L's pocket and between the
// buildings (where the old bounding-box paper drew a white square), and is
// NOT inside any room
if (probes) {
const shot = (await page.screenshot()).toString('base64');
const px = await page.evaluate(async ({ shot, rects }) => {
const px = await page.evaluate(async ({ shot, probes }) => {
const img = new Image();
img.src = 'data:image/png;base64,' + shot;
await img.decode();
@@ -236,29 +275,19 @@ if (rects) {
cv.height = img.height;
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const at = (x, y) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
const at = ([x, y]) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
const acid = ([r, g, b]) => r > 200 && b > 200 && g < 160; // #ff00ff admixture
const [pl, pt, pw, ph] = rects.paper;
const [sl, st, sw, sh] = rects.stage;
const probes = [];
for (const fx of [0.1, 0.3, 0.5, 0.7, 0.9])
for (const fy of [0.1, 0.3, 0.5, 0.7, 0.9]) probes.push(at(pl + pw * fx, pt + ph * fy));
// a point in the stage but outside the paper (the widest gap side)
const gaps = [
[pl - sl, [(sl + pl) / 2, pt + ph / 2]],
[sl + sw - pl - pw, [(pl + pw + sl + sw) / 2, pt + ph / 2]],
[pt - st, [pl + pw / 2, (st + pt) / 2]],
[st + sh - pt - ph, [pl + pw / 2, (pt + ph + st + sh) / 2]],
].sort((a, b) => b[0] - a[0]);
return {
noBleedInsidePlan: probes.every((q) => !acid(q)),
acidAroundPlan: gaps[0][0] > 8 && acid(at(gaps[0][1][0], gaps[0][1][1])),
acidInPocket: acid(at(probes.pocket)),
acidBetweenBuildings: acid(at(probes.gap)),
noAcidInsideRooms: probes.inside.every((q) => !acid(at(q))),
};
}, { shot, rects });
}, { shot, probes });
Object.assign(res, px);
} else {
res.noBleedInsidePlan = false;
res.acidAroundPlan = false;
res.acidInPocket = false;
res.acidBetweenBuildings = false;
res.noAcidInsideRooms = false;
}
// ---- 13) the static space-card follows the same paper contract ----------
@@ -266,17 +295,17 @@ const stat = await page.evaluate(async () => {
const out = {};
const c = window.__card;
await customElements.whenDefined('houseplan-space-card');
// NOTE: the config-store cache is module-level and already primed by the
// earlier static-card sections (with the PRE-§12 rooms), so a mocked
// callWS would never reach the card — inject the snapshot directly.
const mk = async (cfg) => {
const hass2 = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
return c.hass.callWS(m);
} };
const host = document.createElement('div');
document.body.appendChild(host);
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
el.hass = hass2;
el._snap = { config: cfg, rev: 1, layout: {} };
el._loadedOnce = true;
el.hass = c.hass;
host.appendChild(el);
const t0 = Date.now();
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000)
@@ -293,14 +322,22 @@ const stat = await page.evaluate(async () => {
out.staticPaperOpaque = !!pa && getComputedStyle(pa).fillOpacity === '1'
&& getComputedStyle(pa).fill !== 'none';
a.host.remove();
// drawn plan: per-room paper shapes (the L + the detached rect from §12)
const drawnCfg = JSON.parse(JSON.stringify(base));
drawnCfg.spaces.find((s) => s.id === c._space).plan_url = '';
const dsp = drawnCfg.spaces.find((s) => s.id === c._space);
dsp.plan_url = '';
const b = await mk(drawnCfg);
const pb = b.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
out.staticDrawnPaperExists = !!pb;
const pbs = [...b.el.renderRoot.querySelectorAll('.hp-static-stage svg .hp-paper')];
out.staticDrawnPaperPerRoom = pbs.length === dsp.rooms.length;
out.staticDrawnPaperShapes = pbs.length === 2
&& pbs[0].tagName.toLowerCase() === 'polygon'
&& pbs[1].tagName.toLowerCase() === 'rect';
b.host.remove();
return out;
});
Object.assign(res, stat);
checkAll({ ...paper, ...drawn, ...stat, noBleedInsidePlan: res.noBleedInsidePlan, acidAroundPlan: res.acidAroundPlan });
checkAll({ ...paper, ...drawn, ...stat,
acidInPocket: res.acidInPocket,
acidBetweenBuildings: res.acidBetweenBuildings,
noAcidInsideRooms: res.noAcidInsideRooms });
await finish(browser, res);
File diff suppressed because one or more lines are too long
+60 -53
View File
File diff suppressed because one or more lines are too long
+14 -9
View File
@@ -63,15 +63,20 @@ only — no entities are created, no services are called.
without `sun.sun`) it behaves as `'static'`.
- **The scene background never bleeds through the plan** (owner,
2026-08-03). In BOTH modes the background — `bg_color` or the
daynight sky — is visible only AROUND the plan: an opaque
`rect.hp-paper` sits under everything the plan draws and hugs the
plan's extents (the backdrop image rect, or the drawn content bounds
the opening view fits). Its colour is the pre-bg_color canvas —
white for hand-drawn plans, the theme card background under an
image. The night dimming above is the `brightness` filter on the
zoomwrap ONLY; the paper's alpha never changes. Applies to
view/kiosk/editors and the static space-card alike
(smoke_bg_color).
daynight sky — is visible only AROUND the plan: opaque `.hp-paper`
shapes sit under everything the plan draws. An image plan papers the
backdrop image rect (the canvas IS the paper); a hand-drawn plan
papers the ROOM CONTOURS — one shape per room in exactly the room's
own geometry (fill only, no stroke), never their bounding box, so
the background reaches the exterior walls of an L-shaped house and
fills the gaps between detached buildings (an empty drawn space has
no paper). Open (virtual) boundaries do not affect the paper; a live
resize preview moves it together with the rooms. Its colour is the
pre-bg_color canvas — white for hand-drawn plans, the theme card
background under an image. The night dimming above is the
`brightness` filter on the zoomwrap ONLY; the paper's alpha never
changes. Applies to view/kiosk/editors and the static space-card
alike (smoke_bg_color).
## Window light wedges — `settings.sun_rays`
+12 -7
View File
@@ -882,13 +882,18 @@ require hands on real hardware — they remain for the human pass.
- [ ] Opaque plan paper (2026-08-03, owner): the scene background —
`bg_color` or the daynight sky — is visible ONLY around the plan and
NEVER bleeds through it, in view/kiosk/editors and on the static
space-card. An opaque `rect.hp-paper` under everything the plan draws
hugs the plan's extents (the backdrop image rect, or the drawn
content bounds the opening view fits); a hand-drawn plan keeps its
historical white canvas, an image plan the theme card background.
At night (`daynight`) the paper dims via the zoomwrap `brightness`
filter only — its alpha stays 1. Pixel-proofed against an acid
`#ff00ff` background [auto: smoke_bg_color]
space-card. An image plan papers the backdrop image rect
(`rect.hp-paper`). A hand-drawn plan papers the ROOM CONTOURS: one
`.hp-paper` shape per room in exactly the room's own geometry (fill
only, no stroke), so an L-shaped house or detached buildings never
grow a white bounding rectangle — the scene colour reaches the
exterior walls, shows in the L's pocket and between buildings, and an
empty drawn space has no paper at all. A live resize preview
(`_rszPreview`) moves the paper together with the dragged wall.
Colours: historical white for drawn plans, the theme card background
under an image. At night (`daynight`) the paper dims via the zoomwrap
`brightness` filter only — its alpha stays 1. Pixel-proofed against
an acid `#ff00ff` background [auto: smoke_bg_color]
- [ ] Per-space overrides (background mode, north, sun-in-windows) inherit
when empty, exactly like show_lqi/fill_mode
- [ ] «Sunlight through windows» (default OFF): wedges appear only from
+28 -15
View File
@@ -13,7 +13,7 @@ import {
} from './rules';
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
snapToWall, openingAmount, interiorPoint, poleOfInaccessibility, subst,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
@@ -1598,19 +1598,20 @@ class HouseplanCard extends LitElement {
/**
* Opaque plan "paper" (owner 2026-08-03): the scene background — bg_color or
* the 'daynight' sky — must never bleed through the plan itself, so an
* opaque rect sits under everything the plan draws. It hugs the plan's
* extents: the backdrop image when there is one, otherwise the same content
* bounds the opening view fits (an empty drawn space papers its whole
* canvas — exactly the pre-bg_color look). Its colour is the pre-bg_color
* canvas (styles.ts .hp-paper): white for drawn plans, the theme card
* background under an image. At night 'daynight' dims it via the zoomwrap
* brightness filter ONLY — its alpha stays 1 (docs/SUN.md).
* the 'daynight' sky — must never bleed through the plan itself, so opaque
* shapes sit under everything the plan draws. With a backdrop image the
* paper is the image rect (the canvas IS the paper) — this helper returns
* it. For a hand-drawn plan the paper follows the ROOM CONTOURS instead
* (paperRoomShapes in logic.ts): one shape per room in exactly the room's
* own geometry, so an L-shaped house or detached buildings never grow a
* white bounding rectangle — the scene colour is visible right up to the
* exterior walls, and an empty drawn space has no paper at all. Its colour
* is the pre-bg_color canvas (styles.ts .hp-paper): white for drawn plans,
* the theme card background under an image. At night 'daynight' dims it via
* the zoomwrap brightness filter ONLY — its alpha stays 1 (docs/SUN.md).
*/
private _paperRect(m: SpaceModel): { x: number; y: number; w: number; h: number } {
if (m.bg) return { x: m.bg.x, y: m.bg.y, w: m.bg.w, h: m.bg.h };
const b = this._baseVb();
return { x: b[0], y: b[1], w: b[2], h: b[3] };
private _paperRect(m: SpaceModel): { x: number; y: number; w: number; h: number } | null {
return m.bg ? { x: m.bg.x, y: m.bg.y, w: m.bg.w, h: m.bg.h } : null;
}
/** Aspect ratio of the scene (width/height, px). */
@@ -5065,8 +5066,20 @@ class HouseplanCard extends LitElement {
style="${dayNight ? `filter:brightness(${(1 - planDim).toFixed(3)})` : ''}">
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
${(() => {
const pp = this._paperRect(space);
return svg`<rect class="hp-paper" x="${pp.x}" y="${pp.y}" width="${pp.w}" height="${pp.h}" pointer-events="none"></rect>`;
// opaque paper: the image rect for image plans, the room
// contours for drawn ones (owner: no white square around an
// L-shaped house — the scene bg reaches the exterior walls).
// `space` comes from _renderCfg, so a live resize preview
// (_rszPreview) moves the paper together with the rooms.
if (space.bg) {
const pp = this._paperRect(space)!;
return svg`<rect class="hp-paper" x="${pp.x}" y="${pp.y}" width="${pp.w}" height="${pp.h}" pointer-events="none"></rect>`;
}
return paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}" pointer-events="none"></rect>`,
);
})()}
${this._editing ? this._renderMarkupDefs(vb) : nothing}
${this._editing && !this._markup
+24
View File
@@ -62,6 +62,30 @@ export function roomPoly(r: any): number[][] | null {
return null;
}
/**
* Paper shapes for a hand-drawn plan (owner 2026-08-03): the opaque paper
* follows the ROOM CONTOURS, not their bounding box — an L-shaped house or a
* detached building must not grow a white rectangle around itself. One shape
* per room, in EXACTLY the geometry the room itself renders (same polygon
* points string, same rounded rect), so the paper never peeks past a wall;
* the union of the stack is the paper. Islands simply paint over their
* parent; open (virtual) boundaries do not affect the paper at all.
*/
export function paperRoomShapes(rooms: any[]): Array<
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
for (const r of rooms || []) {
if (r?.poly?.length >= 3) {
out.push({ poly: r.poly.map((p: number[]) => p.join(',')).join(' ') });
} else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {
out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
}
}
return out;
}
export function roomEdges(rooms: any[]): number[][] {
const out: number[][] = [];
const seen = new Set<string>();
+4 -1
View File
@@ -207,9 +207,12 @@ class HouseplanSpaceCard extends LitElement {
}
/* Opaque plan paper — the scene bg_color/daynight sky shows only AROUND
the plan (owner 2026-08-03). The static card keeps its historical
canvas colour: the theme card background. */
canvas colour: the theme card background. Drawn plans paper the room
contours (per-room shapes, fill only, no stroke) — same contract as
the full card. */
.hp-paper {
fill: var(--ha-card-background, var(--card-background-color, #111));
stroke: none;
}
.hp-static-stage .devlayer {
position: absolute;
+15 -11
View File
@@ -8,13 +8,13 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf } from './logic';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf, paperRoomShapes } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
import type { ServerConfig } from './types';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, contentBounds, type Layout,
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
} from './space-geometry';
export { spaceModels } from './space-geometry';
@@ -159,21 +159,25 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const stageBg = sunBg || stageBgOf(o.cfg?.settings, disp);
// Opaque plan paper (owner 2026-08-03), same contract as the full card: the
// scene background never bleeds through the plan. The rect hugs the backdrop
// image, or the drawn content's bounds (marker positions included, as on the
// full card); an empty space papers its whole canvas.
const paperPts = spaceDevs.map((d) => {
const p = markerPos(d, o.layout, o.cfg, defPos, space);
return [p.x, p.y] as const;
});
// scene background never bleeds through the plan. With a backdrop image the
// paper is the image rect; a hand-drawn plan papers the ROOM CONTOURS
// (paperRoomShapes) — never their bounding box, so the scene colour reaches
// the exterior walls of an L-shaped house and fills the gaps between
// detached buildings. An empty drawn space has no paper at all.
const paper = space.bg
? { x: space.bg.x, y: space.bg.y, w: space.bg.w, h: space.bg.h }
: contentBounds(space, 0.05, paperPts) || { x: vb[0], y: vb[1], w: vb[2], h: vb[3] };
: null;
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${svg`<rect class="hp-paper" x="${paper.x}" y="${paper.y}" width="${paper.w}" height="${paper.h}"></rect>`}
${paper
? svg`<rect class="hp-paper" x="${paper.x}" y="${paper.y}" width="${paper.w}" height="${paper.h}"></rect>`
: paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
: nothing}
+6 -2
View File
@@ -316,10 +316,14 @@ export const cardStyles = css`
/* Opaque plan paper (owner 2026-08-03): the scene bg_color / daynight sky
shows ONLY around the plan, never through it. The colour is the
pre-bg_color canvas — the theme card background under an image plan,
plain white for a hand-drawn one (.stage.noplan). Night dimming comes
from the .zoomwrap brightness filter, never from alpha (docs/SUN.md). */
plain white for a hand-drawn one (.stage.noplan). On drawn plans the
paper is per-room shapes following the room contours — fill only,
never a stroke, so the paper cannot poke past a wall. Night dimming
comes from the .zoomwrap brightness filter, never from alpha
(docs/SUN.md). */
.hp-paper {
fill: var(--ha-card-background, var(--card-background-color, #111));
stroke: none;
}
.stage.noplan .hp-paper {
fill: #ffffff;
+22 -1
View File
@@ -14,7 +14,7 @@ import {
roomFillModeOf,
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -959,3 +959,24 @@ test('tap action run: explicit-only, with runnable targets (owner spec 2026-07-2
// locks and alarms stay forbidden, run or not
assert.equal(resolveTapAction('toggle', undefined, 'lock'), 'info');
});
test('paperRoomShapes: paper follows room contours, one shape per room (owner 2026-08-03)', () => {
const L = [[100, 100], [600, 100], [600, 350], [350, 350], [350, 600], [100, 600]];
const rooms = [
{ id: 'L', poly: L }, // L-shaped: polygon, verbatim
{ id: 'q', x: 650, y: 650, w: 200, h: 100 }, // legacy rect: same rounded rect the room draws
{ id: 'bad' }, // no geometry -> no paper
];
const shapes = paperRoomShapes(rooms);
assert.equal(shapes.length, 2, 'one shape per room WITH geometry');
// the polygon uses exactly the room's own points string — same coordinates,
// so the paper can never peek past a wall
assert.deepEqual(shapes[0], { poly: L.map((p) => p.join(',')).join(' ') });
// the rect mirrors the room shape incl. its corner rounding rx
assert.deepEqual(shapes[1], { rect: { x: 650, y: 650, w: 200, h: 100, rx: 100 * 0.03 } });
// degenerate inputs never throw and produce no paper
assert.deepEqual(paperRoomShapes([]), []);
assert.deepEqual(paperRoomShapes(null), []);
assert.deepEqual(paperRoomShapes([{ poly: [[0, 0], [1, 1]] }]), [], 'a 2-point poly is not a surface');
});