fix(B05): 3D에 노선 모양 유령 커브 — 표고 없는 제어점이 지형 아래 평면에 깔리던 문제

계획노선 CSV로 만든 제어점에는 표고가 없다(z: null). modelToScene이 point.z - cz를
계산하는데 JS에서 null - 548.5 = -548.5이라, BP·EP·경유점 135개가 전부 지형 중심보다
548m 아래 수평면에 깔렸다. 화면에서는 노선 모양이 평면에 투영된 주황 구슬 커브로
보였고(경유점 색 0xf59e0b), 원근 때문에 지형 밖으로 밀려 나갔다.

- markerElevation() 신설: 표고가 있으면 그 값, 없으면 시·종점만 지형 표면을 찾아 얹는다.
  표고 없는 경유점은 그리지 않는다 — 그 자리는 경로선으로 이미 보이고, CSV 정점
  전부(여기선 133개)에 마커를 세우면 노선이 구슬에 덮인다.
- 뷰어에 terrainElevation() 추가: 지형 위에서 수직으로 광선을 쏴 표면 높이를 읽는다.
- renderRoute도 같은 함정을 막는다: z ?? 0 대신 지형 표고 → 직전 점 높이 순으로 채운다.
  경고 구간이 인덱스로 점 배열을 다시 자르므로 점 개수는 그대로 둔다.

typecheck·prettier 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 18:17:27 +09:00
co-authored by Claude Opus 5
parent 91f86c5629
commit 53ab941aef
2 changed files with 54 additions and 8 deletions
+40 -7
View File
@@ -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),
+14 -1
View File
@@ -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({