feat(B05): 절성토 완전 분리(행 지반 트림) · 그랩 팬 · 측점 그룹 라이브 갱신

2026-08-23 사용자 지시 3건:
- 절성토 완전 분리: 전환 구간을 상대 측점의 폭 0 축퇴점으로 모핑하던 방식
  제거. 비탈을 측구와 같은 도로부 앵커 상대 좌표로 보간하고, 행마다 두 측점
  지반선을 섞은 행 지반선과 직접 교차시켜 종결(trimSlopeToGround). 지반이
  반대편인 행은 조각 드롭 - 절토가 지반으로 내려가거나 서피스끼리 침범하던
  문제 해소. 클리핑 외곽도 행 실점유(비탈-측구-노견)로 일치.
  700줄 제한: 측점 분류·트림 유틸을 Corridor_Station.ts로 분리(Build 494줄).
- 그랩 팬: 가운데 드래그를 커서 아래 지형점이 1:1로 따라오는 팬으로 직접
  구현(B04_PreProcess_UI_Camera). OrbitControls 기본 PAN은 카메라-target
  거리 비례라 커서 피벗 회전·줌 후 극단적으로 느려졌다. B04 뷰어도 공용.
- 측점 그룹 갱신: Profile_Panel이 라이브 alignment.samples 노출, Page의
  측점 바 계획고·코리도 designSamples가 그걸 사용, 종단 편집 디바운스에서
  renderStationLines 동반 호출. BUILD_VERSION 4-5로 저장본 만료.

