Files
Aislo/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts
T
eomsangdonandClaude Opus 5 5203191751 feat(B05): 진입 로딩 3D 후순위 · 도넛 회전 분리 · 직교/원근 전환 단추
사용자 지시(2026-09-04) — 「3D 는 보조라 후순위. 조용히 뒤에서 뜨고 다 되면
팝업으로 알릴 것. 로딩 도넛은 진행률과 무관하게 계속 돌고 숫자만 갱신」,
그리고 「직교/원근 전환 단추는 넣음」.

진입 로딩
- 단계 5 → 4. ④ 종·횡단 자료까지 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 함.
- 3D 지형은 finally 뒤 배경 작업으로 돌리고 끝나면 토스트 「3D 지형 준비 완료」.
  실패해도 나머지 화면은 그대로 씀. [초기화]는 재진입이라 같은 규칙이 걸림.
- 「확정 지표면 없음」 판정을 ③ 뒤로 앞당김.

진행 서클(공용)
- 회전 껍데기(`__spin`)를 한 겹 두고 CSS 회전을 거기에만 검. 껍데기는 늘 돌고
  안쪽 svg 는 12시 고정이라 호·숫자가 제자리에서 갱신됨. `is-indeterminate` 제거.

직교/원근 전환
- 카메라 두 벌을 `B05_Profile_UI_Viewer_Camera.ts` 가 쥐고 갈아 끼움. 위치·시선·
  근평면·먼평면과 보이는 크기를 옮겨 화면이 튀지 않음.
- 마커 입력·구조물 클릭·커서 피벗 유틸이 카메라를 함수로 받게 바꿈(공용 유틸은
  값도 그대로 받아 B04 호출부는 무변경).

자체검증(공용 브라우저 5173, 용화_LAS) — 진행률 25→50→75%, 회전 애니메이션
running 유지(변환행렬 표본 전부 다름), 로딩 걷힌 순간 그래프선 2·좌측단추 61·
3D 메쉬 2, 1.8초 뒤 메쉬 489 + 토스트. 초기화 재진입도 같음. 전환 단추는
Ortho ↔ Perspective 왕복 후 값 완전 일치, B04 페이지 오류 0건.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:13:46 +09:00

