/* ============================================================================= * common_util_cross_design_geometry.ts * 표준횡단 단면 기하 — 노면·측구·사면 순으로 offset 의 설계고를 계산한다. * * ⚠ 짝 파일 `B06_Section/B06_Section_Engine_Design.py` 의 `_SectionGeometry` 와 한 벌이다. * `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다. * ========================================================================== */ import { type BermSpec, cutProfilePoints, elevationAt, fillProfilePoints, } from "./common_util_cross_berm"; /** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */ export interface CutSlopeSegment { side: string; ratio: number; rise_m: number; run_m: number; start_offset_m: number; end_offset_m: number; material: string | null; } /** 파이썬 `round(x, 4)` 와 같은 자리 맞춤. */ function round4(value: number): number { return Math.round(value * 10000) / 10000; } /** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ const MARCH_STEP_M = 0.05; const CROSS_MAX_M = 500; export interface ResolvedGroup { road_width_m: number; shoulder_left_m: number; shoulder_right_m: number; ditch_top_width_m: number; ditch_bottom_width_m: number; ditch_depth_m: number; l_ditch_width_m: number; l_ditch_depth_m: number; cross_slope_pct: number; fill_slope_ratio: number; cut_slope_ratio: number; pavement_thickness_m: number; } /** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */ export function sideRole(sectionMode: string): [string, string] { if (sectionMode === "left_cut") return ["cut", "fill"]; if (sectionMode === "right_cut") return ["fill", "cut"]; if (sectionMode === "both_cut") return ["cut", "cut"]; if (sectionMode === "both_fill") return ["fill", "fill"]; throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`); } /** * 곡선부 너비 확폭표 — **짝: `config_system_design.CURVE_WIDENING_TABLE_M`** * (별표2 Ⅰ.2.나.(4)). `[반경 하한, 반경 상한(미만), 확폭(m)]`이고 45m 이상은 확폭이 없다. * 두 벌이 한 세트이므로 값을 고칠 때는 파이썬 쪽도 함께 고친다. */ const CURVE_WIDENING_TABLE_M: ReadonlyArray = [ [10, 13, 2.25], [13, 14, 2.0], [14, 15, 1.75], [15, 18, 1.5], [18, 20, 1.25], [20, 25, 1.0], [25, 30, 0.75], [30, 40, 0.5], [40, 45, 0.25], ]; /** 확폭을 더한 뒤의 유효너비 상한(m) — 짝: `CURVE_WIDENING_MAX_WIDTH_M`. */ export const CURVE_WIDENING_MAX_WIDTH_M = 5.0; /** 평면 곡선반경으로 확폭량(m)을 정한다. 직선·45m 이상·값 없음은 0. */ export function curveWideningM(planRadiusM: number | null | undefined): number { if (planRadiusM === null || planRadiusM === undefined || !Number.isFinite(planRadiusM)) return 0; const found = CURVE_WIDENING_TABLE_M.find( ([low, high]) => planRadiusM >= low && planRadiusM < high, ); return found ? found[2] : 0; } /** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */ export class SectionGeometry { /** 규격 차도 반폭(확폭 전) — 수량·표기 기준. */ halfRoad: number; /** 좌(+)·우(−) 차도 반폭 — 곡선부 확폭이 **한쪽에만** 붙어 좌우가 갈린다(2026-09-06). */ halfRoadLeft: number; halfRoadRight: number; leftExtent: number; rightExtent: number; zCenter: number; cutRatio: number; fillRatio: number; leftRole: string; rightRole: string; ditchSide: string; soilCutRatio: number; twoStage: boolean; ditchType: string; slopePerOffset: number; hasDitch: boolean; ditchPoints: Array<[number, number]> = []; private groundAt: ((offsetM: number) => number) | null; private rockOffset: number; /** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */ berm: BermSpec | null = null; private cutPointsCache = new Map>(); private fillPointsCache = new Map>(); private cutCross = new Map(); private fillCross = new Map(); constructor(params: { designElevationM: number; group: ResolvedGroup; sectionMode: string; ditchSide: string; ditchType: string; crossSlopePct: number; groundAt: ((offsetM: number) => number) | null; soilCutRatio: number | null; rockBoundaryOffsetM: number | null; twoStageSlope: boolean; ditchEnabled: boolean | null; /** 곡선부 확폭(m) — 붙는 쪽만 값이 있고 반대쪽은 0이다. */ wideningLeftM?: number; wideningRightM?: number; /** 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ berm?: BermSpec | null; }) { const { group } = params; const halfRoad = group.road_width_m / 2; this.halfRoad = halfRoad; this.halfRoadLeft = halfRoad + Math.max(params.wideningLeftM ?? 0, 0); this.halfRoadRight = halfRoad + Math.max(params.wideningRightM ?? 0, 0); this.leftExtent = this.halfRoadLeft + group.shoulder_left_m; this.rightExtent = this.halfRoadRight + group.shoulder_right_m; this.zCenter = params.designElevationM; this.cutRatio = Math.max(group.cut_slope_ratio, 1e-6); this.fillRatio = Math.max(group.fill_slope_ratio, 1e-6); [this.leftRole, this.rightRole] = sideRole(params.sectionMode); this.ditchSide = params.ditchSide; this.soilCutRatio = Math.max(params.soilCutRatio ?? group.cut_slope_ratio, 1e-6); this.twoStage = Boolean( params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null, ); this.groundAt = params.groundAt; this.berm = params.berm ?? null; this.rockOffset = params.rockBoundaryOffsetM ?? 0; this.ditchType = params.ditchType; // 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). const slope = params.crossSlopePct / 100; this.slopePerOffset = params.ditchSide === "left" ? -slope : slope; // 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토. const groundAt = params.groundAt; if (groundAt !== null) { this.leftRole = groundAt(this.leftExtent) > this.roadZ(this.leftExtent) + 1e-3 ? "cut" : "fill"; this.rightRole = groundAt(-this.rightExtent) > this.roadZ(-this.rightExtent) + 1e-3 ? "cut" : "fill"; } // 측구 생성 여부(D-1). if (params.sectionMode === "both_fill") { this.hasDitch = false; } else if (params.ditchEnabled !== null && params.ditchEnabled !== undefined) { this.hasDitch = params.ditchEnabled; } else if (groundAt !== null) { const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; this.hasDitch = groundAt(ditchEdge) > this.roadZ(ditchEdge) + 1e-3; } else { this.hasDitch = true; } // 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; const outward = params.ditchSide === "left" ? 1 : -1; const edgeZ = this.roadZ(edgeOffset); if (this.hasDitch) { if (params.ditchType === "l_type") { // L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽. const width = group.l_ditch_width_m; const depth = group.l_ditch_depth_m; this.ditchPoints = [ [edgeOffset, edgeZ], [edgeOffset + outward * width, edgeZ - depth], [edgeOffset + outward * width, edgeZ], ]; } else { // 일반: 상단폭/저폭/깊이 사다리꼴. const top = group.ditch_top_width_m; const bottom = Math.min(group.ditch_bottom_width_m, top); const depth = group.ditch_depth_m; const inset = (top - bottom) / 2; this.ditchPoints = [ [edgeOffset, edgeZ], [edgeOffset + outward * inset, edgeZ - depth], [edgeOffset + outward * (inset + bottom), edgeZ - depth], [edgeOffset + outward * top, edgeZ], ]; } } } /** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */ roadZ(offsetM: number): number { return this.zCenter + this.slopePerOffset * offsetM; } /** 짝: `_slope_start`. 사면 시작점 [오프셋 절대값 거리, 표고]. */ private slopeStart(side: string): [number, number] { const edgeOffset = side === "left" ? this.leftExtent : this.rightExtent; const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent); if (side === this.ditchSide && this.ditchPoints.length) { const outer = this.ditchPoints[this.ditchPoints.length - 1]; return [Math.abs(outer[0]), outer[1]]; } return [edgeOffset, edgeZ]; } /** 짝: `_rock_boundary_z`. 암반 경계선 표고 = 지반선 + 오프셋(음수=하향). */ private rockBoundaryZ(side: string, dist: number): number { const signed = side === "left" ? dist : -dist; return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset; } /** 짝: `cut_points`. 절토 사면 꼭짓점 — 무릎과 소단이 모두 여기 들어 있다. */ cutPoints(side: string): Array<[number, number]> { const cached = this.cutPointsCache.get(side); if (cached !== undefined) return cached; const [startDist, startZ] = this.slopeStart(side); const boundary = this.twoStage ? (dist: number) => this.rockBoundaryZ(side, dist) : null; const points = cutProfilePoints( startDist, startZ, this.cutRatio, this.soilCutRatio, boundary, this.berm, undefined, // 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07). this.berm !== null, ); this.cutPointsCache.set(side, points); return points; } /** 짝: `_cut_slope_z`. 절토 사면선 표고(무릎·소단 반영, 지반 클램프 없음). */ private cutSlopeZ(side: string, dist: number): number { return elevationAt(this.cutPoints(side), dist); } /** 짝: `fill_points`. 성토 사면 꼭짓점 — 소단이 들어 있다(무릎은 없다). */ fillPoints(side: string): Array<[number, number]> { const cached = this.fillPointsCache.get(side); if (cached !== undefined) return cached; const [startDist, startZ] = this.slopeStart(side); const points = fillProfilePoints(startDist, startZ, this.fillRatio, this.berm); this.fillPointsCache.set(side, points); return points; } /** 짝: `_fill_slope_z`. 성토 사면선 표고(소단 반영, 지반 클램프 없음). */ private fillSlopeZ(side: string, dist: number): number { return elevationAt(this.fillPoints(side), dist); } /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ cutCrossDist(side: string): number | null { const cached = this.cutCross.get(side); if (cached !== undefined) return cached; let result: number | null = null; if (this.groundAt !== null) { const [startDist] = this.slopeStart(side); let dist = startDist; const maxDist = startDist + CROSS_MAX_M; while (dist <= maxDist) { const signed = side === "left" ? dist : -dist; if (this.cutSlopeZ(side, dist) - this.groundAt(signed) >= 0) { result = dist; break; } dist += MARCH_STEP_M; } } this.cutCross.set(side, result); return result; } /** 짝: `fill_cross_dist`. 성토 사면이 지반선과 처음 만나는 거리. */ fillCrossDist(side: string): number | null { const cached = this.fillCross.get(side); if (cached !== undefined) return cached; let result: number | null = null; if (this.groundAt !== null) { const [startDist] = this.slopeStart(side); let dist = startDist; const maxDist = startDist + CROSS_MAX_M; while (dist <= maxDist) { const signed = side === "left" ? dist : -dist; const fillLine = this.fillSlopeZ(side, dist); if (fillLine - this.groundAt(signed) <= 0) { result = dist; break; } dist += MARCH_STEP_M; } } this.fillCross.set(side, result); return result; } /** 짝: `fill_ground_slope`. 성토측 자연 지반 평균 경사(rise/run). */ fillGroundSlope(): number | null { if (this.groundAt === null) return null; const slopes: number[] = []; for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; if (role !== "fill") continue; const [startDist] = this.slopeStart(side); const endDist = this.fillCrossDist(side) ?? startDist + 10; const run = endDist - startDist; if (run <= 1e-6) continue; const sign = side === "left" ? 1 : -1; const rise = Math.abs(this.groundAt(sign * endDist) - this.groundAt(sign * startDist)); slopes.push(rise / run); } return slopes.length ? Math.min(...slopes) : null; } /** 짝: `design_z`. offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종). */ designZ(offsetM: number, groundM: number): number { const side = offsetM >= 0 ? "left" : "right"; const extent = side === "left" ? this.leftExtent : this.rightExtent; if (Math.abs(offsetM) <= extent + 1e-9) return this.roadZ(offsetM); // 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). if (side === this.ditchSide && this.ditchPoints.length) { const inner = Math.abs(this.ditchPoints[0][0]); const outer = Math.abs(this.ditchPoints[this.ditchPoints.length - 1][0]); if (Math.abs(offsetM) >= inner - 1e-9 && Math.abs(offsetM) <= outer + 1e-9) { const points = this.ditchPoints; for (let index = 1; index < points.length; index += 1) { const x0 = Math.abs(points[index - 1][0]); const z0 = points[index - 1][1]; const x1 = Math.abs(points[index][0]); const z1 = points[index][1]; if (Math.abs(offsetM) > x1 + 1e-9) continue; const span = x1 - x0; if (span <= 1e-9) return z1; return z0 + (z1 - z0) * ((Math.abs(offsetM) - x0) / span); } return points[points.length - 1][1]; } } const role = side === "left" ? this.leftRole : this.rightRole; const dist = Math.abs(offsetM); if (role === "cut") { const cross = this.cutCrossDist(side); if (cross !== null && dist >= cross) return groundM; return Math.min(this.cutSlopeZ(side, dist), groundM); } const cross = this.fillCrossDist(side); if (cross !== null && dist >= cross) return groundM; return Math.max(this.fillSlopeZ(side, dist), groundM); } /** * 짝: `cut_slope_segments`. 절토 사면을 **경사 구간별로** 쪼갠 목록. * ⚠ 지금 읽는 곳은 없다(2026-09-07, 법정 검사 폐기). 소단 기하가 이 셈 위에 선다. * * 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 위반이 사라진 것처럼 * 보인다. 검사는 소단을 뺀 **사면 구간 자체**를 봐야 하므로 그 구간을 내보낸다. */ cutSlopeSegments(): CutSlopeSegment[] { const segments: CutSlopeSegment[] = []; for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; if (role !== "cut") continue; const cross = this.cutCrossDist(side); const points = this.cutPoints(side); const sign = side === "left" ? 1 : -1; for (let index = 1; index < points.length; index += 1) { const [startD, startZ] = points[index - 1]; let [endD, endZ] = points[index]; if (cross !== null && startD >= cross - 1e-9) break; // 지반과 만난 뒤는 절토가 없다 if (cross !== null && endD > cross) { endZ = elevationAt(points, cross); endD = cross; } const run = endD - startD; const rise = endZ - startZ; if (run <= 1e-9 || rise <= 1e-6) continue; if (this.berm !== null && Math.abs(run - this.berm.widthM) < 1e-6) { const bermRise = Math.tan((this.berm.slopeDeg * Math.PI) / 180) * this.berm.widthM; if (Math.abs(rise - bermRise) < 1e-9) continue; // 소단(평탄부) } // 재료는 **그 구간을 실제로 그린 경사비**로 가른다(짝 파이썬과 같은 까닭) — // 경계선을 다시 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다. let material: string | null = null; if (this.twoStage && Math.abs(this.soilCutRatio - this.cutRatio) > 1e-9) { const drawn = run / rise; material = Math.abs(drawn - this.soilCutRatio) < Math.abs(drawn - this.cutRatio) ? "soil" : "rock"; } segments.push({ side, ratio: round4(run / rise), rise_m: round4(rise), run_m: round4(run), start_offset_m: round4(sign * startD), end_offset_m: round4(sign * endD), material, }); } } return segments; } /** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */ breakpoints(): number[] { const points = [0, this.leftExtent, -this.rightExtent]; for (const [offset] of this.ditchPoints) points.push(offset); // 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. if (this.twoStage || this.berm !== null) { for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; if (role !== "cut") continue; const cross = this.cutCrossDist(side); for (const [offset] of this.cutPoints(side)) { if (cross !== null && offset > cross + 1e-9) break; // 지반과 만난 뒤는 절토가 없다 points.push(side === "left" ? offset : -offset); } } } // 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다. if (this.berm !== null) { for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; if (role !== "fill") continue; const cross = this.fillCrossDist(side); for (const [offset] of this.fillPoints(side)) { if (cross !== null && offset > cross + 1e-9) break; points.push(side === "left" ? offset : -offset); } } } for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side); if (cross !== null) points.push(side === "left" ? cross : -cross); } return points; } }