Files
houseplan-card/src/iso-scene-render.ts
T
Claude 47469bab22 Монолит: мёртвый код снят по noUnusedLocals, связность измеряется шестью числами и гейтом (#624)
Карточка и редакторский рантайм держали ≈380 неиспользуемых импортов, 56
мёртвых объявлений и дублей типов (warm-boot, LS_*, GLOW_*, debounce,
navigate, lruRead — копии карточки в рантайме) и 112 приватных членов
карточки, которых не читал никто — делегаты `_editorRuntimeOrThrow()._x()`,
оставшиеся от выноса #425, и аксессоры glow-состояния. Всё это снято; в 9
других файлах — по одиночной ошибке. Делегаты и поля, которых касаются
браузерные смоки (`card._x(...)`), оставлены и посчитаны отдельно.

Гейт `npm run lint:unused` (scripts/unused-locals-gate.mjs, в gate:small и
Validate после сборки): `tsc --noUnusedLocals` чист, кроме приватных членов
карточки из порта HouseplanEditorHostPort / `host.` (portPrivates) и членов,
которых зовёт харнесс (harnessPrivates); храповик по шести числам
scripts/monolith-metrics.mjs против scripts/monolith-baseline.json —
delegates 260→159, portMembers 350, hostRefs 4948, portPrivates 96,
harnessPrivates 107, bundleBytes 2 510 141→2 500 387. `npm run inventory`
печатает те же числа. Заморозка 54 тестов, читающих монолит как текст
(test/monolith-text-anchors.test.mjs); PROCESS.md §2.7 — правило.

Логический исходник для контрактных тестов (test/houseplan-source.mjs)
дописывает члены рантайма без делегата в карточке — контракт продукта не
зависит от наличия заглушки. Потолки ядер и initial gzip опущены на выигрыш
(292 000 → 290 400). Бандл пересобран, три копии синхронны.

Issue: #624
User-Visible: no
2026-09-23 21:09:44 +03:00

1290 lines
53 KiB
TypeScript

/**
* Stage 4 isometric scene orchestration and inert SVG rendering.
*
* The card remains the owner of live HA state and editor interaction. This
* module owns the presentation-only structural cache, overlay projection and
* SVG volume layers so the main Lit shell does not also become a renderer.
*/
import { nothing, svg, type TemplateResult } from 'lit';
import { clampScale, islandsOf, roomPoly, type SpaceDisplay } from './logic';
import {
ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_OVERLAY_VISUAL_OFFSET,
ISO_RAISED_OVERLAY_HEIGHT, ISO_WALL_HEIGHT,
projectPlanPoint, projectedFrame, type PlanPoint, type ScenePoint,
} from './iso-projection';
import {
buildIsoFloorGeometry, buildIsoWallGeometry, isoGeometryFingerprint,
type IsoFloorGeometry, type IsoWallFace, type IsoWallGeometry, type IsoWallTopFace,
} from './iso-walls';
import {
ISO_OPENING_GEOMETRY_POLICY, buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening,
projectIsoOpeningStructure, resolveIsoDecoration,
type IsoDecorationLayers, type IsoOpeningBasis, type IsoOpeningPanel,
type IsoOpeningGeometryPolicy, type IsoOpeningSurface,
} from './iso-openings';
import {
ISO_OVERLAY_MAX_NUDGE_CSS_PX, isoOverlayCollisionKey, isoRoomSafePoint,
resolveIsoOverlayCollisions, resolveIsoOverlayOwner, resolveIsoOverlayPlacement,
type IsoOverlayPlacement, type IsoOverlayRoom, type IsoRaisedOverlayKind,
type IsoWallSilhouette,
} from './iso-overlays';
import {
floorFootprintGeometry, openingInnerFaceOffsetFromIndex,
openingWallIndex as buildOpeningWallIndex, resolveOpeningWallAssociation,
wallBodiesGeometry,
type OpeningWallIndex, type WallEntry,
} from './wall-thickness';
import { partitionOpeningFace } from './partition-openings';
import { openingLockFloorPlacement } from './opening-symbol-placement';
import { physicalBodyParts, type PartitionOpeningCut } from './physical-geometry';
import { gridVisualScale, gridVisualUnits } from './grid-scale';
import { iconUnit, type Rect } from './space-geometry';
import { lruRead, lruWrite } from './card-runtime';
import type { ResolvedDevicePresentation } from './device-presentation';
import { deviceTextScale, legacySupplementalMetrics } from './device-face';
import type { RenderOpening } from './interaction-types';
import type { DevItem, RoomCfg, SpaceModel } from './types';
/** Exact-build handshake for the hidden lazy alpha runtime. */
export const ISO_SCENE_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export type IsoOpeningRenderSurface = IsoOpeningSurface & {
id: string;
sourceIndex: number;
type: string;
leaf?: number;
};
export type IsoWallDepthQueueEntry =
| { layer: 'wall-side'; depth: number; face: IsoWallFace }
| { layer: 'wall-top'; depth: number; face: IsoWallTopFace }
| { layer: 'opening'; depth: number; surface: IsoOpeningRenderSurface };
const isoDepthLayerRank = (entry: IsoWallDepthQueueEntry): number =>
entry.layer === 'wall-side' ? 0 : entry.layer === 'wall-top' ? 1 : 2;
const isoDepthStableKey = (entry: IsoWallDepthQueueEntry): string => {
if (entry.layer === 'wall-side') {
return `${entry.face.polygon}:${entry.face.ring}:${entry.face.edge}`;
}
if (entry.layer === 'wall-top') return String(entry.face.component);
const surface = entry.surface;
return `${surface.sourceIndex}:${surface.leaf ?? -1}:${surface.kind}:${surface.jamb ?? -1}`
+ `:${surface.edge ?? ''}:${surface.id}:${surface.material}`;
};
/**
* Far-to-near painter queue shared by every opaque Stage 4 structural surface.
*
* The established screen-depth order remains authoritative between unrelated
* walls/openings. Inside one opening, however, physical camera depth is the
* authoritative face order: elevated window glass must paint over its rear
* sill, and a door/gate prism must not invert its front/back faces while it
* rotates. Reorder only that opening's already allocated queue slots, so the
* fix cannot move an unrelated wall or opening into a different layer.
*/
export function buildIsoWallDepthQueue(
geometry: IsoWallGeometry,
openingSurfaces: readonly IsoOpeningRenderSurface[],
): readonly IsoWallDepthQueueEntry[] {
const entries: IsoWallDepthQueueEntry[] = [
...geometry.sides.map((face) => ({
layer: 'wall-side' as const, depth: face.depth, face,
})),
...geometry.topFaces.map((face) => ({
layer: 'wall-top' as const, depth: face.depth, face,
})),
...openingSurfaces.map((surface) => ({
layer: 'opening' as const, depth: surface.depth, surface,
})),
];
entries.sort((a, b) => a.depth - b.depth
|| isoDepthLayerRank(a) - isoDepthLayerRank(b)
|| isoDepthStableKey(a).localeCompare(isoDepthStableKey(b)));
const openingSlots = new Map<string, IsoWallDepthQueueEntry[]>();
for (const entry of entries) {
if (entry.layer !== 'opening') continue;
const key = `${entry.surface.type}\u0000${entry.surface.id}`;
const slots = openingSlots.get(key) || [];
slots.push(entry);
openingSlots.set(key, slots);
}
for (const slots of openingSlots.values()) slots.sort((a, b) => {
const surfaceA = a.layer === 'opening' ? a.surface : null;
const surfaceB = b.layer === 'opening' ? b.surface : null;
return (surfaceA?.cameraDepth ?? surfaceA?.depth ?? 0)
- (surfaceB?.cameraDepth ?? surfaceB?.depth ?? 0)
|| isoDepthStableKey(a).localeCompare(isoDepthStableKey(b));
});
const nextOpeningSlot = new Map<string, number>();
return Object.freeze(entries.map((entry) => {
if (entry.layer !== 'opening') return entry;
const key = `${entry.surface.type}\u0000${entry.surface.id}`;
const index = nextOpeningSlot.get(key) || 0;
nextOpeningSlot.set(key, index + 1);
return openingSlots.get(key)?.[index] || entry;
}));
}
export type IsoOverlayRenderEntry = {
id: string;
kind: IsoRaisedOverlayKind;
placement: IsoOverlayPlacement;
groundRadius: number;
/** Screen-facing HTML footprint around visualScene, in scene units. */
screenHalfSize: PlanPoint;
};
export type IsoOverlayRenderScene = {
devices: ReadonlyMap<string, IsoOverlayPlacement>;
rooms: ReadonlyMap<RoomCfg, IsoOverlayPlacement>;
locks: ReadonlyMap<string, IsoOverlayPlacement>;
entries: readonly IsoOverlayRenderEntry[];
/** Internal memo key for the complete raised-overlay group placement. */
collisionSignature: string;
/** Stable keys of pairs that could not be fully separated inside the cap. */
residualPairs: readonly (readonly [string, string])[];
};
/**
* CSS-derived, deliberately conservative footprint constants. The invisible
* footprint has no DOM measurement loop, so every value is an upper bound in
* multiples of the device core / room base-font rather than a sampled pixel
* size. Keep these in one place beside the pure footprint resolver whenever
* the shared face/room-label CSS changes.
*/
export const ISO_RAISED_FOOTPRINT = Object.freeze({
textLatinAdvance: 0.72,
textWideAsciiAdvance: 1,
textWideAdvance: 1.1,
textSpaceAdvance: 0.4,
textWidthSafety: 1.08,
deviceShellInset: 0.134375,
deviceShellGap: 0.1,
deviceSectionGap: 0.08,
deviceBadgeMinWidth: 0.7875,
deviceBadgeHeight: 0.7875,
deviceBadgeInlinePadding: 0.28,
deviceValueInlinePadding: 0.32,
deviceAuxiliaryExtent: 0.7,
deviceLqiFontSize: 0.38,
deviceLqiTop: 0.734375,
deviceLqiBelowBottomBadgeTop: 1.571875,
devicePadding: 0.08,
roomNameLineHeight: 1.25,
roomAreaLinkWidth: 1.1,
roomMetricsTopGap: 0.15,
roomMetricFontSize: 0.75,
roomMetricLineHeight: 1.25,
roomMetricIconWidth: 1.05,
roomMetricInnerGap: 0.12,
roomMetricRowGap: 0.55,
roomMetricMinWidth: 11,
roomPadding: 0.4,
openingLockHalfSize: 0.62,
roomMetricTextCharacters: Object.freeze({
temperature: 10,
humidity: 8,
lqi: 6,
light: 24,
}),
});
type IsoRaisedFootprintInput =
| { kind: 'device'; core: number; presentation: ResolvedDevicePresentation }
| {
kind: 'room-label';
font: number;
room: RoomCfg;
display: Pick<SpaceDisplay, 'labelTemp' | 'labelHum' | 'labelLqi' | 'labelLight'>;
}
| { kind: 'opening-lock'; size: number };
type RelativeBounds = { left: number; right: number; top: number; bottom: number };
function conservativeTextAdvance(text: string): number {
return [...String(text)].reduce((sum, char) => {
if (/\s/.test(char)) return sum + ISO_RAISED_FOOTPRINT.textSpaceAdvance;
if (/[MW@#%&]/.test(char)) return sum + ISO_RAISED_FOOTPRINT.textWideAsciiAdvance;
return sum + (char.codePointAt(0)! > 0xff
? ISO_RAISED_FOOTPRINT.textWideAdvance : ISO_RAISED_FOOTPRINT.textLatinAdvance);
}, 0) * ISO_RAISED_FOOTPRINT.textWidthSafety;
}
function deviceFaceTextWidth(text: string, fullText: string): number {
return conservativeTextAdvance(text) * deviceTextScale(fullText || text);
}
function halfSizeOf(bounds: RelativeBounds, padding: number): PlanPoint {
return [
Math.max(Math.abs(bounds.left), Math.abs(bounds.right)) + padding,
Math.max(Math.abs(bounds.top), Math.abs(bounds.bottom)) + padding,
];
}
/** Conservative invisible footprint for every supported raised HTML overlay. */
export function isoRaisedOverlayHalfSize(input: IsoRaisedFootprintInput): PlanPoint {
if (input.kind === 'opening-lock') {
const size = Number.isFinite(input.size) && input.size > 0 ? input.size : 0;
const half = size * ISO_RAISED_FOOTPRINT.openingLockHalfSize;
return [half, half];
}
if (input.kind === 'room-label') {
const font = Number.isFinite(input.font) && input.font > 0 ? input.font : 0;
const nameScale = clampScale(input.room.settings?.name_scale);
const metricScale = clampScale(input.room.settings?.label_scale);
const nameWidth = (conservativeTextAdvance(input.room.name || '')
+ (input.room.area ? ISO_RAISED_FOOTPRINT.roomAreaLinkWidth : 0)) * nameScale;
const nameHeight = ISO_RAISED_FOOTPRINT.roomNameLineHeight * nameScale;
const metricSlots = [
input.display.labelTemp ? ISO_RAISED_FOOTPRINT.roomMetricTextCharacters.temperature : 0,
input.display.labelHum ? ISO_RAISED_FOOTPRINT.roomMetricTextCharacters.humidity : 0,
input.display.labelLqi && input.room.area
? ISO_RAISED_FOOTPRINT.roomMetricTextCharacters.lqi : 0,
input.display.labelLight ? ISO_RAISED_FOOTPRINT.roomMetricTextCharacters.light : 0,
].filter((characters) => characters > 0);
const metricFont = ISO_RAISED_FOOTPRINT.roomMetricFontSize * metricScale;
const metricsWidth = metricSlots.length
? Math.max(ISO_RAISED_FOOTPRINT.roomMetricMinWidth,
metricSlots.reduce((sum, characters) => sum
+ ISO_RAISED_FOOTPRINT.roomMetricIconWidth
+ ISO_RAISED_FOOTPRINT.roomMetricInnerGap
+ characters * ISO_RAISED_FOOTPRINT.textLatinAdvance
* ISO_RAISED_FOOTPRINT.textWidthSafety, 0)
+ (metricSlots.length - 1) * ISO_RAISED_FOOTPRINT.roomMetricRowGap) * metricFont
: 0;
const metricsHeight = metricSlots.length
? metricFont * ISO_RAISED_FOOTPRINT.roomMetricLineHeight : 0;
const bounds: RelativeBounds = {
left: -Math.max(nameWidth, metricsWidth) / 2,
right: Math.max(nameWidth, metricsWidth) / 2,
top: -nameHeight / 2,
bottom: metricSlots.length
? nameHeight / 2 + ISO_RAISED_FOOTPRINT.roomMetricsTopGap + metricsHeight
: nameHeight / 2,
};
const half = halfSizeOf(bounds, ISO_RAISED_FOOTPRINT.roomPadding);
return [half[0] * font, half[1] * font];
}
const core = Number.isFinite(input.core) && input.core > 0 ? input.core : 0;
const presentation = input.presentation;
const coreWidth = presentation.valueText == null ? 1 : Math.max(1,
deviceFaceTextWidth(presentation.valueText,
presentation.valueFullText || presentation.valueText)
+ ISO_RAISED_FOOTPRINT.deviceValueInlinePadding);
const sectionWidths: number[] = [];
if (presentation.valueBadge) {
sectionWidths.push(Math.max(ISO_RAISED_FOOTPRINT.deviceBadgeMinWidth,
deviceFaceTextWidth(presentation.valueBadge.text,
presentation.valueBadge.fullText || presentation.valueBadge.text)
+ ISO_RAISED_FOOTPRINT.deviceBadgeInlinePadding));
}
for (const metric of legacySupplementalMetrics(presentation)) {
const text = metric.text + metric.suffix;
sectionWidths.push(Math.max(ISO_RAISED_FOOTPRINT.deviceBadgeMinWidth,
deviceFaceTextWidth(text, text) + ISO_RAISED_FOOTPRINT.deviceBadgeInlinePadding));
}
const position = presentation.valueBadge?.position || 'right';
let bounds: RelativeBounds;
if (sectionWidths.length && (position === 'left' || position === 'right')) {
const sectionWidth = Math.max(...sectionWidths);
const sectionHeight = sectionWidths.length * ISO_RAISED_FOOTPRINT.deviceBadgeHeight
+ (sectionWidths.length - 1) * ISO_RAISED_FOOTPRINT.deviceSectionGap;
const contentWidth = coreWidth + ISO_RAISED_FOOTPRINT.deviceShellGap + sectionWidth;
const contentHeight = Math.max(1, sectionHeight);
bounds = position === 'right' ? {
left: -0.5 - ISO_RAISED_FOOTPRINT.deviceShellInset,
right: -0.5 + contentWidth + ISO_RAISED_FOOTPRINT.deviceShellInset,
top: -contentHeight / 2 - ISO_RAISED_FOOTPRINT.deviceShellInset,
bottom: contentHeight / 2 + ISO_RAISED_FOOTPRINT.deviceShellInset,
} : {
left: 0.5 - contentWidth - ISO_RAISED_FOOTPRINT.deviceShellInset,
right: 0.5 + ISO_RAISED_FOOTPRINT.deviceShellInset,
top: -contentHeight / 2 - ISO_RAISED_FOOTPRINT.deviceShellInset,
bottom: contentHeight / 2 + ISO_RAISED_FOOTPRINT.deviceShellInset,
};
} else if (sectionWidths.length) {
const sectionWidth = sectionWidths.reduce((sum, width) => sum + width, 0)
+ (sectionWidths.length - 1) * ISO_RAISED_FOOTPRINT.deviceSectionGap;
const contentWidth = Math.max(coreWidth, sectionWidth);
const contentHeight = 1 + ISO_RAISED_FOOTPRINT.deviceShellGap
+ ISO_RAISED_FOOTPRINT.deviceBadgeHeight;
bounds = position === 'bottom' ? {
left: -contentWidth / 2 - ISO_RAISED_FOOTPRINT.deviceShellInset,
right: contentWidth / 2 + ISO_RAISED_FOOTPRINT.deviceShellInset,
top: -0.5 - ISO_RAISED_FOOTPRINT.deviceShellInset,
bottom: -0.5 + contentHeight + ISO_RAISED_FOOTPRINT.deviceShellInset,
} : {
left: -contentWidth / 2 - ISO_RAISED_FOOTPRINT.deviceShellInset,
right: contentWidth / 2 + ISO_RAISED_FOOTPRINT.deviceShellInset,
top: 0.5 - contentHeight - ISO_RAISED_FOOTPRINT.deviceShellInset,
bottom: 0.5 + ISO_RAISED_FOOTPRINT.deviceShellInset,
};
} else {
bounds = {
left: -0.5 - ISO_RAISED_FOOTPRINT.deviceShellInset,
right: -0.5 + coreWidth + ISO_RAISED_FOOTPRINT.deviceShellInset,
top: -0.5 - ISO_RAISED_FOOTPRINT.deviceShellInset,
bottom: 0.5 + ISO_RAISED_FOOTPRINT.deviceShellInset,
};
}
const auxiliary = ISO_RAISED_FOOTPRINT.deviceAuxiliaryExtent;
bounds.left = Math.min(bounds.left, -auxiliary);
bounds.right = Math.max(bounds.right, auxiliary);
bounds.top = Math.min(bounds.top, -auxiliary);
bounds.bottom = Math.max(bounds.bottom, auxiliary);
if (presentation.pulse.animated) {
const pulseRadius = Math.max(1, presentation.pulse.diameterScale) / 2;
bounds.left = Math.min(bounds.left, -pulseRadius);
bounds.right = Math.max(bounds.right, pulseRadius);
bounds.top = Math.min(bounds.top, -pulseRadius);
bounds.bottom = Math.max(bounds.bottom, pulseRadius);
}
if (presentation.lqiText != null) {
const lqiTop = presentation.valueBadge?.position === 'bottom'
? ISO_RAISED_FOOTPRINT.deviceLqiBelowBottomBadgeTop
: ISO_RAISED_FOOTPRINT.deviceLqiTop;
const lqiHalfWidth = conservativeTextAdvance(presentation.lqiText)
* ISO_RAISED_FOOTPRINT.deviceLqiFontSize / 2;
bounds.left = Math.min(bounds.left, -lqiHalfWidth);
bounds.right = Math.max(bounds.right, lqiHalfWidth);
bounds.bottom = Math.max(bounds.bottom,
lqiTop + ISO_RAISED_FOOTPRINT.deviceLqiFontSize);
}
const half = halfSizeOf(bounds, ISO_RAISED_FOOTPRINT.devicePadding);
return [half[0] * core, half[1] * core];
}
export type IsoSceneCacheEntry = {
geometry: IsoWallGeometry;
floor: IsoFloorGeometry;
wallSilhouettes: readonly IsoWallSilhouette[];
openings: readonly IsoOpeningBasis[];
openingSurfaces: readonly IsoOpeningRenderSurface[];
};
export type IsoRenderScene = IsoSceneCacheEntry & {
key: string;
frame: Rect;
/** Presentation-only metadata; never stored in the structural LRU. */
overlayFitEntries?: readonly IsoOverlayRenderEntry[];
};
export type IsoStructuralSource = {
key: string;
build: () => {
walls: unknown;
floor: unknown;
openings: readonly IsoOpeningBasis[];
openingSurfaces: readonly IsoOpeningRenderSurface[];
};
};
export type IsoSourceOpening = {
id: string;
sourceIndex: number;
type: RenderOpening['type'];
x: number;
y: number;
angle: number;
length: number;
flipH: boolean;
flipV: boolean;
};
export function isoSourceOpenings(
openings: readonly RenderOpening[], coordinateScale: number,
): IsoSourceOpening[] {
return openings.flatMap((opening, sourceIndex) => opening.orphanReason ? [] : [{
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 * coordinateScale,
flipH: !!opening.flip_h,
flipV: !!opening.flip_v,
}]);
}
/** Geometry-only room snapshot used by the structural cache fingerprint. */
export function isoStructuralRoomGeometry(room: RoomCfg): Pick<
RoomCfg, 'id' | 'x' | 'y' | 'w' | 'h' | 'poly' | 'wall_ids'
> {
return {
id: room.id,
x: room.x,
y: room.y,
w: room.w,
h: room.h,
poly: room.poly,
wall_ids: room.wall_ids,
};
}
export type IsoStructuralOpeningHost = {
sourceIndex: number;
hostId: string;
t: number;
depth: number;
face: { ox: number; oy: number; cm: number; side: -1 | 1 };
};
/** Exact canonical partition-host inputs consumed by the opening-volume build. */
export function isoStructuralOpeningHost(
rendered: RenderOpening | undefined,
opening: IsoSourceOpening,
): IsoStructuralOpeningHost | null {
const resolved = rendered?.partitionHost;
if (!resolved) return null;
const faceFlipV = opening.type === 'gate' ? !opening.flipV : opening.flipV;
return {
sourceIndex: opening.sourceIndex,
hostId: resolved.host.id,
t: resolved.t,
depth: resolved.depth,
face: partitionOpeningFace(resolved, faceFlipV),
};
}
export interface IsoStructuralSourceInput {
space: SpaceModel;
walls: WallEntry[];
openCuts: number[][];
openings: readonly RenderOpening[];
partitionCuts(): readonly PartitionOpeningCut[];
roomOpenings(): Array<{ x: number; y: number; angle: number; length: number }>;
cellCm: number;
gridPitch: number;
wallKeyPitch: number;
coordinateScale: number;
onBuild(): void;
}
/** Build the structural key eagerly and the expensive booleans only on miss. */
export function createIsoStructuralSource(
input: IsoStructuralSourceInput,
openingGeometryPolicy: Readonly<IsoOpeningGeometryPolicy> = ISO_OPENING_GEOMETRY_POLICY,
): IsoStructuralSource {
const openings = isoSourceOpenings(input.openings, input.coordinateScale);
const openingHosts = openings.map((opening) =>
isoStructuralOpeningHost(input.openings[opening.sourceIndex], opening));
const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, input.cellCm);
const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, input.cellCm);
const raisedHeight = gridVisualUnits(ISO_RAISED_OVERLAY_HEIGHT, input.cellCm);
const key = `${input.space.id}|${isoGeometryFingerprint({
rooms: input.space.rooms.map(isoStructuralRoomGeometry),
walls: input.walls,
openCuts: input.openCuts,
openings,
openingHosts,
openingGeometryPolicy,
partitions: input.space.partitions,
columns: input.space.wall_columns,
cellCm: input.cellCm,
gridPitch: input.gridPitch,
wallKeyPitch: input.wallKeyPitch,
camera: ISO_CAMERA,
wallHeight,
raisedHeight,
floorEdgeHeight,
algorithm: 5,
})}`;
return {
key,
build: () => {
input.onBuild();
const extras = physicalBodyParts(
input.space, input.cellCm, input.gridPitch, input.gridPitch * 0.0002,
input.partitionCuts(),
).all;
const roomOpenings = input.roomOpenings();
const united = input.walls.length || extras.length
? wallBodiesGeometry(
input.space.rooms, input.walls, input.openCuts, roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordinateScale, extras,
)
: null;
if (united && (united.status === 'failed-core' || united.status === 'not-applicable'))
throw new Error('wall boolean geometry failed');
const floor = united?.paperGeom ?? floorFootprintGeometry(
input.space.rooms, input.walls, input.openCuts,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordinateScale,
);
if (!floor) throw new Error('floor boolean geometry failed');
const openingIndex = united?.openingIndex || buildOpeningWallIndex(
input.space.rooms, input.walls, input.openCuts,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordinateScale,
);
const openingBases = openings.map((opening, index) => {
const host = openingHosts[index];
const faceFlipV = opening.type === 'gate' ? !opening.flipV : opening.flipV;
const face = host
? host.face
: input.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, openingGeometryPolicy);
});
const frozenBases = Object.freeze(openingBases);
return {
walls: united?.components.flatMap((component) => component.geom) || [],
floor,
openings: frozenBases,
openingSurfaces: Object.freeze(frozenBases.flatMap((basis) =>
projectIsoOpeningStructure(basis).map((surface) => Object.freeze({
...surface,
id: basis.id,
sourceIndex: basis.sourceIndex,
type: basis.type,
})))),
};
},
};
}
const unknownArray = (value: unknown): readonly unknown[] => Array.isArray(value) ? value : [];
/** Project a cached physical wall union once for overlay collision tests. */
export function isoWallSilhouettesOf(geometry: unknown, height: number): IsoWallSilhouette[] {
const projectRing = (raw: unknown): ScenePoint[] => unknownArray(raw).flatMap((point) => {
if (!Array.isArray(point) || !Number.isFinite(point[0]) || !Number.isFinite(point[1])) return [];
return [projectPlanPoint([Number(point[0]), Number(point[1])], height)];
});
return unknownArray(geometry).flatMap((rawPolygon) => {
const polygon = unknownArray(rawPolygon);
if (!polygon.length) return [];
const outer = projectRing(polygon[0]);
if (outer.length < 3) return [];
const holes = polygon.slice(1).map(projectRing).filter((ring) => ring.length >= 3);
return [{ outer, holes } satisfies IsoWallSilhouette];
});
}
export interface ResolveIsoSceneInput {
source: IsoStructuralSource;
cache: Map<string, IsoSceneCacheEntry>;
cellCm: number;
liveFrame: Rect;
}
export function resolveIsoScene(input: ResolveIsoSceneInput): IsoRenderScene {
const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, input.cellCm);
const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, input.cellCm);
const raisedHeight = gridVisualUnits(ISO_RAISED_OVERLAY_HEIGHT, input.cellCm);
const cached = lruRead(input.cache, input.source.key);
let value = cached.hit ? cached.value : undefined;
if (!value) {
const structural = input.source.build();
const geometry = buildIsoWallGeometry(structural.walls, ISO_CAMERA, wallHeight);
const wallTops = isoWallSilhouettesOf(structural.walls, wallHeight);
value = {
geometry,
floor: buildIsoFloorGeometry(structural.floor, floorEdgeHeight),
wallSilhouettes: Object.freeze([
...wallTops,
...geometry.sides.map((face) => ({ outer: face.points })),
]),
openings: structural.openings,
openingSurfaces: structural.openingSurfaces,
};
lruWrite(input.cache, input.source.key, value, 8);
}
const openingFrame = isoOpeningBounds(value.openings);
const structuralFrame = openingFrame ? unionRect(input.liveFrame, openingFrame) : input.liveFrame;
const frame = projectedFrame({
rect: structuralFrame,
wallHeight,
openingHeight: wallHeight,
floorDepth: floorEdgeHeight,
raisedHeight,
});
return { key: input.source.key, ...value, frame };
}
const unionRect = (a: Rect, b: Rect): Rect => {
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
return {
x,
y,
w: Math.max(a.x + a.w, b.x + b.w) - x,
h: Math.max(a.y + a.h, b.y + b.h) - y,
};
};
const rectFromPoints = (points: readonly ScenePoint[]): Rect | null => {
if (!points.length || !points.every((point) =>
Number.isFinite(point[0]) && Number.isFinite(point[1]))) return null;
const xs = points.map((point) => point[0]), ys = points.map((point) => point[1]);
const x = Math.min(...xs), y = Math.min(...ys);
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
};
const overlayEntryPoints = (
entry: IsoOverlayRenderEntry, final: boolean,
): ScenePoint[] => {
const placement = entry.placement;
const center = final ? placement.visualScene : placement.raisedScene;
const [halfX, halfY] = entry.screenHalfSize;
const footprint = final ? placement.footprint : placement.footprint.map((point) => [
point[0] - placement.nudgeScene[0], point[1] - placement.nudgeScene[1],
] as ScenePoint);
return [
placement.floorScene,
...footprint,
[center[0] - halfX, center[1] - halfY],
[center[0] + halfX, center[1] - halfY],
[center[0] + halfX, center[1] + halfY],
[center[0] - halfX, center[1] + halfY],
];
};
const selectedOverlayEntries = (
entries: readonly IsoOverlayRenderEntry[], ownerId?: string,
): readonly IsoOverlayRenderEntry[] => ownerId === undefined ? entries
: entries.filter((entry) => entry.placement.owner?.id === ownerId);
/** Exact AABB of the final screen-facing roots and their invisible footprints. */
export function isoOverlaySceneBounds(
scene: IsoOverlayRenderScene | null, ownerId?: string,
): Rect | null {
return scene ? rectFromPoints(selectedOverlayEntries(scene.entries, ownerId)
.flatMap((entry) => overlayEntryPoints(entry, true))) : null;
}
export interface IsoOverlayFitEnvelopeInput {
baseBounds: Rect;
entries: readonly IsoOverlayRenderEntry[];
stageSize?: { width: number; height: number } | null;
targetView(bounds: Rect): Rect | null;
ownerId?: string;
}
/**
* Stable fit envelope for screen-facing content. It starts from unnudged
* geometry, then reserves the complete bounded CSS nudge at the fitted scale;
* current zoom can therefore never feed back into the canonical home frame.
*/
export function resolveIsoOverlayFitEnvelope(
input: IsoOverlayFitEnvelopeInput,
): { bounds: Rect; view: Rect } | null {
const entries = selectedOverlayEntries(input.entries, input.ownerId);
const overlay = rectFromPoints(entries.flatMap((entry) => overlayEntryPoints(entry, false)));
const base = overlay ? unionRect(input.baseBounds, overlay) : input.baseBounds;
const initial = input.targetView(base);
if (!initial || !(initial.w > 0) || !(initial.h > 0)
|| ![initial.x, initial.y, initial.w, initial.h].every(Number.isFinite)) return null;
const stage = input.stageSize;
if (!overlay || !stage || !(stage.width > 0) || !(stage.height > 0))
return { bounds: base, view: initial };
const scaleOf = (view: Rect) => Math.max(view.w / stage.width, view.h / stage.height);
const candidate = (scale: number): { bounds: Rect; view: Rect } | null => {
const pad = ISO_OVERLAY_MAX_NUDGE_CSS_PX * scale;
const padded = { x: overlay.x - pad, y: overlay.y - pad,
w: overlay.w + pad * 2, h: overlay.h + pad * 2 };
const bounds = unionRect(input.baseBounds, padded);
const view = input.targetView(bounds);
return view && [view.x, view.y, view.w, view.h].every(Number.isFinite)
&& view.w > 0 && view.h > 0 ? { bounds, view } : null;
};
let low = 0, high = Math.max(scaleOf(initial), Number.EPSILON), resolved = candidate(high);
for (let iteration = 0; resolved && scaleOf(resolved.view) > high * (1 + 1e-10)
&& iteration < 24; iteration++) {
low = high; high *= 2; resolved = candidate(high);
}
if (!resolved) return null;
for (let iteration = 0; iteration < 40; iteration++) {
const middle = (low + high) / 2;
const next = candidate(middle);
if (!next) return null;
if (scaleOf(next.view) <= middle) { high = middle; resolved = next; }
else low = middle;
}
return resolved;
}
export interface IsoOverlaySceneInput {
space: SpaceModel;
devices: readonly DevItem[];
openings: readonly RenderOpening[];
view: Rect;
display: SpaceDisplay;
wallSilhouettes: readonly IsoWallSilhouette[];
/** Fit-envelope probes need unnudged bounds only, not wall collision search. */
resolveCollisions?: boolean;
iconPct: number;
deviceBasePct: number;
showLqi: boolean;
cellCm: number;
kioskIconScale: number;
kioskFontScale: number;
stageSize?: { width: number; height: number } | null;
positionOf(device: DevItem): { x: number; y: number };
presentationOf(device: DevItem, showLqi: boolean): ResolvedDevicePresentation;
labelPositionOf(room: RoomCfg, spaceId: string): { x: number; y: number };
labelScaleOf(room: RoomCfg): number;
openingEntityAvailable(entityId: string): boolean;
openingWallIndex(): OpeningWallIndex;
}
type IsoOverlayRoomRow = { room: RoomCfg; overlayRoom: IsoOverlayRoom };
const isoOverlayRoomCache = new WeakMap<readonly RoomCfg[], readonly IsoOverlayRoomRow[]>();
type IsoOverlayPlacementCacheEntry = {
signature: string;
shapeSignature: string;
unitsPerPixel: number;
placement: IsoOverlayPlacement;
};
type IsoOverlayOwnerCacheEntry = { signature: string; owner: IsoOverlayPlacement['owner'] };
export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;
const isoOverlayPlacementCache = new WeakMap<
readonly IsoWallSilhouette[], Map<string, IsoOverlayPlacementCacheEntry>
>();
const isoOverlayOwnerCache = new WeakMap<
readonly IsoOverlayRoomRow[], Map<string, IsoOverlayOwnerCacheEntry>
>();
const isoOverlayRenderSceneCache = new WeakMap<
readonly IsoWallSilhouette[], Map<'fit' | 'live', IsoOverlayRenderScene>
>();
function samePlacementMap<K>(
previous: ReadonlyMap<K, IsoOverlayPlacement>, next: ReadonlyMap<K, IsoOverlayPlacement>,
): boolean {
if (previous.size !== next.size) return false;
for (const [key, placement] of next) if (previous.get(key) !== placement) return false;
return true;
}
function sameOverlayEntries(
previous: readonly IsoOverlayRenderEntry[], next: readonly IsoOverlayRenderEntry[],
): boolean {
return previous.length === next.length && next.every((entry, index) => {
const before = previous[index];
return before.id === entry.id && before.kind === entry.kind
&& before.placement === entry.placement && before.groundRadius === entry.groundRadius
&& before.screenHalfSize[0] === entry.screenHalfSize[0]
&& before.screenHalfSize[1] === entry.screenHalfSize[1];
});
}
/** Build legal island-room holes once per immutable room snapshot. */
export function isoOverlayRooms(space: SpaceModel): readonly IsoOverlayRoomRow[] {
const cached = isoOverlayRoomCache.get(space.rooms);
if (cached) return cached;
const rows = space.rooms.flatMap((room, index) => {
const poly = roomPoly(room);
return poly && poly.length >= 3 ? [{ room, index, poly }] : [];
});
const result = Object.freeze(rows.map(({ room, index, poly }) => {
const holes = islandsOf(poly, rows.filter((row) => row.index !== index).map((row) => row.poly));
const base: IsoOverlayRoom = {
id: room.id || `room-index-${index}`,
outer: poly.map((point) => [point[0], point[1]] as PlanPoint),
holes: holes.map((ring) => ring.map((point) => [point[0], point[1]] as PlanPoint)),
};
return { room, overlayRoom: { ...base, safePoint: isoRoomSafePoint(base) || undefined } };
}));
isoOverlayRoomCache.set(space.rooms, result);
return result;
}
/** Build the one-frame mapping from immutable floor anchors to raised visuals. */
export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOverlayRenderScene {
const unitsPerPixel = input.stageSize && input.stageSize.width > 0 && input.stageSize.height > 0
? Math.max(input.view.w / input.stageSize.width, input.view.h / input.stageSize.height)
: Math.max(input.view.w, input.view.h) / 1000;
const roomRows = isoOverlayRooms(input.space);
const rooms = roomRows.map((row) => row.overlayRoom);
const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, input.cellCm);
const visualOffset = gridVisualUnits(ISO_OVERLAY_VISUAL_OFFSET, input.cellCm);
const devices = new Map<string, IsoOverlayPlacement>();
const roomPlacements = new Map<RoomCfg, IsoOverlayPlacement>();
const locks = new Map<string, IsoOverlayPlacement>();
const entries: IsoOverlayRenderEntry[] = [];
let placements = isoOverlayPlacementCache.get(input.wallSilhouettes);
if (!placements) {
placements = new Map();
isoOverlayPlacementCache.set(input.wallSilhouettes, placements);
}
let owners = isoOverlayOwnerCache.get(roomRows);
if (!owners) {
owners = new Map();
isoOverlayOwnerCache.set(roomRows, owners);
}
const baseIconUnits = input.iconPct * iconUnit(input.space) * input.kioskIconScale / 100;
const baseDeviceUnits = input.deviceBasePct * iconUnit(input.space) * input.kioskIconScale / 100;
const place = (
kind: IsoRaisedOverlayKind,
id: string,
floorAnchor: PlanPoint,
footprintHalfSize: PlanPoint,
preferredRoomId?: string | null,
): IsoOverlayPlacement => {
const collisionMode = input.resolveCollisions === false ? 'fit' : 'live';
const cacheKey = `${collisionMode}\u0000${kind}\u0000${id}`;
const shapeSignature = [floorAnchor[0], floorAnchor[1],
footprintHalfSize[0], footprintHalfSize[1],
preferredRoomId || '', wallHeight, visualOffset,
].join('|');
const signature = `${shapeSignature}|${unitsPerPixel}`;
const cached = lruRead(placements!, cacheKey);
if (cached.hit && cached.value.signature === signature) return cached.value.placement;
if (cached.hit && cached.value.shapeSignature === shapeSignature
&& unitsPerPixel <= cached.value.unitsPerPixel) {
const previous = cached.value.placement;
const distanceAtNewScale = previous.nudgeDistanceCss
* cached.value.unitsPerPixel / unitsPerPixel;
// Zooming in only shrinks the required scene-unit safety gap. An
// unchanged footprint that was clear therefore stays clear; an unchanged
// non-near footprint stays non-near. Reuse the immutable scene placement so
// Lit also keeps the raised SVG subtree. Once its CSS displacement would
// exceed the public cap, fall through to the exact resolver.
if (!previous.nearWallBefore
|| previous.cleared && distanceAtNewScale <= ISO_OVERLAY_MAX_NUDGE_CSS_PX)
return previous;
}
const ownerKey = `${kind}\u0000${id}`;
const ownerSignature = [floorAnchor[0], floorAnchor[1], preferredRoomId || ''].join('|');
const cachedOwner = lruRead(owners!, ownerKey);
const owner = cachedOwner.hit && cachedOwner.value.signature === ownerSignature
? cachedOwner.value.owner
: resolveIsoOverlayOwner({
kind, floorAnchor, rooms, roomsValidated: true, preferredRoomId,
});
if (!cachedOwner.hit || cachedOwner.value.signature !== ownerSignature) {
lruWrite(owners!, ownerKey, { signature: ownerSignature, owner },
ISO_OVERLAY_PLACEMENT_CACHE_LIMIT);
}
const placement = resolveIsoOverlayPlacement({
kind,
floorAnchor,
rooms, roomsValidated: true,
preferredRoomId,
ownerAlreadyResolved: true,
resolvedOwner: owner,
nudgeHintCss: cached.hit && cached.value.shapeSignature === shapeSignature
? cached.value.placement.nudgeDistanceCss * cached.value.unitsPerPixel / unitsPerPixel
: undefined,
showBorders: true,
wallSilhouettes: input.resolveCollisions === false ? [] : input.wallSilhouettes,
wallGeometryValidated: true,
footprintHalfSize,
wallHeight,
visualOffset,
sceneUnitsPerCssPixel: unitsPerPixel,
});
lruWrite(placements!, cacheKey, { signature, shapeSignature, unitsPerPixel, placement },
ISO_OVERLAY_PLACEMENT_CACHE_LIMIT);
return placement;
};
for (const device of input.devices) {
const pos = input.positionOf(device);
const presentation = input.presentationOf(device, input.showLqi);
const core = baseDeviceUnits * presentation.scale;
const halfSize = isoRaisedOverlayHalfSize({ kind: 'device', core, presentation });
const preferredRoomId = device.marker?.room_id
|| roomRows.find((row) => !!device.area && row.room.area === device.area)?.overlayRoom.id
|| null;
const placement = place(
'device', device.id, [pos.x, pos.y],
halfSize,
preferredRoomId,
);
devices.set(device.id, placement);
entries.push({
id: device.id,
kind: 'device',
placement,
groundRadius: Math.max(core * 0.32, 2),
screenHalfSize: halfSize,
});
}
for (const { room, overlayRoom } of roomRows) {
if (!input.display.showNames || !room.name) continue;
const pos = input.labelPositionOf(room, input.space.id);
const scale = input.labelScaleOf(room) * input.kioskFontScale * input.display.cardFontScale;
const font = baseIconUnits * 0.5 * scale;
const halfSize = isoRaisedOverlayHalfSize({
kind: 'room-label', font, room, display: input.display,
});
const placement = place(
'room-label', overlayRoom.id, [pos.x, pos.y],
halfSize, overlayRoom.id,
);
roomPlacements.set(room, placement);
entries.push({
id: overlayRoom.id,
kind: 'room-label',
placement,
groundRadius: Math.max(font * 0.75, 2),
screenHalfSize: halfSize,
});
}
const lockItems = input.openings.filter((opening): opening is RenderOpening & { lock: string } =>
!opening.orphanReason && (opening.type === 'door' || opening.type === 'gate')
&& typeof opening.lock === 'string' && !!opening.lock
&& input.openingEntityAvailable(opening.lock));
if (lockItems.length) {
const openingWallIndex = input.openingWallIndex();
for (const opening of lockItems) {
const lockPlacement = isoOpeningLockPlacement(opening, openingWallIndex, input.cellCm);
const floorAnchor = lockPlacement.floorAnchor;
const size = baseIconUnits * 0.62;
const halfSize = isoRaisedOverlayHalfSize({ kind: 'opening-lock', size });
const placement = place(
'opening-lock', String(opening.id), floorAnchor,
halfSize, lockPlacement.preferredRoomId,
);
locks.set(String(opening.id), placement);
entries.push({
id: String(opening.id),
kind: 'opening-lock',
placement,
groundRadius: Math.max(size * 0.28, 2),
screenHalfSize: halfSize,
});
}
}
const mode = input.resolveCollisions === false ? 'fit' : 'live';
let renderScenes = isoOverlayRenderSceneCache.get(input.wallSilhouettes);
if (!renderScenes) {
renderScenes = new Map();
isoOverlayRenderSceneCache.set(input.wallSilhouettes, renderScenes);
}
const previous = renderScenes.get(mode);
const collisionSignature = entries.map((entry) => {
const placement = entry.placement;
return [entry.kind, entry.id, placement.floorAnchor[0], placement.floorAnchor[1],
placement.raisedScene[0], placement.raisedScene[1], placement.nudgeScene[0],
placement.nudgeScene[1], placement.owner?.id || '', placement.status, placement.reason || '',
entry.screenHalfSize[0], entry.screenHalfSize[1], unitsPerPixel].join('|');
}).sort().join('\u0001');
if (previous?.collisionSignature === collisionSignature
&& samePlacementMap(previous.rooms, roomPlacements)) return previous;
const previousEntries = new Map(previous?.entries.map((entry) => [
`${entry.kind}\u0000${entry.id}`, entry,
]) || []);
const collision = mode === 'live' ? resolveIsoOverlayCollisions({
items: entries.flatMap((entry) => {
if (entry.kind === 'room-label') return [];
const before = previousEntries.get(`${entry.kind}\u0000${entry.id}`);
const sameShape = !!before
&& before.screenHalfSize[0] === entry.screenHalfSize[0]
&& before.screenHalfSize[1] === entry.screenHalfSize[1]
&& before.placement.floorAnchor[0] === entry.placement.floorAnchor[0]
&& before.placement.floorAnchor[1] === entry.placement.floorAnchor[1]
&& (before.placement.owner?.id || '') === (entry.placement.owner?.id || '');
return [{
id: entry.id,
kind: entry.kind,
placement: entry.placement,
screenHalfSize: entry.screenHalfSize,
...(sameShape ? { nudgeHintCss: [
before!.placement.nudgeScene[0] / unitsPerPixel,
before!.placement.nudgeScene[1] / unitsPerPixel,
] as ScenePoint } : {}),
}];
}),
rooms,
wallSilhouettes: input.wallSilhouettes,
sceneUnitsPerCssPixel: unitsPerPixel,
visualOffset,
}) : { placements: new Map<string, IsoOverlayPlacement>(), residualPairs: [] };
const resolvedEntries = Object.freeze(entries.map((entry) => {
if (entry.kind === 'room-label') return entry;
const placement = collision.placements.get(isoOverlayCollisionKey(entry.kind, entry.id))
|| entry.placement;
return placement === entry.placement ? entry : { ...entry, placement };
}));
const resolvedDevices = new Map<string, IsoOverlayPlacement>();
const resolvedLocks = new Map<string, IsoOverlayPlacement>();
for (const entry of resolvedEntries) {
if (entry.kind === 'device') resolvedDevices.set(entry.id, entry.placement);
else if (entry.kind === 'opening-lock') resolvedLocks.set(entry.id, entry.placement);
}
const scene: IsoOverlayRenderScene = {
devices: resolvedDevices,
rooms: roomPlacements,
locks: resolvedLocks,
entries: resolvedEntries,
collisionSignature,
residualPairs: Object.freeze([...collision.residualPairs]),
};
if (previous && sameOverlayEntries(previous.entries, scene.entries)
&& samePlacementMap(previous.devices, scene.devices)
&& samePlacementMap(previous.rooms, scene.rooms)
&& samePlacementMap(previous.locks, scene.locks)) return previous;
renderScenes.set(mode, scene);
return scene;
}
export function isoOpeningLockAnchor(
opening: RenderOpening,
openingWallIndex: OpeningWallIndex,
cellCm: number,
): PlanPoint {
return isoOpeningLockPlacement(opening, openingWallIndex, cellCm).floorAnchor;
}
export function isoOpeningLockPlacement(
opening: RenderOpening,
openingWallIndex: OpeningWallIndex,
cellCm: number,
): { floorAnchor: PlanPoint; preferredRoomId: string | null } {
const faceFlipV = !opening.flip_v;
const gateFace = opening.type === 'gate'
? opening.partitionHost
? partitionOpeningFace(opening.partitionHost, faceFlipV)
: openingInnerFaceOffsetFromIndex(openingWallIndex, {
x: opening.rx,
y: opening.ry,
angle: opening.angle,
length: opening.rlen,
flip_v: faceFlipV,
})
: null;
const [floorAnchor, negativeSide] = openingLockFloorPlacement({
x: opening.rx,
y: opening.ry,
angle: opening.angle,
flipV: !!opening.flip_v,
gateFace,
}, cellCm);
if (opening.partitionHost) return { floorAnchor, preferredRoomId: null };
const association = resolveOpeningWallAssociation(openingWallIndex, {
x: opening.rx,
y: opening.ry,
angle: opening.angle,
length: opening.rlen,
}, true);
const side = negativeSide ? 'negative' : 'positive';
return { floorAnchor, preferredRoomId: association[side]?.roomId || null };
}
export function resolveIsoDecorationLayers(display: SpaceDisplay): IsoDecorationLayers {
// Capability probes are presentation-only and must never enter the
// structural Flat fallback. Unknown/throwing filter support degrades to the
// same solid path as an explicitly unsupported filter.
let filtersSupported = false;
try {
filtersSupported = typeof CSS !== 'undefined'
&& typeof CSS.supports === 'function'
&& CSS.supports('filter', 'blur(1px)');
} catch { filtersSupported = false; }
let forcedColors = false;
try {
forcedColors = typeof matchMedia === 'function'
&& matchMedia('(forced-colors: active)').matches;
} catch { forcedColors = true; }
return resolveIsoDecoration({
showBorders: display.showBorders,
hideOpenings: display.hideOpenings,
filtersSupported,
forcedColors,
});
}
export function resolveIsoOpeningPanels(
layers: IsoDecorationLayers,
scene: IsoRenderScene | null,
openings: readonly RenderOpening[],
amountOf: (opening: RenderOpening) => number,
): IsoOpeningPanel[] {
if (!layers.panels || !scene) return [];
const panels = scene.openings.flatMap((basis) => {
const opening = openings[basis.sourceIndex];
return opening ? projectIsoOpening(basis, amountOf(opening)) : [];
});
return panels.sort((a, b) => a.depth - b.depth
|| a.sourceIndex - b.sourceIndex || a.leaf - b.leaf);
}
export interface IsoFramePresentation {
layers: IsoDecorationLayers;
panels: readonly IsoOpeningPanel[];
overlays: IsoOverlayRenderScene | null;
underlay: TemplateResult;
shadows: TemplateResult;
walls: TemplateResult;
grounds: TemplateResult;
raised: TemplateResult;
}
/** Resolve every lazy Stage 4 artifact inside the card's single failure boundary. */
export function resolveIsoFramePresentation(input: {
projection: 'flat' | 'iso';
display: SpaceDisplay;
scene: IsoRenderScene | null;
openings: readonly RenderOpening[];
amountOf: (opening: RenderOpening) => number;
overlays: (layers: IsoDecorationLayers) => IsoOverlayRenderScene | null;
cellCm: number;
}): IsoFramePresentation {
const layers = resolveIsoDecorationLayers(input.display);
const panels = layers.panels
? resolveIsoOpeningPanels(layers, input.scene, input.openings, input.amountOf)
: [];
const overlays = input.overlays(layers);
const render = (renderLayers: IsoDecorationLayers): IsoFramePresentation => ({
layers: renderLayers, panels, overlays,
underlay: renderIsoUnderlay(renderLayers, input.scene?.floor, input.cellCm),
shadows: renderIsoShadows(renderLayers, panels, input.scene?.geometry, input.cellCm),
walls: renderIsoWalls(input.projection, renderLayers, input.scene, panels, input.cellCm),
grounds: renderIsoOverlayGrounds(overlays, renderLayers, input.cellCm),
raised: renderIsoRaisedOverlays(overlays),
});
try { return render(layers); } catch (error) {
// A material/shadow presentation failure loses only decorative nuance.
// Structural projection errors above (panels/overlays), or failures that
// persist on the solid retry, still escape to the fingerprint latch.
if (!layers.shadows && !layers.materialNuance) throw error;
return render({ ...layers, shadows: false, materialNuance: false });
};
}
const emptySvg = (): TemplateResult => svg`` as unknown as TemplateResult;
/** Scale-aware visual light vector for the one remaining building ambient shadow. */
export function isoFixedLightTransform(cellCm: number): string {
return `translate(${gridVisualUnits(4, cellCm)} ${gridVisualUnits(8, cellCm)})`;
}
function renderIsoDefs(
layers: IsoDecorationLayers,
root: 'underlay' | 'walls',
cellCm: number,
): TemplateResult {
const visualScale = gridVisualScale(cellCm);
return svg`<defs>
${root === 'walls' && layers.materialNuance ? svg`
<linearGradient id="hp-iso-wall-side" data-hp-iso-material-def 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" data-hp-iso-material-def 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>
<pattern id="hp-iso-wall-texture" data-hp-iso-material-def patternUnits="userSpaceOnUse"
width="${12 * visualScale}" height="${12 * visualScale}">
<circle class="iso-texture-mark" cx="${2 * visualScale}" cy="${2 * visualScale}"
r="${0.55 * visualScale}"></circle>
<path class="iso-texture-line" d="M ${5 * visualScale} ${10 * visualScale}
l ${5 * visualScale} ${-5 * visualScale}"></path>
</pattern>` : nothing}
${root === 'underlay' && layers.materialNuance ? svg`
<pattern id="hp-iso-floor-texture" data-hp-iso-material-def patternUnits="userSpaceOnUse"
width="${14 * visualScale}" height="${14 * visualScale}">
<circle class="iso-texture-mark" cx="${3 * visualScale}" cy="${3 * visualScale}"
r="${0.5 * visualScale}"></circle>
</pattern>` : nothing}
${root === 'underlay' && layers.shadows ? svg`
<filter id="hp-iso-ambient-shadow" data-hp-iso-material-def x="-12%" y="-12%" width="124%" height="130%">
<feGaussianBlur stdDeviation="${7 * visualScale}"></feGaussianBlur>
</filter>` : nothing}
</defs>` as unknown as TemplateResult;
}
export function renderIsoOverlayGrounds(
_overlays: IsoOverlayRenderScene | null,
_layers: IsoDecorationLayers,
_cellCm: number,
): TemplateResult {
return emptySvg();
}
export function renderIsoRaisedOverlays(
_overlays: IsoOverlayRenderScene | null,
): TemplateResult {
return emptySvg();
}
export function renderIsoUnderlay(
layers: IsoDecorationLayers,
floor: IsoFloorGeometry | undefined,
cellCm: number,
): TemplateResult {
if (!layers.structural || !floor) return emptySvg();
return svg`<g class="iso-underlay" data-hp="iso-underlay" aria-hidden="true" pointer-events="none">
${renderIsoDefs(layers, 'underlay', cellCm)}
${layers.shadows && floor.footprintPath
? svg`<path class="iso-ambient-shadow" d=${floor.footprintPath}
transform=${isoFixedLightTransform(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>${layers.materialNuance
? svg`<path class="iso-material-texture iso-floor-texture" d=${face.d}></path>`
: nothing}`)}</g>
</g>` as unknown as TemplateResult;
}
export function renderIsoShadows(
_layers: IsoDecorationLayers,
_panels: readonly IsoOpeningPanel[],
_geometry: IsoWallGeometry | undefined,
_cellCm: number,
): TemplateResult {
return emptySvg();
}
export function renderIsoWalls(
projection: 'flat' | 'iso',
layers: IsoDecorationLayers,
scene: IsoRenderScene | null,
panels: readonly IsoOpeningPanel[],
cellCm: number,
): TemplateResult {
if (projection !== 'iso' || !layers.structural || !scene) return emptySvg();
const openingSurfaces: IsoOpeningRenderSurface[] = layers.panels ? [
...scene.openingSurfaces,
...panels.flatMap((panel) => panel.surfaces.map((surface) => ({
...surface,
id: panel.id,
sourceIndex: panel.sourceIndex,
type: panel.type,
leaf: panel.leaf,
}))),
] : [];
const depthQueue = buildIsoWallDepthQueue(scene.geometry, openingSurfaces);
return svg`<g class="iso-walls" data-hp="iso-walls" data-fingerprint=${scene.key}>
${renderIsoDefs(layers, 'walls', cellCm)}
<g class=${layers.panels ? 'iso-wall-depth-queue iso-openings' : 'iso-wall-depth-queue'}
data-hp=${layers.panels ? 'iso-openings' : nothing}
aria-hidden="true" pointer-events="none">${depthQueue.map((entry) => {
if (entry.layer === 'wall-side') {
const face = entry.face;
return svg`<path class="iso-wall-side" d=${face.d} data-edge=${face.edge}></path>${layers.materialNuance
? svg`<path class="iso-material-texture iso-wall-texture" d=${face.d}></path>`
: nothing}`;
}
if (entry.layer === 'wall-top') {
const face = entry.face;
return svg`<path class="iso-wall-top" d=${face.d} data-component=${face.component}
fill-rule="evenodd"></path>${layers.materialNuance
? svg`<path class="iso-material-texture iso-wall-texture" d=${face.d}
fill-rule="evenodd"></path>`
: nothing}`;
}
const surface = entry.surface;
return svg`<path class="iso-opening-panel iso-${surface.type} iso-opening-${surface.kind} iso-material-${surface.material}"
d=${surface.d} data-id=${surface.id} data-kind=${surface.type}
data-surface=${surface.kind} data-leaf=${surface.leaf ?? nothing}></path>${layers.materialNuance
? svg`<path class="iso-material-texture iso-wall-texture iso-opening-texture"
d=${surface.d}></path>`
: nothing}`;
})}</g>
</g>` as unknown as TemplateResult;
}