/* ============================================================================= * common_util_cross_design.ts * B06 측점 표준횡단 설계 계산 — **브라우저 판**. * * ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠ * 짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`) * 면적 적분은 `common_util_cross_design_areas.ts` ↔ `B06_Section_Engine_Areas.py`. * 회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다: * `tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것. * * ── 왜 같은 계산이 두 벌인가 (2026-09-03 사용자 확정) ──────────────── * 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야 한다**. 조작은 세션 * 캐시에 쌓이고 화면은 즉시 따라오며, 서버는 [저장]·[확정]에서만 부른다. 계획고가 바뀔 * 때마다 서버에 전 측점 횡단을 물으면 왕복이 조작 속도를 지배한다(2026-09-03 실측 보고). * 계획선 선형(`B05_Profile_Engine_Grade_Alignment.py` ↔ `B05_Profile_UI_Profile_Alignment.ts`) * 이 이미 같은 이유로 1:1 미러다. * * ── 갈라지지 않게 하는 규칙 ────────────────────────────────────────── * 1. **config 상수를 여기에 복제하지 않는다.** 표준단면 수치는 서버가 config 에서 내려 * 주는 `sections/context.standard_cross_section` 을 입력으로 받는다. 아래 상수는 * config 의 *열거값*(지반유형→프리셋 등)뿐이며 그마저 짝 파일과 나란히 둔다. * 2. 반올림·경계 판정 상수까지 파이썬과 같은 값을 쓴다. * 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다. * ========================================================================== */ import type { BermSpec } from "./common_util_cross_berm"; import type { SpoilFillSection } from "./common_util_spoil_fill"; import { spoilFillSection } from "./common_util_spoil_fill"; import { benchCutLength, fillAreaBeyond, splitCutAreas, splitDitchArea, trapezoidAreas, } from "./common_util_cross_design_areas"; // 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04). import { CURVE_WIDENING_MAX_WIDTH_M, type CutSlopeSegment, SectionGeometry, curveWideningM, type ResolvedGroup, } from "./common_util_cross_design_geometry"; /** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */ const GROUND_TYPE_PRESET: Record = { soil: "soil", ripping_rock: "rock", blasting_rock: "rock", }; /** 단면유형. 짝: config `SECTION_MODES`. 좌=양(+)offset, 우=음(-)offset. */ const SECTION_MODES = ["left_cut", "right_cut", "both_cut", "both_fill"]; /** 짝: config `SECTION_DITCH_SIDES` / `SECTION_DITCH_TYPES`. */ const DITCH_SIDES = ["left", "right"]; const DITCH_TYPES = ["standard", "l_type"]; /** 사면이 원지반과 만났다고 볼 높이차(m). 짝: `_SLOPE_CLOSE_TOLERANCE_M`. */ const SLOPE_CLOSE_TOLERANCE_M = 0.01; export interface CrossGroundSample { offset_m?: number | null; elevation_m?: number | null; valid?: boolean; } /** `sections/context.standard_cross_section` 한 그룹의 모양(서버가 config 에서 내려 준다). */ export interface StandardGroupSpec { road_width_m?: number; shoulder_left_m?: number; shoulder_right_m?: number; ditch?: { top_width_m?: number; bottom_width_m?: number; depth_m?: number }; ditch_l_type?: { width_m?: number; depth_m?: number }; cross_slope_pct?: { min?: number; max?: number }; fill_slope_ratio?: number; cut_slope_ratio?: number; pavement_thickness_m?: number; } /** 프리셋 키(soil/rock/paved) → 그룹 값. 세션 편집값도 같은 모양이다. */ export type StandardCrossSectionSpec = Record; export interface CrossDesignOptions { groundType: string; sectionMode: string; ditchSide?: string | null; ditchType?: string; paved?: boolean; /** 표준단면 수치(필수) — config 기본값 또는 그 위에 얹은 세션 편집값. */ standard: StandardCrossSectionSpec; rockBoundaryOffsetM?: number | null; twoStageSlope?: boolean; /** 이 측점만 쓰는 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값. 없으면 표준값. * 짝: 파이썬 `compute_cross_design(cut_slope_ratio=…)`. */ cutSlopeRatio?: number | null; ditchEnabled?: boolean | null; /** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */ surfaceDropM?: number; /** 이 측점의 평면 곡선반경(m) — 곡선부 확폭을 정하는 입력. 직선이면 null. */ planRadiusM?: number | null; /** 곡선 **바깥쪽**("left"/"right") — 확폭이 붙는 쪽(2026-09-06 사용자 확정). */ curveOuterSide?: "left" | "right" | null; /** 저장된 확폭량(m) — 곡선 앞뒤 테이퍼가 얹힌 값. 있으면 반경 표값 대신 쓴다. */ curveWideningM?: number | null; /** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ berm?: BermSpec | null; /** * 이 측점에 선 유용토운반작업장(구 사토장). 폭은 **노면 끝**(노견이 시작하는 자리)에서 * 재고, 그 바깥 성토는 **노선 몫이 아니라 사토장 몫**이라 `fill_area_m2` 에서 뺀다 * (2026-09-09 확정 ㉠ — 두 번 세지 않기). `slopeRatioN` 이 비면 노선 성토 기울기를 쓴다. * 짝: 파이썬 `compute_cross_design(spoil_fill=…)`. */ spoilFill?: { side: "left" | "right"; widthM: number; slopeRatioN?: number | null } | null; } export interface CrossDesignEdge { offset_m: number; elevation_m: number; } export interface CrossDesignResult { ground_type: string; geometry_preset: string; section_mode: string; ditch_side: string; ditch_type: string | null; cut_slope_ratio: number; soil_cut_slope_ratio: number; two_stage_slope: boolean; fill_slope_ratio: number; roadbed_width_m: number; carriageway_width_m: number; /** 규격 차도 폭(확폭 전, m) — 확폭 라벨·수량이 둘을 나눠 쓴다(2026-09-06). */ carriageway_standard_width_m?: number; /** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */ widening_left_m?: number; widening_right_m?: number; cross_slope_pct: number; ditch: Record; ditch_enabled: boolean; paved: boolean; road_edges: { left: CrossDesignEdge; right: CrossDesignEdge }; carriageway_edges: { left: CrossDesignEdge; right: CrossDesignEdge }; design_elevation_m: number; cut_area_m2: number; cut_soil_area_m2: number; cut_rock_area_m2: number; cut_rock_kind: string | null; fill_area_m2: number; /** 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). */ bench_cut_length_m: number; /** 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다). */ spoil_fill_area_m2: number; spoil_fill_side: "left" | "right" | null; spoil_fill_width_m: number; spoil_fill_max_width_m: number; spoil_fill_line: CrossDesignEdge[]; spoil_fill_unclosed: boolean; /** 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. */ spoil_fill_replaced_fill_m2: number; slope_unclosed: boolean; fill_ground_slope: number | null; ditch_area_m2: number; /** 측구터파기 내역(합=ditch_area_m2)과 가른 근거. 짝: 파이썬 `ditch_split_basis`. */ ditch_soil_area_m2: number; ditch_rock_area_m2: number; ditch_split_basis: string; design_line: CrossDesignEdge[]; /** 절토 사면 경사 구간(소단 제외). 짝: `cut_slope_segments`. * ⚠ **지금 읽는 곳은 없다**(2026-09-07) — 임자였던 별표2 검사는 폐기됐고 저장분에도 * 안 들어간다. 소단 기하가 이 셈 위에 서 있어 남겨 둔다. 되살릴 때는 **계산해서** 쓸 것. */ cut_slope_segments: CutSlopeSegment[]; /** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */ berm?: { width_m: number; interval_m: number; slope_deg: number }; surface_drop_m?: number; pavement_thickness_m?: number; rock_boundary_offset_m?: number; } /** 짝: 파이썬 `round(value, 4)`. */ function round4(value: number): number { return Math.round(value * 1e4) / 1e4; } /** 짝: `_as_float` — 손상값·음수면 fallback. */ function asFloat(value: unknown, fallback: number): number { const parsed = typeof value === "number" ? value : Number(value); if (!Number.isFinite(parsed)) return fallback; return parsed >= 0 ? parsed : fallback; } /** * 짝: `_resolve_group`. 파이썬은 config 위에 패널 편집값을 덮지만, 여기서는 이미 그렇게 * 합쳐진 한 벌(`standard`)을 받는다 — config 수치를 프론트에 복제하지 않기 위함이다. */ function resolveGroup(presetKey: string, standard: StandardCrossSectionSpec): ResolvedGroup { const group = standard[presetKey]; if (!group) { throw new Error(`표준횡단 설정에 '${presetKey}' 그룹이 없어 횡단 설계를 계산할 수 없습니다.`); } const ditch = group.ditch ?? {}; const lDitch = group.ditch_l_type ?? {}; const slope = group.cross_slope_pct ?? {}; return { road_width_m: asFloat(group.road_width_m, 0), shoulder_left_m: asFloat(group.shoulder_left_m, 0), shoulder_right_m: asFloat(group.shoulder_right_m, 0), ditch_top_width_m: asFloat(ditch.top_width_m, 0), ditch_bottom_width_m: asFloat(ditch.bottom_width_m, 0), ditch_depth_m: asFloat(ditch.depth_m, 0), // 짝 파일의 `base.get("width_m", 0.5)`와 같은 최후 기본값. l_ditch_width_m: asFloat(lDitch.width_m, 0.5), l_ditch_depth_m: asFloat(lDitch.depth_m, 0.1), // 횡단경사는 범위(min~max) 중 하한을 기본 채택한다(도면 표기 앞값). cross_slope_pct: asFloat(slope.min, 0), fill_slope_ratio: asFloat(group.fill_slope_ratio, 0), cut_slope_ratio: asFloat(group.cut_slope_ratio, 0), pavement_thickness_m: asFloat(group.pavement_thickness_m, 0.2), }; } /** 짝: `_resolve_ditch_side`. */ function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefined): string { if (sectionMode === "left_cut") return "left"; if (sectionMode === "right_cut") return "right"; if (ditchSide && DITCH_SIDES.includes(ditchSide)) return ditchSide; return "left"; } /** * 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프). * * 짝 파일은 앞에서부터 훑지만 여기서는 이진탐색으로 같은 구간을 고른다 — 사면·지반 * 교차 행진이 이 함수를 수만 번 부르는데 브라우저에서는 그 비용이 조작 속도에 바로 * 드러나기 때문이다. **결과는 완전히 같다**(같은 구간, 같은 선형보간). */ function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) => number { const last = valid.length - 1; return (offsetM: number): number => { if (offsetM <= valid[0][0]) return valid[0][1]; if (offsetM >= valid[last][0]) return valid[last][1]; let low = 1; let high = last; while (low < high) { const mid = (low + high) >> 1; if (valid[mid][0] < offsetM) low = mid + 1; else high = mid; } const [x1, z1] = valid[low]; const [x0, z0] = valid[low - 1]; const span = x1 - x0; if (span <= 0) return z1; return z0 + (z1 - z0) * ((offsetM - x0) / span); }; } /** * 짝: `compute_cross_design`. 측점 하나의 표준횡단 설계선과 절·성토 단면적을 낸다. * * `samples` 는 지반선 원시 샘플, `designElevationM` 은 중심선 계획고(노면고)다. * 계산 불가(계획고 없음·샘플 부족·잘못된 유형)면 던진다 — 호출부가 그 측점을 건너뛴다. */ export function computeCrossDesign( samples: CrossGroundSample[], designElevationM: number | null | undefined, options: CrossDesignOptions, ): CrossDesignResult { const groundType = options.groundType; const sectionMode = options.sectionMode; const ditchType = options.ditchType ?? "standard"; const paved = Boolean(options.paved); if (!(groundType in GROUND_TYPE_PRESET)) { throw new Error(`지원하지 않는 지반유형입니다: ${groundType}`); } if (!SECTION_MODES.includes(sectionMode)) { throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`); } if (!DITCH_TYPES.includes(ditchType)) { throw new Error(`지원하지 않는 측구 형식입니다: ${ditchType}`); } if (designElevationM === null || designElevationM === undefined) { throw new Error("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다."); } const drop = Math.max(options.surfaceDropM ?? 0, 0); const centerElevation = designElevationM - drop; const presetKey = GROUND_TYPE_PRESET[groundType]; if (ditchType === "l_type" && presetKey !== "rock") { throw new Error("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다."); } let group = resolveGroup(presetKey, options.standard); // 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다. // ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 값만 베껴 // 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다. const userCutRatio = options.cutSlopeRatio; if (typeof userCutRatio === "number" && Number.isFinite(userCutRatio) && userCutRatio > 0) { group = { ...group, cut_slope_ratio: userCutRatio }; } const pavedGroup = resolveGroup("paved", options.standard); // 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct; const resolvedDitchSide = resolveDitchSide(sectionMode, options.ditchSide); const valid: Array<[number, number]> = samples .filter( (sample) => sample.valid !== false && sample.offset_m !== null && sample.offset_m !== undefined && sample.elevation_m !== null && sample.elevation_m !== undefined, ) .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]) .sort((a, b) => a[0] - b[0]); if (valid.length < 2) { throw new Error("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다."); } const groundAt = groundInterpolator(valid); const rockBoundaryOffsetM = options.rockBoundaryOffsetM ?? null; // 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다. const enableTwoStage = presetKey === "rock" && (options.twoStageSlope ?? true) && rockBoundaryOffsetM !== null; // 곡선부 확폭 — 표는 하한이고, 확폭을 더한 유효너비가 법정 상한(5m)을 넘지 않게 자른다. // 짝: 파이썬 `compute_cross_design`. 표·상한 값은 config 한 곳에서 온다. const outerSide = options.curveOuterSide; // 저장된 확폭량(테이퍼 포함)이 있으면 그것을 쓰고, 없으면 반경 표값으로 되돌아간다. let widening = outerSide === "left" || outerSide === "right" ? typeof options.curveWideningM === "number" && Number.isFinite(options.curveWideningM) ? options.curveWideningM : curveWideningM(options.planRadiusM) : 0; if (widening > 0) { widening = Math.min(widening, Math.max(CURVE_WIDENING_MAX_WIDTH_M - group.road_width_m, 0)); } const geometry = new SectionGeometry({ designElevationM: centerElevation, wideningLeftM: outerSide === "left" ? widening : 0, wideningRightM: outerSide === "right" ? widening : 0, group, sectionMode, ditchSide: resolvedDitchSide, ditchType, crossSlopePct, groundAt, soilCutRatio: resolveGroup("soil", options.standard).cut_slope_ratio, rockBoundaryOffsetM, twoStageSlope: enableTwoStage, ditchEnabled: options.ditchEnabled ?? null, berm: options.berm ?? null, }); // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). const minOffset = valid[0][0]; const maxOffset = valid[valid.length - 1][0]; const mergedSet = new Set(valid.map(([offset]) => round6(offset))); for (const point of geometry.breakpoints()) { if (point >= minOffset && point <= maxOffset) mergedSet.add(round6(point)); } const merged = [...mergedSet].sort((a, b) => a - b); const offsets: number[] = []; const grounds: number[] = []; const diffs: number[] = []; const designLine: CrossDesignEdge[] = []; for (const offsetM of merged) { const groundM = groundAt(offsetM); const designZ = geometry.designZ(offsetM, groundM); offsets.push(offsetM); grounds.push(groundM); diffs.push(groundM - designZ); designLine.push({ offset_m: round4(offsetM), elevation_m: round4(designZ) }); } // 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음). const [cutArea, baseFillArea] = trapezoidAreas(offsets, diffs); let fillArea = baseFillArea; // 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이. const benchCut = benchCutLength(offsets, grounds, diffs); const fillGroundSlope = geometry.fillGroundSlope(); const slopeUnclosed = diffs.length > 0 && (Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M || Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M); // 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: 파이썬 `compute_cross_design`. // ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠). const spoilSide = options.spoilFill?.side ?? null; const spoilWidth = Math.max(Number(options.spoilFill?.widthM ?? 0), 0); let spoilSection: SpoilFillSection | null = null; let spoilReplaced = 0; if ((spoilSide === "left" || spoilSide === "right") && spoilWidth > 0) { const spoilX0 = spoilSide === "left" ? geometry.halfRoadLeft : -geometry.halfRoadRight; const askedRatio = Number(options.spoilFill?.slopeRatioN ?? 0); const spoilRatio = askedRatio > 0 ? askedRatio : geometry.fillRatio; spoilSection = spoilFillSection({ ground: valid.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), startOffsetM: spoilX0, startElevationM: geometry.roadZ(spoilX0), side: spoilSide, widthM: spoilWidth, slopeRatioN: spoilRatio, }); spoilReplaced = fillAreaBeyond(offsets, diffs, spoilX0, spoilSide); fillArea = Math.max(fillArea - spoilReplaced, 0); } // 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암. let cutSoilArea: number; let cutRockArea: number; let cutRockKind: string | null; if (presetKey !== "rock") { cutSoilArea = cutArea; cutRockArea = 0; cutRockKind = null; } else if (rockBoundaryOffsetM === null) { cutSoilArea = 0; cutRockArea = cutArea; cutRockKind = groundType; } else { [cutSoilArea, cutRockArea] = splitCutAreas(offsets, diffs, Math.abs(rockBoundaryOffsetM)); cutRockKind = groundType; } // 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사. let ditchArea: number; let ditchSpec: Record; if (!geometry.hasDitch) { ditchArea = 0; ditchSpec = { type: "none" }; } else if (ditchType === "l_type") { ditchArea = (group.l_ditch_width_m * group.l_ditch_depth_m) / 2; ditchSpec = { type: "l_type", width_m: group.l_ditch_width_m, depth_m: group.l_ditch_depth_m, }; } else { ditchArea = ((group.ditch_top_width_m + group.ditch_bottom_width_m) / 2) * group.ditch_depth_m; ditchSpec = { type: "standard", top_width_m: group.ditch_top_width_m, bottom_width_m: group.ditch_bottom_width_m, depth_m: group.ditch_depth_m, }; } // 측구터파기 토사/암 분리 — **새 입력을 만들지 않는다.** 절토 분리와 같은 근거 // (지반 유형 + 암반 경계선)를 그대로 쓴다. 근거가 없으면 나누지 않고 사유를 낸다. // ⚠ 파이썬 짝: `B06_Section_Engine_Design` 의 같은 자리. let ditchSoilArea: number; let ditchRockArea: number; let ditchSplitBasis: string; if (!geometry.hasDitch) { ditchSoilArea = 0; ditchRockArea = 0; ditchSplitBasis = "no_ditch"; } else if (presetKey !== "rock") { ditchSoilArea = ditchArea; ditchRockArea = 0; ditchSplitBasis = "soil_ground"; } else if ( rockBoundaryOffsetM === null || rockBoundaryOffsetM === undefined || !geometry.ditchPoints.length ) { ditchSoilArea = 0; ditchRockArea = ditchArea; ditchSplitBasis = "rock_ground_no_boundary"; } else { const ditchTopZ = geometry.ditchPoints[0][1]; const midOffset = geometry.ditchPoints.reduce((sum, point) => sum + point[0], 0) / geometry.ditchPoints.length; const boundaryZ = groundAt(midOffset) - Math.abs(rockBoundaryOffsetM); [ditchSoilArea, ditchRockArea] = splitDitchArea(ditchSpec, ditchTopZ - boundaryZ); ditchSplitBasis = "rock_boundary"; } // 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo 한다(D-2). let resolvedMode: string; if (geometry.leftRole === "cut" && geometry.rightRole === "cut") resolvedMode = "both_cut"; else if (geometry.leftRole === "fill" && geometry.rightRole === "fill") resolvedMode = "both_fill"; else if (geometry.leftRole === "cut") resolvedMode = "left_cut"; else resolvedMode = "right_cut"; const result: CrossDesignResult = { ground_type: groundType, geometry_preset: presetKey, section_mode: resolvedMode, ditch_side: resolvedDitchSide, ditch_type: geometry.hasDitch ? ditchType : null, cut_slope_ratio: round4(geometry.cutRatio), soil_cut_slope_ratio: round4(geometry.soilCutRatio), // 사용자가 **켠 값**을 그대로 돌려준다 — 엔진이 실제로 적용했는지(`geometry.twoStage`)가 // 아니다(2026-09-07, 짝: `B06_Section_Engine_Design.py`). 적용 결과를 저장하면 토사 // 측점처럼 못 쓰는 자리에서 false 가 저장되고, 그 false 가 다음 재계산 인자로 되먹여져 // 사용자의 「켬」이 영구히 사라졌다. two_stage_slope: options.twoStageSlope ?? true, fill_slope_ratio: round4(geometry.fillRatio), roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent), carriageway_width_m: round4(geometry.halfRoadLeft + geometry.halfRoadRight), // 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다(짝: 파이썬). carriageway_standard_width_m: round4(group.road_width_m), widening_left_m: round4(geometry.halfRoadLeft - geometry.halfRoad), widening_right_m: round4(geometry.halfRoadRight - geometry.halfRoad), cross_slope_pct: round4(crossSlopePct), ditch: ditchSpec, ditch_enabled: geometry.hasDitch, paved, road_edges: { left: { offset_m: round4(geometry.leftExtent), elevation_m: round4(geometry.roadZ(geometry.leftExtent)), }, right: { offset_m: round4(-geometry.rightExtent), elevation_m: round4(geometry.roadZ(-geometry.rightExtent)), }, }, carriageway_edges: { left: { offset_m: round4(geometry.halfRoadLeft), elevation_m: round4(geometry.roadZ(geometry.halfRoadLeft)), }, right: { offset_m: round4(-geometry.halfRoadRight), elevation_m: round4(geometry.roadZ(-geometry.halfRoadRight)), }, }, design_elevation_m: round4(centerElevation), cut_area_m2: round4(cutArea), cut_soil_area_m2: round4(cutSoilArea), cut_rock_area_m2: round4(cutRockArea), cut_rock_kind: cutRockKind, fill_area_m2: round4(fillArea), spoil_fill_area_m2: spoilSection ? round4(spoilSection.area_m2) : 0, spoil_fill_side: spoilSection ? spoilSide : null, spoil_fill_width_m: spoilSection ? round4(spoilWidth) : 0, spoil_fill_max_width_m: spoilSection ? round4(spoilSection.maxWidthM) : 0, spoil_fill_line: spoilSection ? spoilSection.line.map((point) => ({ offset_m: point.offset_m, elevation_m: point.elevation_m, })) : [], spoil_fill_unclosed: spoilSection ? spoilSection.unclosed : false, spoil_fill_replaced_fill_m2: round4(spoilReplaced), bench_cut_length_m: round4(benchCut), slope_unclosed: slopeUnclosed, fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope), ditch_area_m2: round4(ditchArea), ditch_soil_area_m2: round4(ditchSoilArea), ditch_rock_area_m2: round4(ditchRockArea), ditch_split_basis: ditchSplitBasis, design_line: designLine, cut_slope_segments: geometry.cutSlopeSegments(), }; if (drop > 0) result.surface_drop_m = round4(drop); if (options.berm) { // 소단 제원을 설계에 되싣는다 — 짝 파이썬과 같은 까닭이고, **옹벽 의무 판정 // (`fillSlopeLengths`)이 이 값을 보고** 사면을 도막으로 끊어 잰다(계획서 3-9). result.berm = { width_m: round4(options.berm.widthM), interval_m: round4(options.berm.intervalM), slope_deg: round4(options.berm.slopeDeg), }; } if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m); if (presetKey === "rock" && rockBoundaryOffsetM !== null) { result.rock_boundary_offset_m = round4(rockBoundaryOffsetM); } return result; } /** 짝: 파이썬 `round(offset, 6)` — 병합 격자 중복 제거 기준. */ function round6(value: number): number { return Math.round(value * 1e6) / 1e6; }