feat(B05): 3D 측점 라벨 기본 켜짐, BP·EP·5측점 배수까지 표시

- [측점 라벨] 토글 기본값을 켜짐으로 바꿨다. 3D에서 측점 위치를 바로 읽는 것이
  기본 동작이다.
- 라벨 대상 확대: BP·EP(이름 접두), 5측점 배수 규칙 측점, 구조물(비정규) 측점
  (측점번호 + 구조물 이름). 나머지 규칙 측점은 달지 않는다 — 전부 달면 겹친다.
- 표기를 종단 그래프·도면 테이블과 같은 규칙(측점번호+잔여거리)으로 통일했다.
  서버 label은 STA.0+000.000 형식이라 화면마다 표기가 갈렸다. 누가거리와 최빈
  측점간격으로 되짚어 stationLabel()로 만든다.

확인: 23측점 노선에서 라벨 9개(BP / 5·10·15측점 / 구조물 4 / EP)만 표시됨을
실데이터로 검증. typecheck·vite build 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 21:07:41 +09:00
co-authored by Claude Opus 5
parent f39ecf2f5e
commit b3c64625df
2 changed files with 49 additions and 12 deletions
+47 -10
View File
@@ -1,4 +1,5 @@
import * as THREE from "three";
import { inferStationInterval, stationLabel } from "@util/common_util_svg";
export type RoutePointKind = "bp" | "ep" | "cp" | "ap" | "fp";
@@ -33,14 +34,17 @@ export interface SectionStationMarker {
/** 상단측(등고 높은 쪽) — 램프 컬러 표시용. Page가 사용자 변경분을 반영해 넘긴다. */
uphill_side?: "left" | "right" | null;
frame: { left_xy: [number, number] };
/** 측점 종류 — 구조물(비정규) 측점만 3D 라벨을 단다(2026-08-03 사용자 지시). */
/** 측점 종류(`bp`/`ep`/`regular`/`irregular`) — 어느 측점에 라벨을 달지 가르는 기준. */
kind?: string;
/** 측점번호 표기(`4+0.0`). 라벨 본문에 그대로 쓴다. */
label?: string;
/** 시점부터의 누가거리(m). 측점번호·라벨 표기를 여기서 되짚는다. */
chainage_m?: number;
/** 구조물 이름(배관 등). 있으면 측점번호 뒤에 붙인다. */
structure?: string;
}
/** 규칙 측점 라벨을 몇 칸마다 달지. 전부 달면 글자가 겹쳐 도면을 못 읽는다. */
const STATION_LABEL_STEP = 5;
// 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색.
const UPHILL_LAMP_COLOR = 0xf97316;
const UPHILL_LAMP_INACTIVE_COLOR = 0x9ca3af;
@@ -89,7 +93,8 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
const stationGroup = new THREE.Group();
// 구조물 측점 라벨(스프라이트) 전용 — 측점선과 별개 토글이라 그룹을 나눈다.
const stationLabelGroup = new THREE.Group();
stationLabelGroup.visible = false;
// 기본 켜짐 — 측점 위치를 3D에서 바로 읽는 것이 기본 동작이다(2026-08-04 사용자 지시).
stationLabelGroup.visible = true;
interactionGroup.add(markerGroup, stationGroup, stationLabelGroup);
scene.add(interactionGroup, routeGroup);
let points = emptyPoints();
@@ -249,6 +254,35 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
});
}
/**
* 이 측점에 라벨을 달지, 단다면 무슨 글자를 쓸지 (2026-08-04 사용자 지시).
*
* BP·EP — 시·종점은 항상. 이름을 앞에 붙여 어느 끝인지 바로 읽히게 한다.
* 구조물(비정규) — 측점번호 + 구조물 이름(배관 등).
* 5측점 배수 — 규칙 측점은 5칸마다만. 전부 달면 글자가 겹쳐 도면을 못 읽는다.
*
* 측점번호는 라벨 표기(`측점번호+잔여거리`)에서 되짚는다 — 측점간격은 렌더러가 모른다.
* 잔여거리가 남은 측점(예: `4+12.3`)은 규칙 격자가 아니므로 배수 판정에서 뺀다.
*/
function stationLabelText(station: SectionStationMarker, intervalM: number): string | 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 `BP ${text}`;
if (station.kind === "ep") return `EP ${text}`;
if (station.kind === "irregular") {
const structure = station.structure?.trim();
return structure ? `${text} ${structure}` : text;
}
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 stationNumber % STATION_LABEL_STEP === 0 ? text : null;
}
/**
* 측점 라벨 스프라이트. 카메라를 항상 바라보는 캔버스 텍스처라 줌·회전과 무관하게 읽힌다.
* 라벨은 **구조물(비정규) 측점에만** 단다 — 전 측점에 달면 글자가 겹쳐 도면을 못 읽는다.
@@ -288,6 +322,12 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
disposeGroup(stationLabelGroup);
const bounds = getBounds();
if (!bounds || halfWidth <= 0) return;
// 측점간격은 최빈 간격으로 되짚는다 — 비정규 측점이 섞여 있어도 규칙 간격이 나온다.
const stationIntervalM = inferStationInterval(
stations
.filter((entry) => Number.isFinite(entry.chainage_m))
.map((entry) => ({ chainage_m: entry.chainage_m as number })),
);
stations.forEach((station) => {
if (station.center_z === null) return;
const [leftX, leftY] = station.frame.left_xy;
@@ -313,12 +353,9 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
// 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색.
// 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick).
// 구조물 측점 라벨: `측점번호 구조물명`. 일반 측점은 라벨을 달지 않는다.
if (station.kind === "irregular" && station.label) {
const text = station.structure?.trim()
? `${station.label} ${station.structure.trim()}`
: station.label;
stationLabelGroup.add(stationLabelSprite(text, modelToScene(center, bounds)));
const labelText = stationLabelText(station, stationIntervalM);
if (labelText) {
stationLabelGroup.add(stationLabelSprite(labelText, modelToScene(center, bounds)));
}
(["left", "right"] as const).forEach((side, endIndex) => {
+2 -2
View File
@@ -52,7 +52,7 @@ interface PanelCallbacks {
onContoursVisible: (visible: boolean) => void;
onAxesVisible: (visible: boolean) => void;
onStationLinesVisible: (visible: boolean) => void;
/** 구조물 측점 번호·이름 라벨 표시 토글(기본 꺼짐). */
/** 측점 번호·이름 라벨 표시 토글(기본 켜짐 — BP·EP·5측점 배수·구조물). */
onStationLabelsVisible: (visible: boolean) => void;
onView: (view: "iso" | "top" | "front" | "side") => void;
onResetView: () => void;
@@ -161,7 +161,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
toggleButton("등고선", true, callbacks.onContoursVisible),
toggleButton("축 표시", false, callbacks.onAxesVisible),
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
toggleButton(L("B05_Route_Field_StationLabels"), false, callbacks.onStationLabelsVisible),
toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible),
);
const separator1 = document.createElement("span");
separator1.className = "b05-route__view-separator";