Files
Aislo/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts
T
eomsangdonandClaude Opus 5.5 66e84b56bf fix(cad): 줌아웃 느려짐 — 공간 색인 칸 범위 자름 · 모눈을 장면 비트맵에서 뺌(227-1)
- 까닭: 화면 상자 조회가 엔티티 칸(64x64) 밖 빈 칸까지 돎 — 0.01배에서 한 화면 19.3 초
- 칸 범위를 0~64 로 잘라 0.01배 19,348 → 16 ms · 0.1배 175 → 15 ms
- 모눈은 화면 clear() 만 그림 — 모눈 켜도 정지 화면에서 장면 다시 그리지 않음
- 측정 · 회귀 시험 zoom-out-perf.test.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 16:33:00 +09:00

145 lines
4.2 KiB
TypeScript

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<number, number[]> = new Map();
let cellSize = 1;
let gridMinX = 0;
let gridMinY = 0;
let gridCols = 1;
/** 칸 번호 범위 — 엔티티가 놓인 칸(0~GRID_CELLS_PER_AXIS) 밖은 비었으니 잘라 냄(줌아웃 때 빈 칸 수억 개를 돌지 않게). */
function cellRange(min: number, max: number, gridMin: number): [number, number] {
return [
Math.max(0, Math.floor((min - gridMin) / cellSize)),
Math.min(GRID_CELLS_PER_AXIS, 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<number>();
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);
}