From db4f17fb1ca683b158f39fbff3b84b40d53d6cfc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:38:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8):=20=EC=84=B1=ED=86=A0?= =?UTF-8?q?=EB=A9=B4=20=EC=86=8C=EB=8B=A8=20+=20=EC=98=B9=EB=B2=BD=20?= =?UTF-8?q?=EC=9D=98=EB=AC=B4=20=ED=8C=90=EC=A0=95=EC=9D=84=20=E3=80=8C?= =?UTF-8?q?=EC=86=8C=EB=8B=A8=20=EC=82=AC=EC=9D=B4=20=EA=B5=AC=EA=B0=84?= =?UTF-8?q?=EB=B3=84=20=EC=B5=9C=EB=8C=80=E3=80=8D=EB=A1=9C=20(=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=84=9C=203-9=20=E2=91=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 묶음으로 처리했음 — 화면에 5m 판정이 틀린 채 서 있는 시간이 없게. 성토 사면 꼭짓점 짝 신설(`fill_profile_points` · `fillProfilePoints`). 절토와 달리 경사가 하나뿐이라 무릎이 없고, 소단 규칙은 같음. 설계선·지반 교차·꼭짓점 목록이 모두 그 선을 봄. ⚠ **옹벽 의무 판정을 함께 고쳤음** — 「성토사면 길이 5m 이내, 넘으면 옹벽·석축 의무」 (`성토_비탈면.md` §2)를 재는 `fillSlopeLengths` 가 **「성토선은 1:n 직선」을 전제로 수평거리 × 기울기**로 재고 있었음(주석에도 그 전제가 적혀 있었음). 소단이 들어가면 그 전제가 깨져 값이 틀림. 고침 — 소단이 있으면 설계선을 걸어가며 **소단으로 끊긴 한 도막**의 최대 길이를 잼. · 전체를 한 줄로 재면 소단을 넣어도 5m 를 넘어 **의무가 사라지지 않음** · 실효 경사로 재면 완만해져 **의무가 사라진 것처럼** 보임 둘 다 틀리므로 「끊긴 한 도막」이 맞는 기준임. **소단이 없는 측점은 종전 식을 그대로 탐** — `design.berm` 이 없으면 예전 계산 그대로라 값이 한 톨도 안 바뀜(구조로 보장). 화면 전후 대조는 다음 단계에서 냄. 자체검증 — 새 시험 2건(성토면에 계단이 서고 같은 거리에서 덜 내려감 · 소단이 없으면 성토선이 곧은 한 줄). 전체 542 passed · 18 skipped. TS 타입 검사·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Api_Types.ts | 3 ++ B06_Section/B06_Section_Engine_Design.py | 33 +++++++++++-- B06_Section/B06_Section_UI_Cross_Fit.ts | 49 ++++++++++++++++++- common_util/common_util_cross_berm.py | 46 +++++++++++++++++ common_util/common_util_cross_berm.ts | 45 +++++++++++++++++ .../common_util_cross_design_geometry.ts | 43 +++++++++++++--- 6 files changed, 207 insertions(+), 12 deletions(-) diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 590c76c5..d86d89bd 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -475,6 +475,9 @@ export interface CrossDesign { rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06). 확정 시 병합되며 세션 값이 우선이다. */ display_half_width_m?: number; + /** 이 측점에 놓인 소단 제원(계획서 3-9). 없으면 계단 없음. + * 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 계단이 서게 되싣는 값이다. */ + berm?: { width_m: number; interval_m: number; slope_deg: number }; } export interface CrossDesignResponse { diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 7c7a86a7..cac4b5b3 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -38,6 +38,7 @@ from B06_Section.B06_Section_Engine_Areas import ( 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 config.config_system import ( @@ -196,6 +197,7 @@ class _SectionGeometry: # 소단 제원(없으면 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] = {} @@ -313,6 +315,19 @@ class _SectionGeometry: """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" 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]]: """절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사가 읽는 값이다. @@ -445,7 +460,7 @@ class _SectionGeometry: 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 + fill_line = self._fill_slope_z(side, dist) if fill_line - self._ground_at(signed) <= 0: result = dist break @@ -477,9 +492,7 @@ class _SectionGeometry: 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) @@ -490,8 +503,7 @@ class _SectionGeometry: 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) + return max(self._fill_slope_z(side, dist), ground_m) def breakpoints(self) -> list[float]: """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함).""" @@ -508,6 +520,17 @@ class _SectionGeometry: 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 diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index ac6cd84a..af03913e 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -103,13 +103,60 @@ export function fillSlopeLengths(section: CrossSection): { const meet = meetOffset(designAt, groundAt, start, limit, outward); const end = Math.abs(meet) > Math.abs(limit) ? limit : meet; lengths[side] = { - lengthM: Math.abs(end - start) * slant, + // 소단이 있으면 사면이 계단으로 끊기므로 **구간별 최대**를 잰다. 없으면 종전대로 + // 수평 성분 × 기울기 — 값이 한 톨도 안 바뀐다(2026-09-07, 계획서 3-9). + lengthM: design.berm + ? longestFillRun(design, start, end, slant) + : Math.abs(end - start) * slant, open: Math.abs(designAt(end) - groundAt(end)) > MEET_TOLERANCE_M, }; } return lengths; } +/** + * 소단으로 끊긴 성토사면에서 **가장 긴 한 구간**의 경사길이(m). + * + * 왜 최대인가 — 법정 기준은 「성토사면 길이 5m 이내, 넘으면 옹벽·석축 의무」다 + * (`성토_비탈면.md` §2). 소단은 사면을 끊는 시설이므로, 판정은 **끊긴 한 도막**을 봐야 한다. + * 전체를 한 줄로 재면 소단을 넣어도 5m 를 넘어 **의무가 사라지지 않고**, 반대로 실효 경사로 + * 재면 완만해져 **의무가 사라진 것처럼** 보인다. 둘 다 틀린다. + * + * 평탄부(소단)는 길이에 안 넣고 그 자리에서 도막을 끊는다. + */ +function longestFillRun( + design: CrossDesign, + startOffset: number, + endOffset: number, + slant: number, +): number { + const berm = design.berm; + if (!berm) return Math.abs(endOffset - startOffset) * slant; + const low = Math.min(startOffset, endOffset); + const high = Math.max(startOffset, endOffset); + const bermRise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m; + let longest = 0; + let current = 0; + const line = design.design_line; + for (let index = 1; index < line.length; index += 1) { + const a = line[index - 1]; + const b = line[index]; + const from = Math.max(Math.min(a.offset_m, b.offset_m), low); + const to = Math.min(Math.max(a.offset_m, b.offset_m), high); + if (to - from <= 1e-9) continue; // 이 도막은 사면 밖이다 + const run = Math.abs(b.offset_m - a.offset_m); + const rise = b.elevation_m - a.elevation_m; + const isBerm = Math.abs(run - berm.width_m) < 1e-6 && Math.abs(rise - bermRise) < 1e-6; + if (isBerm) { + longest = Math.max(longest, current); + current = 0; + continue; + } + current += (to - from) * slant; + } + return Math.max(longest, current); +} + /** 노면·노견(+측구) 구간의 바깥 경계 — 교차점 탐색은 여기서부터 바깥으로 간다. */ function protectedSpan( design: CrossDesign, diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index 02c69c73..ae79cb0b 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -132,6 +132,52 @@ def cut_profile_points( return _dedupe(points) +def fill_profile_points( + start_dist: float, + start_z: float, + fill_ratio: float, + berm: BermSpec | None, + max_reach_m: float = _MAX_REACH_M, +) -> list[tuple[float, float]]: + """성토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로 **내려간다**. + + 절토와 달리 경사가 하나뿐이라 무릎이 없다(암 경계는 절토만 본다). 소단은 같은 규칙 — + 사면길이가 `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 + ) + ratio = max(fill_ratio, 1e-6) + + while dist < limit: + drop = _STEP_M / ratio + slant = math.hypot(_STEP_M, drop) + 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 + dist += _STEP_M + elevation -= drop + 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]] = [] diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index c4e0a192..5d97e807 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -122,6 +122,51 @@ export function cutProfilePoints( return dedupe(points); } +/** + * 짝: `fill_profile_points`. 성토 사면 꼭짓점 — 시작에서 바깥으로 **내려간다**. + * + * 절토와 달리 경사가 하나뿐이라 무릎이 없다. 소단 규칙은 같다. + */ +export function fillProfilePoints( + startDist: number, + startZ: number, + fillRatio: number, + 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; + const ratio = Math.max(fillRatio, 1e-6); + + while (dist < limit) { + const drop = STEP_M / ratio; + const slant = Math.hypot(STEP_M, drop); + 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; + } + dist += STEP_M; + elevation -= drop; + slantSinceBerm += slant; + } + + points.push([dist, elevation]); + return dedupe(points); +} + /** 같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게. */ function dedupe(points: Array<[number, number]>): Array<[number, number]> { const out: Array<[number, number]> = []; diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 7e7f6d27..99f74aa4 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -6,7 +6,12 @@ * `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다. * ========================================================================== */ -import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm"; +import { + type BermSpec, + cutProfilePoints, + elevationAt, + fillProfilePoints, +} from "./common_util_cross_berm"; /** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */ export interface CutSlopeSegment { @@ -107,6 +112,7 @@ export class SectionGeometry { /** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */ berm: BermSpec | null = null; private cutPointsCache = new Map>(); + private fillPointsCache = new Map>(); private cutCross = new Map(); private fillCross = new Map(); @@ -251,6 +257,21 @@ export class SectionGeometry { return elevationAt(this.cutPoints(side), dist); } + /** 짝: `fill_points`. 성토 사면 꼭짓점 — 소단이 들어 있다(무릎은 없다). */ + fillPoints(side: string): Array<[number, number]> { + const cached = this.fillPointsCache.get(side); + if (cached !== undefined) return cached; + const [startDist, startZ] = this.slopeStart(side); + const points = fillProfilePoints(startDist, startZ, this.fillRatio, this.berm); + this.fillPointsCache.set(side, points); + return points; + } + + /** 짝: `_fill_slope_z`. 성토 사면선 표고(소단 반영, 지반 클램프 없음). */ + private fillSlopeZ(side: string, dist: number): number { + return elevationAt(this.fillPoints(side), dist); + } + /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ cutCrossDist(side: string): number | null { const cached = this.cutCross.get(side); @@ -279,12 +300,12 @@ export class SectionGeometry { if (cached !== undefined) return cached; let result: number | null = null; if (this.groundAt !== null) { - const [startDist, startZ] = this.slopeStart(side); + const [startDist] = this.slopeStart(side); let dist = startDist; const maxDist = startDist + CROSS_MAX_M; while (dist <= maxDist) { const signed = side === "left" ? dist : -dist; - const fillLine = startZ - (dist - startDist) / this.fillRatio; + const fillLine = this.fillSlopeZ(side, dist); if (fillLine - this.groundAt(signed) <= 0) { result = dist; break; @@ -339,9 +360,7 @@ export class SectionGeometry { } } const role = side === "left" ? this.leftRole : this.rightRole; - const [startDist, startZ] = this.slopeStart(side); const dist = Math.abs(offsetM); - const run = dist - startDist; if (role === "cut") { const cross = this.cutCrossDist(side); if (cross !== null && dist >= cross) return groundM; @@ -349,7 +368,7 @@ export class SectionGeometry { } const cross = this.fillCrossDist(side); if (cross !== null && dist >= cross) return groundM; - return Math.max(startZ - run / this.fillRatio, groundM); + return Math.max(this.fillSlopeZ(side, dist), groundM); } /** @@ -419,6 +438,18 @@ export class SectionGeometry { } } } + // 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다. + if (this.berm !== null) { + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + if (role !== "fill") continue; + const cross = this.fillCrossDist(side); + for (const [offset] of this.fillPoints(side)) { + if (cross !== null && offset > cross + 1e-9) break; + points.push(side === "left" ? offset : -offset); + } + } + } for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side);