fix: address custom decor image review

Issue: #51
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-02 21:56:04 +00:00
committed by claude[bot]
parent fe8970ee84
commit 590d178e29
28 changed files with 462 additions and 307 deletions
+36 -2
View File
@@ -1,4 +1,5 @@
import type { DecorShape } from './editors/decor/types';
import { clamp01, normalizeAngle } from './editors/decor/geometry';
export const DECOR_ASSETS_API_VERSION = 1;
export const DECOR_ASSET_ID_RE = /^[0-9a-f]{64}$/;
@@ -15,6 +16,12 @@ export interface DecorAsset {
used_by?: { space_id: string; decor_id: string }[];
}
export type DecorImageProjection = readonly [
x: number, y: number, w: number, h: number, opacity: number, transform: string,
];
const resolveCache = new WeakMap<object, [string, Map<string, DecorAsset>]>();
export function decorAssetIds(config: any): string[] {
const ids = new Set<string>();
for (const space of config?.spaces || []) {
@@ -25,6 +32,25 @@ export function decorAssetIds(config: any): string[] {
return [...ids];
}
/** One projection contract shared by the full and static renderers. */
export function projectDecorImage(
shape: Extract<DecorShape, { kind: 'image' }>,
canvasWidth: number,
canvasHeight: number,
): DecorImageProjection | null {
const x = Number(shape.x) * canvasWidth;
const y = Number(shape.y) * canvasHeight;
const w = Number(shape.w) * canvasWidth;
const h = Number(shape.h) * canvasHeight;
if (![x, y, w, h].every(Number.isFinite) || w <= 0 || h <= 0) return null;
const cx = x + w / 2;
const cy = y + h / 2;
const angle = normalizeAngle(shape.angle);
const opacity = clamp01(shape.opacity, 1);
const transform = `translate(${cx} ${cy}) rotate(${angle}) scale(${shape.flip_h ? -1 : 1} ${shape.flip_v ? -1 : 1}) translate(${-cx} ${-cy})`;
return [x, y, w, h, opacity, transform];
}
export function adoptDecorAssets(value: unknown): Map<string, DecorAsset> {
const out = new Map<string, DecorAsset>();
const rows = (value as { assets?: unknown })?.assets;
@@ -47,10 +73,17 @@ export function adoptDecorAssets(value: unknown): Map<string, DecorAsset> {
/** Resolve every unique id without ever exceeding the backend message cap. */
export async function resolveDecorAssets(
hass: { callWS: (message: Record<string, unknown>) => Promise<unknown> },
hass: {
callWS: (message: Record<string, unknown>) => Promise<unknown>;
connection?: object;
},
assetIds: readonly string[],
): Promise<Map<string, DecorAsset>> {
const unique = [...new Set(assetIds.filter((id) => DECOR_ASSET_ID_RE.test(id)))];
const unique = [...new Set(assetIds.filter((id) => DECOR_ASSET_ID_RE.test(id)))].sort();
const owner = hass.connection || hass;
const key = unique.join(',');
const cached = resolveCache.get(owner);
if (cached?.[0] === key) return cached[1];
const out = new Map<string, DecorAsset>();
for (let offset = 0; offset < unique.length; offset += DECOR_ASSET_RESOLVE_BATCH) {
const response = await hass.callWS({
@@ -59,6 +92,7 @@ export async function resolveDecorAssets(
});
for (const [id, asset] of adoptDecorAssets(response)) out.set(id, asset);
}
resolveCache.set(owner, [key, out]);
return out;
}
+5 -5
View File
@@ -343,7 +343,7 @@ import {
} from './ha-binding-status';
import type { DecorShape, DecorStyle } from './editors/decor/types';
import {
DECOR_ASSETS_API_VERSION, decorAssetIds,
DECOR_ASSETS_API_VERSION, decorAssetIds, projectDecorImage,
resolveDecorAssets, type DecorAsset,
} from './decor-assets';
import {
@@ -8760,10 +8760,10 @@ export class HouseplanCard extends LitElement {
transform=${ang ? `rotate(${ang} ${cx} ${cy})` : nothing} @pointerdown=${down}></ellipse>` : nothing}`;
}
if (sh.kind === 'image') {
const projection = projectDecorImage(sh, W, H);
if (!projection) return nothing;
const [x, y, w, h, opacity, transform] = projection;
const asset = this._decorAssets.get(sh.asset_id);
const x = sh.x * W, y = sh.y * H, w = sh.w * W, h = sh.h * H;
const cx = x + w / 2, cy = y + h / 2;
const transform = `translate(${cx} ${cy}) rotate(${normalizeAngle(sh.angle)}) scale(${sh.flip_h ? -1 : 1} ${sh.flip_v ? -1 : 1}) translate(${-cx} ${-cy})`;
if (!asset) return editing && this._editorRuntime
? this._editorRuntime._renderMissingDecorImage(sh, cls, transform, x, y, w, h, down, dbl)
: nothing;
@@ -8771,7 +8771,7 @@ export class HouseplanCard extends LitElement {
if (!href) return nothing;
return svg`<image class="${cls} dimage" data-hp="decor" data-id=${sh.id}
data-kind="image" href=${href} x=${x} y=${y} width=${w} height=${h}
opacity=${clamp01(sh.opacity, 1)} preserveAspectRatio="none" transform=${transform}
opacity=${opacity} preserveAspectRatio="none" transform=${transform}
@load=${() => this._signer.markLoaded(this._renderPlanHass, asset.url, href)}
@pointerdown=${down} @dblclick=${dbl}></image>
${erasing ? svg`<rect class="dshape derasehit" data-hp="decor" data-id=${sh.id}
+4 -4
View File
@@ -4200,7 +4200,7 @@ public _decorPointerDown(ev: PointerEvent): boolean {
const pointerType = ev.pointerType || 'mouse';
if (pointerType === 'mouse') {
if (t === 'furniture') this._furnPlace(this._svgPoint(ev), ev.shiftKey, pointerType);
else this._decorImagePlace(this._svgPoint(ev));
else this._decorImagePlace(this._svgPoint(ev), pointerType);
return true;
}
const pending = this.host._furnTouchPending;
@@ -5042,7 +5042,7 @@ public _furnPointerUp(ev: PointerEvent): boolean {
|| (this.host._decorTool === 'image' && this.host._decorImagePalette))) {
if (this.host._decorTool === 'furniture')
this._furnPlace(this._svgPoint(ev), ev.shiftKey, pending.pointerType);
else this._decorImagePlace(this._svgPoint(ev));
else this._decorImagePlace(this._svgPoint(ev), pending.pointerType);
}
return true;
}
@@ -5127,11 +5127,11 @@ public _decorApplyBox(id: string, box: { x: number; y: number; w: number; h: num
this.host.requestUpdate();
}
public _decorImagePlace(raw: number[]): void {
public _decorImagePlace(raw: number[], pointerType = 'mouse'): void {
const asset = this.host._decorImagePalette;
const sp = this.host._curSpaceCfg;
if (!asset || !sp) return;
const at = this._decorSnap(raw);
const at = this._decorSnap(raw, pointerType);
const size = initialDecorImageCm(asset.width, asset.height);
const w = decorCmToUnits(size.w, this.host._cellCm, this.host._gridPitch) / NORM_W;
const h = decorCmToUnits(size.h, this.host._cellCm, this.host._gridPitch) / this.host._decorH;
+1 -1
View File
@@ -730,7 +730,7 @@ class HouseplanSpaceCard extends LitElement {
if (configChanged) this._continuity.note('config-candidate', { configRev: snap.rev });
try {
this._decorAssets = await resolveDecorAssets(this.hass, decorAssetIds(snap.config));
} catch { /* retain the last complete frame across a transient resolve failure */ }
} catch { /* retain the last complete frame; failed sets are not cached and retry next load */ }
if (layoutChanged) this._continuity.note('layout-candidate', { layoutRev: snap.layoutRev });
if (virtualLightsChanged) this._capturedSnapshotSequence = -1;
this._loadedOnce = true;
+5 -8
View File
@@ -57,6 +57,7 @@ import {
import { resolveZeroWalls } from './zero-walls';
import { geometryOpenings } from './plan-geometry-preflight';
import { resolveDeviceAreaRelocations } from './device-area-relocation';
import { projectDecorImage } from './decor-assets';
import {
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
@@ -638,16 +639,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const raw = o.decorAssetUrl?.(String(shape.asset_id || '')) || '';
const href = raw && o.displayUrl ? o.displayUrl(raw) : raw;
if (!href) return [];
const x = Number(shape.x) * NORM_W, y = Number(shape.y) * NORM_W;
const w = Number(shape.w) * NORM_W, h = Number(shape.h) * NORM_W;
if (![x, y, w, h].every(Number.isFinite) || w <= 0 || h <= 0) return [];
const cx = x + w / 2, cy = y + h / 2;
const angle = Number(shape.angle) || 0;
const transform = `translate(${cx} ${cy}) rotate(${angle}) scale(${shape.flip_h ? -1 : 1} ${shape.flip_v ? -1 : 1}) translate(${-cx} ${-cy})`;
const projection = projectDecorImage(shape, NORM_W, NORM_W);
if (!projection) return [];
const [x, y, w, h, opacity, transform] = projection;
return [svg`<image class="dimage" data-hp="decor"
href=${href} x=${x} y=${y} width=${w} height=${h}
opacity=${Number.isFinite(Number(shape.opacity))
? Math.max(0, Math.min(1, Number(shape.opacity))) : 1}
opacity=${opacity}
@load=${() => o.assetLoaded?.(raw, href)}
preserveAspectRatio="none" transform=${transform} pointer-events="none"></image>`];
});