- 바깥 선택이 폼에 실릴 때 접힌 좌측 패널 자동 펼침(onReveal 창구) - 계곡 통과 시설 목록 행도 보이는 자리로 스크롤(rAF) - 종단 테이블 구조물 구간 판정을 허용오차 비교로 교체 + 병합 측점 포함(강조 0건→6건) - 구조물 선택 세션을 소비형에서 유지형으로 — B06 카드·목록·부재 선택도 같은 칸에 기록, 양쪽 진입 시 복원(목록 지연 로드 대비 이중 복원) - 조정창 닫기가 세션 선택까지 지우던 결함 수정(부재 해제와 측점 해제 분리) - B05 페이지 700줄 유지 위해 상단측 세션 보관을 Page_Helpers로 이동(동작 불변) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
172 lines
7.3 KiB
TypeScript
172 lines
7.3 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;
|
|
}
|