diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 9c67a27c..51cd5ea1 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -11,10 +11,10 @@ import { sceneToModel, type ModelBounds, type RouteMarkers, - type RoutePointKind, type SectionStationMarker, } from "./B05_Profile_UI_Markers"; import { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera"; +import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { createCorridorGroup, @@ -184,10 +184,6 @@ export function createRouteViewer(): RouteViewer { let bounds: ModelBounds | null = null; let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null = null; - let movingSelected = false; - let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null; - let draggingMarker = false; - let lastDragPoint: { x: number; y: number; z: number } | null = null; /** * 모델 좌표 (x, y) 자리의 지형 표고. 지형 위에서 수직으로 광선을 쏴 맞은 높이를 돌려준다. * 계획노선 CSV로 만든 점처럼 표고가 없는 자리를 지형에 얹을 때 쓴다. @@ -218,6 +214,15 @@ export function createRouteViewer(): RouteViewer { }, }; const markers = createRouteMarkers(scene, () => bounds, terrainElevation); + // 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한). + const markerInput = bindMarkerPointerControls({ + canvas, + camera, + controls, + markers, + getTerrain: () => terrain, + getBounds: () => bounds, + }); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. const releaseCursorPivot = bindCursorPivotControls({ @@ -225,7 +230,7 @@ export function createRouteViewer(): RouteViewer { controls, element: canvas, pickables: () => (terrain ? [terrain] : []), - blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + blocked: () => markerInput.blocked(), scene, }); @@ -310,132 +315,6 @@ export function createRouteViewer(): RouteViewer { } /** 화면(client) 좌표 아래 지형의 모델 좌표 — 우클릭 구조물 배치 등 외부 픽에도 쓴다. */ - function terrainPointAt( - clientX: number, - clientY: number, - ): { x: number; y: number; z: number } | null { - if (!terrain || !bounds) return null; - const rect = canvas.getBoundingClientRect(); - const pointer = new THREE.Vector2( - ((clientX - rect.left) / rect.width) * 2 - 1, - -((clientY - rect.top) / rect.height) * 2 + 1, - ); - const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(pointer, camera); - const hit = raycaster.intersectObject(terrain, true)[0]; - return hit ? sceneToModel(hit.point, bounds) : null; - } - - function terrainPoint( - event: PointerEvent | DragEvent, - ): { x: number; y: number; z: number } | null { - return terrainPointAt(event.clientX, event.clientY); - } - - canvas.addEventListener("dragover", (event) => event.preventDefault()); - canvas.addEventListener("drop", (event) => { - event.preventDefault(); - const kind = event.dataTransfer?.getData("pointType") as RoutePointKind; - const point = terrainPoint(event); - if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point); - }); - function markerHit(event: PointerEvent): THREE.Object3D | undefined { - const rect = canvas.getBoundingClientRect(); - const pointer = new THREE.Vector2( - ((event.clientX - rect.left) / rect.width) * 2 - 1, - -((event.clientY - rect.top) / rect.height) * 2 + 1, - ); - const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(pointer, camera); - return raycaster.intersectObject(markers.group, true)[0]?.object; - } - - function finishMarkerInteraction(selectCandidate: boolean): void { - if (dragCandidate && (draggingMarker || selectCandidate)) { - markers.selectPoint(dragCandidate.id); - } - if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) { - canvas.releasePointerCapture(dragCandidate.pointerId); - } - controls.enabled = true; - dragCandidate = null; - draggingMarker = false; - lastDragPoint = null; - } - - function handlePointerDown(event: PointerEvent): void { - if (event.button !== 0) return; - const hit = markerHit(event); - const pointId = markers.pointIdForObject(hit); - if (pointId) { - dragCandidate = { - id: pointId, - pointerId: event.pointerId, - x: event.clientX, - y: event.clientY, - }; - draggingMarker = false; - lastDragPoint = null; - canvas.setPointerCapture(event.pointerId); - event.preventDefault(); - event.stopPropagation(); - return; - } - if (hit) { - markers.selectObject(hit); - event.stopPropagation(); - return; - } - if (movingSelected) { - const point = terrainPoint(event); - if (point) markers.moveSelected(point); - movingSelected = false; - } else { - markers.selectObject(undefined); - } - } - - function handlePointerMove(event: PointerEvent): void { - if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; - event.preventDefault(); - event.stopPropagation(); - if ( - !draggingMarker && - Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3 - ) { - draggingMarker = true; - controls.enabled = false; - } - if (!draggingMarker) return; - const point = terrainPoint(event); - if (!point) return; - lastDragPoint = point; - markers.movePoint(dragCandidate.id, point); - } - - function handlePointerUp(event: PointerEvent): void { - if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; - event.preventDefault(); - event.stopPropagation(); - if (draggingMarker) { - const point = terrainPoint(event) ?? lastDragPoint; - if (point) markers.movePoint(dragCandidate.id, point); - } - finishMarkerInteraction(!draggingMarker); - } - - function handlePointerExit(event: PointerEvent): void { - if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; - event.preventDefault(); - event.stopPropagation(); - finishMarkerInteraction(false); - } - - canvas.addEventListener("pointerdown", handlePointerDown, true); - canvas.addEventListener("pointermove", handlePointerMove, true); - canvas.addEventListener("pointerup", handlePointerUp, true); - canvas.addEventListener("pointerleave", handlePointerExit, true); - canvas.addEventListener("pointercancel", handlePointerExit, true); let frame = 0; function animate(): void { @@ -774,20 +653,16 @@ export function createRouteViewer(): RouteViewer { renderStationLines: markers.renderStationLines, setView: fit, beginMoveSelected() { - movingSelected = true; + markerInput.beginMoveSelected(); status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; }, - modelPointAt: terrainPointAt, + modelPointAt: markerInput.terrainPointAt, dispose() { + markerInput.dispose(); cancelAnimationFrame(frame); resizeObserver.disconnect(); themeObserver.disconnect(); systemDarkTheme.removeEventListener("change", updateSceneBackground); - canvas.removeEventListener("pointerdown", handlePointerDown, true); - canvas.removeEventListener("pointermove", handlePointerMove, true); - canvas.removeEventListener("pointerup", handlePointerUp, true); - canvas.removeEventListener("pointerleave", handlePointerExit, true); - canvas.removeEventListener("pointercancel", handlePointerExit, true); releaseCursorPivot(); markers.dispose(); clearContours(); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts new file mode 100644 index 00000000..6ff036bb --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -0,0 +1,196 @@ +/* ============================================================================= + * B05_Profile_UI_Viewer_Marker_Input.ts + * 3D 뷰어의 **포인터 입력** — 지형 위 좌표 얻기, 마커 끌기·고르기, 끌어놓기(drop). + * + * `B05_Profile_UI_Viewer` 에서 떼어낸 몫이다(700줄 제한, 2026-09-02). 카메라·씬 조립과 + * 코리도 조립은 뷰어에 남고, 여기에는 캔버스 이벤트로 마커를 다루는 흐름만 둔다. + * 회전 중심 유틸(`bindCursorPivotControls`)이 "지금 마커를 잡고 있나"를 묻는 창구도 + * 여기서 낸다(`blocked`). + * ========================================================================== */ + +import * as THREE from "three"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { + sceneToModel, + type ModelBounds, + type RouteMarkers, + type RoutePointKind, +} from "./B05_Profile_UI_Markers"; + +export interface MarkerPointerControls { + /** 화면 좌표 아래 지형 지점(모델 좌표). 지형·bounds가 없으면 null. */ + terrainPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; + /** 다음 클릭으로 선택 포인트를 옮기는 모드로 들어간다. */ + beginMoveSelected: () => void; + /** 마커를 잡고 있거나 이동 대기 중인가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */ + blocked: () => boolean; + /** 등록한 캔버스 이벤트를 모두 떼어낸다. */ + dispose: () => void; +} + +export function bindMarkerPointerControls(options: { + canvas: HTMLCanvasElement; + camera: THREE.Camera; + controls: OrbitControls; + markers: RouteMarkers; + getTerrain: () => THREE.Object3D | null; + getBounds: () => ModelBounds | null; +}): MarkerPointerControls { + const { canvas, camera, controls, markers, getTerrain, getBounds } = options; + + let movingSelected = false; + let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null; + let draggingMarker = false; + let lastDragPoint: { x: number; y: number; z: number } | null = null; + + /** 화면 좌표 → 정규화 장치 좌표(-1~1). */ + function pointerOf(clientX: number, clientY: number): THREE.Vector2 { + const rect = canvas.getBoundingClientRect(); + return new THREE.Vector2( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ); + } + + function terrainPointAt( + clientX: number, + clientY: number, + ): { x: number; y: number; z: number } | null { + const terrain = getTerrain(); + const bounds = getBounds(); + if (!terrain || !bounds) return null; + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(pointerOf(clientX, clientY), camera); + const hit = raycaster.intersectObject(terrain, true)[0]; + return hit ? sceneToModel(hit.point, bounds) : null; + } + + function terrainPoint( + event: PointerEvent | DragEvent, + ): { x: number; y: number; z: number } | null { + return terrainPointAt(event.clientX, event.clientY); + } + + function handleDragOver(event: DragEvent): void { + event.preventDefault(); + } + + function handleDrop(event: DragEvent): void { + event.preventDefault(); + const kind = event.dataTransfer?.getData("pointType") as RoutePointKind; + const point = terrainPoint(event); + if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point); + } + + function markerHit(event: PointerEvent): THREE.Object3D | undefined { + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera); + return raycaster.intersectObject(markers.group, true)[0]?.object; + } + + function finishMarkerInteraction(selectCandidate: boolean): void { + if (dragCandidate && (draggingMarker || selectCandidate)) { + markers.selectPoint(dragCandidate.id); + } + if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) { + canvas.releasePointerCapture(dragCandidate.pointerId); + } + controls.enabled = true; + dragCandidate = null; + draggingMarker = false; + lastDragPoint = null; + } + + function handlePointerDown(event: PointerEvent): void { + if (event.button !== 0) return; + const hit = markerHit(event); + const pointId = markers.pointIdForObject(hit); + if (pointId) { + dragCandidate = { + id: pointId, + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + draggingMarker = false; + lastDragPoint = null; + canvas.setPointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + return; + } + if (hit) { + markers.selectObject(hit); + event.stopPropagation(); + return; + } + if (movingSelected) { + const point = terrainPoint(event); + if (point) markers.moveSelected(point); + movingSelected = false; + } else { + markers.selectObject(undefined); + } + } + + function handlePointerMove(event: PointerEvent): void { + if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + if ( + !draggingMarker && + Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3 + ) { + draggingMarker = true; + controls.enabled = false; + } + if (!draggingMarker) return; + const point = terrainPoint(event); + if (!point) return; + lastDragPoint = point; + markers.movePoint(dragCandidate.id, point); + } + + function handlePointerUp(event: PointerEvent): void { + if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + if (draggingMarker) { + const point = terrainPoint(event) ?? lastDragPoint; + if (point) markers.movePoint(dragCandidate.id, point); + } + finishMarkerInteraction(!draggingMarker); + } + + function handlePointerExit(event: PointerEvent): void { + if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + finishMarkerInteraction(false); + } + + canvas.addEventListener("dragover", handleDragOver); + canvas.addEventListener("drop", handleDrop); + canvas.addEventListener("pointerdown", handlePointerDown, true); + canvas.addEventListener("pointermove", handlePointerMove, true); + canvas.addEventListener("pointerup", handlePointerUp, true); + canvas.addEventListener("pointerleave", handlePointerExit, true); + canvas.addEventListener("pointercancel", handlePointerExit, true); + + return { + terrainPointAt, + beginMoveSelected: () => { + movingSelected = true; + }, + blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + dispose: () => { + canvas.removeEventListener("dragover", handleDragOver); + canvas.removeEventListener("drop", handleDrop); + canvas.removeEventListener("pointerdown", handlePointerDown, true); + canvas.removeEventListener("pointermove", handlePointerMove, true); + canvas.removeEventListener("pointerup", handlePointerUp, true); + canvas.removeEventListener("pointerleave", handlePointerExit, true); + canvas.removeEventListener("pointercancel", handlePointerExit, true); + }, + }; +}