/** * 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 { ISO_ICON_SCALE } from './iso-tiles'; // #649 п.2: the 2.5D light lives in this lazy graph; Flat never loads it. export { computeIsoSunBeams, renderIsoSunWash } from './iso-sun'; 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, resolveIsoOverlayOwner, resolveIsoOverlayPlacement, resolveIsoOverlayRigidGroups, 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(); 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(); 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; rooms: ReadonlyMap; locks: ReadonlyMap; 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; } | { 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 = 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; 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; /** Fit-scale viewport used only for deterministic overlay layout. */ referenceView?: 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(); 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 >(); const isoOverlayOwnerCache = new WeakMap< readonly IsoOverlayRoomRow[], Map >(); const isoOverlayRenderSceneCache = new WeakMap< readonly IsoWallSilhouette[], Map<'fit' | 'live', IsoOverlayRenderScene> >(); function samePlacementMap( previous: ReadonlyMap, next: ReadonlyMap, ): 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 layoutView = input.referenceView || input.view; const unitsPerPixel = input.stageSize && input.stageSize.width > 0 && input.stageSize.height > 0 ? Math.max(layoutView.w / input.stageSize.width, layoutView.h / input.stageSize.height) : Math.max(layoutView.w, layoutView.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(); const roomPlacements = new Map(); const locks = new Map(); 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); // #649: the 2.5D tile is ICON_SCALE larger; layout and collision see that size. const core = baseDeviceUnits * presentation.scale * ISO_ICON_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 * ISO_ICON_SCALE; 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' ? resolveIsoOverlayRigidGroups({ 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(), 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(); const resolvedLocks = new Map(); 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` ${root === 'walls' && layers.materialNuance ? svg` ` : nothing} ${root === 'underlay' && layers.materialNuance ? svg` ` : nothing} ${root === 'underlay' && layers.shadows ? svg` ` : nothing} ` 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`` 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` ${renderIsoDefs(layers, 'walls', cellCm)} ` as unknown as TemplateResult; }