From 24aba4008d9561fef90d4a2a590ac2f52ae3691c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:50:02 +0900 Subject: [PATCH] =?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)