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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 자동 판정 + 사용자 변경. */
|
||||
|
||||
@@ -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 고정이다. 사용자가 화면에서 고른
|
||||
|
||||
Reference in New Issue
Block a user