feat(B06): 곡선부 노폭 자동 확폭 — 평면 R 기준, 곡선 바깥쪽 편측

2026-09-06 사용자 확정. 별표2 Ⅰ.2.나.(4) 확폭표(R 10~45m → 2.25~0.25m)를 측점별
평면 곡선반경에 물려 차도 폭을 넓힌다.

- 확폭 방향은 **곡선 바깥쪽 편측** — 노선 폴리라인의 외적 부호로 회전 방향을 보고
  바깥쪽을 정한다(좌회전이면 우측). 측점 기록에 `curve_outer_side` 로 실린다.
- 차도 반폭을 좌·우로 나눠 들어 한쪽만 넓어지게 함. 확폭이 0이면 예전과 같은 대칭
  단면이다. 노견·측구·사면은 그 바깥으로 그대로 밀린다.
- 확폭을 더한 유효너비는 법정 상한 5m 에서 자른다(규격 3.0m 면 최대 2.0m 까지).
- 계산 짝을 함께 고침 — 파이썬 `compute_cross_design` 과 브라우저 `computeCrossDesign`,
  표는 양쪽에 두되 짝임을 주석으로 못 박음. 확폭 입력은 측점 기록에서 뽑는 헬퍼
  하나로 9개 호출부(횡단·확정·B07 도면)에 같은 값이 가게 함.
- 횡단도에 노폭 라벨 — 확폭이 걸리면 「노폭 4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
- 확인: 표 경계·편측 적용·5m 상한·회전 방향 판정 5건(pytest) + 브라우저 표 15건 일치.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 12:07:56 +09:00
co-authored by Claude Opus 5
parent c7099bd1d3
commit 283a248f89
13 changed files with 232 additions and 33 deletions
+23 -10
View File
@@ -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,
+3 -1
View File
@@ -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)
+7
View File
@@ -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 }
+6
View File
@@ -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 {
+50 -8
View File
@@ -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),
+3 -2
View File
@@ -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"
+9 -3
View File
@@ -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
@@ -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);
}
}
@@ -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;
@@ -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
+35 -6
View File
@@ -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<string, string> = {
@@ -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<string, unknown>;
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),
@@ -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<readonly [number, number, number]> = [
[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);
+32
View File
@@ -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": {