173 lines
7.5 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Viewer_Structure_Pick.ts
* 3D 코리도 **구조물 개별 선택**(2026-09-04 사용자 지시) — 부재 하나를 클릭으로 골라
* 밝히고, 고른 신원표를 바깥으로 넘긴다. 정보상자는 만들지 않는다(좌측 「구조물 배치」
* 패널이 그 일을 한다).
*
* 뷰어·마커 입력에 얹지 않고 새 모듈로 뒀다(700줄 제한). 규칙 두 가지 —
* · **마커보다 뒤 순위**: 마커를 잡고 있거나 이동 대기 중이면 선택을 건너뛴다.
* · **누른 자리에서 3px 안에서 뗐을 때만** 선택으로 본다(카메라 회전과 구분).
* ========================================================================== */
import * as THREE from "three";
/** 3D에서 고른 부재의 신원표 — 메쉬 `userData`에 실려 있는 그 값이다. */
export interface StructurePick {
chainageM: number;
kind: string;
/** 부재키(`inlet`·`outlet`·`extra{i}`·`bextra{i}`·`own`·`basin`·`pipe`). 없을 수 있다. */
key?: string;
}
export interface StructurePickControls {
/** 3D에서 골랐을 때 알린다. null = 빈 곳(지형·리본) 클릭으로 해제. */
onPick?: (pick: StructurePick | null) => void;
/** 좌측 패널·종단 그래프에서 고른 것을 3D 강조에 반영한다(부재키 없이 측점만). */
selectAtChainage: (chainageM: number | null) => void;
/** 부재키까지 지정해 강조를 세운다 — 세션에 남은 선택을 되살릴 때 쓴다. */
select: (pick: StructurePick | null) => void;
/** 코리도를 다시 만든 뒤 강조를 되살린다 — 메쉬가 통째로 새 것이라 다시 칠해야 한다. */
reapply: () => void;
dispose: () => void;
}
/** 고른 부재를 밝히는 자체발광색 — 원래 색은 그대로 두고 밝기만 얹는다. */
const HIGHLIGHT = 0x2f5f9f;
/** 클릭으로 볼 이동 허용치(px) — 마커 끌기 판정과 같은 값. */
const CLICK_SLOP_PX = 3;
export function bindStructurePick(options: {
canvas: HTMLCanvasElement;
/** 카메라 조회 — 뷰어가 원근/직교를 갈아 끼우므로 값이 아니라 함수로 받는다. */
camera: () => THREE.Camera;
/** 코리도 그룹 — 없거나 꺼져 있으면 고르지 않는다. */
group: () => THREE.Object3D | null;
/** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */
blocked: () => boolean;
}): StructurePickControls {
const { canvas, camera, group, blocked } = options;
let selected: StructurePick | null = null;
let down: { x: number; y: number; pointerId: number } | null = null;
// 마커 "이동 대기" 모드는 마커 쪽 pointerdown이 그 자리에서 꺼 버린다 — 그전(창 캡처
// 단계)에 한 번 물어 둔다. 마커를 집었는지는 그 뒤 캔버스 단계에서 본다.
let blockedBeforeDown = false;
/** 메쉬(또는 그 부모)에 달린 신원표. 없으면 구조물이 아니다. */
function tagOf(object: THREE.Object3D | null): StructurePick | null {
let node = object;
while (node) {
const data = node.userData as Partial<StructurePick>;
if (typeof data.chainageM === "number") {
return { chainageM: data.chainageM, kind: String(data.kind ?? ""), key: data.key };
}
node = node.parent;
}
return null;
}
/** 이 부재가 지금 고른 것인가 — 부재키가 없는 선택(측점만)은 그 측점 전부를 켠다. */
function matches(tag: StructurePick): boolean {
if (!selected) return false;
if (Math.abs(tag.chainageM - selected.chainageM) >= 0.01) return false;
return selected.key === undefined || tag.key === selected.key;
}
function applyHighlight(): void {
const root = group();
if (!root) return;
root.traverse((object) => {
const mesh = object as THREE.Mesh;
if (!mesh.isMesh) return;
const tag = tagOf(mesh);
if (!tag) return;
const on = matches(tag);
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) {
const lit = material as THREE.MeshLambertMaterial;
// 자체발광이 없는 재질(라인 등)은 건너뛴다.
if (lit.emissive) lit.emissive.setHex(on ? HIGHLIGHT : 0x000000);
}
});
}
/** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=해제)이다. */
function pickAt(clientX: number, clientY: number): StructurePick | null {
const root = group();
if (!root || !root.visible) return null;
const rect = canvas.getBoundingClientRect();
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(
new THREE.Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
),
camera(),
);
// 모서리 선(LineSegments)은 뺀다 — 라인 레이캐스트 허용반경이 1m라 클릭을 가로챈다.
const hit = raycaster
.intersectObject(root, true)
.find((entry) => (entry.object as THREE.Mesh).isMesh);
return hit ? tagOf(hit.object) : null;
}
function handleWindowDown(): void {
blockedBeforeDown = blocked();
}
function handleDown(event: PointerEvent): void {
down =
event.button === 0 && !blockedBeforeDown && !blocked()
? { x: event.clientX, y: event.clientY, pointerId: event.pointerId }
: null;
}
function handleUp(event: PointerEvent): void {
const start = down;
down = null;
if (!start || event.pointerId !== start.pointerId) return;
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return;
if (blocked()) return;
selected = pickAt(event.clientX, event.clientY);
applyHighlight();
controls.onPick?.(selected);
}
function handleExit(): void {
down = null;
}
// **캡처 단계**로 단다(2026-09-04 실측) — 캔버스에 이미 붙은 캡처 리스너가 pointerdown
// 전파를 멈춰, 같은 캔버스의 버블 리스너는 아예 불리지 않는다(마커 입력·회전 중심
// 유틸과 같은 자리). 마커 입력보다 **나중에** 달아 순서상 뒤에 선다.
window.addEventListener("pointerdown", handleWindowDown, true);
canvas.addEventListener("pointerdown", handleDown, true);
canvas.addEventListener("pointerup", handleUp, true);
canvas.addEventListener("pointerleave", handleExit, true);
canvas.addEventListener("pointercancel", handleExit, true);
const controls: StructurePickControls = {
selectAtChainage(chainageM) {
// 3D에서 부재를 집어 이미 그 측점을 고른 상태면 그대로 둔다 — 되돌아온 동기화가
// 부재 하나짜리 강조를 그 측점 전체로 넓히면 안 된다(2026-09-04).
if (chainageM !== null && selected && Math.abs(selected.chainageM - chainageM) < 0.01) return;
selected = chainageM === null ? null : { chainageM, kind: "" };
applyHighlight();
},
select(pick) {
selected = pick;
applyHighlight();
},
reapply: applyHighlight,
dispose() {
window.removeEventListener("pointerdown", handleWindowDown, true);
canvas.removeEventListener("pointerdown", handleDown, true);
canvas.removeEventListener("pointerup", handleUp, true);
canvas.removeEventListener("pointerleave", handleExit, true);
canvas.removeEventListener("pointercancel", handleExit, true);
},
};
return controls;
}