This commit is contained in:
2026-07-25 10:01:55 +09:00
parent 42de855ab3
commit 5d6c6aa1be
15 changed files with 845 additions and 62 deletions
@@ -15,6 +15,7 @@
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
"""
from collections.abc import Callable
from typing import Any
from config.config_system import (
@@ -170,15 +171,28 @@ class _SectionGeometry:
ditch_side: str,
ditch_type: str,
cross_slope_pct: float,
ground_at: Callable[[float], float] | None = None,
soil_cut_ratio: float | None = None,
rock_boundary_offset_m: float | None = None,
two_stage_slope: bool = False,
) -> None:
half_road = group["road_width_m"] / 2.0
self.left_extent = half_road + group["shoulder_left_m"] # 좌(+) 노면 끝
self.right_extent = half_road + group["shoulder_right_m"] # 우(-) 노면 끝
self.z_center = design_elevation_m
self.cut_ratio = max(group["cut_slope_ratio"], 1e-6)
self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사
self.fill_ratio = max(group["fill_slope_ratio"], 1e-6)
self.left_role, self.right_role = _side_role(section_mode)
self.ditch_side = ditch_side
# 2단계 절토: 암반 경계선(지반선 + rock_boundary_offset) 아래는 암 경사(cut_ratio),
# 위는 토사 경사(soil_cut_ratio)를 쓴다. 경계 아래→위 전환점(무릎)을 측별로 미리 구한다.
self.soil_cut_ratio = max(soil_cut_ratio or group["cut_slope_ratio"], 1e-6)
self.two_stage = bool(
two_stage_slope and ground_at is not None and rock_boundary_offset_m is not None
)
self._ground_at = ground_at
self._rock_offset = rock_boundary_offset_m or 0.0
self._rock_knee: dict[str, tuple[float, float] | None] = {}
# 성토만(양성)이면 측구 없음(합의). 절토가 있는 단면만 측구를 판다.
self.has_ditch = section_mode != "both_fill"
self.ditch_type = ditch_type
@@ -186,6 +200,19 @@ class _SectionGeometry:
slope = cross_slope_pct / 100.0
self.slope_per_offset = -slope if ditch_side == "left" else slope
# 편절편성에서 계획고가 지반보다 많이 낮으면 성토측 지반이 노면 끝보다 높아
# 실제로는 양측 절토가 된다. 그 측 역할을 절토로 자동 전환한다(측구 위치는
# ditch_side 그대로 유지, 전환된 측도 2단 경사 대상). 양절·양성(사용자 명시)은
# 손대지 않는다.
if section_mode in ("left_cut", "right_cut") and ground_at is not None:
for side, edge in (("left", self.left_extent), ("right", -self.right_extent)):
role = self.left_role if side == "left" else self.right_role
if role == "fill" and ground_at(edge) > self.road_z(edge) + 1e-3:
if side == "left":
self.left_role = "cut"
else:
self.right_role = "cut"
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
self.ditch_points: list[tuple[float, float]] = []
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
@@ -228,6 +255,48 @@ class _SectionGeometry:
return abs(outer[0]), outer[1]
return edge_offset, edge_z
def _rock_boundary_z(self, side: str, dist: float) -> float:
"""측·거리(절대 오프셋)에서 암반 경계선 표고 = 지반선 + 오프셋(음수=하향)."""
signed = dist if side == "left" else -dist
assert self._ground_at is not None # two_stage일 때만 호출
return self._ground_at(signed) + self._rock_offset
def knee(self, side: str) -> tuple[float, float] | None:
"""절토 사면이 암반 경계선을 지나는 전환점(무릎 거리, 표고)을 구한다(측별 캐시).
노면 끝(사면 시작)에서 암 경사(cut_ratio)로 올라가며 경계선을 만나면 그 지점부터
토사 경사로 완만해진다. 시작부터 경계 위면 무릎=시작(전부 토사), 끝까지 못 만나면
None(전부 암). 경계선은 지반을 따라 변하므로 세밀 행진으로 교차점을 찾는다.
"""
if not self.two_stage:
return None
if side in self._rock_knee:
return self._rock_knee[side]
start_dist, start_z = self._slope_start(side)
diff_prev = start_z - self._rock_boundary_z(side, start_dist)
result: tuple[float, float] | None
if diff_prev >= 0:
result = (start_dist, start_z) # 시작부터 토사(경계 위)
else:
result = None
step = 0.05
dist_prev = start_dist
dist = start_dist + step
while dist <= start_dist + 200.0:
z_rock = start_z + (dist - start_dist) / self.cut_ratio
diff = z_rock - self._rock_boundary_z(side, dist)
if diff >= 0:
span = diff - diff_prev
ratio = (-diff_prev) / span if abs(span) > 1e-9 else 0.0
knee_dist = dist_prev + (dist - dist_prev) * ratio
knee_z = start_z + (knee_dist - start_dist) / self.cut_ratio
result = (knee_dist, knee_z)
break
dist_prev, diff_prev = dist, diff
dist += step
self._rock_knee[side] = result
return result
def design_z(self, offset_m: float, ground_m: float) -> float:
"""offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종)."""
side = "left" if offset_m >= 0 else "right"
@@ -253,17 +322,32 @@ class _SectionGeometry:
return points[-1][1]
role = self.left_role if side == "left" else self.right_role
start_dist, start_z = self._slope_start(side)
run = abs(offset_m) - start_dist
dist = abs(offset_m)
run = dist - start_dist
if role == "cut":
slope_line = start_z + run / self.cut_ratio
knee = self.knee(side) if self.two_stage else None
if knee is not None:
knee_dist, knee_z = knee
if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사
slope_line = start_z + (dist - start_dist) / self.cut_ratio
else: # 토사 구간(경계 위): 무릎에서 토사 경사로 완만
slope_line = knee_z + (dist - knee_dist) / self.soil_cut_ratio
else:
slope_line = start_z + run / self.cut_ratio
return min(slope_line, ground_m)
fill_line = start_z - run / self.fill_ratio
return max(fill_line, ground_m)
def breakpoints(self) -> list[float]:
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록."""
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎 포함)."""
points = [0.0, self.left_extent, -self.right_extent]
points.extend(offset for offset, _z in self.ditch_points)
if self.two_stage:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
knee = self.knee(side) if role == "cut" else None
if knee is not None:
points.append(knee[0] if side == "left" else -knee[0])
return points
@@ -277,6 +361,8 @@ def compute_cross_design(
ditch_type: str = "standard",
paved: bool = False,
standard: dict[str, Any] | None = None,
rock_boundary_offset_m: float | None = None,
two_stage_slope: bool = True,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
@@ -285,6 +371,8 @@ def compute_cross_design(
ditch_type: 일반(standard)/L형(l_type, 암 구간 전용).
paved: 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 바꾼다(기하는 지반유형).
standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순.
rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용.
two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제).
"""
if ground_type not in SECTION_GROUND_TYPE_PRESET:
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}")
@@ -317,6 +405,12 @@ def compute_cross_design(
if len(valid) < 2:
raise ValueError("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.")
ground_at = _ground_interpolator(valid)
# 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다.
enable_two_stage = (
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"]
geometry = _SectionGeometry(
design_elevation_m=design_elevation_m,
group=group,
@@ -324,8 +418,11 @@ def compute_cross_design(
ditch_side=resolved_ditch_side,
ditch_type=ditch_type,
cross_slope_pct=cross_slope_pct,
ground_at=ground_at,
soil_cut_ratio=soil_cut_ratio,
rock_boundary_offset_m=rock_boundary_offset_m,
two_stage_slope=enable_two_stage,
)
ground_at = _ground_interpolator(valid)
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
# 측구 모서리·노면 끝이 잘리지 않아 면적과 설계선이 정확해진다.
@@ -378,6 +475,8 @@ def compute_cross_design(
"ditch_side": resolved_ditch_side,
"ditch_type": ditch_type if geometry.has_ditch else None,
"cut_slope_ratio": round(geometry.cut_ratio, 4),
"soil_cut_slope_ratio": round(geometry.soil_cut_ratio, 4),
"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),
@@ -403,6 +502,9 @@ def compute_cross_design(
}
if paved:
result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4)
# 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다.
if preset_key == "rock" and rock_boundary_offset_m is not None:
result["rock_boundary_offset_m"] = round(float(rock_boundary_offset_m), 4)
return result