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:
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user