From bdbefd9472f1855bd5563cb559f04758a72e2261 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 15:02:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EA=B3=A1=EC=84=A0=EB=B6=80=20?= =?UTF-8?q?=ED=99=95=ED=8F=AD=EC=97=90=20=EC=95=9E=EB=92=A4=2010m=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=ED=8D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별표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) --- .../B05_Profile_Engine_Sections_Core.py | 55 +++++++++++++++++++ B06_Section/B06_Section_Api_Types.ts | 2 + B06_Section/B06_Section_Cross_Refresh.ts | 1 + B06_Section/B06_Section_Engine_Design.py | 20 +++++-- common_util/common_util_cross_design.ts | 9 ++- config/config_system_design.py | 5 ++ 6 files changed, 87 insertions(+), 5 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 9b5bb05a..9c1274e9 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -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, diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 5f2e5150..17a2e81a 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -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 자동 판정 + 사용자 변경. */ diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index bd18d731..1206212b 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -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 { diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 0d240498..6dfc383a 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -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) diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 3442507c..5e137e28 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -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)); } diff --git a/config/config_system_design.py b/config/config_system_design.py index 47b3dcd4..c239ab33 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -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."""