검증: tsc 통과 · pytest 175 passed(신규 separation 8건) · 사용자 화면 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 16:06:03 +09:00
co-authored by Claude Fable 5
parent 0e3bdb145d
commit 8393d22c23
6 changed files with 545 additions and 339 deletions
+44 -4
View File
@@ -138,8 +138,11 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
// 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다).
controls.enableRotate = false;
controls.enableZoom = false;
// 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다.
controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN;
// 가운데 버튼 드래그 = 그랩 팬(커서 아래 지형점이 커서를 1:1로 따라온다) — 아래에서
// 직접 처리한다. OrbitControls 기본 PAN은 카메라↔target 거리에 비례하는데, 커서 피벗
// 회전·줌이 target을 지형과 무관한 곳으로 옮겨 두면 실제 지형 거리보다 훨씬 느려진다
// (2026-08-23 사용자 보고). 겹치지 않게 내장 팬은 끈다.
controls.enablePan = false;
// 회전 중심 조준점 — 돌리는 동안에만 보인다. 항상 카메라를 향하는 스프라이트라
// 어느 각도에서도 또렷하고, 화면상 크기는 거리와 무관하게 일정하다.
@@ -176,6 +179,11 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
let pointerId: number | null = null;
let lastX = 0;
let lastY = 0;
// 그랩 팬 상태 — 잡은 지형점(월드)과 그 점을 지나는 시선 수직 평면.
let panPointerId: number | null = null;
const panAnchor = new THREE.Vector3();
const panPlane = new THREE.Plane();
const panPoint = new THREE.Vector3();
/** 커서가 가리키는 지형 위 지점을 회전축으로 잡는다.
*
@@ -204,10 +212,20 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
}
function onPointerDown(event: PointerEvent): void {
// 가운데 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 화면 이동과 겹치므로 막는다.
// 가운데 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 과 겹치므로 막는다.
if (event.button === 1) event.preventDefault();
if (event.button !== 0 || pointerId !== null) return;
if (options.blocked?.() || !controls.enabled) return;
if (event.button === 1 && panPointerId === null) {
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
pickPivot(event);
panAnchor.copy(pivot);
camera.getWorldDirection(viewDirection);
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
panPointerId = event.pointerId;
element.setPointerCapture?.(event.pointerId);
return;
}
if (event.button !== 0 || pointerId !== null) return;
pickPivot(event);
pointerId = event.pointerId;
// 드래그가 캔버스 밖(브라우저 위·아래 끝)으로 나가도 회전이 끊기지 않게 포인터를 잡아 둔다.
@@ -241,6 +259,22 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
}
function onPointerMove(event: PointerEvent): void {
if (panPointerId === event.pointerId) {
// 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다.
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
pointer.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(pointer, camera);
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
const delta = panAnchor.clone().sub(panPoint);
camera.position.add(delta);
controls.target.add(delta);
controls.update();
return;
}
if (pointerId !== event.pointerId) return;
if (options.blocked?.()) {
stop();
@@ -311,6 +345,12 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
function onPointerEnd(event: PointerEvent): void {
if (pointerId === event.pointerId) stop();
if (panPointerId === event.pointerId) {
if (element.hasPointerCapture?.(event.pointerId)) {
element.releasePointerCapture(event.pointerId);
}
panPointerId = null;
}
}
/** 캔버스를 벗어나도 잡고 있는 동안에는 회전을 이어 간다 — 놓을 때만 끝낸다. */
+14 -3
View File
@@ -73,7 +73,7 @@ function fnv1a(text: string): string {
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
* 섞어 두면 배포와 동시에 저장본이 만료된다.
*/
const BUILD_VERSION = 4;
const BUILD_VERSION = 5; // 절성토 완전 분리(행 지반 트림) — 저장본 만료.
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
@@ -201,11 +201,19 @@ async function putStored(
* 반환된 build는 뷰어 setCorridor가 심 보정으로 제자리 수정할 수 있다(직렬화는
* saveCorridorIfDirty 시점의 최신 상태를 담는다).
*/
/** 종단 계획선 샘플(라이브 편집 반영분) — buildCorridor designSamples와 같은 꼴. */
export type ProfileSamples = Array<{
chainage_m: number;
elevation_m: number;
ground_elevation_m?: number;
}>;
export async function ensureCorridor(
projectId: string,
routeId: number,
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
): Promise<CorridorBuildResult | null> {
const key = keyOf(projectId, routeId);
const hash = corridorHash(detail, routePoints);
@@ -224,10 +232,12 @@ export async function ensureCorridor(
}
// 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다.
// 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는
// 편집 전 값이라 전환점·종단 보정이 낡은 계획고를 따라간다(2026-08-23 지적 ③).
const build = buildCorridor(
detail.cross_sections,
routePoints,
detail.longitudinal.design_profiles?.[0]?.samples,
designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples,
);
if (!build) {
cache.delete(key);
@@ -266,9 +276,10 @@ export function refreshCorridor(
routeId: number | undefined,
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
): void {
if (!routeId) return;
void ensureCorridor(projectId, routeId, detail, routePoints)
void ensureCorridor(projectId, routeId, detail, routePoints, designSamples)
.then((build) => viewer.setCorridor(build))
.catch(() => viewer.setCorridor(null));
}
+80 -330
View File
@@ -14,9 +14,21 @@
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import {
classifyStation,
mixedGround,
PIECE_COLS,
pieceKey,
resample,
trimSlopeToGround,
type CorridorKind,
type CorridorSide,
type OffsetPoint,
type StationPieces,
type XY,
} from "./B05_Profile_UI_Corridor_Station";
export type CorridorKind = "carriageway" | "shoulder" | "ditch" | "cut" | "fill";
export type CorridorSide = "left" | "right" | "center";
export type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Station";
/** 리본 하나 — rows[i]는 프레임 i의 단면 점(colCount개, 모델 x/y/z 평탄 배열). */
export interface CorridorRibbon {
@@ -48,278 +60,11 @@ export interface CorridorBuildResult {
caps: CorridorCap[];
}
/** 조각별 고정 열 수 — 보간 시 t-리샘플 기준. 비탈은 무릎(2단 경사)까지 담게 넉넉히. */
const PIECE_COLS: Record<CorridorKind, number> = {
carriageway: 3,
shoulder: 2,
ditch: 5,
cut: 9,
fill: 9,
};
/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분).
* 평면 궤적을 스플라인으로 잇게 되어(2026-08-23) 0.1m까지 쪼갤 이유가 없어졌다 —
* 0.2m면 곡선이 충분히 매끄럽고 데이터·연산은 절반이다(사용자 제안). */
const SUBDIVIDE_STEP_M = 0.2;
interface XY {
x: number;
y: number;
}
interface OffsetPoint {
offset_m: number;
elevation_m: number;
}
/** 측점 하나의 분류·리샘플 결과 — (kind,side)별 colCount 고정 폴리라인. */
interface StationPieces {
chainage_m: number;
center: XY;
left: XY;
/** kind:side 키 → 리샘플된 단면 점(offset/z). 없는 조각은 키 부재. */
pieces: Map<string, OffsetPoint[]>;
/** 축퇴 기준점 — 좌/우 road edge (조각이 없는 측점에서 리본 폭 0 수렴용). */
roadEdge: { left: OffsetPoint; right: OffsetPoint };
/** 최외곽(catch) offset/z — 클리핑 외곽선. */
outer: { left: OffsetPoint; right: OffsetPoint };
/** 마구리용 단면 — catch 사이 [offset, 설계z, 지반z] (시·종점에서만 쓴다). */
capSection: Array<[number, number, number]>;
/**
* 측구 조각을 **도로 끝(road edge) 기준 상대 좌표**로도 들고 있는다.
*
* 도로부(차도·노견·측구)는 한 몸이다 — 그런데 측구는 있는 측점과 없는 측점이
* 갈려서, 절대 좌표로 따로 보간하면 도로 끝은 다음 측점을 향해 내려가는데 측구만
* 제자리에 남아 최대 0.9m까지 벌어졌다(2026-08-23 실측). 상대 좌표로 들고 있다가
* 프레임의 도로 끝에 얹으면 무슨 일이 있어도 노견에 붙는다.
*/
ditchRelative: Map<string, OffsetPoint[]>;
}
function pieceKey(kind: CorridorKind, side: CorridorSide): string {
return `${kind}:${side}`;
}
/** design_line에서 [a,b] 구간 서브폴리라인 추출(경계점은 선형 보간으로 삽입). */
function slicePolyline(line: OffsetPoint[], a: number, b: number): OffsetPoint[] {
const lo = Math.min(a, b);
const hi = Math.max(a, b);
if (hi - lo < 1e-9 || line.length < 2) return [];
const zAt = (offset: number): number => {
if (offset <= line[0].offset_m) return line[0].elevation_m;
for (let i = 1; i < line.length; i += 1) {
const p0 = line[i - 1];
const p1 = line[i];
if (offset <= p1.offset_m + 1e-12) {
const span = p1.offset_m - p0.offset_m;
if (span <= 1e-12) return p1.elevation_m;
const t = (offset - p0.offset_m) / span;
return p0.elevation_m + (p1.elevation_m - p0.elevation_m) * t;
}
}
return line[line.length - 1].elevation_m;
};
const result: OffsetPoint[] = [{ offset_m: lo, elevation_m: zAt(lo) }];
for (const point of line) {
if (point.offset_m > lo + 1e-9 && point.offset_m < hi - 1e-9) result.push(point);
}
result.push({ offset_m: hi, elevation_m: zAt(hi) });
return result;
}
/** 폴리라인을 호길이 비례 t(0..1)로 cols개 점으로 리샘플 — 프레임 간 열 대응용. */
function resample(points: OffsetPoint[], cols: number): OffsetPoint[] {
if (points.length === 0) return [];
if (points.length === 1) return Array.from({ length: cols }, () => ({ ...points[0] }));
const lengths: number[] = [0];
for (let i = 1; i < points.length; i += 1) {
const dOffset = points[i].offset_m - points[i - 1].offset_m;
const dz = points[i].elevation_m - points[i - 1].elevation_m;
lengths.push(lengths[i - 1] + Math.hypot(dOffset, dz));
}
const total = lengths[lengths.length - 1];
const result: OffsetPoint[] = [];
for (let c = 0; c < cols; c += 1) {
const target = total <= 1e-12 ? 0 : (total * c) / (cols - 1);
let index = 1;
while (index < points.length - 1 && lengths[index] < target) index += 1;
const p0 = points[index - 1];
const p1 = points[index];
const span = lengths[index] - lengths[index - 1];
const t = span <= 1e-12 ? 0 : (target - lengths[index - 1]) / span;
result.push({
offset_m: p0.offset_m + (p1.offset_m - p0.offset_m) * t,
elevation_m: p0.elevation_m + (p1.elevation_m - p0.elevation_m) * t,
});
}
return result;
}
/** 지반선 보간기 — samples(offset, elevation)로 지반고를 되짚는다. */
function groundSampler(section: CrossSection): ((offset: number) => number) | null {
const points = section.samples
.map((sample) => ({
offset_m: sample.offset_m,
elevation_m: sample.elevation_m ?? sample.z,
}))
.filter(
(p): p is OffsetPoint =>
p.offset_m !== undefined && p.elevation_m !== undefined && p.elevation_m !== null,
)
.sort((a, b) => a.offset_m - b.offset_m);
if (points.length < 2) return null;
return (offset: number): number => {
if (offset <= points[0].offset_m) return points[0].elevation_m;
for (let i = 1; i < points.length; i += 1) {
if (offset <= points[i].offset_m) {
const span = points[i].offset_m - points[i - 1].offset_m;
const t = span <= 1e-12 ? 0 : (offset - points[i - 1].offset_m) / span;
return points[i - 1].elevation_m + (points[i].elevation_m - points[i - 1].elevation_m) * t;
}
}
return points[points.length - 1].elevation_m;
};
}
/** 설계선-지반 허용차(m) — 이보다 가까우면 "지반에 닿았다"(catch)로 본다. */
const CATCH_EPS_M = 0.005;
/**
* 비탈 시작점에서 바깥으로 스캔해 설계선이 지반과 만나는 catch point를 찾는다.
* design_line은 샘플 반폭 끝까지 이어지고 catch 밖은 지반을 그대로 따르므로,
* 여기서 잘라야 코리도·클리핑 폭이 실제 절·성토 점유 범위가 된다.
*/
function catchOffset(
line: OffsetPoint[],
groundAt: (offset: number) => number,
start: number,
direction: 1 | -1,
end: number,
): number {
const probes = line
.filter((p) => (direction > 0 ? p.offset_m > start + 1e-9 : p.offset_m < start - 1e-9))
.sort((a, b) => (a.offset_m - b.offset_m) * direction);
for (const probe of probes) {
if (Math.abs(probe.elevation_m - groundAt(probe.offset_m)) <= CATCH_EPS_M) {
return probe.offset_m;
}
}
return end; // 반폭 안에서 지반을 못 만남(깊은 절토·높은 성토) — 샘플 끝까지.
}
/** 측구 조각을 도로 끝 기준 상대 좌표로 바꾼다(도로부 한 몸 제어용). */
function relativeToRoadEdge(
pieces: Map<string, OffsetPoint[]>,
roadEdge: { left: OffsetPoint; right: OffsetPoint },
): Map<string, OffsetPoint[]> {
const relative = new Map<string, OffsetPoint[]>();
(["left", "right"] as const).forEach((side) => {
const key = pieceKey("ditch", side);
const points = pieces.get(key);
if (!points) return;
const edge = roadEdge[side];
relative.set(
key,
points.map((point) => ({
offset_m: point.offset_m - edge.offset_m,
elevation_m: point.elevation_m - edge.elevation_m,
})),
);
});
return relative;
}
/** 측점 하나를 종류별 조각으로 분류·리샘플. design 없거나 설계선 부실 → null. */
function classifyStation(section: CrossSection): StationPieces | null {
const design = section.design;
const line = design?.design_line;
if (!design || !line || line.length < 2) return null;
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
const roadL = design.road_edges.left.offset_m;
const roadR = design.road_edges.right.offset_m;
const cwL = design.carriageway_edges?.left.offset_m ?? roadL;
const cwR = design.carriageway_edges?.right.offset_m ?? roadR;
// 측구 폭 — road edge에서 갭 없이 시작(엔진 ditch_points 규약).
const ditch = design.ditch;
const ditchEnabled = design.ditch_enabled ?? (ditch != null && ditch.type !== "none");
const ditchWidth =
!ditchEnabled || !ditch || ditch.type === "none"
? 0
: ditch.type === "standard"
? ditch.top_width_m
: ditch.width_m;
const ditchSide: "left" | "right" = design.ditch_side === "right" ? "right" : "left";
// 좌/우 비탈 종류 — 엔진이 echo한 resolved section_mode 기준.
const mode = design.section_mode;
const leftRole: CorridorKind = mode === "left_cut" || mode === "both_cut" ? "cut" : "fill";
const rightRole: CorridorKind = mode === "right_cut" || mode === "both_cut" ? "cut" : "fill";
const pieces = new Map<string, OffsetPoint[]>();
const put = (kind: CorridorKind, side: CorridorSide, a: number, b: number): void => {
const sub = slicePolyline(sorted, a, b);
if (sub.length >= 2) pieces.set(pieceKey(kind, side), resample(sub, PIECE_COLS[kind]));
};
put("carriageway", "center", cwR, cwL);
put("shoulder", "left", cwL, roadL);
put("shoulder", "right", roadR, cwR);
// 좌측: road edge → (측구) → 비탈끝. 우측은 대칭(음수 방향).
let slopeStartL = roadL;
let slopeStartR = roadR;
if (ditchWidth > 0) {
if (ditchSide === "left") {
put("ditch", "left", roadL, roadL + ditchWidth);
slopeStartL = roadL + ditchWidth;
} else {
put("ditch", "right", roadR - ditchWidth, roadR);
slopeStartR = roadR - ditchWidth;
}
}
// 비탈 끝 = catch point(설계선-지반 교차) — 그 밖은 설계선이 지반을 따라갈 뿐
// 절·성토 점유가 아니므로 코리도·클리핑에서 제외한다(2026-08-23 화면 검증 수정).
const ground = groundSampler(section);
const sampleEndL = sorted[sorted.length - 1].offset_m;
const sampleEndR = sorted[0].offset_m;
const endL = ground ? catchOffset(sorted, ground, slopeStartL, 1, sampleEndL) : sampleEndL;
const endR = ground ? catchOffset(sorted, ground, slopeStartR, -1, sampleEndR) : sampleEndR;
if (endL > slopeStartL + 1e-9) put(leftRole, "left", slopeStartL, endL);
if (endR < slopeStartR - 1e-9) put(rightRole, "right", endR, slopeStartR);
const zOf = (target: number): number => {
const sub = slicePolyline(sorted, target, target + 1e-6);
return sub.length ? sub[0].elevation_m : design.design_elevation_m;
};
return {
chainage_m: section.chainage_m,
center: { x: section.center_x, y: section.center_y },
left: { x: section.frame.left_xy[0], y: section.frame.left_xy[1] },
pieces,
roadEdge: {
left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m },
right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m },
},
outer: {
left: { offset_m: endL, elevation_m: zOf(endL) },
right: { offset_m: endR, elevation_m: zOf(endR) },
},
ditchRelative: relativeToRoadEdge(pieces, {
left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m },
right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m },
}),
// 마구리(시·종점 봉인)용 단면 — catch 사이 설계선 점마다 지반고를 짝지운다.
capSection: slicePolyline(sorted, endR, endL).map(
(point) =>
[
point.offset_m,
point.elevation_m,
ground ? ground(point.offset_m) : point.elevation_m,
] as [number, number, number],
),
};
}
/** 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 보간한다. */
/**
* Centripetal Catmull-Rom 한 구간(p1→p2). 제어점을 지나면서 오버슈트·자기교차가
@@ -456,37 +201,10 @@ function buildCrossingFinder(
};
}
/**
* 종단 보정량을 단면에 싣는다. 도로·노견·측구는 계획고에 붙어 있으므로 통째로,
* 절·성토 비탈은 안쪽(도로측) 전량 → 바깥쪽(지반 접점) 0으로 감쇠시킨다 —
* 그래야 비탈 끝이 지반에서 떨어지지 않는다.
*/
function applyProfileShift(
points: OffsetPoint[],
kind: CorridorKind,
side: CorridorSide,
shift: number,
): void {
if (kind !== "cut" && kind !== "fill") {
points.forEach((point) => (point.elevation_m += shift));
return;
}
const last = points.length - 1;
points.forEach((point, index) => {
// 좌측 비탈은 index 0이 도로측, 우측 비탈은 index last가 도로측(offset 부호 반대).
const innerRatio = side === "right" ? index / last : 1 - index / last;
point.elevation_m += shift * innerRatio;
});
}
/** 조각이 없는 측점의 대응 폴리라인 — road edge 한 점으로 축퇴(리본 폭 0 수렴). */
function degeneratePiece(
station: StationPieces,
kind: CorridorKind,
side: CorridorSide,
): OffsetPoint[] {
const edge = side === "right" ? station.roadEdge.right : station.roadEdge.left;
return Array.from({ length: PIECE_COLS[kind] }, () => ({ ...edge }));
/** 종단 보정량 — 도로부(차도·노견·측구)는 계획고에 붙어 있으므로 전량 가산한다.
* 비탈은 상대 좌표라 행 앵커를 통해 보정이 실리고, 끝은 지반 트림이 정한다. */
function applyProfileShift(points: OffsetPoint[], shift: number): void {
points.forEach((point) => (point.elevation_m += shift));
}
/**
@@ -560,8 +278,8 @@ export function buildCorridor(
});
const left = slerpLeft(s0.left, s1.left, t);
// 종단 보정 — 측점 사이를 직선으로 이으면 종단곡선이 각진다. 계획선 실제
// 표고와 직선 보간값의 차이만큼 단면을 통째로 올린다(도로·측구는 전부,
// 비탈은 안쪽에서 바깥으로 0까지 감쇠시켜 지반 접점을 지킨다).
// 표고와 직선 보간값의 차이만큼 도로부(차도·노견·측구)를 통째로 올린다.
// 비탈은 상대 좌표라 행 앵커로 보정이 실리고, 끝은 지반 트림이 정한다.
const shift = profileZ
? (profileZ(chainage) ?? 0) -
((profileZ(s0.chainage_m) ?? 0) +
@@ -575,9 +293,9 @@ export function buildCorridor(
// 양쪽 다 없으면 그 구간에는 조각 자체가 없다 — 리본을 끊어 없는 자리에
// 서피스가 생기지 않게 한다(2026-08-23 사용자 지적: 측구 없는 구간).
if (!hasA && !hasB) return;
// 측구는 한쪽에만 있으면 **측점 사이 중간에서 끊는다**(2026-08-23 사용자 확정).
// 폭 0으로 길게 수렴시키면 없는 구간까지 얇은 쐐기가 남는다. 횡단배수 연결
// 자동 계산이 생기기 전까지의 표현 규칙이다.
// 측구는 한쪽에만 있으면 **전환점(계획선-지반선 교차, 없으면 중간)에서
// 끊는다**(2026-08-23 사용자 확정). 횡단배수 연결 자동 계산이 생기기
// 전까지의 표현 규칙이다.
let points: OffsetPoint[];
if (kind === "ditch") {
// 측구는 **도로 끝에 얹는다** — 도로부는 한 몸이므로 위치를 따로 보간하지
@@ -599,25 +317,30 @@ export function buildCorridor(
offset_m: edge.offset_m + point.offset_m,
elevation_m: edge.elevation_m + point.elevation_m,
}));
} else if (kind === "cut" || kind === "fill") {
// 절·성토는 상대 측점의 축퇴점으로 모핑하지 않는다(2026-08-23 사용자:
// 완전 분리). 비탈 시작점 기준 **상대 좌표**를 보간해 두고, 아래 공통
// 블록에서 행 앵커(측구 끝/노견 끝)에 얹은 뒤 행 지반과 교차시켜 종결한다.
const relA = s0.slopeRelative.get(key);
const relB = s1.slopeRelative.get(key);
const relative =
relA && relB
? lerpPoints(relA, relB, t)
: (relA ?? relB)?.map((point) => ({ ...point }));
if (!relative || relative.length < 2) return;
points = relative;
} else {
const pa = s0.pieces.get(key) ?? degeneratePiece(s0, kind, side);
const pb = s1.pieces.get(key) ?? degeneratePiece(s1, kind, side);
// 절·성토가 한쪽 측점에만 있으면 **전환점에서 폭 0이 되도록** 보간 구간을
// 다시 잡는다. 구간 전체에 걸쳐 서서히 사라지던 것을, 실제로 절토가 끝나고
// 성토가 시작되는 자리에 맞춘다(2026-08-23 사용자 확정).
const taper =
(kind === "cut" || kind === "fill") && hasA !== hasB
? hasA
? Math.min(1, t / Math.max(1e-6, tSwitch))
: Math.max(0, (t - tSwitch) / Math.max(1e-6, 1 - tSwitch))
: t;
points = lerpPoints(pa, pb, taper);
const pa = s0.pieces.get(key);
const pb = s1.pieces.get(key);
if (!pa || !pb) return;
points = lerpPoints(pa, pb, t);
}
if (shift !== 0) applyProfileShift(points, kind, side, shift);
if (shift !== 0 && kind !== "cut" && kind !== "fill") applyProfileShift(points, shift);
sections.set(key, points);
});
// 비탈 안쪽 끝을 그 프레임의 도로부 바깥 끝(측구가 있으면 측구 끝, 없으면 노견
// 끝)에 붙인다 — 도로부를 한 몸으로 옮겼으니 비탈도 그 자리에서 시작해야 한다.
// 비탈(상대 좌표)을 그 의 도로부 바깥 끝(측구가 있으면 측구 끝, 없으면 노견
// 끝) 앵커에 얹고, 행 지반선과 직접 교차시켜 종결한다(2026-08-23 완전 분리).
const groundRow = mixedGround(s0.ground, s1.ground, t);
(["left", "right"] as const).forEach((side) => {
const ditchPoints = sections.get(pieceKey("ditch", side));
const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left;
@@ -632,24 +355,51 @@ export function buildCorridor(
elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t + shift,
};
(["cut", "fill"] as const).forEach((kind) => {
const slope = sections.get(pieceKey(kind, side));
const slopeKey = pieceKey(kind, side);
const slope = sections.get(slopeKey);
if (!slope || slope.length < 2) return;
const inner = side === "right" ? slope[slope.length - 1] : slope[0];
inner.offset_m = anchor.offset_m;
inner.elevation_m = anchor.elevation_m;
// 상대 좌표 → 앵커 절대 좌표(경사 형상 보존, 위치만 도로부를 따라간다).
slope.forEach((point) => {
point.offset_m += anchor.offset_m;
point.elevation_m += anchor.elevation_m;
});
if (!groundRow) return;
// 절토는 지반 위로 만나는 곳까지, 성토는 아래로 만나는 곳까지 — 지반이
// 반대편이면 이 행에 그 종류는 없다(조각 드롭 → 리본 자연 종료 = 분리).
const inner2outer = side === "right" ? [...slope].reverse() : slope;
const trimmed = trimSlopeToGround(inner2outer, kind, groundRow);
if (!trimmed) {
sections.delete(slopeKey);
return;
}
const rescaled = resample(trimmed, PIECE_COLS[kind]);
if (side === "right") rescaled.reverse();
sections.set(slopeKey, rescaled);
});
});
const lerpOuter = (a: OffsetPoint, b: OffsetPoint): OffsetPoint => ({
offset_m: a.offset_m + (b.offset_m - a.offset_m) * t,
elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t,
});
// 클리핑 외곽 = 이 행의 실제 점유 끝(비탈 → 측구 → 노견 순) — 리본과 클리핑
// 구멍을 행 단위로 일치시켜 서피스 상호 침범을 없앤다(2026-08-23).
const outerOf = (side: "left" | "right"): OffsetPoint => {
for (const kind of ["cut", "fill", "ditch"] as const) {
const piece = sections.get(pieceKey(kind, side));
if (piece && piece.length >= 2) {
return side === "right" ? piece[0] : piece[piece.length - 1];
}
}
const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left;
const edge1 = side === "right" ? s1.roadEdge.right : s1.roadEdge.left;
return {
offset_m: edge0.offset_m + (edge1.offset_m - edge0.offset_m) * t,
elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t,
};
};
rows.push({
chainage_m: chainage,
center,
left,
sections,
outerLeft: lerpOuter(s0.outer.left, s1.outer.left),
outerRight: lerpOuter(s0.outer.right, s1.outer.right),
outerLeft: outerOf("left"),
outerRight: outerOf("right"),
});
}
}
@@ -0,0 +1,390 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Station.ts
* 코리도 측점 분류 · 행 지반 트림 유틸 (Build에서 분리 — 700줄 제한).
*
* classifyStation: 측점 하나의 design_line을 종류별(차도/노견/측구/절·성토 비탈)
* 조각으로 자르고 리샘플한다. 계산식 재구현 금지 — 백엔드 design_line을 offset
* 경계로 분류만 한다.
* trimSlopeToGround: 비탈을 행 지반선과 직접 교차시켜 종결 — 절성토 완전 분리
* (2026-08-23 사용자 지시)의 핵심.
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
export type CorridorKind = "carriageway" | "shoulder" | "ditch" | "cut" | "fill";
export type CorridorSide = "left" | "right" | "center";
/** 조각별 고정 열 수 — 보간 시 t-리샘플 기준. 비탈은 무릎(2단 경사)까지 담게 넉넉히. */
export const PIECE_COLS: Record<CorridorKind, number> = {
carriageway: 3,
shoulder: 2,
ditch: 5,
cut: 9,
fill: 9,
};
export interface XY {
x: number;
y: number;
}
export interface OffsetPoint {
offset_m: number;
elevation_m: number;
}
/** 측점 하나의 분류·리샘플 결과 — (kind,side)별 colCount 고정 폴리라인. */
export interface StationPieces {
chainage_m: number;
center: XY;
left: XY;
/** kind:side 키 → 리샘플된 단면 점(offset/z). 없는 조각은 키 부재. */
pieces: Map<string, OffsetPoint[]>;
/** 축퇴 기준점 — 좌/우 road edge (조각이 없는 측점에서 리본 폭 0 수렴용). */
roadEdge: { left: OffsetPoint; right: OffsetPoint };
/** 마구리용 단면 — catch 사이 [offset, 설계z, 지반z] (시·종점에서만 쓴다). */
capSection: Array<[number, number, number]>;
/**
* 측구 조각을 **도로 끝(road edge) 기준 상대 좌표**로도 들고 있는다.
*
* 도로부(차도·노견·측구)는 한 몸이다 — 그런데 측구는 있는 측점과 없는 측점이
* 갈려서, 절대 좌표로 따로 보간하면 도로 끝은 다음 측점을 향해 내려가는데 측구만
* 제자리에 남아 최대 0.9m까지 벌어졌다(2026-08-23 실측). 상대 좌표로 들고 있다가
* 프레임의 도로 끝에 얹으면 무슨 일이 있어도 노견에 붙는다.
*/
ditchRelative: Map<string, OffsetPoint[]>;
/**
* 절·성토 비탈을 **비탈 시작점(도로부 바깥 끝) 기준 상대 좌표**로도 들고 있는다.
*
* 측구와 같은 규칙(도로부 한 몸 제어) — 행마다 도로부 끝 앵커에 얹으면 경사
* 형상(1:1.2 등)이 그대로 유지된 채 위치만 따라온다(2026-08-23 완전 분리).
*/
slopeRelative: Map<string, OffsetPoint[]>;
/** 지반선 보간기 — 행 지반선(두 측점 혼합) 계산용. 샘플 부실이면 null. */
ground: ((offset: number) => number) | null;
}
export function pieceKey(kind: CorridorKind, side: CorridorSide): string {
return `${kind}:${side}`;
}
/** design_line에서 [a,b] 구간 서브폴리라인 추출(경계점은 선형 보간으로 삽입). */
function slicePolyline(line: OffsetPoint[], a: number, b: number): OffsetPoint[] {
const lo = Math.min(a, b);
const hi = Math.max(a, b);
if (hi - lo < 1e-9 || line.length < 2) return [];
const zAt = (offset: number): number => {
if (offset <= line[0].offset_m) return line[0].elevation_m;
for (let i = 1; i < line.length; i += 1) {
const p0 = line[i - 1];
const p1 = line[i];
if (offset <= p1.offset_m + 1e-12) {
const span = p1.offset_m - p0.offset_m;
if (span <= 1e-12) return p1.elevation_m;
const t = (offset - p0.offset_m) / span;
return p0.elevation_m + (p1.elevation_m - p0.elevation_m) * t;
}
}
return line[line.length - 1].elevation_m;
};
const result: OffsetPoint[] = [{ offset_m: lo, elevation_m: zAt(lo) }];
for (const point of line) {
if (point.offset_m > lo + 1e-9 && point.offset_m < hi - 1e-9) result.push(point);
}
result.push({ offset_m: hi, elevation_m: zAt(hi) });
return result;
}
/** 폴리라인을 호길이 비례 t(0..1)로 cols개 점으로 리샘플 — 프레임 간 열 대응용. */
export function resample(points: OffsetPoint[], cols: number): OffsetPoint[] {
if (points.length === 0) return [];
if (points.length === 1) return Array.from({ length: cols }, () => ({ ...points[0] }));
const lengths: number[] = [0];
for (let i = 1; i < points.length; i += 1) {
const dOffset = points[i].offset_m - points[i - 1].offset_m;
const dz = points[i].elevation_m - points[i - 1].elevation_m;
lengths.push(lengths[i - 1] + Math.hypot(dOffset, dz));
}
const total = lengths[lengths.length - 1];
const result: OffsetPoint[] = [];
for (let c = 0; c < cols; c += 1) {
const target = total <= 1e-12 ? 0 : (total * c) / (cols - 1);
let index = 1;
while (index < points.length - 1 && lengths[index] < target) index += 1;
const p0 = points[index - 1];
const p1 = points[index];
const span = lengths[index] - lengths[index - 1];
const t = span <= 1e-12 ? 0 : (target - lengths[index - 1]) / span;
result.push({
offset_m: p0.offset_m + (p1.offset_m - p0.offset_m) * t,
elevation_m: p0.elevation_m + (p1.elevation_m - p0.elevation_m) * t,
});
}
return result;
}
/** 지반선 보간기 — samples(offset, elevation)로 지반고를 되짚는다. */
function groundSampler(section: CrossSection): ((offset: number) => number) | null {
const points = section.samples
.map((sample) => ({
offset_m: sample.offset_m,
elevation_m: sample.elevation_m ?? sample.z,
}))
.filter(
(p): p is OffsetPoint =>
p.offset_m !== undefined && p.elevation_m !== undefined && p.elevation_m !== null,
)
.sort((a, b) => a.offset_m - b.offset_m);
if (points.length < 2) return null;
return (offset: number): number => {
if (offset <= points[0].offset_m) return points[0].elevation_m;
for (let i = 1; i < points.length; i += 1) {
if (offset <= points[i].offset_m) {
const span = points[i].offset_m - points[i - 1].offset_m;
const t = span <= 1e-12 ? 0 : (offset - points[i - 1].offset_m) / span;
return points[i - 1].elevation_m + (points[i].elevation_m - points[i - 1].elevation_m) * t;
}
}
return points[points.length - 1].elevation_m;
};
}
/** 설계선-지반 허용차(m) — 이보다 가까우면 "지반에 닿았다"(catch)로 본다. */
export const CATCH_EPS_M = 0.005;
/**
* 비탈 시작점에서 바깥으로 스캔해 설계선이 지반과 만나는 catch point를 찾는다.
* design_line은 샘플 반폭 끝까지 이어지고 catch 밖은 지반을 그대로 따르므로,
* 여기서 잘라야 코리도·클리핑 폭이 실제 절·성토 점유 범위가 된다.
*/
function catchOffset(
line: OffsetPoint[],
groundAt: (offset: number) => number,
start: number,
direction: 1 | -1,
end: number,
): number {
const probes = line
.filter((p) => (direction > 0 ? p.offset_m > start + 1e-9 : p.offset_m < start - 1e-9))
.sort((a, b) => (a.offset_m - b.offset_m) * direction);
for (const probe of probes) {
if (Math.abs(probe.elevation_m - groundAt(probe.offset_m)) <= CATCH_EPS_M) {
return probe.offset_m;
}
}
return end; // 반폭 안에서 지반을 못 만남(깊은 절토·높은 성토) — 샘플 끝까지.
}
/** 측구 조각을 도로 끝 기준 상대 좌표로 바꾼다(도로부 한 몸 제어용). */
function relativeToRoadEdge(
pieces: Map<string, OffsetPoint[]>,
roadEdge: { left: OffsetPoint; right: OffsetPoint },
): Map<string, OffsetPoint[]> {
const relative = new Map<string, OffsetPoint[]>();
(["left", "right"] as const).forEach((side) => {
const key = pieceKey("ditch", side);
const points = pieces.get(key);
if (!points) return;
const edge = roadEdge[side];
relative.set(
key,
points.map((point) => ({
offset_m: point.offset_m - edge.offset_m,
elevation_m: point.elevation_m - edge.elevation_m,
})),
);
});
return relative;
}
/** 측점 하나를 종류별 조각으로 분류·리샘플. design 없거나 설계선 부실 → null. */
export function classifyStation(section: CrossSection): StationPieces | null {
const design = section.design;
const line = design?.design_line;
if (!design || !line || line.length < 2) return null;
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
const roadL = design.road_edges.left.offset_m;
const roadR = design.road_edges.right.offset_m;
const cwL = design.carriageway_edges?.left.offset_m ?? roadL;
const cwR = design.carriageway_edges?.right.offset_m ?? roadR;
// 측구 폭 — road edge에서 갭 없이 시작(엔진 ditch_points 규약).
const ditch = design.ditch;
const ditchEnabled = design.ditch_enabled ?? (ditch != null && ditch.type !== "none");
const ditchWidth =
!ditchEnabled || !ditch || ditch.type === "none"
? 0
: ditch.type === "standard"
? ditch.top_width_m
: ditch.width_m;
const ditchSide: "left" | "right" = design.ditch_side === "right" ? "right" : "left";
// 좌/우 비탈 종류 — 엔진이 echo한 resolved section_mode 기준.
const mode = design.section_mode;
const leftRole: CorridorKind = mode === "left_cut" || mode === "both_cut" ? "cut" : "fill";
const rightRole: CorridorKind = mode === "right_cut" || mode === "both_cut" ? "cut" : "fill";
const pieces = new Map<string, OffsetPoint[]>();
const put = (kind: CorridorKind, side: CorridorSide, a: number, b: number): void => {
const sub = slicePolyline(sorted, a, b);
if (sub.length >= 2) pieces.set(pieceKey(kind, side), resample(sub, PIECE_COLS[kind]));
};
put("carriageway", "center", cwR, cwL);
put("shoulder", "left", cwL, roadL);
put("shoulder", "right", roadR, cwR);
// 좌측: road edge → (측구) → 비탈끝. 우측은 대칭(음수 방향).
let slopeStartL = roadL;
let slopeStartR = roadR;
if (ditchWidth > 0) {
if (ditchSide === "left") {
put("ditch", "left", roadL, roadL + ditchWidth);
slopeStartL = roadL + ditchWidth;
} else {
put("ditch", "right", roadR - ditchWidth, roadR);
slopeStartR = roadR - ditchWidth;
}
}
// 비탈 끝 = catch point(설계선-지반 교차) — 그 밖은 설계선이 지반을 따라갈 뿐
// 절·성토 점유가 아니므로 코리도·클리핑에서 제외한다(2026-08-23 화면 검증 수정).
const ground = groundSampler(section);
const sampleEndL = sorted[sorted.length - 1].offset_m;
const sampleEndR = sorted[0].offset_m;
const endL = ground ? catchOffset(sorted, ground, slopeStartL, 1, sampleEndL) : sampleEndL;
const endR = ground ? catchOffset(sorted, ground, slopeStartR, -1, sampleEndR) : sampleEndR;
if (endL > slopeStartL + 1e-9) put(leftRole, "left", slopeStartL, endL);
if (endR < slopeStartR - 1e-9) put(rightRole, "right", endR, slopeStartR);
// 비탈 상대 좌표 — 안쪽(도로측) 끝점을 원점으로 둔다(행 앵커에 얹는 용도).
const slopeRelative = new Map<string, OffsetPoint[]>();
(["left", "right"] as const).forEach((side) => {
(["cut", "fill"] as const).forEach((kind) => {
const key = pieceKey(kind, side);
const points = pieces.get(key);
if (!points || points.length < 2) return;
const inner = side === "right" ? points[points.length - 1] : points[0];
slopeRelative.set(
key,
points.map((point) => ({
offset_m: point.offset_m - inner.offset_m,
elevation_m: point.elevation_m - inner.elevation_m,
})),
);
});
});
return {
chainage_m: section.chainage_m,
center: { x: section.center_x, y: section.center_y },
left: { x: section.frame.left_xy[0], y: section.frame.left_xy[1] },
pieces,
roadEdge: {
left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m },
right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m },
},
slopeRelative,
ground,
ditchRelative: relativeToRoadEdge(pieces, {
left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m },
right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m },
}),
// 마구리(시·종점 봉인)용 단면 — catch 사이 설계선 점마다 지반고를 짝지운다.
capSection: slicePolyline(sorted, endR, endL).map(
(point) =>
[
point.offset_m,
point.elevation_m,
ground ? ground(point.offset_m) : point.elevation_m,
] as [number, number, number],
),
};
}
/** 비탈 폭 하한(m) — 트림 후 이보다 좁으면 그 행에서 비탈이 없는 것으로 본다. */
export const SLOPE_MIN_WIDTH_M = 0.02;
/** 폴리라인 안에서 지반을 못 만날 때 마지막 구배로 연장하는 한계(m)·걸음(m). */
const EXTEND_MAX_M = 30;
const EXTEND_STEP_M = 0.25;
/** 두 측점 지반선을 t로 섞은 행 지반선 — 한쪽뿐이면 그쪽을 그대로 쓴다. */
export function mixedGround(
a: ((offset: number) => number) | null,
b: ((offset: number) => number) | null,
t: number,
): ((offset: number) => number) | null {
if (!a || !b) return a ?? b;
return (offset: number): number => a(offset) * (1 - t) + b(offset) * t;
}
/**
* 비탈 폴리라인(안→밖)을 행 지반선과 직접 교차시켜 종결한다(2026-08-23 사용자:
* 절성토 완전 분리 + 주변 데이터 반영).
* - 절토는 설계선이 지반 **아래**(파는 쪽)에서 시작해 위로 올라가 만나야 하고,
* 성토는 지반 **위**에서 시작해 내려가 만나야 한다. 시작부터 반대편이면 이 행에
* 그 종류는 없다 → null(호출부가 조각을 지워 리본이 끊긴다).
* - 폴리라인 안에서 지반을 통과하면 그 지점까지 자르고, 못 만나면 마지막 구배로
* 한 걸음씩 연장해 찾는다(EXTEND_MAX_M 한도 — 그래도 없으면 원래 끝 유지).
*/
export function trimSlopeToGround(
inner2outer: OffsetPoint[],
kind: "cut" | "fill",
groundAt: (offset: number) => number,
): OffsetPoint[] | null {
if (inner2outer.length < 2) return null;
// e = (설계 - 지반) × 부호: 유효 구간에서 양수, 지반 접점에서 0, 반대편이면 음수.
const sign = kind === "cut" ? -1 : 1;
const eOf = (point: OffsetPoint): number => (point.elevation_m - groundAt(point.offset_m)) * sign;
const first = inner2outer[0];
if (eOf(first) < -CATCH_EPS_M) return null;
const kept: OffsetPoint[] = [{ ...first }];
let prev = first;
let ePrev = eOf(first);
let closed = false;
for (let i = 1; i < inner2outer.length && !closed; i += 1) {
const point = inner2outer[i];
const eNow = eOf(point);
if (eNow > CATCH_EPS_M) {
kept.push({ ...point });
prev = point;
ePrev = eNow;
continue;
}
// 이 구간에서 지반 통과 — 교차 offset을 선형보간하고 z는 지반에 얹는다.
const ratio = ePrev - eNow <= 1e-12 ? 0 : ePrev / (ePrev - eNow);
const offset = prev.offset_m + (point.offset_m - prev.offset_m) * ratio;
kept.push({ offset_m: offset, elevation_m: groundAt(offset) });
closed = true;
}
if (!closed) {
// 깊은 절토·높은 성토가 단면 밖으로 나간 경우 — 마지막 구배로 연장해 지반을 찾는다.
const tail = inner2outer[inner2outer.length - 1];
const before = inner2outer[inner2outer.length - 2];
const runOffset = tail.offset_m - before.offset_m;
const runLength = Math.hypot(runOffset, tail.elevation_m - before.elevation_m);
if (Math.abs(runOffset) > 1e-9 && runLength > 1e-9) {
const stepOffset = (runOffset / runLength) * EXTEND_STEP_M;
const stepZ = ((tail.elevation_m - before.elevation_m) / runLength) * EXTEND_STEP_M;
let cursor = { ...tail };
let eCursor = eOf(cursor);
for (let step = 0; step * EXTEND_STEP_M < EXTEND_MAX_M; step += 1) {
const next = {
offset_m: cursor.offset_m + stepOffset,
elevation_m: cursor.elevation_m + stepZ,
};
const eNext = eOf(next);
if (eNext <= CATCH_EPS_M) {
const ratio = eCursor - eNext <= 1e-12 ? 0 : eCursor / (eCursor - eNext);
const offset = cursor.offset_m + stepOffset * ratio;
kept.push({ offset_m: offset, elevation_m: groundAt(offset) });
closed = true;
break;
}
cursor = next;
eCursor = eNext;
kept.push({ ...cursor });
}
}
}
const width = Math.abs(kept[kept.length - 1].offset_m - kept[0].offset_m);
if (kept.length < 2 || width < SLOPE_MIN_WIDTH_M) return null;
return kept;
}
+14 -2
View File
@@ -105,7 +105,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
latest.route.id,
currentSectionDetail,
latest.route_points,
profilePanel.alignmentSamples() ?? undefined,
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
renderStationLines(currentSectionDetail);
}
}, 500);
},
@@ -400,7 +403,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 얹힌 예상형상에 묻혀 측점 기준선이 안 보이던 문제(2026-08-23 사용자 지시).
const designAt = (chainageM: number): number | null => {
if (!corridorVisible) return null;
const samples = detail.longitudinal.design_profiles?.[0]?.samples;
// 편집 중에는 라이브 alignment 샘플이 정본보다 새것이다(2026-08-23 지적 ③).
const samples =
profilePanel.alignmentSamples() ?? detail.longitudinal.design_profiles?.[0]?.samples;
if (!samples?.length) return null;
let best = samples[0];
for (const sample of samples) {
@@ -454,7 +459,14 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지).
const routePoints = latest?.route_points ?? [];
if (routePoints.length > 1) {
refreshCorridor(viewer, activeProjectId, routeId ?? latest?.route?.id, detail, routePoints);
refreshCorridor(
viewer,
activeProjectId,
routeId ?? latest?.route?.id,
detail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
);
}
}
@@ -598,6 +598,9 @@ export function createRouteProfilePanel(
draw();
requestAnimationFrame(draw);
},
/** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23).
* 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */
alignmentSamples: () => alignment?.samples ?? null,
/**
* 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭).
* Page가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다.