feat(B05): 종단곡선 기준을 모든 변화점에서 길이 L로 통일

2026-08-03(d21b0ec)에 곡선 기준을 R에서 L로 바꿨지만 배관 정착 계획선 산출부
한 곳에만 들어갔다. 공용 곡선 계산기와 정책값은 R 기준(측점간격 20m ×
curve_radius_ratio 0.40 = 8m)으로 남아, 사용자가 변화점을 새로 추가하면 그 자리만
R=8m가 적용됐다(대수차 3%면 L=0.24m — 도면에서 곡선이 사라진다). 화면 요약도
"기본 R 8.0 m"를 그대로 보여줘 오해를 키웠다.

- config: pipe_anchor_curve_length_m -> default_curve_length_m(15.0)로 승격.
  배관 정착뿐 아니라 모든 변화점의 1차 기준값이다.
- AlignmentPolicy에 default_curve_length_m 추가, 정책 스냅샷에도 실어 보낸다.
  옛 R 기준 값(default_curve_radius_m)은 옛 저장분 호환용으로 남긴다.
- build_curves(백엔드)와 buildCurves(프론트) 규칙 일원화:
  사용자 지정 R > 기본 L > (옛 저장분) 기본 R × 대수차.
- 인접 직선이 짧아 목표 L을 못 넣으면 **넣을 수 있는 최대 L**까지만 줄이고 경고를
  남긴다(2026-08-08 사용자 지시). 경고 문구도 길이 우선으로 고쳤다.
- 옛 저장분(정책에 L 없음)은 양쪽 모두 R 기준을 유지한다 — 서버만 L로 올리면
  화면과 다른 곡선이 나온다. 새 기준은 계획선을 다시 산출할 때 적용된다.
- 패널 요약 라벨: "기본 R" -> "기본 곡선길이 L"(옛 저장분은 "기본 R(옛 저장분)").

