diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index 71f1489d..643f84f1 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -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; + } } /** 캔버스를 벗어나도 잡고 있는 동안에는 회전을 이어 간다 — 놓을 때만 끝낸다. */ diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 6d0f2f20..5a095997 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 { 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)); } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 577ac197..450950a1 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -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 = { - 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; - /** 축퇴 기준점 — 좌/우 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; -} - -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, - roadEdge: { left: OffsetPoint; right: OffsetPoint }, -): Map { - const relative = new Map(); - (["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(); - 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"), }); } } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/B05_Profile_UI_Corridor_Station.ts new file mode 100644 index 00000000..96db78ba --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Station.ts @@ -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 = { + 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; + /** 축퇴 기준점 — 좌/우 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; + /** + * 절·성토 비탈을 **비탈 시작점(도로부 바깥 끝) 기준 상대 좌표**로도 들고 있는다. + * + * 측구와 같은 규칙(도로부 한 몸 제어) — 행마다 도로부 끝 앵커에 얹으면 경사 + * 형상(1:1.2 등)이 그대로 유지된 채 위치만 따라온다(2026-08-23 완전 분리). + */ + slopeRelative: Map; + /** 지반선 보간기 — 행 지반선(두 측점 혼합) 계산용. 샘플 부실이면 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, + roadEdge: { left: OffsetPoint; right: OffsetPoint }, +): Map { + const relative = new Map(); + (["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(); + 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(); + (["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; +} diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 39e58888..10f727d2 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -105,7 +105,10 @@ export async function renderB05Route(root: HTMLElement): Promise { 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 { // 얹힌 예상형상에 묻혀 측점 기준선이 안 보이던 문제(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 { // 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, + ); } } diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index d519b683..fd5e28f4 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -598,6 +598,9 @@ export function createRouteProfilePanel( draw(); requestAnimationFrame(draw); }, + /** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23). + * 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */ + alignmentSamples: () => alignment?.samples ?? null, /** * 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭). * Page가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다.