feat(B05,B06): 측점별 평면 곡선반경 산출 + 법정 최소반경 경고, 자동탐색 입구 내림

2026-09-06 사용자 확정. 자동탐색은 평면만 보고 노선을 정해 실제 판단과 맞지 않으므로
쓰지 않는다 — 버튼만 감추고 코드·API 는 남긴다. 대신 법정 평면 기준을 **경고**로 낸다.

- 횡단 생성이 측점마다 평면 곡선반경(`plan_radius_m`)을 낸다. 노선 폴리라인 위에서
  앞뒤 10m 떨어진 세 점의 외접원 반경이며, 직선(1만m 이상)은 null. 반지름 50m 원호로
  50.008 이 나오는 것을 확인.
- 법정 최소곡선반지름 표(별표2 Ⅰ.2.다.(1) — 40:60/40, 30:30/20, 20:15/12)와 배향곡선
  하한 10m 를 설정에 넣고 계획선 정책에 실어 화면으로 내린다. 탐색이 쓰는 등급별
  상수와는 별개다.
- 종단 상단줄에 「곡선반경 부족 n곳 / 최소 R」 경고 추가 — 자동 보정·차단은 없다.
  툴팁에 하한값과 배향곡선 하한 미만 개수를 적는다.
- 이 반경은 다음 작업(곡선부 확폭)이 그대로 쓴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 11:55:22 +09:00
co-authored by Claude Opus 5
parent 6d9c7087e9
commit c7099bd1d3
11 changed files with 146 additions and 1 deletions
+12
View File
@@ -139,6 +139,18 @@ def legal_grade_limit_pct(
return legal_max 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: def _pick(*candidates: Any) -> Any:
"""요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다.""" """요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다."""
for value in candidates: for value in candidates:
@@ -20,7 +20,7 @@ from typing import Any
import numpy as np 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 ALIGNMENT_SCHEMA_VERSION = 1
# chainage를 dict 키로 쓸 때의 표기. 프론트엔드(`toFixed(3)`)와 반드시 같아야 한다. # chainage를 dict 키로 쓸 때의 표기. 프론트엔드(`toFixed(3)`)와 반드시 같아야 한다.
@@ -51,6 +51,10 @@ class AlignmentPolicy:
max_grade_pct: float max_grade_pct: float
curve_skip_delta_pct: float curve_skip_delta_pct: float
paved: bool paved: bool
#: 법정 평면 최소곡선반지름(m) — 위반 표시 기준(2026-09-06). 0이면 판정하지 않는다.
min_plan_radius_m: float = 0.0
#: 배향곡선 하한(m) — 이보다 급하면 경고만 낸다.
hairpin_min_radius_m: float = 0.0
@classmethod @classmethod
def from_config( def from_config(
@@ -60,6 +64,7 @@ class AlignmentPolicy:
max_grade_pct: float, max_grade_pct: float,
curve_skip_delta_pct: float, curve_skip_delta_pct: float,
paved: bool, paved: bool,
min_plan_radius_m: float = 0.0,
) -> "AlignmentPolicy": ) -> "AlignmentPolicy":
config = FOREST_ROAD_PROFILE_ALIGNMENT config = FOREST_ROAD_PROFILE_ALIGNMENT
return cls( return cls(
@@ -76,6 +81,8 @@ class AlignmentPolicy:
max_grade_pct=float(max_grade_pct), max_grade_pct=float(max_grade_pct),
curve_skip_delta_pct=float(curve_skip_delta_pct), curve_skip_delta_pct=float(curve_skip_delta_pct),
paved=bool(paved), 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 @classmethod
@@ -140,6 +147,9 @@ class AlignmentPolicy:
"max_grade_pct": self.max_grade_pct, "max_grade_pct": self.max_grade_pct,
"curve_skip_delta_pct": self.curve_skip_delta_pct, "curve_skip_delta_pct": self.curve_skip_delta_pct,
"paved": self.paved, "paved": self.paved,
# 평면 곡선 판정값 — 화면 위반 표시가 쓴다(2026-09-06).
"min_plan_radius_m": self.min_plan_radius_m,
"hairpin_min_radius_m": self.hairpin_min_radius_m,
} }
@@ -30,6 +30,7 @@ from B05_Profile.B05_Profile_Engine_Grade import (
GradeDesignOptions, GradeDesignOptions,
detect_main_direction, detect_main_direction,
ground_profile, ground_profile,
legal_plan_radius_min_m,
) )
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
ALIGNMENT_SCHEMA_VERSION, ALIGNMENT_SCHEMA_VERSION,
@@ -179,6 +180,8 @@ def design_ground_following_profile(
max_grade_pct=options.max_grade_pct, max_grade_pct=options.max_grade_pct,
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
paved=options.paved, 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) warnings = list(options.warnings)
@@ -270,6 +273,8 @@ def design_pipe_anchored_profile(
max_grade_pct=options.max_grade_pct, max_grade_pct=options.max_grade_pct,
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
paved=options.paved, 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) warnings = list(options.warnings)
@@ -385,6 +390,8 @@ def design_alignment_profile(
max_grade_pct=options.max_grade_pct, max_grade_pct=options.max_grade_pct,
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
paved=options.paved, 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) warnings = list(options.warnings)
@@ -100,6 +100,8 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
"chainage_m": cross_section.get("chainage_m"), "chainage_m": cross_section.get("chainage_m"),
"center_z": cross_section.get("center_z"), "center_z": cross_section.get("center_z"),
"azimuth_deg": cross_section.get("azimuth_deg"), "azimuth_deg": cross_section.get("azimuth_deg"),
# 평면 곡선반경 — 곡선부 확폭·최소곡선반지름 판정이 쓴다(2026-09-06).
"plan_radius_m": cross_section.get("plan_radius_m"),
"sample_count": len(samples), "sample_count": len(samples),
"min_elevation_m": min(valid_z) if valid_z else None, "min_elevation_m": min(valid_z) if valid_z else None,
"max_elevation_m": max(valid_z) if valid_z else None, "max_elevation_m": max(valid_z) if valid_z else None,
@@ -11,6 +11,7 @@ from typing import Any
import numpy as np import numpy as np
from B05_Profile.B05_Profile_Engine_Geometry import circumradius_2d
from common_util.common_util_surface_sampler import SurfaceElevationSampler from common_util.common_util_surface_sampler import SurfaceElevationSampler
from config.config_system import ( from config.config_system import (
SECTION_CROSS_HALF_WIDTH_M, 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 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( def generate_sections(
polyline: np.ndarray | list[list[float]], polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler, sampler: SurfaceElevationSampler,
@@ -167,6 +205,10 @@ def generate_sections(
] ]
) )
left_axes = np.column_stack([-tangents[:, 1], tangents[:, 0]]) 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( offsets = np.arange(
-options.cross_half_width_m, -options.cross_half_width_m,
@@ -240,6 +282,7 @@ def generate_sections(
"center_y": round(float(station_xy[index, 1]), 6), "center_y": round(float(station_xy[index, 1]), 6),
"center_z": _float_or_none(center_z), "center_z": _float_or_none(center_z),
"azimuth_deg": round(azimuth, 6), "azimuth_deg": round(azimuth, 6),
"plan_radius_m": plan_radii[index],
# 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). 사용자 변경 시 확정에서 덮어쓴다. # 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). 사용자 변경 시 확정에서 덮어쓴다.
"uphill_side": uphill_side, "uphill_side": uphill_side,
"frame": frame, "frame": frame,
+4
View File
@@ -387,7 +387,11 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minUphillGrade.wrapper, minUphillGrade.wrapper,
minDownhillGrade.wrapper, minDownhillGrade.wrapper,
); );
// [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고
// 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다.
// 코드·API 는 그대로 두고 버튼만 뺀다. 노선 변경은 계획노선 편집이 대신한다.
const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled");
solveButton.hidden = true;
routeCalc.body.append( routeCalc.body.append(
algorithmField.root, algorithmField.root,
paved.wrapper, paved.wrapper,
@@ -28,6 +28,10 @@ export interface AlignmentPolicy {
max_grade_pct: number; max_grade_pct: number;
curve_skip_delta_pct: number; curve_skip_delta_pct: number;
paved: boolean; paved: boolean;
/** 법정 평면 최소곡선반지름(m) — 위반 표시 기준. 0이면 판정하지 않는다(옛 저장분). */
min_plan_radius_m?: number;
/** 배향곡선 하한(m) — 이보다 급하면 경고만 낸다(2026-09-06 사용자 확정). */
hairpin_min_radius_m?: number;
} }
export interface AlignmentNode { export interface AlignmentNode {
@@ -45,6 +45,8 @@ export interface BalanceBarParams {
/** (2026-09-06 ). /** (2026-09-06 ).
* . */ * . */
massHaul?: MassHaulSummaryValues | null; massHaul?: MassHaulSummaryValues | null;
/** 법정 평면 최소곡선반지름 위반(2026-09-06) — 자동 보정 없이 **경고만** 낸다. */
planCurve?: { count: number; worstRadiusM: number; limitM: number; hairpin: number } | null;
/** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */ /** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */
onResetAll: () => void; onResetAll: () => void;
} }
@@ -93,6 +95,16 @@ export function renderBalanceBar(params: BalanceBarParams): void {
if (mass) { if (mass) {
entries.push(["누가토량", volume(mass.finalM3), mass.finalM3 < 0 ? "over" : undefined]); 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곳"은 화면 폭만 먹는다. // 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다.
if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded}`, "over"]); if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded}`, "over"]);
// 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은 // 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은
@@ -107,6 +119,15 @@ export function renderBalanceBar(params: BalanceBarParams): void {
item.append(caption, document.createTextNode(value)); item.append(caption, document.createTextNode(value));
// 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 — // 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 —
// 같은 사실을 두 번 적지 않는다(2026-08-19 재편). // 같은 사실을 두 번 적지 않는다(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) { if (label === "누가토량" && mass) {
item.title = [ item.title = [
`절토(자연) ${volume(mass.cutNaturalM3)} · 절토(다짐) ${volume(mass.cutCompactedM3)}`, `절토(자연) ${volume(mass.cutNaturalM3)} · 절토(다짐) ${volume(mass.cutCompactedM3)}`,
@@ -325,6 +325,34 @@ export function createRouteProfilePanel(
return samples[samples.length - 1][field]; 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 { function renderBalance(): void {
const minCoverViolations = findMinCoverViolations( const minCoverViolations = findMinCoverViolations(
minCoverTargets, minCoverTargets,
@@ -332,6 +360,7 @@ export function createRouteProfilePanel(
(chainageM) => sampleAt(chainageM, "elevation_m"), (chainageM) => sampleAt(chainageM, "elevation_m"),
); );
renderBalanceBar({ renderBalanceBar({
planCurve: planCurveWarning(),
minCoverViolations, minCoverViolations,
balanceBar, balanceBar,
tools: tools.render(), tools: tools.render(),
+2
View File
@@ -116,6 +116,8 @@ export interface SectionStation {
structure?: string; structure?: string;
center_z: number | null; center_z: number | null;
azimuth_deg: number | null; azimuth_deg: number | null;
/** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */
plan_radius_m?: number | null;
center_x: number; center_x: number;
center_y: number; center_y: number;
/** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */ /** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */
+11
View File
@@ -442,6 +442,17 @@ FOREST_ROAD_PROFILE_CRITERIA = {
"min_curve_length_m": 20.0, "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이 # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이
# 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서 # 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서
# 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른 # 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른