diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 4f6c4539..5a51e581 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -56,8 +56,12 @@ export interface RoutePlanResponse { nodes: RoutePlanNode[]; /** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */ curves: RoutePlanCurve[]; - /** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */ + /** 이 프로젝트에 적용한 법정 최소곡선반지름(m) — **기본값·위반 표시 기준**. */ min_radius_m: number; + /** **못 넘는** 곡선반지름 하한(m). 0이면 제한 없음(작업임도). 기본값과 다른 값이다. */ + limit_radius_m?: number; + /** **못 넘는** 곡선 길이 하한(m). 0이면 제한 없음 — 지금은 전부 0(법에 값이 없음). */ + limit_curve_length_m?: number; curve_count: number; violation_count: number; /** 사용자가 고친 계획노선이 저장돼 있으면 true. */ diff --git a/B05_Profile/B05_Profile_Engine_Grade.py b/B05_Profile/B05_Profile_Engine_Grade.py index e250d8f4..706bc263 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -151,6 +151,37 @@ def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal") return float(speeds[terrain]) +def plan_radius_limit_m( + grade_class: str, + design_speed_kph: int | None = None, + terrain_type: str = "normal", +) -> float: + """계획노선 편집 화면이 **못 넘게 막을** 평면 곡선반지름 하한(m). 0이면 제한 없음. + + 위 `legal_plan_radius_min_m` 은 **기본값·위반 표시 기준**이고 이것은 **제한**이다 + (2026-09-12 사용자 확정: 「아예 못 넘게 막음」). 임도 종류별 칸이 비어 있으면(None) + 법정 표를 그대로 하한으로 쓰고, 값이 적혀 있으면 그 값을 쓴다 — 작업임도는 별표2에 + 곡선반지름 규정이 없어 0(제한 없음)으로 열려 있다. + """ + table = FOREST_ROAD_PROFILE_CRITERIA["plan_radius_limit_by_grade_m"] + override = table.get(grade_class) + if override is not None: + return float(override) + return legal_plan_radius_min_m( + resolve_design_speed(grade_class, design_speed_kph), terrain_type + ) + + +def plan_curve_length_limit_m(grade_class: str) -> float: + """평면 **곡선 길이(L)** 하한(m). 0이면 제한 없음. + + 법령·교본에 값이 없어 지금은 임도 종류 전부 0이다 — 자리만 열어 둔 칸이라 + 실무값이 정해지면 `config_system_design` 의 표만 고치면 된다(2026-09-12 사용자 확정). + """ + table = FOREST_ROAD_PROFILE_CRITERIA["plan_curve_length_limit_by_grade_m"] + return float(table.get(grade_class) or 0.0) + + def _pick(*candidates: Any) -> Any: """요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다.""" for value in candidates: diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index cfbe16d5..50bc3103 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -133,7 +133,19 @@ def _ensure_expected_route(project_root: Path) -> str: async def _min_plan_radius_m(project_id: UUID) -> float: - """이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다. + """기본 반지름만 필요한 자리 — 하한까지 필요하면 `_plan_criteria` 를 쓸 것.""" + criteria = await _plan_criteria(project_id) + return criteria[0] + + +async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]: + """이 프로젝트의 **기본 반지름 · 반지름 하한 · 곡선 길이 하한**(m) 세 값. + + 기본 반지름은 곡선을 만들 때 쓰는 값이고, 하한 둘은 **화면이 못 넘게 막는** 값이다 + (2026-09-12 사용자 확정). 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어 곡선이 + 아예 안 그려지므로 반드시 갈라 둔다. + + 기본 반지름은 임도 종류·설계속도·지형으로 고른다. 값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 Ⅰ.2.다)이고 산식은 이미 `B05_Profile_Engine_Grade.legal_plan_radius_min_m` 에 있다 — 여기서 다시 짜지 않는다. @@ -145,7 +157,12 @@ async def _min_plan_radius_m(project_id: UUID) -> float: """ import aiomysql - from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed + from B05_Profile.B05_Profile_Engine_Grade import ( + legal_plan_radius_min_m, + plan_curve_length_limit_m, + plan_radius_limit_m, + resolve_design_speed, + ) from common_util.common_util_workflow_state import get_workflow_state grade_class, design_speed, terrain = "work", None, "special" @@ -172,7 +189,11 @@ async def _min_plan_radius_m(project_id: UUID) -> float: terrain = str(params["terrain_type"]) except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다 logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id) - return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain) + return ( + legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain), + plan_radius_limit_m(grade_class, design_speed, terrain), + plan_curve_length_limit_m(grade_class), + ) def _nodes_path(path: Path) -> Path: @@ -404,7 +425,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: if not expected: expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root)) - radius_m = await _min_plan_radius_m(project_id) + radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id) await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root)) initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root)) @@ -445,6 +466,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: # (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다. "curves": saved_curves or [curve.as_dict() for curve in outline.curves], "min_radius_m": round(radius_m, 2), + # **못 넘는 하한** — 기본값(`min_radius_m`)과 다른 값이다. 0이면 제한 없음 + # (작업임도는 별표2에 곡선반지름 규정이 없어 0으로 열려 있다, 2026-09-12 확정). + "limit_radius_m": round(radius_limit_m, 2), + "limit_curve_length_m": round(arc_limit_m, 2), "curve_count": len(saved_curves) if saved_curves else outline.curve_count, "violation_count": outline.violation_count, "edited": bool(working), @@ -468,7 +493,7 @@ async def replan_route( # 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다. await asyncio.to_thread(_ensure_expected_route, project_root) - radius_m = await _min_plan_radius_m(project_id) + radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id) await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) # 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다. # 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자). @@ -527,7 +552,7 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) project_root, stored_path = paths await asyncio.to_thread(_ensure_expected_route, project_root) - radius_m = await _min_plan_radius_m(project_id) + radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id) await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) working_path = planned_route_working_path(project_root) if working_path.is_file(): diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 444833cf..350a9760 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -50,8 +50,11 @@ import { } from "./B05_Profile_UI_RouteEdit_Label"; import { applyArcLocks, + applyCurveLimits, + curveShortfalls, curveSummary, flattenServerPlan, + shortfallCrossed, type CurveLock, } from "./B05_Profile_UI_RouteEdit_Edits"; import { @@ -134,6 +137,9 @@ export async function openRouteEditModal( /** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */ let nodeInfo: EditedNode[] = []; let minRadiusM = 0; + /** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */ + let limitRadiusM = 0; + let limitArcM = 0; /** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */ let curveInfo: EditedCurve[] = []; /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ @@ -284,6 +290,8 @@ export async function openRouteEditModal( function markEdited(): void { // 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다. applyArcLocks(planned, curveLock, curveArc, curveRadius); + // 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④). + applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM); const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); plannedLine = built.vertices; curveInfo = built.curves; @@ -394,6 +402,8 @@ export async function openRouteEditModal( arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, lock: curveLock[picked] ?? null, innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, + limitRadiusM, + limitArcM, }); } @@ -477,7 +487,8 @@ export async function openRouteEditModal( const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); if (moved) { planned[node] = moved.apex; - curveRadius[node] = Math.round(moved.radius * 100) / 100; + // 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④). + curveRadius[node] = Math.max(limitRadiusM, Math.round(moved.radius * 100) / 100); curveOn[node] = true; picked = node; // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 @@ -491,9 +502,23 @@ export async function openRouteEditModal( return; } if (dragNode >= 0) { + // 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로 + // **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④). + const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM); + const previous = planned[dragNode]; dragMoved = true; planned[dragNode] = toMetric(px, py); markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. + if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) { + // 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다. + planned[dragNode] = previous; + markEdited(); + status.textContent = + `${routeHead()} — 하한에 걸려 더 못 옮깁니다` + + `(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`; + draw(); + return; + } // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`; @@ -626,6 +651,8 @@ export async function openRouteEditModal( // (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다). const nodes = plan.nodes ?? []; minRadiusM = plan.min_radius_m ?? 0; + limitRadiusM = plan.limit_radius_m ?? 0; + limitArcM = plan.limit_curve_length_m ?? 0; // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); planned = flat.planned; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts index a21e6987..55dcd2db 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts @@ -48,6 +48,75 @@ export function applyArcLocks( } } +/** + * **하한을 지키도록 지정 반지름을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정). + * + * L = R·Δ 이므로 「곡선 길이 하한」은 그 자리에서 「반지름 하한 L/Δ」과 같은 말이다. 두 하한 + * 중 큰 쪽으로 올린다. **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을 + * 쓰고 있고, 여기서 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다. + */ +export function applyCurveLimits( + planned: Vertex[], + curveOn: ReadonlyArray, + curveRadius: Array, + limitRadiusM: number, + limitArcM: number, +): void { + if (limitRadiusM <= 0 && limitArcM <= 0) return; + for (let seat = 1; seat < planned.length - 1; seat += 1) { + if (curveOn[seat] === false) continue; + const current = curveRadius[seat]; + if (current === null || current === undefined) continue; + const deflection = deflectionRad( + innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]), + ); + const byArc = limitArcM > 0 && deflection > 1e-9 ? limitArcM / deflection : 0; + const floor = Math.max(limitRadiusM, byArc); + if (floor > 0 && current < floor) curveRadius[seat] = floor; + } +} + +/** + * 노드마다 하한을 **얼마나 밑돌고 있나**(m). 다 지키고 있으면 0. + * + * 노드를 옮기면 접선 자리가 모자라 그리기 단계에서 R 이 눌릴 수 있다. 그 눌림까지 막으려면 + * 옮기기 자체를 되돌려야 하므로, **옮기기 전보다 나빠진 자리가 있는지**만 견준다 — 이미 + * 하한을 밑돌던 옛 노선도 그대로 고칠 수 있어야 하기 때문이다(2026-09-12). + * + * ⚠ 가장 큰 값 하나로 견주면 안 된다. 크게 밑도는 자리가 이미 있으면 **다른 자리가 새로 + * 무너져도 최댓값이 안 움직여** 그냥 통과한다(실화면에서 「기준 미달 1곳 → 2곳」이 그대로 + * 지나갔다). 자리마다 따로 견준다. + */ +export function curveShortfalls( + nodes: ReadonlyArray, + limitRadiusM: number, + limitArcM: number, +): number[] { + return nodes.map((node) => { + if (node.radius_m === null || (limitRadiusM <= 0 && limitArcM <= 0)) return 0; + let short = 0; + if (limitRadiusM > 0) short = Math.max(short, limitRadiusM - node.radius_m); + if (limitArcM > 0) { + short = Math.max(short, limitArcM - node.radius_m * deflectionRad(node.inner_angle_deg)); + } + return Math.max(0, short); + }); +} + +/** + * 하한을 지키던 자리가 **이번 걸음에 처음으로 무너졌나**. 자리 수가 달라지면(넣기·지우기) + * 안 따진다. + * + * ⚠ 「조금이라도 나빠졌으면 막기」로 두면 **이미 하한을 밑돌던 옛 노선을 아예 못 고친다** — + * 그 옆 노드를 1px 만 건드려도 밑돌던 값이 미세하게 더 내려가 첫 걸음부터 막혔다(2026-09-12 + * 실화면). 이미 무너진 자리는 그대로 두고(붉은 표시는 남는다), **지키고 있던 자리가 넘어가는 + * 것만** 막는다. + */ +export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean { + if (before.length !== after.length) return false; + return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6); +} + export interface CurveSummaryInput { nodeCount: number; curveOn: boolean[]; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index 89f3f290..c6388f8d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -71,6 +71,9 @@ export interface CurveLabelState { arcLengthShown: number | null; lock: CurveLock; innerAngleDeg: number | null; + /** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ + limitRadiusM?: number; + limitArcM?: number; } export interface CurveLabelHandlers { @@ -148,15 +151,25 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { /** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */ let manual: [number, number] | null = null; let anchor: [number, number] = [0, 0]; + /** 지금 자리의 하한 — 칸이 여기서 멈춘다. 0이면 제한 없음. */ + let limitRadius = 0; + let limitArc = 0; /** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */ let limit: CurveLabelState["bounds"]; - const numberOf = (input: HTMLInputElement): number | null => { + /** 칸에서 읽은 값. **하한 아래는 하한에서 멈추고, 멈춘 값을 칸에 되적어 보인다** + * (2026-09-12 사용자 확정 「아예 못 넘게 막음」) — 조용히 바꾸면 왜 안 먹었는지 모른다. */ + const numberOf = (input: HTMLInputElement, floor: number): number | null => { const value = Number(input.value); - return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null; + if (input.value.trim() === "" || !Number.isFinite(value) || value <= 0) return null; + if (floor > 0 && value < floor) { + input.value = String(floor); + return floor; + } + return value; }; - radius.addEventListener("change", () => handlers.onRadius(numberOf(radius))); - arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc))); + radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius))); + arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc))); toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius")); lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc")); @@ -242,6 +255,11 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { curveOn = state.curveOn; lock = state.lock; limit = state.bounds; + limitRadius = state.limitRadiusM ?? 0; + limitArc = state.limitArcM ?? 0; + // 칸 자체에도 하한을 박아 화살표·스피너가 그 아래로 안 내려가게 한다. + radius.min = limitRadius > 0 ? String(limitRadius) : "1"; + arc.min = limitArc > 0 ? String(limitArc) : "1"; root.hidden = false; seatText.textContent = `${state.seat + 1}번째 꺾임점`; toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; @@ -258,8 +276,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { const inner = state.innerAngleDeg; const held = lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음"; + const floors = [ + limitRadius > 0 ? `R ≥ ${limitRadius}m` : "", + limitArc > 0 ? `L ≥ ${limitArc}m` : "", + ] + .filter(Boolean) + .join(" · "); info.textContent = state.curveOn - ? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + ? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}${floors ? ` · ${floors}` : ""}` : "곡선 없음 — 직선이 그대로 꺾입니다"; place(state); // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. diff --git a/config/config_system_design.py b/config/config_system_design.py index 201716b1..1cc5dc79 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -496,6 +496,31 @@ FOREST_ROAD_PROFILE_CRITERIA = { # 배향곡선(Hair Pin) 중심선 반지름 하한(m, 별표2 Ⅰ.2.다.(2)). 이보다 급하면 **경고만** # 낸다 — 자동 보정·차단은 하지 않는다(2026-09-06 사용자 확정). "hairpin_min_radius_m": 10.0, + # 임도 종류별 **못 넘는 하한**(m) — 계획노선 편집 화면이 값을 막는 기준이다 + # (2026-09-12 사용자 확정). 위 `min_plan_radius_m` 은 **기본값·위반 표시 기준**이고 + # 여기는 **제한**이라 서로 다르다. 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어 + # 곡선이 아예 안 그려진다. + # · None = 위 표(설계속도 × 지형)를 그대로 하한으로 쓴다. + # · 0.0 = 제한 없음. 작업임도는 별표2에 곡선반지름 규정이 없어 열어 둔다 — + # 값이 정해지면 **이 칸만** 고치면 서버·화면이 함께 따라간다. + # ⚠ `projects.road_type` 은 main|fire|work 로 들어온다(B02 스키마) — 계획선 등급 코드 + # trunk 와 같은 뜻이라 둘 다 적어 둔다. 없는 키는 None 과 같게(법정 표) 다뤄진다. + "plan_radius_limit_by_grade_m": { + "main": None, + "trunk": None, + "fire": None, + "work": 0.0, + "branch": None, + }, + # 평면 **곡선 길이(L)** 하한(m). 법령·교본에 값이 없어 지금은 전부 0(제한 없음)이다. + # 자리만 만들어 두고, 실무값이 정해지면 여기에 적는다(2026-09-12 사용자 확정). + "plan_curve_length_limit_by_grade_m": { + "main": 0.0, + "trunk": 0.0, + "fire": 0.0, + "work": 0.0, + "branch": 0.0, + }, # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이 # 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서 # 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른 diff --git a/resources/tester/test_plan_curve_limits.py b/resources/tester/test_plan_curve_limits.py new file mode 100644 index 00000000..09eb0db1 --- /dev/null +++ b/resources/tester/test_plan_curve_limits.py @@ -0,0 +1,45 @@ +"""계획노선 곡선 **하한**(반지름·곡선 길이) 해석 시험. + +기본값(`legal_plan_radius_min_m`)과 **못 넘는 하한**(`plan_radius_limit_m`)은 다른 값이다 +(2026-09-12 사용자 확정). 둘을 한 값으로 묶으면 작업임도의 하한 0 이 곧 반지름 0 이 되어 +곡선이 아예 안 그려지므로, 갈라져 있다는 것 자체를 시험으로 못박는다. +""" + +from B05_Profile.B05_Profile_Engine_Grade import ( + legal_plan_radius_min_m, + plan_curve_length_limit_m, + plan_radius_limit_m, + resolve_design_speed, +) + + +def _default(grade_class: str, terrain: str) -> float: + return legal_plan_radius_min_m(resolve_design_speed(grade_class, None), terrain) + + +def test_작업임도는_하한이_없다(): + """별표2에 작업임도 곡선반지름 규정이 없어 하한을 0(제한 없음)으로 열어 둔다.""" + assert plan_radius_limit_m("work", None, "normal") == 0.0 + assert plan_radius_limit_m("work", None, "special") == 0.0 + + +def test_작업임도도_기본_반지름은_그대로다(): + """하한이 0이어도 **곡선을 만들 때 쓰는 기본값**은 살아 있어야 한다.""" + assert _default("work", "normal") > 0 + + +def test_간선_산불진화는_법정표가_곧_하한이다(): + for grade_class in ("main", "trunk", "fire"): + for terrain in ("normal", "special"): + assert plan_radius_limit_m(grade_class, None, terrain) == _default(grade_class, terrain) + + +def test_모르는_임도종류는_법정표로_떨어진다(): + """칸이 없는 값이 와도 막지 않고 법정표를 하한으로 쓴다(가장 보수적인 쪽).""" + assert plan_radius_limit_m("알 수 없는 종류", None, "normal") == _default("work", "normal") + + +def test_곡선길이_하한은_아직_전부_0이다(): + """법령·교본에 평면 곡선 길이 하한 값이 없다 — 자리만 열어 둔 칸이다.""" + for grade_class in ("main", "trunk", "fire", "work", "branch", "없는종류"): + assert plan_curve_length_limit_m(grade_class) == 0.0