diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index d5e98392..e7b95c19 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -86,7 +86,12 @@ export function sceneToModel(point: THREE.Vector3, bounds: ModelBounds) { return { x: point.x + cx, y: -point.z + cy, z: point.y + cz }; } -export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBounds | null) { +export function createRouteMarkers( + scene: THREE.Scene, + getBounds: () => ModelBounds | null, + /** 모델 좌표(x, y) 자리의 지형 표고. 표고를 모르는 점을 지형에 얹을 때만 쓴다. */ + getTerrainZ?: (x: number, y: number) => number | null, +) { const interactionGroup = new THREE.Group(); const markerGroup = new THREE.Group(); const routeGroup = new THREE.Group(); @@ -115,11 +120,31 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou return allPoints().find((point) => point.id === selectedId) ?? null; } + /** + * 이 점을 3D 어느 높이에 놓을지. 놓을 수 없으면 `null`(= 그리지 않는다). + * + * 계획노선 CSV로 만든 제어점에는 표고가 없다(`z: null`). 그대로 넘기면 `null - cz`가 + * `-cz`로 계산돼 마커 전체가 지형 한참 아래 수평면에 깔린다 — 화면에는 노선 모양이 + * 평면에 투영된 유령 커브로 보인다(2026-08-08 사용자 보고). + * + * 시·종점은 사용자가 잡고 옮기는 앵커라 지형 표면을 찾아 얹는다. 표고 없는 경유점은 + * 그리지 않는다 — 그 자리는 경로선 자체로 이미 보이고, CSV 정점 전부(수백 개)에 + * 마커를 세우면 노선이 구슬에 덮인다. + */ + function markerElevation(point: PlacedRoutePoint): number | null { + if (Number.isFinite(point.z)) return point.z; + if (point.type !== "bp" && point.type !== "ep") return null; + return getTerrainZ?.(point.x, point.y) ?? null; + } + function renderMarkers(): void { disposeGroup(markerGroup); const bounds = getBounds(); if (!bounds) return; allPoints().forEach((point) => { + const elevation = markerElevation(point); + if (elevation === null) return; + const placed = { ...point, z: elevation }; const pointIndex = point.type === "bp" || point.type === "ep" ? 0 @@ -131,7 +156,7 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou }; const material = new THREE.MeshBasicMaterial({ color: COLORS[point.type] }); const marker = new THREE.Mesh(new THREE.SphereGeometry(1.6, 18, 12), material); - marker.position.copy(modelToScene(point, bounds)); + marker.position.copy(modelToScene(placed, bounds)); marker.position.y += 1.6; Object.assign(marker.userData, interactionData); if (point.id === selectedId) marker.scale.setScalar(1.35); @@ -146,7 +171,7 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou depthWrite: false, }), ); - zone.position.copy(modelToScene(point, bounds)); + zone.position.copy(modelToScene(placed, bounds)); zone.position.y += 0.2; Object.assign(zone.userData, interactionData); markerGroup.add(zone); @@ -220,11 +245,19 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou disposeGroup(routeGroup); const bounds = getBounds(); if (!bounds || polyline.length < 2) return; - const linePoints = polyline.map((point) => - modelToScene({ x: point.x, y: point.y, z: point.z ?? 0 }, bounds).add( + // 표고 없는 점을 0으로 삼키면 마커와 같은 함정에 빠진다(지형 한참 아래 평면에 눕는다). + // 지형 표면에서 찾아 채우고, 그래도 모르면 직전 점 높이를 이어 쓴다. 경고 구간이 + // 인덱스로 이 배열을 다시 자르므로 점 개수는 그대로 두어야 한다. + let lastZ: number | null = null; + const linePoints = polyline.map((point) => { + const resolved = Number.isFinite(point.z) + ? (point.z as number) + : (getTerrainZ?.(point.x, point.y) ?? lastZ); + if (resolved !== null) lastZ = resolved; + return modelToScene({ x: point.x, y: point.y, z: resolved ?? bounds.z[0] }, bounds).add( new THREE.Vector3(0, 0.35, 0), - ), - ); + ); + }); routeGroup.add( new THREE.Line( new THREE.BufferGeometry().setFromPoints(linePoints), diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index d02b2829..b259a1ba 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -7,6 +7,7 @@ import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { bindCursorPivotControls } from "../B04_PreProcess/B04_PreProcess_UI_Camera"; import { createRouteMarkers, + modelToScene, sceneToModel, type ModelBounds, type RouteMarkers, @@ -117,7 +118,19 @@ export function createRouteViewer(): RouteViewer { 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; - const markers = createRouteMarkers(scene, () => bounds); + /** + * 모델 좌표 (x, y) 자리의 지형 표고. 지형 위에서 수직으로 광선을 쏴 맞은 높이를 돌려준다. + * 계획노선 CSV로 만든 점처럼 표고가 없는 자리를 지형에 얹을 때 쓴다. + */ + function terrainElevation(x: number, y: number): number | null { + if (!terrain || !bounds) return null; + const origin = modelToScene({ x, y, z: bounds.z[1] + 100 }, bounds); + const raycaster = new THREE.Raycaster(origin, new THREE.Vector3(0, -1, 0)); + const hit = raycaster.intersectObject(terrain, true)[0]; + return hit ? sceneToModel(hit.point, bounds).z : null; + } + + const markers = createRouteMarkers(scene, () => bounds, terrainElevation); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. const releaseCursorPivot = bindCursorPivotControls({