feat: implement isometric stage 3

Issue: #160
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-06 07:06:54 +03:00
parent 3bf9e2d6d3
commit ee7d486924
87 changed files with 7676 additions and 1375 deletions
+17
View File
@@ -10,6 +10,23 @@ export interface EditorRuntimeLoaderFailure {
readonly terminal: boolean;
}
export type SafeRuntimeDiagnostic = Readonly<{
kind: 'runtime-load' | 'structural';
fingerprint: string;
terminal: boolean;
}>;
/** Allow only an internal short content hash or a fixed non-secret sentinel. */
export function safeRuntimeDiagnostic(
kind: SafeRuntimeDiagnostic['kind'], fingerprint: unknown, terminal: boolean,
): SafeRuntimeDiagnostic {
const value = typeof fingerprint === 'string' && (
fingerprint === 'invalid' || fingerprint === 'no-borders' || fingerprint === 'unverified'
|| /^[a-z0-9]{1,8}$/i.test(fingerprint)
) ? fingerprint : 'redacted';
return Object.freeze({ kind, fingerprint: value, terminal: !!terminal });
}
export interface EditorRuntimeLoaderOptions<Runtime> {
readonly expectedFingerprint: string;
readonly load: (attempt: 0 | 1) => Promise<EditorRuntimeModule<Runtime>>;
+307 -306
View File
@@ -327,7 +327,7 @@ import {
roomFitGeometryBounds, roomFitOwnerFromPath, STAGE_TAP_DISTANCE_PX,
type RoomFitGestureCandidate,
} from './room-fit';
import { EditorRuntimeLoader, lazyLoadFailureMessage, type EditorRuntimeLoaderState } from './editor-runtime-loader';
import { EditorRuntimeLoader, lazyLoadFailureMessage, safeRuntimeDiagnostic, type EditorRuntimeLoaderState } from './editor-runtime-loader';
import type { BackdropGuardState } from './backdrop-pick';
import {
expiredWarmViewport, lruRead, lruWrite, normalizeMarkupTool, strictNumber,
@@ -344,18 +344,16 @@ import {
currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot,
} from './labs';
import {
ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, isoFloorMatrixCss,
ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, isoFloorMatrixCss,
projectPlanPoint, projectedFrame,
unprojectFloorPoint, type ScenePoint, type ViewRect,
unprojectFloorPoint, type PlanPoint, type ScenePoint,
} from './iso-projection';
import {
buildIsoFloorGeometry, buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint,
type IsoFloorGeometry, type IsoWallGeometry,
} from './iso-walls';
import {
buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening, resolveIsoDecoration,
type IsoDecorationLayers, type IsoOpeningBasis, type IsoOpeningPanel,
} from './iso-openings';
import type { IsoDecorationLayers } from './iso-openings';
import type { IsoOverlayPlacement } from './iso-overlays';
import type {
IsoFramePresentation, IsoOverlayRenderScene, IsoRenderScene, IsoSceneCacheEntry,
IsoStructuralSource,
} from './iso-scene-render';
import {
acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses,
fullRegistryHass, haRegistryBuildSignature, haRegistryDiagnostics, haRegistrySnapshot,
@@ -406,6 +404,7 @@ import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from
const CARD_VERSION = '1.72.0';
const ENTRY_BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const EDITOR_RETRY_ASSET = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
const ISO_RETRY_ASSET = '__HOUSEPLAN_ISO_RETRY_ASSET__';
type ResizeLiveLabel = {
kind: 'length';
@@ -750,6 +749,43 @@ export class HouseplanCard extends LitElement {
},
});
/**
* Stage 3 is an opt-in alpha surface. Keep its renderer and geometry outside
* the ordinary View graph, but use the same exact-build, atomic installation
* contract as the editor runtimes so a stale chunk can never half-install.
*/
private _isoSceneRuntime: typeof import('./iso-scene-render') | null = null;
private readonly _isoSceneRuntimeLoader = new EditorRuntimeLoader<
typeof import('./iso-scene-render')
>({
expectedFingerprint: ENTRY_BUILD_FINGERPRINT,
load: async (attempt) => {
const module = attempt === 0
? await import('./iso-scene-render')
: await import(/* @vite-ignore */ (() => {
const url = new URL(ISO_RETRY_ASSET, import.meta.url);
url.searchParams.set('hp_retry', `${CARD_VERSION}-${++hpLazyRetrySeq}`);
return url.href;
})()) as typeof import('./iso-scene-render');
return {
fingerprint: module.ISO_SCENE_RUNTIME_FINGERPRINT,
create: () => module,
};
},
install: (runtime) => {
const from = this._effectiveProjection();
this._isoSceneRuntime = runtime;
this._isoProjectionSnapshot = null;
const to = this._effectiveProjection();
this._convertProjectionView(from, to);
if (this.isConnected) this.requestUpdate();
},
failed: (_ignored, info) => {
console.warn('[houseplan] hidden isometric runtime diagnostic', safeRuntimeDiagnostic('runtime-load', 'unverified', info.terminal));
if (this.isConnected) this.requestUpdate();
},
});
public _editorRuntimeOrThrow(): import('./houseplan-editor-runtime').HouseplanEditorRuntime {
if (!this._editorRuntime) throw new Error('Houseplan editor runtime is not loaded');
return this._editorRuntime;
@@ -763,6 +799,9 @@ export class HouseplanCard extends LitElement {
return this._onboardingRuntimeLoader.ensure();
}
/** Test/performance hook; product callers trigger it only while alpha is active. */
public async _ensureIsoSceneRuntime(): Promise<boolean> { return this._isoSceneRuntimeLoader.ensure(); }
private _editorRuntimeStateChanged(state: EditorRuntimeLoaderState): void {
clearTimeout(this._editorRuntimeLoadingTimer);
this._editorRuntimeLoadingTimer = undefined;
@@ -1310,7 +1349,7 @@ export class HouseplanCard extends LitElement {
private _adoptMode(mode: HouseplanMode): void {
if (mode !== this._mode) this._clearRoomFocus(true);
this._cancelModeTransition(false);
this._mode = mode;
this._mode = mode; this._isoProjectionSnapshot = null;
if (mode !== 'view') this._editorChromeMode = mode;
}
@@ -1450,7 +1489,7 @@ export class HouseplanCard extends LitElement {
this._cancelDevicePressFeedback();
this._editorRuntime?._clearFurniturePreview();
}
this._space = id;
this._space = id; this._isoProjectionSnapshot = null;
return true;
}
@@ -1778,12 +1817,12 @@ export class HouseplanCard extends LitElement {
this._wallUnionCacheValue = value;
if (value === null) this._wallUnionPool.clear();
}
private _isoGeometryCache = new Map<string, {
geometry: IsoWallGeometry;
floor: IsoFloorGeometry;
openings: readonly IsoOpeningBasis[];
frame: Rect;
}>();
private _isoGeometryCache = new Map<string, IsoSceneCacheEntry>();
private _renderIsoScene: IsoRenderScene | null = null;
private _isoProjectionSnapshot: 'flat' | 'iso' | null = null;
/** Observable Stage 3 performance seam: increments only for a real
* structural LRU miss, never for HA/theme/hover/opening-state paint. */
private _isoStructuralBuildCount = 0;
private _isoFallback = new Set<string>();
private _openingTunnelCache: {
key: string;
@@ -2373,10 +2412,13 @@ export class HouseplanCard extends LitElement {
const from = this._effectiveProjection();
this._viewPreference = { ...this._viewPreference, [this._space]: projection };
if (projection === 'iso') {
void this._ensureIsoSceneRuntime();
const retry = this._isoSceneKey();
if (retry) this._isoFallback.delete(retry);
this._isoFallback.delete(`${this._space}|no-borders`); this._clearIsoInvalidFallback();
}
this._saveViewPreference();
this._isoProjectionSnapshot = null;
const to = this._effectiveProjection();
this._convertProjectionView(from, to);
}
@@ -2384,6 +2426,8 @@ export class HouseplanCard extends LitElement {
private _onLabsSnapshot = (next: LabsSnapshot): void => {
const from = this._effectiveProjection();
this._labs = next;
this._isoProjectionSnapshot = null;
if (this._labsIso) void this._ensureIsoSceneRuntime();
const to = this._effectiveProjection();
this._convertProjectionView(from, to);
this.requestUpdate();
@@ -2560,6 +2604,7 @@ export class HouseplanCard extends LitElement {
window.addEventListener('hashchange', this._onHashChange);
this._labsUnsub?.();
this._labsUnsub = subscribeLabs(this._onLabsSnapshot);
if (this._labsIso) void this._ensureIsoSceneRuntime();
// 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
@@ -3122,6 +3167,7 @@ export class HouseplanCard extends LitElement {
this._viewPreference = {};
}
this._labs = currentLabs();
if (this.isConnected && this._labsIso) void this._ensureIsoSceneRuntime();
try {
const ks = JSON.parse(localStorage.getItem(LS_KIOSK) || 'null');
this._kioskScale = { icon: clampScale(ks?.icon), font: clampScale(ks?.font) };
@@ -4020,6 +4066,7 @@ export class HouseplanCard extends LitElement {
}
protected willUpdate(changed: PropertyValues): void {
this._isoProjectionSnapshot = null;
// `_serverCfg` is the root of every geometry cache. Keep the epoch
// invariant local to that reactive assignment so imports, reconnects and
// demo harnesses cannot accidentally reuse an older config object's data.
@@ -6016,163 +6063,172 @@ export class HouseplanCard extends LitElement {
return this._frame;
}
/** The rectangle "fit to screen" fits — always the content (docs/CANVAS.md). */
private _isoSource(): { key: string; build: () => {
walls: any;
floor: any;
openings: readonly IsoOpeningBasis[];
} } | null {
/** Presentation-only structural source; its expensive build runs on LRU miss. */
private _isoSource(): IsoStructuralSource | null {
const runtime = this._isoSceneRuntime;
if (!runtime) return null;
const space = this._spaceModel();
if (!space) return null;
const walls = this._spaceWalls;
const openCuts = this._openCuts();
const openings = this._openingsR.map((opening, sourceIndex) => ({
id: String(opening.id || sourceIndex),
sourceIndex,
type: opening.type,
x: opening.rx,
y: opening.ry,
angle: Number(opening.angle) || 0,
length: opening.rlen > 0 ? opening.rlen : 0.9 * NORM_W,
flipH: !!opening.flip_h,
flipV: !!opening.flip_v,
}));
const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm);
const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, this._cellCm);
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,
floorEdgeHeight, algorithm: 3,
})}`;
return {
key,
build: () => {
const extras = physicalBodyParts(
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
this._partitionOpeningCuts(space),
).all;
const roomOpenings = this._roomWallOpeningInputs(this._openingsR, space);
const united = walls.length || extras.length
? wallBodiesGeometry(
space.rooms, walls, openCuts, roomOpenings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
)
: null;
if (united && (united.status === 'failed-core' || united.status === 'not-applicable'))
throw new Error('wall boolean geometry failed');
const floor = united?.paperGeom ?? floorFootprintGeometry(
space.rooms, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
if (!floor) throw new Error('floor boolean geometry failed');
const openingIndex = united?.openingIndex || buildOpeningWallIndex(
space.rooms, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
const openingBases = openings.map((opening) => {
const rendered = this._openingsR[opening.sourceIndex];
const faceFlipV = opening.type === 'gate' ? !opening.flipV : opening.flipV;
const face = rendered?.partitionHost
? partitionOpeningFace(rendered.partitionHost, faceFlipV)
: walls.length || opening.type === 'gate'
? openingInnerFaceOffsetFromIndex(openingIndex, {
x: opening.x,
y: opening.y,
angle: opening.angle,
length: opening.length,
flip_v: faceFlipV,
})
: { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 };
return buildIsoOpeningBasis({ ...opening, face }, wallHeight);
});
return {
walls: united?.components.flatMap((component) => component.geom) || [],
floor, openings: Object.freeze(openingBases),
};
},
};
const openings = this._openingsR;
return runtime.createIsoStructuralSource({
space, walls, openCuts, openings,
partitionCuts: () => this._partitionOpeningCuts(space),
roomOpenings: () => this._roomWallOpeningInputs(openings, space),
cellCm: this._cellCm, gridPitch: this._gridPitch,
wallKeyPitch: this._wallKeyPitch, coordinateScale: NORM_W,
onBuild: () => { this._isoStructuralBuildCount += 1; },
});
}
private _isoSceneKey(): string | null {
if (!this._labsIso || this._mode !== 'view') return null;
try { return this._isoSource()?.key ?? null; } catch { return `${this._space}|invalid`; }
try { return this._isoSource()?.key ?? null; } catch { return this._isoInvalidKey(); }
}
private _isoScene(): {
key: string;
geometry: IsoWallGeometry;
floor: IsoFloorGeometry;
openings: readonly IsoOpeningBasis[];
frame: Rect;
} | null {
const source = this._isoSource();
if (!source) return null;
const cached = this._isoGeometryCache.get(source.key);
if (cached) return { key: source.key, ...cached };
const flat = this._frameOf().rect;
const structural = source.build();
const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm);
const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, this._cellCm);
const openingFrame = isoOpeningBounds(structural.openings);
const structuralFrame = openingFrame ? unionRect(flat, openingFrame) : flat;
const frame = projectedFrame({
rect: structuralFrame,
wallHeight,
openingHeight: wallHeight,
floorDepth: floorEdgeHeight,
/** A malformed source is stable only for the current structural config revision. */
private _isoInvalidKey(): string { return `${this._space}|invalid|${this._cfgEpoch}`; }
private _clearIsoInvalidFallback(active?: string): void {
const prefix = `${this._space}|invalid|`;
for (const key of this._isoFallback)
if (key.startsWith(prefix) && key !== active) this._isoFallback.delete(key);
}
private _isoScene(source: IsoStructuralSource | null = this._isoSource()): IsoRenderScene | null {
const runtime = this._isoSceneRuntime;
if (!runtime || !source) return null;
let liveFrame = this._frameOf().rect;
const disp = this._spaceDisplayForRender();
const space = this._spaceModel();
if (space && disp.showNames) {
for (const room of space.rooms) if (room.name) {
const point = this._labelPos(room, space.id);
liveFrame = unionRect(liveFrame, { x: point.x, y: point.y, w: 0, h: 0 }); }
}
const scene = runtime.resolveIsoScene({
source, cache: this._isoGeometryCache, cellCm: this._cellCm, liveFrame,
});
const geometry = buildIsoWallGeometry(structural.walls, ISO_CAMERA, wallHeight);
const floor = buildIsoFloorGeometry(structural.floor, floorEdgeHeight);
const value = { geometry, floor, openings: structural.openings, frame };
lruWrite(this._isoGeometryCache, source.key, value, 8);
return { key: source.key, ...value };
if (!space || !disp.showBorders) return scene;
const cfgSize = this._config?.icon_size ?? 2.5, iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const stageSize = this._stageEl?.getBoundingClientRect?.() ?? null;
const aspect = stageSize?.height ? stageSize.width / stageSize.height : scene.frame.w / scene.frame.h;
const baseView = fitView([scene.frame.x, scene.frame.y, scene.frame.w, scene.frame.h], aspect);
const overlays = this._isoOverlayScene(
space, this._renderDevices.filter((device) => device.space === space.id && !device.hidden),
baseView, disp, runtime.resolveIsoDecorationLayers(disp), scene, iconPct, effectiveDeviceBaseSize(iconPct),
disp.showLqi ?? this._config?.show_signal ?? true);
const envelope = overlays && runtime.resolveIsoOverlayFitEnvelope({
baseBounds: scene.frame, entries: overlays.entries, stageSize, targetView: (bounds) =>
fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect) });
return envelope ? { ...scene, frame: envelope.bounds, overlayFitEntries: overlays.entries } : scene;
}
private _latchIsoFallback(key: string, _ignored: unknown): void {
if (this._isoFallback.has(key)) return;
this._isoFallback.add(key);
const fingerprint = key.includes('|invalid|') ? 'invalid' : key.split('|').pop() || 'invalid';
console.warn('HOUSEPLAN ISO FALLBACK: #89/#160', safeRuntimeDiagnostic('structural', fingerprint, true));
}
private _effectiveProjection(): 'flat' | 'iso' {
if (this._desiredProjection !== 'iso' || !this._model.length) return 'flat';
const key = this._isoSceneKey() || `${this._space}|invalid`;
if (this._isoFallback.has(key)) return 'flat';
if (this._isoProjectionSnapshot) return this._isoProjectionSnapshot;
const finish = (value: 'flat' | 'iso') => this._isoProjectionSnapshot = value;
const fallback = () => { if (this._renderProjection === 'iso') { this._cancelCameraTransition(false); this._view = null; this._viewModeSnap = null; } return finish('flat'); };
this._renderIsoScene = null;
if (this._desiredProjection !== 'iso' || !this._model.length || !this._isoSceneRuntime)
return finish('flat');
if (!this._spaceDisplayForRender().showBorders)
return this._isoFallback.has(`${this._space}|no-borders`) ? fallback() : finish('iso');
let key = this._isoInvalidKey();
this._clearIsoInvalidFallback(key);
if (this._isoFallback.has(key)) return fallback();
try {
const scene = this._isoScene();
if (!scene) return 'flat';
return isoEffectiveView('iso', scene.key, this._isoFallback);
const source = this._isoSource();
if (!source) return finish('flat');
key = source.key;
if (this._isoFallback.has(key)) return fallback();
const scene = this._isoScene(source);
if (!scene) return finish('flat');
if (this._isoFallback.has(scene.key)) return fallback();
this._renderIsoScene = scene;
return finish('iso');
} catch (error) {
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';
this._latchIsoFallback(key, error);
return fallback();
}
}
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 {
private _floorView(view: { x: number; y: number; w: number; h: number }): { x: number; y: number; w: number; h: number } {
if (this._renderProjection !== 'iso') return view;
const start = unprojectFloorPoint([view.x, view.y]);
const end = unprojectFloorPoint([view.x + view.w, view.y + view.h]);
const start = unprojectFloorPoint([view.x, view.y]), 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] };
}
/** Shared Flat/Iso host-face semantics for raised opening locks. */
private _openingLockAnchor(opening: RenderOpening, openingWallIndex: OpeningWallIndex): PlanPoint {
const rad = ((opening.angle + 90) * Math.PI) / 180;
const gateFace = opening.type === 'gate'
? this._openingFace(opening, openingWallIndex, !opening.flip_v)
: null;
const lockOffset = gridVisualUnits(16, this._cellCm);
const offset = gateFace
? -lockOffset * gateFace.side
: lockOffset * (opening.flip_v ? -1 : 1);
return [opening.rx + Math.cos(rad) * offset, opening.ry + Math.sin(rad) * offset];
}
/** Stage 3 keeps one immutable floor point and one runtime visual point per
* raised item. This snapshot is presentation-only and never enters config. */
private _isoOverlayScene(
space: SpaceModel,
devs: readonly DevItem[],
view: { x: number; y: number; w: number; h: number },
disp: SpaceDisplay,
layers: IsoDecorationLayers | null,
structural: IsoRenderScene | null,
iconPct: number,
deviceBasePct: number,
showLqi: boolean,
): IsoOverlayRenderScene | null {
const runtime = this._isoSceneRuntime;
if (!runtime || !layers?.structural || !structural) return null;
return runtime.buildIsoOverlayRenderScene({
space, devices: devs, openings: this._openingsR, view, display: disp, layers,
wallSilhouettes: structural.wallSilhouettes,
iconPct, deviceBasePct, showLqi, cellCm: this._cellCm,
kioskIconScale: this._kiosk ? this._kioskScale.icon : 1,
kioskFontScale: this._kiosk ? this._kioskScale.font : 1,
stageSize: this._stageEl?.getBoundingClientRect?.(),
selectedDeviceId: this._selId,
focusedRoomId: this._roomFocus?.roomId,
selectedOpeningId: this._openingInfo?.id,
positionOf: (device) => this._pos(device),
presentationOf: (device, withLqi) => this._devicePresentation(device, withLqi),
labelPositionOf: (room, spaceId) => this._labelPos(room, spaceId),
labelScaleOf: (room) => this._labelScale(room),
openingEntityAvailable: (entityId) => this._renderOpeningEntityAvailable(entityId),
openingWallIndex: () => this._openingWallIndexFor(space, this._openCuts()).value,
});
}
private _baseVb(): number[] {
if (this._effectiveProjection() === 'iso') {
// No-borders is the accepted Stage 1 no-volume scene. Stage 2 structure
// is absent there, so its opening/floor-edge bounds must be absent too;
// otherwise merely hiding volume reframes every live floor pixel.
private _baseVb(
projection: 'flat' | 'iso' = this._effectiveProjection(),
scene: IsoRenderScene | null = this._renderIsoScene,
): number[] {
if (projection === 'iso') {
// No-borders is the accepted no-volume scene. Stage 2/3 structure and
// raised overlays are absent there, so invisible wall/opening/overlay
// height must not reframe the affine-projected floor.
if (!this._spaceDisplayForRender().showBorders) {
const flat = this._frameOf().rect;
const frame = projectedFrame({
rect: flat,
wallHeight: gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm),
wallHeight: 0,
});
return [frame.x, frame.y, frame.w, frame.h];
}
const frame = this._isoScene()?.frame ?? this._frameOf().rect;
const frame = scene?.frame ?? this._frameOf().rect;
return [frame.x, frame.y, frame.w, frame.h];
}
const r = this._frameOf().rect;
@@ -6234,7 +6290,6 @@ export class HouseplanCard extends LitElement {
? gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm) : 0,
floorDepth: projection === 'iso' && disp.showBorders
? gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, this._cellCm) : 0,
// Room outlines and wall-body outlines are centred on their primitives.
padding: disp.showBorders ? gridVisualUnits(1.25, this._cellCm) : 0,
});
}
@@ -6249,26 +6304,33 @@ export class HouseplanCard extends LitElement {
this._clearRoomFocus();
return false;
}
const bounds = this._roomFitBounds(room, space);
let bounds = this._roomFitBounds(room, space);
if (!bounds) {
this._clearRoomFocus();
return false;
}
// Store only stable ids. A temporarily unmeasurable stage may retry this
// intent at the next positive ResizeObserver delivery.
this._roomFocus = { spaceId: space.id, roomId };
const stage = this._stageEl;
if (!stage || stage.clientWidth <= 0 || stage.clientHeight <= 0) return true;
const base = this._baseVb();
const baseFit = fitView(base, stage.clientWidth / stage.clientHeight);
const target = roomFitCameraTarget({
bounds,
const targetOf = (fitBounds: NonNullable<typeof bounds>) => roomFitCameraTarget({
bounds: fitBounds,
baseFit,
stageWidth: stage.clientWidth,
stageHeight: stage.clientHeight,
minZoom: HouseplanCard.ZOOM_MIN,
maxZoom: HouseplanCard.ZOOM_MAX,
});
let target = targetOf(bounds);
const runtime = this._isoSceneRuntime, entries = this._renderIsoScene?.overlayFitEntries;
if (target && this._effectiveProjection() === 'iso' && runtime && entries?.length) {
const envelope = runtime.resolveIsoOverlayFitEnvelope({
baseBounds: bounds, entries, ownerId: room.id, stageSize: {
width: stage.clientWidth, height: stage.clientHeight },
targetView: (fitBounds) => targetOf(fitBounds)?.viewBox ?? null });
if (envelope) { bounds = envelope.bounds; target = targetOf(bounds); }
}
if (!target) {
this._clearRoomFocus();
return false;
@@ -6316,11 +6378,10 @@ export class HouseplanCard extends LitElement {
/** "Home is that way" (docs/CANVAS.md §5): the plane has no edges, so it is
* possible to pan until nothing is on screen. One small pointer towards the
* content, one click back to it. */
private _renderHomeArrow(): TemplateResult | typeof nothing {
private _renderHomeArrow(base = this._baseVb()): TemplateResult | typeof nothing {
if (this._booting) return nothing;
const v = this._view;
if (!v || !v.w || !v.h) return nothing;
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;
@@ -6335,15 +6396,14 @@ export class HouseplanCard extends LitElement {
}
/** Aspect ratio of the scene (width/height, px). */
private _stageAspect(): number {
private _stageAspect(vb = this._baseVb()): number {
const s = this._stageEl;
const vb = this._baseVb();
return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3];
}
/** Current view with a fallback to the full fit. */
private _viewOr(vb: number[]): { x: number; y: number; w: number; h: number } {
return this._view && this._view.w ? this._view : fitView(vb, this._stageAspect());
return this._view && this._view.w ? this._view : fitView(vb, this._stageAspect(vb));
}
/**
@@ -8766,7 +8826,10 @@ export class HouseplanCard extends LitElement {
</g>` as unknown as TemplateResult;
}
private _renderDecorLayer(onlyId?: string | null): TemplateResult {
private _renderDecorLayer(
onlyId?: string | null,
planView = this._viewOr(this._baseVb()),
): TemplateResult {
const W = NORM_W, H = this._decorH;
const editing = this._mode === 'decor';
const erasing = editing && this._decorTool === 'erase';
@@ -8775,7 +8838,6 @@ export class HouseplanCard extends LitElement {
// depth transform; multiplying by this factor restores the same physical
// camera zoom ordinary decor gets from the plan SVG (#361).
const stage = this._stageEl;
const planView = this._viewOr(this._baseVb());
// #376(г): the compensation formula models the 2D camera (uniform
// min(stage/planView)). The labs iso projection scales the floor through
// its own non-uniform transform, where ordinary decor is anisotropic as
@@ -9499,110 +9561,6 @@ export class HouseplanCard extends LitElement {
</g>` as unknown as TemplateResult;
}
private _isoDecorationLayers(disp: SpaceDisplay): IsoDecorationLayers {
const filtersSupported = typeof CSS === 'undefined'
|| typeof CSS.supports !== 'function'
|| CSS.supports('filter', 'blur(1px)');
const forcedColors = typeof matchMedia === 'function'
&& matchMedia('(forced-colors: active)').matches;
return resolveIsoDecoration({
showBorders: disp.showBorders,
hideOpenings: disp.hideOpenings,
filtersSupported,
forcedColors,
});
}
private _isoOpeningPanels(layers: IsoDecorationLayers): IsoOpeningPanel[] {
if (!layers.panels) return [];
const scene = this._isoScene();
if (!scene) return [];
const panels = scene.openings.flatMap((basis) => {
const opening = this._openingsR[basis.sourceIndex];
return opening ? projectIsoOpening(basis, this._openingAmt(opening)) : [];
});
return panels.sort((a, b) => a.depth - b.depth
|| a.sourceIndex - b.sourceIndex || a.leaf - b.leaf);
}
private _renderIsoDefs(
layers: IsoDecorationLayers,
root: 'underlay' | 'shadows' | 'walls',
): TemplateResult {
const visualScale = gridVisualScale(this._cellCm);
return svg`<defs>
${root === 'walls' && layers.materialNuance ? svg`
<linearGradient id="hp-iso-wall-side" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" class="iso-side-hi"></stop><stop offset="1" class="iso-side-lo"></stop>
</linearGradient>
<linearGradient id="hp-iso-wall-top" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" class="iso-top-hi"></stop><stop offset="1" class="iso-top-lo"></stop>
</linearGradient>` : nothing}
${root === 'underlay' && layers.shadows ? svg`
<filter id="hp-iso-ambient-shadow" x="-12%" y="-12%" width="124%" height="130%">
<feGaussianBlur stdDeviation="${7 * visualScale}"></feGaussianBlur>
</filter>` : nothing}
${root === 'shadows' && layers.shadows ? svg`
<filter id="hp-iso-contact-shadow" x="-8%" y="-20%" width="116%" height="140%">
<feGaussianBlur stdDeviation="${2.5 * visualScale}"></feGaussianBlur>
</filter>
<filter id="hp-iso-leaf-shadow" x="-12%" y="-30%" width="124%" height="160%">
<feGaussianBlur stdDeviation="${2 * visualScale}"></feGaussianBlur>
</filter>` : nothing}
</defs>` as unknown as TemplateResult;
}
private _renderIsoUnderlay(layers: IsoDecorationLayers): TemplateResult {
if (!layers.structural) return svg`` as unknown as TemplateResult;
const floor = this._isoScene()?.floor;
if (!floor) return svg`` as unknown as TemplateResult;
return svg`<g class="iso-underlay" data-hp="iso-underlay" aria-hidden="true" pointer-events="none">
${this._renderIsoDefs(layers, 'underlay')}
${layers.shadows && floor.footprintPath
? svg`<path class="iso-ambient-shadow" d=${floor.footprintPath}
transform="translate(0 ${gridVisualUnits(8, this._cellCm)})"></path>`
: nothing}
<g class="iso-floor-edge">${floor.sides.map((face) =>
svg`<path class="iso-floor-side" d=${face.d} data-component=${face.component}
data-edge=${face.edge}></path>`)}</g>
</g>` as unknown as TemplateResult;
}
private _renderIsoShadows(
layers: IsoDecorationLayers,
panels: readonly IsoOpeningPanel[],
): TemplateResult {
if (!layers.shadows) return svg`` as unknown as TemplateResult;
const geometry = this._isoScene()?.geometry;
if (!geometry) return svg`` as unknown as TemplateResult;
return svg`<g class="iso-shadows" data-hp="iso-shadows" aria-hidden="true" pointer-events="none">
${this._renderIsoDefs(layers, 'shadows')}
<path class="iso-contact-shadow" d=${geometry.contactPath}></path>
<g class="iso-leaf-shadows">${panels.map((panel) =>
svg`<path class="iso-leaf-shadow" d=${panel.shadowD}
data-id=${panel.id} data-leaf=${panel.leaf}></path>`)}</g>
</g>` as unknown as TemplateResult;
}
private _renderIsoWalls(
layers: IsoDecorationLayers,
panels: readonly IsoOpeningPanel[],
): TemplateResult {
if (this._renderProjection !== 'iso') return svg`` as unknown as TemplateResult;
if (!layers.structural) return svg`` as unknown as TemplateResult;
const scene = this._isoScene();
if (!scene) return svg`` as unknown as TemplateResult;
return svg`<g class="iso-walls" data-hp="iso-walls" data-fingerprint=${scene.key}>
${this._renderIsoDefs(layers, 'walls')}
<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>
${layers.panels ? svg`<g class="iso-openings" data-hp="iso-openings"
aria-hidden="true" pointer-events="none">${panels.map((panel) =>
svg`<path class="iso-opening-panel iso-${panel.type}" d=${panel.d}
data-id=${panel.id} data-kind=${panel.type} data-leaf=${panel.leaf}></path>`)}</g>` : nothing}
</g>` as unknown as TemplateResult;
}
/**
* View-only clean-floor perimeter above real wall bodies.
@@ -11040,7 +10998,10 @@ export class HouseplanCard extends LitElement {
}
/** Light pools of the current space: dark house, glowing sources. */
private _renderGlowLayer(space: SpaceModel, disp: SpaceDisplay): TemplateResult {
private _renderGlowLayer(
space: SpaceModel, disp: SpaceDisplay,
view = this._viewOr(this._baseVb()),
): TemplateResult {
const colors = this._fillColors;
const defaultR = (this._glowRadiusCm / this._cellCm) * this._gridPitch;
const polys = space.rooms
@@ -11152,7 +11113,6 @@ export class HouseplanCard extends LitElement {
});
// A hair on SCREEN, so an edge stays a hairline at any zoom instead of
// turning into a smear when the plan is enlarged.
const view = this._viewOr(this._baseVb());
const perUnit = this._stageEl?.clientWidth && view.w
? this._stageEl.clientWidth / view.w
: 1;
@@ -11384,10 +11344,11 @@ export class HouseplanCard extends LitElement {
if (!space) return nothing;
const navigationSpaces = fixed.kind === 'valid' ? [space] : model;
const vb = space.vb;
const projection = this._effectiveProjection();
let projection = this._effectiveProjection();
this._renderProjection = projection;
if (this._labs.active.length) noteLabsRender();
const iso = projection === 'iso';
let iso = projection === 'iso';
const isoRuntime = iso ? this._isoSceneRuntime : null;
// 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)
@@ -11395,18 +11356,32 @@ export class HouseplanCard extends LitElement {
const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
const deviceSnapshot = this._renderDeviceSnapshot;
const disp = this._spaceDisplayForRender();
// Stage 2 capability work is deliberately absent from Flat/Labs-off
// renders. Decorative capability never enters the structural cache key.
const isoLayers = iso ? this._isoDecorationLayers(disp) : null;
const isoPanels = isoLayers ? this._isoOpeningPanels(isoLayers) : [];
const roomFills = this._resolvedRoomFills(space, disp);
const glowBase = this._resolvedGlowBase(space, disp, roomFills);
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 deviceBasePct = effectiveDeviceBaseSize(iconPct);
const view = this._viewOr(this._baseVb());
const floorView = this._floorView(view);
let isoScene = iso ? this._renderIsoScene : null;
let view = this._viewOr(this._baseVb(projection, isoScene));
let isoFrame: IsoFramePresentation | null = null;
try {
isoFrame = isoRuntime?.resolveIsoFramePresentation({
projection, display: disp, scene: isoScene, openings: this._openingsR,
amountOf: (opening) => this._openingAmt(opening), cellCm: this._cellCm,
overlays: (layers) => this._isoOverlayScene(
space, devs, view, disp, layers, isoScene, iconPct, deviceBasePct, showLqi),
}) ?? null;
} catch (error) {
this._latchIsoFallback(disp.showBorders
? isoScene?.key || this._isoInvalidKey() : `${space.id}|no-borders`, error);
this._cancelCameraTransition(false); projection = 'flat'; iso = false; isoScene = null; isoFrame = null;
this._renderProjection = 'flat'; this._renderIsoScene = null;
this._isoProjectionSnapshot = 'flat'; this._view = null; this._viewModeSnap = null;
view = this._viewOr(this._baseVb('flat', null));
}
const isoLayers = isoFrame?.layers ?? null;
const isoOverlays = isoFrame?.overlays ?? null;
// 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);
@@ -11553,7 +11528,8 @@ export class HouseplanCard extends LitElement {
: nothing}
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}${this._modeTransitionBusy ? ' mode-transition' : ''}"
<div class="stage ${iso ? `projection-iso ${deviceThemeClass(this._renderPlanHass)}` : ''} ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}${this._modeTransitionBusy ? ' mode-transition' : ''}"
data-hp-iso-stage=${iso ? '3' : nothing} data-hp-iso-structural-builds=${iso ? this._isoStructuralBuildCount : nothing}
?inert=${this._modeTransitionBusy}
style="height:${modeVisual ? `${modeVisual.stageHeight}px` : this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${transitionStageBg ? `;background:${transitionStageBg}` : ''};--hp-cell-visual-scale:${gridVisualScale(this._cellCm)};--wall-fill:${this._fillColors.wall_fill.c};--wall-fill-op:${this._fillColors.wall_fill.a};--hp-mode-architecture-opacity:${modeVisual ? modeVisual.architectureOpacity : this._mode === 'decor' ? 0.35 : 1};--hp-mode-view-weight:${modeVisual?.viewWeight ?? (this._mode === 'view' ? 1 : 0)};--hp-mode-editor-weight:${modeVisual?.editorWeight ?? (this._mode === 'view' ? 0 : 1)}${modeVisual ? `;--hp-mode-paper:${modeVisual.paperColor}` : ''}${dayCycle ? `;${dayCycleStageVars(dayCycle)}` : ''}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@@ -11571,16 +11547,14 @@ export class HouseplanCard extends LitElement {
${iso && isoLayers?.structural ? svg`<svg class="iso-underlay-svg" data-hp-live-viewbox="camera"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoUnderlay(isoLayers)}
${isoFrame?.underlay ?? nothing}
</svg>` : nothing}
<svg class=${isoLayers?.structural ? 'plan-svg' : nothing}
data-hp-live-viewbox=${isoLayers?.structural ? 'camera' : 'floor'}
viewBox=${isoLayers?.structural
? `${view.x} ${view.y} ${view.w} ${view.h}`
: `${floorView.x} ${floorView.y} ${floorView.w} ${floorView.h}`}
preserveAspectRatio=${isoLayers?.structural || !iso ? 'xMidYMid meet' : 'none'}>
<g class=${isoLayers?.structural ? 'iso-floor-scene' : nothing}
transform=${isoLayers?.structural ? isoFloorMatrixCss() : nothing}>
<svg class=${iso ? 'plan-svg' : nothing}
data-hp-live-viewbox=${iso ? 'camera' : 'floor'}
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet">
<g class=${iso ? 'iso-floor-scene' : nothing}
transform=${iso ? isoFloorMatrixCss() : nothing}>
${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner
2026-08-04). Opaque shapes stop the scene background —
bg_color or the day-cycle environment — from bleeding through the
@@ -11758,8 +11732,8 @@ export class HouseplanCard extends LitElement {
live lighting, physical plan geometry and devices. Keep
hide_decor visual-only: the decor editor must always paint
stored shapes so they remain editable. */}
${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}
${glowLayerVisible ? this._renderGlowLayer(space, disp) : nothing}
${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer(undefined, view)}
${glowLayerVisible ? this._renderGlowLayer(space, disp, view) : nothing}
${this._renderSunRays(space)}
${this._editing ? svg`<g class="hp-editor-only-layer"
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderAlignGuides()}</g>` : nothing}
@@ -11819,11 +11793,16 @@ export class HouseplanCard extends LitElement {
${iso && isoLayers?.structural ? svg`<svg class="iso-shadows-svg" data-hp-live-viewbox="camera"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoShadows(isoLayers, isoPanels)}
${isoFrame?.shadows ?? nothing}
</svg>
<svg class="iso-walls-svg" data-hp-live-viewbox="camera" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoWalls(isoLayers, isoPanels)}
${isoFrame?.walls ?? nothing}
</svg>
<svg class="iso-overlays-svg" data-hp-live-viewbox="camera" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${isoFrame?.grounds ?? nothing}
${isoFrame?.raised ?? nothing}
</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
@@ -11837,12 +11816,16 @@ export class HouseplanCard extends LitElement {
base is resolved before `iconCqw`; only the per-device and
kiosk multipliers still feed --dev-size. */}
<div class="devlayer" data-hp-live-layer="camera" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-icon-size:${iconCqw(iconPct, space, this._roomLabelReferenceViewWidth(view), this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi))}
${devs.map((d) => this._renderDevice(
d, view, showLqi, isoOverlays?.devices.get(d.id),
))}
${this._renderVacuums(this._renderVacuumDevices, view, space.id)}
${this._renderVacFit(view)}
${this._renderOpeningLocks(view)}
${this._renderOpeningLocks(view, isoOverlays?.locks)}
${disp.showNames || this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
? space.rooms.map((r) => this._renderRoomLabel(
r, space, view, disp, isoOverlays?.rooms.get(r),
))
: nothing}
${this._markup ? space.rooms.map((r) => this._renderRoomGear(r, space, view)) : nothing}
${renderZigbeeTopologyOverlay({ hass: this.hass, settings: this._settings, devices: this._renderDevices, registry: this._haRegistry, currentSpace: space.id, spaces: this._serverCfg?.spaces, viewKey: view, view: this._mode === 'view', kiosk: this._kiosk })}
@@ -11854,7 +11837,7 @@ export class HouseplanCard extends LitElement {
</div>
<div class="zoombadge" data-hp-live-zoom hidden><span data-hp-live-zoom-value></span></div>
${this._renderFarHint()}
${this._renderHomeArrow()}
${this._renderHomeArrow(this._baseVb(projection, isoScene))}
${this._renderEditorRuntimeLoading()}
${this._renderRecoveryOverlay()}
${this._booting || this._bootFading
@@ -12457,9 +12440,14 @@ export class HouseplanCard extends LitElement {
${pucks}`;
}
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true): TemplateResult {
private _renderDevice(
d: DevItem,
view: { x: number; y: number; w: number; h: number },
showLqi = true,
isoPlacement?: IsoOverlayPlacement,
): TemplateResult {
const pos = this._pos(d);
const point = this._scenePoint([pos.x, pos.y]);
const point = isoPlacement?.visualScene ?? 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);
@@ -12501,6 +12489,11 @@ export class HouseplanCard extends LitElement {
data-disabled-reason=${disabledReason ? disabledReason.replace('_', '-') : nothing}
data-state=${a11yState}
data-lqi-band=${presentation.lqiText != null ? presentation.lqiBand || nothing : nothing}
data-hp-iso-overlay-kind=${isoPlacement?.plane === 'raised' ? 'device' : nothing}
data-hp-iso-raised=${isoPlacement?.plane === 'raised' ? 'true' : nothing}
data-hp-iso-nudged=${isoPlacement?.plane === 'raised' ? String(isoPlacement.nudged) : nothing}
data-hp-iso-floor=${isoPlacement ? `${isoPlacement.floorScene[0]},${isoPlacement.floorScene[1]}` : nothing}
data-hp-iso-visual=${isoPlacement ? `${point[0]},${point[1]}` : nothing}
role=${interactive ? 'button' : nothing}
tabindex=${interactive ? '0' : nothing}
aria-label=${deviceAriaLabel}
@@ -12713,13 +12706,17 @@ export class HouseplanCard extends LitElement {
}
private _renderRoomLabel(
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay,
r: RoomCfg,
space: SpaceModel,
view: { x: number; y: number; w: number; h: number },
disp: SpaceDisplay,
isoPlacement?: IsoOverlayPlacement,
): TemplateResult | typeof nothing {
// audit/feedback: rooms without a name still need their gear in the Plan
// 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 point = this._scenePoint([p.x, p.y]);
const point = isoPlacement?.visualScene ?? 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);
@@ -12760,6 +12757,11 @@ export class HouseplanCard extends LitElement {
const areaLinkInteractive = !this._markup;
return html`<div class="roomlabel ${rows.length ? 'card' : ''}"
data-hp="room-label" data-id=${r.id || nothing} data-area=${r.area || nothing}
data-hp-iso-overlay-kind=${isoPlacement?.plane === 'raised' ? 'room-label' : nothing}
data-hp-iso-raised=${isoPlacement?.plane === 'raised' ? 'true' : nothing}
data-hp-iso-nudged=${isoPlacement?.plane === 'raised' ? String(isoPlacement.nudged) : nothing}
data-hp-iso-floor=${isoPlacement ? `${isoPlacement.floorScene[0]},${isoPlacement.floorScene[1]}` : nothing}
data-hp-iso-visual=${isoPlacement ? `${point[0]},${point[1]}` : nothing}
role=${this._mode === 'view' ? 'button' : nothing}
tabindex=${this._mode === 'view' ? '0' : nothing}
aria-label=${this._mode === 'view' ? this._t('room.fit_action', { name: r.name || '' }) : nothing}
@@ -13075,7 +13077,10 @@ export class HouseplanCard extends LitElement {
}
/** Padlock badges for door-like openings with a lock entity. */
private _renderOpeningLocks(view: { x: number; y: number; w: number; h: number }): TemplateResult {
private _renderOpeningLocks(
view: { x: number; y: number; w: number; h: number },
isoPlacements?: ReadonlyMap<string, IsoOverlayPlacement>,
): TemplateResult {
const items = this._openingsR.filter(
(o) => !o.orphanReason && (o.type === 'door' || o.type === 'gate')
&& o.lock && this._renderOpeningEntityAvailable(o.lock),
@@ -13089,21 +13094,17 @@ export class HouseplanCard extends LitElement {
const st = this._renderPlanHass.states[o.lock!]?.state;
const locked = st === 'locked';
const known = locked || ['unlocked', 'open', 'opening', 'unlocking', 'locking'].includes(String(st));
// Perpendicular offset from the opening center, away from the swing side.
// Gate exterior depends on the room edge, not merely on normalized wall
// angle, so resolve that face explicitly.
const rad = ((o.angle + 90) * Math.PI) / 180;
const gateFace = o.type === 'gate'
? this._openingFace(o, openingWallIndex, !o.flip_v)
: null;
const lockOffset = gridVisualUnits(16, this._cellCm);
const off = gateFace ? -lockOffset * gateFace.side : lockOffset * (o.flip_v ? -1 : 1);
const px = o.rx + Math.cos(rad) * off;
const py = o.ry + Math.sin(rad) * off;
const point = this._scenePoint([px, py]);
const floorAnchor = this._openingLockAnchor(o, openingWallIndex);
const isoPlacement = isoPlacements?.get(String(o.id));
const point = isoPlacement?.visualScene ?? this._scenePoint(floorAnchor);
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
return html`<div class="oplock ${deviceThemeClass(this._renderPlanHass)} ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}"
data-hp-iso-overlay-kind=${isoPlacement?.plane === 'raised' ? 'opening-lock' : nothing}
data-hp-iso-raised=${isoPlacement?.plane === 'raised' ? 'true' : nothing}
data-hp-iso-nudged=${isoPlacement?.plane === 'raised' ? String(isoPlacement.nudged) : nothing}
data-hp-iso-floor=${isoPlacement ? `${isoPlacement.floorScene[0]},${isoPlacement.floorScene[1]}` : nothing}
data-hp-iso-visual=${isoPlacement ? `${point[0]},${point[1]}` : nothing}
style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => {
if (this._mode !== 'view') return;
+1 -13
View File
@@ -319,19 +319,7 @@ import {
import {
currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot,
} from './labs';
import {
ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, isoFloorMatrixCss,
projectPlanPoint, projectedFrame,
unprojectFloorPoint, type ScenePoint, type ViewRect,
} from './iso-projection';
import {
buildIsoFloorGeometry, buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint,
type IsoFloorGeometry, type IsoWallGeometry,
} from './iso-walls';
import {
buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening, resolveIsoDecoration,
type IsoDecorationLayers, type IsoOpeningBasis, type IsoOpeningPanel,
} from './iso-openings';
import { projectPlanPoint } from './iso-projection';
import {
acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses,
fullRegistryHass, haRegistryBuildSignature, haRegistryDiagnostics, haRegistrySnapshot,
+361 -19
View File
@@ -9,6 +9,8 @@ export type IsoOpeningType = 'door' | 'window' | 'gate' | 'passage';
export interface IsoOpeningFace {
ox: number;
oy: number;
/** Physical thickness metadata from the resolved host, in centimetres. */
cm?: number;
side: -1 | 1;
}
@@ -35,14 +37,107 @@ export interface IsoOpeningLeafBasis {
top: number;
}
export interface IsoOpeningAxisBasis {
center: PlanPoint;
tangent: PlanPoint;
normal: PlanPoint;
start: PlanPoint;
end: PlanPoint;
}
export interface IsoOpeningFaceBasis {
side: -1 | 1;
cm: number;
/** Full physical wall depth in plan-render units. */
depth: number;
/** Centreline-to-selected-face vector returned by the canonical host resolver. */
offset: PlanPoint;
selectedStart: PlanPoint;
selectedEnd: PlanPoint;
oppositeStart: PlanPoint;
oppositeEnd: PlanPoint;
}
export interface IsoOpeningRevealBasis {
jamb: 0 | 1;
center: PlanPoint;
selected: PlanPoint;
opposite: PlanPoint;
}
/**
* Versioned authority for every fixed Stage 3 opening dimension. It is part of
* the structural fingerprint, so a policy change cannot reuse stale cached
* opening volumes from an older algorithm.
*/
export interface IsoOpeningGeometryPolicy {
revision: number;
leafThicknessRatio: number;
frameThicknessRatio: number;
gateTurnDeg: number;
gateTopRatio: number;
windowBottomRatio: number;
windowTopRatio: number;
doorTopRatio: number;
}
export const ISO_OPENING_GEOMETRY_POLICY: Readonly<IsoOpeningGeometryPolicy> = Object.freeze({
revision: 1,
leafThicknessRatio: 0.04,
frameThicknessRatio: 0.055,
gateTurnDeg: 10,
gateTopRatio: 0.88,
windowBottomRatio: 0.27,
windowTopRatio: 0.78,
doorTopRatio: 0.92,
});
export const ISO_OPENING_LEAF_THICKNESS_RATIO =
ISO_OPENING_GEOMETRY_POLICY.leafThicknessRatio;
export const ISO_OPENING_FRAME_THICKNESS_RATIO =
ISO_OPENING_GEOMETRY_POLICY.frameThicknessRatio;
/** Immutable jamb/axis topology stored in the structural Iso LRU. */
export interface IsoOpeningBasis {
id: string;
sourceIndex: number;
type: IsoOpeningType;
wallHeight: number;
leafThickness: number;
frameThickness: number;
axis: IsoOpeningAxisBasis;
face: IsoOpeningFaceBasis;
reveals: readonly IsoOpeningRevealBasis[];
leaves: readonly IsoOpeningLeafBasis[];
}
export type IsoOpeningSurfaceKind =
| 'jamb-reveal'
| 'leaf-front'
| 'leaf-back'
| 'leaf-edge'
| 'leaf-top'
| 'window-insert'
| 'window-frame-side'
| 'window-frame-top'
| 'window-sill';
export type IsoOpeningMaterial =
| 'reveal'
| 'matte-leaf'
| 'light-window'
| 'light-frame'
| 'light-sill';
export interface IsoOpeningSurface {
kind: IsoOpeningSurfaceKind;
material: IsoOpeningMaterial;
d: string;
depth: number;
jamb?: 0 | 1;
edge?: 'hinge' | 'tip';
}
export interface IsoOpeningPanel {
id: string;
sourceIndex: number;
@@ -51,16 +146,35 @@ export interface IsoOpeningPanel {
d: string;
shadowD: string;
depth: number;
material: 'matte-leaf' | 'light-window';
thickness: number;
/** Stage 3 prism/frame faces. The legacy d remains the centre face for compatibility. */
surfaces: readonly IsoOpeningSurface[];
}
export interface IsoOpeningBounds { x: number; y: number; w: number; h: number; }
const finite = (values: readonly number[]): boolean => values.every(Number.isFinite);
function frozenPoint(x: number, y: number): PlanPoint {
return Object.freeze([
Object.is(x, -0) ? 0 : x,
Object.is(y, -0) ? 0 : y,
]) as PlanPoint;
}
function add(a: PlanPoint, b: PlanPoint): PlanPoint {
return [a[0] + b[0], a[1] + b[1]];
}
function subtract(a: PlanPoint, b: PlanPoint): PlanPoint {
return [a[0] - b[0], a[1] - b[1]];
}
function scaled(a: PlanPoint, factor: number): PlanPoint {
return [a[0] * factor, a[1] * factor];
}
function transformVector(
point: PlanPoint, angleDeg: number, sx: number, sy: number,
): PlanPoint {
@@ -102,42 +216,137 @@ function leafBasis(
export function buildIsoOpeningBasis(
input: IsoOpeningInput,
wallHeight = ISO_WALL_HEIGHT,
policy: Readonly<IsoOpeningGeometryPolicy> = ISO_OPENING_GEOMETRY_POLICY,
): IsoOpeningBasis {
const faceCm = input.face.cm ?? 0;
if (!finite([
input.x, input.y, input.angle, input.length,
input.face.ox, input.face.oy, wallHeight,
]) || !(input.length > 0) || !(wallHeight > 0)) {
input.face.ox, input.face.oy, faceCm, wallHeight,
]) || !(input.length > 0) || !(wallHeight > 0)
|| (input.face.side !== -1 && input.face.side !== 1) || faceCm < 0) {
throw new Error('invalid isometric opening input');
}
const half = input.length / 2;
const angle = input.angle * Math.PI / 180;
const tangent = frozenPoint(Math.cos(angle), Math.sin(angle));
const normal = frozenPoint(-Math.sin(angle), Math.cos(angle));
const center = frozenPoint(input.x, input.y);
const start = frozenPoint(input.x - tangent[0] * half, input.y - tangent[1] * half);
const end = frozenPoint(input.x + tangent[0] * half, input.y + tangent[1] * half);
const offset = frozenPoint(input.face.ox, input.face.oy);
const selectedStart = frozenPoint(start[0] + offset[0], start[1] + offset[1]);
const selectedEnd = frozenPoint(end[0] + offset[0], end[1] + offset[1]);
const oppositeStart = frozenPoint(start[0] - offset[0], start[1] - offset[1]);
const oppositeEnd = frozenPoint(end[0] - offset[0], end[1] - offset[1]);
const axis = Object.freeze({ center, tangent, normal, start, end });
const face = Object.freeze({
side: input.face.side,
cm: faceCm,
depth: Math.hypot(input.face.ox, input.face.oy) * 2,
offset,
selectedStart,
selectedEnd,
oppositeStart,
oppositeEnd,
});
const reveals = input.type === 'passage' ? [] : [
Object.freeze({
jamb: 0 as const,
center: start,
selected: selectedStart,
opposite: oppositeStart,
}),
Object.freeze({
jamb: 1 as const,
center: end,
selected: selectedEnd,
opposite: oppositeEnd,
}),
];
let leaves: IsoOpeningLeafBasis[];
if (input.type === 'passage') {
leaves = [];
} else if (input.type === 'gate') {
const turn = input.face.side * 10;
const turn = input.face.side * policy.gateTurnDeg;
leaves = [
leafBasis(input, 0, [-half, 0], [half, 0], turn, 0, wallHeight * 0.88),
leafBasis(input, 1, [half, 0], [-half, 0], -turn, 0, wallHeight * 0.88),
leafBasis(input, 0, [-half, 0], [half, 0], turn, 0,
wallHeight * policy.gateTopRatio),
leafBasis(input, 1, [half, 0], [-half, 0], -turn, 0,
wallHeight * policy.gateTopRatio),
];
} else if (input.type === 'window') {
leaves = [
leafBasis(input, 0, [-half, 0], [half, 0], -90, wallHeight * 0.27, wallHeight * 0.78),
leafBasis(input, 1, [half, 0], [-half, 0], 90, wallHeight * 0.27, wallHeight * 0.78),
leafBasis(input, 0, [-half, 0], [half, 0], -90,
wallHeight * policy.windowBottomRatio, wallHeight * policy.windowTopRatio),
leafBasis(input, 1, [half, 0], [-half, 0], 90,
wallHeight * policy.windowBottomRatio, wallHeight * policy.windowTopRatio),
];
} else {
leaves = [leafBasis(input, 0, [-half, 0], [input.length, 0], -90, 0, wallHeight * 0.92)];
leaves = [leafBasis(input, 0, [-half, 0], [input.length, 0], -90, 0,
wallHeight * policy.doorTopRatio)];
}
return {
return Object.freeze({
id: input.id,
sourceIndex: input.sourceIndex,
type: input.type,
leaves: Object.freeze(leaves.map((leaf) => Object.freeze(leaf))),
};
wallHeight,
leafThickness: wallHeight * policy.leafThicknessRatio,
frameThickness: wallHeight * policy.frameThicknessRatio,
axis,
face,
reveals: Object.freeze(reveals),
leaves: Object.freeze(leaves.map((leaf) => Object.freeze({
...leaf,
hinge: frozenPoint(leaf.hinge[0], leaf.hinge[1]),
closedVector: frozenPoint(leaf.closedVector[0], leaf.closedVector[1]),
quarterVector: frozenPoint(leaf.quarterVector[0], leaf.quarterVector[1]),
}))),
});
}
const pointText = (point: ScenePoint): string =>
`${Number(point[0].toFixed(4))} ${Number(point[1].toFixed(4))}`;
interface IsoSpatialPoint {
point: PlanPoint;
z: number;
}
function projectedSurface(
kind: IsoOpeningSurfaceKind,
material: IsoOpeningMaterial,
points: readonly IsoSpatialPoint[],
camera: IsoCamera,
metadata: Pick<IsoOpeningSurface, 'jamb' | 'edge'> = {},
): IsoOpeningSurface {
const projected = points.map((value) => projectPlanPoint(value.point, value.z, camera));
return Object.freeze({
kind,
material,
d: `M ${projected.map(pointText).join(' L ')} Z`,
depth: Math.max(...projected.map((point) => point[1])),
...metadata,
});
}
function verticalSurface(
kind: IsoOpeningSurfaceKind,
material: IsoOpeningMaterial,
a: PlanPoint,
b: PlanPoint,
bottom: number,
top: number,
camera: IsoCamera,
metadata: Pick<IsoOpeningSurface, 'jamb' | 'edge'> = {},
): IsoOpeningSurface {
return projectedSurface(kind, material, [
{ point: a, z: bottom },
{ point: b, z: bottom },
{ point: b, z: top },
{ point: a, z: top },
], camera, metadata);
}
function liveTip(leaf: IsoOpeningLeafBasis, amount: number): PlanPoint {
const angle = leaf.turnDeg * amount * Math.PI / 180;
return [
@@ -148,6 +357,118 @@ function liveTip(leaf: IsoOpeningLeafBasis, amount: number): PlanPoint {
];
}
function leafPrismSurfaces(
leaf: IsoOpeningLeafBasis,
tip: PlanPoint,
thickness: number,
camera: IsoCamera,
): readonly IsoOpeningSurface[] {
const vector = subtract(tip, leaf.hinge);
const length = Math.hypot(vector[0], vector[1]);
if (!(length > 1e-9) || !(thickness > 0)) return Object.freeze([]);
const halfNormal = frozenPoint(
-vector[1] * thickness / (2 * length),
vector[0] * thickness / (2 * length),
);
const frontHinge = add(leaf.hinge, halfNormal);
const frontTip = add(tip, halfNormal);
const backHinge = subtract(leaf.hinge, halfNormal);
const backTip = subtract(tip, halfNormal);
const surfaces = [
verticalSurface(
'leaf-back', 'matte-leaf', backTip, backHinge,
leaf.bottom, leaf.top, camera,
),
verticalSurface(
'leaf-front', 'matte-leaf', frontHinge, frontTip,
leaf.bottom, leaf.top, camera,
),
verticalSurface(
'leaf-edge', 'matte-leaf', backHinge, frontHinge,
leaf.bottom, leaf.top, camera, { edge: 'hinge' },
),
verticalSurface(
'leaf-edge', 'matte-leaf', frontTip, backTip,
leaf.bottom, leaf.top, camera, { edge: 'tip' },
),
projectedSurface('leaf-top', 'matte-leaf', [
{ point: frontHinge, z: leaf.top },
{ point: backHinge, z: leaf.top },
{ point: backTip, z: leaf.top },
{ point: frontTip, z: leaf.top },
], camera),
];
return Object.freeze(surfaces.sort((a, b) => a.depth - b.depth
|| a.kind.localeCompare(b.kind) || String(a.edge || '').localeCompare(String(b.edge || ''))));
}
/**
* Project state-independent Stage 3 jamb/reveal and window frame/sill surfaces.
* Passage deliberately has no decorative volume. This function consumes only
* the immutable structural basis and is safe to cache with that basis.
*/
export function projectIsoOpeningStructure(
basis: IsoOpeningBasis,
camera: IsoCamera = ISO_CAMERA,
): readonly IsoOpeningSurface[] {
if (basis.type === 'passage') return Object.freeze([]);
const surfaces: IsoOpeningSurface[] = [];
if (basis.face.depth > 1e-9) {
for (const reveal of basis.reveals) {
surfaces.push(verticalSurface(
'jamb-reveal', 'reveal', reveal.selected, reveal.opposite,
0, basis.wallHeight, camera, { jamb: reveal.jamb },
));
}
}
if (basis.type !== 'window' || !basis.leaves.length) {
return Object.freeze(surfaces.sort((a, b) => a.depth - b.depth
|| a.kind.localeCompare(b.kind) || (a.jamb ?? 0) - (b.jamb ?? 0)));
}
const bottom = Math.min(...basis.leaves.map((leaf) => leaf.bottom));
const top = Math.max(...basis.leaves.map((leaf) => leaf.top));
const member = Math.min(basis.frameThickness, Math.hypot(
basis.axis.end[0] - basis.axis.start[0],
basis.axis.end[1] - basis.axis.start[1],
) / 4, (top - bottom) / 4);
const innerStart = add(basis.axis.start, scaled(basis.axis.tangent, member));
const innerEnd = subtract(basis.axis.end, scaled(basis.axis.tangent, member));
const offsets: readonly PlanPoint[] = basis.face.depth > 1e-9
? [basis.face.offset, frozenPoint(-basis.face.offset[0], -basis.face.offset[1])]
: [basis.face.offset];
for (const offset of offsets) {
const outerStart = add(basis.axis.start, offset);
const outerEnd = add(basis.axis.end, offset);
const innerFaceStart = add(innerStart, offset);
const innerFaceEnd = add(innerEnd, offset);
surfaces.push(
verticalSurface(
'window-frame-side', 'light-frame', outerStart, innerFaceStart,
bottom, top, camera, { jamb: 0 },
),
verticalSurface(
'window-frame-side', 'light-frame', innerFaceEnd, outerEnd,
bottom, top, camera, { jamb: 1 },
),
verticalSurface(
'window-frame-top', 'light-frame', innerFaceStart, innerFaceEnd,
Math.max(bottom, top - member), top, camera,
),
);
}
if (basis.face.depth > 1e-9) {
surfaces.push(projectedSurface('window-sill', 'light-sill', [
{ point: basis.face.selectedStart, z: bottom },
{ point: basis.face.selectedEnd, z: bottom },
{ point: basis.face.oppositeEnd, z: bottom },
{ point: basis.face.oppositeStart, z: bottom },
], camera));
}
return Object.freeze(surfaces.sort((a, b) => a.depth - b.depth
|| a.kind.localeCompare(b.kind) || (a.jamb ?? 0) - (b.jamb ?? 0)));
}
/** Apply live state after the structural cache: O(leaves), no topology work. */
export function projectIsoOpening(
basis: IsoOpeningBasis,
@@ -161,7 +482,14 @@ export function projectIsoOpening(
const floorTip = projectPlanPoint(tip, leaf.bottom, camera);
const topTip = projectPlanPoint(tip, leaf.top, camera);
const topHinge = projectPlanPoint(leaf.hinge, leaf.top, camera);
return {
const material = basis.type === 'window' ? 'light-window' : 'matte-leaf';
const surfaces = basis.type === 'window'
? Object.freeze([verticalSurface(
'window-insert', 'light-window', leaf.hinge, tip,
leaf.bottom, leaf.top, camera,
)])
: leafPrismSurfaces(leaf, tip, basis.leafThickness, camera);
return Object.freeze({
id: basis.id,
sourceIndex: basis.sourceIndex,
type: basis.type,
@@ -169,7 +497,10 @@ export function projectIsoOpening(
d: `M ${pointText(floorHinge)} L ${pointText(floorTip)} L ${pointText(topTip)} L ${pointText(topHinge)} Z`,
shadowD: `M ${pointText(projectPlanPoint(leaf.hinge, 0, camera))} L ${pointText(projectPlanPoint(tip, 0, camera))}`,
depth: Math.max(floorHinge[1], floorTip[1]),
};
material,
thickness: basis.type === 'window' ? 0 : basis.leafThickness,
surfaces,
});
});
}
@@ -178,13 +509,24 @@ export function isoOpeningBounds(
bases: readonly IsoOpeningBasis[],
): IsoOpeningBounds | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const include = (point: PlanPoint, radius = 0): void => {
minX = Math.min(minX, point[0] - radius);
minY = Math.min(minY, point[1] - radius);
maxX = Math.max(maxX, point[0] + radius);
maxY = Math.max(maxY, point[1] + radius);
};
for (const basis of bases) {
if (basis.type === 'passage') continue;
include(basis.axis.start);
include(basis.axis.end);
include(basis.face.selectedStart);
include(basis.face.selectedEnd);
include(basis.face.oppositeStart);
include(basis.face.oppositeEnd);
for (const leaf of basis.leaves) {
const radius = Math.hypot(leaf.closedVector[0], leaf.closedVector[1]);
minX = Math.min(minX, leaf.hinge[0] - radius);
minY = Math.min(minY, leaf.hinge[1] - radius);
maxX = Math.max(maxX, leaf.hinge[0] + radius);
maxY = Math.max(maxY, leaf.hinge[1] + radius);
const radius = Math.hypot(leaf.closedVector[0], leaf.closedVector[1])
+ basis.leafThickness / 2;
include(leaf.hinge, radius);
}
}
return finite([minX, minY, maxX, maxY])
@@ -214,7 +556,7 @@ export function resolveIsoDecoration(input: IsoDecorationInput): IsoDecorationLa
structural,
panels: structural && !input.hideOpenings,
shadows: structural && input.filtersSupported && !input.forcedColors,
materialNuance: structural && !input.forcedColors,
materialNuance: structural && input.filtersSupported && !input.forcedColors,
floorSymbols: !input.hideOpenings && !structural,
};
}
+478
View File
@@ -0,0 +1,478 @@
import {
ISO_CAMERA,
ISO_OVERLAY_VISUAL_OFFSET,
ISO_WALL_HEIGHT,
projectPlanPoint,
type IsoCamera,
type PlanPoint,
type ScenePoint,
} from './iso-projection';
export const ISO_OVERLAY_SAFETY_GAP_CSS_PX = 4;
export const ISO_OVERLAY_MAX_NUDGE_CSS_PX = 48;
export type IsoRaisedOverlayKind = 'device' | 'room-label' | 'opening-lock';
export type IsoFloorOverlayKind = 'vacuum' | 'vacuum-trail' | 'glow' | 'room-fill'
| 'room-hover' | 'sunlight' | 'decor' | 'furniture' | 'backdrop';
export type IsoOverlayKind = IsoRaisedOverlayKind | IsoFloorOverlayKind;
export type IsoOverlayPlane = 'floor' | 'raised';
export interface IsoOverlayRoom {
id: string;
outer: readonly PlanPoint[];
holes?: readonly (readonly PlanPoint[])[];
/** A cached, proven inner control point is preferred when the host has one. */
safePoint?: PlanPoint;
}
/** A projected, canonical physical-wall surface. No union is done per marker. */
export interface IsoWallSilhouette {
outer: readonly ScenePoint[];
holes?: readonly (readonly ScenePoint[])[];
}
export interface IsoOverlayOwner {
id: string;
area: number;
safePoint: PlanPoint | null;
}
export interface IsoOverlayOwnerInput {
kind: IsoRaisedOverlayKind;
floorAnchor: PlanPoint;
rooms: readonly IsoOverlayRoom[];
/** Device binding, room label owner, or the room selected by opening-host geometry. */
preferredRoomId?: string | null;
}
export interface IsoOverlayPlacementInput extends IsoOverlayOwnerInput {
showBorders: boolean;
wallSilhouettes: readonly IsoWallSilhouette[];
/** Half-size of the floor-parallel plate in plan units. */
plateHalfSize: PlanPoint;
wallHeight?: number;
visualOffset?: number;
/** Uniform viewBox units represented by one CSS pixel at the current viewport. */
sceneUnitsPerCssPixel?: number;
safetyGapCssPx?: number;
maxNudgeCssPx?: number;
filtersSupported?: boolean;
hovered?: boolean;
focused?: boolean;
selected?: boolean;
camera?: IsoCamera;
}
export interface IsoOverlayTetherGeometry {
from: ScenePoint;
to: ScenePoint;
visible: boolean;
length: number;
angleDeg: number;
}
export interface IsoOverlayPlacement {
plane: IsoOverlayPlane;
owner: IsoOverlayOwner | null;
/** This is always the input logical point; runtime nudge never mutates it. */
floorAnchor: PlanPoint;
floorScene: ScenePoint;
raisedScene: ScenePoint;
visualScene: ScenePoint;
plate: readonly ScenePoint[];
nudgeScene: ScenePoint;
nudgeCss: ScenePoint;
nudgeDistanceCss: number;
nudged: boolean;
nearWallBefore: boolean;
nearWallAfter: boolean;
cleared: boolean;
capped: boolean;
grounding: { center: ScenePoint; visible: boolean };
tether: IsoOverlayTetherGeometry;
status: 'ok' | 'degraded';
reason: 'invalid-wall-geometry' | 'missing-owner' | 'invalid-safe-point'
| 'owner-boundary' | 'nudge-cap' | null;
}
const EPS = 1e-9;
const finitePoint = (point: readonly number[]): boolean =>
point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
function ringArea(ring: readonly (readonly number[])[]): number {
let area = 0;
for (let index = 0; index < ring.length; index++) {
const point = ring[index], next = ring[(index + 1) % ring.length];
area += point[0] * next[1] - next[0] * point[1];
}
return area / 2;
}
function validRing(ring: readonly (readonly number[])[]): boolean {
return ring.length >= 3 && ring.every(finitePoint) && Math.abs(ringArea(ring)) > EPS;
}
function pointSegmentDistance(
point: readonly number[], start: readonly number[], end: readonly number[],
): number {
const dx = end[0] - start[0], dy = end[1] - start[1];
const lengthSquared = dx * dx + dy * dy;
const t = lengthSquared
? Math.max(0, Math.min(1, ((point[0] - start[0]) * dx
+ (point[1] - start[1]) * dy) / lengthSquared))
: 0;
return Math.hypot(point[0] - start[0] - t * dx, point[1] - start[1] - t * dy);
}
function pointOnRing(
point: readonly number[], ring: readonly (readonly number[])[], epsilon = 1e-7,
): boolean {
for (let index = 0; index < ring.length; index++) {
if (pointSegmentDistance(point, ring[index], ring[(index + 1) % ring.length]) <= epsilon)
return true;
}
return false;
}
function pointInRing(point: readonly number[], ring: readonly (readonly number[])[]): boolean {
let inside = false;
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
const a = ring[index], b = ring[previous];
if ((a[1] > point[1]) !== (b[1] > point[1])
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0])
inside = !inside;
}
return inside;
}
function roomArea(room: IsoOverlayRoom): number {
return Math.max(0, Math.abs(ringArea(room.outer))
- (room.holes || []).reduce((sum, hole) => sum + Math.abs(ringArea(hole)), 0));
}
function validRoom(room: IsoOverlayRoom): boolean {
return !!room.id && validRing(room.outer) && (room.holes || []).every(validRing)
&& roomArea(room) > EPS;
}
function pointStrictlyInRoom(point: PlanPoint, room: IsoOverlayRoom): boolean {
if (!validRoom(room) || pointOnRing(point, room.outer) || !pointInRing(point, room.outer))
return false;
for (const hole of room.holes || []) {
if (pointOnRing(point, hole) || pointInRing(point, hole)) return false;
}
return true;
}
function roomBoundaryDistance(point: PlanPoint, room: IsoOverlayRoom): number {
let distance = Infinity;
for (const ring of [room.outer, ...(room.holes || [])]) {
for (let index = 0; index < ring.length; index++)
distance = Math.min(distance, pointSegmentDistance(point, ring[index], ring[(index + 1) % ring.length]));
}
return distance;
}
/** Deterministic fallback when the host cannot provide its cached visual centre. */
export function isoRoomSafePoint(room: IsoOverlayRoom): PlanPoint | null {
if (!validRoom(room)) return null;
if (room.safePoint && pointStrictlyInRoom(room.safePoint, room))
return [room.safePoint[0], room.safePoint[1]];
const xs = room.outer.map((point) => point[0]);
const ys = room.outer.map((point) => point[1]);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const candidates: PlanPoint[] = [
[(minX + maxX) / 2, (minY + maxY) / 2],
[xs.reduce((sum, value) => sum + value, 0) / xs.length,
ys.reduce((sum, value) => sum + value, 0) / ys.length],
];
for (let index = 0; index < room.outer.length; index++) {
const before = room.outer[(index + room.outer.length - 1) % room.outer.length];
const point = room.outer[index], after = room.outer[(index + 1) % room.outer.length];
candidates.push([(before[0] + point[0] + after[0]) / 3,
(before[1] + point[1] + after[1]) / 3]);
}
let best: PlanPoint | null = null;
let bestClearance = -Infinity;
const consider = (candidate: PlanPoint): void => {
if (!pointStrictlyInRoom(candidate, room)) return;
const clearance = roomBoundaryDistance(candidate, room);
if (clearance > bestClearance + EPS) {
best = candidate;
bestClearance = clearance;
}
};
candidates.forEach(consider);
// A bounded grid also covers concave rooms and rooms with holes without any
// random sampling. Hosts should normally supply their cached inner point.
const steps = 16;
for (let xIndex = 1; xIndex < steps; xIndex++) {
for (let yIndex = 1; yIndex < steps; yIndex++) {
consider([
minX + ((maxX - minX) * xIndex) / steps,
minY + ((maxY - minY) * yIndex) / steps,
]);
}
}
return best;
}
const stableIdCompare = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0;
export function resolveIsoOverlayOwner(input: IsoOverlayOwnerInput): IsoOverlayOwner | null {
if (!finitePoint(input.floorAnchor)) return null;
const rooms = input.rooms.filter(validRoom);
const preferred = input.preferredRoomId
? rooms.find((room) => room.id === input.preferredRoomId) || null
: null;
let room: IsoOverlayRoom | null = null;
if (input.kind === 'device') {
if (preferred && pointStrictlyInRoom(input.floorAnchor, preferred)) room = preferred;
if (!room) {
room = rooms.filter((candidate) => pointStrictlyInRoom(input.floorAnchor, candidate))
.sort((a, b) => roomArea(a) - roomArea(b) || stableIdCompare(a.id, b.id))[0] || null;
}
} else {
// Room labels and lock badges inherit their owner from room/host geometry;
// a saved label may legitimately lie outside that room.
room = preferred;
}
return room ? { id: room.id, area: roomArea(room), safePoint: isoRoomSafePoint(room) } : null;
}
export function isoOverlayPlane(kind: IsoOverlayKind, showBorders: boolean): IsoOverlayPlane {
return showBorders && (kind === 'device' || kind === 'room-label' || kind === 'opening-lock')
? 'raised' : 'floor';
}
export function buildIsoPlatePolygon(
center: PlanPoint,
halfSize: PlanPoint,
zUnits: number,
camera: IsoCamera = ISO_CAMERA,
sceneOffset: ScenePoint = [0, 0],
): readonly ScenePoint[] {
if (!finitePoint(center) || !finitePoint(halfSize) || halfSize[0] < 0 || halfSize[1] < 0
|| !Number.isFinite(zUnits) || !finitePoint(sceneOffset))
throw new Error('invalid isometric overlay plate');
return ([
[center[0] - halfSize[0], center[1] - halfSize[1]],
[center[0] + halfSize[0], center[1] - halfSize[1]],
[center[0] + halfSize[0], center[1] + halfSize[1]],
[center[0] - halfSize[0], center[1] + halfSize[1]],
] as PlanPoint[]).map((point) => {
const projected = projectPlanPoint(point, zUnits, camera);
return [projected[0] + sceneOffset[0], projected[1] + sceneOffset[1]] as ScenePoint;
});
}
function orientation(a: readonly number[], b: readonly number[], c: readonly number[]): number {
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
}
function segmentsIntersect(
a: readonly number[], b: readonly number[], c: readonly number[], d: readonly number[],
): boolean {
const o1 = orientation(a, b, c), o2 = orientation(a, b, d);
const o3 = orientation(c, d, a), o4 = orientation(c, d, b);
if (((o1 > EPS && o2 < -EPS) || (o1 < -EPS && o2 > EPS))
&& ((o3 > EPS && o4 < -EPS) || (o3 < -EPS && o4 > EPS))) return true;
return Math.abs(o1) <= EPS && pointSegmentDistance(c, a, b) <= EPS
|| Math.abs(o2) <= EPS && pointSegmentDistance(d, a, b) <= EPS
|| Math.abs(o3) <= EPS && pointSegmentDistance(a, c, d) <= EPS
|| Math.abs(o4) <= EPS && pointSegmentDistance(b, c, d) <= EPS;
}
function segmentDistance(
a: readonly number[], b: readonly number[], c: readonly number[], d: readonly number[],
): number {
if (segmentsIntersect(a, b, c, d)) return 0;
return Math.min(pointSegmentDistance(a, c, d), pointSegmentDistance(b, c, d),
pointSegmentDistance(c, a, b), pointSegmentDistance(d, a, b));
}
function pointInSilhouette(point: ScenePoint, silhouette: IsoWallSilhouette): boolean {
if (!pointInRing(point, silhouette.outer)) return false;
return !(silhouette.holes || []).some((hole) => pointInRing(point, hole));
}
function plateNearSilhouette(
plate: readonly ScenePoint[], silhouette: IsoWallSilhouette, gap: number,
): boolean {
if (plate.some((point) => pointInSilhouette(point, silhouette))) return true;
if (silhouette.outer.some((point) => pointInRing(point, plate))) return true;
for (const wallRing of [silhouette.outer, ...(silhouette.holes || [])]) {
for (let plateIndex = 0; plateIndex < plate.length; plateIndex++) {
const plateNext = (plateIndex + 1) % plate.length;
for (let wallIndex = 0; wallIndex < wallRing.length; wallIndex++) {
const wallNext = (wallIndex + 1) % wallRing.length;
if (segmentDistance(plate[plateIndex], plate[plateNext],
wallRing[wallIndex], wallRing[wallNext]) <= gap + EPS) return true;
}
}
}
return false;
}
function validSilhouette(silhouette: IsoWallSilhouette): boolean {
return validRing(silhouette.outer) && (silhouette.holes || []).every(validRing);
}
/** A straight nudge must never cut through a concavity or an island hole. */
function segmentStrictlyInRoom(start: PlanPoint, end: PlanPoint, room: IsoOverlayRoom): boolean {
if (!pointStrictlyInRoom(start, room) || !pointStrictlyInRoom(end, room)) return false;
for (const ring of [room.outer, ...(room.holes || [])]) {
for (let index = 0; index < ring.length; index++) {
if (segmentsIntersect(start, end, ring[index], ring[(index + 1) % ring.length]))
return false;
}
}
return true;
}
function tetherGeometry(
from: ScenePoint, to: ScenePoint, visible: boolean,
): IsoOverlayTetherGeometry {
const dx = to[0] - from[0], dy = to[1] - from[1];
return { from, to, visible, length: Math.hypot(dx, dy), angleDeg: Math.atan2(dy, dx) * 180 / Math.PI };
}
/**
* Resolve one overlay without mutating its saved coordinate. Collision data is
* already projected/cached structural data, so HA and interaction updates do
* not repeat wall unions.
*/
export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): IsoOverlayPlacement {
const camera = input.camera || ISO_CAMERA;
const wallHeight = input.wallHeight ?? ISO_WALL_HEIGHT;
const visualOffset = input.visualOffset ?? ISO_OVERLAY_VISUAL_OFFSET;
const unitsPerPixel = input.sceneUnitsPerCssPixel ?? 1;
const safetyGap = input.safetyGapCssPx ?? ISO_OVERLAY_SAFETY_GAP_CSS_PX;
const maxNudge = input.maxNudgeCssPx ?? ISO_OVERLAY_MAX_NUDGE_CSS_PX;
if (!finitePoint(input.floorAnchor) || !finitePoint(input.plateHalfSize)
|| input.plateHalfSize[0] < 0 || input.plateHalfSize[1] < 0
|| !Number.isFinite(wallHeight) || wallHeight < 0
|| !Number.isFinite(visualOffset) || visualOffset < 0
|| !Number.isFinite(unitsPerPixel) || unitsPerPixel <= 0
|| !Number.isFinite(safetyGap) || safetyGap < 0
|| !Number.isFinite(maxNudge) || maxNudge < 0)
throw new Error('invalid isometric overlay input');
const floorAnchor: PlanPoint = [input.floorAnchor[0], input.floorAnchor[1]];
const floorScene = projectPlanPoint(floorAnchor, 0, camera);
const plane = isoOverlayPlane(input.kind, input.showBorders);
if (plane === 'floor') {
const tether = tetherGeometry(floorScene, floorScene, false);
return {
plane, owner: null, floorAnchor, floorScene, raisedScene: floorScene,
visualScene: floorScene, plate: [], nudgeScene: [0, 0], nudgeCss: [0, 0],
nudgeDistanceCss: 0, nudged: false, nearWallBefore: false, nearWallAfter: false,
cleared: true, capped: false,
grounding: { center: floorScene, visible: false }, tether,
status: 'ok', reason: null,
};
}
const raisedHeight = wallHeight + visualOffset;
const raisedScene = projectPlanPoint(floorAnchor, raisedHeight, camera);
const owner = resolveIsoOverlayOwner(input);
const geometryValid = input.wallSilhouettes.every(validSilhouette);
const gapUnits = safetyGap * unitsPerPixel;
const basePlate = buildIsoPlatePolygon(floorAnchor, input.plateHalfSize,
raisedHeight, camera);
const isNear = (plate: readonly ScenePoint[]): boolean =>
input.wallSilhouettes.some((wall) => plateNearSilhouette(plate, wall, gapUnits));
const nearWallBefore = geometryValid ? isNear(basePlate) : true;
let distanceCss = 0;
let nudgeScene: ScenePoint = [0, 0];
let capped = false;
let status: IsoOverlayPlacement['status'] = geometryValid ? 'ok' : 'degraded';
let reason: IsoOverlayPlacement['reason'] = geometryValid ? null : 'invalid-wall-geometry';
if (geometryValid && nearWallBefore) {
if (!owner) {
status = 'degraded';
reason = 'missing-owner';
} else if (!owner.safePoint) {
status = 'degraded';
reason = 'invalid-safe-point';
} else {
const ownerRoom = input.rooms.find((room) => room.id === owner.id) || null;
const safeScene = projectPlanPoint(owner.safePoint, raisedHeight, camera);
const dx = safeScene[0] - raisedScene[0], dy = safeScene[1] - raisedScene[1];
const length = Math.hypot(dx, dy);
if (length <= EPS) {
status = 'degraded';
reason = 'invalid-safe-point';
} else {
const ux = dx / length, uy = dy / length;
// The visual point may approach the proven inner control point but
// must never run past it and leave the owning-room direction again.
const searchLimitCss = Math.min(maxNudge, length / unitsPerPixel);
const collidesAt = (candidateCss: number): boolean => {
const offset: ScenePoint = [ux * candidateCss * unitsPerPixel,
uy * candidateCss * unitsPerPixel];
return isNear(basePlate.map((point) =>
[point[0] + offset[0], point[1] + offset[1]] as ScenePoint));
};
let clearAt: number | null = null;
let previous = 0;
let ownerBoundary = false;
const samples = Math.ceil(searchLimitCss);
for (let sample = 1; sample <= samples; sample++) {
const candidate = Math.min(searchLimitCss, sample);
const ratio = candidate * unitsPerPixel / length;
const candidatePlan: PlanPoint = [
floorAnchor[0] + (owner.safePoint[0] - floorAnchor[0]) * ratio,
floorAnchor[1] + (owner.safePoint[1] - floorAnchor[1]) * ratio,
];
if (!ownerRoom || !segmentStrictlyInRoom(floorAnchor, candidatePlan, ownerRoom)) {
ownerBoundary = true;
break;
}
if (!collidesAt(candidate)) { clearAt = candidate; break; }
previous = candidate;
}
if (clearAt !== null) {
let low = previous, high = clearAt;
for (let iteration = 0; iteration < 28; iteration++) {
const middle = (low + high) / 2;
if (collidesAt(middle)) low = middle;
else high = middle;
}
distanceCss = high;
} else {
distanceCss = previous;
capped = true;
status = 'degraded';
reason = ownerBoundary ? 'owner-boundary' : 'nudge-cap';
}
nudgeScene = [ux * distanceCss * unitsPerPixel, uy * distanceCss * unitsPerPixel];
}
}
}
const visualScene: ScenePoint = [raisedScene[0] + nudgeScene[0], raisedScene[1] + nudgeScene[1]];
const plate = buildIsoPlatePolygon(floorAnchor, input.plateHalfSize,
raisedHeight, camera, nudgeScene);
const nearWallAfter = geometryValid ? isNear(plate) : true;
const nudged = distanceCss > EPS;
const tetherVisible = nudged || nearWallBefore || nearWallAfter
|| !!input.hovered || !!input.focused || !!input.selected;
return {
plane, owner, floorAnchor, floorScene, raisedScene, visualScene, plate,
nudgeScene,
nudgeCss: [nudgeScene[0] / unitsPerPixel, nudgeScene[1] / unitsPerPixel],
nudgeDistanceCss: distanceCss,
nudged, nearWallBefore, nearWallAfter,
cleared: !nearWallAfter, capped,
grounding: { center: floorScene, visible: input.filtersSupported !== false },
tether: tetherGeometry(floorScene, visualScene, tetherVisible),
status, reason,
};
}
+50 -18
View File
@@ -1,5 +1,6 @@
export type PlanPoint = readonly [number, number];
export type ScenePoint = readonly [number, number];
export type IsoAffineMatrix = readonly [number, number, number, number, number, number];
export interface IsoCamera {
rotDeg: number;
@@ -16,10 +17,11 @@ export interface IsoFrameInput {
wallHeight: number;
openingHeight?: number;
floorDepth?: number;
raisedHeight?: number;
}
export const ISO_CAMERA: Readonly<IsoCamera> = Object.freeze({
rotDeg: 0,
rotDeg: 4,
tiltDeg: 20,
xyScale: 1,
zScale: 1,
@@ -28,6 +30,9 @@ export const ISO_CAMERA: Readonly<IsoCamera> = Object.freeze({
export const ISO_WALL_HEIGHT = 64;
export const ISO_FLOOR_EDGE_HEIGHT = 10;
/** Nominal Stage 3 offset; callers scale it with the same wall-height policy. */
export const ISO_OVERLAY_VISUAL_OFFSET = 4;
export const ISO_RAISED_OVERLAY_HEIGHT = ISO_WALL_HEIGHT + ISO_OVERLAY_VISUAL_OFFSET;
function finiteCamera(camera: IsoCamera): boolean {
return [camera.rotDeg, camera.tiltDeg, camera.xyScale, camera.zScale,
@@ -38,17 +43,9 @@ function finiteCamera(camera: IsoCamera): boolean {
export function projectPlanPoint(
point: PlanPoint, zUnits: number, camera: IsoCamera = ISO_CAMERA,
): ScenePoint {
if (!finiteCamera(camera) || !Number.isFinite(point[0]) || !Number.isFinite(point[1])
|| !Number.isFinite(zUnits)) throw new Error('invalid isometric projection input');
const rot = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
const dx = point[0] - camera.origin[0], dy = point[1] - camera.origin[1];
const rx = (dx * Math.cos(rot) - dy * Math.sin(rot)) * camera.xyScale;
const ry = (dx * Math.sin(rot) + dy * Math.cos(rot)) * camera.xyScale;
return [
camera.origin[0] + rx,
camera.origin[1] + ry * Math.cos(tilt) - zUnits * camera.zScale * Math.sin(tilt),
];
if (!Number.isFinite(point[0]) || !Number.isFinite(point[1]))
throw new Error('invalid isometric projection input');
return applyIsoMatrix(point, isoPlaneMatrix(zUnits, camera));
}
export function unprojectFloorPoint(
@@ -66,8 +63,15 @@ export function unprojectFloorPoint(
];
}
export function isoFloorMatrix(camera: IsoCamera = ISO_CAMERA): readonly [number, number, number, number, number, number] {
if (!finiteCamera(camera)) throw new Error('invalid isometric camera');
/**
* Canonical plan-plane affine matrix at one logical height. Floor SVG, raised
* plates and individual point projection all use this exact transform.
*/
export function isoPlaneMatrix(
zUnits = 0, camera: IsoCamera = ISO_CAMERA,
): IsoAffineMatrix {
if (!finiteCamera(camera) || !Number.isFinite(zUnits))
throw new Error('invalid isometric camera');
const rot = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
const a = camera.xyScale * Math.cos(rot);
@@ -75,12 +79,38 @@ export function isoFloorMatrix(camera: IsoCamera = ISO_CAMERA): readonly [number
const b = camera.xyScale * Math.sin(rot) * Math.cos(tilt);
const d = camera.xyScale * Math.cos(rot) * Math.cos(tilt);
const e = camera.origin[0] - a * camera.origin[0] - c * camera.origin[1];
const f = camera.origin[1] - b * camera.origin[0] - d * camera.origin[1];
const f = camera.origin[1] - b * camera.origin[0] - d * camera.origin[1]
- zUnits * camera.zScale * Math.sin(tilt);
return [a, b, c, d, e, f];
}
export function isoFloorMatrix(camera: IsoCamera = ISO_CAMERA): IsoAffineMatrix {
return isoPlaneMatrix(0, camera);
}
export function applyIsoMatrix(point: PlanPoint, matrix: IsoAffineMatrix): ScenePoint {
if (!Number.isFinite(point[0]) || !Number.isFinite(point[1])
|| !matrix.every(Number.isFinite)) throw new Error('invalid isometric affine input');
const [a, b, c, d, e, f] = matrix;
return [a * point[0] + c * point[1] + e, b * point[0] + d * point[1] + f];
}
export function isoPlaneMatrixCss(zUnits = 0, camera: IsoCamera = ISO_CAMERA): string {
return `matrix(${isoPlaneMatrix(zUnits, camera)
.map((value) => Number(value.toFixed(12))).join(' ')})`;
}
export function isoFloorMatrixCss(camera: IsoCamera = ISO_CAMERA): string {
return `matrix(${isoFloorMatrix(camera).map((value) => Number(value.toFixed(12))).join(' ')})`;
return isoPlaneMatrixCss(0, camera);
}
export function isoRaisedOverlayHeight(
wallHeight: number, visualOffset: number,
): number {
if (!Number.isFinite(wallHeight) || !Number.isFinite(visualOffset)
|| wallHeight < 0 || visualOffset < 0)
throw new Error('invalid raised overlay height');
return wallHeight + visualOffset;
}
export function clientToScenePoint(
@@ -102,15 +132,17 @@ export function projectedFrame(
const { rect, wallHeight } = input;
const openingHeight = input.openingHeight ?? wallHeight;
const floorDepth = input.floorDepth ?? 0;
const raisedHeight = input.raisedHeight ?? wallHeight;
if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight)
|| !Number.isFinite(openingHeight) || !Number.isFinite(floorDepth)
|| wallHeight < 0 || openingHeight < 0 || floorDepth < 0)
|| !Number.isFinite(raisedHeight) || wallHeight < 0 || openingHeight < 0
|| floorDepth < 0 || raisedHeight < 0)
throw new Error('invalid isometric frame');
const corners: PlanPoint[] = [
[rect.x, rect.y], [rect.x + rect.w, rect.y],
[rect.x + rect.w, rect.y + rect.h], [rect.x, rect.y + rect.h],
];
const top = Math.max(wallHeight, openingHeight);
const top = Math.max(wallHeight, openingHeight, raisedHeight);
const points = corners.flatMap((point) => [
projectPlanPoint(point, -floorDepth, camera), projectPlanPoint(point, top, camera),
]);
File diff suppressed because it is too large Load Diff
+34 -3
View File
@@ -5,14 +5,26 @@ import {
export interface IsoWallFace {
d: string;
/** Exact visible quad, reused by Stage 3 overlay collision geometry. */
points: readonly ScenePoint[];
depth: number;
polygon: number;
ring: number;
edge: number;
}
export interface IsoWallTopFace {
/** One physical union component, including its evenodd hole subpaths. */
d: string;
depth: number;
component: number;
}
export interface IsoWallGeometry {
/** Compatibility aggregate used by collision/debug consumers. */
topPath: string;
/** Independently paintable top components for the shared Stage 3 depth queue. */
topFaces: readonly IsoWallTopFace[];
sides: readonly IsoWallFace[];
contactPath: string;
edgeCount: number;
@@ -103,15 +115,22 @@ export function buildIsoWallGeometry(
): IsoWallGeometry {
if (!Number.isFinite(height) || height < 0) throw new Error('invalid wall height');
const tops: string[] = [];
const topFaces: IsoWallTopFace[] = [];
const sides: IsoWallFace[] = [];
const contacts: string[] = [];
let edgeCount = 0;
for (let polygon = 0; polygon < (geometry || []).length; polygon++) {
const source = geometry[polygon];
const componentPaths: string[] = [];
let componentDepth = -Infinity;
for (let ring = 0; ring < (source || []).length; ring++) {
const points = normalizedRing(source[ring], ring > 0);
if (points.length < 3 || Math.abs(signedArea(points)) < 1e-9) continue;
tops.push(topRingPath(points, camera, height));
const topPath = topRingPath(points, camera, height);
tops.push(topPath);
componentPaths.push(topPath);
componentDepth = Math.max(componentDepth,
...points.map((point) => projectPlanPoint(point, height, camera)[1]));
edgeCount += points.length;
for (let edge = 0; edge < points.length; edge++) {
const a = points[edge], b = points[(edge + 1) % points.length];
@@ -121,16 +140,28 @@ export function buildIsoWallGeometry(
if (visibleNormalY(a, b, camera) <= 1e-9) continue;
const topB = projectPlanPoint(b, height, camera);
const topA = projectPlanPoint(a, height, camera);
const facePoints = Object.freeze([floorA, floorB, topB, topA]);
sides.push({
d: `M ${pointText(floorA)} L ${pointText(floorB)} L ${pointText(topB)} L ${pointText(topA)} Z`,
d: `M ${facePoints.map(pointText).join(' L ')} Z`,
points: facePoints,
depth: Math.max(floorA[1], floorB[1]), polygon, ring, edge,
});
}
}
if (componentPaths.length) {
topFaces.push({
d: componentPaths.join(' '),
depth: componentDepth,
component: polygon,
});
}
}
sides.sort((a, b) => a.depth - b.depth || a.polygon - b.polygon
|| a.ring - b.ring || a.edge - b.edge);
return { topPath: tops.join(' '), sides, contactPath: contacts.join(' '), edgeCount };
topFaces.sort((a, b) => a.depth - b.depth || a.component - b.component);
return {
topPath: tops.join(' '), topFaces, sides, contactPath: contacts.join(' '), edgeCount,
};
}
/**
+141 -14
View File
@@ -248,9 +248,11 @@ export const planStyles = css`
z-index: 4;
overflow: visible;
}
.iso-overlays-svg { z-index: 5; overflow: visible; }
.iso-underlay-svg,
.iso-shadows-svg,
.iso-walls-svg,
.iso-overlays-svg,
.iso-underlay,
.iso-shadows,
.iso-walls,
@@ -273,6 +275,19 @@ export const planStyles = css`
stroke-width: 0.8;
vector-effect: non-scaling-stroke;
}
.iso-material-texture {
fill: url(#hp-iso-wall-texture);
stroke: none;
opacity: 0.12;
pointer-events: none;
}
.iso-floor-texture { fill: url(#hp-iso-floor-texture); opacity: 0.1; }
.iso-texture-mark { fill: rgba(36, 43, 47, 0.34); }
.iso-texture-line {
fill: none;
stroke: rgba(36, 43, 47, 0.24);
stroke-width: 0.55;
}
.iso-floor-side {
fill: #858b8d;
stroke: #71787b;
@@ -291,6 +306,48 @@ export const planStyles = css`
fill-opacity: 0.72;
stroke: #8aa7b1;
}
.iso-opening-panel.iso-material-reveal {
fill: #c7cbca;
stroke: #8b9294;
}
.iso-opening-panel.iso-material-matte-leaf {
fill: #a6abad;
stroke: #737b7f;
}
.iso-opening-panel.iso-opening-leaf-edge,
.iso-opening-panel.iso-opening-leaf-top { fill: #8d9497; }
.iso-opening-panel.iso-material-light-window,
.iso-opening-panel.iso-material-light-frame,
.iso-opening-panel.iso-material-light-sill {
fill: #edf7f9;
fill-opacity: 0.94;
stroke: #94adb4;
}
.iso-overlay-ground {
fill: rgba(20, 26, 29, 0.3);
filter: url(#hp-iso-overlay-ground);
pointer-events: none;
}
.iso-overlay-tether {
stroke: rgba(54, 65, 71, 0.48);
stroke-width: 1;
stroke-dasharray: 3 2;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.iso-overlay-plate {
fill: rgba(248, 249, 247, 0.9);
stroke: rgba(123, 132, 136, 0.58);
stroke-width: 0.8;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.iso-overlay-plate-texture {
fill: url(#hp-iso-overlay-texture);
opacity: 0.11;
stroke: none;
pointer-events: none;
}
.iso-ambient-shadow {
fill: rgba(15, 21, 25, 0.22);
filter: url(#hp-iso-ambient-shadow);
@@ -309,29 +366,82 @@ export const planStyles = css`
filter: url(#hp-iso-leaf-shadow);
vector-effect: non-scaling-stroke;
}
.stage.theme-dark .iso-side-hi { stop-color: #4c555a; }
.stage.theme-dark .iso-side-lo { stop-color: #343c40; }
.stage.theme-dark .iso-top-hi { stop-color: #687176; }
.stage.theme-dark .iso-top-lo { stop-color: #50585d; }
.stage.theme-dark .iso-wall-side { stroke: #30373b; }
.stage.theme-dark .iso-wall-top { stroke: #7b858a; }
.stage.theme-dark .iso-floor-side { fill: #2d3438; stroke: #20272a; }
.stage.theme-dark .iso-opening-panel { fill: #626b70; stroke: #899399; }
.stage.theme-dark .iso-opening-panel.iso-window { fill: #75919b; stroke: #abc6ce; }
.stage.theme-dark .iso-opening-panel.iso-material-reveal { fill: #454e52; stroke: #727d82; }
.stage.theme-dark .iso-opening-panel.iso-material-matte-leaf { fill: #626a6e; stroke: #899398; }
.stage.theme-dark .iso-opening-panel.iso-opening-leaf-edge,
.stage.theme-dark .iso-opening-panel.iso-opening-leaf-top { fill: #4b5458; }
.stage.theme-dark .iso-opening-panel.iso-material-light-window,
.stage.theme-dark .iso-opening-panel.iso-material-light-frame,
.stage.theme-dark .iso-opening-panel.iso-material-light-sill {
fill: #d8e8eb;
stroke: #9eb8bf;
}
.stage.theme-dark .iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); }
.stage.theme-dark .iso-contact-shadow,
.stage.theme-dark .iso-leaf-shadow { stroke: rgba(0, 0, 0, 0.38); }
.stage.theme-dark .iso-texture-mark { fill: rgba(255, 255, 255, 0.24); }
.stage.theme-dark .iso-texture-line { stroke: rgba(255, 255, 255, 0.18); }
.stage.theme-dark .iso-overlay-ground { fill: rgba(0, 0, 0, 0.42); }
.stage.theme-dark .iso-overlay-tether { stroke: rgba(224, 232, 235, 0.5); }
.stage.theme-dark .iso-overlay-plate {
fill: rgba(68, 77, 81, 0.92);
stroke: rgba(183, 195, 200, 0.52);
}
@media (prefers-color-scheme: dark) {
.iso-side-hi { stop-color: #4c555a; }
.iso-side-lo { stop-color: #343c40; }
.iso-top-hi { stop-color: #687176; }
.iso-top-lo { stop-color: #50585d; }
.iso-wall-side { stroke: #30373b; }
.iso-wall-top { stroke: #7b858a; }
.iso-floor-side { fill: #2d3438; stroke: #20272a; }
.iso-opening-panel { fill: #626b70; stroke: #899399; }
.iso-opening-panel.iso-window { fill: #75919b; stroke: #abc6ce; }
.iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); }
.iso-contact-shadow, .iso-leaf-shadow { stroke: rgba(0, 0, 0, 0.38); }
/* HA's explicit theme wins over the operating-system preference. */
.stage:not(.theme-light) .iso-side-hi { stop-color: #4c555a; }
.stage:not(.theme-light) .iso-side-lo { stop-color: #343c40; }
.stage:not(.theme-light) .iso-top-hi { stop-color: #687176; }
.stage:not(.theme-light) .iso-top-lo { stop-color: #50585d; }
.stage:not(.theme-light) .iso-wall-side { stroke: #30373b; }
.stage:not(.theme-light) .iso-wall-top { stroke: #7b858a; }
.stage:not(.theme-light) .iso-floor-side { fill: #2d3438; stroke: #20272a; }
.stage:not(.theme-light) .iso-opening-panel { fill: #626b70; stroke: #899399; }
.stage:not(.theme-light) .iso-opening-panel.iso-window { fill: #75919b; stroke: #abc6ce; }
.stage:not(.theme-light) .iso-opening-panel.iso-material-reveal { fill: #454e52; stroke: #727d82; }
.stage:not(.theme-light) .iso-opening-panel.iso-material-matte-leaf { fill: #626a6e; stroke: #899398; }
.stage:not(.theme-light) .iso-opening-panel.iso-opening-leaf-edge,
.stage:not(.theme-light) .iso-opening-panel.iso-opening-leaf-top { fill: #4b5458; }
.stage:not(.theme-light) .iso-opening-panel.iso-material-light-window,
.stage:not(.theme-light) .iso-opening-panel.iso-material-light-frame,
.stage:not(.theme-light) .iso-opening-panel.iso-material-light-sill { fill: #d8e8eb; stroke: #9eb8bf; }
.stage:not(.theme-light) .iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); }
.stage:not(.theme-light) .iso-contact-shadow,
.stage:not(.theme-light) .iso-leaf-shadow { stroke: rgba(0, 0, 0, 0.38); }
.stage:not(.theme-light) .iso-texture-mark { fill: rgba(255, 255, 255, 0.24); }
.stage:not(.theme-light) .iso-texture-line { stroke: rgba(255, 255, 255, 0.18); }
.stage:not(.theme-light) .iso-overlay-ground { fill: rgba(0, 0, 0, 0.42); }
.stage:not(.theme-light) .iso-overlay-tether { stroke: rgba(224, 232, 235, 0.5); }
.stage:not(.theme-light) .iso-overlay-plate {
fill: rgba(68, 77, 81, 0.92);
stroke: rgba(183, 195, 200, 0.52);
}
}
@media (forced-colors: active) {
.iso-wall-side, .iso-wall-top, .iso-floor-side, .iso-opening-panel {
.iso-wall-side, .iso-wall-top, .iso-floor-side, .iso-opening-panel,
.iso-overlay-plate {
fill: Canvas;
stroke: CanvasText;
forced-color-adjust: auto;
}
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow { display: none; }
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow,
.iso-overlay-ground, .iso-material-texture,
.iso-overlay-plate-texture { display: none; }
.iso-overlay-tether { stroke: CanvasText; }
}
@supports not (filter: blur(1px)) {
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow { display: none; }
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow,
.iso-overlay-ground, .iso-material-texture,
.iso-overlay-plate-texture { display: none; }
}
/* 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
@@ -496,6 +606,17 @@ export const planStyles = css`
pointer-events: auto;
cursor: pointer;
}
.stage.projection-iso.mode-view .oplock::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: max(44px, 100%);
height: max(44px, 100%);
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.op-leaf, .op-arc { transition: none; }
}
@@ -628,6 +749,12 @@ export const planStyles = css`
cursor: pointer;
border-radius: 0.2em;
}
.stage.projection-iso.mode-view .roomlabel {
box-sizing: border-box;
min-width: 44px;
min-height: 44px;
justify-content: center;
}
.stage.mode-view .roomlabel:focus-visible {
outline: 2px solid var(--hp-accent);
outline-offset: 4px;