Files
Aislo/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts
T
eomsangdonandClaude Fable 5 6e195afb69 feat(B07,B08): 워크플로 순서 교환 — 상세설계를 수량산출 앞으로
횡단설계(B06) 다음을 상세설계 → 수량산출 → 설계도서 순으로 재배열하고,
폴더 번호가 흐름과 일치하도록 이름을 맞바꾼다.

- B08_DesignDetail → B07_DesignDetail, B07_Quantity → B08_Quantity
  (파일 접두어·식별자·라우트·locale 키 전량 스왑)
- STAGE_KEYS 4=DESIGN_DETAIL, 5=QUANTITY 스왑 + 라우터 stage 리터럴 교체
- CAD 마운트 /b08-cad → /b07-cad (main.py·vite proxy·iframe URL),
  openwebcad Toolbar 라벨 B07로 수정 후 재빌드
- 유지: openwebcad postMessage 프로토콜 aislo:b08:*·패키지명(내부 식별자)
- 기존 프로젝트 storage 폴더 rename + project_manifest 갱신,
  DB project_workflow_stages stage_no 4↔5 행 스왑 완료

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 16:16:46 +09:00

141 lines
4.0 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;
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<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);
}