mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
committed by
claude[bot]
parent
fe8970ee84
commit
590d178e29
+36
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>`];
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user