횡단설계(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>
141 lines
4.0 KiB
TypeScript
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);
|
|
}
|