perf: bound isometric overlay collision work

Issue: #160
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-06 09:17:54 +03:00
parent de2155787d
commit f1b9bbf33e
34 changed files with 555 additions and 434 deletions
+3 -1
View File
@@ -6119,7 +6119,7 @@ export class HouseplanCard extends LitElement {
const overlays = this._isoOverlayScene(
space, this._renderDevices.filter((device) => device.space === space.id && !device.hidden),
baseView, disp, runtime.resolveIsoDecorationLayers(disp), scene, iconPct, effectiveDeviceBaseSize(iconPct),
disp.showLqi ?? this._config?.show_signal ?? true);
disp.showLqi ?? this._config?.show_signal ?? true, false);
const envelope = overlays && runtime.resolveIsoOverlayFitEnvelope({
baseBounds: scene.frame, entries: overlays.entries, stageSize, targetView: (bounds) =>
fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect) });
@@ -6179,12 +6179,14 @@ export class HouseplanCard extends LitElement {
iconPct: number,
deviceBasePct: number,
showLqi: boolean,
resolveCollisions = true,
): IsoOverlayRenderScene | null {
const runtime = this._isoSceneRuntime;
if (!runtime || !layers?.structural || !structural) return null;
return runtime.buildIsoOverlayRenderScene({
space, devices: devs, openings: this._openingsR, view, display: disp, layers,
wallSilhouettes: structural.wallSilhouettes,
resolveCollisions,
iconPct, deviceBasePct, showLqi, cellCm: this._cellCm,
kioskIconScale: this._kiosk ? this._kioskScale.icon : 1,
kioskFontScale: this._kiosk ? this._kioskScale.font : 1,
+48 -6
View File
@@ -43,11 +43,15 @@ export interface IsoOverlayOwnerInput {
rooms: readonly IsoOverlayRoom[];
/** Device binding, room label owner, or the room selected by opening-host geometry. */
preferredRoomId?: string | null;
/** Internal fast path for room rows already normalised by isoOverlayRooms(). */
roomsValidated?: boolean;
}
export interface IsoOverlayPlacementInput extends IsoOverlayOwnerInput {
showBorders: boolean;
wallSilhouettes: readonly IsoWallSilhouette[];
/** Internal fast path for silhouettes produced by the cached structural scene. */
wallGeometryValidated?: boolean;
/** Half-size of the floor-parallel plate in plan units. */
plateHalfSize: PlanPoint;
wallHeight?: number;
@@ -225,7 +229,7 @@ const stableIdCompare = (a: string, b: string): number => a < b ? -1 : a > b ? 1
export function resolveIsoOverlayOwner(input: IsoOverlayOwnerInput): IsoOverlayOwner | null {
if (!finitePoint(input.floorAnchor)) return null;
const rooms = input.rooms.filter(validRoom);
const rooms = input.roomsValidated ? input.rooms : input.rooms.filter(validRoom);
const preferred = input.preferredRoomId
? rooms.find((room) => room.id === input.preferredRoomId) || null
: null;
@@ -241,7 +245,12 @@ export function resolveIsoOverlayOwner(input: IsoOverlayOwnerInput): IsoOverlayO
// a saved label may legitimately lie outside that room.
room = preferred;
}
return room ? { id: room.id, area: roomArea(room), safePoint: isoRoomSafePoint(room) } : null;
return room ? {
id: room.id,
area: roomArea(room),
safePoint: input.roomsValidated && room.safePoint
? [room.safePoint[0], room.safePoint[1]] : isoRoomSafePoint(room),
} : null;
}
export function isoOverlayPlane(kind: IsoOverlayKind, showBorders: boolean): IsoOverlayPlane {
@@ -300,9 +309,39 @@ function pointInSilhouette(point: ScenePoint, silhouette: IsoWallSilhouette): bo
return !(silhouette.holes || []).some((hole) => pointInRing(point, hole));
}
type Bounds = readonly [minX: number, minY: number, maxX: number, maxY: number];
const silhouetteBoundsCache = new WeakMap<IsoWallSilhouette, Bounds | null>();
function ringBounds(ring: readonly (readonly number[])[]): Bounds | null {
if (!ring.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const point of ring) {
if (!finitePoint(point)) return null;
minX = Math.min(minX, point[0]); minY = Math.min(minY, point[1]);
maxX = Math.max(maxX, point[0]); maxY = Math.max(maxY, point[1]);
}
return [minX, minY, maxX, maxY];
}
function silhouetteBounds(silhouette: IsoWallSilhouette, cache: boolean): Bounds | null {
if (!cache) return ringBounds(silhouette.outer);
if (silhouetteBoundsCache.has(silhouette)) return silhouetteBoundsCache.get(silhouette) ?? null;
const bounds = ringBounds(silhouette.outer);
silhouetteBoundsCache.set(silhouette, bounds);
return bounds;
}
function boundsNear(a: Bounds, b: Bounds, gap: number): boolean {
return a[0] <= b[2] + gap && a[2] >= b[0] - gap
&& a[1] <= b[3] + gap && a[3] >= b[1] - gap;
}
function plateNearSilhouette(
plate: readonly ScenePoint[], silhouette: IsoWallSilhouette, gap: number,
plate: readonly ScenePoint[], plateBounds: Bounds,
silhouette: IsoWallSilhouette, gap: number, cacheBounds: boolean,
): boolean {
const wallBounds = silhouetteBounds(silhouette, cacheBounds);
if (!wallBounds || !boundsNear(plateBounds, wallBounds, gap)) return false;
if (plate.some((point) => pointInSilhouette(point, silhouette))) return true;
if (silhouette.outer.some((point) => pointInRing(point, plate))) return true;
for (const wallRing of [silhouette.outer, ...(silhouette.holes || [])]) {
@@ -380,12 +419,15 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
const raisedHeight = wallHeight + visualOffset;
const raisedScene = projectPlanPoint(floorAnchor, raisedHeight, camera);
const owner = resolveIsoOverlayOwner(input);
const geometryValid = input.wallSilhouettes.every(validSilhouette);
const geometryValid = input.wallGeometryValidated ?? input.wallSilhouettes.every(validSilhouette);
const gapUnits = safetyGap * unitsPerPixel;
const basePlate = buildIsoPlatePolygon(floorAnchor, input.plateHalfSize,
raisedHeight, camera);
const isNear = (plate: readonly ScenePoint[]): boolean =>
input.wallSilhouettes.some((wall) => plateNearSilhouette(plate, wall, gapUnits));
const isNear = (plate: readonly ScenePoint[]): boolean => {
const bounds = ringBounds(plate);
return !!bounds && input.wallSilhouettes.some((wall) =>
plateNearSilhouette(plate, bounds, wall, gapUnits, input.wallGeometryValidated === true));
};
const nearWallBefore = geometryValid ? isNear(basePlate) : true;
let distanceCss = 0;
+41 -17
View File
@@ -685,6 +685,8 @@ export interface IsoOverlaySceneInput {
display: SpaceDisplay;
layers: IsoDecorationLayers;
wallSilhouettes: readonly IsoWallSilhouette[];
/** Fit-envelope probes need unnudged bounds only, not wall collision search. */
resolveCollisions?: boolean;
iconPct: number;
deviceBasePct: number;
showLqi: boolean;
@@ -705,6 +707,11 @@ export interface IsoOverlaySceneInput {
type IsoOverlayRoomRow = { room: RoomCfg; overlayRoom: IsoOverlayRoom };
const isoOverlayRoomCache = new WeakMap<readonly RoomCfg[], readonly IsoOverlayRoomRow[]>();
type IsoOverlayPlacementCacheEntry = { signature: string; placement: IsoOverlayPlacement };
export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;
const isoOverlayPlacementCache = new WeakMap<
readonly IsoWallSilhouette[], Map<string, IsoOverlayPlacementCacheEntry>
>();
/** Build legal island-room holes once per immutable room snapshot. */
export function isoOverlayRooms(space: SpaceModel): readonly IsoOverlayRoomRow[] {
@@ -740,6 +747,11 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
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);
}
const baseIconUnits = input.iconPct * iconUnit(input.space) * input.kioskIconScale / 100;
const baseDeviceUnits = input.deviceBasePct * iconUnit(input.space) * input.kioskIconScale / 100;
const place = (
@@ -749,23 +761,35 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
plateHalfSize: PlanPoint,
preferredRoomId?: string | null,
selected = false,
): IsoOverlayPlacement => resolveIsoOverlayPlacement({
kind,
floorAnchor,
rooms,
preferredRoomId,
showBorders: true,
wallSilhouettes: input.wallSilhouettes,
plateHalfSize,
wallHeight,
visualOffset,
sceneUnitsPerCssPixel: unitsPerPixel,
filtersSupported: input.layers.shadows,
// A persistent tether keeps ownership explicit without a second hover-only
// render pipeline for the inert raised geometry.
hovered: true,
selected,
});
): IsoOverlayPlacement => {
const collisionMode = input.resolveCollisions === false ? 'fit' : 'live';
const cacheKey = `${collisionMode}\u0000${kind}\u0000${id}`;
const signature = [floorAnchor[0], floorAnchor[1], plateHalfSize[0], plateHalfSize[1],
preferredRoomId || '', wallHeight, visualOffset, unitsPerPixel,
input.layers.shadows ? 1 : 0, selected ? 1 : 0].join('|');
const cached = lruRead(placements!, cacheKey);
if (cached.hit && cached.value.signature === signature) return cached.value.placement;
const placement = resolveIsoOverlayPlacement({
kind,
floorAnchor,
rooms, roomsValidated: true,
preferredRoomId,
showBorders: true,
wallSilhouettes: input.resolveCollisions === false ? [] : input.wallSilhouettes,
wallGeometryValidated: true,
plateHalfSize,
wallHeight,
visualOffset,
sceneUnitsPerCssPixel: unitsPerPixel,
filtersSupported: input.layers.shadows,
// A persistent tether keeps ownership explicit without a second hover-only
// render pipeline for the inert raised geometry.
hovered: true,
selected,
});
lruWrite(placements!, cacheKey, { signature, placement }, ISO_OVERLAY_PLACEMENT_CACHE_LIMIT);
return placement;
};
for (const device of input.devices) {
const pos = input.positionOf(device);