diff --git a/B05_Profile/B05_Profile_Engine_Grade.py b/B05_Profile/B05_Profile_Engine_Grade.py index c7574a34..e250d8f4 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -139,6 +139,18 @@ def legal_grade_limit_pct( return legal_max +def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal") -> float: + """설계속도 × 지형 구분에 따른 법정 **평면** 최소곡선반지름(m, 별표2 Ⅰ.2.다.(1)). + + 경로탐색 제약이 아니라 **위반 표시** 기준이다(2026-09-06 사용자 확정). 설계속도는 + 이미 확정된 값을 받는다(계획선 옵션이 `resolve_design_speed`로 눌러 둔 값). + """ + table = FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"] + terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" + speeds = table.get(int(design_speed_kph)) or table[20] + return float(speeds[terrain]) + + def _pick(*candidates: Any) -> Any: """요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다.""" for value in candidates: diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 005d5a8a..254b252d 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -20,7 +20,7 @@ from typing import Any import numpy as np -from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT +from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT, FOREST_ROAD_PROFILE_CRITERIA ALIGNMENT_SCHEMA_VERSION = 1 # chainage를 dict 키로 쓸 때의 표기. 프론트엔드(`toFixed(3)`)와 반드시 같아야 한다. @@ -51,6 +51,10 @@ class AlignmentPolicy: max_grade_pct: float curve_skip_delta_pct: float paved: bool + #: 법정 평면 최소곡선반지름(m) — 위반 표시 기준(2026-09-06). 0이면 판정하지 않는다. + min_plan_radius_m: float = 0.0 + #: 배향곡선 하한(m) — 이보다 급하면 경고만 낸다. + hairpin_min_radius_m: float = 0.0 @classmethod def from_config( @@ -60,6 +64,7 @@ class AlignmentPolicy: max_grade_pct: float, curve_skip_delta_pct: float, paved: bool, + min_plan_radius_m: float = 0.0, ) -> "AlignmentPolicy": config = FOREST_ROAD_PROFILE_ALIGNMENT return cls( @@ -76,6 +81,8 @@ class AlignmentPolicy: max_grade_pct=float(max_grade_pct), curve_skip_delta_pct=float(curve_skip_delta_pct), paved=bool(paved), + min_plan_radius_m=float(min_plan_radius_m), + hairpin_min_radius_m=float(FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"]), ) @classmethod @@ -140,6 +147,9 @@ class AlignmentPolicy: "max_grade_pct": self.max_grade_pct, "curve_skip_delta_pct": self.curve_skip_delta_pct, "paved": self.paved, + # 평면 곡선 판정값 — 화면 위반 표시가 쓴다(2026-09-06). + "min_plan_radius_m": self.min_plan_radius_m, + "hairpin_min_radius_m": self.hairpin_min_radius_m, } diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index fc7e51db..b5991a44 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -30,6 +30,7 @@ from B05_Profile.B05_Profile_Engine_Grade import ( GradeDesignOptions, detect_main_direction, ground_profile, + legal_plan_radius_min_m, ) from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( ALIGNMENT_SCHEMA_VERSION, @@ -179,6 +180,8 @@ def design_ground_following_profile( max_grade_pct=options.max_grade_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, + # 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개. + min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), ) warnings = list(options.warnings) @@ -270,6 +273,8 @@ def design_pipe_anchored_profile( max_grade_pct=options.max_grade_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, + # 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개. + min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), ) warnings = list(options.warnings) @@ -385,6 +390,8 @@ def design_alignment_profile( max_grade_pct=options.max_grade_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, + # 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개. + min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), ) warnings = list(options.warnings) diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 33ca8bd3..491a76fc 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -100,6 +100,8 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]: "chainage_m": cross_section.get("chainage_m"), "center_z": cross_section.get("center_z"), "azimuth_deg": cross_section.get("azimuth_deg"), + # 평면 곡선반경 — 곡선부 확폭·최소곡선반지름 판정이 쓴다(2026-09-06). + "plan_radius_m": cross_section.get("plan_radius_m"), "sample_count": len(samples), "min_elevation_m": min(valid_z) if valid_z else None, "max_elevation_m": max(valid_z) if valid_z else None, diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 94b4eb5a..4a73a331 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -11,6 +11,7 @@ from typing import Any import numpy as np +from B05_Profile.B05_Profile_Engine_Geometry import circumradius_2d from common_util.common_util_surface_sampler import SurfaceElevationSampler from config.config_system import ( SECTION_CROSS_HALF_WIDTH_M, @@ -116,6 +117,43 @@ def _float_or_none(value: float) -> float | None: return round(float(value), 6) if math.isfinite(float(value)) else None +#: 평면 곡선반경을 재는 앞뒤 거리(m). 측점 간격(20m)의 절반이라 한 측점의 곡률을 +#: 이웃 측점에 번지지 않게 재고, 짧은 곡선도 놓치지 않는다. +PLAN_RADIUS_ARM_M = 10.0 +#: 이보다 크면 직선으로 본다(m). 별표2 확폭표가 45m 이상을 "확폭 없음"으로 두므로 +#: 그보다 넉넉한 값이면 판정에 영향이 없다. +PLAN_RADIUS_STRAIGHT_M = 10000.0 + + +def _plan_radii( + points: np.ndarray, + route_chainage: np.ndarray, + station_chainage: np.ndarray, + total: float, +) -> list[float | None]: + """측점마다 평면 곡선반경(m). 직선·측정 불가는 None.""" + arm = min(PLAN_RADIUS_ARM_M, max(total / 2.0, 0.0)) + if arm < 0.5: + return [None] * len(station_chainage) + radii: list[float | None] = [] + for value in station_chainage: + center = float(value) + back = max(0.0, center - arm) + ahead = min(total, center + arm) + # 시·종점에서는 한쪽 팔이 짧아진다 — 양쪽이 다 확보될 때만 잰다. + if center - back < arm * 0.5 or ahead - center < arm * 0.5: + radii.append(None) + continue + trio = _interpolate_xy(points, route_chainage, np.array([back, center, ahead])) + radius = circumradius_2d(trio[0], trio[1], trio[2]) + radii.append( + None + if not math.isfinite(radius) or radius >= PLAN_RADIUS_STRAIGHT_M + else round(radius, 3) + ) + return radii + + def generate_sections( polyline: np.ndarray | list[list[float]], sampler: SurfaceElevationSampler, @@ -167,6 +205,10 @@ def generate_sections( ] ) left_axes = np.column_stack([-tangents[:, 1], tangents[:, 0]]) + # 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반 + # 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세 + # 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다. + plan_radii = _plan_radii(points, route_chainage, station_chainage, total) offsets = np.arange( -options.cross_half_width_m, @@ -240,6 +282,7 @@ def generate_sections( "center_y": round(float(station_xy[index, 1]), 6), "center_z": _float_or_none(center_z), "azimuth_deg": round(azimuth, 6), + "plan_radius_m": plan_radii[index], # 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). 사용자 변경 시 확정에서 덮어쓴다. "uphill_side": uphill_side, "frame": frame, diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 905b0ca2..e9ec05cf 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -387,7 +387,11 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minUphillGrade.wrapper, minDownhillGrade.wrapper, ); + // [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고 + // 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다. + // 코드·API 는 그대로 두고 버튼만 뺀다. 노선 변경은 계획노선 편집이 대신한다. const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); + solveButton.hidden = true; routeCalc.body.append( algorithmField.root, paved.wrapper, diff --git a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts index 929494dc..1a6c62b1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts @@ -28,6 +28,10 @@ export interface AlignmentPolicy { max_grade_pct: number; curve_skip_delta_pct: number; paved: boolean; + /** 법정 평면 최소곡선반지름(m) — 위반 표시 기준. 0이면 판정하지 않는다(옛 저장분). */ + min_plan_radius_m?: number; + /** 배향곡선 하한(m) — 이보다 급하면 경고만 낸다(2026-09-06 사용자 확정). */ + hairpin_min_radius_m?: number; } export interface AlignmentNode { diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index ac23412c..3c3e262e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -45,6 +45,8 @@ export interface BalanceBarParams { /** 유토곡선 총괄값 — 「최대 기울기」 오른쪽에 적는다(2026-09-06 사용자 지시). * 유토곡선 패널을 접어도 이 값은 남아야 계획선을 만지며 볼 수 있다. */ massHaul?: MassHaulSummaryValues | null; + /** 법정 평면 최소곡선반지름 위반(2026-09-06) — 자동 보정 없이 **경고만** 낸다. */ + planCurve?: { count: number; worstRadiusM: number; limitM: number; hairpin: number } | null; /** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */ onResetAll: () => void; } @@ -93,6 +95,16 @@ export function renderBalanceBar(params: BalanceBarParams): void { if (mass) { entries.push(["누가토량", volume(mass.finalM3), mass.finalM3 < 0 ? "over" : undefined]); } + // 평면 곡선반경 — 법정 하한을 밑도는 측점이 있을 때만 적는다(별표2 Ⅰ.2.다.(1)). + // 프로그램은 값만 드러내고 고치지 않는다: 노선을 바꿀지는 사용자 판단이다. + const plan = params.planCurve; + if (plan && plan.count > 0) { + entries.push([ + "곡선반경 부족", + `${plan.count} 곳 / 최소 ${plan.worstRadiusM.toFixed(1)} m`, + "over", + ]); + } // 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다. if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]); // 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은 @@ -107,6 +119,15 @@ export function renderBalanceBar(params: BalanceBarParams): void { item.append(caption, document.createTextNode(value)); // 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 — // 같은 사실을 두 번 적지 않는다(2026-08-19 재편). + if (label === "곡선반경 부족" && plan) { + item.title = [ + `법정 하한 ${plan.limitM.toFixed(1)}m 미만인 측점 ${plan.count}곳`, + `가장 급한 곳 R=${plan.worstRadiusM.toFixed(1)}m`, + plan.hairpin > 0 + ? `그중 배향곡선 하한 미만 ${plan.hairpin}곳 — 경고만 낸다(자동 보정 없음)` + : "배향곡선 하한 미만은 없음", + ].join("\n"); + } if (label === "누가토량" && mass) { item.title = [ `절토(자연) ${volume(mass.cutNaturalM3)} · 절토(다짐) ${volume(mass.cutCompactedM3)}`, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 823556f2..2a60f6d8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -325,6 +325,34 @@ export function createRouteProfilePanel( return samples[samples.length - 1][field]; } + /** + * 법정 평면 최소곡선반지름을 밑도는 측점을 센다(별표2 Ⅰ.2.다.(1), 2026-09-06). + * 반경은 횡단 측점마다 실려 오고(`plan_radius_m`), 하한은 계획선 정책이 들고 있다. + * **경고만** 낸다 — 노선을 고칠지는 사용자 판단이다. + */ + function planCurveWarning(): { + count: number; + worstRadiusM: number; + limitM: number; + hairpin: number; + } | null { + const limit = alignment?.policy.min_plan_radius_m ?? 0; + const sections = detail?.cross_sections ?? []; + if (!limit || !sections.length) return null; + const hairpinLimit = alignment?.policy.hairpin_min_radius_m ?? 0; + let count = 0; + let hairpin = 0; + let worst = Number.POSITIVE_INFINITY; + sections.forEach((section) => { + const radius = section.plan_radius_m; + if (typeof radius !== "number" || !Number.isFinite(radius) || radius >= limit) return; + count += 1; + worst = Math.min(worst, radius); + if (hairpinLimit > 0 && radius < hairpinLimit) hairpin += 1; + }); + return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null; + } + function renderBalance(): void { const minCoverViolations = findMinCoverViolations( minCoverTargets, @@ -332,6 +360,7 @@ export function createRouteProfilePanel( (chainageM) => sampleAt(chainageM, "elevation_m"), ); renderBalanceBar({ + planCurve: planCurveWarning(), minCoverViolations, balanceBar, tools: tools.render(), diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index e0c0b13c..5eb16598 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -116,6 +116,8 @@ export interface SectionStation { structure?: string; center_z: number | null; azimuth_deg: number | null; + /** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */ + plan_radius_m?: number | null; center_x: number; center_y: number; /** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */ diff --git a/config/config_system_design.py b/config/config_system_design.py index 881ce871..8574ccc3 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -442,6 +442,17 @@ FOREST_ROAD_PROFILE_CRITERIA = { "min_curve_length_m": 20.0, }, }, + # 평면 최소곡선반지름(m, 별표2 Ⅰ.2.다.(1)) — 설계속도·지형별. **경로탐색 제약이 아니라 + # 위반 표시 기준**이다(2026-09-06 사용자 확정: 자동탐색은 쓰지 않고 계획노선을 직접 + # 고친다). 탐색이 쓰는 등급별 상수(FOREST_ROAD_MIN_CURVE_R_M)와 혼용하지 않는다. + "min_plan_radius_m": { + 40: {"normal": 60.0, "special": 40.0}, + 30: {"normal": 30.0, "special": 20.0}, + 20: {"normal": 15.0, "special": 12.0}, + }, + # 배향곡선(Hair Pin) 중심선 반지름 하한(m, 별표2 Ⅰ.2.다.(2)). 이보다 급하면 **경고만** + # 낸다 — 자동 보정·차단은 하지 않는다(2026-09-06 사용자 확정). + "hairpin_min_radius_m": 10.0, # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이 # 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서 # 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른