feat(B06): 곡선부 확폭에 앞뒤 10m 테이퍼 추가

별표2 는 확폭량 표만 주고 붙이는 방식이 없어, 곡선 시·종점에서 폭이 뚝 끊겼다.
곡선 앞뒤 CURVE_WIDENING_TAPER_M(10m) 구간에서 0 → W 로 잇는다.

- 측점 생성 때 확폭량을 한 번에 계산해 curve_widening_m 으로 저장. 테이퍼는
  이웃 측점을 봐야 하므로 측점 단위 계산으로는 낼 수 없다.
- 설계 엔진(파이썬·TS 짝)이 저장된 확폭량을 우선 쓰고, 없으면 반경 표값으로
  되돌아간다 — 옛 저장분도 그대로 돈다.

검증: pytest 389 passed(테이퍼 경계값 테스트 2건 추가), tsc --noEmit 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 15:02:58 +09:00
co-authored by Claude Opus 5
parent dd61754705
commit bdbefd9472
6 changed files with 87 additions and 5 deletions
@@ -14,11 +14,13 @@ 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 (
CURVE_WIDENING_TAPER_M,
SECTION_CROSS_HALF_WIDTH_M,
SECTION_CROSS_SAMPLE_INTERVAL_M,
SECTION_INCLUDE_ENDPOINT,
SECTION_LONG_SAMPLE_INTERVAL_M,
SECTION_STATION_INTERVAL_M,
curve_widening_m,
)
SECTION_SCHEMA_VERSION = 1
@@ -165,6 +167,52 @@ def _plan_radii(
return radii, outer_sides
def _curve_widenings(
station_chainage: np.ndarray,
radii: list[float | None],
outer_sides: list[str | None],
) -> tuple[list[float], list[str | None]]:
"""측점별 확폭량(m)과 그것이 붙는 쪽 — 곡선 앞뒤 테이퍼까지 반영한다.
표(별표2 Ⅰ.2.나.(4))는 곡선 안에서의 확폭량만 준다. 곡선 시·종점에서 폭이 뚝
끊기면 안 되므로, 앞뒤 `CURVE_WIDENING_TAPER_M` 구간에서 0 → W 로 잇는다
(2026-09-06 사용자 지시). 측점 간격이 테이퍼보다 넓으면 이 함수가 낼 중간값이
없고, 화면·3D 가 측점 사이를 이어 그리는 것으로 대신한다.
"""
base = [curve_widening_m(radius) for radius in radii]
widenings = list(base)
sides: list[str | None] = list(outer_sides)
# 확폭이 붙는 측점의 연속 덩어리(=곡선 구간)를 찾아 그 바깥으로 테이퍼를 편다.
runs: list[tuple[int, int]] = []
start: int | None = None
for index, value in enumerate(base):
if value > 0.0 and outer_sides[index] in ("left", "right"):
if start is None:
start = index
elif start is not None:
runs.append((start, index - 1))
start = None
if start is not None:
runs.append((start, len(base) - 1))
for first, last in runs:
for edge, step in ((first, -1), (last, 1)):
edge_chainage = float(station_chainage[edge])
index = edge + step
while 0 <= index < len(base):
distance = abs(float(station_chainage[index]) - edge_chainage)
if distance > CURVE_WIDENING_TAPER_M or base[index] > 0.0:
break
ratio = max(0.0, 1.0 - distance / CURVE_WIDENING_TAPER_M)
tapered = round(base[edge] * ratio, 4)
# 양쪽 곡선 사이에 낀 측점은 넓은 쪽을 따른다.
if tapered > widenings[index]:
widenings[index] = tapered
sides[index] = outer_sides[edge]
index += step
return widenings, sides
def generate_sections(
polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler,
@@ -220,6 +268,10 @@ def generate_sections(
# 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세
# 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다.
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
plan_widenings, plan_outer_sides = _curve_widenings(
station_chainage, plan_radii, plan_outer_sides
)
offsets = np.arange(
-options.cross_half_width_m,
@@ -296,6 +348,9 @@ def generate_sections(
"plan_radius_m": plan_radii[index],
# 곡선 바깥쪽 — 곡선부 확폭이 붙는 쪽(2026-09-06 사용자 확정).
"curve_outer_side": plan_outer_sides[index],
# 확폭량(m) — 표값에 곡선 앞뒤 테이퍼를 얹은 값. 설계는 반경이 아니라
# 이 값을 쓴다(테이퍼 측점은 반경이 없거나 커도 확폭이 남아 있다).
"curve_widening_m": plan_widenings[index],
# 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). 사용자 변경 시 확정에서 덮어쓴다.
"uphill_side": uphill_side,
"frame": frame,
+2
View File
@@ -120,6 +120,8 @@ export interface SectionStation {
plan_radius_m?: number | null;
/** 곡선 바깥쪽 — 확폭이 붙는 쪽(2026-09-06 사용자 확정). 직선이면 null. */
curve_outer_side?: "left" | "right" | null;
/** 확폭량(m) — 표값에 곡선 앞뒤 테이퍼를 얹은 값. 설계는 이 값을 우선 쓴다. */
curve_widening_m?: number | null;
center_x: number;
center_y: number;
/** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */
+1
View File
@@ -194,6 +194,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
section.curve_outer_side === "left" || section.curve_outer_side === "right"
? section.curve_outer_side
: null,
curveWideningM: section.curve_widening_m ?? null,
},
);
} catch {
+16 -4
View File
@@ -41,9 +41,10 @@ from config.config_system import (
SECTION_GROUND_TYPE_PRESET,
SECTION_MODES,
STANDARD_CROSS_SECTION,
curve_widening_m,
)
from config.config_system import (
curve_widening_m as _curve_widening_m,
)
# 사면이 원지반과 만났다고 볼 높이차(m). 이보다 크면 샘플 끝에서 잘린 것으로 본다.
_SLOPE_CLOSE_TOLERANCE_M = 0.01
@@ -463,10 +464,12 @@ def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]:
옛 저장분에는 두 값이 없어 확폭 없이 예전과 같은 단면이 나온다.
"""
if not isinstance(section, dict):
return {"plan_radius_m": None, "curve_outer_side": None}
return {"plan_radius_m": None, "curve_outer_side": None, "curve_widening_m": None}
return {
"plan_radius_m": section.get("plan_radius_m"),
"curve_outer_side": section.get("curve_outer_side"),
# 곡선 앞뒤 테이퍼가 얹힌 값 — 있으면 반경 표값 대신 이걸 쓴다(2026-09-06).
"curve_widening_m": section.get("curve_widening_m"),
}
@@ -486,6 +489,7 @@ def compute_cross_design(
surface_drop_m: float = 0.0,
plan_radius_m: float | None = None,
curve_outer_side: str | None = None,
curve_widening_m: float | None = None,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
@@ -544,7 +548,15 @@ def compute_cross_design(
)
soil_cut_ratio = _resolve_group("soil", standard)["cut_slope_ratio"]
# 곡선부 확폭 — 표는 하한이고, 확폭을 더한 유효너비가 법정 상한(5m)을 넘지 않게 자른다.
widening = curve_widening_m(plan_radius_m) if curve_outer_side in ("left", "right") else 0.0
# 저장된 확폭량(테이퍼 포함)이 있으면 그것을 쓰고, 없으면 반경 표값으로 되돌아간다.
if curve_outer_side in ("left", "right"):
widening = (
float(curve_widening_m)
if isinstance(curve_widening_m, (int, float))
else _curve_widening_m(plan_radius_m)
)
else:
widening = 0.0
if widening > 0.0:
room = max(CURVE_WIDENING_MAX_WIDTH_M - group["road_width_m"], 0.0)
widening = min(widening, room)
+8 -1
View File
@@ -86,6 +86,8 @@ export interface CrossDesignOptions {
planRadiusM?: number | null;
/** 곡선 **바깥쪽**("left"/"right") — 확폭이 붙는 쪽(2026-09-06 사용자 확정). */
curveOuterSide?: "left" | "right" | null;
/** 저장된 확폭량(m) — 곡선 앞뒤 테이퍼가 얹힌 값. 있으면 반경 표값 대신 쓴다. */
curveWideningM?: number | null;
}
export interface CrossDesignEdge {
@@ -271,8 +273,13 @@ export function computeCrossDesign(
// 곡선부 확폭 — 표는 하한이고, 확폭을 더한 유효너비가 법정 상한(5m)을 넘지 않게 자른다.
// 짝: 파이썬 `compute_cross_design`. 표·상한 값은 config 한 곳에서 온다.
const outerSide = options.curveOuterSide;
// 저장된 확폭량(테이퍼 포함)이 있으면 그것을 쓰고, 없으면 반경 표값으로 되돌아간다.
let widening =
outerSide === "left" || outerSide === "right" ? curveWideningM(options.planRadiusM) : 0;
outerSide === "left" || outerSide === "right"
? typeof options.curveWideningM === "number" && Number.isFinite(options.curveWideningM)
? options.curveWideningM
: curveWideningM(options.planRadiusM)
: 0;
if (widening > 0) {
widening = Math.min(widening, Math.max(CURVE_WIDENING_MAX_WIDTH_M - group.road_width_m, 0));
}
+5
View File
@@ -440,6 +440,11 @@ CURVE_WIDENING_TABLE_M: tuple[tuple[float, float, float], ...] = (
# 넘으면 그 자리에서 자르고 화면이 경고한다.
CURVE_WIDENING_MAX_WIDTH_M = 5.0
# 곡선 앞뒤에서 확폭을 0 → W 로 잇는 길이(m). 별표2에는 확폭량 표만 있고 붙이는 방식이
# 없어, 실무 관행(곡선 시·종점 앞뒤 10m 직선 테이퍼)을 따른다(2026-09-06 사용자 지시).
# 측점 간격(기본 20m)이 이보다 넓으면 화면에서는 측점 간 보간이 대신 이어 준다.
CURVE_WIDENING_TAPER_M = 10.0
def curve_widening_m(plan_radius_m: float | None) -> float:
"""평면 곡선반경으로 확폭량(m)을 정한다. 직선·45m 이상·값 없음은 0."""