diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts index a97adaa7..af223076 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts @@ -19,6 +19,8 @@ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import type { CorridorStructure, RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; import { assembleStripLoops, CarveFootprint, ringEdges } from "./B05_Profile_UI_Corridor_Carve"; +import { mixedGround, trimSlopeToGround } from "./B05_Profile_UI_Corridor_Station"; +import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; import type { PXY } from "./B05_Profile_UI_Corridor_Union"; export type PlanCurveSource = "structure" | "slope-deformed" | "slope-original"; @@ -40,7 +42,7 @@ export interface PlanCurve { const PLANE_LIFT_M = 5; /** 비탈 스트립 종방향 걸음(m). */ -const STEP_M = 1.0; +const STEP_M = 0.5; /** 안팎 폭이 이만큼은 돼야 커브를 만든다(m). */ const MIN_WIDTH_M = 0.05; @@ -62,6 +64,30 @@ interface PlanFrame { leftY: number; } +/** 누가거리 → 그 행의 지반선. 인접 두 측점 지반을 섞는다(코리도 빌드와 같은 방식). */ +function buildGroundAt( + crossSections: ReadonlyArray, +): (chainageM: number) => ((offset: number) => number) | null { + const rows = crossSections + .map((section) => ({ + chainage: section.chainage_m, + ground: groundInterpolator(section.samples), + })) + .sort((a, b) => a.chainage - b.chainage); + return (chainageM) => { + if (!rows.length) return null; + for (let i = 0; i < rows.length - 1; i += 1) { + const lo = rows[i]; + const hi = rows[i + 1]; + if (chainageM < lo.chainage || chainageM > hi.chainage) continue; + const span = hi.chainage - lo.chainage; + const t = span > 1e-6 ? (chainageM - lo.chainage) / span : 0; + return mixedGround(lo.ground, hi.ground, t); + } + return chainageM <= rows[0].chainage ? rows[0].ground : rows[rows.length - 1].ground; + }; +} + function at(frame: PlanFrame, offset: number): PXY { return { x: frame.cx + frame.leftX * offset, y: frame.cy + frame.leftY * offset }; } @@ -165,6 +191,7 @@ export function buildPlanCurves( outline: CorridorOutline, ): PlanCurve[] { const curves: PlanCurve[] = []; + const groundAt = buildGroundAt(crossSections); for (const section of crossSections) { const set = structures.filter( (structure) => Math.abs(structure.chainage_m - section.chainage_m) < 1e-6, @@ -213,30 +240,41 @@ export function buildPlanCurves( const side = silhouette.side; const sign = side === "left" ? 1 : -1; const roadEdge = roadEdgeOf(side); - // 변형 실루엣 — [노견 확장끝 → 실루엣 바깥끝]. + // 변형 실루엣 — [노견 확장끝 → 실루엣이 그 행 지반과 만나는 자리]. + // 실루엣은 소유 측점 단면 한 장이라 그대로 밀면 구간 내내 같은 폭이 된다 + // (2026-08-26 사용자 ②) — 행마다 지반선에 트림해 실제 발끝을 따라가게 한다. + // 서피스는 여전히 안 건드린다 — 표기 커브만 바뀐다. const inner = silhouette.widenTo?.offset_m ?? roadEdge; - let outer = inner; - for (const point of silhouette.points) { - if ((point.offset_m - outer) * sign > 0) outer = point.offset_m; - } const from = section.chainage_m - silhouette.span.beforeM; const to = section.chainage_m + silhouette.span.afterM; - if ((outer - inner) * sign > MIN_WIDTH_M) { - const steps: number[] = []; - for (let atM = from; atM < to; atM += STEP_M) steps.push(atM); - steps.push(to); - push( - "slope-deformed", - side, - stripLoops( - steps.map((atM) => { - const frame = frameOf(atM); - return [at(frame, inner), at(frame, outer)] as [PXY, PXY]; - }), - planZ, - ), - ); + // "auto" = 실루엣 가운데가 지반보다 높으면 성토, 낮으면 절토(패치 리본과 같은 판정). + let kind: "cut" | "fill" = silhouette.kind === "auto" ? "fill" : silhouette.kind; + if (silhouette.kind === "auto") { + const stationGround = groundInterpolator(section.samples); + const mid = silhouette.points[Math.floor(silhouette.points.length / 2)]; + if (stationGround && mid.elevation_m < stationGround(mid.offset_m) - 0.05) kind = "cut"; } + const baseZ = frameOf(section.chainage_m).designZ; + const steps: number[] = []; + for (let atM = from; atM < to; atM += STEP_M) steps.push(atM); + steps.push(to); + const deformedRows = steps.map((atM): [PXY, PXY] | null => { + const frame = frameOf(atM); + const ground = groundAt(atM); + if (!ground) return null; + // 종단 경사만큼 실루엣을 통째로 올리고 내린다 — 측점 단면은 그 측점 계획고 기준이다. + const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0; + const placed = silhouette.points.map((point) => ({ + offset_m: point.offset_m, + elevation_m: point.elevation_m + shift, + })); + const trimmed = trimSlopeToGround(placed, kind, ground); + if (!trimmed) return null; + const outer = trimmed[trimmed.length - 1].offset_m; + if ((outer - inner) * sign <= MIN_WIDTH_M) return null; + return [at(frame, inner), at(frame, outer)]; + }); + push("slope-deformed", side, stripLoops(deformedRows, planZ)); // 원본 성토·절토선 — [노견 끝 → 코리도 바깥 윤곽(catch)]. 구조물이 없다고 본 면이다. const rows: Array<[PXY, PXY] | null> = []; outline.chainages.forEach((chainageM, index) => {