From 24aba4008d9561fef90d4a2a590ac2f52ae3691c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:50:02 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(b06):=20=EC=82=AC=EB=A9=B4=EC=95=88?= =?UTF-8?q?=EC=A0=95=EA=B5=B0=20=EA=B5=AC=EA=B0=84=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EC=A4=80=20=C2=B7=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EC=8B=AC=EC=9D=8C=20=C2=B7=20=EA=B4=80=20?= =?UTF-8?q?=EA=B7=BC=EC=B2=98=20=EB=8F=85=EB=A6=BD=20=EB=B2=BD=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=20=ED=95=9C=20=EB=B2=8C(=EC=B9=B4=EB=93=9C=20=C2=B7?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EB=A9=B4=EC=A0=81=20=C2=B7=20B07=20?= =?UTF-8?q?=C2=B7=203D)(178-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B05_Profile/B05_Profile_Structures_Schema.py | 4 +- .../B05_Profile_UI_Corridor_Structures.ts | 13 ++- B06_Section/B06_Section_Structure_Layouts.ts | 6 +- .../B06_Section_UI_Cross_Culvert_Wire.ts | 9 ++ B06_Section/B06_Section_UI_Cross_View.ts | 5 +- .../tester/test_178_1_slope_wall_guard.py | 105 ++++++++++++++++++ .../test_b05_extra_stations_supplier.py | 43 +++++-- 7 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_178_1_slope_wall_guard.py diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 5e4ff6444..64264b26d 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -146,13 +146,15 @@ def is_station_planting_type(item: StructureType) -> bool: - A군(횡단배수·계곡 통과 시설): 그 자리에 횡단도가 필요하다. - D군 구간형(기슭막이 등): 구간 시작·기준·종료에 횡단도가 필요하다 — 길이가 길면 그만큼 여러 장이 나온다(사용자 확정). + - C군 구간형(사면안정 — 옹벽·흙막이·소단 등): D군 구간형과 같이 시작·기준·종료. + 안 심으면 측점 격자 사이에 놓인 벽이 횡단도에 안 얹힌다. 측점 생성(`resolve_extra_stations`)과 구조물 재이관 차단 (`B05_Profile_Structures_Migration`)이 같은 규칙을 봐야 "기타" 고스트가 안 생긴다. """ if item.group == "A": return True - return item.group == "D" and item.placement == "interval" + return item.group in ("C", "D") and item.placement == "interval" def station_planting_labels() -> set[str]: diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 00de70541..c39573839 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -23,7 +23,12 @@ import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash"; import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch"; import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; -import { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; +import { + culvertOwnerFor, + ownWallsDrawAt, + restrictToSide, + tierSpanOf, +} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import { computeFordLayout, fordAdjustOf } from "../B06_Section/B06_Section_UI_Cross_Ford"; @@ -277,9 +282,9 @@ export function buildCorridorStructures( // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 // culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다 // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). - const revetLayout = section.culvert - ? null - : computeRevetmentLayout(section, section.design?.revet_adjust?.own); + const revetLayout = ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) + ? computeRevetmentLayout(section, section.design?.revet_adjust?.own) + : null; const paveChute = fordPavementChute(section, crossSections); if (!layout && !fordLayout && !section.box && !revetLayout && !paveChute) continue; const chainage = section.chainage_m; diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 936694a71..9c4aeaff3 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -22,6 +22,7 @@ import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_ import { computeCardCulvert, culvertLinkFor, + ownWallsDrawAt, tierSpanOf, } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { @@ -123,8 +124,9 @@ export function computeStoredLayouts(section: CrossSection, sections: readonly C culvert, ); // 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다. - const own = - !section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null; + const own = ownWallsDrawAt(section, link?.source) + ? computeRevetmentLayout(section, design.revet_adjust?.own) + : null; return { design, link, culvert, box, ford, own }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index ab8212da6..810f0396f 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -262,6 +262,15 @@ export function culvertWallsStandAt(owner: CrossSection, section: CrossSection): return keys.some((key) => wallStandsAt(owner, section, key)); } +/** + * 독립 벽(사면안정 C군 · 옛 D경로)을 이 측점에 그리나 — 카드 · 서버 면적 · B07 · 3D 가 + * 함께 쓰는 가드 한 벌(178-1). 관 세트가 붙은 측점이거나 연동 관(`owner`)의 벽이 이 + * 측점에 실제로 서면 배관 경로가 그린다(겹침 방지) — 링크만 있고 벽이 안 서면 그린다. + */ +export function ownWallsDrawAt(section: CrossSection, owner: CrossSection | undefined): boolean { + return !section.culvert && !(owner && culvertWallsStandAt(owner, section)); +} + export function culvertLinkFor( section: CrossSection, sections: readonly CrossSection[], diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 18ff82f52..f148b57df 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -20,7 +20,7 @@ import { hideFordPavedRoad, appendFordSurfaceDropPlan, } from "./B06_Section_UI_Cross_Ford_Pavement"; -import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch"; import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill"; @@ -465,8 +465,7 @@ export function createCrossSectionCard( // 옆에서 이어져 온**(culvertLink) 경우엔 배관 경로가 그린다 — 옛 D경로는 건너뛴다 // (둘 다 그리면 이웃 카드에 벽이 겹친다). 가드는 **그 링크의 벽이 이 카드에 실제로 // 설 때만** 막는다(2026-09-09 — 링크만 있어도 막으면 구조물이 안 보였다). - const linkedWallsHere = !!culvertLink && culvertWallsStandAt(culvertLink.source, section); - if (!section.culvert && !linkedWallsHere) { + if (ownWallsDrawAt(section, culvertLink?.source)) { const ownAdjust = revetOffset?.adjustFor(section, "own"); const ownLayout = computeRevetmentLayout(section, ownAdjust); ownDesignTrim = ownLayout?.designTrim; diff --git a/resources/tester/test_178_1_slope_wall_guard.py b/resources/tester/test_178_1_slope_wall_guard.py new file mode 100644 index 000000000..b743055ad --- /dev/null +++ b/resources/tester/test_178_1_slope_wall_guard.py @@ -0,0 +1,105 @@ +"""178-1 사면안정 벽(흙막이 · 옹벽) 가드 한 벌 — 카드 · 서버 면적 · B07 · 3D. + +흠: 카드는 「연동 관 벽이 이 측점에 실제로 설 때만」 독립 벽을 건너뛰는데, 서버 면적 +(`computeStoredLayouts` → Node) · B07 은 링크만 있어도 건너뛰고 3D 는 관 측점만 봤다. +⇒ 관 링크는 있으나 관 벽이 안 서는 측점에서 카드엔 흙막이가 서고 서버 면적 · B07 엔 없음. + +고침: `ownWallsDrawAt` 한 벌을 세 곳이 함께 씀. 서버 면적은 같은 TS 를 Node 로 돌리므로 +번들을 새로 빌드해 그 결과로 판정한다. +""" + +import copy +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" +FIXTURE = ROOT / "resources" / "tester" / "fixtures" / "b06_73_4_body_guard_sections.json" + +SOIL_GUARD = { + "structure_id": "w1", + "type_id": "soil_guard", + "name": "흙막이", + "start_m": 150, + "end_m": 370, + "anchor_m": 260, + "form": "돌쌓기(찰)", + "height_m": 1.5, +} + + +@pytest.fixture(scope="module") +def bundle(tmp_path_factory) -> Path: + if not VITE.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + out = tmp_path_factory.mktemp("server_calc") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + "../B06_Section/B06_Section_Server_Calc_Node.ts", + "--outDir", + str(out), + "--logLevel", + "error", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return out / "B06_Section_Server_Calc_Node.js" + + +def _areas(bundle: Path, sections: list, tmp_path: Path) -> dict[float, tuple]: + src, dst = tmp_path / "in.json", tmp_path / "out.json" + src.write_text(json.dumps({"detail": {"cross_sections": sections}}), encoding="utf-8") + subprocess.run(["node", str(bundle), str(src), str(dst)], check=True, cwd=str(ROOT)) # noqa: S603 + rows = json.loads(dst.read_text(encoding="utf-8"))["areas"] + return {row["chainage_m"]: (row["cut_area_m2"], row["fill_area_m2"]) for row in rows} + + +def _sections() -> list: + return copy.deepcopy(json.loads(FIXTURE.read_text(encoding="utf-8"))["cross_sections"]) + + +def test_링크만_있고_관_벽이_안_서면_흙막이_면적_행이_나온다(bundle, tmp_path): + """173.087 관의 다단 구간값(20 m)이 160 · 180 을 덮어 링크는 서지만 관 벽(전 · 후 5 m)은 안 섬.""" + sections = [s for s in _sections() if s["chainage_m"] < 300] + sections[1]["design"]["extra_spans"] = {"extra0": {"before_m": 20, "after_m": 20}} + for section in sections: + if not section.get("culvert"): + section["revetment"] = SOIL_GUARD + areas = _areas(bundle, sections, tmp_path) + assert 160 in areas and 180 in areas, f"흙막이가 선 측점 면적 행 없음: {sorted(areas)}" + # 표준 성토(160 = 1.45 · 180 = 5.32 ㎡)보다 벽이 사면을 끊어 줄어듦 + assert areas[160][1] < 1.45 and areas[180][1] < 5.32 + + +def test_관_벽이_서는_측점은_독립_벽을_안_그린다(bundle, tmp_path): + """340 · 360 은 352.14 관 벽이 섬 — 흙막이를 얹어도 면적이 관 벽 그대로(겹쳐 그리지 않음).""" + plain = _areas(bundle, _sections(), tmp_path) + walled = _sections() + for section in walled: + if not section.get("culvert"): + section["revetment"] = SOIL_GUARD + areas = _areas(bundle, walled, tmp_path) + assert areas[340] == plain[340] and areas[360] == plain[360] + + +def test_카드_서버_3D_가_가드_한_벌을_쓴다(): + files = { + "카드": ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts", + "서버 면적 · B07": ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts", + "3D": ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Structures.ts", + } + for where, path in files.items(): + source = path.read_text(encoding="utf-8") + assert "ownWallsDrawAt(" in source, f"{where} 가 가드 한 벌을 안 씀" + assert "culvertWallsStandAt(" not in source, f"{where} 에 따로 가드가 남음" diff --git a/resources/tester/test_b05_extra_stations_supplier.py b/resources/tester/test_b05_extra_stations_supplier.py index 237ce6a70..9aa2c9c9d 100644 --- a/resources/tester/test_b05_extra_stations_supplier.py +++ b/resources/tester/test_b05_extra_stations_supplier.py @@ -133,17 +133,10 @@ def test_group_a_point_and_revetment_facility_join(tmp_path): def test_other_groups_do_not_plant_stations(tmp_path): - """C·E·G군은 측점을 만들지 않는다 — 지금 켠 것은 A군과 D군 구간형뿐이다.""" + """E·G군은 측점을 만들지 않는다 — 켠 것은 A군과 C·D군 구간형뿐이다(178-1).""" _write_structures( tmp_path, [ - { - "type_id": "retaining_wall", - "chainage_m": 40.0, - "start_m": 40.0, - "end_m": 60.0, - "placement": "interval", - }, { "type_id": "pavement_concrete", "chainage_m": 300.0, @@ -157,6 +150,40 @@ def test_other_groups_do_not_plant_stations(tmp_path): assert [chainage for chainage, _label in extras] == [84.3, 149.73, 200.92, 275.71] +def test_slope_group_interval_plants_start_anchor_end(tmp_path): + """C군 구간형(옹벽·흙막이·소단)도 시작·기준·종료 셋을 심는다 — 격자 사이 벽도 횡단에 얹힘(178-1).""" + _write_structures( + tmp_path, + [ + { + "type_id": "soil_guard", + "chainage_m": 45.0, + "start_m": 40.0, + "end_m": 50.0, + "placement": "interval", + }, + { + "type_id": "retaining_wall", + "chainage_m": 125.0, + "start_m": 120.0, + "end_m": 130.0, + "placement": "interval", + }, + { + "type_id": "berm", + "chainage_m": 165.0, + "start_m": 160.0, + "end_m": 170.0, + "placement": "interval", + }, + ], + ) + extras = resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS)) + assert [c for c, label in extras if label == "흙막이"] == [40.0, 45.0, 50.0] + assert [c for c, label in extras if label == "옹벽"] == [120.0, 125.0, 130.0] + assert [c for c, label in extras if label == "소단"] == [160.0, 165.0, 170.0] + + def test_broken_structures_file_keeps_pipe_derived_stations(tmp_path): route_dir = tmp_path / "B05_Profile" / "route" route_dir.mkdir(parents=True, exist_ok=True) From 922a2876eb9d39d5e67f0c037bd5f1707b56bb44 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:56:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(b06):=20=EC=86=8C=EB=8B=A8=20=3D=20?= =?UTF-8?q?=EC=84=B1=ED=86=A0=20=EC=82=AC=EB=A9=B4=20=EB=85=B8=EB=A9=B4=20?= =?UTF-8?q?=EB=81=9D=20=EC=95=84=EB=9E=98=20depth=5Fm=20=EC=97=90=20?= =?UTF-8?q?=EC=88=98=ED=8F=89=20=ED=95=9C=20=EB=8B=A8=20=C2=B7=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=EB=A1=9C=20=EC=84=B1=ED=86=A0=20=EC=9D=B4=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=A0=88=ED=86=A0=20=EC=86=8C=EB=8B=A8=20=EB=81=94=20?= =?UTF-8?q?=C2=B7=20=EB=86=92=EC=9D=B4=20=EC=B9=B8=20=EC=88=A8=EA=B9=80=20?= =?UTF-8?q?=C2=B7=20=EA=B9=8A=EC=9D=B4=20=EC=B9=B8(=EA=B8=B0=EB=B3=B8=201.?= =?UTF-8?q?0=20=C2=B7=200=20=EB=AF=B8=EB=A7=8C=20=3D=200)=20=C2=B7=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=EC=8D=AC=20=C2=B7=20TS=20=EA=B1=B0=EC=9A=B8?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98(178-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B06_Section/B06_Section_Api_Fetch.ts | 5 +- B06_Section/B06_Section_Api_Types.ts | 2 +- B06_Section/B06_Section_Cross_Refresh.ts | 12 +- B06_Section/B06_Section_Engine_Design.py | 1 + .../B06_Section_Engine_Design_Geometry.py | 17 +- B06_Section/B06_Section_Router_Design.py | 3 + B06_Section/B06_Section_Schema.py | 2 +- .../B06_Section_UI_Cross_Structure_Cut.ts | 6 +- B06_Section/B06_Section_UI_Page_Persist.ts | 19 +- common_util/common_util_cross_berm.py | 28 ++- common_util/common_util_cross_berm.ts | 33 ++- common_util/common_util_cross_design.ts | 5 +- .../common_util_cross_design_geometry.ts | 21 +- resources/master_template/구조물정의.json | 13 +- .../tester/test_178_2_berm_fill_mirror.py | 206 ++++++++++++++++++ resources/tester/test_54_26_slope_table09.py | 2 +- 16 files changed, 321 insertions(+), 54 deletions(-) create mode 100644 resources/tester/test_178_2_berm_fill_mirror.py diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 67266c563..111cecb06 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -265,7 +265,10 @@ export async function previewCrossDesigns( /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ rockBoundaryOffsets?: Record; /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ - berms?: Record; + berms?: Record< + string, + { width_m: number; interval_m: number; slope_deg: number; depth_m?: number } + >; /** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */ cutSlopeRatios?: Record; }, diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index e59268017..85f5aacb3 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -528,7 +528,7 @@ export interface CrossDesign { display_half_width_m?: number; /** 이 측점에 놓인 소단 제원(계획서 3-9). 없으면 계단 없음. * 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 계단이 서게 되싣는 값이다. */ - berm?: { width_m: number; interval_m: number; slope_deg: number }; + berm?: { width_m: number; interval_m: number; slope_deg: number; depth_m?: number }; /** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07). * 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */ cut_slope_ratio_user?: number; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index fe8f9b199..68c590c6d 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -29,6 +29,7 @@ import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist"; import type { BermSpec } from "@util/common_util_cross_berm"; import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; import { @@ -189,9 +190,9 @@ function bermPayload( projectId: string, routeId: number, detail: SectionDetailResponse, -): Record { +): Record { const spans = readBermSpans(projectId, routeId); - const out: Record = {}; + const out: Record = {}; if (!spans.length) return out; for (const section of detail.cross_sections) { const spec = bermSpecAt(spans, section.chainage_m); @@ -256,7 +257,12 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const bermAt = (chainageM: number): BermSpec | null => { const spec = bermSpecAt(bermSpans, chainageM); return spec - ? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg } + ? { + widthM: spec.width_m, + intervalM: spec.interval_m, + slopeDeg: spec.slope_deg, + depthM: spec.depth_m, + } : null; }; // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index a993a1ef1..885293480 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -539,6 +539,7 @@ def compute_cross_design( "width_m": round(float(berm.width_m), 4), "interval_m": round(float(berm.interval_m), 4), "slope_deg": round(float(berm.slope_deg), 4), + "depth_m": round(max(float(berm.depth_m), 0.0), 4), } # 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력) for side, extend in ( diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index 9f900def6..db48dd7c9 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -217,11 +217,8 @@ class _SectionGeometry: self.cut_ratio, self.soil_cut_ratio, boundary, - self.berm, - # 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을 - # 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데 - # 토사 경사로 그려 절토가 조용히 커진다(2026-09-07). - multi_knee=self.berm is not None, + # 절토 소단 끔(178-2 · 소단 = 성토 사면 한 단) — 경계는 한 번만 꺾음(종전 규칙). + None, ) self._cut_points_cache[side] = points return points @@ -235,7 +232,11 @@ class _SectionGeometry: 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) + # 소단 깊이 기준 = 그쪽 노면 끝(길어깨 끝) 표고 — 측구가 그쪽이어도 같음(178-2). + edge_z = ( + self.road_z(self.left_extent) if side == "left" else self.road_z(-self.right_extent) + ) + points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm, top_z=edge_z) self._fill_points_cache[side] = points return points @@ -428,8 +429,8 @@ class _SectionGeometry: """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(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: + # 절토 사면 꼭짓점(무릎) — 빠뜨리면 꺾임이 설계선에 안 실린다. + if self.two_stage: for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role if role != "cut": diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index f3c644403..89598f6b9 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -15,6 +15,7 @@ from B06_Section.B06_Section_Engine_Design import ( stored_shoulder_extend, ) from common_util.common_util_cross_berm import ( + BERM_DEFAULT_DEPTH_M, BERM_DEFAULT_INTERVAL_M, BERM_DEFAULT_SLOPE_DEG, BERM_DEFAULT_WIDTH_M, @@ -456,6 +457,7 @@ def stored_berm(stored: dict[str, Any]) -> BermSpec | None: width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0), ) except (TypeError, ValueError): return None @@ -500,6 +502,7 @@ def recompute_designs_for_alignment( width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0), ) except (TypeError, ValueError, AttributeError): continue diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 2a864e6b2..f30f46037 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -329,7 +329,7 @@ class CrossDesignPreviewRequest(BaseModel): # 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는 # 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다. rock_boundary_offsets: dict[str, float] | None = None - # 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg}). 위와 같은 성격으로, + # 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg, depth_m}). 위와 같은 성격으로, # 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9). # 값이 없는 측점은 소단 없음 — 종전 설계 그대로다. berms: dict[str, dict[str, float]] | None = None diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts b/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts index d24ec6819..9fdf7053e 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts @@ -8,7 +8,7 @@ * 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤. * ========================================================================== */ -import { bermSpec, cutProfilePoints } from "../common_util/common_util_cross_berm"; +import { cutProfilePoints } from "../common_util/common_util_cross_berm"; import type { BermSpec } from "../common_util/common_util_cross_berm"; import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; @@ -27,12 +27,12 @@ export function structureCutSpec(design?: Partial | null): Structur const offset = design?.rock_boundary_offset_m; const twoStage = design?.geometry_preset === "rock" && design.two_stage_slope === true && offset != null; - const berm = design?.berm; return { cutRatio, soilRatio: Math.max(design?.soil_cut_slope_ratio ?? cutRatio, 0.1), rockOffsetM: twoStage ? offset : null, - berm: berm ? bermSpec(berm.width_m, berm.interval_m, berm.slope_deg) : null, + // 절토 소단 끔 — 본선 `cutPoints` 와 같음(178-2 · 소단 = 성토 사면 한 단). + berm: null, }; } diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 88398bdcf..7c45b7aac 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -19,6 +19,7 @@ import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; import { + BERM_DEFAULT_DEPTH_M, BERM_DEFAULT_INTERVAL_M, BERM_DEFAULT_SLOPE_DEG, BERM_DEFAULT_WIDTH_M, @@ -59,6 +60,8 @@ export interface BermSessionSpec { width_m: number; interval_m: number; slope_deg: number; + /** 노면 끝 아래 깊이(m · ≥ 0 · 178-2). */ + depth_m: number; } /** @@ -122,13 +125,20 @@ export function bermSpansFromStructures( }; const width = number("width_m", BERM_DEFAULT_WIDTH_M); const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); - if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다 + if (width <= 0) continue; // 폭이 0이면 계단이 없다(간격은 성토 한 단에 안 씀 · 178-2) + // 깊이 = 노면 끝 아래 · 음수는 0(도로보다 높을 수 없음) · 빈 값은 기본 1.0 + const rawDepth = Number(options.depth_m); + const depth = + options.depth_m === undefined || options.depth_m === "" || !Number.isFinite(rawDepth) + ? BERM_DEFAULT_DEPTH_M + : Math.max(rawDepth, 0); spans.push({ start_m: Math.min(start, end), end_m: Math.max(start, end), width_m: width, interval_m: interval, slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), + depth_m: depth, }); } return spans; @@ -142,7 +152,12 @@ export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpe chainageM <= Math.max(span.start_m, span.end_m) + 1e-6, ); return found - ? { width_m: found.width_m, interval_m: found.interval_m, slope_deg: found.slope_deg } + ? { + width_m: found.width_m, + interval_m: found.interval_m, + slope_deg: found.slope_deg, + depth_m: found.depth_m ?? BERM_DEFAULT_DEPTH_M, + } : null; } diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index 4435b560d..29b3282e2 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -1,7 +1,7 @@ """절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9). 짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며 -`tmp/tests/test_cross_berm_mirror.py` 가 그것을 지킨다. +`resources/tester/test_178_2_berm_fill_mirror.py` 가 그것을 지킨다. **왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. 지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을 @@ -18,6 +18,8 @@ · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정 — 처음 2° 로 잡았다가 바꿨다). 기울이는 것 자체는 실무이나 **법령·교본 근거가 없어** 기본으로 넣지 않고 폼에서 받는다. 그래서 지식DB 에도 적지 않는다(사용자 지시). +· 성토 소단(178-2) = 노면 끝 아래 깊이 `depth_m`(기본 1.0 · 사용자 값)에 **한 단만** · + 0 미만은 0(도로보다 높을 수 없음). 간격은 성토에 안 씀 · 절토는 소단을 안 넣음. """ import logging @@ -30,6 +32,7 @@ logger = logging.getLogger(__name__) BERM_DEFAULT_WIDTH_M = 0.5 BERM_DEFAULT_INTERVAL_M = 3.0 BERM_DEFAULT_SLOPE_DEG = 0.0 +BERM_DEFAULT_DEPTH_M = 1.0 # 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. _STEP_M = 0.05 @@ -41,11 +44,12 @@ _MAX_STEPS = int(_MAX_REACH_M / _STEP_M) * 10 class BermSpec(NamedTuple): - """소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도).""" + """소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도) · 깊이(노면 끝 아래 m · 성토).""" width_m: float = BERM_DEFAULT_WIDTH_M interval_m: float = BERM_DEFAULT_INTERVAL_M slope_deg: float = BERM_DEFAULT_SLOPE_DEG + depth_m: float = BERM_DEFAULT_DEPTH_M def cut_profile_points( @@ -166,16 +170,16 @@ def fill_profile_points( fill_ratio: float, berm: BermSpec | None, max_reach_m: float = _MAX_REACH_M, + top_z: float | None = None, ) -> list[tuple[float, float]]: """성토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로 **내려간다**. - 절토와 달리 경사가 하나뿐이라 무릎이 없다(암 경계는 절토만 본다). 소단은 같은 규칙 — - 사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣고, 그 평탄부는 - 안쪽이 낮도록 `slope_deg` 만큼 기울어 **바깥으로 갈수록 조금 올라간다**(물이 노면 쪽으로). + 소단은 **한 단**(178-2) — 표고가 `노면 끝(top_z) − depth_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 @@ -183,24 +187,24 @@ def fill_profile_points( else 0.0 ) ratio = max(fill_ratio, 1e-6) + top = start_z if top_z is None else top_z + berm_z = top - max(berm.depth_m, 0.0) if berm is not None and berm.width_m > 0 else None + placed = berm_z is None 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) + if not placed and berm_z is not None and elevation - drop <= berm_z: + run = max(elevation - berm_z, 0.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 + placed = True continue dist += _STEP_M elevation -= drop - slant_since_berm += slant points.append((dist, elevation)) return _dedupe(points) diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index a48422906..8f1f69dc3 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -3,7 +3,7 @@ * 절토 사면의 **계단(소단) 포함 꼭짓점** — 파이썬 짝 (계획서 3-9) * * ⚠⚠ 짝: `common_util/common_util_cross_berm.py` — 한쪽만 고치면 화면과 저장본이 갈린다. - * 거울 시험: `tmp/tests/test_cross_berm_mirror.py` + * 거울 시험: `resources/tester/test_178_2_berm_fill_mirror.py` * * 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. * 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다. @@ -15,12 +15,15 @@ * 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. * · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정). 기울이는 것은 실무이나 * 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받는다 — 지식DB 에도 적지 않는다. + * · 성토 소단(178-2) = 노면 끝 아래 깊이 `depthM`(기본 1.0 · 사용자 값)에 **한 단만** · + * 0 미만은 0(도로보다 높을 수 없음). 간격은 성토에 안 씀 · 절토는 소단을 안 넣음. * ========================================================================== */ /** 소단 기본값 — 근거는 위 설명. */ export const BERM_DEFAULT_WIDTH_M = 0.5; export const BERM_DEFAULT_INTERVAL_M = 3.0; export const BERM_DEFAULT_SLOPE_DEG = 0.0; +export const BERM_DEFAULT_DEPTH_M = 1.0; /** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ const STEP_M = 0.05; @@ -30,19 +33,21 @@ const MAX_REACH_M = 200.0; * 짝: 파이썬 `_MAX_STEPS`. */ const MAX_STEPS = (MAX_REACH_M / STEP_M) * 10; -/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */ +/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도) · 깊이(노면 끝 아래 m · 성토). */ export interface BermSpec { widthM: number; intervalM: number; slopeDeg: number; + depthM?: number; } export function bermSpec( widthM = BERM_DEFAULT_WIDTH_M, intervalM = BERM_DEFAULT_INTERVAL_M, slopeDeg = BERM_DEFAULT_SLOPE_DEG, + depthM = BERM_DEFAULT_DEPTH_M, ): BermSpec { - return { widthM, intervalM, slopeDeg }; + return { widthM, intervalM, slopeDeg, depthM }; } /** @@ -148,7 +153,9 @@ export function cutProfilePoints( /** * 짝: `fill_profile_points`. 성토 사면 꼭짓점 — 시작에서 바깥으로 **내려간다**. * - * 절토와 달리 경사가 하나뿐이라 무릎이 없다. 소단 규칙은 같다. + * 소단은 **한 단** — 표고가 `노면 끝(topZ) − 깊이` 에 닿으면 폭만큼 수평(안쪽 기울기) + * 놓고 그 아래로 같은 성토 경사로 이어 내린다(178-2). 그 깊이 전에 원지반을 만나면 + * 지반 교점이 먼저라 소단이 안 선다. */ export function fillProfilePoints( startDist: number, @@ -156,34 +163,36 @@ export function fillProfilePoints( fillRatio: number, berm: BermSpec | null, maxReachM: number = MAX_REACH_M, + topZ: number = startZ, ): 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); + const bermZ = + berm !== null && berm.widthM > 0 + ? topZ - Math.max(berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0) + : null; + let placed = bermZ === null; 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); + if (!placed && bermZ !== null && elevation - drop <= bermZ) { + const run = Math.max(elevation - bermZ, 0) * ratio; dist += run; elevation -= run / ratio; points.push([dist, elevation]); // 소단 안쪽 모서리 - dist += berm.widthM; + dist += (berm as BermSpec).widthM; elevation += bermRise; points.push([dist, elevation]); // 소단 바깥 모서리 - slantSinceBerm = 0; + placed = true; continue; } dist += STEP_M; elevation -= drop; - slantSinceBerm += slant; } points.push([dist, elevation]); diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index d7a3a8910..b568edabb 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -23,7 +23,7 @@ * 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다. * ========================================================================== */ -import type { BermSpec } from "./common_util_cross_berm"; +import { BERM_DEFAULT_DEPTH_M, type BermSpec } from "./common_util_cross_berm"; import type { SpoilFillSection } from "./common_util_spoil_fill"; import { spoilFillSection } from "./common_util_spoil_fill"; import { @@ -182,7 +182,7 @@ export interface CrossDesignResult { * 안 들어간다. 소단 기하가 이 셈 위에 서 있어 남겨 둔다. 되살릴 때는 **계산해서** 쓸 것. */ cut_slope_segments: CutSlopeSegment[]; /** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */ - berm?: { width_m: number; interval_m: number; slope_deg: number }; + berm?: { width_m: number; interval_m: number; slope_deg: number; depth_m?: number }; surface_drop_m?: number; pavement_thickness_m?: number; rock_boundary_offset_m?: number; @@ -586,6 +586,7 @@ export function computeCrossDesign( width_m: round4(options.berm.widthM), interval_m: round4(options.berm.intervalM), slope_deg: round4(options.berm.slopeDeg), + depth_m: round4(Math.max(options.berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0)), }; } // 노견 확장(139-6) — 넓힌 쪽만 되싣는다(짝: 파이썬) diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index d3dbdf042..ec3d0f978 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -283,10 +283,8 @@ export class SectionGeometry { this.cutRatio, this.soilCutRatio, boundary, - this.berm, - undefined, - // 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07). - this.berm !== null, + // 절토 소단 끔(178-2 · 소단 = 성토 사면 한 단) — 경계는 한 번만 꺾음(종전 규칙). + null, ); this.cutPointsCache.set(side, points); return points; @@ -302,7 +300,16 @@ export class SectionGeometry { 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); + // 소단 깊이 기준 = 그쪽 노면 끝(길어깨 끝) 표고 — 측구가 그쪽이어도 같음(178-2). + const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent); + const points = fillProfilePoints( + startDist, + startZ, + this.fillRatio, + this.berm, + undefined, + edgeZ, + ); this.fillPointsCache.set(side, points); return points; } @@ -467,8 +474,8 @@ export class SectionGeometry { breakpoints(): number[] { const points = [0, this.leftExtent, -this.rightExtent]; for (const [offset] of this.ditchPoints) points.push(offset); - // 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. - if (this.twoStage || this.berm !== null) { + // 절토 사면 꼭짓점(무릎) — 빠뜨리면 꺾임이 설계선에 안 실린다. + if (this.twoStage) { for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; if (role !== "cut") continue; diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 6cc24ba67..999f30a21 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -2013,6 +2013,16 @@ "required": false, "phase": "b05" }, + { + "key": "depth_m", + "label": "소단 위치(도로 아래)", + "input": "number", + "unit": "m", + "default": 1.0, + "required": false, + "phase": "b05", + "default_basis": "사용자 지정 — 성토 사면 노면 끝 아래 1 m · 0 미만 = 0(도로보다 높을 수 없음)" + }, { "key": "height_m", "label": "소단 높이", @@ -3561,6 +3571,7 @@ "weep_hole_area_m2": false }, "berm": { + "height_m": false, "length_m": false, "before_m": false, "after_m": false, @@ -3795,7 +3806,7 @@ "이름": "상세 내용", "옵션": [ "width_m", - "height_m" + "depth_m" ], "형상": {} } diff --git a/resources/tester/test_178_2_berm_fill_mirror.py b/resources/tester/test_178_2_berm_fill_mirror.py new file mode 100644 index 000000000..90cc7231d --- /dev/null +++ b/resources/tester/test_178_2_berm_fill_mirror.py @@ -0,0 +1,206 @@ +"""178-2 소단 = 성토 사면 노면 끝 아래 depth_m 에 수평 한 단 · 아래로 성토 이음 — 파이썬 · TS 거울. + +짝: `common_util_cross_berm.py` ↔ `.ts` · `B06_Section_Engine_Design_Geometry.py` ↔ +`common_util_cross_design_geometry.ts`. 절토 소단은 끔 · 깊이 0 미만 = 0(도로보다 높을 수 없음). +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Engine_Design import compute_cross_design +from common_util.common_util_cross_berm import BermSpec, fill_profile_points +from config.config_system import STANDARD_CROSS_SECTION + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + + +def _ground(slope: float) -> list[dict]: + return [ + {"offset_m": o / 2, "elevation_m": round(100.0 + slope * o / 2, 4), "valid": True} + for o in range(-40, 41) + ] + + +def _case(name, slope, z, mode, berm): + return {"name": name, "samples": _ground(slope), "z": z, "mode": mode, "berm": berm} + + +# 좌절토 = 우측 성토(지반이 오른쪽으로 내려감) +CASES = [ + _case("깊이 1.0 · 폭 0.5", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 1.0)), + _case("깊이 0 = 노면 끝 높이", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 0.0)), + _case("깊이 음수 = 0", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, -2.0)), + _case("깊이 > 사면 높이 = 소단 없음", 0.15, 100.0, "left_cut", (0.5, 3.0, 0.0, 5.0)), + _case("안쪽 기울기 5°", 0.6, 100.0, "left_cut", (1.0, 3.0, 5.0, 1.5)), + _case("소단 없음", 0.6, 100.0, "left_cut", None), +] + + +def _python(case): + berm = BermSpec(*case["berm"]) if case["berm"] else None + return compute_cross_design( + case["samples"], + case["z"], + standard=STANDARD_CROSS_SECTION, + ground_type="soil", + section_mode=case["mode"], + ditch_side="left", + berm=berm, + ) + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { computeCrossDesign } from "./common_util_cross_design.js"; +import { fillProfilePoints } from "./common_util_cross_berm.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const designs = input.cases.map((c) => computeCrossDesign(c.samples, c.z, { + standard: input.standard, groundType: "soil", sectionMode: c.mode, ditchSide: "left", + berm: c.berm ? { widthM: c.berm[0], intervalM: c.berm[1], slopeDeg: c.berm[2], depthM: c.berm[3] } : null, +})); +const profile = fillProfilePoints(4, 99.9, 1.5, { widthM: 0.5, intervalM: 3, slopeDeg: 0, depthM: 1 }, 20, 100); +writeFileSync(process.argv[3], JSON.stringify({ designs, profile })); +""" + + +@pytest.fixture(scope="module") +def ts_results(tmp_path_factory): + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + tmp = tmp_path_factory.mktemp("berm") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(ROOT / "common_util" / "common_util_cross_design.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.glob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + src, dst = tmp / "in.json", tmp / "out.json" + src.write_text( + json.dumps({"cases": CASES, "standard": STANDARD_CROSS_SECTION}, ensure_ascii=False), + encoding="utf-8", + ) + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True + ) + return json.loads(dst.read_text(encoding="utf-8")) + + +def _flats(design: dict) -> list[tuple[float, float, float]]: + """성토(오른쪽 · 음수 오프셋) 노면 끝 밖의 평탄 · 완만 도막 [(안 오프셋, 바깥 오프셋, 표고)].""" + edge = design["road_edges"]["right"]["offset_m"] + line = sorted(design["design_line"], key=lambda p: -p["offset_m"]) # 노면 → 바깥 + found: list[tuple[float, float, float]] = [] + for a, b in zip(line, line[1:], strict=False): + run = a["offset_m"] - b["offset_m"] + if a["offset_m"] > edge + 1e-6 or run <= 1e-6: + continue + if abs(b["elevation_m"] - a["elevation_m"]) / run < 0.1: # 소단(≤ 5°) · 지반 0.15 는 빠짐 + if found and abs(found[-1][1] - a["offset_m"]) < 1e-6: # 이어진 도막은 하나로 + found[-1] = (found[-1][0], b["offset_m"], found[-1][2]) + else: + found.append((a["offset_m"], b["offset_m"], a["elevation_m"])) + return found + + +def _close(left, right, path=""): + if isinstance(left, dict): + assert set(left) == set(right), f"{path} 키 다름" + for key in left: + _close(left[key], right[key], f"{path}.{key}") + elif isinstance(left, list): + assert len(left) == len(right), f"{path} 길이 {len(left)} vs {len(right)}" + for i, (a, b) in enumerate(zip(left, right, strict=True)): + _close(a, b, f"{path}[{i}]") + elif isinstance(left, (int, float)) and not isinstance(left, bool): + assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}" + else: + assert left == right, f"{path}: {left!r} vs {right!r}" + + +def test_파이썬_TS_설계선_같음(ts_results): + for case, ts in zip(CASES, ts_results["designs"], strict=True): + _close(_python(case), ts, case["name"]) + + +def test_사면_꼭짓점_거울(): + py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100) + assert py[1] == pytest.approx((4 + 0.9 * 1.5, 99.0)) # 노면 끝 100 − 1.0 + assert py[2] == pytest.approx((4 + 0.9 * 1.5 + 0.5, 99.0)) # 폭 0.5 수평 + assert len(py) == 4 # 한 단만 · 그 아래 같은 경사로 끝까지 + + +def test_사면_꼭짓점_TS_같음(ts_results): + py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100) + _close([list(p) for p in py], ts_results["profile"], "profile") + + +def test_한_단_깊이_폭(): + by = {case["name"]: _python(case) for case in CASES} + edge_z = by["깊이 1.0 · 폭 0.5"]["road_edges"]["right"]["elevation_m"] + flats = _flats(by["깊이 1.0 · 폭 0.5"]) + assert len(flats) == 1 + inner, outer, z = flats[0] + assert z == pytest.approx(edge_z - 1.0, abs=1e-3) + assert inner - outer == pytest.approx(0.5, abs=1e-3) + assert by["깊이 1.0 · 폭 0.5"]["berm"]["depth_m"] == 1.0 + + zero = _flats(by["깊이 0 = 노면 끝 높이"]) + assert len(zero) == 1 and zero[0][2] == pytest.approx(edge_z, abs=1e-3) + assert by["깊이 음수 = 0"]["design_line"] == by["깊이 0 = 노면 끝 높이"]["design_line"] + assert by["깊이 음수 = 0"]["berm"]["depth_m"] == 0.0 + + assert _flats(by["깊이 > 사면 높이 = 소단 없음"]) == [] + assert _flats(by["소단 없음"]) == [] + # 소단이 선 만큼 성토 사면이 바깥으로 밀려 성토 면적이 늘어남 + assert by["깊이 1.0 · 폭 0.5"]["fill_area_m2"] > by["소단 없음"]["fill_area_m2"] + + +def test_절토_소단_끔(): + """좌측 절토 사면은 소단을 놓아도 소단 없는 단면과 같음.""" + with_berm = _python(CASES[0]) + plain = _python(CASES[-1]) + left = [p for p in with_berm["design_line"] if p["offset_m"] > 0] + assert left == [p for p in plain["design_line"] if p["offset_m"] > 0] + assert with_berm["cut_area_m2"] == plain["cut_area_m2"] + + +def test_저장분_깊이_읽기(): + """[저장] 재계산이 읽는 저장분 — 없으면 기본 1.0 · 음수는 0.""" + from B06_Section.B06_Section_Router_Design import stored_berm + + assert stored_berm({"berm": {"width_m": 0.5}}).depth_m == 1.0 + assert stored_berm({"berm": {"width_m": 0.5, "depth_m": -1}}).depth_m == 0.0 + assert stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}}).depth_m == 2.5 + + +def test_좌측_패널_칸_구간_깊이_나름(): + """구조물 옵션 depth_m → 세션 구간 → 측점 제원 · 서버 payload(같은 꼴).""" + source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + assert "depthM: spec.depth_m" in refresh diff --git a/resources/tester/test_54_26_slope_table09.py b/resources/tester/test_54_26_slope_table09.py index 514fd4b34..d39729a73 100644 --- a/resources/tester/test_54_26_slope_table09.py +++ b/resources/tester/test_54_26_slope_table09.py @@ -28,7 +28,7 @@ TABLE = { "옹벽", ["wall_form", "length_m", "height_m", "before_m", "after_m"], ), - "berm": ("gbermdetail", "상세 내용", ["width_m", "height_m"]), + "berm": ("gbermdetail", "상세 내용", ["width_m", "depth_m"]), # 높이 칸 숨김 · 깊이(178-2) } SOIL_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]