fix(B05): 종단곡선 기준을 L로 전환하고 테이블 곡선 R·L 누락 수정

영구저장소를 직접 확인하니 R·L은 정상 저장돼 있었고, 안 보인 원인은 둘이었다.

호가 너무 짧았다
- 배관 정착 선형은 변화점 대수차가 1~3%로 작아 R 기준(250m)에서 L = R × A 가
  3~7m. 350m 연장 도면에서 화면상 10~20px이라 직선과 구분되지 않았다.
- 곡선 기준을 R에서 L로 바꿨다: pipe_anchor_curve_length_m = 15.0 신설,
  R = L / A 로 역산(법정 최소반경 하한 유지). 표고 보정 반복마다 대수차가
  변하므로 R도 매 회 다시 역산한다. 변화점마다 호 길이가 같아 도면이 고르다.

테이블 곡선 행이 규칙 측점만 순회했다
- buildCurveRows가 20m 격자 측점만 돌아 배관 변화점(84.3m 등)의 R·L 셀이 아예
  그려지지 않았다. 격자 밖 변화점 셀을 추가해 선택·하이라이트와 무관하게
  기본으로 값이 보이게 했다.

검증: solve 재실행 후 84.30/200.92/275.71m 호 길이 정확히 15.00m, 149.73m만
대수차 25.2%로 최소반경 바닥에 걸려 46.07m(정상). 배관 4곳 계획고=지반고 유지.
typecheck·vite build·ruff·B03 테스트 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 20:35:15 +09:00
co-authored by Claude Opus 5
parent 1ee6516dd5
commit d21b0ec51d
4 changed files with 82 additions and 11 deletions
@@ -41,6 +41,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Solver import (
solve_alignment_elevations,
station_breakpoints,
)
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
ALIGNMENT_PROFILE_ID = "design_grade_line"
ALIGNMENT_BASIS = "station_alignment"
@@ -133,9 +134,10 @@ def design_pipe_anchored_profile(
대칭 종단곡선은 원래 변화점(꼭짓점)을 지나지 않으므로, 변화점 표고를 반복 보정해
**곡선이 정확히 배관 지반고를 통과**하도록 맞춘다(중앙종거만큼 꼭짓점을 밀어낸다).
반경은 설계 기준의 종단곡선 최소 반경(`options.min_vertical_radius_m`,
config_system 법정 기준 해석값)을 기본으로 각 변화점에 지정하며, 이 값은 편집
델타(curve_radii)로 저장돼 사용자가 B05 테이블에서 그대로 고칠 수 있다.
는 **길이 L을 기준**으로 잡는다(`pipe_anchor_curve_length_m`). 변화점 사이 대수차 A가
작아 R을 고정하면 L = R × A 가 변화점마다 널뛰기 때문이다. R = L / A 로 역산해 저장하며
법정 최소반경(`options.min_vertical_radius_m`) 아래로는 내려가지 않게 눌러 준다.
이 R은 편집 델타(curve_radii)로 저장돼 사용자가 B05 테이블에서 그대로 고칠 수 있다.
기울기 위반은 막지 않고 경고로 남긴다 — 배관 위치가 우선이고 조정은 사용자 몫이다.
"""
options.validate()
@@ -185,18 +187,30 @@ def design_pipe_anchored_profile(
target[0] = fixed[0]
target[-1] = fixed[1]
# 각 배관 변화점의 호 반경 기본값 — 설계 기준의 종단곡선 최소 반경.
anchor_radii = {chainage_key(a): float(options.min_vertical_radius_m) for a in anchors}
target_length = float(FOREST_ROAD_PROFILE_ALIGNMENT["pipe_anchor_curve_length_m"])
min_radius = float(options.min_vertical_radius_m)
edits = dict(edits or {})
edits["curve_radii"] = {**anchor_radii, **(edits.get("curve_radii") or {})}
user_radii = dict(edits.get("curve_radii") or {})
def radii_for(nodes_z: np.ndarray) -> dict[str, float]:
"""현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다)."""
spans = base_s[1:] - base_s[:-1]
grades = (nodes_z[1:] - nodes_z[:-1]) / np.where(spans > 0, spans, 1.0)
resolved: dict[str, float] = {}
for index in range(1, len(base_s) - 1):
delta = abs(float(grades[index] - grades[index - 1]))
radius = target_length / delta if delta > 1e-9 else min_radius
resolved[chainage_key(base_s[index])] = max(radius, min_radius)
return {**resolved, **user_radii}
# 대칭 종단곡선은 꼭짓점(변화점)을 지나지 않는다 — 곡선이 배관 지반고를 통과하도록
# 변화점 표고를 반복 보정한다(호가 안쪽으로 파고드는 중앙종거만큼 꼭짓점을 밀어낸다).
# 오차는 반복마다 중앙종거의 고차항만 남아 수 회면 mm 아래로 떨어진다.
# 표고가 움직이면 대수차도 변하므로 R도 매 회 다시 역산한다.
base_z = target.copy()
radii_for_iter = edits["curve_radii"]
radii = radii_for(base_z)
for _ in range(12):
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii_for_iter)
radii = radii_for(base_z)
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii)
plan_at = evaluate(base_s, base_z, curves, base_s)
error = target - plan_at
error[0] = 0.0
@@ -204,6 +218,7 @@ def design_pipe_anchored_profile(
if float(np.max(np.abs(error))) < 1e-4:
break
base_z = base_z + error
edits["curve_radii"] = radii
alignment = build_alignment(
base_s=base_s,
@@ -292,7 +292,12 @@ function curveTitle(curve: AlignmentCurve): string {
* 곡선이 없는 측점에도 **빈 칸을 만든다**. 변화점에만 셀을 두면 세로 구분선이 띄엄띄엄
* 끊겨 위쪽 측점값 행들과 격자가 맞지 않는다.
*/
function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLElement[] {
function buildCurveRows(
options: ProfileTableOptions,
centers: number[],
x: (chainage: number) => number,
cellWidth: number,
): HTMLElement[] {
const { alignment, onCurveRadiusChange } = options;
const lengthRow = createRow(
"b05-profile-table__row--curve is-group-start",
@@ -339,6 +344,42 @@ function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLEl
placeCell(radiusRow, centers[index], radiusCell);
});
// 규칙 측점 격자에 없는 변화점(배관 자리처럼 임의 chainage에 승격된 점)의 곡선도
// **기본으로** 값을 보여 준다 — 격자에만 셀을 두면 배관 변화점의 R·L이 표에서 통째로
// 빠져 "R이 안 들어간 것"처럼 읽힌다(2026-08-03 사용자 보고).
const stationKeys = new Set(alignment.stations.map((row) => row.chainage_m.toFixed(3)));
alignment.curves.forEach((curve) => {
const key = curve.chainage_m.toFixed(3);
if (stationKeys.has(key)) return;
const lengthCell = element("span", "b05-profile-table__curve is-floating");
const radiusCell = element("span", "b05-profile-table__curve is-floating");
radiusCell.append(
curveInput(
curve,
curve.r_m.toFixed(1),
`종단곡선 반경 R (m)
${curveTitle(curve)}`,
(r) => r,
),
);
lengthCell.append(
curveInput(
curve,
curve.l_m.toFixed(2),
`종단곡선 길이 L (m)
${curveTitle(curve)}`,
(l) => (curve.l_m > 1e-9 ? (l * curve.r_m) / curve.l_m : null),
),
);
[lengthCell, radiusCell].forEach((cell) => {
if (curve.skip_allowed) cell.classList.add("is-optional");
if (curve.omitted) cell.classList.add("is-omitted");
cell.style.width = `${cellWidth}px`;
});
placeCell(lengthRow, x(curve.chainage_m), lengthCell);
placeCell(radiusRow, x(curve.chainage_m), radiusCell);
});
function curveInput(
curve: AlignmentCurve,
value: string,
@@ -540,7 +581,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
});
table.append(row);
});
table.append(...buildCurveRows(options, centers));
table.append(...buildCurveRows(options, centers, x, cellWidth));
// 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다.
// 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다.
+7
View File
@@ -1146,3 +1146,10 @@
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
outline: 1px solid var(--color-primary);
}
/* 규칙 측점 격자 밖 변화점(배관 자리)의 곡선 R·L 셀 — 격자 셀 위에 얹혀 값을 보인다. */
.b05-profile-table__curve.is-floating {
z-index: 2;
background: var(--color-surface-raised);
outline: 1px solid var(--color-border);
}
+8
View File
@@ -554,6 +554,14 @@ 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 = R × A 가 변화점마다 3~50m로 널뛰고, 작은 쪽은 도면에서 직선과 구분되지 않는다
# (2026-08-03 사용자 지적). 그래서 **L을 기준값으로 고정**하고 R = L / A 로 역산한다.
# 결과적으로 호 길이가 변화점마다 같아 도면이 고르게 읽힌다.
# 인접 직선이 짧아 `curve_tangent_max_ratio`에 걸리면 자동으로 줄고 경고가 남는다.
# 호를 더/덜 길게 보이려면 여기만 고친다.
"pipe_anchor_curve_length_m": 15.0,
# 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율.
# 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다.
"curve_tangent_max_ratio": 0.45,