diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 4a73a331..9b5bb05a 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -130,12 +130,17 @@ def _plan_radii( route_chainage: np.ndarray, station_chainage: np.ndarray, total: float, -) -> list[float | None]: - """측점마다 평면 곡선반경(m). 직선·측정 불가는 None.""" +) -> tuple[list[float | None], list[str | None]]: + """측점마다 (평면 곡선반경 m, 곡선 **바깥쪽**). 직선·측정 불가는 (None, None). + + 바깥쪽은 확폭이 붙는 쪽이다(2026-09-06 사용자 확정). 좌회전(반시계)이면 곡선 안쪽이 + 좌측이므로 바깥은 우측이다. offset 부호는 +가 좌측이라는 횡단 규약을 따른다. + """ arm = min(PLAN_RADIUS_ARM_M, max(total / 2.0, 0.0)) if arm < 0.5: - return [None] * len(station_chainage) + return [None] * len(station_chainage), [None] * len(station_chainage) radii: list[float | None] = [] + outer_sides: list[str | None] = [] for value in station_chainage: center = float(value) back = max(0.0, center - arm) @@ -143,15 +148,21 @@ def _plan_radii( # 시·종점에서는 한쪽 팔이 짧아진다 — 양쪽이 다 확보될 때만 잰다. if center - back < arm * 0.5 or ahead - center < arm * 0.5: radii.append(None) + outer_sides.append(None) continue trio = _interpolate_xy(points, route_chainage, np.array([back, center, ahead])) radius = circumradius_2d(trio[0], trio[1], trio[2]) - radii.append( - None - if not math.isfinite(radius) or radius >= PLAN_RADIUS_STRAIGHT_M - else round(radius, 3) - ) - return radii + if not math.isfinite(radius) or radius >= PLAN_RADIUS_STRAIGHT_M: + radii.append(None) + outer_sides.append(None) + continue + radii.append(round(radius, 3)) + # 외적 z 부호로 회전 방향을 본다: 양수 = 좌회전 → 안쪽이 좌측 → 바깥은 우측. + first = trio[1] - trio[0] + second = trio[2] - trio[1] + cross = float(first[0] * second[1] - first[1] * second[0]) + outer_sides.append("right" if cross > 0 else "left" if cross < 0 else None) + return radii, outer_sides def generate_sections( @@ -208,7 +219,7 @@ def generate_sections( # 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반 # 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세 # 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다. - plan_radii = _plan_radii(points, route_chainage, station_chainage, total) + plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total) offsets = np.arange( -options.cross_half_width_m, @@ -283,6 +294,8 @@ def generate_sections( "center_z": _float_or_none(center_z), "azimuth_deg": round(azimuth, 6), "plan_radius_m": plan_radii[index], + # 곡선 바깥쪽 — 곡선부 확폭이 붙는 쪽(2026-09-06 사용자 확정). + "curve_outer_side": plan_outer_sides[index], # 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). 사용자 변경 시 확정에서 덮어쓴다. "uphill_side": uphill_side, "frame": frame, diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 14539e1f..8e87d7b6 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -95,6 +95,7 @@ async def sync_uphill_overrides_into_designs( get_cross_section_designs, update_cross_section_design, ) + from B06_Section.B06_Section_Engine_Design import curve_widening_args from B06_Section.B06_Section_Router import _read_cross_design_inputs from B06_Section.B06_Section_Router_Design import ford_drop_at, ford_surface_drops @@ -123,7 +124,7 @@ async def sync_uphill_overrides_into_designs( next_ditch = side if next_mode == mode and design.get("ditch_side") == next_ditch: continue - samples, design_elevation, pavement_suggested = await asyncio.to_thread( + samples, design_elevation, pavement_suggested, cross_record = await asyncio.to_thread( _read_cross_design_inputs, project_root, longitudinal_file_path, float(chainage) ) next_design = compute_cross_design( @@ -139,6 +140,7 @@ async def sync_uphill_overrides_into_designs( two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=ford_drop_at(float(chainage), ford_drops), + **curve_widening_args(cross_record), ) next_design["status"] = design.get("status", "provisional") next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 5eb16598..8529efac 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -118,6 +118,8 @@ export interface SectionStation { azimuth_deg: number | null; /** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */ plan_radius_m?: number | null; + /** 곡선 바깥쪽 — 확폭이 붙는 쪽(2026-09-06 사용자 확정). 직선이면 null. */ + curve_outer_side?: "left" | "right" | null; center_x: number; center_y: number; /** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */ @@ -401,6 +403,11 @@ export interface CrossDesign { fill_slope_ratio: number; roadbed_width_m: number; carriageway_width_m: number; + /** 규격 차도 폭(확폭 전, m) — 확폭 라벨·수량이 둘을 나눠 쓴다(2026-09-06). */ + carriageway_standard_width_m?: number; + /** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */ + widening_left_m?: number; + widening_right_m?: number; cross_slope_pct: number; ditch: | { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number } diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 8f91e139..bd18d731 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -188,6 +188,12 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, // 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다. surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_m : 0, + // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). + planRadiusM: section.plan_radius_m ?? null, + curveOuterSide: + section.curve_outer_side === "left" || section.curve_outer_side === "right" + ? section.curve_outer_side + : null, }, ); } catch { diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index a9fa8a11..0d240498 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -35,11 +35,13 @@ from B06_Section.B06_Section_Engine_Areas import ( _trapezoid_areas, ) from config.config_system import ( + CURVE_WIDENING_MAX_WIDTH_M, SECTION_DITCH_SIDES, SECTION_DITCH_TYPES, SECTION_GROUND_TYPE_PRESET, SECTION_MODES, STANDARD_CROSS_SECTION, + curve_widening_m, ) @@ -159,11 +161,17 @@ class _SectionGeometry: rock_boundary_offset_m: float | None = None, two_stage_slope: bool = False, ditch_enabled: bool | None = None, + widening_left_m: float = 0.0, + widening_right_m: float = 0.0, ) -> None: half_road = group["road_width_m"] / 2.0 - self.half_road = half_road # 차도 반폭(노견 제외) — 포장 범위 기준 - self.left_extent = half_road + group["shoulder_left_m"] # 좌(+) 노면 끝 - self.right_extent = half_road + group["shoulder_right_m"] # 우(-) 노면 끝 + # 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽). + # 그래서 반폭을 좌·우로 나눠 든다 — 확폭이 0이면 예전과 똑같은 대칭 단면이다. + self.half_road_left = half_road + max(widening_left_m, 0.0) + self.half_road_right = half_road + max(widening_right_m, 0.0) + self.half_road = half_road # 규격 차도 반폭(확폭 전) — 수량·표기 기준 + self.left_extent = self.half_road_left + group["shoulder_left_m"] # 좌(+) 노면 끝 + self.right_extent = self.half_road_right + group["shoulder_right_m"] # 우(-) 노면 끝 self.z_center = design_elevation_m self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사 self.fill_ratio = max(group["fill_slope_ratio"], 1e-6) @@ -448,6 +456,20 @@ class _SectionGeometry: return points +def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]: + """측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다. + + 측점마다 실려 오는 값이라 호출자마다 따로 꺼내 쓰면 빠뜨리기 쉽다(2026-09-06). + 옛 저장분에는 두 값이 없어 확폭 없이 예전과 같은 단면이 나온다. + """ + if not isinstance(section, dict): + return {"plan_radius_m": None, "curve_outer_side": None} + return { + "plan_radius_m": section.get("plan_radius_m"), + "curve_outer_side": section.get("curve_outer_side"), + } + + def compute_cross_design( samples: list[dict[str, Any]], design_elevation_m: float | None, @@ -462,6 +484,8 @@ def compute_cross_design( two_stage_slope: bool = True, ditch_enabled: bool | None = None, surface_drop_m: float = 0.0, + plan_radius_m: float | None = None, + curve_outer_side: str | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -472,6 +496,10 @@ def compute_cross_design( standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용. two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제). + plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는 + 입력이며, None·45m 이상이면 확폭이 없다. + curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다 + (2026-09-06 사용자 확정). 값이 없으면 확폭을 넣지 않는다. surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). @@ -515,6 +543,14 @@ def compute_cross_design( preset_key == "rock" and two_stage_slope and rock_boundary_offset_m is not None ) 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 widening > 0.0: + room = max(CURVE_WIDENING_MAX_WIDTH_M - group["road_width_m"], 0.0) + widening = min(widening, room) + widening_left = widening if curve_outer_side == "left" else 0.0 + widening_right = widening if curve_outer_side == "right" else 0.0 + geometry = _SectionGeometry( design_elevation_m=design_elevation_m, group=group, @@ -527,6 +563,8 @@ def compute_cross_design( rock_boundary_offset_m=rock_boundary_offset_m, two_stage_slope=enable_two_stage, ditch_enabled=ditch_enabled, + widening_left_m=widening_left, + widening_right_m=widening_right, ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 @@ -617,7 +655,11 @@ def compute_cross_design( "two_stage_slope": bool(geometry.two_stage), "fill_slope_ratio": round(geometry.fill_ratio, 4), "roadbed_width_m": round(geometry.left_extent + geometry.right_extent, 4), - "carriageway_width_m": round(group["road_width_m"], 4), + "carriageway_width_m": round(geometry.half_road_left + geometry.half_road_right, 4), + # 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다. + "carriageway_standard_width_m": round(group["road_width_m"], 4), + "widening_left_m": round(geometry.half_road_left - geometry.half_road, 4), + "widening_right_m": round(geometry.half_road_right - geometry.half_road, 4), "cross_slope_pct": round(cross_slope_pct, 4), "ditch": ditch_spec, "ditch_enabled": bool(geometry.has_ditch), @@ -636,12 +678,12 @@ def compute_cross_design( # 차도(노견 제외) 양 끝점 — 포장 범위 기준(D-5). "carriageway_edges": { "left": { - "offset_m": round(geometry.half_road, 4), - "elevation_m": round(geometry.road_z(geometry.half_road), 4), + "offset_m": round(geometry.half_road_left, 4), + "elevation_m": round(geometry.road_z(geometry.half_road_left), 4), }, "right": { - "offset_m": round(-geometry.half_road, 4), - "elevation_m": round(geometry.road_z(-geometry.half_road), 4), + "offset_m": round(-geometry.half_road_right, 4), + "elevation_m": round(geometry.road_z(-geometry.half_road_right), 4), }, }, "design_elevation_m": round(float(design_elevation_m), 4), diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 9c7a7a16..a865299e 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -21,7 +21,7 @@ from B05_Profile.B05_Profile_Engine_Sections import ( ) from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets -from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B06_Section.B06_Section_Repository import ( count_cross_sections, create_longitudinal_section, @@ -607,7 +607,7 @@ async def compute_cross_section_design( ) stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) - samples, design_elevation, pavement_suggested = await asyncio.to_thread( + samples, design_elevation, pavement_suggested, cross_record = await asyncio.to_thread( _read_cross_design_inputs, project_root, str(longitudinal["longitudinal_file_path"]), @@ -626,6 +626,7 @@ async def compute_cross_section_design( two_stage_slope=request.two_stage_slope, ditch_enabled=request.ditch_enabled, surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)), + **curve_widening_args(cross_record), ) # B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다. design["status"] = "provisional" diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 9b67101e..af631349 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -9,7 +9,7 @@ from B05_Profile.B05_Profile_Engine_Sections import cross_filename from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets -from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M @@ -178,6 +178,7 @@ def enforce_pavement_ranges( two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), + **curve_widening_args(section), ) except (ValueError, KeyError): continue @@ -228,6 +229,7 @@ def enforce_ford_surface_drops( two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=wanted, + **curve_widening_args(section), ) except (ValueError, KeyError): continue @@ -251,7 +253,8 @@ def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]: def read_cross_design_inputs( project_root: Path, longitudinal_file_path: str, chainage_m: float -) -> tuple[list[dict], float | None, bool]: +) -> tuple[list[dict], float | None, bool, dict[str, Any]]: + """(지반 샘플, 계획고, 포장 제안, 측점 기록) — 측점 기록은 곡선부 확폭 입력을 담고 있다.""" root = project_root.resolve() longitudinal_path = (root / longitudinal_file_path).resolve() if root not in longitudinal_path.parents: @@ -269,7 +272,7 @@ def read_cross_design_inputs( samples = cross.get("samples") if isinstance(cross, dict) else None if not isinstance(samples, list): raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.") - return samples, design_elevation, suggested + return samples, design_elevation, suggested, cross def attach_default_designs( @@ -298,6 +301,7 @@ def attach_default_designs( standard=standard, rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, surface_drop_m=ford_drop_at(chainage, ford_drops), + **curve_widening_args(section), ) design.update(status="provisional", pavement_suggested=suggested) section["design"] = design @@ -349,6 +353,7 @@ def recompute_designs_for_alignment( two_stage_slope=bool(stored.get("two_stage_slope", True)), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), + **curve_widening_args(section), ) except (ValueError, KeyError): continue @@ -408,6 +413,7 @@ def compute_default_designs( standard=standard, rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, surface_drop_m=ford_drop_at(chainage_m, ford_drops), + **curve_widening_args(cross), ) design["status"] = "provisional" design["pavement_suggested"] = suggested diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 6e0349aa..a04a81bd 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -647,6 +647,19 @@ export function appendCrossDesignOverlay( tick.setAttribute("class", "b06-chart__carriageway-tick"); svg.append(tick); } + // 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다. + // 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다. + const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0); + const standardWidth = design.carriageway_standard_width_m; + const label = document.createElementNS(SVG_NS, "text"); + label.setAttribute("x", String((x(edges.left.offset_m) + x(edges.right.offset_m)) / 2)); + label.setAttribute("y", String(toDisplayY(edges.left.elevation_m) - 6)); + label.setAttribute("class", "b06-chart__carriageway-label"); + label.textContent = + widened > 0.001 && typeof standardWidth === "number" + ? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})` + : `노폭 ${design.carriageway_width_m.toFixed(2)}m`; + svg.append(label); } } diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index a2c430b5..d6728a14 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -147,6 +147,14 @@ stroke-width: 1.2; } +/* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */ +.b06-chart__carriageway-label { + fill: var(--color-royal-amethyst); + font-size: 9px; + text-anchor: middle; + pointer-events: none; +} + /* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */ .b06-chart__rock-boundary { fill: none; diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index fed5bbbd..ef7bfcc3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -9,7 +9,7 @@ import re from pathlib import Path from typing import Any -from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, build_cross_drawing, @@ -345,6 +345,7 @@ def _cross_design_line( design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))), ground_type="soil", section_mode="left_cut", + **curve_widening_args(source), ) return design["design_line"] except (ValueError, KeyError, OSError, json.JSONDecodeError): @@ -677,6 +678,7 @@ def _recompute_confirmed_design( ground_type=designation["ground_type"], section_mode=designation["section_mode"], ditch_side=designation.get("ditch_side"), + **curve_widening_args(source), ) design["status"] = "confirmed" return design diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 3aaeebee..3442507c 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -25,7 +25,12 @@ import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; // 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04). -import { SectionGeometry, type ResolvedGroup } from "./common_util_cross_design_geometry"; +import { + CURVE_WIDENING_MAX_WIDTH_M, + SectionGeometry, + curveWideningM, + type ResolvedGroup, +} from "./common_util_cross_design_geometry"; /** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */ const GROUND_TYPE_PRESET: Record = { @@ -77,6 +82,10 @@ export interface CrossDesignOptions { ditchEnabled?: boolean | null; /** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */ surfaceDropM?: number; + /** 이 측점의 평면 곡선반경(m) — 곡선부 확폭을 정하는 입력. 직선이면 null. */ + planRadiusM?: number | null; + /** 곡선 **바깥쪽**("left"/"right") — 확폭이 붙는 쪽(2026-09-06 사용자 확정). */ + curveOuterSide?: "left" | "right" | null; } export interface CrossDesignEdge { @@ -96,6 +105,11 @@ export interface CrossDesignResult { fill_slope_ratio: number; roadbed_width_m: number; carriageway_width_m: number; + /** 규격 차도 폭(확폭 전, m) — 확폭 라벨·수량이 둘을 나눠 쓴다(2026-09-06). */ + carriageway_standard_width_m?: number; + /** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */ + widening_left_m?: number; + widening_right_m?: number; cross_slope_pct: number; ditch: Record; ditch_enabled: boolean; @@ -254,8 +268,19 @@ export function computeCrossDesign( // 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다. const enableTwoStage = presetKey === "rock" && (options.twoStageSlope ?? true) && rockBoundaryOffsetM !== null; + // 곡선부 확폭 — 표는 하한이고, 확폭을 더한 유효너비가 법정 상한(5m)을 넘지 않게 자른다. + // 짝: 파이썬 `compute_cross_design`. 표·상한 값은 config 한 곳에서 온다. + const outerSide = options.curveOuterSide; + let widening = + outerSide === "left" || outerSide === "right" ? curveWideningM(options.planRadiusM) : 0; + if (widening > 0) { + widening = Math.min(widening, Math.max(CURVE_WIDENING_MAX_WIDTH_M - group.road_width_m, 0)); + } + const geometry = new SectionGeometry({ designElevationM: centerElevation, + wideningLeftM: outerSide === "left" ? widening : 0, + wideningRightM: outerSide === "right" ? widening : 0, group, sectionMode, ditchSide: resolvedDitchSide, @@ -355,7 +380,11 @@ export function computeCrossDesign( two_stage_slope: geometry.twoStage, fill_slope_ratio: round4(geometry.fillRatio), roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent), - carriageway_width_m: round4(group.road_width_m), + carriageway_width_m: round4(geometry.halfRoadLeft + geometry.halfRoadRight), + // 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다(짝: 파이썬). + carriageway_standard_width_m: round4(group.road_width_m), + widening_left_m: round4(geometry.halfRoadLeft - geometry.halfRoad), + widening_right_m: round4(geometry.halfRoadRight - geometry.halfRoad), cross_slope_pct: round4(crossSlopePct), ditch: ditchSpec, ditch_enabled: geometry.hasDitch, @@ -372,12 +401,12 @@ export function computeCrossDesign( }, carriageway_edges: { left: { - offset_m: round4(geometry.halfRoad), - elevation_m: round4(geometry.roadZ(geometry.halfRoad)), + offset_m: round4(geometry.halfRoadLeft), + elevation_m: round4(geometry.roadZ(geometry.halfRoadLeft)), }, right: { - offset_m: round4(-geometry.halfRoad), - elevation_m: round4(geometry.roadZ(-geometry.halfRoad)), + offset_m: round4(-geometry.halfRoadRight), + elevation_m: round4(geometry.roadZ(-geometry.halfRoadRight)), }, }, design_elevation_m: round4(centerElevation), diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 466ead9b..1ff67234 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -35,9 +35,42 @@ export function sideRole(sectionMode: string): [string, string] { throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`); } +/** + * 곡선부 너비 확폭표 — **짝: `config_system_design.CURVE_WIDENING_TABLE_M`** + * (별표2 Ⅰ.2.나.(4)). `[반경 하한, 반경 상한(미만), 확폭(m)]`이고 45m 이상은 확폭이 없다. + * 두 벌이 한 세트이므로 값을 고칠 때는 파이썬 쪽도 함께 고친다. + */ +const CURVE_WIDENING_TABLE_M: ReadonlyArray = [ + [10, 13, 2.25], + [13, 14, 2.0], + [14, 15, 1.75], + [15, 18, 1.5], + [18, 20, 1.25], + [20, 25, 1.0], + [25, 30, 0.75], + [30, 40, 0.5], + [40, 45, 0.25], +]; + +/** 확폭을 더한 뒤의 유효너비 상한(m) — 짝: `CURVE_WIDENING_MAX_WIDTH_M`. */ +export const CURVE_WIDENING_MAX_WIDTH_M = 5.0; + +/** 평면 곡선반경으로 확폭량(m)을 정한다. 직선·45m 이상·값 없음은 0. */ +export function curveWideningM(planRadiusM: number | null | undefined): number { + if (planRadiusM === null || planRadiusM === undefined || !Number.isFinite(planRadiusM)) return 0; + const found = CURVE_WIDENING_TABLE_M.find( + ([low, high]) => planRadiusM >= low && planRadiusM < high, + ); + return found ? found[2] : 0; +} + /** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */ export class SectionGeometry { + /** 규격 차도 반폭(확폭 전) — 수량·표기 기준. */ halfRoad: number; + /** 좌(+)·우(−) 차도 반폭 — 곡선부 확폭이 **한쪽에만** 붙어 좌우가 갈린다(2026-09-06). */ + halfRoadLeft: number; + halfRoadRight: number; leftExtent: number; rightExtent: number; zCenter: number; @@ -70,12 +103,17 @@ export class SectionGeometry { rockBoundaryOffsetM: number | null; twoStageSlope: boolean; ditchEnabled: boolean | null; + /** 곡선부 확폭(m) — 붙는 쪽만 값이 있고 반대쪽은 0이다. */ + wideningLeftM?: number; + wideningRightM?: number; }) { const { group } = params; const halfRoad = group.road_width_m / 2; this.halfRoad = halfRoad; - this.leftExtent = halfRoad + group.shoulder_left_m; - this.rightExtent = halfRoad + group.shoulder_right_m; + this.halfRoadLeft = halfRoad + Math.max(params.wideningLeftM ?? 0, 0); + this.halfRoadRight = halfRoad + Math.max(params.wideningRightM ?? 0, 0); + this.leftExtent = this.halfRoadLeft + group.shoulder_left_m; + this.rightExtent = this.halfRoadRight + group.shoulder_right_m; this.zCenter = params.designElevationM; this.cutRatio = Math.max(group.cut_slope_ratio, 1e-6); this.fillRatio = Math.max(group.fill_slope_ratio, 1e-6); diff --git a/config/config_system_design.py b/config/config_system_design.py index 8574ccc3..47b3dcd4 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -420,6 +420,38 @@ NATURAL_SPOIL_MIN_GROUND_SLOPE = 1.0 / 1.5 # 위 5-3의 경로탐색(평면) 기준(FOREST_ROAD_MAX_GRADE 등)과는 별개의 값이므로 # 서로 혼용하지 않는다. # ───────────────────────────────────────────────────────────────────────── +# ── 곡선부 너비 확폭 (별표2 Ⅰ.2.나.(4) / Ⅰ.3.다.(4)) ───────────────────── +# 평면 곡선반경 R(m) 구간별 **확대 기준(m)**. 원문은 "다음의 기준 이상으로 확대"라 이 값이 +# 하한이다. 45m 이상은 확폭하지 않는다. 경계는 "이상 ~ 미만". +# 확폭 방향 = **곡선 바깥쪽 편측**(2026-09-06 사용자 확정 — 회전 시 차량이 밀리는 쪽). +# 법령·교본에 방향 규정이 없어 사용자가 정한 값이며, 도면 실물로 재확인할 여지가 있다. +CURVE_WIDENING_TABLE_M: tuple[tuple[float, float, float], ...] = ( + (10.0, 13.0, 2.25), + (13.0, 14.0, 2.00), + (14.0, 15.0, 1.75), + (15.0, 18.0, 1.50), + (18.0, 20.0, 1.25), + (20.0, 25.0, 1.00), + (25.0, 30.0, 0.75), + (30.0, 40.0, 0.50), + (40.0, 45.0, 0.25), +) +# 확폭을 더한 뒤의 **유효너비 상한**(m, 별표2 Ⅰ.2.다.(1) 비고 — "최대 5미터까지"). +# 넘으면 그 자리에서 자르고 화면이 경고한다. +CURVE_WIDENING_MAX_WIDTH_M = 5.0 + + +def curve_widening_m(plan_radius_m: float | None) -> float: + """평면 곡선반경으로 확폭량(m)을 정한다. 직선·45m 이상·값 없음은 0.""" + if plan_radius_m is None: + return 0.0 + radius = float(plan_radius_m) + for low, high, widening in CURVE_WIDENING_TABLE_M: + if low <= radius < high: + return widening + return 0.0 + + FOREST_ROAD_PROFILE_CRITERIA = { # 설계속도(km/h)별 법정 기준 "design_speed": {