Files
Aislo/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts
T
eomsangdonandClaude Opus 5.5 cb4bab7bf2 feat(구조물표): 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(pickLights · 카드 형상 · 3D 같은 함수) · 그린 것마다 신원표(data-structure-id + data-part) · 같은 자리 거리 = PIPE_MATCH_M 하나 · 322-0 이 깬 옛 시험 도움 파일 · 줄 수 고침
- `design_pipe_ids.ts` — 장부(세션 pipe-ids) · pipe-s{차례} · 자리 → id(찾는 거리 안 가장 가까운 것) · followMovedPipe 가 id 를 새 자리로(고름 id 그대로 · 순환 import 없게 design_selection 에서 옮김) · 설계 페이지가 프로젝트를 정함(usePipeIds) · 자리 이름 만들기 열 곳 · 되읽기 둘 걷음 · 측점 계획 주인 = 세션 id(withPipeIds)
- `design_pick_match.ts` — pickLights(① id ② 같은 자리 ③ 고른 부분) · paintPicked(카드 통째 형상 · 부분 · 관 계열 · 포장층은 제 손잡이) · tagShape — 관 세트 벽 · 집수정 · 관 · 세월교 측벽 · 몸체 · BOX 끝 · C군 벽 · 물넘이 콘크리트에 신원표 · 형상 판정면은 통째 형상 · 콘크리트만
- 3D — 부재 맞춤 = pickLights(옛 0.01 걷음) · C군 벽 솔리드 = 구조물 id(덮는 측점 어디서 골라도 한 번 스윕한 벽이 켜짐)
- 322-0 이 깬 것 고침 — 노면공 흐름 도움 파일(165 · 231-1) 정의 판 확인 흉내 · design_view_profile 700줄(322-0 이 704)
- 시험: test_323_5_pick(세션 id · 옮김 · 강조 규칙 · 칠하기) · 옛 시험 도움 파일 흉내 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:06:13 +09:00

205 lines
8.9 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Viewer_Structure_Pick.ts
* 3D 코리도 **구조물 개별 선택**(2026-09-04 사용자 지시) — 부재 하나를 클릭으로 골라
* 밝히고, 고른 신원표를 바깥으로 넘긴다. 정보상자는 만들지 않는다(좌측 「구조물 배치」
* 패널이 그 일을 한다).
*
* 뷰어·마커 입력에 얹지 않고 새 모듈로 뒀다(700줄 제한). 규칙 두 가지 —
* · **마커보다 뒤 순위**: 마커를 잡고 있거나 이동 대기 중이면 선택을 건너뛴다.
* · **누른 자리에서 3px 안에서 뗐을 때만** 선택으로 본다(카메라 회전과 구분).
* ========================================================================== */
import * as THREE from "three";
import { pickLights } from "../A00_Common/design/design_pick_match";
import { isNear } from "../A00_Common/design/design_selection";
/** 3D에서 고른 부재의 신원표 — 메쉬 `userData`에 실려 있는 그 값이다. */
export interface StructurePick {
chainageM: number;
kind: string;
/** 부재키(`inlet`·`outlet`·`extra{i}`·`bextra{i}`·`own`·`basin`·`pipe`). 없을 수 있다. */
key?: string;
/** 구조물 id(323-5 · C군 벽) — 있으면 id 로 맞춤(고름 강조 한 규칙 `pickLights`). */
structureId?: string | null;
}
export interface StructurePickControls {
/** 3D에서 골랐을 때 알린다. null = 같은 부재 다시 누름으로 해제(빈 곳 누름은 안 알림 · 232-1). */
onPick?: (pick: StructurePick | null) => void;
/** 좌측 패널·종단 그래프에서 고른 것을 3D 강조에 반영한다(부재키 없이 측점만). */
selectAtChainage: (chainageM: number | null) => void;
/** 부재키까지 지정해 강조를 세운다 — 세션에 남은 선택을 되살릴 때 쓴다. */
select: (pick: StructurePick | null) => void;
/** 코리도를 다시 만든 뒤 강조를 되살린다 — 메쉬가 통째로 새 것이라 다시 칠해야 한다. */
reapply: () => void;
/** 창(window)에 건 듣기를 잇고 뗀다 — 뷰어가 숨으면 뗀다(계획서 24-3a). */
setListening: (on: boolean) => 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;
/** 이 누름이 마커 · 측점 띠에 맞았나 — 참이면 그 누름은 마커 몫(빈 곳 누름 아님 · 232-1). */
pressedMarker?: () => boolean;
}): StructurePickControls {
const { canvas, camera, group, blocked, pressedMarker } = 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,
structureId: data.structureId,
};
}
node = node.parent;
}
return null;
}
/** 이 부재가 지금 고른 것인가 — 고름 강조 한 규칙(카드 형상과 같음 · 부재키가 없는 선택 = 그 자리 전부). */
const asSelection = (pick: StructurePick) => ({
chainageM: pick.chainageM,
structureId: pick.structureId ?? null,
partKey: pick.key,
});
const asTag = (pick: StructurePick) => ({
chainageM: pick.chainageM,
structureId: pick.structureId ?? null,
part: pick.key,
});
function matches(tag: StructurePick): boolean {
return !!selected && pickLights(asSelection(selected), asTag(tag));
}
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() && !pressedMarker?.()
? { 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;
const pick = pickAt(event.clientX, event.clientY);
// 빈 곳(지형 · 리본) 누름은 고름을 안 풂(08-05 사용자 결정 · 232-1) — 같은 부재 다시 누름만 풂
if (!pick) return;
const same =
selected !== null &&
selected.key === pick.key &&
pickLights(asSelection(selected), asTag(pick));
selected = same ? null : pick;
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 && isNear(selected.chainageM, chainageM)) return;
selected = chainageM === null ? null : { chainageM, kind: "" };
applyHighlight();
},
select(pick) {
selected = pick;
applyHighlight();
},
reapply: applyHighlight,
setListening(on) {
window.removeEventListener("pointerdown", handleWindowDown, true);
if (on) window.addEventListener("pointerdown", handleWindowDown, true);
},
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;
}