import * as THREE from "three"; import { inferStationInterval, stationLabel } from "@util/common_util_svg"; export type RoutePointKind = "bp" | "ep" | "cp" | "ap" | "fp"; export interface PlacedRoutePoint { id: string; type: RoutePointKind; x: number; y: number; /** 표고(m). `null`은 **모른다**는 뜻 — 계획노선 CSV로 만든 점이 그렇다. 0으로 눕히면 안 된다. */ z: number | null; radius_m?: number; } export interface RouteDesignPoints { bp: PlacedRoutePoint | null; ep: PlacedRoutePoint | null; cp: PlacedRoutePoint[]; ap: PlacedRoutePoint[]; fp: PlacedRoutePoint[]; } export interface ModelBounds { x: [number, number]; y: [number, number]; z: [number, number]; } export interface SectionStationMarker { station_id: string; center_x: number; center_y: number; center_z: number | null; /** 상단측(등고 높은 쪽) — 램프 컬러 표시용. Page가 사용자 변경분을 반영해 넘긴다. */ uphill_side?: "left" | "right" | null; frame: { left_xy: [number, number] }; /** 측점 종류(`bp`/`ep`/`regular`/`irregular`) — 어느 측점에 라벨을 달지 가르는 기준. */ kind?: string; /** 시점부터의 누가거리(m). 측점번호·라벨 표기를 여기서 되짚는다. */ chainage_m?: number; /** 구조물 이름(배관 등). 있으면 측점번호 뒤에 붙인다. */ structure?: string; } /** * 규칙 측점 라벨 솎기 — 라벨은 **전 측점에 만들어 두고** 카메라 거리로 골라 보인다 * (2026-09-04 사용자 지시 「전체 라벨이 있으면 좋겠음」). 멀면 글자가 겹치므로 * 5칸 → 2칸 → 전부로 단계를 올린다. 경계는 카메라~시점거리(m). */ const LABEL_LOD: ReadonlyArray<{ within: number; step: number }> = [ { within: 150, step: 1 }, { within: 400, step: 2 }, { within: Infinity, step: 5 }, ]; // 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색. const UPHILL_LAMP_COLOR = 0xf97316; const UPHILL_LAMP_INACTIVE_COLOR = 0x9ca3af; const COLORS: Record = { bp: 0x10b981, ep: 0xef4444, cp: 0xf59e0b, ap: 0x64748b, fp: 0xdc2626, }; function emptyPoints(): RouteDesignPoints { return { bp: null, ep: null, cp: [], ap: [], fp: [] }; } function disposeGroup(group: THREE.Group): void { group.traverse((object) => { if (object instanceof THREE.Mesh || object instanceof THREE.Line) { object.geometry.dispose(); const materials = Array.isArray(object.material) ? object.material : [object.material]; materials.forEach((material) => material.dispose()); } }); group.clear(); } export function modelToScene(point: { x: number; y: number; z: number }, bounds: ModelBounds) { const cx = (bounds.x[0] + bounds.x[1]) / 2; const cy = (bounds.y[0] + bounds.y[1]) / 2; const cz = (bounds.z[0] + bounds.z[1]) / 2; return new THREE.Vector3(point.x - cx, point.z - cz, -(point.y - cy)); } export function sceneToModel(point: THREE.Vector3, bounds: ModelBounds) { const cx = (bounds.x[0] + bounds.x[1]) / 2; const cy = (bounds.y[0] + bounds.y[1]) / 2; const cz = (bounds.z[0] + bounds.z[1]) / 2; return { x: point.x + cx, y: -point.z + cy, z: point.y + cz }; } /** 노선 선을 지형에 붙일 때 정점 사이를 나누는 간격(m) — 2m 면 20m 측점 사이가 10토막이라 * 능선을 가로질러도 선이 지면 아래로 잠기지 않는다(2026-09-06). */ const DRAPE_STEP_M = 2; /** 한 구간을 나누는 최대 토막 수 — 정점이 아주 멀리 떨어진 자료에서 점이 폭주하지 않게 한다. */ const MAX_DRAPE_STEPS = 64; /** 노선 선을 지형 위로 띄우는 높이(m) — 지형과 겹쳐 깜빡이지 않을 만큼만. */ const ROUTE_LINE_LIFT_M = 0.35; 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(); const stationGroup = new THREE.Group(); // 구조물 측점 라벨(스프라이트) 전용 — 측점선과 별개 토글이라 그룹을 나눈다. const stationLabelGroup = new THREE.Group(); // 기본 켜짐 — 측점 위치를 3D에서 바로 읽는 것이 기본 동작이다(2026-08-04 사용자 지시). stationLabelGroup.visible = true; interactionGroup.add(markerGroup, stationGroup, stationLabelGroup); scene.add(interactionGroup, routeGroup); let points = emptyPoints(); let selectedId: string | null = null; let changeListener: ((points: RouteDesignPoints) => void) | undefined; let selectionListener: ((point: PlacedRoutePoint | null) => void) | undefined; let stationSelectionListener: ((stationId: string | null) => void) | undefined; let uphillPickListener: ((stationId: string, side: "left" | "right") => void) | undefined; let selectedStationId: string | null = null; function allPoints(): PlacedRoutePoint[] { return [points.bp, points.ep, ...points.cp, ...points.ap, ...points.fp].filter( (point): point is PlacedRoutePoint => Boolean(point), ); } function selected(): PlacedRoutePoint | null { 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 : points[point.type].findIndex((candidate) => candidate.id === point.id); const interactionData = { routePointId: point.id, routePointKind: point.type, routePointIndex: pointIndex, }; 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(placed, bounds)); marker.position.y += 1.6; Object.assign(marker.userData, interactionData); if (point.id === selectedId) marker.scale.setScalar(1.35); markerGroup.add(marker); if ((point.type === "ap" || point.type === "fp") && point.radius_m) { const zone = new THREE.Mesh( new THREE.CylinderGeometry(point.radius_m, point.radius_m, 0.35, 40), new THREE.MeshBasicMaterial({ color: COLORS[point.type], transparent: true, opacity: 0.22, depthWrite: false, }), ); zone.position.copy(modelToScene(placed, bounds)); zone.position.y += 0.2; Object.assign(zone.userData, interactionData); markerGroup.add(zone); } }); } function notify(): void { renderMarkers(); changeListener?.(points); selectionListener?.(selected()); } function place(type: RoutePointKind, model: { x: number; y: number; z: number }): void { const point: PlacedRoutePoint = { ...model, id: `${type}-${crypto.randomUUID()}`, type, ...(type === "ap" || type === "fp" ? { radius_m: 25 } : {}), }; if (type === "bp" || type === "ep") points = { ...points, [type]: point }; else points = { ...points, [type]: [...points[type], point] }; selectedId = point.id; notify(); } function updateSelected(values: Partial): void { const current = selected(); if (!current) return; const update = (point: PlacedRoutePoint) => point.id === current.id ? { ...point, ...values } : point; if (current.type === "bp" || current.type === "ep") { points = { ...points, [current.type]: update(current) }; } else { points = { ...points, [current.type]: points[current.type].map(update) }; } notify(); } function movePoint(id: string, model: { x: number; y: number; z: number }): void { const point = allPoints().find((candidate) => candidate.id === id); if (!point) return; const update = (candidate: PlacedRoutePoint) => candidate.id === id ? { ...candidate, ...model } : candidate; if (point.type === "bp" || point.type === "ep") { points = { ...points, [point.type]: update(point) }; } else { points = { ...points, [point.type]: points[point.type].map(update) }; } notify(); } function deleteSelected(): void { const current = selected(); if (!current) return; if (current.type === "bp" || current.type === "ep") points = { ...points, [current.type]: null }; else points = { ...points, [current.type]: points[current.type].filter((p) => p.id !== current.id), }; selectedId = null; notify(); } function renderRoute( polyline: Array<{ x: number; y: number; z?: number }>, warnings: Array<{ polyline_start_index: number; polyline_end_index: number }> = [], ): void { disposeGroup(routeGroup); const bounds = getBounds(); if (!bounds || polyline.length < 2) return; // 정점 사이를 잘게 나눠 **각 점의 지형고를 찍어** 선을 지면에 붙인다(2026-09-06). // 원래는 정점(간격 중앙값 10m)을 곧바로 이었는데, 볼록한 능선에서는 그 10m 직선이 // 지면을 뚫고 들어가 선이 땅속에 잠겼다 — 화면에는 폴리라인이 끊긴 것처럼 보였다 // (실측: 161+0.0~163+0.0 구간이 통째로 사라짐). 원래 정점은 모두 남기므로 평면 // 형상은 바뀌지 않는다. const dense: Array<{ x: number; y: number; z?: number }> = []; /** 원래 정점 i 가 조밀화 뒤 몇 번째인지 — 경고 구간이 인덱스로 자르므로 필요하다. */ const denseIndex: number[] = []; polyline.forEach((point, index) => { if (index > 0) { const previous = polyline[index - 1]; const span = Math.hypot(point.x - previous.x, point.y - previous.y); const steps = Math.min(MAX_DRAPE_STEPS, Math.ceil(span / DRAPE_STEP_M)); for (let step = 1; step < steps; step += 1) { const ratio = step / steps; dense.push({ x: previous.x + (point.x - previous.x) * ratio, y: previous.y + (point.y - previous.y) * ratio, }); } } denseIndex[index] = dense.length; dense.push(point); }); // 표고 없는 점을 0으로 삼키면 마커와 같은 함정에 빠진다(지형 한참 아래 평면에 눕는다). // 지형 표면에서 찾아 채우고, 그래도 모르면 직전 점 높이를 이어 쓴다. 끼워 넣은 점과 // 원래 정점이 **같은 지형면**에서 높이를 받아야 선이 매끄러우므로 지형고를 먼저 본다. let lastZ: number | null = null; const linePoints = dense.map((point) => { const sampled = getTerrainZ?.(point.x, point.y) ?? null; const resolved = sampled ?? (Number.isFinite(point.z) ? (point.z as number) : 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, ROUTE_LINE_LIFT_M, 0), ); }); routeGroup.add( new THREE.Line( new THREE.BufferGeometry().setFromPoints(linePoints), new THREE.LineBasicMaterial({ color: 0x38bdf8 }), ), ); warnings.forEach((warning) => { // 경고 구간의 인덱스는 **원래 정점 기준**이라 조밀화 뒤 자리로 옮겨 자른다. const from = denseIndex[Math.max(0, warning.polyline_start_index)] ?? 0; const to = denseIndex[Math.min(warning.polyline_end_index, polyline.length - 1)]; const segment = linePoints.slice(from, (to ?? linePoints.length - 1) + 1); if (segment.length > 1) { routeGroup.add( new THREE.Line( new THREE.BufferGeometry().setFromPoints(segment), new THREE.LineBasicMaterial({ color: 0xef4444 }), ), ); const marker = new THREE.Mesh( new THREE.SphereGeometry(1.2, 12, 8), new THREE.MeshBasicMaterial({ color: 0xef4444 }), ); marker.position.copy(segment[Math.floor(segment.length / 2)]); marker.position.y += 1.2; routeGroup.add(marker); } }); } /** * 이 측점에 라벨을 달지, 단다면 무슨 글자를 쓸지 (2026-08-04 사용자 지시). * * BP·EP — 시·종점은 항상. 이름을 앞에 붙여 어느 끝인지 바로 읽히게 한다. * 구조물(비정규) — 측점번호 + 구조물 이름(배관 등). * 규칙 측점 — 전부 만든다. 몇 개를 보일지는 카메라 거리가 정한다(`LABEL_LOD`). * * 측점번호는 라벨 표기(`측점번호+잔여거리`)에서 되짚는다 — 측점간격은 렌더러가 모른다. * 잔여거리가 남은 측점(예: `4+12.3`)은 규칙 격자가 아니므로 솎기 판정에서 뺀다 * (`number: null` = 거리와 무관하게 항상 보임). */ function stationLabelText( station: SectionStationMarker, intervalM: number, ): { text: string; number: number | null } | null { const chainage = station.chainage_m; if (!Number.isFinite(chainage)) return null; // 표기는 종단 그래프·도면 테이블과 **같은 규칙**(`측점번호+잔여거리`)을 쓴다. // 서버가 내려주는 `label`(`STA.0+000.000`)을 그대로 쓰면 화면마다 표기가 갈린다. const text = stationLabel(chainage as number, intervalM); if (station.kind === "bp") return { text: `BP ${text}`, number: null }; if (station.kind === "ep") return { text: `EP ${text}`, number: null }; if (station.kind === "irregular") { const structure = station.structure?.trim(); return { text: structure ? `${text} ${structure}` : text, number: null }; } const safeInterval = intervalM > 0 ? intervalM : 1; const stationNumber = Math.round((chainage as number) / safeInterval); const remainder = (chainage as number) - stationNumber * safeInterval; if (Math.abs(remainder) > 0.05) return null; return { text, number: stationNumber }; } /** 지금 솎기 단계(몇 칸마다 보일지). 카메라 거리로 바뀐다. */ let labelStep = LABEL_LOD[LABEL_LOD.length - 1].step; function applyLabelStep(): void { stationLabelGroup.children.forEach((child) => { const number = (child.userData as { stationNumber?: number | null }).stationNumber; child.visible = typeof number !== "number" || number % labelStep === 0; }); } /** * 측점 라벨 스프라이트. 카메라를 항상 바라보는 캔버스 텍스처라 줌·회전과 무관하게 읽힌다. * 라벨은 **구조물(비정규) 측점에만** 단다 — 전 측점에 달면 글자가 겹쳐 도면을 못 읽는다. */ function stationLabelSprite(text: string, position: THREE.Vector3): THREE.Sprite { const fontPx = 28; const padPx = 10; const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); if (context) { context.font = `600 ${fontPx}px sans-serif`; canvas.width = Math.ceil(context.measureText(text).width) + padPx * 2; canvas.height = fontPx + padPx * 2; // 캔버스 크기를 바꾸면 컨텍스트가 초기화되므로 폰트를 다시 지정해야 한다. context.font = `600 ${fontPx}px sans-serif`; context.fillStyle = "rgba(15, 23, 42, 0.72)"; // 모서리 라운드 배경(2026-08-06 사용자 지시) — 각진 사각형 대신 둥근 판. context.beginPath(); context.roundRect(0, 0, canvas.width, canvas.height, 10); context.fill(); context.fillStyle = "#fef3c7"; context.textBaseline = "middle"; context.fillText(text, padPx, canvas.height / 2); } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; const sprite = new THREE.Sprite( new THREE.SpriteMaterial({ map: texture, depthTest: false, transparent: true }), ); const heightUnits = 3.2; sprite.scale.set((canvas.width / canvas.height) * heightUnits, heightUnits, 1); sprite.position.copy(position); sprite.position.y += 3.4; sprite.renderOrder = 10; return sprite; } function renderStationLines(stations: SectionStationMarker[], halfWidth: number): void { disposeGroup(stationGroup); disposeGroup(stationLabelGroup); stationCenters.clear(); const bounds = getBounds(); if (!bounds || halfWidth <= 0) { syncSelectionPin(); return; } // 측점간격은 최빈 간격으로 되짚는다 — 비정규 측점이 섞여 있어도 규칙 간격이 나온다. const stationIntervalM = inferStationInterval( stations .filter((entry) => Number.isFinite(entry.chainage_m)) .map((entry) => ({ chainage_m: entry.chainage_m as number })), ); // 측점선(노란 띠)은 도로 반폭의 1.5배로 그린다 — 지형에 묻혀 짧아 보이는 문제 // (2026-08-19 사용자 지시). 측구 방향 램프는 늘어난 선의 **끝단**에 따라붙는다. const barHalfWidth = halfWidth * 1.5; stations.forEach((station) => { if (station.center_z === null) return; const [leftX, leftY] = station.frame.left_xy; // 띄움 0.8 — 예전 0.45는 굴곡진 지형에 절반쯤 묻혀 잘 안 보였다(2026-08-06 보고). const center = { x: station.center_x, y: station.center_y, z: station.center_z + 0.8 }; stationCenters.set(station.station_id, modelToScene(center, bounds)); const points = [ modelToScene( { x: center.x + leftX * barHalfWidth, y: center.y + leftY * barHalfWidth, z: center.z }, bounds, ), modelToScene( { x: center.x - leftX * barHalfWidth, y: center.y - leftY * barHalfWidth, z: center.z }, bounds, ), ]; const selected = station.station_id === selectedStationId; // 측점선은 선(Line)이 아니라 **납작한 띠(Mesh)**로 그린다 — WebGL에서 Line은 // 굵기 지정이 안 먹혀 항상 1px라 안 보이고, 레이캐스트도 얇아 클릭이 어려웠다 // (2026-08-06 보고). 띠는 굵게 보이고 클릭 판정도 몸통 전체다. const barLength = points[0].distanceTo(points[1]); const bar = new THREE.Mesh( new THREE.BoxGeometry(barLength, 0.18, 0.5), new THREE.MeshBasicMaterial({ color: selected ? 0xef4444 : 0xfacc15 }), ); bar.position.lerpVectors(points[0], points[1], 0.5); const direction = points[1].clone().sub(points[0]); bar.rotation.y = Math.atan2(-direction.z, direction.x); bar.userData.stationId = station.station_id; stationGroup.add(bar); // 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색. // 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick). const label = stationLabelText(station, stationIntervalM); if (label) { const sprite = stationLabelSprite(label.text, modelToScene(center, bounds)); sprite.userData.stationNumber = label.number; stationLabelGroup.add(sprite); } (["left", "right"] as const).forEach((side, endIndex) => { const active = station.uphill_side === side; // 램프 절반 축소(1.1→0.55) — 측점 고를 때 가리는 문제(2026-08-06 보고). const lamp = new THREE.Mesh( new THREE.SphereGeometry(0.55, 14, 10), new THREE.MeshBasicMaterial({ color: active ? UPHILL_LAMP_COLOR : UPHILL_LAMP_INACTIVE_COLOR, }), ); lamp.position.copy(points[endIndex]); lamp.position.y += 0.3; lamp.userData.uphillStationId = station.station_id; lamp.userData.uphillSide = side; stationGroup.add(lamp); // 클릭 판정은 예전 크기 그대로 — 투명 히트 구를 겹쳐 "작게 보여도 잘 눌리게". const lampHit = new THREE.Mesh( new THREE.SphereGeometry(1.1, 8, 6), new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }), ); lampHit.position.copy(lamp.position); lampHit.userData.uphillStationId = station.station_id; lampHit.userData.uphillSide = side; stationGroup.add(lampHit); }); }); // 새로 만든 라벨에도 지금 솎기 단계를 그대로 먹인다. applyLabelStep(); // 재렌더로 좌표가 갱신됐으니 선택 핀도 그 자리로 다시 놓는다. syncSelectionPin(); } /* ── 선택 측점 수직 핀 마커 ────────────────────────────────────────────── * 원형 표기는 지형에 묻혀 잘 안 보인다(2026-08-05 사용자 지시로 지양). 대신 지형에서 * 수직으로 솟는 기둥 + 아래를 가리키는 역원뿔 헤드를 세운다. depthTest를 꺼서 능선 * 뒤에 있어도 항상 보인다. 측점 좌표는 renderStationLines가 갱신하는 맵에서 찾는다. */ const stationCenters = new Map(); const PIN_HEIGHT = 14; const selectionPin = (() => { const group = new THREE.Group(); const material = new THREE.MeshBasicMaterial({ color: 0xff3b30, depthTest: false }); const beam = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.28, PIN_HEIGHT, 10), material); beam.position.y = PIN_HEIGHT / 2; const head = new THREE.Mesh(new THREE.ConeGeometry(1.8, 3.4, 14), material); head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다. head.position.y = PIN_HEIGHT + 1.7; group.add(beam, head); group.renderOrder = 9; group.visible = false; interactionGroup.add(group); return group; })(); /** 선택 상태·측점 좌표에 맞춰 핀을 놓는다(재렌더·선택 변경 공용). */ function syncSelectionPin(): void { const center = selectedStationId ? stationCenters.get(selectedStationId) : undefined; selectionPin.visible = !!center; if (center) selectionPin.position.copy(center); } function selectStation(stationId: string | null): void { selectedStationId = stationId; stationGroup.children.forEach((object) => { // 측점 띠(stationId 보유 Mesh)만 색을 바꾼다 — 램프(uphill*)는 제외. if (!(object instanceof THREE.Mesh) || typeof object.userData.stationId !== "string") return; const selected = object.userData.stationId === selectedStationId; (object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15); }); syncSelectionPin(); stationSelectionListener?.(selectedStationId); } return { group: interactionGroup, getPoints: () => points, getSelected: selected, setPoints(next: RouteDesignPoints) { points = next; selectedId = null; notify(); }, place, moveSelected(model: { x: number; y: number; z: number }) { updateSelected(model); }, movePoint, updateSelected, deleteSelected, selectObject(object: THREE.Object3D | undefined) { // 상단측 램프 클릭: 해당 측을 측구 방향으로 지정(측점 선택보다 우선 판정). if (typeof object?.userData.uphillStationId === "string") { uphillPickListener?.( object.userData.uphillStationId, object.userData.uphillSide as "left" | "right", ); return; } if (typeof object?.userData.stationId === "string") { selectStation(object.userData.stationId); selectionListener?.(null); return; } // 3D 빈 공간 클릭으로는 측점 선택을 **해제하지 않는다**(2026-08-05 사용자 지시) — // 회전용 좌클릭 드래그가 클릭으로 인식되어 선택 핀이 풀리는 문제. 해제는 그래프· // 사이드 패널에서만 한다. selectedId = typeof object?.userData.routePointId === "string" ? object.userData.routePointId : null; renderMarkers(); selectionListener?.(selected()); }, pointIdForObject(object: THREE.Object3D | undefined) { return typeof object?.userData.routePointId === "string" ? (object.userData.routePointId as string) : null; }, selectPoint(id: string) { selectedId = allPoints().some((point) => point.id === id) ? id : null; renderMarkers(); selectionListener?.(selected()); }, renderMarkers, renderRoute, renderStationLines, selectStation, setStationLinesVisible(visible: boolean) { stationGroup.visible = visible; }, setStationLabelsVisible(visible: boolean) { stationLabelGroup.visible = visible; }, /** 카메라~시점 거리(m)로 규칙 측점 라벨을 솎는다. 구조물·BP·EP 는 늘 보인다. */ updateLabelDetail(distanceM: number) { const step = (LABEL_LOD.find((lod) => distanceM < lod.within) ?? LABEL_LOD[0]).step; if (step === labelStep) return; labelStep = step; applyLabelStep(); }, onChange(listener: (next: RouteDesignPoints) => void) { changeListener = listener; }, onSelectionChange(listener: (point: PlacedRoutePoint | null) => void) { selectionListener = listener; }, onStationSelectionChange(listener: (stationId: string | null) => void) { stationSelectionListener = listener; }, onUphillPick(listener: (stationId: string, side: "left" | "right") => void) { uphillPickListener = listener; }, dispose() { disposeGroup(interactionGroup); disposeGroup(routeGroup); scene.remove(interactionGroup, routeGroup); }, }; } export type RouteMarkers = ReturnType;