Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1

This commit is contained in:
2026-09-09 12:43:41 +09:00
7 changed files with 683 additions and 467 deletions
+112 -56
View File
@@ -467,7 +467,8 @@
"choices": ["콘크리트 개거 150×200", "L형수로 H=0.2"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
}
]
},
@@ -729,7 +730,8 @@
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
"default": "자동(성토 쪽)",
"default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식"
},
{
"key": "weep_hole_diameter_mm",
@@ -738,7 +740,8 @@
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
@@ -747,7 +750,8 @@
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -832,7 +836,8 @@
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
@@ -841,7 +846,8 @@
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
@@ -849,7 +855,8 @@
"input": "number",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
@@ -865,7 +872,8 @@
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
"default": "자동(성토 쪽)",
"default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식"
},
{
"key": "weep_hole_diameter_mm",
@@ -874,7 +882,8 @@
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
@@ -883,7 +892,8 @@
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -968,7 +978,8 @@
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
@@ -977,7 +988,8 @@
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
@@ -985,7 +997,8 @@
"input": "number",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
@@ -1001,7 +1014,8 @@
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
"default": "자동(성토 쪽)",
"default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식"
},
{
"key": "weep_hole_diameter_mm",
@@ -1010,7 +1024,8 @@
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
@@ -1019,7 +1034,8 @@
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -1093,7 +1109,8 @@
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
"default": "자동(성토 쪽)",
"default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식"
},
{
"key": "tiers",
@@ -1102,7 +1119,8 @@
"unit": "단",
"default": 1,
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)"
},
{
"key": "lift_m",
@@ -1111,7 +1129,8 @@
"unit": "m",
"default": 0,
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)"
},
{
"key": "shift_m",
@@ -1120,7 +1139,8 @@
"unit": "m",
"default": 0,
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)"
},
{
"key": "back_len_cm",
@@ -1129,7 +1149,8 @@
"choices": ["25", "30", "35", "45", "55", "60", "75"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_kind",
@@ -1138,7 +1159,8 @@
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_supply",
@@ -1147,7 +1169,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음"
},
{
"key": "stone_coeff_basis",
@@ -1156,7 +1179,8 @@
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
@@ -1165,7 +1189,8 @@
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
@@ -1173,7 +1198,8 @@
"input": "number",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
@@ -1191,7 +1217,8 @@
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
@@ -1200,7 +1227,8 @@
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -1285,7 +1313,8 @@
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
@@ -1294,7 +1323,8 @@
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
@@ -1302,7 +1332,8 @@
"input": "number",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
@@ -1318,7 +1349,8 @@
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
"default": "자동(성토 쪽)",
"default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식"
},
{
"key": "weep_hole_diameter_mm",
@@ -1327,7 +1359,8 @@
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
@@ -1336,7 +1369,8 @@
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -1470,7 +1504,8 @@
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
@@ -1479,7 +1514,8 @@
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
@@ -1487,7 +1523,8 @@
"input": "number",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
@@ -1537,7 +1574,8 @@
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
@@ -1546,7 +1584,8 @@
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -1586,7 +1625,8 @@
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
@@ -1595,7 +1635,8 @@
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
@@ -1603,7 +1644,8 @@
"input": "number",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
@@ -1675,7 +1717,8 @@
"choices": ["양쪽", "좌", "우"],
"default": "양쪽",
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름"
},
{
"key": "tiers",
@@ -1684,7 +1727,8 @@
"unit": "단",
"default": 1,
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식"
},
{
"key": "lift_m",
@@ -1693,7 +1737,8 @@
"unit": "m",
"default": 0,
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "0 = 자동 자리 그대로"
},
{
"key": "shift_m",
@@ -1702,7 +1747,8 @@
"unit": "m",
"default": 0,
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "0 = 자동 자리 그대로"
},
{
"key": "form",
@@ -1720,7 +1766,8 @@
"choices": ["25", "30", "35", "45", "55", "60", "75"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_kind",
@@ -1729,7 +1776,8 @@
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_supply",
@@ -1747,7 +1795,8 @@
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
@@ -1756,7 +1805,8 @@
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
@@ -1764,7 +1814,8 @@
"input": "number",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
@@ -1818,7 +1869,8 @@
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
@@ -1827,7 +1879,8 @@
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -2005,7 +2058,8 @@
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)",
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "「자동(성토 쪽)」 — 흙은 성토 쪽에 쌓는다는 지금까지의 동작을 이어받는 표식. 측점마다 좌·우로 바꿈"
},
{
"key": "fill_slope_ratio",
@@ -2013,7 +2067,8 @@
"input": "number",
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 그 측점의 노선 성토 기울기를 그대로 씀 — 지식DB 가 「기본 비탈기울기는 근거에 없다」고 못 박은 자리"
},
{
"key": "extra_distance_m",
@@ -2022,7 +2077,8 @@
"unit": "m",
"default": 0,
"required": false,
"phase": "detail"
"phase": "detail",
"default_basis": "0 = 「추가 없음」 — 노선 안 사토장은 측점 누가거리로 거리가 나오므로 이 칸은 외부 사토장 대비 여벌"
},
{
"key": "area_m2",
@@ -48,6 +48,18 @@ class StructureOptionField(BaseModel):
# 기본값이 그대로 저장된다(2026-09-07 사용자: 「폼 선택은 가능하게 반영하고 나중에
# 선택 비활성화로 하자」). 칸 자체를 없애면 나중에 켤 자리를 다시 찾아야 한다.
enabled: bool = True
# ── 「기본값도 필수도 아닌」 세 번째 갈래 (2026-09-09 신설) ────────────────────
# 지금까지 칸은 둘 중 하나여야 했다 — **기본값이 있거나, 필수이거나**. 그러지 않으면
# 빈 값이 조용히 저장되기 때문이다. 그런데 실제로는 셋째가 있다:
# **「비워 두는 것이 뜻인 칸」** — 비면 계산 쪽이 **기준값**으로 돌고 그 사실이 화면에
# 사유로 뜬다. 값을 넣으면 그 값이 이긴다(예: 전면 기울기, 물빼기 구멍, 뒷길이).
# 여기에 기준의 출처를 **한 줄로 적어** 두면, 그 칸이 왜 비어 있어도 되는지가
# **정본 파일 안에** 남는다. 시험 예외 목록(창마다 따로 사는 파일)로 두면 창이 갈릴 때
# 같은 시험이 다른 창에서 깨진다 — 실제로 그렇게 깨졌다(2026-09-09).
empty_means: str | None = None
# 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」).
# 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다.
default_basis: str | None = None
class StructureType(BaseModel):
+3 -410
View File
@@ -27,8 +27,6 @@
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
"""
import math
from collections.abc import Callable
from typing import Any
from B06_Section.B06_Section_Engine_Areas import (
@@ -38,12 +36,12 @@ from B06_Section.B06_Section_Engine_Areas import (
_split_ditch_area,
_trapezoid_areas,
)
from B06_Section.B06_Section_Engine_Design_Geometry import (
_SectionGeometry,
)
from common_util.common_util_cross_berm import (
BermSpec,
cut_profile_points,
fill_profile_points,
)
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
from common_util.common_util_spoil_fill import spoil_fill_section
from config.config_system import (
CURVE_WIDENING_MAX_WIDTH_M,
@@ -61,19 +59,6 @@ from config.config_system import (
_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:
"""측구(배수) 배치 측을 결정한다.
@@ -156,398 +141,6 @@ def _ground_interpolator(valid: list[tuple[float, float]]):
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,
widening_left_m: float = 0.0,
widening_right_m: float = 0.0,
berm: BermSpec | None = None,
) -> None:
half_road = group["road_width_m"] / 2.0
# 곡선부 확폭은 **한쪽으로만** 붙는다(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)
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
# 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다.
self.berm = berm
self._cut_points_cache: dict[str, list[tuple[float, float]]] = {}
self._fill_points_cache: dict[str, list[tuple[float, float]]] = {}
# 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(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 cut_points(self, side: str) -> list[tuple[float, float]]:
"""절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다.
셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다).
소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다).
"""
if side in self._cut_points_cache:
return self._cut_points_cache[side]
start_dist, start_z = self._slope_start(side)
boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None
points = cut_profile_points(
start_dist,
start_z,
self.cut_ratio,
self.soil_cut_ratio,
boundary,
self.berm,
# 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을
# 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데
# 토사 경사로 그려 절토가 조용히 커진다(2026-09-07).
multi_knee=self.berm is not None,
)
self._cut_points_cache[side] = points
return points
def _cut_slope_z(self, side: str, dist: float) -> float:
"""절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.cut_points(side), dist)
def fill_points(self, side: str) -> list[tuple[float, float]]:
"""성토 사면 꼭짓점 — 소단이 들어 있다. 절토와 달리 무릎은 없다."""
if side in self._fill_points_cache:
return self._fill_points_cache[side]
start_dist, start_z = self._slope_start(side)
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm)
self._fill_points_cache[side] = points
return points
def _fill_slope_z(self, side: str, dist: float) -> float:
"""성토 사면선 표고(소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.fill_points(side), dist)
def cut_slope_segments(self) -> list[dict[str, Any]]:
"""절토 사면을 **경사 구간별로** 쪼갠 목록.
⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 임자였던 별표2 법정 경사 검사가 폐기됐고
(암질을 횡단도에서 안 고르기로 사용자 확정), 저장분에도 안 들어간다. 소단 기하가 이 셈
위에 서 있어 남겨 둔다 — **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.**
원래 필요했던 까닭(되살릴 때 그대로 유효) — 소단이 서면 사면 전체를 하나로 재는
「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이
실효 1:1.71). 검사는 소단을 뺀 **사면 구간 자체의 경사**를 봐야 한다.
· 평탄부(소단)는 싣지 않는다 — 경사 구간이 아니고 경사비가 무한대가 된다.
· 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다.
· `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None.
암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다).
"""
segments: list[dict[str, Any]] = []
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "cut":
continue
cross = self.cut_cross_dist(side)
points = self.cut_points(side)
sign = 1.0 if side == "left" else -1.0
for index in range(1, len(points)):
start_d, start_z = points[index - 1]
end_d, end_z = points[index]
if cross is not None and start_d >= cross - 1e-9:
break # 지반과 만난 뒤는 절토가 없다
if cross is not None and end_d > cross:
# 지반과 만나는 점에서 구간을 자른다.
end_z = berm_elevation_at(points, cross)
end_d = cross
run = end_d - start_d
rise = end_z - start_z
if run <= 1e-9 or rise <= 1e-6:
continue # 길이 0·역방향은 검사 대상이 아니다
if self.berm is not None and abs(run - self.berm.width_m) < 1e-6:
# 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다.
berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m
if abs(rise - berm_rise) < 1e-9:
continue
# 재료는 **그 구간을 실제로 그린 경사비**로 가른다 — 경계선을 다시 재면
# 안 된다. 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사
# 경사로 그린 구간이 경계 아래로 되돌아가 있는 일이 흔하다. 그것을 경계로
# 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다(2026-09-07 다른 창
# 실측: 용화 63측점에서 13구간). 그린 대로 적는 것이 맞다.
material: str | None = None
if self.two_stage and abs(self.soil_cut_ratio - self.cut_ratio) > 1e-9:
drawn = run / rise
material = (
"soil"
if abs(drawn - self.soil_cut_ratio) < abs(drawn - self.cut_ratio)
else "rock"
)
segments.append(
{
"side": side,
"ratio": round(run / rise, 4),
"rise_m": round(rise, 4),
"run_m": round(run, 4),
"start_offset_m": round(sign * start_d, 4),
"end_offset_m": round(sign * end_d, 4),
"material": material,
}
)
return segments
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 = self._fill_slope_z(side, dist)
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
dist = abs(offset_m)
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
return max(self._fill_slope_z(side, dist), 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 or self.berm is not None:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "cut":
continue
cross = self.cut_cross_dist(side)
for offset, _z in self.cut_points(side):
if cross is not None and offset > cross + 1e-9:
break # 지반과 만난 뒤는 절토가 없다
points.append(offset if side == "left" else -offset)
# 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다.
if self.berm is not None:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "fill":
continue
cross = self.fill_cross_dist(side)
for offset, _z in self.fill_points(side):
if cross is not None and offset > cross + 1e-9:
break
points.append(offset if side == "left" else -offset)
# 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(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 curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]:
"""측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다.
@@ -0,0 +1,430 @@
"""횡단 설계선의 **기하** — 노면 → 측구 → 사면 순으로 offset 의 설계고를 낸다.
⚠⚠ **TS 짝과 같은 선에서 갈랐다** — `common_util/common_util_cross_design_geometry.ts`.
TS 는 2026-09-04 에 이미 이 선으로 떨어져 있었고, 파이썬만 한 파일에 붙어 있어
924줄이 됐다(700줄 제한 초과). **새로 긋는 선이 아니라 TS 에 있던 선을 이쪽에도 그은 것**
이라 거울 시험(`tmp/tests/test_b06_cross_design_mirror.py`)이 그대로 돈다.
같이 옮긴 것 — `_side_role`(TS `sideRole`). 클래스만 쓰는 도우미다.
안 옮긴 것 — 측구 방향 해석·지반 보간·사면 폐합 허용오차는 TS 도 본체에 두었다.
⚠ 계산은 **한 줄도 바꾸지 않았다.** 옮기기만 한 것이라, 값이 달라지면 옮기다 흘린 것이다.
"""
from __future__ import annotations
import math
from collections.abc import Callable
from typing import Any
from common_util.common_util_cross_berm import (
BermSpec,
cut_profile_points,
fill_profile_points,
)
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
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}")
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,
widening_left_m: float = 0.0,
widening_right_m: float = 0.0,
berm: BermSpec | None = None,
) -> None:
half_road = group["road_width_m"] / 2.0
# 곡선부 확폭은 **한쪽으로만** 붙는다(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)
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
# 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다.
self.berm = berm
self._cut_points_cache: dict[str, list[tuple[float, float]]] = {}
self._fill_points_cache: dict[str, list[tuple[float, float]]] = {}
# 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(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 cut_points(self, side: str) -> list[tuple[float, float]]:
"""절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다.
셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다).
소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다).
"""
if side in self._cut_points_cache:
return self._cut_points_cache[side]
start_dist, start_z = self._slope_start(side)
boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None
points = cut_profile_points(
start_dist,
start_z,
self.cut_ratio,
self.soil_cut_ratio,
boundary,
self.berm,
# 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을
# 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데
# 토사 경사로 그려 절토가 조용히 커진다(2026-09-07).
multi_knee=self.berm is not None,
)
self._cut_points_cache[side] = points
return points
def _cut_slope_z(self, side: str, dist: float) -> float:
"""절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.cut_points(side), dist)
def fill_points(self, side: str) -> list[tuple[float, float]]:
"""성토 사면 꼭짓점 — 소단이 들어 있다. 절토와 달리 무릎은 없다."""
if side in self._fill_points_cache:
return self._fill_points_cache[side]
start_dist, start_z = self._slope_start(side)
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm)
self._fill_points_cache[side] = points
return points
def _fill_slope_z(self, side: str, dist: float) -> float:
"""성토 사면선 표고(소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.fill_points(side), dist)
def cut_slope_segments(self) -> list[dict[str, Any]]:
"""절토 사면을 **경사 구간별로** 쪼갠 목록.
⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 임자였던 별표2 법정 경사 검사가 폐기됐고
(암질을 횡단도에서 안 고르기로 사용자 확정), 저장분에도 안 들어간다. 소단 기하가 이 셈
위에 서 있어 남겨 둔다 — **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.**
원래 필요했던 까닭(되살릴 때 그대로 유효) — 소단이 서면 사면 전체를 하나로 재는
「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이
실효 1:1.71). 검사는 소단을 뺀 **사면 구간 자체의 경사**를 봐야 한다.
· 평탄부(소단)는 싣지 않는다 — 경사 구간이 아니고 경사비가 무한대가 된다.
· 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다.
· `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None.
암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다).
"""
segments: list[dict[str, Any]] = []
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "cut":
continue
cross = self.cut_cross_dist(side)
points = self.cut_points(side)
sign = 1.0 if side == "left" else -1.0
for index in range(1, len(points)):
start_d, start_z = points[index - 1]
end_d, end_z = points[index]
if cross is not None and start_d >= cross - 1e-9:
break # 지반과 만난 뒤는 절토가 없다
if cross is not None and end_d > cross:
# 지반과 만나는 점에서 구간을 자른다.
end_z = berm_elevation_at(points, cross)
end_d = cross
run = end_d - start_d
rise = end_z - start_z
if run <= 1e-9 or rise <= 1e-6:
continue # 길이 0·역방향은 검사 대상이 아니다
if self.berm is not None and abs(run - self.berm.width_m) < 1e-6:
# 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다.
berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m
if abs(rise - berm_rise) < 1e-9:
continue
# 재료는 **그 구간을 실제로 그린 경사비**로 가른다 — 경계선을 다시 재면
# 안 된다. 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사
# 경사로 그린 구간이 경계 아래로 되돌아가 있는 일이 흔하다. 그것을 경계로
# 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다(2026-09-07 다른 창
# 실측: 용화 63측점에서 13구간). 그린 대로 적는 것이 맞다.
material: str | None = None
if self.two_stage and abs(self.soil_cut_ratio - self.cut_ratio) > 1e-9:
drawn = run / rise
material = (
"soil"
if abs(drawn - self.soil_cut_ratio) < abs(drawn - self.cut_ratio)
else "rock"
)
segments.append(
{
"side": side,
"ratio": round(run / rise, 4),
"rise_m": round(rise, 4),
"run_m": round(run, 4),
"start_offset_m": round(sign * start_d, 4),
"end_offset_m": round(sign * end_d, 4),
"material": material,
}
)
return segments
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 = self._fill_slope_z(side, dist)
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
dist = abs(offset_m)
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
return max(self._fill_slope_z(side, dist), 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 or self.berm is not None:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "cut":
continue
cross = self.cut_cross_dist(side)
for offset, _z in self.cut_points(side):
if cross is not None and offset > cross + 1e-9:
break # 지반과 만난 뒤는 절토가 없다
points.append(offset if side == "left" else -offset)
# 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다.
if self.berm is not None:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "fill":
continue
cross = self.fill_cross_dist(side)
for offset, _z in self.fill_points(side):
if cross is not None and offset > cross + 1e-9:
break
points.append(offset if side == "left" else -offset)
# 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(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
@@ -171,7 +171,15 @@ def _leaf_row(
result.excluded.append(row)
return row
if item.blocked_reason:
# ⚠⚠ **차단인지 아닌지는 `blocked_kind` 가 정한다 — 문구가 아니다.**
# 2026-09-09 실측: 배수관 다섯 줄이 `blocked_kind=None · in_bill=True` 인데도
# **사유가 있다는 것만으로 막혀** 금액이 안 서고 있었다. 그 사유는 차단이 아니라
# **주의 문구**였다 — 「관종을 안 정해 기본값(파형강관)으로 섰습니다」.
# ⇒ 사유만 온 줄은 **금액을 세우고 그 문구를 곁말로** 단다.
if item.blocked_reason and not item.blocked_kind:
row.note = " / ".join(part for part in (row.note, f"{item.blocked_reason}") if part)
if item.blocked_reason and item.blocked_kind:
# B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다.
# 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을
# 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다.
@@ -391,6 +391,13 @@ def match_table(
if match_choose_one_machine_table(node, table, catalog, result):
return
# ⚠ **규격이 열로 선 표**(관부설 12-11)도 여기서 가른다 — 값이 「0.62/2.5」 같은
# 나눗셈이라 보통 길로는 한 줄도 안 선다.
from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import match_spec_column_table
if match_spec_column_table(node, table, catalog, result):
return
form = table.get("pum_form", "")
if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS:
result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1
@@ -198,3 +198,113 @@ def match_choose_one_machine_table(
)
)
return True
# ---------------------------------------------------------------------------
# 규격이 **열**로 선 표 (2026-09-09) — 관부설 12-11-1·2·3
# ---------------------------------------------------------------------------
#
# | 구 분 | 규격 | 단위 | 관 경 별 적 용 |
# | ∅800mm | ∅1000mm | ∅1200mm | ← 첫 줄이 **규격 이름만** 늘어선 줄
# | 크레인 | 10ton | hr | 0.62/2.5 | 0.76/2.5 | 0.90/2.5 |
# | 배관공 | | 인 | 0.26/2.5 | 0.35/2.5 | 0.46/2.5 |
#
# ⚠ 값이 **나눗셈 식**이다 — 「0.62/2.5」는 「관 2.5m 한 개당 0.62시간」이라는 뜻이라
# **m 당으로 환산된 값**이다. 그대로 두면 자원 줄이 하나도 안 서서 배수관이 통째로
# 금액을 못 냈다(2026-09-09 실측: 아홉 줄 중 다섯이 길이까지 있는데 단가가 없었다).
#
# ⚠ **값이 빈 칸은 건너뛴다** — 기초콘크리트·거푸집·모래부설은 「별산」 자리다(계획서 9-9).
_SPEC_HEAD = re.compile(r"^[∅Ø⌀]\s*\d")
_FRACTION = re.compile(r"^\s*(\d+(?:\.\d+)?)((?:\s*/\s*\d+(?:\.\d+)?)+)\s*$")
def _fraction_value(cell: str) -> Decimal | None:
"""「0.62/2.5」·「0.016/2.5/2」를 수로. 나눗셈이 아니면 `None`."""
matched = _FRACTION.match(cell)
if not matched:
return None
value = Decimal(matched.group(1))
for part in matched.group(2).split("/"):
part = part.strip()
if not part:
continue
divisor = Decimal(part)
if divisor == 0:
return None
value = value / divisor
return value
def _cell_amount(cell: str) -> Decimal | None:
"""값 칸 하나 — 숫자 그대로이거나 나눗셈 식."""
text = _clean(cell)
if not text:
return None
if _NUMBER.match(text):
return parse_amount(text)
return _fraction_value(text)
def match_spec_column_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> bool:
"""규격이 **열**로 선 표를 읽는다. 그런 표가 아니면 `False`.
⚠ 첫 줄이 **규격 이름만** 늘어선 줄일 때만 내 표로 본다 — 「∅800mm ∅1000mm …」.
"""
rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)]
if len(rows) < 2:
return False
specs = [_clean(cell) for cell in rows[0] if _clean(cell)]
if len(specs) < 2 or not all(_SPEC_HEAD.match(spec) for spec in specs):
return False
unit = table.get("basis_unit") or ""
work_item_code = str(node.get("work_item_code", ""))
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
made = 0
for index, row in enumerate(rows[1:], start=1):
cells = [_clean(cell) for cell in row]
if not cells:
continue
name, spec_text = split_name_and_spec(cells[0])
entry = catalog.resolve(name, spec_text) or catalog.resolve(name, "")
if entry is None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=cells[0],
reason="카탈로그에 없는 이름 (규격이 열로 선 표) — 0 으로 때우지 않습니다.",
)
)
continue
values = [_cell_amount(cell) for cell in cells[1:]]
values = [value for value in values if value is not None]
if len(values) < len(specs):
# 값이 빈 규격이 있다 — 「별산」 줄이거나 표가 덜 찼다. 짐작해 채우지 않는다.
continue
for spec_name, amount in zip(specs, values[-len(specs) :]):
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=table_id,
pum_form=form,
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=amount,
amount_unit=unit,
raw_row_index=index,
variant=spec_name,
)
)
made += 1
return made > 0