Files
Aislo/B05_Profile/B05_Profile_UI_Markers.ts
eomsangdonandClaude Opus 5 fdcd075396 fix(B05): 3D 노선 선이 능선에서 끊겨 보이던 문제 — 2m 간격으로 지형에 붙임
원인은 선의 해상도 (2026-09-06 실측). 노선 정점(간격 중앙값 10.0m)을 곧바로
이어 지형 위 0.35m 로 띄웠는데, 볼록한 능선에서는 그 10m 직선이 지면을 뚫고
들어가 선이 땅속에 잠겼다 — 화면에서는 161+0.0~163+0.0 구간이 통째로 사라졌다.

정점 사이를 2m 간격으로 나눠 각 점의 지형고를 찍는다. 원래 정점은 모두 남기므로
평면 형상은 불변. 끼운 점과 원래 정점이 같은 지형면에서 높이를 받도록 지형고를
먼저 보고, 없을 때만 정점 z 를 쓴다. 곡선 경고 구간은 원래 정점 기준 인덱스라
조밀화 뒤 자리로 옮겨 자른다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 11:32:29 +09:00

625 lines
27 KiB
TypeScript

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<RoutePointKind, number> = {
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<PlacedRoutePoint>): 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<string, THREE.Vector3>();
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<typeof createRouteMarkers>;