- 구조물 솔리드에 부재키(key) 부여 — B06과 같은 이름(inlet·outlet·extra{i}·bextra{i}·own·basin·pipe)
- 구조물 메쉬에 신원표(userData) — 이름 규칙은 그대로 유지
- 신규 모듈 두 개 — 3D 클릭·강조(Viewer_Structure_Pick), 좌측 패널 연결·B06 넘김(Structure_Pick_Session)
- 좌측 패널·종단에서 고른 것도 3D가 따라오도록 선택 동기화에 포트 추가
- B06 진입 시 세션 넘김값으로 그 측점 카드 선택·스크롤 + 기슭막이 조정창 열기
- 저장 코리도 만료(BUILD_VERSION 90→91) — 옛 저장본엔 부재키가 없음
- 뷰어 700줄 여유 — 검증용 요약을 Viewer_Debug로 분리(동작 불변)
- 검증 수단 — __corridorScene에 camera·project 추가
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
166 lines
7.1 KiB
TypeScript
166 lines
7.1 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;
|
|
/** 코리도를 다시 만든 뒤 강조를 되살린다 — 메쉬가 통째로 새 것이라 다시 칠해야 한다. */
|
|
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();
|
|
},
|
|
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;
|
|
}
|