diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 730670a5..3aaeebee 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -24,6 +24,8 @@ * ========================================================================== */ import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; +// 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04). +import { SectionGeometry, type ResolvedGroup } from "./common_util_cross_design_geometry"; /** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */ const GROUND_TYPE_PRESET: Record = { @@ -39,10 +41,6 @@ const DITCH_TYPES = ["standard", "l_type"]; /** 사면이 원지반과 만났다고 볼 높이차(m). 짝: `_SLOPE_CLOSE_TOLERANCE_M`. */ const SLOPE_CLOSE_TOLERANCE_M = 0.01; -/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ -const MARCH_STEP_M = 0.05; -const KNEE_MAX_M = 200; -const CROSS_MAX_M = 500; export interface CrossGroundSample { offset_m?: number | null; @@ -131,21 +129,6 @@ function asFloat(value: unknown, fallback: number): number { return parsed >= 0 ? parsed : fallback; } -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; -} - /** * 짝: `_resolve_group`. 파이썬은 config 위에 패널 편집값을 덮지만, 여기서는 이미 그렇게 * 합쳐진 한 벌(`standard`)을 받는다 — config 수치를 프론트에 복제하지 않기 위함이다. @@ -176,15 +159,6 @@ function resolveGroup(presetKey: string, standard: StandardCrossSectionSpec): Re }; } -/** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */ -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}`); -} - /** 짝: `_resolve_ditch_side`. */ function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefined): string { if (sectionMode === "left_cut") return "left"; @@ -220,303 +194,6 @@ function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) = }; } -/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */ -class SectionGeometry { - halfRoad: 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; - private rockKnee = 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; - }) { - const { group } = params; - const halfRoad = group.road_width_m / 2; - this.halfRoad = halfRoad; - this.leftExtent = halfRoad + group.shoulder_left_m; - this.rightExtent = halfRoad + 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.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; - } - - /** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */ - knee(side: string): [number, number] | null { - if (!this.twoStage) return null; - const cached = this.rockKnee.get(side); - if (cached !== undefined) return cached; - const [startDist, startZ] = this.slopeStart(side); - let diffPrev = startZ - this.rockBoundaryZ(side, startDist); - let result: [number, number] | null; - if (diffPrev >= 0) { - result = [startDist, startZ]; // 시작부터 토사(경계 위) - } else { - result = null; - let distPrev = startDist; - let dist = startDist + MARCH_STEP_M; - while (dist <= startDist + KNEE_MAX_M) { - const zRock = startZ + (dist - startDist) / this.cutRatio; - const diff = zRock - this.rockBoundaryZ(side, dist); - if (diff >= 0) { - const span = diff - diffPrev; - const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0; - const kneeDist = distPrev + (dist - distPrev) * ratio; - const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio; - result = [kneeDist, kneeZ]; - break; - } - distPrev = dist; - diffPrev = diff; - dist += MARCH_STEP_M; - } - } - this.rockKnee.set(side, result); - return result; - } - - /** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */ - private cutSlopeZ(side: string, dist: number): number { - const [startDist, startZ] = this.slopeStart(side); - const knee = this.twoStage ? this.knee(side) : null; - if (knee !== null) { - const [kneeDist, kneeZ] = knee; - if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio; - return kneeZ + (dist - kneeDist) / this.soilCutRatio; - } - return startZ + (dist - startDist) / this.cutRatio; - } - - /** 짝: `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, startZ] = this.slopeStart(side); - let dist = startDist; - const maxDist = startDist + CROSS_MAX_M; - while (dist <= maxDist) { - const signed = side === "left" ? dist : -dist; - const fillLine = startZ - (dist - startDist) / this.fillRatio; - 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 [startDist, startZ] = this.slopeStart(side); - const dist = Math.abs(offsetM); - const run = dist - startDist; - 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(startZ - run / this.fillRatio, groundM); - } - - /** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */ - breakpoints(): number[] { - const points = [0, this.leftExtent, -this.rightExtent]; - for (const [offset] of this.ditchPoints) points.push(offset); - if (this.twoStage) { - for (const side of ["left", "right"]) { - const role = side === "left" ? this.leftRole : this.rightRole; - const knee = role === "cut" ? this.knee(side) : null; - if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]); - } - } - 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; - } -} - /** * 짝: `compute_cross_design`. 측점 하나의 표준횡단 설계선과 절·성토 단면적을 낸다. * diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts new file mode 100644 index 00000000..466ead9b --- /dev/null +++ b/common_util/common_util_cross_design_geometry.ts @@ -0,0 +1,333 @@ +/* ============================================================================= + * common_util_cross_design_geometry.ts + * 표준횡단 단면 기하 — 노면·측구·사면 순으로 offset 의 설계고를 계산한다. + * + * ⚠ 짝 파일 `B06_Section/B06_Section_Engine_Design.py` 의 `_SectionGeometry` 와 한 벌이다. + * `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다. + * ========================================================================== */ + +/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ +const MARCH_STEP_M = 0.05; +const KNEE_MAX_M = 200; +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}`); +} + +/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */ +export class SectionGeometry { + halfRoad: 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; + private rockKnee = 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; + }) { + const { group } = params; + const halfRoad = group.road_width_m / 2; + this.halfRoad = halfRoad; + this.leftExtent = halfRoad + group.shoulder_left_m; + this.rightExtent = halfRoad + 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.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; + } + + /** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */ + knee(side: string): [number, number] | null { + if (!this.twoStage) return null; + const cached = this.rockKnee.get(side); + if (cached !== undefined) return cached; + const [startDist, startZ] = this.slopeStart(side); + let diffPrev = startZ - this.rockBoundaryZ(side, startDist); + let result: [number, number] | null; + if (diffPrev >= 0) { + result = [startDist, startZ]; // 시작부터 토사(경계 위) + } else { + result = null; + let distPrev = startDist; + let dist = startDist + MARCH_STEP_M; + while (dist <= startDist + KNEE_MAX_M) { + const zRock = startZ + (dist - startDist) / this.cutRatio; + const diff = zRock - this.rockBoundaryZ(side, dist); + if (diff >= 0) { + const span = diff - diffPrev; + const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0; + const kneeDist = distPrev + (dist - distPrev) * ratio; + const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio; + result = [kneeDist, kneeZ]; + break; + } + distPrev = dist; + diffPrev = diff; + dist += MARCH_STEP_M; + } + } + this.rockKnee.set(side, result); + return result; + } + + /** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */ + private cutSlopeZ(side: string, dist: number): number { + const [startDist, startZ] = this.slopeStart(side); + const knee = this.twoStage ? this.knee(side) : null; + if (knee !== null) { + const [kneeDist, kneeZ] = knee; + if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio; + return kneeZ + (dist - kneeDist) / this.soilCutRatio; + } + return startZ + (dist - startDist) / this.cutRatio; + } + + /** 짝: `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, startZ] = this.slopeStart(side); + let dist = startDist; + const maxDist = startDist + CROSS_MAX_M; + while (dist <= maxDist) { + const signed = side === "left" ? dist : -dist; + const fillLine = startZ - (dist - startDist) / this.fillRatio; + 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 [startDist, startZ] = this.slopeStart(side); + const dist = Math.abs(offsetM); + const run = dist - startDist; + 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(startZ - run / this.fillRatio, groundM); + } + + /** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */ + breakpoints(): number[] { + const points = [0, this.leftExtent, -this.rightExtent]; + for (const [offset] of this.ditchPoints) points.push(offset); + if (this.twoStage) { + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + const knee = role === "cut" ? this.knee(side) : null; + if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]); + } + } + 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; + } +}