fix v1.37.3: true dashed open boundary, rendered above the glow

- outlineWithout trims the rooms' solid strokes under open stretches
  (.room.noedge kills the polygon stroke incl. hover; trimmed
  .room-outline path draws the remaining walls)
- dash color follows the space stroke color; openwalls layer moved
  after the glow layer
- +1 unit test (105); smoke_openwall extended; docs same-commit
This commit is contained in:
Matysh
2026-07-23 14:34:41 +03:00
parent 0e14139fe2
commit 05f162434b
13 changed files with 195 additions and 53 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.37.2" VERSION = "1.37.3"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.37.2" "version": "1.37.3"
} }
+10
View File
@@ -15,6 +15,16 @@ const res = await page.evaluate(async () => {
// пунктир отрисован // пунктир отрисован
out.dashes = sr().querySelectorAll('.openwall').length > 0; out.dashes = sr().querySelectorAll('.openwall').length > 0;
out.hotClass = !!sr().querySelector('.openwalls.hot'); out.hotClass = !!sr().querySelector('.openwalls.hot');
// в Просмотре: сплошной штрих комнаты снят, контур без выреза + пунктир ПОВЕРХ glow
c._setMode('view'); await c.updateComplete;
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
out.trimmedOutline = sr().querySelectorAll('.room-outline').length >= 2;
const svgEl = sr().querySelector('svg');
const order = [...svgEl.children].map((el) => el.classList?.[0] || el.tagName);
const gi = order.indexOf('glowlayer');
const oi = order.indexOf('openwalls');
out.dashAboveGlow = gi === -1 || oi > gi;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
// повторный клик закрывает // повторный клик закрывает
c._openWallClick([550, 0.25 * H]); await c.updateComplete; c._openWallClick([550, 0.25 * H]); await c.updateComplete;
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2'); out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
File diff suppressed because one or more lines are too long
+26 -14
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## v1.37.3 — 2026-07-23
- Open boundaries now render as a **true dash**: the rooms' solid outlines are
trimmed under the open stretch (outlineWithout) instead of dashes being
painted over a solid line, and the dashed layer moved **above the glow
pools** so light never covers it.
## v1.37.2 — 2026-07-23 ## v1.37.2 — 2026-07-23
- Glow falloff tuned: full brightness for the inner 70% of the radius, - Glow falloff tuned: full brightness for the inner 70% of the radius,
gradient on the outer 30% (was 80/20). gradient on the outer 30% (was 80/20).
+4
View File
@@ -140,6 +140,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto] (explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any - [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] True dashed boundary (v1.37.3): the open stretch is a REAL dash — the
rooms' solid strokes are trimmed out beneath it (hover doesn't bring
them back), walls elsewhere stay solid; the dashes render ABOVE the
glow pools [auto]
- [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default; - [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default;
near a shared wall it turns pointer and the exact stretch that would near a shared wall it turns pointer and the exact stretch that would
open is previewed (amber dashed); an already-open boundary previews red open is previewed (amber dashed); an already-open boundary previews red
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.37.2", "version": "1.37.3",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+22 -6
View File
@@ -14,7 +14,7 @@ import {
import { import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding, lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout,
snapToWall, openingAmount, snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.37.2'; const CARD_VERSION = '1.37.3';
const LS_KEY = 'houseplan_card_layout_v1'; const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -1606,12 +1606,13 @@ class HouseplanCard extends LitElement {
} }
/** Dashed strokes over open (virtual) boundaries; highlighted in the tool. */ /** Dashed strokes over open (virtual) boundaries; highlighted in the tool. */
private _renderOpenWalls(): TemplateResult { private _renderOpenWalls(disp?: SpaceDisplay): TemplateResult {
const pairs = this._openPairs(); const pairs = this._openPairs();
const hover = this._openWallHover; const hover = this._openWallHover;
if (!pairs.length && !hover) return svg`` as unknown as TemplateResult; if (!pairs.length && !hover) return svg`` as unknown as TemplateResult;
const hot = this._markup && this._tool === 'openwall'; const hot = this._markup && this._tool === 'openwall';
return svg`<g class="openwalls ${hot ? 'hot' : ''}"> const stroke = disp?.color || 'var(--hp-muted)';
return svg`<g class="openwalls ${hot ? 'hot' : ''}" style="--ow-stroke:${stroke}">
${pairs.flatMap((p) => p.segs.map((sg) => svg`<line class="openwall" ${pairs.flatMap((p) => p.segs.map((sg) => svg`<line class="openwall"
x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`))} x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`))}
${hover ${hover
@@ -3152,6 +3153,14 @@ class HouseplanCard extends LitElement {
r.area ? areaTemp(this.hass, this._devices, r.area) : null); r.area ? areaTemp(this.hass, this._devices, r.area) : null);
const label = !space.bg && !disp.showNames && !this._markup; const label = !space.bg && !disp.showNames && !this._markup;
const c = this._roomCenter(r); const c = this._roomCenter(r);
// open boundaries: this room's solid stroke must not run beneath
// the dashed stretches — suppress it and draw a trimmed outline
const openCuts = !this._markup && r.id
? this._openPairs()
.filter((pp) => pp.a.id === r.id || pp.b.id === r.id)
.flatMap((pp) => pp.segs)
: [];
if (openCuts.length) cls += ' noedge';
// island rooms punch holes in their parent's fill (evenodd) // island rooms punch holes in their parent's fill (evenodd)
const myPoly = roomPoly(r); const myPoly = roomPoly(r);
const holes = myPoly const holes = myPoly
@@ -3172,10 +3181,17 @@ class HouseplanCard extends LitElement {
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}" x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"
@click=${() => this._clickRoom(r)} @mousemove=${tip} @click=${() => this._clickRoom(r)} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></rect>`; @mouseleave=${() => (this._tip = null)}></rect>`;
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`; const trimmed = openCuts.length && myPoly
? outlineWithout(myPoly, openCuts, this._gridPitch * 0.02)
: null;
const outline = trimmed
? svg`<path class="room-outline" d="${trimmed.map((sg) => `M ${sg[0]} ${sg[1]} L ${sg[2]} ${sg[3]}`).join(' ')}"
style="stroke:${disp.color};stroke-opacity:${disp.showBorders && !this._markup ? disp.opacity : 0}"></path>`
: nothing;
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
})} })}
${this._renderOpenWalls()}
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing} ${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
${this._renderOpenWalls(disp)}
${this._markup ? this._renderMarkupLayer(vb) : nothing} ${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)} ${this._renderOpenings(disp)}
</svg> </svg>
+41
View File
@@ -911,6 +911,47 @@ export function openZoneOf(roomId: string, rooms: { id?: string; open_to?: strin
return zone; return zone;
} }
/**
* Room outline pieces with the given collinear stretches removed used to
* draw a TRUE dashed open boundary (the solid stroke must not run beneath).
* Returns segments [x1,y1,x2,y2].
*/
export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6): number[][] {
const out: number[][] = [];
for (let i = 0; i < poly.length; i++) {
const p1 = poly[i], p2 = poly[(i + 1) % poly.length];
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy);
if (len < eps) continue;
const ux = dx / len, uy = dy / len;
// collect cut intervals on this edge
const iv: [number, number][] = [];
for (const c of cuts) {
const d1 = Math.abs((c[0] - p1[0]) * uy - (c[1] - p1[1]) * ux);
const d2 = Math.abs((c[2] - p1[0]) * uy - (c[3] - p1[1]) * ux);
const tol = Math.max(eps, len * 1e-6);
if (d1 > tol || d2 > tol) continue;
const t1 = (c[0] - p1[0]) * ux + (c[1] - p1[1]) * uy;
const t2 = (c[2] - p1[0]) * ux + (c[3] - p1[1]) * uy;
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(len, Math.max(t1, t2));
if (hi - lo > eps) iv.push([lo, hi]);
}
if (!iv.length) {
out.push([p1[0], p1[1], p2[0], p2[1]]);
continue;
}
iv.sort((a, b) => a[0] - b[0]);
let cur = 0;
for (const [lo, hi] of iv) {
if (lo - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p1[0] + ux * lo, p1[1] + uy * lo]);
cur = Math.max(cur, hi);
}
if (len - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p2[0], p2[1]]);
}
return out;
}
/** Distance from a point to a segment [x1,y1,x2,y2]. */ /** Distance from a point to a segment [x1,y1,x2,y2]. */
export function distToSegment(p: number[], s: number[]): number { export function distToSegment(p: number[], s: number[]): number {
const dx = s[2] - s[0], dy = s[3] - s[1]; const dx = s[2] - s[0], dy = s[3] - s[1];
+13 -2
View File
@@ -545,12 +545,23 @@ export const cardStyles = css`
.stage.markup.tool-openwall { cursor: default; } .stage.markup.tool-openwall { cursor: default; }
.stage.markup.tool-openwall.wallhot { cursor: pointer; } .stage.markup.tool-openwall.wallhot { cursor: pointer; }
.openwall { .openwall {
stroke: var(--card-background-color, var(--hp-bg, #fff)); stroke: var(--ow-stroke, var(--hp-muted));
stroke-width: 3.2; stroke-width: 2.5;
stroke-dasharray: 7 7; stroke-dasharray: 7 7;
stroke-linecap: butt;
pointer-events: none; pointer-events: none;
opacity: 0.9; opacity: 0.9;
} }
/* rooms with open stretches: the polygon's own stroke is fully off
(hover included) the trimmed .room-outline path draws the walls */
.room.noedge {
stroke-opacity: 0 !important;
}
.room-outline {
fill: none;
stroke-width: 2.5;
pointer-events: none;
}
.openwalls.hot .openwall { .openwalls.hot .openwall {
stroke: #ffc14d; stroke: #ffc14d;
opacity: 1; opacity: 1;
+18
View File
@@ -7,6 +7,7 @@ import {
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind, kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
controlsAction, isControllable, controlsAction, isControllable,
sharedBoundary, openZoneOf, distToSegment, sharedBoundary, openZoneOf, distToSegment,
outlineWithout,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
@@ -703,3 +704,20 @@ test('distToSegment', () => {
assert.equal(distToSegment([3, 5], [0, 0, 0, 10]), 3); assert.equal(distToSegment([3, 5], [0, 0, 0, 10]), 3);
assert.ok(Math.abs(distToSegment([-3, -4], [0, 0, 0, 10]) - 5) < 1e-9); assert.ok(Math.abs(distToSegment([-3, -4], [0, 0, 0, 10]) - 5) < 1e-9);
}); });
test('outlineWithout: removes the cut stretch, keeps the rest', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
// вырез середины нижней стены: 3..7
const pieces = outlineWithout(sq, [[3, 0, 7, 0]]);
const len = (s) => Math.hypot(s[2] - s[0], s[3] - s[1]);
const total = pieces.reduce((a, s) => a + len(s), 0);
assert.ok(Math.abs(total - (40 - 4)) < 1e-6);
// куски нижней стены: 0..3 и 7..10
const bottom = pieces.filter((s) => s[1] === 0 && s[3] === 0);
assert.equal(bottom.length, 2);
// вырез целого ребра
const p2 = outlineWithout(sq, [[0, 0, 10, 0]]);
assert.ok(Math.abs(p2.reduce((a, s) => a + len(s), 0) - 30) < 1e-6);
// без вырезов — весь периметр
assert.ok(Math.abs(outlineWithout(sq, []).reduce((a, s) => a + len(s), 0) - 40) < 1e-6);
});