"""B06 측점 표준횡단 설계 계산 엔진. 지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단 설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. B06에서 사용자가 버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서 확정치로 대체된다. 표준단면 기하는 config STANDARD_CROSS_SECTION(단일 진실 원천)을 읽고, B06 설정 패널 편집값(standard 인자)이 오면 요청값 → config 기본값 순으로 우선한다. 설계선은 도면 표준대로 횡단경사(측구 방향), 노견, 측구(사다리꼴/L형), 절·성토 사면을 모두 포함하며, 면적은 지반 샘플과 설계 꼭짓점을 합친 오프셋 격자에서 사다리꼴 적분한다(측구 굴착이 절토 면적에 자연 포함). 좌표 규약(generate_sections cad_exchange 준수): offset_m 양수=좌, 음수=우. 경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). """ from collections.abc import Callable from typing import Any from B06_Section.B06_Section_Engine_Areas import ( _split_cut_areas, _trapezoid_areas, ) from config.config_system import ( SECTION_DITCH_SIDES, SECTION_DITCH_TYPES, SECTION_GROUND_TYPE_PRESET, SECTION_MODES, STANDARD_CROSS_SECTION, ) # 사면이 원지반과 만났다고 볼 높이차(m). 이보다 크면 샘플 끝에서 잘린 것으로 본다. _SLOPE_CLOSE_TOLERANCE_M = 0.01 def _side_role(section_mode: str) -> tuple[str, str]: """단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'.""" if section_mode == "left_cut": return "cut", "fill" if section_mode == "right_cut": return "fill", "cut" if section_mode == "both_cut": return "cut", "cut" if section_mode == "both_fill": return "fill", "fill" raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}") def _resolve_ditch_side(section_mode: str, ditch_side: str | None) -> str: """측구(배수) 배치 측을 결정한다. 편절편성은 절토측이 곧 측구측이라 자동 결정하고, 양절·양성은 배수 방향을 사용자 지정(ditch_side)에 맡긴다(미지정 시 좌측 기본). """ if section_mode == "left_cut": return "left" if section_mode == "right_cut": return "right" if ditch_side in SECTION_DITCH_SIDES: return ditch_side return "left" def _as_float(value: Any, fallback: float) -> float: """패널 편집값을 안전하게 float으로 읽는다(손상 시 config 기본값).""" try: parsed = float(value) except (TypeError, ValueError): return fallback return parsed if parsed >= 0 else fallback def _resolve_group(preset_key: str, standard: dict[str, Any] | None) -> dict[str, float]: """config 기본값 위에 패널 편집값(standard[preset_key])을 덮어 평탄화한다.""" base = STANDARD_CROSS_SECTION[preset_key] override = standard.get(preset_key) if isinstance(standard, dict) else None override = override if isinstance(override, dict) else {} base_ditch = base["ditch"] over_ditch = override.get("ditch") over_ditch = over_ditch if isinstance(over_ditch, dict) else {} base_l = base.get("ditch_l_type", {}) over_l = override.get("ditch_l_type") over_l = over_l if isinstance(over_l, dict) else {} base_slope = base["cross_slope_pct"] over_slope = override.get("cross_slope_pct") over_slope = over_slope if isinstance(over_slope, dict) else {} return { "road_width_m": _as_float(override.get("road_width_m"), base["road_width_m"]), "shoulder_left_m": _as_float(override.get("shoulder_left_m"), base["shoulder_left_m"]), "shoulder_right_m": _as_float(override.get("shoulder_right_m"), base["shoulder_right_m"]), "ditch_top_width_m": _as_float(over_ditch.get("top_width_m"), base_ditch["top_width_m"]), "ditch_bottom_width_m": _as_float( over_ditch.get("bottom_width_m"), base_ditch["bottom_width_m"] ), "ditch_depth_m": _as_float(over_ditch.get("depth_m"), base_ditch["depth_m"]), "l_ditch_width_m": _as_float(over_l.get("width_m"), base_l.get("width_m", 0.5)), "l_ditch_depth_m": _as_float(over_l.get("depth_m"), base_l.get("depth_m", 0.1)), # 횡단경사는 범위(min~max) 중 하한을 기본 채택한다(도면 표기 앞값). "cross_slope_pct": _as_float(over_slope.get("min"), base_slope["min"]), "fill_slope_ratio": _as_float(override.get("fill_slope_ratio"), base["fill_slope_ratio"]), "cut_slope_ratio": _as_float(override.get("cut_slope_ratio"), base["cut_slope_ratio"]), "pavement_thickness_m": _as_float( override.get("pavement_thickness_m"), base.get("pavement_thickness_m", 0.2) ), } def _ground_interpolator(valid: list[tuple[float, float]]): """정렬된 (offset, 지반고) 샘플의 선형 보간 함수를 만든다(범위 밖 끝값 클램프).""" def ground_at(offset_m: float) -> float: if offset_m <= valid[0][0]: return valid[0][1] if offset_m >= valid[-1][0]: return valid[-1][1] for index in range(1, len(valid)): x1, z1 = valid[index] if offset_m > x1: continue x0, z0 = valid[index - 1] span = x1 - x0 if span <= 0: return z1 ratio = (offset_m - x0) / span return z0 + (z1 - z0) * ratio return valid[-1][1] return ground_at class _SectionGeometry: """설계선 피스와이즈 평가기. 노면 → 측구 → 사면 순으로 offset의 설계고를 계산한다.""" def __init__( self, *, design_elevation_m: float, group: dict[str, float], section_mode: str, 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, ditch_enabled: bool | None = None, ) -> 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"] # 우(-) 노면 끝 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) 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] = {} # 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4). self._cut_cross: dict[str, float | None] = {} self._fill_cross: dict[str, float | None] = {} self.ditch_type = ditch_type # 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). slope = cross_slope_pct / 100.0 self.slope_per_offset = -slope if ditch_side == "left" else slope # 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다 # 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서 # 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고 # 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다. if ground_at is not None: self.left_role = ( "cut" if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3 else "fill" ) self.right_role = ( "cut" if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3 else "fill" ) # 측구 생성 여부(D-1): 양성은 항상 미생성. ditch_enabled가 오면 그 값을 따르고(수동 # override), None이면 자동 판정 — 측구측 노면 끝에서 지반이 설계면보다 높으면(절토 # 상황) 생성, 낮으면(성토 상황, 자연 배수) 미생성. ground_at 없으면 보수적으로 생성. if section_mode == "both_fill": self.has_ditch = False elif ditch_enabled is not None: self.has_ditch = ditch_enabled elif ground_at is not None: ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent self.has_ditch = ground_at(ditch_edge) > self.road_z(ditch_edge) + 1e-3 else: self.has_ditch = True # 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). self.ditch_points: list[tuple[float, float]] = [] edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent outward = 1.0 if ditch_side == "left" else -1.0 edge_z = self.road_z(edge_offset) if self.has_ditch: if ditch_type == "l_type": # L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽. # 바깥(노견 반대측) 상단은 **노견과 같은 표고**로 닫는다 — 사면 시작점이 # 측구 바닥 높이로 내려가면 안 된다(2026-08-23 사용자 지시). width = group["l_ditch_width_m"] depth = group["l_ditch_depth_m"] self.ditch_points = [ (edge_offset, edge_z), (edge_offset + outward * width, edge_z - depth), (edge_offset + outward * width, edge_z), ] else: # 일반: 상단폭/저폭/깊이 사다리꼴. top = group["ditch_top_width_m"] bottom = min(group["ditch_bottom_width_m"], top) depth = group["ditch_depth_m"] inset = (top - bottom) / 2.0 self.ditch_points = [ (edge_offset, edge_z), (edge_offset + outward * inset, edge_z - depth), (edge_offset + outward * (inset + bottom), edge_z - depth), (edge_offset + outward * top, edge_z), ] def road_z(self, offset_m: float) -> float: """노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면.""" return self.z_center + self.slope_per_offset * offset_m def _slope_start(self, side: str) -> tuple[float, float]: """사면 시작점(오프셋 절대값 기준 거리, 표고)을 계산한다.""" if side == "left": edge_offset, edge_z = self.left_extent, self.road_z(self.left_extent) else: edge_offset, edge_z = self.right_extent, self.road_z(-self.right_extent) if side == self.ditch_side and self.ditch_points: outer = self.ditch_points[-1] 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 _cut_slope_z(self, side: str, dist: float) -> float: """절토 사면선 표고(2단계 무릎 반영). 지반 교차 클램프는 하지 않는다.""" start_dist, start_z = self._slope_start(side) knee = self.knee(side) if self.two_stage else None if knee is not None: knee_dist, knee_z = knee if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사 return start_z + (dist - start_dist) / self.cut_ratio return knee_z + (dist - knee_dist) / self.soil_cut_ratio # 토사 구간: 완만 return start_z + (dist - start_dist) / self.cut_ratio def cut_cross_dist(self, side: str) -> float | None: """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). 지면과 1회 교차하면 그다음 경사(2단계 전환 포함)는 의미가 없으므로 교차점에서 절토를 종료한다. 시작(노면 끝)부터 사면이 지반 위면 교차거리=시작(절토 없음), 끝까지 못 만나면 None. """ if side in self._cut_cross: return self._cut_cross[side] result: float | None = None if self._ground_at is not None: start_dist, _start_z = self._slope_start(side) step = 0.05 dist = start_dist max_dist = start_dist + 500.0 while dist <= max_dist: signed = dist if side == "left" else -dist if self._cut_slope_z(side, dist) - self._ground_at(signed) >= 0: result = dist break dist += step self._cut_cross[side] = result return result def fill_ground_slope(self) -> float | None: """성토측 **자연 지반**의 평균 경사(rise/run, 무차원). 성토측이 없으면 None. 자연방토 판정에 쓴다 — 지반이 가파르면 부어 놓은 흙이 쌓이지 않고 흘러내린다. 구간은 노면 끝(사면 시작)부터 성토 사면이 지반과 처음 만나는 곳까지이며, 끝까지 만나지 못하면 10m를 본다. 양쪽이 다 성토면 **완만한 쪽**을 택한다(보수적 판정). """ if self._ground_at is None: return None slopes: list[float] = [] for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role if role != "fill": continue start_dist, _start_z = self._slope_start(side) end_dist = self.fill_cross_dist(side) or (start_dist + 10.0) run = end_dist - start_dist if run <= 1e-6: continue sign = 1.0 if side == "left" else -1.0 rise = abs(self._ground_at(sign * end_dist) - self._ground_at(sign * start_dist)) slopes.append(rise / run) return min(slopes) if slopes else None def fill_cross_dist(self, side: str) -> float | None: """성토 사면이 지반선과 **처음** 만나는 거리(절대 오프셋). 이후는 성토 없음. 절토(cut_cross_dist)와 같은 규칙이다. 실제 지면은 울퉁불퉁해서 성토 사면이 지반과 여러 번 만날 수 있는데, **첫 교차점이 성토사면의 끝**이고 그 바깥은 손대지 않은 지반이다(2026-08-02 사용자 지시). `max(fill_line, ground)`만 쓰면 지반이 다시 꺼졌을 때 성토 사면이 되살아나 사면이 끊겼다 이어지는 그림이 나온다. 시작(노면 끝)부터 사면이 지반 아래면 교차거리=시작(성토 없음), 끝까지 못 만나면 None. """ if side in self._fill_cross: return self._fill_cross[side] result: float | None = None if self._ground_at is not None: start_dist, start_z = self._slope_start(side) step = 0.05 dist = start_dist max_dist = start_dist + 500.0 while dist <= max_dist: signed = dist if side == "left" else -dist fill_line = start_z - (dist - start_dist) / self.fill_ratio if fill_line - self._ground_at(signed) <= 0: result = dist break dist += step self._fill_cross[side] = result return result def design_z(self, offset_m: float, ground_m: float) -> float: """offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종).""" side = "left" if offset_m >= 0 else "right" extent = self.left_extent if side == "left" else self.right_extent if abs(offset_m) <= extent + 1e-9: return self.road_z(offset_m) # 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). if side == self.ditch_side and self.ditch_points: inner = abs(self.ditch_points[0][0]) outer = abs(self.ditch_points[-1][0]) if inner - 1e-9 <= abs(offset_m) <= outer + 1e-9: points = self.ditch_points for index in range(1, len(points)): x0, z0 = abs(points[index - 1][0]), points[index - 1][1] x1, z1 = abs(points[index][0]), points[index][1] if abs(offset_m) > x1 + 1e-9: continue span = x1 - x0 if span <= 1e-9: return z1 ratio = (abs(offset_m) - x0) / span return z0 + (z1 - z0) * ratio return points[-1][1] role = self.left_role if side == "left" else self.right_role start_dist, start_z = self._slope_start(side) dist = abs(offset_m) run = dist - start_dist if role == "cut": # 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4). cross = self.cut_cross_dist(side) if cross is not None and dist >= cross: return ground_m return min(self._cut_slope_z(side, dist), ground_m) # 지반과 1회 교차하면 그 바깥은 성토가 아니라 원지반이다(절토와 같은 규칙). cross = self.fill_cross_dist(side) if cross is not None and dist >= cross: return 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]) # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role cross = self.cut_cross_dist(side) if role == "cut" else self.fill_cross_dist(side) if cross is not None: points.append(cross if side == "left" else -cross) return points def compute_cross_design( samples: list[dict[str, Any]], design_elevation_m: float | None, *, ground_type: str, section_mode: str, ditch_side: str | None = None, 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, ditch_enabled: bool | None = None, surface_drop_m: float = 0.0, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. samples: [{offset_m, elevation_m, valid}] 지반선 원시 샘플. design_elevation_m: 중심선 계획고(노면고). None이면 계산 불가. ditch_type: 일반(standard)/L형(l_type, 암 구간 전용). paved: 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 바꾼다(기하는 지반유형). standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용. two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제). surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). """ if ground_type not in SECTION_GROUND_TYPE_PRESET: raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}") if section_mode not in SECTION_MODES: raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}") if ditch_type not in SECTION_DITCH_TYPES: raise ValueError(f"지원하지 않는 측구 형식입니다: {ditch_type}") if design_elevation_m is None: raise ValueError("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다.") drop = max(float(surface_drop_m), 0.0) design_elevation_m = float(design_elevation_m) - drop preset_key = SECTION_GROUND_TYPE_PRESET[ground_type] if ditch_type == "l_type" and preset_key != "rock": raise ValueError("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.") group = _resolve_group(preset_key, standard) paved_group = _resolve_group("paved", standard) # 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"] resolved_ditch_side = _resolve_ditch_side(section_mode, ditch_side) valid = sorted( ( (float(s["offset_m"]), float(s["elevation_m"])) for s in samples if s.get("valid") is not False and s.get("offset_m") is not None and s.get("elevation_m") is not None ), key=lambda pair: pair[0], ) 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, section_mode=section_mode, 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, ditch_enabled=ditch_enabled, ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 # 측구 모서리·노면 끝이 잘리지 않아 면적과 설계선이 정확해진다. min_offset, max_offset = valid[0][0], valid[-1][0] merged: list[float] = [offset for offset, _z in valid] merged.extend(point for point in geometry.breakpoints() if min_offset <= point <= max_offset) merged = sorted(set(round(offset, 6) for offset in merged)) offsets: list[float] = [] diffs: list[float] = [] design_line: list[dict[str, float]] = [] for offset_m in merged: ground_m = ground_at(offset_m) design_z = geometry.design_z(offset_m, ground_m) offsets.append(offset_m) diffs.append(ground_m - design_z) design_line.append({"offset_m": round(offset_m, 4), "elevation_m": round(design_z, 4)}) # 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지). cut_area, fill_area = _trapezoid_areas(offsets, diffs) fill_ground_slope = geometry.fill_ground_slope() # 사면이 샘플 범위 끝에서도 원지반과 만나지 않으면 면적이 거기서 잘린다 — 그만큼 # 절·성토량이 실제와 다르고 유토곡선도 그 값을 그대로 쌓는다. 영원히 안 만나는 # 지형이 있을 수 있으므로 계산은 손대지 않고 **경고만** 낸다(2026-09-03 사용자 확정). slope_unclosed = bool(diffs) and ( abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M ) # 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가 # 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다. # 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터) # 분리 근거가 없으므로 전량 암으로 둔다(기존 단일 지반유형 환산과 같은 결과). if preset_key != "rock": cut_soil_area, cut_rock_area = cut_area, 0.0 cut_rock_kind: str | None = None elif rock_boundary_offset_m is None: cut_soil_area, cut_rock_area = 0.0, cut_area cut_rock_kind = ground_type else: cut_soil_area, cut_rock_area = _split_cut_areas( offsets, diffs, abs(float(rock_boundary_offset_m)) ) cut_rock_kind = ground_type # 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사. if not geometry.has_ditch: ditch_area = 0.0 ditch_spec: dict[str, Any] = {"type": "none"} elif ditch_type == "l_type": ditch_area = group["l_ditch_width_m"] * group["l_ditch_depth_m"] / 2.0 ditch_spec = { "type": "l_type", "width_m": group["l_ditch_width_m"], "depth_m": group["l_ditch_depth_m"], } else: ditch_area = ( (group["ditch_top_width_m"] + group["ditch_bottom_width_m"]) / 2.0 * group["ditch_depth_m"] ) ditch_spec = { "type": "standard", "top_width_m": group["ditch_top_width_m"], "bottom_width_m": group["ditch_bottom_width_m"], "depth_m": group["ditch_depth_m"], } # 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo한다(D-2, 표시·저장용). if geometry.left_role == "cut" and geometry.right_role == "cut": resolved_mode = "both_cut" elif geometry.left_role == "fill" and geometry.right_role == "fill": resolved_mode = "both_fill" elif geometry.left_role == "cut": resolved_mode = "left_cut" else: resolved_mode = "right_cut" result: dict[str, Any] = { "ground_type": ground_type, "geometry_preset": preset_key, "section_mode": resolved_mode, "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), "cross_slope_pct": round(cross_slope_pct, 4), "ditch": ditch_spec, "ditch_enabled": bool(geometry.has_ditch), "paved": bool(paved), # 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. "road_edges": { "left": { "offset_m": round(geometry.left_extent, 4), "elevation_m": round(geometry.road_z(geometry.left_extent), 4), }, "right": { "offset_m": round(-geometry.right_extent, 4), "elevation_m": round(geometry.road_z(-geometry.right_extent), 4), }, }, # 차도(노견 제외) 양 끝점 — 포장 범위 기준(D-5). "carriageway_edges": { "left": { "offset_m": round(geometry.half_road, 4), "elevation_m": round(geometry.road_z(geometry.half_road), 4), }, "right": { "offset_m": round(-geometry.half_road, 4), "elevation_m": round(geometry.road_z(-geometry.half_road), 4), }, }, "design_elevation_m": round(float(design_elevation_m), 4), "cut_area_m2": round(cut_area, 4), # 절토 내역(합=cut_area_m2). 유토곡선이 지반유형별 환산계수를 물리는 단위다. "cut_soil_area_m2": round(cut_soil_area, 4), "cut_rock_area_m2": round(cut_rock_area, 4), "cut_rock_kind": cut_rock_kind, "fill_area_m2": round(fill_area, 4), # 사면이 샘플 범위 끝까지 원지반을 못 만나 면적이 잘린 측점 — 경고 표기용. "slope_unclosed": slope_unclosed, # 성토측 자연 지반 경사(rise/run) — 자연방토 판정 입력. 성토측이 없으면 None. "fill_ground_slope": ( round(fill_ground_slope, 4) if fill_ground_slope is not None else None ), "ditch_area_m2": round(ditch_area, 4), "design_line": design_line, } if drop > 0: # 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다. result["surface_drop_m"] = round(drop, 4) 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