import type { Entity } from '../entities/Entity'; import { getEntities } from '../state'; import { getSceneVersion } from './scene-version'; /** * Uniform-grid spatial index over top-level entity bounding boxes. * Rebuilt lazily whenever the scene version changes (entity edits bump it). * Queries return entities in original array order so z-order is preserved. */ const GRID_CELLS_PER_AXIS = 64; interface IndexedEntity { entity: Entity; minX: number; minY: number; maxX: number; maxY: number; } let indexVersion = -1; let indexedEntities: IndexedEntity[] = []; let unindexedEntities: Entity[] = []; // bbox unavailable — always included in results let cells: Map = new Map(); let cellSize = 1; let gridMinX = 0; let gridMinY = 0; let gridCols = 1; function cellRange(min: number, max: number, gridMin: number): [number, number] { return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)]; } function ensureIndex(): void { const version = getSceneVersion(); if (version === indexVersion) return; indexedEntities = []; unindexedEntities = []; cells = new Map(); const entities = getEntities(); let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; for (const entity of entities) { let box: { xmin: number; ymin: number; xmax: number; ymax: number } | null = null; try { box = entity.getBoundingBox(); } catch { box = null; } if ( !box || !Number.isFinite(box.xmin) || !Number.isFinite(box.ymin) || !Number.isFinite(box.xmax) || !Number.isFinite(box.ymax) ) { unindexedEntities.push(entity); continue; } indexedEntities.push({ entity, minX: box.xmin, minY: box.ymin, maxX: box.xmax, maxY: box.ymax, }); if (box.xmin < minX) minX = box.xmin; if (box.ymin < minY) minY = box.ymin; if (box.xmax > maxX) maxX = box.xmax; if (box.ymax > maxY) maxY = box.ymax; } if (indexedEntities.length) { const extent = Math.max(maxX - minX, maxY - minY, 1e-9); cellSize = extent / GRID_CELLS_PER_AXIS; gridMinX = minX; gridMinY = minY; gridCols = GRID_CELLS_PER_AXIS + 2; indexedEntities.forEach((item, index) => { const [cx0, cx1] = cellRange(item.minX, item.maxX, gridMinX); const [cy0, cy1] = cellRange(item.minY, item.maxY, gridMinY); for (let cy = cy0; cy <= cy1; cy++) { for (let cx = cx0; cx <= cx1; cx++) { const key = cy * gridCols + cx; const bucket = cells.get(key); if (bucket) bucket.push(index); else cells.set(key, [index]); } } }); } indexVersion = version; } /** 뷰포트/사각 영역과 bbox가 겹치는 엔티티 (원본 배열 순서 유지). */ export function queryEntitiesInBox( minX: number, minY: number, maxX: number, maxY: number ): Entity[] { ensureIndex(); if (!indexedEntities.length) return [...unindexedEntities]; const seen = new Set(); const [cx0, cx1] = cellRange(minX, maxX, gridMinX); const [cy0, cy1] = cellRange(minY, maxY, gridMinY); for (let cy = cy0; cy <= cy1; cy++) { for (let cx = cx0; cx <= cx1; cx++) { const bucket = cells.get(cy * gridCols + cx); if (!bucket) continue; for (const index of bucket) seen.add(index); } } const result: Entity[] = []; let unindexedCursor = 0; for (let index = 0; index < indexedEntities.length; index++) { if (!seen.has(index)) continue; const item = indexedEntities[index]; if (item.maxX < minX || item.minX > maxX || item.maxY < minY || item.minY > maxY) continue; result.push(item.entity); } // bbox 불명 엔티티는 항상 포함 (뒤에 붙여도 소수라 시각 영향 없음) for (; unindexedCursor < unindexedEntities.length; unindexedCursor++) { result.push(unindexedEntities[unindexedCursor]); } return result; } /** 점 주변 반경 후보 엔티티 (스냅·호버용). */ export function queryEntitiesNearPoint(x: number, y: number, radius: number): Entity[] { return queryEntitiesInBox(x - radius, y - radius, x + radius, y + radius); }