From 061d550e552998de3fb23e9b020a193cde1a0d85 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 15:28:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8):=20=EC=86=8C=EB=8B=A8?= =?UTF-8?q?=EC=9D=84=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=C2=B7=EC=9E=AC?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=97=90=20=EC=8B=A4=EC=96=B4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=EA=B3=84=EB=8B=A8=EC=9D=B4=20=EC=84=9C?= =?UTF-8?q?=EA=B2=8C=20=ED=95=A8=20(=EA=B3=84=ED=9A=8D=EC=84=9C=203-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계단이 값에만 있던 것을 화면까지 연결함. 배선 — 세션 열쇠 `berm`(측점키 → 폭·간격·기울기)을 등록표에 두고, `readBermSession` 으로 읽어 ① 브라우저 재계산(`refreshCrossDesigns`)과 ② 서버 미리보기(`cross-design/preview` 의 `berms`) 양쪽에 실음. 암 경계선 오프셋과 같은 길이라 「계획선을 고치면 계단이 사라지는」 일이 없음. 실화면 확인(8001·5174, `/api/health` `stale:false`) — 측점 4120.0m 에 소단을 놓고 계획고를 한 칸 올렸다 내려 전 구간 재계산을 태움. · 폭 0.5m · 간격 3.0m → 절토 6.84 → 8.10㎡ (토사 2.42→3.24 · 암 4.42→4.86) · 폭 1.0m · 간격 2.0m → 절토 6.84 → 12.96㎡, 횡단도에 **계단이 눈으로 보임** · 소단을 안 놓은 옆 측점(4100.0m)은 3.77㎡ 그대로 — 놓은 곳만 달라짐 · 되돌린 뒤 6.84㎡ 로 복귀. [저장]·[확정] 안 눌렀으므로 정본은 그대로. `cut_slope_segments` 신설 — 절토 사면을 경사 구간별로 쪼갠 목록(파이썬·TS 짝). 법정 경사 검사가 읽을 값임(다른 창 요청). 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보이므로(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71), 검사는 소단을 뺀 구간 자체를 봐야 함. 실측 — 소단을 놓아도 구간별 경사비는 1.0 그대로 나옴. 평탄부(소단)와 지반 만난 뒤 구간은 싣지 않음. 자체검증 — 거울 시험에 사면 구간 대조를 더해 파이썬·TS 일치 확인. 전체 539 passed · 18 skipped. TS 타입 검사 통과. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 4 ++ B06_Section/B06_Section_Api_Fetch.ts | 3 + B06_Section/B06_Section_Cross_Refresh.ts | 14 +++- B06_Section/B06_Section_Engine_Design.py | 61 +++++++++++++++++ B06_Section/B06_Section_Router.py | 1 + B06_Section/B06_Section_Router_Design.py | 19 ++++++ B06_Section/B06_Section_Router_HaulPlan.py | 4 +- B06_Section/B06_Section_Schema.py | 4 ++ B06_Section/B06_Section_UI_Page_Persist.ts | 21 ++++++ common_util/common_util_cross_design.ts | 8 +++ .../common_util_cross_design_geometry.ts | 68 +++++++++++++++++++ 11 files changed, 203 insertions(+), 4 deletions(-) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 59904f2b..ed3dff06 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -109,6 +109,10 @@ export const STATE_REGISTRY = { culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` }, /** 암 경계선 오프셋(측점별). */ rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, + /** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9. + * 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을 + * 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */ + berm: { bucket: "draft", scope: "route" }, /** 표준 횡단면 설정 패널의 편집값. * * ⚠ **[저장]·[확정] 뒤에도 지우지 않는다**(2026-09-07 확인). 다른 초안과 달리 이 값은 diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 626813ba..26e84d89 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -253,6 +253,8 @@ export async function previewCrossDesigns( fullDesigns?: boolean; /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ rockBoundaryOffsets?: Record; + /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ + berms?: Record; }, ): Promise { return requestJson( @@ -264,6 +266,7 @@ export async function previewCrossDesigns( standard_cross_section: standardCrossSection ?? null, full_designs: options?.fullDesigns ?? false, rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, + berms: options?.berms ?? null, }), }, ); diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 5305e28d..602f9b3c 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -28,7 +28,8 @@ import { computeCrossDesign } from "@util/common_util_cross_design"; import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { readBermSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import type { BermSpec } from "@util/common_util_cross_berm"; import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import { effectiveStandardCross, @@ -162,6 +163,15 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const rockOffsets = readRockOffsets(projectId, input.routeId); const rockDefault = readRockBoundaryDefault(projectId); + // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 + // 순간 계단이 사라진다(계획서 3-9). + const berms = readBermSession(projectId, input.routeId); + const bermAt = (chainageM: number): BermSpec | null => { + const spec = berms[rockKey(chainageM).toFixed(2)]; + return spec + ? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg } + : null; + }; // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). const choices = crossDesignChoices(projectId, input.routeId); @@ -204,6 +214,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), paved: choice?.paved ?? Boolean(design.paved), standard, + berm: bermAt(section.chainage_m), rockBoundaryOffsetM, twoStageSlope: choice?.two_stage_slope ?? @@ -243,6 +254,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), + berms: readBermSession(projectId, routeId), }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 2ea1e9ba..8a0fcf11 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -27,6 +27,7 @@ 경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). """ +import math from collections.abc import Callable from typing import Any @@ -312,6 +313,64 @@ class _SectionGeometry: """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" return berm_elevation_at(self.cut_points(side), dist) + def cut_slope_segments(self) -> list[dict[str, Any]]: + """절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사가 읽는 값이다. + + 왜 필요한가 — 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 + **위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). 검사는 + 소단을 뺀 **사면 구간 자체의 경사**를 봐야 하므로 그 구간을 여기서 내보낸다. + + · 평탄부(소단)는 싣지 않는다 — 검사 대상이 아니고 경사비가 무한대가 된다. + · 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다. + · `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None. + 암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다). + """ + segments: list[dict[str, Any]] = [] + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role != "cut": + continue + cross = self.cut_cross_dist(side) + points = self.cut_points(side) + sign = 1.0 if side == "left" else -1.0 + for index in range(1, len(points)): + start_d, start_z = points[index - 1] + end_d, end_z = points[index] + if cross is not None and start_d >= cross - 1e-9: + break # 지반과 만난 뒤는 절토가 없다 + if cross is not None and end_d > cross: + # 지반과 만나는 점에서 구간을 자른다. + end_z = berm_elevation_at(points, cross) + end_d = cross + run = end_d - start_d + rise = end_z - start_z + if run <= 1e-9 or rise <= 1e-6: + continue # 길이 0·역방향은 검사 대상이 아니다 + if self.berm is not None and abs(run - self.berm.width_m) < 1e-6: + # 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다. + berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m + if abs(rise - berm_rise) < 1e-9: + continue + material: str | None = None + if self.two_stage: + middle_d = (start_d + end_d) / 2.0 + middle_z = (start_z + end_z) / 2.0 + material = ( + "soil" if middle_z >= self._rock_boundary_z(side, middle_d) else "rock" + ) + segments.append( + { + "side": side, + "ratio": round(run / rise, 4), + "rise_m": round(rise, 4), + "run_m": round(run, 4), + "start_offset_m": round(sign * start_d, 4), + "end_offset_m": round(sign * end_d, 4), + "material": material, + } + ) + return segments + def cut_cross_dist(self, side: str) -> float | None: """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). @@ -715,6 +774,8 @@ def compute_cross_design( ), "ditch_area_m2": round(ditch_area, 4), "design_line": design_line, + # 절토 사면을 경사 구간별로 쪼갠 목록 — 법정 경사 검사가 읽는다(소단 제외). + "cut_slope_segments": geometry.cut_slope_segments(), } if drop > 0: # 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다. diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index c497a156..ff330c5b 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -553,6 +553,7 @@ async def preview_cross_designs( request.standard_cross_section, request.rock_boundary_offsets, project_root, + request.berms, ) await asyncio.to_thread(rebuild) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 2ca30aea..6cd07943 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -10,6 +10,12 @@ from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args +from common_util.common_util_cross_berm import ( + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, + BermSpec, +) from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M @@ -343,6 +349,7 @@ def recompute_designs_for_alignment( standard: dict[str, Any] | None, rock_boundary_offsets: dict[str, float] | None = None, project_root: Path | None = None, + berms: dict[str, dict[str, float]] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) @@ -358,6 +365,17 @@ def recompute_designs_for_alignment( session_offsets[round(float(raw_key), 3)] = float(offset) except (TypeError, ValueError): continue + # 측점별 소단 제원 — 값이 없는 측점은 소단 없음(종전 설계 그대로). + session_berms: dict[float, BermSpec] = {} + for raw_key, spec in (berms or {}).items(): + try: + session_berms[round(float(raw_key), 3)] = BermSpec( + width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), + interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), + slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + ) + except (TypeError, ValueError, AttributeError): + continue for section in cross_sections: chainage = float(section.get("chainage_m", 0.0)) key = round(chainage, 3) @@ -380,6 +398,7 @@ def recompute_designs_for_alignment( two_stage_slope=bool(stored.get("two_stage_slope", True)), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), + berm=session_berms.get(key), **curve_widening_args(section), ) except (ValueError, KeyError): diff --git a/B06_Section/B06_Section_Router_HaulPlan.py b/B06_Section/B06_Section_Router_HaulPlan.py index 21f2fdbe..77c26186 100644 --- a/B06_Section/B06_Section_Router_HaulPlan.py +++ b/B06_Section/B06_Section_Router_HaulPlan.py @@ -64,9 +64,7 @@ async def compute_haul_plan( {"haul_plan_for": result, "context": _mass_haul_context()}, ) except Exception: - logger.exception( - "유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id - ) + logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id) return JSONResponse( status_code=500, content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index db92a93d..9b8dac61 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -298,6 +298,10 @@ class CrossDesignPreviewRequest(BaseModel): # 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는 # 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다. rock_boundary_offsets: dict[str, float] | None = None + # 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg}). 위와 같은 성격으로, + # 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9). + # 값이 없는 측점은 소단 없음 — 종전 설계 그대로다. + berms: dict[str, dict[str, float]] | None = None def edits(self) -> dict[str, Any]: return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 5c90c6fa..0642671d 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -52,6 +52,27 @@ export function readRockBoundarySession( return stored && typeof stored === "object" ? stored : {}; } +/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */ +export interface BermSessionSpec { + width_m: number; + interval_m: number; + slope_deg: number; +} + +/** + * 세션에 쌓인 소단 제원(측점키 → 제원). 없거나 손상되면 빈 객체. + * + * 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어 + * 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9). + */ +export function readBermSession( + projectId: string, + routeId: number, +): Record { + const stored = readState>("berm", projectId, routeId); + return stored && typeof stored === "object" ? stored : {}; +} + /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 0cfe08ea..83f060b0 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -23,10 +23,12 @@ * 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다. * ========================================================================== */ +import type { BermSpec } from "./common_util_cross_berm"; import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; // 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04). import { CURVE_WIDENING_MAX_WIDTH_M, + type CutSlopeSegment, SectionGeometry, curveWideningM, type ResolvedGroup, @@ -88,6 +90,8 @@ export interface CrossDesignOptions { curveOuterSide?: "left" | "right" | null; /** 저장된 확폭량(m) — 곡선 앞뒤 테이퍼가 얹힌 값. 있으면 반경 표값 대신 쓴다. */ curveWideningM?: number | null; + /** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ + berm?: BermSpec | null; } export interface CrossDesignEdge { @@ -128,6 +132,8 @@ export interface CrossDesignResult { fill_ground_slope: number | null; ditch_area_m2: number; design_line: CrossDesignEdge[]; + /** 절토 사면 경사 구간(소단 제외) — 법정 경사 검사가 읽는다. 짝: `cut_slope_segments`. */ + cut_slope_segments: CutSlopeSegment[]; surface_drop_m?: number; pavement_thickness_m?: number; rock_boundary_offset_m?: number; @@ -298,6 +304,7 @@ export function computeCrossDesign( rockBoundaryOffsetM, twoStageSlope: enableTwoStage, ditchEnabled: options.ditchEnabled ?? null, + berm: options.berm ?? null, }); // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). @@ -430,6 +437,7 @@ export function computeCrossDesign( fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope), ditch_area_m2: round4(ditchArea), design_line: designLine, + cut_slope_segments: geometry.cutSlopeSegments(), }; if (drop > 0) result.surface_drop_m = round4(drop); if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m); diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index d9b9502f..87d84ae8 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -8,6 +8,22 @@ import { type BermSpec, cutProfilePoints, elevationAt } 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; @@ -109,6 +125,8 @@ export class SectionGeometry { /** 곡선부 확폭(m) — 붙는 쪽만 값이 있고 반대쪽은 0이다. */ wideningLeftM?: number; wideningRightM?: number; + /** 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ + berm?: BermSpec | null; }) { const { group } = params; const halfRoad = group.road_width_m / 2; @@ -127,6 +145,7 @@ export class SectionGeometry { 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 규약). @@ -333,6 +352,55 @@ export class SectionGeometry { return Math.max(startZ - run / this.fillRatio, groundM); } + /** + * 짝: `cut_slope_segments`. 절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사용. + * + * 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 위반이 사라진 것처럼 + * 보인다. 검사는 소단을 뺀 **사면 구간 자체**를 봐야 하므로 그 구간을 내보낸다. + */ + 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) { + const middleD = (startD + endD) / 2; + const middleZ = (startZ + endZ) / 2; + material = middleZ >= this.rockBoundaryZ(side, middleD) ? "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];