/* ============================================================================= * 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; 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; }