feat: integrate isometric labs renderer

Issue: #89
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-13 14:40:31 +03:00
parent 02502c990c
commit 0ef900a3ae
9 changed files with 635 additions and 276 deletions
+290 -248
View File
File diff suppressed because one or more lines are too long
+260 -25
View File
@@ -113,7 +113,7 @@ import {
COLUMN_MAX_CM, canonicalColumnAngle, clampColumnCm, columnBody,
directionalOccluders, draftBodies, floorMinusBodies, geometryArea, geometryOuterRings,
geometryAllRings, intersectionPaths, partitionBody, polyclipPathD,
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement,
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement, physicalBodies,
} from './physical-geometry';
import {
LightSegment, polygonSegments, splitAtIntersections, visibilityPolygon,
@@ -172,6 +172,16 @@ import {
ModeTransitionController, viewportFromViewBox,
type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox,
} from './mode-transition';
import {
currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot,
} from './labs';
import {
ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint, projectedFrame,
unprojectFloorPoint, type ScenePoint, type ViewRect,
} from './iso-projection';
import {
buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint, type IsoWallGeometry,
} from './iso-walls';
import {
acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses,
fullRegistryHass, haRegistryBuildSignature, haRegistryDiagnostics, haRegistrySnapshot,
@@ -278,6 +288,9 @@ const lruWrite = <K, V>(cache: Map<K, V>, key: K, value: V, limit: number): void
type WarmViewport = {
space: string;
mode: 'view' | 'plan' | 'devices' | 'decor';
projection: 'flat' | 'iso';
activeLabsIso: boolean;
logicalCenter: { x: number; y: number } | null;
zoom: number;
view: { x: number; y: number; w: number; h: number } | null;
/** the view-mode viewport an editor was entered from (_viewModeSnap) */
@@ -417,6 +430,7 @@ const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for
const LS_ZOOM = 'houseplan_card_zoom_v1';
const LS_NAV = 'houseplan_card_nav_v1'; // last space only; editor sessions never survive page navigation
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
const LS_VIEW = 'houseplan_card_view_v1'; // presentation preference per space, Labs-only
const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0)
/** Short semantic-event / direct-terminal-transition window. Event uses
three sequential 1.1 s waves; motion cool-down itself never animates. */
@@ -1138,6 +1152,8 @@ class HouseplanCard extends LitElement {
key: string;
value: ReturnType<typeof wallBodiesUnionPath>;
} | null = null;
private _isoGeometryCache = new Map<string, { geometry: IsoWallGeometry; frame: Rect }>();
private _isoFallback = new Set<string>();
private _openingTunnelCache: {
key: string;
value: Array<OpeningTunnelGeometry | null>;
@@ -1492,6 +1508,10 @@ class HouseplanCard extends LitElement {
private _warmSlot: WarmEntry | null = null;
private _hashApplied = false;
private _navApplied = false; // the saved space was restored (or the user navigated)
private _labs: LabsSnapshot = { active: Object.freeze([]), space: '' };
private _labsUnsub?: () => void;
private _viewPreference: Record<string, 'flat' | 'iso'> = {};
private _renderProjection: 'flat' | 'iso' = 'flat';
// ---- kiosk (wall device) mode ----
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 };
private _kioskDialog = false;
@@ -1603,10 +1623,63 @@ class HouseplanCard extends LitElement {
private _cyclePausedUntil = 0;
private _swipeStart: { x: number; y: number; id: number } | null = null;
private _lastTap = 0;
private get _labsIso(): boolean {
return this._labs.active.includes('iso');
}
private get _desiredProjection(): 'flat' | 'iso' {
return this._mode === 'view' && this._labsIso && this._viewPreference[this._space] === 'iso'
? 'iso' : 'flat';
}
private _saveViewPreference(): void {
try { localStorage.setItem(LS_VIEW, JSON.stringify(this._viewPreference)); } catch { /* private mode */ }
}
private _logicalViewCenter(projection: 'flat' | 'iso'): { x: number; y: number } | null {
const view = this._view;
if (!view) return null;
const center: ScenePoint = [view.x + view.w / 2, view.y + view.h / 2];
const point = projection === 'iso' ? unprojectFloorPoint(center) : center;
return { x: point[0], y: point[1] };
}
private _convertProjectionView(from: 'flat' | 'iso', to: 'flat' | 'iso'): void {
if (from === to) return;
const logical = this._logicalViewCenter(from);
this._view = null;
const target = logical
? to === 'iso' ? projectPlanPoint([logical.x, logical.y], 0) : [logical.x, logical.y] as ScenePoint
: null;
this._applyView(this._zoom, target?.[0], target?.[1]);
this._warmPatch({ vp: this._warmViewportState() });
this.requestUpdate();
}
private _setProjection(projection: 'flat' | 'iso'): void {
if (!this._labsIso || this._mode !== 'view') return;
const from = this._effectiveProjection();
this._viewPreference = { ...this._viewPreference, [this._space]: projection };
if (projection === 'iso') {
const retry = this._isoSceneKey();
if (retry) this._isoFallback.delete(retry);
}
this._saveViewPreference();
const to = this._effectiveProjection();
this._convertProjectionView(from, to);
}
private _onLabsSnapshot = (next: LabsSnapshot): void => {
const from = this._effectiveProjection();
this._labs = next;
const to = this._effectiveProjection();
this._convertProjectionView(from, to);
this.requestUpdate();
};
/** Deep-link: read `#space=<id>` from the URL (used by embedded houseplan-space-card). */
private _hashSpace(): string {
const m = /(?:^|[#&])space=([^&]+)/.exec(window.location.hash || '');
return m ? decodeURIComponent(m[1]) : '';
return hashSpace(window.location.hash || '');
}
private _onHashChange = (): void => {
const id = this._hashSpace();
@@ -1747,6 +1820,8 @@ class HouseplanCard extends LitElement {
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
}
window.addEventListener('hashchange', this._onHashChange);
this._labsUnsub?.();
this._labsUnsub = subscribeLabs(CARD_VERSION, this._onLabsSnapshot);
// AUD-1552-01: the boot-veil timers die in disconnectedCallback, so a
// disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view
// switch remounts the card) used to strand _booting=true with no watcher
@@ -1832,6 +1907,8 @@ class HouseplanCard extends LitElement {
clearTimeout(this._bootSoftTimer);
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
window.removeEventListener('hashchange', this._onHashChange);
this._labsUnsub?.();
this._labsUnsub = undefined;
clearTimeout(this._holdTimer);
this._roViewport?.disconnect();
this._roViewport = undefined;
@@ -2175,6 +2252,14 @@ class HouseplanCard extends LitElement {
} catch {
this._zoomBySpace = {};
}
try {
const stored = JSON.parse(localStorage.getItem(LS_VIEW) || '{}') || {};
this._viewPreference = Object.fromEntries(Object.entries(stored)
.filter((entry): entry is [string, 'flat' | 'iso'] => entry[1] === 'flat' || entry[1] === 'iso'));
} catch {
this._viewPreference = {};
}
this._labs = currentLabs(CARD_VERSION);
try {
const ks = JSON.parse(localStorage.getItem(LS_KIOSK) || 'null');
this._kioskScale = { icon: clampScale(ks?.icon), font: clampScale(ks?.font) };
@@ -2381,8 +2466,16 @@ class HouseplanCard extends LitElement {
// pending intent is REPLACED here, never merely added to.
this._pendingNavMode = vp.mode !== 'view' && !this._canEdit && !config.kiosk ? vp.mode : null;
this._zoom = vp.zoom;
this._view = vp.view ? { ...vp.view } : null;
this._viewModeSnap = vp.snap ? { ...vp.snap } : null;
const projection = this._effectiveProjection();
const sameProjection = projection === vp.projection && this._labsIso === vp.activeLabsIso;
this._view = sameProjection && vp.view ? { ...vp.view } : null;
this._viewModeSnap = sameProjection && vp.snap ? { ...vp.snap } : null;
if (!sameProjection && vp.logicalCenter) {
const center = projection === 'iso'
? projectPlanPoint([vp.logicalCenter.x, vp.logicalCenter.y], 0)
: [vp.logicalCenter.x, vp.logicalCenter.y] as ScenePoint;
this._applyView(vp.zoom, center[0], center[1]);
}
this._tool = normalizeMarkupTool(vp.tool);
this._decorTool = vp.decorTool;
this._showHidden = vp.showHidden;
@@ -2428,9 +2521,13 @@ class HouseplanCard extends LitElement {
}
private _warmViewportState(): WarmViewport {
const projection = this._effectiveProjection();
return {
space: this._space,
mode: this._mode,
projection,
activeLabsIso: this._labsIso,
logicalCenter: this._logicalViewCenter(projection),
zoom: this._zoom,
view: this._view ? { ...this._view } : null,
snap: this._viewModeSnap ? { ...this._viewModeSnap } : null,
@@ -4321,7 +4418,87 @@ class HouseplanCard extends LitElement {
}
/** The rectangle "fit to screen" fits — always the content (docs/CANVAS.md). */
private _isoSource(): { key: string; build: () => any } {
const space = this._spaceModel();
const walls = this._spaceWalls;
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
const openings = (this._curSpaceCfg?.openings || []).map((opening: any) => ({
x: Number(opening.x) * NORM_W,
y: Number(opening.y) * NORM_W,
angle: Number(opening.angle) || 0,
length: (Number(opening.length) > 0 ? Number(opening.length) : 0.9) * NORM_W,
}));
const key = `${space.id}|${isoGeometryFingerprint({
rooms: space.rooms, walls, openCuts, openings,
partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns,
cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch,
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 1,
})}`;
return {
key,
build: () => {
const extras = physicalBodies(space, this._cellCm, this._gridPitch);
const united = walls.length || extras.length
? wallBodiesGeometry(
space.rooms, walls, openCuts, openings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
)
: { geom: [] };
if (!united) throw new Error('wall boolean geometry failed');
return united.geom;
},
};
}
private _isoSceneKey(): string | null {
if (!this._labsIso || this._mode !== 'view') return null;
try { return this._isoSource().key; } catch { return `${this._space}|invalid`; }
}
private _isoScene(): { key: string; geometry: IsoWallGeometry; frame: Rect } {
const source = this._isoSource();
const cached = this._isoGeometryCache.get(source.key);
if (cached) return { key: source.key, ...cached };
const flat = this._frameOf().rect;
const frame = projectedFrame({ rect: flat, wallHeight: ISO_WALL_HEIGHT });
const geometry = buildIsoWallGeometry(source.build());
const value = { geometry, frame };
lruWrite(this._isoGeometryCache, source.key, value, 8);
return { key: source.key, ...value };
}
private _effectiveProjection(): 'flat' | 'iso' {
if (this._desiredProjection !== 'iso' || !this._model.length) return 'flat';
try {
const scene = this._isoScene();
return isoEffectiveView('iso', scene.key, this._isoFallback);
} catch (error) {
const key = this._isoSceneKey() || `${this._space}|invalid`;
if (!this._isoFallback.has(key)) {
this._isoFallback.add(key);
const parts = key.split('|');
console.warn(`HOUSEPLAN ISO FALLBACK: #89, space ${this._space}, fingerprint ${parts[parts.length - 1]}, ${error instanceof Error ? error.message : 'renderer error'}`);
}
return 'flat';
}
}
private _scenePoint(point: readonly [number, number]): ScenePoint {
return this._renderProjection === 'iso' ? projectPlanPoint(point, 0) : point;
}
private _floorView(view: { x: number; y: number; w: number; h: number }): ViewRect {
if (this._renderProjection !== 'iso') return view;
const start = unprojectFloorPoint([view.x, view.y]);
const end = unprojectFloorPoint([view.x + view.w, view.y + view.h]);
return { x: start[0], y: start[1], w: end[0] - start[0], h: end[1] - start[1] };
}
private _baseVb(): number[] {
if (this._effectiveProjection() === 'iso') {
const frame = this._isoScene().frame;
return [frame.x, frame.y, frame.w, frame.h];
}
const r = this._frameOf().rect;
return [r.x, r.y, r.w, r.h];
}
@@ -4366,7 +4543,8 @@ class HouseplanCard extends LitElement {
if (this._booting) return nothing;
const v = this._view;
if (!v || !v.w || !v.h) return nothing;
const f = this._frameOf().rect;
const base = this._baseVb();
const f = { x: base[0], y: base[1], w: base[2], h: base[3] };
const gone = f.x + f.w <= v.x || f.x >= v.x + v.w || f.y + f.h <= v.y || f.y >= v.y + v.h;
if (!gone) return nothing;
const ang = Math.atan2((f.y + f.h / 2) - (v.y + v.h / 2), (f.x + f.w / 2) - (v.x + v.w / 2));
@@ -5310,6 +5488,7 @@ class HouseplanCard extends LitElement {
return;
}
const previousMode = this._mode;
const previousProjection = this._effectiveProjection();
const capturedVisual = this._currentModeVisual(previousMode);
const retargeting = this._modeTransitionBusy;
// The target toolbar replaces the old editor markup during the hidden
@@ -5346,9 +5525,17 @@ class HouseplanCard extends LitElement {
this._viewModeSnap = {
space: this._space,
zoom: this._zoom,
cx: v ? v.x + v.w / 2 : undefined,
cy: v ? v.y + v.h / 2 : undefined,
cx: v ? this._logicalViewCenter(previousProjection)?.x : undefined,
cy: v ? this._logicalViewCenter(previousProjection)?.y : undefined,
};
if (previousProjection === 'iso') {
const logical = this._logicalViewCenter('iso');
targetCenterX = logical?.x;
targetCenterY = logical?.y;
this._view = null;
this._mode = mode;
this._applyView(this._zoom, logical?.x, logical?.y);
}
if (baseChanges) {
targetZoom = 1;
targetCenterX = undefined;
@@ -5371,8 +5558,12 @@ class HouseplanCard extends LitElement {
// restore the snapshot only for the space it was taken in
if (snap && snap.space === this._space) {
targetZoom = snap.zoom;
targetCenterX = snap.cx;
targetCenterY = snap.cy;
const targetProjection = this._labsIso && this._viewPreference[this._space] === 'iso'
? 'iso' : 'flat';
const center = snap.cx != null && snap.cy != null && targetProjection === 'iso'
? projectPlanPoint([snap.cx, snap.cy], 0) : null;
targetCenterX = center?.[0] ?? snap.cx;
targetCenterY = center?.[1] ?? snap.cy;
} else if (snap) {
// HP-1543-01: the floor CHANGED inside the editor — the snapshot
// belongs to the floor the editor was entered from, while _zoom still
@@ -5395,6 +5586,10 @@ class HouseplanCard extends LitElement {
this._modeTransitionTargetZoom = targetZoom;
this._modeTransitionTargetCenterX = targetCenterX;
this._modeTransitionTargetCenterY = targetCenterY;
if (previousProjection === 'iso'
|| (mode === 'view' && this._labsIso && this._viewPreference[this._space] === 'iso')) {
this._modeTransitionForceAtomic = true;
}
const request = ++this._modeTransitionRequest;
if (!animate) {
// The only non-animated caller is route departure; keep the guard local
@@ -9799,7 +9994,8 @@ class HouseplanCard extends LitElement {
}
private _renderSvgRoomLabels(space: SpaceModel, disp: SpaceDisplay): TemplateResult {
if (space.bg || disp.showNames || this._markup) return svg`` as unknown as TemplateResult;
if (this._renderProjection === 'iso' || space.bg || disp.showNames || this._markup)
return svg`` as unknown as TemplateResult;
return svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {
const center = this._roomCenter(room);
return svg`<text class="rlabel" data-hp="room-label"
@@ -9809,6 +10005,7 @@ class HouseplanCard extends LitElement {
}
private _renderWallBodies(disp: SpaceDisplay): TemplateResult {
if (this._renderProjection === 'iso') return svg`` as unknown as TemplateResult;
if (disp && !disp.showBorders && (this._mode === 'view' || this._mode === 'devices'))
return svg`` as unknown as TemplateResult;
const walls = this._spaceWalls;
@@ -9852,6 +10049,18 @@ class HouseplanCard extends LitElement {
</g>` as unknown as TemplateResult;
}
private _renderIsoWalls(): TemplateResult {
if (this._renderProjection !== 'iso') return svg`` as unknown as TemplateResult;
const disp = this._spaceDisplayForRender();
if (!disp.showBorders) return svg`` as unknown as TemplateResult;
const scene = this._isoScene();
return svg`<g class="iso-walls" data-hp="iso-walls" data-fingerprint=${scene.key}>
<g class="iso-wall-sides">${scene.geometry.sides.map((face) =>
svg`<path class="iso-wall-side" d=${face.d} data-edge=${face.edge}></path>`)}</g>
<path class="iso-wall-top" d=${scene.geometry.topPath} fill-rule="evenodd"></path>
</g>` as unknown as TemplateResult;
}
/**
* View-only clean-floor perimeter above real wall bodies.
*
@@ -13630,6 +13839,10 @@ class HouseplanCard extends LitElement {
}
const space = this._spaceModel();
const vb = space.vb;
const projection = this._effectiveProjection();
this._renderProjection = projection;
if (this._labs.active.length) noteLabsRender(CARD_VERSION);
const iso = projection === 'iso';
// hidden devices render ONLY in the device editor with "show hidden" on
// (ghosted); everywhere else the flag removes them from sight — but not
// from the build, so room LQI still counts them (docs/FILTERING.md)
@@ -13642,7 +13855,8 @@ class HouseplanCard extends LitElement {
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const view = this._viewOr(vb);
const view = this._viewOr(this._baseVb());
const floorView = this._floorView(view);
// Background around the plan (view/kiosk; editors keep their own canvas).
// Both settings dialogs preview their pending value live.
const stageBg = this._editing ? '' : this._stageBg(disp);
@@ -13737,6 +13951,15 @@ class HouseplanCard extends LitElement {
: nothing}
<span class="count">${this._t('count.devices', { n: devs.filter((d) => !d.hidden).length })}</span>
<span class="spacer"></span>
${this._labsIso && this._mode === 'view' && !this._kiosk
? html`<button class="btn projection-toggle ${iso ? 'on' : ''}"
data-hp="projection-toggle" aria-pressed=${iso ? 'true' : 'false'}
aria-label=${this._t('view.volumetric')}
title=${this._t(iso ? 'view.flat' : 'view.volumetric')}
@click=${() => this._setProjection(iso ? 'flat' : 'iso')}>
<ha-icon icon=${iso ? 'mdi:view-grid-outline' : 'mdi:cube-outline'}></ha-icon>
</button>`
: nothing}
<div class="zoomctl">
<button class="btn zb" @click=${() => this._stepZoom(-1)} title=${this._t('title.zoom_out')}><ha-icon icon="mdi:minus"></ha-icon></button>
${''/* docs/CANVAS.md §8: this IS «вписать всё» — the old "reset
@@ -13783,7 +14006,8 @@ class HouseplanCard extends LitElement {
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
?inert=${this._continuity.overlayBlocksInteraction || this._modeTransitionBusy}
style="${transitionBrightness !== 1 ? `filter:brightness(${transitionBrightness.toFixed(3)})` : ''}">
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
<svg viewBox="${floorView.x} ${floorView.y} ${floorView.w} ${floorView.h}"
preserveAspectRatio=${iso ? 'none' : 'xMidYMid meet'}>
${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner
2026-08-04). Opaque shapes stop the scene background —
bg_color or the 'daynight' sky — from bleeding through the
@@ -13983,6 +14207,10 @@ class HouseplanCard extends LitElement {
${this._renderBackdropFrame(view)}
${this._renderTextFrame(view)}
</svg>
${iso ? svg`<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoWalls()}
</svg>` : nothing}
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
scales with it when you zoom — the behaviour the card always
had, restored by the owner. `iconCqw` is `iconPct * iconUnit
@@ -13998,7 +14226,7 @@ class HouseplanCard extends LitElement {
${this._renderVacuums(devs, view)}
${this._renderVacFit(view)}
${this._renderOpeningLocks(view)}
${disp.showNames || this._markup
${disp.showNames || (iso && !space.bg) || this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
: nothing}
${this._markup ? space.rooms.map((r) => this._renderRoomGear(r, space, view)) : nothing}
@@ -14842,7 +15070,8 @@ class HouseplanCard extends LitElement {
if (tmode === 'always' && srvPrev && srvPrev.points.length > 1) {
const pts = srvPrev.points.map(([x, y]: number[]) => {
const [cx2, cy2] = applyAffine(matrix, x, y);
return cx2.toFixed(1) + ',' + cy2.toFixed(1);
const point = this._scenePoint([cx2, cy2]);
return point[0].toFixed(1) + ',' + point[1].toFixed(1);
}).join(' ');
trails.push(svg`<g class="prev"><polyline class="case" points="${pts}"></polyline><polyline class="core" points="${pts}"></polyline></g>`);
}
@@ -14857,7 +15086,8 @@ class HouseplanCard extends LitElement {
if (raw.length) {
const pathD = raw.map((segment) => segment.map(([x, y], index) => {
const [cx, cy] = applyAffine(matrix, x, y);
return `${index ? 'L' : 'M'} ${cx.toFixed(1)} ${cy.toFixed(1)}`;
const point = this._scenePoint([cx, cy]);
return `${index ? 'L' : 'M'} ${point[0].toFixed(1)} ${point[1].toFixed(1)}`;
}).join(' ')).join(' ');
// A single SVG path with several M commands keeps gaps literal.
trails.push(svg`<path class="case" d="${pathD}"></path><path class="core" d="${pathD}"></path>`);
@@ -14865,15 +15095,17 @@ class HouseplanCard extends LitElement {
if (moving && last?.length >= 2) {
const anchor = last[last.length - 1];
const [ax, ay] = applyAffine(matrix, anchor[0], anchor[1]);
const a1 = ax.toFixed(1), a2 = ay.toFixed(1);
const point = this._scenePoint([ax, ay]);
const a1 = point[0].toFixed(1), a2 = point[1].toFixed(1);
trails.push(svg`<line class="case tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line><line class="core tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line>`);
}
}
}
if (!moving || !tele.pos) continue;
const [cx, cy] = applyAffine(matrix, tele.pos.x, tele.pos.y);
const left = ((cx - view.x) / view.w) * 100;
const top = ((cy - view.y) / view.h) * 100;
const point = this._scenePoint([cx, cy]);
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const stale = rt && rt.lastTs > 0 && Date.now() - rt.lastTs > VAC_STALE_MS;
const icon = d.marker?.icon || d.icon || 'mdi:robot-vacuum';
pucks.push(html`<div
@@ -14895,8 +15127,9 @@ class HouseplanCard extends LitElement {
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true): TemplateResult {
const pos = this._pos(d);
const left = ((pos.x - view.x) / view.w) * 100;
const top = ((pos.y - view.y) / view.h) * 100;
const point = this._scenePoint([pos.x, pos.y]);
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const presentation = this._devicePresentation(d, showLqi);
const st = [`left:${left}%`, `top:${top}%`, ...deviceFaceStyle(presentation)];
const disabledReason = presentation.disabledReason;
@@ -15267,8 +15500,9 @@ class HouseplanCard extends LitElement {
// editor — that is where you name them (field report, 2026-07-27)
if (!r.name && !this._markup) return nothing;
const p = this._labelPos(r, space.id);
const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100;
const point = this._scenePoint([p.x, p.y]);
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const op = Math.min(1, disp.opacity + 0.25);
const k = this._labelScale(r);
// Optional metrics row. Light sources may use an explicit room_id even
@@ -15650,8 +15884,9 @@ class HouseplanCard extends LitElement {
const off = gateFace ? -16 * gateFace.side : 16 * (o.flip_v ? -1 : 1);
const px = o.rx + Math.cos(rad) * off;
const py = o.ry + Math.sin(rad) * off;
const left = ((px - view.x) / view.w) * 100;
const top = ((py - view.y) / view.h) * 100;
const point = this._scenePoint([px, py]);
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
return html`<div class="oplock ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}"
style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => { e.stopPropagation(); if (this._mode === 'view') this._openingInfo = o; }}>
+2
View File
@@ -505,6 +505,8 @@
"marker.show_entities_tip": "Adds not only devices to the list, but all their entities too",
"marker.pick_ph": "Choose a device…",
"room.open_area": "Open the HA area",
"view.volumetric": "Volumetric view",
"view.flat": "Flat view",
"kiosk.title": "This screen's sizes",
"kiosk.hint": "Stored on this device only — every wall tablet or TV can have its own comfortable sizes.",
"kiosk.icon_scale": "Device icon size",
+2
View File
@@ -505,6 +505,8 @@
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
"marker.pick_ph": "Выберите устройство…",
"room.open_area": "Открыть зону в HA",
"view.volumetric": "Объёмный вид",
"view.flat": "Плоский вид",
"kiosk.title": "Размеры на этом экране",
"kiosk.hint": "Хранится только на этом устройстве — у каждого настенного планшета или ТВ свои удобные размеры.",
"kiosk.icon_scale": "Размер значков устройств",
+6
View File
@@ -17,6 +17,12 @@ export interface IsoWallGeometry {
edgeCount: number;
}
export function isoEffectiveView(
desired: 'flat' | 'iso', fingerprint: string, failed: ReadonlySet<string>,
): 'flat' | 'iso' {
return desired === 'iso' && fingerprint && !failed.has(fingerprint) ? 'iso' : 'flat';
}
const pointText = (point: ScenePoint): string =>
`${Number(point[0].toFixed(4))} ${Number(point[1].toFixed(4))}`;
+4 -2
View File
@@ -183,7 +183,8 @@ export function subscribeLabs(
version: string, subscriber: (value: LabsSnapshot) => void,
): () => void {
subscribers.add(subscriber);
subscriber(ensureBrowserLabs(version));
const current = ensureBrowserLabs(version);
if (typeof window === 'undefined') subscriber(current);
return () => { subscribers.delete(subscriber); };
}
@@ -193,7 +194,8 @@ export function currentLabs(version: string): LabsSnapshot {
/** Diagnostics are emitted only when an active Labs frame is actually rendered. */
export function noteLabsRender(version: string): void {
const current = currentLabs(version);
if (!configuredVersion) ensureBrowserLabs(version);
const current = snapshot;
const signature = current.active.join(',');
if (!signature || signature === loggedSignature) return;
loggedSignature = signature;
+27
View File
@@ -383,6 +383,12 @@ export const cardStyles = css`
opacity: 0.4;
pointer-events: none;
}
.projection-toggle {
min-width: 44px;
min-height: 44px;
justify-content: center;
padding: var(--sp-3);
}
/* docs/CANVAS.md §4.1: objects an order of magnitude away from the plan
do not decide the opening view — one quiet chip says so and offers to
take them in. Never a modal (owner). */
@@ -463,6 +469,26 @@ export const cardStyles = css`
height: 100%;
display: block;
}
.iso-walls-svg {
z-index: 4;
overflow: visible;
}
.iso-wall-side {
fill: #a8acae;
stroke: #92989b;
stroke-width: 0.7;
vector-effect: non-scaling-stroke;
}
.iso-wall-top {
fill: #f3f3f1;
stroke: #d7d9d8;
stroke-width: 0.8;
vector-effect: non-scaling-stroke;
}
@media (prefers-color-scheme: dark) {
.iso-wall-side { fill: #3f474c; stroke: #333a3e; }
.iso-wall-top { fill: #596166; stroke: #747d82; }
}
/* 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,
@@ -1654,6 +1680,7 @@ export const cardStyles = css`
position: absolute;
inset: 0;
pointer-events: none;
z-index: 6;
}
.dev {
position: absolute;
+11 -1
View File
@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildIsoWallGeometry, isoGeometryFingerprint } from '../test-build/iso-walls.js';
import {
buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint,
} from '../test-build/iso-walls.js';
const closed = (points) => [...points, points[0]];
const square = closed([[0, 0], [100, 0], [100, 100], [0, 100]]);
@@ -42,3 +44,11 @@ test('fingerprint is content based, stable and sensitive to in-place geometry ed
isoGeometryFingerprint({ rooms: value.rooms, showBorders: false }));
});
test('latched renderer failures stay flat until explicit retry or a new fingerprint', () => {
const failed = new Set(['space|old']);
assert.equal(isoEffectiveView('iso', 'space|old', failed), 'flat');
assert.equal(isoEffectiveView('iso', 'space|new', failed), 'iso');
assert.equal(isoEffectiveView('flat', 'space|new', failed), 'flat');
failed.delete('space|old');
assert.equal(isoEffectiveView('iso', 'space|old', failed), 'iso');
});
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../src/styles.ts', import.meta.url), 'utf8');
const spaceCard = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
const spaceRender = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8');
test('Labs iso is presentation-only and absent from the secondary space card', () => {
assert.match(card, /subscribeLabs\(CARD_VERSION/);
assert.match(card, /data-hp="projection-toggle"/);
assert.match(card, /this\._labsIso && this\._mode === 'view' && !this\._kiosk/);
assert.doesNotMatch(spaceCard, /hp-labs|iso-walls|projection-toggle|view\.volumetric/);
assert.doesNotMatch(spaceRender, /hp-labs|iso-walls|projection-toggle|view\.volumetric/);
});
test('renderer uses SVG geometry without a CSS 3D context or new window light', () => {
assert.match(card, /<svg class="iso-walls-svg"/);
assert.match(card, /class="iso-wall-top"/);
assert.match(card, /class="iso-wall-side"/);
assert.doesNotMatch(styles, /perspective\s*:|preserve-3d|rotateX\(|rotateZ\(/);
assert.doesNotMatch(card, /iso-window|window-light|vertical-door/);
});
test('all current floor/live layers remain in the one main scene', () => {
for (const renderer of [
'_renderDecorLayer()', '_renderRoomHoverFill(roomHover)', '_renderGlowLayer(space, disp)',
'_renderSunRays(space)', '_renderOpenings(disp)', '_renderVacuums(devs, view)',
]) assert.ok(card.includes(renderer), `missing ${renderer}`);
assert.match(card, /const point = this\._scenePoint\(\[pos\.x, pos\.y\]\)/);
assert.match(card, /const point = this\._scenePoint\(\[p\.x, p\.y\]\)/);
});