검증(측점간격 20m, 대수차 3%): 기본 L=15.00m/R=500m, 사용자 R=300 지정 시
L=9.00m/R=300m, 인접 직선 10m면 L=9.00m로 잘리고 경고 1건, 옛 스냅샷은 종전대로
L=0.24m/R=8m. typecheck·ruff format·check 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 17:50:36 +09:00
co-authored by Claude Opus 5
parent 7a265e5db4
commit 91f86c5629
5 changed files with 57 additions and 22 deletions
@@ -38,6 +38,9 @@ class AlignmentPolicy:
station_interval_m: float
curve_radius_ratio: float
# 기본 종단곡선 길이 L(m). `None`은 **옛 R 기준 저장분**이라는 뜻이다 — 그 계획선은
# 저장 당시 기하를 그대로 지켜야 하므로 L을 새로 끼워 넣지 않는다.
default_curve_length_m: float | None
curve_tangent_max_ratio: float
curve_skip_legal_exception: bool
balance_tolerance_percent: float
@@ -62,6 +65,7 @@ class AlignmentPolicy:
return cls(
station_interval_m=float(station_interval_m),
curve_radius_ratio=float(config["curve_radius_ratio"]),
default_curve_length_m=float(config["default_curve_length_m"]),
curve_tangent_max_ratio=float(config["curve_tangent_max_ratio"]),
curve_skip_legal_exception=bool(config["curve_skip_legal_exception"]),
balance_tolerance_percent=float(config["balance_tolerance_percent"]),
@@ -83,6 +87,14 @@ class AlignmentPolicy:
curve_radius_ratio=float(
payload.get("curve_radius_ratio", config["curve_radius_ratio"])
),
# 옛 스냅샷에는 L이 없다(R 기준 시절). 그때는 저장 당시 기하를 그대로 지킨다 —
# 여기서 L을 끼워 넣으면 화면(옛 정책이면 R로 계산)과 서버가 다른 곡선을 낸다.
# 새 기준으로 올리려면 계획선을 다시 산출해야 한다.
default_curve_length_m=(
float(payload["default_curve_length_m"])
if payload.get("default_curve_length_m")
else None
),
curve_tangent_max_ratio=float(
payload.get("curve_tangent_max_ratio", config["curve_tangent_max_ratio"])
),
@@ -105,10 +117,11 @@ class AlignmentPolicy:
@property
def default_curve_radius_m(self) -> float:
"""측점간격 기준 기본 종단곡선 반경.
"""측점간격 기준 기본 종단곡선 반경 — **옛 R 기준 값(호환용)**.
R을 1차 값으로 고정해야 계획고를 편집해도 곡선 반경이 흔들리지 않는다.
곡선 길이는 L = R × |대수차| 로 따라 움직인다.
기준은 2026-08-08부터 길이 L이다(`default_curve_length_m`). 이 값은 옛 저장분을
읽는 화면이 계산을 이어갈 수 있도록 정책 스냅샷에 남겨 둘 뿐, 새 곡선 계산은
L을 먼저 본다.
"""
return max(1e-6, self.station_interval_m * self.curve_radius_ratio)
@@ -117,6 +130,7 @@ class AlignmentPolicy:
return {
"station_interval_m": self.station_interval_m,
"curve_radius_ratio": self.curve_radius_ratio,
"default_curve_length_m": self.default_curve_length_m,
"curve_tangent_max_ratio": self.curve_tangent_max_ratio,
"curve_skip_legal_exception": self.curve_skip_legal_exception,
"default_curve_radius_m": self.default_curve_radius_m,
@@ -200,24 +214,33 @@ def build_curves(
# 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능).
skip_allowed = not policy.paved and abs(delta) <= skip_delta + 1e-12
omitted = skip_allowed and policy.curve_skip_legal_exception
# 기준은 길이 L이다(2026-08-08 사용자 확정). 사용자가 이 변화점 R을 직접 지정했으면
# 그쪽이 이기고, 아니면 기본 L을 그대로 쓴다 — 대수차가 작아도 호가 사라지지 않는다.
requested = overrides.get(key)
try:
radius = float(requested) if requested is not None else policy.default_curve_radius_m
radius = float(requested) if requested is not None else None
except (TypeError, ValueError):
radius = policy.default_curve_radius_m
if not np.isfinite(radius) or radius <= 0:
radius = policy.default_curve_radius_m
length = radius * abs(delta)
radius = None
if radius is not None and (not np.isfinite(radius) or radius <= 0):
radius = None
if radius is not None:
length = radius * abs(delta)
elif policy.default_curve_length_m:
length = policy.default_curve_length_m
else:
# L이 없는 옛 저장분 — 그 시절 기준(R)으로 계산해 저장 당시 기하를 지킨다.
length = policy.default_curve_radius_m * abs(delta)
half_limit = (
min(float(spans[index - 1]), float(spans[index])) * policy.curve_tangent_max_ratio
)
# 인접 직선이 짧으면 넣을 수 있는 **최대 L**까지만 줄인다 — 0으로 죽이지 않는다.
half = min(length / 2.0, half_limit)
if half <= 1e-9:
continue
if length / 2.0 - half > 1e-6:
warnings.append(
f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 반경을 "
f"{half * 2.0 / abs(delta):.0f}m(길이 {half * 2.0:.1f}m)로 줄였습니다."
f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 길이를 "
f"{half * 2.0:.1f}m(반경 {half * 2.0 / abs(delta):.0f}m)로 줄였습니다."
)
length = half * 2.0
curves.append(
@@ -134,7 +134,7 @@ def design_pipe_anchored_profile(
대칭 종단곡선은 원래 변화점(꼭짓점)을 지나지 않으므로, 변화점 표고를 반복 보정해
**곡선이 정확히 배관 지반고를 통과**하도록 맞춘다(중앙종거만큼 꼭짓점을 밀어낸다).
호는 **길이 L을 기준**으로 잡는다(`pipe_anchor_curve_length_m`). 변화점 사이 대수차 A가
호는 **길이 L을 기준**으로 잡는다(`default_curve_length_m`). 변화점 사이 대수차 A가
작아 R을 고정하면 L = R × A 가 변화점마다 널뛰기 때문이다. R = L / A 로 역산해 저장하며
법정 최소반경(`options.min_vertical_radius_m`) 아래로는 내려가지 않게 눌러 준다.
이 R은 편집 델타(curve_radii)로 저장돼 사용자가 B05 테이블에서 그대로 고칠 수 있다.
@@ -187,7 +187,7 @@ def design_pipe_anchored_profile(
target[0] = fixed[0]
target[-1] = fixed[1]
target_length = float(FOREST_ROAD_PROFILE_ALIGNMENT["pipe_anchor_curve_length_m"])
target_length = float(FOREST_ROAD_PROFILE_ALIGNMENT["default_curve_length_m"])
min_radius = float(options.min_vertical_radius_m)
edits = dict(edits or {})
user_radii = dict(edits.get("curve_radii") or {})
@@ -18,6 +18,9 @@ export interface AlignmentPolicy {
curve_radius_ratio: number;
curve_tangent_max_ratio: number;
curve_skip_legal_exception: boolean;
/** 기본 종단곡선 길이 L(m) — 2026-08-08부터 이쪽이 기준이다. 옛 저장분은 없거나 null. */
default_curve_length_m?: number | null;
/** 옛 R 기준 값. L이 없는 저장분을 되살릴 때만 쓴다. */
default_curve_radius_m: number;
balance_tolerance_percent: number;
edit_step_m: number;
@@ -218,16 +221,21 @@ function buildCurves(
const chainage = pvi[index].chainage_m;
const key = chainageKey(chainage);
const skipAllowed = !policy.paved && Math.abs(delta) <= skipDelta + 1e-12;
// 백엔드 build_curves와 같은 규칙 — 기준은 길이 L이고, 사용자가 지정한 R이 있으면 그쪽.
// 기본 L이 없는 옛 저장분만 R 기준으로 되돌아간다.
const requested = curveRadii[key];
const radius =
Number.isFinite(requested) && requested > 0 ? requested : policy.default_curve_radius_m;
const desired = radius * Math.abs(delta);
const hasRequested = Number.isFinite(requested) && requested > 0;
const fallbackLength = Number.isFinite(policy.default_curve_length_m)
? (policy.default_curve_length_m as number)
: policy.default_curve_radius_m * Math.abs(delta);
const desired = hasRequested ? requested * Math.abs(delta) : fallbackLength;
const halfLimit = Math.min(spanLeft, spanRight) * policy.curve_tangent_max_ratio;
// 인접 직선이 짧으면 넣을 수 있는 최대 L까지만 줄인다 — 0으로 죽이지 않는다.
const half = Math.min(desired / 2, halfLimit);
if (half <= 1e-9) continue;
if (desired / 2 - half > 1e-6) {
warnings.push(
`${chainage.toFixed(1)}m: 인접 직선이 짧아 종단곡선 반경 ${((half * 2) / Math.abs(delta)).toFixed(0)}m로 줄였습니다.`,
`${chainage.toFixed(1)}m: 인접 직선이 짧아 종단곡선 길이를 ${(half * 2).toFixed(1)}m(반경 ${((half * 2) / Math.abs(delta)).toFixed(0)}m)로 줄였습니다.`,
);
}
const length = half * 2;
+4 -1
View File
@@ -417,7 +417,10 @@ export function createRouteProfilePanel(
],
["변화점", `${alignment.pvi.length}`],
["종단곡선", `${alignment.curves.filter((curve) => !curve.omitted).length}`],
["기본 R", `${policy.default_curve_radius_m.toFixed(1)} m`],
// 기준은 길이 L이다. 옛 저장분(L 없음)만 그 시절 기준인 R을 그대로 밝혀 적는다.
Number.isFinite(policy.default_curve_length_m)
? ["기본 곡선길이 L", `${(policy.default_curve_length_m as number).toFixed(1)} m`]
: ["기본 R(옛 저장분)", `${policy.default_curve_radius_m.toFixed(1)} m`],
];
const editedCount = Object.keys(alignment.edits.station_offsets).length;
if (editedCount) entries.push(["편집 측점", `${editedCount}`, "edited"]);
+6 -5
View File
@@ -633,14 +633,15 @@ FOREST_ROAD_PROFILE_ALIGNMENT = {
# 도면상 곡선이 거의 드러나지 않는다. 곡선을 뚜렷하게 보려면 이 비율을 크게 올린다
# (예: 20.0 → R=400m, A=10%일 때 L=40m).
"curve_radius_ratio": 0.40,
# **배관 정착 계획선(1차 로직)의 기본 종단곡선 길이 L(m).**
# 이 선형은 변화점 사이 대수차 A가 작아(1~3%가 흔하다) 반경 R을 기준으로 잡으면
# **기본 종단곡선 길이 L(m) — 모든 변화점의 1차 기준값.**
# 임도 계획선은 변화점 사이 대수차 A가 작아(1~3%가 흔하다) 반경 R을 기준으로 잡으면
# L = R × A 가 변화점마다 3~50m로 널뛰고, 작은 쪽은 도면에서 직선과 구분되지 않는다
# (2026-08-03 사용자 지적). 그래서 **L을 기준값으로 고정**하고 R = L / A 로 역산한다.
# 결과적으로 호 길이가 변화점마다 같아 도면이 고르게 읽힌다.
# 인접 직선이 짧아 `curve_tangent_max_ratio`에 걸리면 자동으로 줄고 경고가 남는다.
# 호를 더/덜 길게 보이려면 여기만 고친다.
"pipe_anchor_curve_length_m": 15.0,
# 인접 직선이 짧아 `curve_tangent_max_ratio`에 걸리면 넣을 수 있는 **최대 L**까지만
# 줄이고 경고를 남긴다(0으로 죽이지 않는다 — 2026-08-08 사용자 지시).
# 사용자가 그 변화점 R을 직접 지정하면 그쪽이 이긴다. 호를 더/덜 길게 보이려면 여기만 고친다.
"default_curve_length_m": 15.0,
# 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율.
# 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다.
"curve_tangent_max_ratio": 0.45,