From e43ad18f97a6d6535f81736ef8b5c7e875a13ac9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 14:54:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8):=20=EC=A0=88=ED=86=A0?= =?UTF-8?q?=20=EC=82=AC=EB=A9=B4=EC=97=90=20=EC=86=8C=EB=8B=A8(=EA=B3=84?= =?UTF-8?q?=EB=8B=A8)=20=EA=B8=B0=ED=95=98=EB=A5=BC=20=EB=84=A3=EC=9D=8C?= =?UTF-8?q?=20=E2=80=94=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=C2=B7TS=20=EC=A7=9D?= =?UTF-8?q?=20(=EA=B3=84=ED=9A=8D=EC=84=9C=203-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07)에 따라 소단을 **자동 적용하지 않고 사용자가 놓는 것**으로 만듦. 이 커밋은 그 기하 한 벌이고, 화면 폼은 다음 단계임. 새 짝 모듈 `common_util_cross_berm.py` · `.ts` — 절토 사면 꼭짓점을 만듦. 암 경계 무릎과 소단이 한 목록에 함께 들어가고, `breakpoints` 가 그 꼭짓점을 설계선에 실어 도면·면적·유토곡선·3D 가 계단을 그대로 봄. 기본값 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 가장 적게 파는 조합임. 기본값은 되돌리기 쉬운 쪽이어야 함 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 되돌리면 전 측점을 다시 계산해야 함. 실효 경사로도 그러함(경사 1:1 기준) 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → 1:1.71. · 기울기 2°는 사용자 확정값이고 법령·교본 근거가 없어 지식DB 에 적지 않음(사용자 지시). 무릎은 종전대로 **한 번만** 꺾음. 여러 번 꺾게 풀면 소단이 없는 지금 측점 설계도 같이 바뀌므로 별건으로 미룸(실측: 물결 경계에서 0.0016m 차이, 경계를 다시 만나면 구조가 달라짐). 자체검증 - 거울 시험 10건 `tmp/tests/test_cross_berm_mirror.py` — 소단 켠 5경우 포함해 파이썬·TS 꼭짓점이 1e-9 안에서 일치. 간격이 수평이 아니라 사면길이 기준인 것, 평탄부가 2° 기운 것, 소단이 없으면 옛 무릎 방식과 완전히 같은 것까지 확인. - 면적 시험 6건 `tmp/tests/test_cross_berm_area.py` — 절토 55.115 → 78.785㎡(기본값), 폭·간격이 물량에 단조로 반영, 소단 모서리가 설계선에 실림, 소단을 안 주면 설계선까지 동일. - ⚠ 어림식 두 번을 시험이 잡아냄. 「늘어난 면적 = 폭 × 그 위 높이」는 틀림(11.797 vs 7.770㎡) — 설계선이 밀리면 지반과 만나는 점도 함께 밀려 절토가 더 길어짐. 「폭만큼 밀림」도 틀림 — 참값은 「그 아래 소단 개수 × 폭」임. 시험은 그 참값으로 잼. - 기존 거울 시험(`test_b06_cross_design_mirror.py`) 10건 그대로 통과 — 리팩터가 값을 안 바꿨다는 증거임. 전체 537 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Design.py | 82 ++++----- common_util/common_util_cross_berm.py | 157 ++++++++++++++++++ common_util/common_util_cross_berm.ts | 152 +++++++++++++++++ .../common_util_cross_design_geometry.ts | 73 ++++---- 4 files changed, 373 insertions(+), 91 deletions(-) create mode 100644 common_util/common_util_cross_berm.py create mode 100644 common_util/common_util_cross_berm.ts diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 7cf2e4f8..75290112 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -34,6 +34,11 @@ from B06_Section.B06_Section_Engine_Areas import ( _split_cut_areas, _trapezoid_areas, ) +from common_util.common_util_cross_berm import ( + BermSpec, + cut_profile_points, +) +from common_util.common_util_cross_berm import elevation_at as berm_elevation_at from config.config_system import ( CURVE_WIDENING_MAX_WIDTH_M, SECTION_DITCH_SIDES, @@ -164,6 +169,7 @@ class _SectionGeometry: 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 사용자 확정: 곡선 바깥쪽). @@ -186,7 +192,9 @@ class _SectionGeometry: ) self._ground_at = ground_at self._rock_offset = rock_boundary_offset_m or 0.0 - self._rock_knee: dict[str, tuple[float, float] | None] = {} + # 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. + self.berm = berm + self._cut_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] = {} @@ -275,52 +283,25 @@ class _SectionGeometry: 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: - """절토 사면이 암반 경계선을 지나는 전환점(무릎 거리, 표고)을 구한다(측별 캐시). + def cut_points(self, side: str) -> list[tuple[float, float]]: + """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다. - 노면 끝(사면 시작)에서 암 경사(cut_ratio)로 올라가며 경계선을 만나면 그 지점부터 - 토사 경사로 완만해진다. 시작부터 경계 위면 무릎=시작(전부 토사), 끝까지 못 만나면 - None(전부 암). 경계선은 지반을 따라 변하므로 세밀 행진으로 교차점을 찾는다. + 셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다). + 소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다). """ - if not self.two_stage: - return None - if side in self._rock_knee: - return self._rock_knee[side] + if side in self._cut_points_cache: + return self._cut_points_cache[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 + 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 + ) + self._cut_points_cache[side] = points + return points 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 + """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" + return berm_elevation_at(self.cut_points(side), dist) def cut_cross_dist(self, side: str) -> float | None: """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). @@ -439,15 +420,20 @@ class _SectionGeometry: return max(fill_line, ground_m) def breakpoints(self) -> list[float]: - """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎 포함).""" + """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함).""" points = [0.0, self.left_extent, -self.right_extent] points.extend(offset for offset, _z in self.ditch_points) - if self.two_stage: + # 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. + 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 - knee = self.knee(side) if role == "cut" else None - if knee is not None: - points.append(knee[0] if side == "left" else -knee[0]) + 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) # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role @@ -490,6 +476,7 @@ def compute_cross_design( plan_radius_m: float | None = None, curve_outer_side: str | None = None, curve_widening_m: float | None = None, + berm: BermSpec | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -577,6 +564,7 @@ def compute_cross_design( ditch_enabled=ditch_enabled, widening_left_m=widening_left, widening_right_m=widening_right, + berm=berm, ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py new file mode 100644 index 00000000..d0f28997 --- /dev/null +++ b/common_util/common_util_cross_berm.py @@ -0,0 +1,157 @@ +"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9). + +짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며 +`tmp/tests/test_cross_berm_mirror.py` 가 그것을 지킨다. + +**왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. +지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을 +겹쳐 놓을 수 있으므로 경계를 **여러 번** 오갈 수 있다. 그래서 무릎을 미리 한 번 구하는 대신 +**바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은 +값이 나온다(거울 시험이 그것도 지킨다). + +**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. +· 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**이다. + 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 + 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): + 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. + 넓고 촘촘하면 설계 1:1 이 실제로는 1:1.7 로 서서 다른 비탈이 된다. +· 기울기 2°는 **2026-09-07 사용자 확정**이다 — 물이 고이지 않게 안쪽으로 기울이는 실무이고 + **법령·교본 근거가 없다**. 그래서 지식DB 에는 적지 않는다(사용자 지시). +""" + +import math +from typing import Callable, NamedTuple + +# 소단 기본값 — 근거는 위 모듈 설명. +BERM_DEFAULT_WIDTH_M = 0.5 +BERM_DEFAULT_INTERVAL_M = 3.0 +BERM_DEFAULT_SLOPE_DEG = 2.0 + +# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. +_STEP_M = 0.05 +_MAX_REACH_M = 200.0 + + +class BermSpec(NamedTuple): + """소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도).""" + + width_m: float = BERM_DEFAULT_WIDTH_M + interval_m: float = BERM_DEFAULT_INTERVAL_M + slope_deg: float = BERM_DEFAULT_SLOPE_DEG + + +def cut_profile_points( + start_dist: float, + start_z: float, + cut_ratio: float, + soil_cut_ratio: float, + rock_boundary_z: Callable[[float], float] | None, + berm: BermSpec | None, + max_reach_m: float = _MAX_REACH_M, +) -> list[tuple[float, float]]: + """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로. + + `rock_boundary_z` 가 None 이면 2단계 절토가 아니므로 경사는 `cut_ratio` 하나다. + 있으면 걸어가며 경계를 만나는 자리에서 암(`cut_ratio`) → 토사(`soil_cut_ratio`)로 **한 번** + 꺾는다. + + ⚠ **한 번만 꺾는 것은 기존 규칙을 그대로 지킨 것이다.** 소단이 들어가면 사면이 경계를 여러 + 번 오갈 수 있어 「무릎은 하나」 전제를 풀 수 있으나, 풀면 **소단이 없는 지금 측점들의 설계도 + 같이 바뀐다**(2026-09-07 실측: 물결 경계에서 0.0016m, 경계를 다시 만나는 자리에서는 구조가 + 통째로 달라짐). 소단을 넣는 김에 기존 설계를 조용히 바꿀 수는 없으므로 **별건으로 미룬다**. + + `berm` 이 있으면 사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣는다. + 평탄부는 안쪽이 낮도록 `slope_deg` 만큼 기울어 있어 바깥으로 갈수록 조금 올라간다 + (물이 노면 쪽으로 흐르게 — 소단측구를 놓는 자리다). + + 꼭짓점만 돌려준다 — 경사가 바뀌는 점과 소단 모서리뿐이라 사이는 직선이다. + """ + points: list[tuple[float, float]] = [(start_dist, start_z)] + dist, elevation = start_dist, start_z + slant_since_berm = 0.0 + limit = start_dist + max_reach_m + berm_rise = ( + math.tan(math.radians(berm.slope_deg)) * berm.width_m + if berm is not None and berm.width_m > 0 + else 0.0 + ) + # 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다). + in_soil = rock_boundary_z is None or elevation >= rock_boundary_z(dist) + ratio = soil_cut_ratio if (rock_boundary_z is not None and in_soil) else cut_ratio + + while dist < limit: + rise = _STEP_M / ratio + slant = math.hypot(_STEP_M, rise) + + # ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다. + if berm is not None and berm.interval_m > 0 and slant_since_berm + slant >= berm.interval_m: + remain = max(berm.interval_m - slant_since_berm, 0.0) + run = remain / math.hypot(1.0, 1.0 / ratio) + dist += run + elevation += run / ratio + points.append((dist, elevation)) # 소단 안쪽 모서리 + dist += berm.width_m + elevation += berm_rise + points.append((dist, elevation)) # 소단 바깥 모서리 + slant_since_berm = 0.0 + continue + + next_dist = dist + _STEP_M + next_z = elevation + rise + + # ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다. + if rock_boundary_z is not None and not in_soil: + diff_now = elevation - rock_boundary_z(dist) + diff_next = next_z - rock_boundary_z(next_dist) + if diff_next >= 0: + span = diff_next - diff_now + share = (-diff_now) / span if abs(span) > 1e-12 else 0.0 + share = min(max(share, 0.0), 1.0) + knee_dist = dist + _STEP_M * share + knee_z = elevation + rise * share + slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation) + dist, elevation = knee_dist, knee_z + points.append((dist, elevation)) # 무릎 + in_soil = True + ratio = soil_cut_ratio + continue + + dist, elevation = next_dist, next_z + slant_since_berm += slant + + points.append((dist, elevation)) + return _dedupe(points) + + +def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]: + """같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게.""" + out: list[tuple[float, float]] = [] + for point in points: + if out and abs(point[0] - out[-1][0]) < 1e-9 and abs(point[1] - out[-1][1]) < 1e-9: + continue + out.append(point) + return out + + +def elevation_at(points: list[tuple[float, float]], dist: float) -> float: + """꼭짓점 목록에서 거리 하나의 표고 — 사이는 직선 보간, 끝은 마지막 경사 연장.""" + if not points: + return 0.0 + if dist <= points[0][0]: + return points[0][1] + for index in range(1, len(points)): + x0, z0 = points[index - 1] + x1, z1 = points[index] + if dist > x1 + 1e-12: + continue + span = x1 - x0 + if span <= 1e-12: + return z1 + return z0 + (z1 - z0) * ((dist - x0) / span) + # 끝을 넘어가면 마지막 두 점의 기울기로 잇는다. + x0, z0 = points[-2] if len(points) > 1 else points[-1] + x1, z1 = points[-1] + span = x1 - x0 + if span <= 1e-12: + return z1 + return z1 + (z1 - z0) / span * (dist - x1) diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts new file mode 100644 index 00000000..d9a669b4 --- /dev/null +++ b/common_util/common_util_cross_berm.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * common_util/common_util_cross_berm.ts + * 절토 사면의 **계단(소단) 포함 꼭짓점** — 파이썬 짝 (계획서 3-9) + * + * ⚠⚠ 짝: `common_util/common_util_cross_berm.py` — 한쪽만 고치면 화면과 저장본이 갈린다. + * 거울 시험: `tmp/tests/test_cross_berm_mirror.py` + * + * 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. + * 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다. + * + * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. + * · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**. + * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 + * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): + * 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. + * · 기울기 2°는 **2026-09-07 사용자 확정**이고 법령·교본 근거가 없다 — 지식DB 에 적지 않는다. + * ========================================================================== */ + +/** 소단 기본값 — 근거는 위 설명. */ +export const BERM_DEFAULT_WIDTH_M = 0.5; +export const BERM_DEFAULT_INTERVAL_M = 3.0; +export const BERM_DEFAULT_SLOPE_DEG = 2.0; + +/** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ +const STEP_M = 0.05; +const MAX_REACH_M = 200.0; + +/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */ +export interface BermSpec { + widthM: number; + intervalM: number; + slopeDeg: number; +} + +export function bermSpec( + widthM = BERM_DEFAULT_WIDTH_M, + intervalM = BERM_DEFAULT_INTERVAL_M, + slopeDeg = BERM_DEFAULT_SLOPE_DEG, +): BermSpec { + return { widthM, intervalM, slopeDeg }; +} + +/** + * 짝: `cut_profile_points`. 절토 사면 꼭짓점 `[[거리, 표고], ...]` — 시작에서 바깥으로. + * + * `rockBoundaryZ` 가 null 이면 2단계 절토가 아니라 경사가 하나다. 있으면 경계를 만나는 + * 자리에서 암 → 토사로 **한 번** 꺾는다. + * + * ⚠ 한 번만 꺾는 것은 **기존 규칙을 그대로 지킨 것**이다. 여러 번 꺾게 바꾸면 소단이 없는 + * 지금 측점들의 설계도 같이 바뀌므로 별건으로 미룬다(2026-09-07). + */ +export function cutProfilePoints( + startDist: number, + startZ: number, + cutRatio: number, + soilCutRatio: number, + rockBoundaryZ: ((dist: number) => number) | null, + berm: BermSpec | null, + maxReachM: number = MAX_REACH_M, +): Array<[number, number]> { + const points: Array<[number, number]> = [[startDist, startZ]]; + let dist = startDist; + let elevation = startZ; + let slantSinceBerm = 0; + const limit = startDist + maxReachM; + const bermRise = + berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0; + // 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다). + let inSoil = rockBoundaryZ === null || elevation >= rockBoundaryZ(dist); + let ratio = rockBoundaryZ !== null && inSoil ? soilCutRatio : cutRatio; + + while (dist < limit) { + const rise = STEP_M / ratio; + const slant = Math.hypot(STEP_M, rise); + + // ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다. + if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) { + const remain = Math.max(berm.intervalM - slantSinceBerm, 0); + const run = remain / Math.hypot(1, 1 / ratio); + dist += run; + elevation += run / ratio; + points.push([dist, elevation]); // 소단 안쪽 모서리 + dist += berm.widthM; + elevation += bermRise; + points.push([dist, elevation]); // 소단 바깥 모서리 + slantSinceBerm = 0; + continue; + } + + const nextDist = dist + STEP_M; + const nextZ = elevation + rise; + + // ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다. + if (rockBoundaryZ !== null && !inSoil) { + const diffNow = elevation - rockBoundaryZ(dist); + const diffNext = nextZ - rockBoundaryZ(nextDist); + if (diffNext >= 0) { + const span = diffNext - diffNow; + let share = Math.abs(span) > 1e-12 ? -diffNow / span : 0; + share = Math.min(Math.max(share, 0), 1); + const kneeDist = dist + STEP_M * share; + const kneeZ = elevation + rise * share; + slantSinceBerm += Math.hypot(kneeDist - dist, kneeZ - elevation); + dist = kneeDist; + elevation = kneeZ; + points.push([dist, elevation]); // 무릎 + inSoil = true; + ratio = soilCutRatio; + continue; + } + } + + dist = nextDist; + elevation = nextZ; + slantSinceBerm += slant; + } + + points.push([dist, elevation]); + return dedupe(points); +} + +/** 같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게. */ +function dedupe(points: Array<[number, number]>): Array<[number, number]> { + const out: Array<[number, number]> = []; + for (const point of points) { + const last = out[out.length - 1]; + if (last && Math.abs(point[0] - last[0]) < 1e-9 && Math.abs(point[1] - last[1]) < 1e-9) { + continue; + } + out.push(point); + } + return out; +} + +/** 짝: `elevation_at`. 꼭짓점 목록에서 거리 하나의 표고(사이는 직선, 끝은 연장). */ +export function elevationAt(points: Array<[number, number]>, dist: number): number { + if (points.length === 0) return 0; + if (dist <= points[0][0]) return points[0][1]; + for (let index = 1; index < points.length; index += 1) { + const [x0, z0] = points[index - 1]; + const [x1, z1] = points[index]; + if (dist > x1 + 1e-12) continue; + const span = x1 - x0; + if (span <= 1e-12) return z1; + return z0 + (z1 - z0) * ((dist - x0) / span); + } + const [x0, z0] = points.length > 1 ? points[points.length - 2] : points[points.length - 1]; + const [x1, z1] = points[points.length - 1]; + const span = x1 - x0; + if (span <= 1e-12) return z1; + return z1 + ((z1 - z0) / span) * (dist - x1); +} diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 1ff67234..bb9cdbc1 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -6,9 +6,10 @@ * `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다. * ========================================================================== */ +import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm"; + /** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ const MARCH_STEP_M = 0.05; -const KNEE_MAX_M = 200; const CROSS_MAX_M = 500; export interface ResolvedGroup { @@ -87,7 +88,9 @@ export class SectionGeometry { ditchPoints: Array<[number, number]> = []; private groundAt: ((offsetM: number) => number) | null; private rockOffset: number; - private rockKnee = new Map(); + /** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */ + berm: BermSpec | null = null; + private cutPointsCache = new Map>(); private cutCross = new Map(); private fillCross = new Map(); @@ -203,50 +206,27 @@ export class SectionGeometry { return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset; } - /** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */ - knee(side: string): [number, number] | null { - if (!this.twoStage) return null; - const cached = this.rockKnee.get(side); + /** 짝: `cut_points`. 절토 사면 꼭짓점 — 무릎과 소단이 모두 여기 들어 있다. */ + cutPoints(side: string): Array<[number, number]> { + const cached = this.cutPointsCache.get(side); if (cached !== undefined) return cached; const [startDist, startZ] = this.slopeStart(side); - let diffPrev = startZ - this.rockBoundaryZ(side, startDist); - let result: [number, number] | null; - if (diffPrev >= 0) { - result = [startDist, startZ]; // 시작부터 토사(경계 위) - } else { - result = null; - let distPrev = startDist; - let dist = startDist + MARCH_STEP_M; - while (dist <= startDist + KNEE_MAX_M) { - const zRock = startZ + (dist - startDist) / this.cutRatio; - const diff = zRock - this.rockBoundaryZ(side, dist); - if (diff >= 0) { - const span = diff - diffPrev; - const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0; - const kneeDist = distPrev + (dist - distPrev) * ratio; - const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio; - result = [kneeDist, kneeZ]; - break; - } - distPrev = dist; - diffPrev = diff; - dist += MARCH_STEP_M; - } - } - this.rockKnee.set(side, result); - return result; + const boundary = this.twoStage ? (dist: number) => this.rockBoundaryZ(side, dist) : null; + const points = cutProfilePoints( + startDist, + startZ, + this.cutRatio, + this.soilCutRatio, + boundary, + this.berm, + ); + this.cutPointsCache.set(side, points); + return points; } - /** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */ + /** 짝: `_cut_slope_z`. 절토 사면선 표고(무릎·소단 반영, 지반 클램프 없음). */ private cutSlopeZ(side: string, dist: number): number { - const [startDist, startZ] = this.slopeStart(side); - const knee = this.twoStage ? this.knee(side) : null; - if (knee !== null) { - const [kneeDist, kneeZ] = knee; - if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio; - return kneeZ + (dist - kneeDist) / this.soilCutRatio; - } - return startZ + (dist - startDist) / this.cutRatio; + return elevationAt(this.cutPoints(side), dist); } /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ @@ -354,11 +334,16 @@ export class SectionGeometry { breakpoints(): number[] { const points = [0, this.leftExtent, -this.rightExtent]; for (const [offset] of this.ditchPoints) points.push(offset); - if (this.twoStage) { + // 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. + if (this.twoStage || this.berm !== null) { for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; - const knee = role === "cut" ? this.knee(side) : null; - if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]); + if (role !== "cut") continue; + const cross = this.cutCrossDist(side); + for (const [offset] of this.cutPoints(side)) { + if (cross !== null && offset > cross + 1e-9) break; // 지반과 만난 뒤는 절토가 없다 + points.push(side === "left" ? offset : -offset); + } } } for (const side of ["left", "right"]) {