feat(B05): 계획노선 곡선 R·L 하한을 화면에서 막음
- 임도 종류별 하한 표를 config 에 둠, 작업임도는 규정이 없어 0(제한 없음) - 기본 반지름과 하한을 갈라 둠 — 한 값이면 하한 0 이 반지름 0 이 되어 곡선이 안 그려짐 - `/route/plan` 이 `limit_radius_m`·`limit_curve_length_m` 를 함께 내림 - 반지름 칸·곡선 길이 칸·손잡이 끌기가 하한에서 멈추고, 노드 이동은 그 걸음을 되돌림 - 이미 하한을 밑돌던 자리는 그대로 두고 지키던 자리가 넘어가는 것만 막음 - 시험 `resources/tester/test_plan_curve_limits.py` 추가 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,6 +48,75 @@ export function applyArcLocks(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **하한을 지키도록 지정 반지름을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정).
|
||||
*
|
||||
* L = R·Δ 이므로 「곡선 길이 하한」은 그 자리에서 「반지름 하한 L/Δ」과 같은 말이다. 두 하한
|
||||
* 중 큰 쪽으로 올린다. **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을
|
||||
* 쓰고 있고, 여기서 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다.
|
||||
*/
|
||||
export function applyCurveLimits(
|
||||
planned: Vertex[],
|
||||
curveOn: ReadonlyArray<boolean>,
|
||||
curveRadius: Array<number | null>,
|
||||
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<EditedNode>,
|
||||
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[];
|
||||
|
||||
@@ -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);
|
||||
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
|
||||
|
||||
@@ -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 고정이다. 사용자가 화면에서 고른
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user