From 24aba4008d9561fef90d4a2a590ac2f52ae3691c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:50:02 +0900 Subject: [PATCH 1/5] =?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 b830f8c45687d038695f0b60d2da54a77e240b06 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:50:10 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(b06):=20[=EC=83=81=EC=84=B8]=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=8C=AC=20=C2=B7=20=EC=A4=8C=20=3D=20?= =?UTF-8?q?=EB=8F=84=ED=98=95=20=EC=B8=B5=EB=A7=8C=20=C2=B7=20=EC=B6=95=20?= =?UTF-8?q?=EC=A0=9C=EC=9E=90=EB=A6=AC=20=EB=8B=A4=EC=8B=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=20=ED=91=9C=20=EA=B8=80=EC=9E=90=2014px=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EB=A6=84=20=EC=B9=B8=20=EB=A8=B8=EB=A6=AC?= =?UTF-8?q?=EC=83=89=20(179-3)?= 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_01JywHxQuWPk6UYXTebyrBoU --- B06_Section/B06_Section_UI_Cross_Detail.ts | 66 +++++++++---- B06_Section/B06_Section_UI_Section_View.ts | 19 +++- .../B06_Section_UI_Section_View_Types.ts | 10 +- .../B06_Section_UI_Style_Cross_Detail.css | 17 +++- resources/tester/helper_164_detail_zoom.cjs | 99 ++++++++++++++++--- resources/tester/test_164_detail_zoom.py | 52 +++++++++- resources/tester/test_173_areas_detail.py | 9 +- 7 files changed, 229 insertions(+), 43 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 6a233b179..3d7e62700 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -9,7 +9,7 @@ * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). * 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) — - * 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임. + * 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3). * 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤. * ========================================================================== */ @@ -30,14 +30,19 @@ import { type ZoomPanState, } from "../A00_Common/b_svg_zoom_pan"; import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; +import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ export interface CrossDetailHost { projectId: string; routeId: () => number | null; detail: () => SectionDetailResponse | null; - /** 칸 크기(W × H px)로 새로 그린 카드 svg(173-4 · 뷰 `detailChart`). */ - chart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null; + /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */ + chart: ( + stationId: string, + widthPx: number, + heightPx: number, + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; } let host: CrossDetailHost | null = null; @@ -121,17 +126,32 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT /* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */ -/** 복제 자식을 g 하나로 감싸 공용 줌 · 팬을 검(축 · 눈금째 한 장) · 휠 = 커서 고정 줌. */ +/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 · + * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */ export function attachDetailZoom( clone: SVGSVGElement, + axes: CrossAxesOptions | null, initial: ZoomPanState | undefined, onChange: (state: ZoomPanState) => void, -): ZoomPanHandle { - const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number); - const layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); - layer.append(...clone.childNodes); +): ZoomPanHandle | null { + const layer = clone.querySelector("g.b06-chart__plot-layer"); + if (!layer) return null; // 칸 = viewBox(칸 크기로 그림 · 173-4) → 커서 고정 · 팬 속도 정확 - clone.append(layer); + const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number); + const axisLayer = clone.querySelector("g.b06-chart__axes"); + const renderAxes = axes && axisLayer ? createCrossAxes(axisLayer, axes) : null; + const marker = layer.querySelector("g.b06-chart__center"); + const markerLine = marker?.querySelector("line"); + const cx = Number(markerLine?.getAttribute("x1")); + const cy = Number(markerLine?.getAttribute("y1")) + 18; + const follow = (state: ZoomPanState): void => { + renderAxes?.(state); + marker?.setAttribute( + "transform", + `translate(${cx} ${cy}) scale(${1 / Math.max(state.scale, 1e-6)}) translate(${-cx} ${-cy})`, + ); + }; + follow(initial ?? IDENTITY_VIEW); return attachSvgZoomPan({ svg: clone, layer, @@ -143,7 +163,10 @@ export function attachDetailZoom( content: { x: -width, y: -height, width: 3 * width, height: 3 * height }, wheelZoom: true, initial, - onChange, + onChange: (state) => { + follow(state); + onChange(state); + }, }); } @@ -164,12 +187,16 @@ let tableTimer = 0; let drawing = false; /** 칸 크기로 새로 그림 → 복제(듣개 없음) · clip id 를 카드와 가름(같은 id 면 카드 clip 을 씀). */ -function drawChart(section: CrossSection, widthPx: number, heightPx: number): SVGSVGElement | null { +function drawChart( + section: CrossSection, + widthPx: number, + heightPx: number, +): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null { drawing = true; try { - const svg = host?.chart(section.station_id, widthPx, heightPx); - if (!svg) return null; - const clone = svg.cloneNode(true) as SVGSVGElement; + const drawn = host?.chart(section.station_id, widthPx, heightPx); + if (!drawn) return null; + const clone = drawn.svg.cloneNode(true) as SVGSVGElement; clone.querySelectorAll("clipPath[id]").forEach((clip) => { const id = clip.id; clip.id = `b06-detail-${id}`; @@ -177,7 +204,7 @@ function drawChart(section: CrossSection, widthPx: number, heightPx: number): SV .querySelectorAll(`[clip-path="url(#${id})"]`) .forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`)); }); - return clone; + return { clone, axes: drawn.axes }; } finally { drawing = false; } @@ -246,11 +273,12 @@ export function openCrossDetail(section: CrossSection): void { const size = `${width}x${height}`; if (size !== drawnSize) zoom = undefined; drawnSize = size; - const clone = drawChart(state.section, width, height); - if (!clone) cad.replaceChildren(document.createTextNode("그림 없음")); + const drawn = drawChart(state.section, width, height); + if (!drawn) cad.replaceChildren(document.createTextNode("그림 없음")); else { - const handle = attachDetailZoom(clone, zoom, (next) => (zoom = next)); - cad.replaceChildren(clone, buildZoomControls(handle)); + const { clone, axes } = drawn; + const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next)); + cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : [])); } showPicked(); }, diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index b4f916667..3f4141683 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -37,6 +37,7 @@ import { import { type CrossPlotBase, crossPlotBase, + crossPlotFromBase, effectiveCardHalfWidth, fitPlotBase, naturalHeightAtWidth, @@ -561,9 +562,23 @@ export function createSectionView( // 카드 줌 · 팬은 카드 몫 — 모달 그림은 1배에서 시작(카드 상태는 되돌려 둠) const cardZoom = cardZoomStates.get(stationId); cardZoomStates.delete(stationId); - const card = buildCrossCard(section, heightPx, base && fitPlotBase(base, heightPx), widthPx); + const fitted = base && fitPlotBase(base, heightPx); + const card = buildCrossCard(section, heightPx, fitted, widthPx); if (cardZoom) cardZoomStates.set(stationId, cardZoom); - return card.querySelector("svg.b06-section__chart"); + const svg = card.querySelector("svg.b06-section__chart"); + if (!svg) return null; + // 축 다시 그리기 옵션(179-3) — 카드 `createCrossAxes` 와 같은 기하 + const plot = fitted && crossPlotFromBase(fitted, heightPx); + const axes = plot && { + widthPx, + heightPx, + pixelsPerMeter: plot.pixelsPerMeter, + maxOffset: plot.maxOffset, + displayMax: plot.displayMax, + elevationMid: plot.elevationMid, + exaggeration: Math.max(currentExaggeration, 0.1), + }; + return { svg, axes }; }, focusStation(stationId) { if (stationId === null) clearSelection(); diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 1db878a43..a0fe93793 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -11,6 +11,7 @@ import type { } from "./B06_Section_Api_Fetch"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; /** * 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**. @@ -37,8 +38,13 @@ export interface SectionViewController { refreshCard: (chainageM: number) => void; /** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */ refreshCards: (chainages: ReadonlyArray) => void; - /** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null). */ - detailChart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null; + /** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null) + * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */ + detailChart: ( + stationId: string, + widthPx: number, + heightPx: number, + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index a50607922..d5218d7e2 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -54,9 +54,11 @@ } .b06-detail__qtable { + /* 이름 칸 바탕 = 데이터 표 머리색(179-3 · `ui_template_data_table` --dt-head) */ + --b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface)); width: 100%; border-collapse: collapse; - font-size: var(--text-caption); + font-size: var(--text-body-sm); font-variant-numeric: tabular-nums; } @@ -70,7 +72,8 @@ .b06-detail__qtable th { font-weight: 500; - color: var(--color-text-secondary); + background: var(--b06-detail-head); + color: var(--color-text); } .b06-detail__qtable tr:first-child > th { @@ -100,10 +103,18 @@ /* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */ .b06-detail__qtable [data-key][data-empty] { cursor: default; - background: none; color: var(--color-text-muted); } +/* 빈 칸 = 값 칸만 바탕 지움 · 이름 칸은 머리색 그대로(글만 회색) */ +.b06-detail__qtable td[data-key][data-empty] { + background: none; +} + +.b06-detail__qtable th[data-key][data-empty] { + background: var(--b06-detail-head); +} + /* 그림 강조 한 벌 = 붉은 계통(164-2 · 카드 색은 그대로) — 띠 · 판정 선 · 중심 십자 · 사토장 */ .b06-detail__cad .b06-chart__area.is-active > polygon { fill: color-mix(in srgb, var(--color-danger) 35%, transparent); diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs index ada95dc88..75bdba7bb 100644 --- a/resources/tester/helper_164_detail_zoom.cjs +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -11,11 +11,28 @@ const B06 = path.join(ROOT, "B06_Section"); const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); // 카드 여백은 원본에서 읽음(화면과 같은 값) const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +// 축 눈금 간격도 원본 함수 그대로(179-3 · 축 다시 그리기) +const niceSrc = /export function niceTickStep[\s\S]*?\n\}/.exec(common.replace(/\r/g, ""))[0]; +const niceTickStep = ts + .transpileModule(niceSrc.replace("export ", ""), { compilerOptions: {} }) + .outputText.concat("\nreturn niceTickStep;"); +const svgElement = (tag, attrs = {}) => { + const node = el(tag); + for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value); + return node; +}; +const svgText = (text, attrs) => Object.assign(svgElement("text", attrs), { textContent: text }); const STUBS = { "@ui/ui_template_modal": { openModal: () => {} }, "../A00_Common/b_page_state": {}, "./B06_Section_Api_Fetch": {}, - "./B06_Section_UI_Section_Common": { CROSS_PAD, L: (key) => key }, + "./B06_Section_UI_Section_Common": { + CROSS_PAD, + L: (key) => key, + niceTickStep: Function(niceTickStep)(), + svgElement, + svgText, + }, "./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} }, }; const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); @@ -52,6 +69,24 @@ function el(tag) { node.parent = self; self.childNodes.push(node); }), + replaceChildren: (...nodes) => { + self.childNodes.splice(0).forEach((node) => (node.parent = null)); + self.append(...nodes); + }, + // 「tag.class」 · 「tag」 만 — 깊이 우선 첫 것 + querySelector: (selector) => { + const [tagName, cls] = selector.split("."); + const walk = (node) => { + for (const child of node.childNodes) { + const classes = (child.attrs.class ?? "").split(" "); + if (child.tag === tagName && (!cls || classes.includes(cls))) return child; + const found = walk(child); + if (found) return found; + } + return null; + }; + return walk(self); + }, addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), fire: (type, event) => { const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }; @@ -69,16 +104,42 @@ globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: e const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts")); -const svg = el("svg"); -svg.setAttribute("viewBox", "0 0 600 300"); -svg.append(el("rect"), el("g"), el("g")); +// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150) +const AXES = { + widthPx: 600, + heightPx: 300, + pixelsPerMeter: 10, + maxOffset: 24, + displayMax: 120, + elevationMid: 110, + exaggeration: 1, +}; +function chart() { + const svg = el("svg"); + svg.setAttribute("viewBox", "0 0 600 300"); + const axes = svgElement("g", { class: "b06-chart__axes" }); + const clipGroup = svgElement("g", { "clip-path": "url(#c)" }); + const plot = svgElement("g", { class: "b06-chart__plot-layer" }); + const center = svgElement("g", { class: "b06-chart__center" }); + center.append(svgElement("line", { x1: 300, y1: 132, x2: 300, y2: 168 })); + plot.append(center); + clipGroup.append(plot); + svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" })); + return { svg, axes, plot, center }; +} +const ticks = (axes) => + axes.childNodes.filter((node) => node.tag === "text").map((node) => node.textContent); +const { svg, axes, plot: layer, center } = chart(); let saved; -const handle = attachDetailZoom(svg, undefined, (next) => (saved = next)); -const layer = svg.childNodes[0]; +const handle = attachDetailZoom(svg, AXES, undefined, (next) => (saved = next)); +const ticksBefore = ticks(axes); const out = { - wrapped: svg.childNodes.length === 1 && layer.tag === "g" && layer.childNodes.length === 3, + // 179-3 — 감싸지 않음 · 자식 넷 그대로 + wrapped: svg.childNodes.length === 1 && svg.childNodes[0].tag === "g", + children: svg.childNodes.length, ratio: svg.attrs["style:--ratio"], before: layer.getAttribute("transform"), + ticksBefore: ticksBefore.length, }; // 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정 svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); @@ -97,19 +158,35 @@ svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 }); svg.fire("pointermove", { clientX: 300, clientY: 140 }); svg.fire("pointerup", {}); out.middlePan = { ...saved }; +// 팬 뒤 — 도형 층만 transform · 축 층 · 바깥 그룹 transform 없음 · 눈금 글 바뀜 +out.plotTransform = layer.getAttribute("transform"); +out.axesTransform = axes.getAttribute("transform"); +out.clipTransform = svg.childNodes[2].getAttribute("transform"); +out.ticksChanged = JSON.stringify(ticks(axes)) !== JSON.stringify(ticksBefore); +out.ticksAfter = ticks(axes).length; out.panClickStopped = svg.fire("click", { stopPropagation() { this.stopped = true; }, }).stopped; // 다시 떠도 배율 이어 감 -const again = el("svg"); -again.setAttribute("viewBox", "0 0 600 300"); -attachDetailZoom(again, saved, () => {}); -out.restored = again.childNodes[0].getAttribute("transform"); +const again = chart(); +attachDetailZoom(again.svg, AXES, saved, () => {}); +out.restored = again.plot.getAttribute("transform"); +// 다시 떠도 축은 이어 간 범위로(1배 원래 눈금과 다름) +out.restoredTicksShifted = JSON.stringify(ticks(again.axes)) !== JSON.stringify(ticksBefore); +// 축 옵션 없음 · 도형 층 없음 — 줌은 걸고 축은 그대로 / 줌 없음 +const bare = chart(); +out.noAxesHandle = attachDetailZoom(bare.svg, null, undefined, () => {}) !== null; +const flat = el("svg"); +flat.setAttribute("viewBox", "0 0 600 300"); +out.noLayer = attachDetailZoom(flat, AXES, undefined, () => {}); // 더블클릭 원복 svg.fire("dblclick", {}); out.reset = layer.getAttribute("transform"); +// 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로) +svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); +out.centerTransform = center.getAttribute("transform"); out.max = (() => { for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); return saved.scale; diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index 04b48be77..c8b59ecfc 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -40,10 +40,24 @@ def out() -> dict: return json.loads(proc.stdout) -def test_복제_자식을_g_하나로_감쌈_비율(out): - assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임 +def test_감싸지_않음_비율(out): + # 179-3 — 복제를 g 하나로 감싸지 않음(축 · 눈금 · 도형 한 장 → 도형 층만) + assert out["wrapped"] is False and out["children"] == 4 assert out.get("ratio") is None # 173-4 — 칸 = viewBox(칸 크기로 새로 그림) · 비율 변수 없음 assert out["before"] is None + assert out["ticksBefore"] > 0 + + +def test_축_고정_도형_층만_움직임_눈금_다시(out): + # 179-3 — 팬 뒤 도형 층만 transform · 축 층 · clip 그룹 제자리 · 눈금 글은 보이는 범위로 + assert out["plotTransform"] == "translate(100 20) scale(1)" + assert out["axesTransform"] is None and out["clipTransform"] is None + assert out["ticksChanged"] is True and out["ticksAfter"] > 0 + assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로 + # 확대 — 십자는 중심(300,150)에서 배율 되돌림 + assert out["centerTransform"].startswith("translate(300 150) scale(0.869") + assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만 + assert out["noLayer"] is None # 도형 층 없음 = 줌 없음 def test_휠_당기면_확대_커서_고정(out): @@ -69,8 +83,14 @@ def test_모달_배선_단추_칸_비율(): src = _read("B06_Section_UI_Cross_Detail.ts") css = _read("B06_Section_UI_Style_Cross_Detail.css") assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src - assert "cad.replaceChildren(clone, buildZoomControls(handle))" in src - assert "attachDetailZoom(clone, zoom, (next) => (zoom = next))" in src + assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src + assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src + assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3) + assert 'clone.querySelector("g.b06-chart__plot-layer")' in src + assert "createCrossAxes(axisLayer, axes)" in src + view = _read("B06_Section_UI_Section_View.ts") + assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view + assert "return { svg, axes };" in view assert "position: relative;" in css # 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득 assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css @@ -102,3 +122,27 @@ def test_빈칸_누름_없음_이름_칸_첫_키(): assert 'if (!key || el.hasAttribute("data-empty")) return;' in src assert 'cell("th", groupL, { row: 2 }, keyL)' in src assert 'cell("th", groupM, { row: 2 }, keyM)' in src + + +def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만(): + # 179-3 — 표 글자 14px · 이름 칸(th) 바탕 = 데이터 표 머리색 · 빈 칸 바탕 지움은 값 칸(td)만 + css = _read("B06_Section_UI_Style_Cross_Detail.css") + table = css.split(".b06-detail__qtable {", 1)[1].split("}", 1)[0] + assert "font-size: var(--text-body-sm);" in table + assert ( + "--b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));" + in table + ) + head = css.split(".b06-detail__qtable th {", 1)[1].split("}", 1)[0] + assert "background: var(--b06-detail-head);" in head and "color: var(--color-text);" in head + empty = css.split(".b06-detail__qtable [data-key][data-empty] {", 1)[1].split("}", 1)[0] + assert "background" not in empty + assert ".b06-detail__qtable td[data-key][data-empty] {\n background: none;" in css + assert ( + ".b06-detail__qtable th[data-key][data-empty] {\n background: var(--b06-detail-head);" + in css + ) + theme = open( + os.path.join(ROOT, "ui_template", "ui_template_theme.css"), encoding="utf-8" + ).read() + assert "--text-body-sm: 14px;" in theme diff --git a/resources/tester/test_173_areas_detail.py b/resources/tester/test_173_areas_detail.py index 215419903..0ac4e246e 100644 --- a/resources/tester/test_173_areas_detail.py +++ b/resources/tester/test_173_areas_detail.py @@ -87,7 +87,7 @@ def test_상세_테마_토큰_어두운_고정색_없음(): def test_상세_칸_크기로_새로_그림_배선(): src = _read("B06_Section_UI_Cross_Detail.ts") assert "const { clientWidth: width, clientHeight: height } = cad;" in src - assert "const clone = drawChart(state.section, width, height);" in src + assert "const drawn = drawChart(state.section, width, height);" in src assert "host?.chart(section.station_id, widthPx, heightPx)" in src assert "new ResizeObserver(() => state.paint())" in src and "resize.disconnect()" in src # 카드 clip id 와 가름 · 그리는 동안 refresh 되먹임 막음 @@ -99,7 +99,12 @@ def test_상세_칸_크기로_새로_그림_배선(): assert "base && fitPlotBase(base, heightPx)" in view assert "cardZoomStates.delete(stationId);" in view types = _read("B06_Section_UI_Section_View_Types.ts") - assert "detailChart: (stationId: string, widthPx: number, heightPx: number) =>" in types + # 179-3 — svg + 축 옵션 + assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types + assert ( + " detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number," + in types + ) sync = _read("B06_Section_UI_Page_Design_Sync.ts") assert "ctx.view().detailChart(stationId, widthPx, heightPx)" in sync From 922a2876eb9d39d5e67f0c037bd5f1707b56bb44 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:56:48 +0900 Subject: [PATCH 3/5] =?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 = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"] From 20f17827d35791002e0ebc21437d9013e302bdc5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:57:14 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(b09):=20=EC=9E=AC=EB=A3=8C=EB=B9=84?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=ED=91=9C=20=3D=20=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=EB=A7=8C=20=C2=B7=20=EC=88=98=EB=9F=89=20=3D?= =?UTF-8?q?=20=EC=97=AD=EC=A0=84=EA=B0=9C=20=EC=B4=9D=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=C2=B7=20=EB=B9=84=EA=B3=A0=20=3D=20=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EA=B3=B3=20(183-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수요: 사본 일위대가 자재 · 굳힌 사전조합 자재 코드도(Linked.used_keys) → 대가 재료 줄 단가가 MT 줄에서 남 - 두 번 셈(Router.compute_twice): 1차 셈 → 대가 · 산근 · 중기 재료 줄 · B08 줄 · 관급 · 별도 줄만 남김(Summary.used_materials) · 1차 역전개 총수량 = material_qty → 2차 셈 - 수량 원천: Calc_Base 짝 material_sheet 가 material_qty(없는 키 빈칸) · 없으면 입력 줄 수량(거울) - 화면: 비고 = 쓰는 곳(대가 · 산근 · 중기 n호표 · 내역 자재대) · 안내 글 - 실측 엄상준_검증: 141 → 24줄 · 목록 수량 = 재료비수량금액집계표 수량 · 목록표에 없음 0 - 시험: test_183_b09_material_list Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_Calc_Base.py | 23 +++-- B09_Estimation/B09_Estimation_Calc_Base.ts | 12 +-- B09_Estimation/B09_Estimation_Linked.py | 8 +- B09_Estimation/B09_Estimation_Router.py | 19 +++- B09_Estimation/B09_Estimation_Summary.py | 19 ++++ B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 30 ++++++- .../tester/test_183_b09_material_list.py | 88 +++++++++++++++++++ 7 files changed, 179 insertions(+), 20 deletions(-) create mode 100644 resources/tester/test_183_b09_material_list.py diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py index 775553d86..127960c1b 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.py +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -13,8 +13,11 @@ · 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`) · 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · B08 파형강관 = 관급) → 재료비목록표 단가 = 적용 단가 - 재료비목록표 = 수량(B08 총괄자재수량집계표 합계 열) × 적용 단가 = 총액 ROUND(·, 0) - (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) + 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) + (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) · 수량 = + 입력 `material_qty`(183-1 · 서버 1차 셈 역전개 총수량 = 재료비수량금액집계표 수량 · + 없는 키 빈칸) · + `material_qty` 가 없으면 입력 줄 수량(B08 총괄 합계 열) 중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임) + 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100) 줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표 @@ -178,14 +181,18 @@ def compare_sheet( return rows -def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]: - """재료비목록표 — 단가 = 대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0).""" - keep = ("no", "key", "name", "spec", "unit", "supply", "origin", "qty") +def material_sheet( + compare: list[dict[str, Any]], qty_of: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """재료비목록표 — 단가 = 대비표 적용 단가 · 수량 = `qty_of`(183-1) ?? 줄 수량 · + 총액 = ROUND(수량 × 단가, 0).""" + keep = ("no", "key", "name", "spec", "unit", "supply", "origin") rows = [] for row in compare: - price, q = row["applied"], row["qty"] + price = row["applied"] + q = row["qty"] if qty_of is None else num(qty_of.get(row["key"])) amount = None if price is None or q is None else round_half(q * price, 0) - rows.append({**{k: row[k] for k in keep}, "price": price, "amount": amount}) + rows.append({**{k: row[k] for k in keep}, "qty": q, "price": price, "amount": amount}) return rows @@ -328,7 +335,7 @@ def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any] "expense": expense, "basis": basis, "compare": compare, - "material": material_sheet(compare), + "material": material_sheet(compare, inputs.get("material_qty")), "machine_blocks": blocks, "machine": machine, "gaps": gaps, diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts index c5223530c..7c133ea91 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.ts +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -9,7 +9,8 @@ * 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0) * 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 · * 관급 · 별도 표시 → 재료비목록표 - * 재료비목록표 = 수량(B08 총괄 합계 열) × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 + * 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 · 수량 = 입력 + * material_qty(183-1 · 서버 역전개 총수량 · 없는 키 빈칸) ?? 입력 줄 수량(B08 총괄 합계 열) * 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표 * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김 * ========================================================================== */ @@ -157,13 +158,14 @@ function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { }); } -function materialSheet(compare: Row[]): Row[] { - const keep = ["no", "key", "name", "spec", "unit", "supply", "origin", "qty"]; +function materialSheet(compare: Row[], qtyOf?: Row | null): Row[] { + const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"]; return compare.map((row) => { const price: Num = row.applied; - const q: Num = row.qty; + const q: Num = qtyOf == null ? row.qty : num(qtyOf[row.key]); return { ...Object.fromEntries(keep.map((k) => [k, row[k]])), + qty: q, price, amount: price === null || q === null ? null : roundHalf(q * price, 0), }; @@ -301,7 +303,7 @@ export function computeBase(inputs: Row, user: Row): Row { expense, basis, compare, - material: materialSheet(compare), + material: materialSheet(compare, inputs.material_qty), machine_blocks: blocks, machine, gaps, diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index ded22e253..ff2427dd3 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -3,7 +3,9 @@ 연결된 사본 = `M02_Item_Snapshot.linked_snapshots`(지정 ∩ 같은 항목 사본 · B08 · B09 공용). 사본마다 `일위대가.json` 담은 로직을 그 사본 단가 묶음(`단가.json`) · 입력값 · 고른자재로 돌리며 실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째 -(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음). +(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) · +사본 일위대가 자재 · 굳힌 사전조합 자재 코드도(183-1 · 로직을 안 돌아 `get` 에 안 걸림). +고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`). 구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음. """ @@ -57,6 +59,10 @@ def used_keys(folder: Path) -> list[str]: labor_doc.run_line( snapshot, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") ) + for mat in [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])]: + code = str(mat.get("코드") or "") if isinstance(mat, dict) else "" + if code[:2] in RESOURCE_TABLES and code not in seen: + seen.append(code) return seen diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index ffa23ccb6..b4a7d7185 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -37,7 +37,7 @@ from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router -from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines +from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -81,6 +81,19 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> return sheets +def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> tuple[dict, dict]: + """183-1 두 번 셈 — 1차 셈으로 쓰는 자재만 남기고(`used_materials`) 재료비목록표 수량 = + 1차 역전개 총수량(`material_qty` = 재료비수량금액집계표 수량) → 2차 셈 · 집계표. + `inputs` 를 고쳐 둠(화면 짝이 거른 입력 · 수량을 그대로 받음).""" + first = compute_sheets(root, inputs, user) + summary = compute_summary(first, hopyo_lines(first)) + keep = used_materials(first, inputs, user) + inputs["materials"] = [m for m in inputs.get("materials") or [] if m["key"] in keep] + inputs["material_qty"] = {row["key"]: row["qty"] for row in summary["sum_material"]} + sheets = compute_sheets(root, inputs, user) + return sheets, compute_summary(sheets, hopyo_lines(sheets)) + + async def _demand(root: Path) -> list[str]: """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2).""" keys = await asyncio.to_thread(linked_resource_keys, root) @@ -103,7 +116,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: inputs["hopyo"] = hopyo_prices(root) inputs["daega"] = await asyncio.to_thread(daega_inputs, root) user = await asyncio.to_thread(store.read_user, root) - sheets = await asyncio.to_thread(compute_sheets, root, inputs, user) + sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, user) return { "inputs": inputs, "user": user, @@ -113,7 +126,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: "rate_table": rate_table(), "notes": notes, # 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜 - "summary": compute_summary(sheets, hopyo_lines(sheets)), + "summary": summary, } diff --git a/B09_Estimation/B09_Estimation_Summary.py b/B09_Estimation/B09_Estimation_Summary.py index 5569f8f31..9a660b718 100644 --- a/B09_Estimation/B09_Estimation_Summary.py +++ b/B09_Estimation/B09_Estimation_Summary.py @@ -155,6 +155,25 @@ def block_lines(blocks: list[dict[str, Any]] | None) -> dict[str, Any]: return out +def used_materials(sheets: dict[str, Any], inputs: dict[str, Any], user: dict[str, Any]) -> set: + """재료비목록표에 남길 자재 키(183-1) — 대가 · 산근 · 중기 블록 재료 줄(수량 빈칸 · 0 도 = + 집계표 역전개와 같은 줄) · B08 총괄 줄(수량 있는 입력) · 관급 · 별도 표시 줄. + 그 밖(고르기 후보 줄) = 뺌.""" + blocks = [*(sheets.get("sg") or []), *(sheets.get("daega_blocks") or [])] + blocks += sheets.get("machine_blocks") or [] + keep = { + str((line.get("ref") or {}).get("key")) + for block in blocks + for line in block.get("lines") or [] + if line.get("kind") == "material" and (line.get("ref") or {}).get("key") + } + marks = user.get("materials") or {} + for m in inputs.get("materials") or []: + if "qty" in m or m.get("supply") or (marks.get(m["key"]) or {}).get("supply"): + keep.add(m["key"]) + return keep + + def hopyo_lines(sheets: dict[str, Any]) -> dict[str, Any]: """호표 구성(1 단위당 자원 · 겹 호표) — 산근(145-1) + 대가(145-2 · 들어오면).""" return {**block_lines(sheets.get("sg")), **block_lines(sheets.get("daega_blocks"))} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 04670f20d..f5267e0cc 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -2,7 +2,8 @@ * B09_Estimation_UI_Tabs_Base.ts * B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 · * 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`). - * 재료비목록표 = 엑셀 꼴 + 수량(B08 합계) · 총액 열(166-3) + * 재료비목록표 = 엑셀 꼴 + 수량 · 총액 열(166-3) · 줄 = 대가 · 산근 · 중기가 쓰는 자재 + B08 줄 · + * 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`) * * 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈 * · 견적 단가 · 면 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도) @@ -207,10 +208,33 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { }; }; +/** 쓰는 곳(183-1) — 자재 키 → 「대가 n호표」 · 「산근 n호표」 · 「중기 n호표」 · 「내역 자재대」 + * (블록 재료 줄 · 설계내역서 자재 항목 · 표시 잇기만 · 셈 아님) */ +function usesOf(sheets: Row): Map { + const out = new Map(); + const add = (key: unknown, label: string): void => { + if (!key) return; + const list = out.get(String(key)) ?? []; + if (!list.includes(label)) out.set(String(key), [...list, label]); + }; + const scan = (blocks: Row[] | undefined, label: (b: Row, n: number) => string): void => + (blocks || []).forEach((b, n) => + (b.lines || []).forEach((ln: Row) => ln.kind === "material" && add(ln.ref?.key, label(b, n))), + ); + scan(sheets.daega_blocks, (b) => `대가 ${b.no}호표`); + scan(sheets.sg, (b) => `산근 ${b.no}호표`); + scan(sheets.machine_blocks, (_b, n) => `중기 ${n + 1}호표`); + for (const r of sheets.bill?.rows || []) + if (r.kind === "item" && r.ref?.tab === "material") add(r.ref.key, "내역 자재대"); + return out; +} + export const buildMaterial: TabBuild = ({ sheets }): TabTable => { + const uses = usesOf(sheets); const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({ ...r, supply: r.supply ?? "", + uses: (uses.get(r.key) ?? []).join(" · ") || (r.origin === "B08" ? "B08 총괄" : ""), })); return { columns: [ @@ -222,14 +246,14 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => { money("price", "단가"), money("amount", "총액"), text("supply", "관급 · 별도", 80), - text("origin", "비고", 90), + text("uses", "비고(쓰는 곳)", 240), ], rows, total: rows.length ? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) } : null, frozen: 2, - hint: `수량 = B08 총괄자재수량집계표 합계 열(할증 포함 · 사본 자원 줄은 빈칸) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · ${LINK_HINT}`, + hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`, link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null), }; }; diff --git a/resources/tester/test_183_b09_material_list.py b/resources/tester/test_183_b09_material_list.py new file mode 100644 index 000000000..e6905631c --- /dev/null +++ b/resources/tester/test_183_b09_material_list.py @@ -0,0 +1,88 @@ +"""183-1 재료비목록표 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재만 · 수량 · 단가 한 줄 · 쓰는 곳. + +- 수요: 사본 일위대가 자재 · 굳힌 사전조합 자재 코드도 든다(`Linked.used_keys`) +- 거름: 대가 · 산근 · 중기 블록 재료 줄 · B08 줄 · 관급 · 별도 줄만 남김(`Summary.used_materials`) +- 수량 = 입력 `material_qty`(1차 역전개 총수량) — `Calc_Base` 짝 거울 · 두 번 셈(`Router.compute_twice`) +- 화면 비고 = 쓰는 곳(`UI_Tabs_Base.usesOf`) +""" + +import json +from pathlib import Path + +from B09_Estimation import B09_Estimation_Router as router_mod +from B09_Estimation.B09_Estimation_Calc_Base import compute_base +from B09_Estimation.B09_Estimation_Linked import used_keys +from B09_Estimation.B09_Estimation_Summary import used_materials +from resources.tester.test_144_b09_base_calc import _run_ts + +ROOT = Path(__file__).resolve().parents[2] + +MATERIALS = [ + {"key": "MT1", "name": "쓰는 자재", "prices": {"물가자료": 1000}}, + {"key": "MT2", "name": "고르기 후보", "prices": {"물가자료": 500}}, + {"key": "MT3", "name": "관급 표시", "prices": {"물가자료": 300}}, + {"key": "MO1", "name": "경유", "prices": {"유가": 1800}}, + {"key": "b08:pipe_800", "name": "파형강관", "prices": {}, "qty": 8, "supply": "관급"}, +] +USER = {"materials": {"MT3": {"supply": "관급"}}} +SG = [{"key": "SG1", "no": 1, "lines": [{"kind": "material", "ref": {"key": "MT1"}, "qty": 2}]}] +MACHINE = [{"key": "EQ1", "lines": [{"kind": "material", "ref": {"key": "MO1"}, "qty": None}]}] + + +def test_사본_일위대가_자재_사전조합_자재_코드도_수요(tmp_path): + labor = { + "줄": {"담은로직": [], "자재": [{"코드": "MT000053"}, {"코드": "PM000001"}]}, + "참조사본": {"참조|system||SC-000001": {"담은로직": [], "자재": [{"코드": "MT000008"}]}}, + } + (tmp_path / "일위대가.json").write_text(json.dumps(labor, ensure_ascii=False), "utf-8") + assert used_keys(tmp_path) == ["MT000053", "MT000008"] # 개인 자재(PM) = 원가 표 밖 + + +def test_쓰는_자재만_남김(): + sheets = {"sg": SG, "daega_blocks": [], "machine_blocks": MACHINE} + inputs = {"materials": MATERIALS} + assert used_materials(sheets, inputs, USER) == {"MT1", "MO1", "MT3", "b08:pipe_800"} + + +def test_수량_원천_material_qty_거울(): + inputs = {"labor": [], "machines": [], "materials": MATERIALS} + old = {r["key"]: r["qty"] for r in compute_base(inputs, USER)["material"]} + assert old["b08:pipe_800"] == 8 and old["MT1"] is None # 없으면 입력 줄 수량(지금 길) + got = compute_base({**inputs, "material_qty": {"MT1": 6, "b08:pipe_800": 8}}, USER) + rows = {r["key"]: r for r in got["material"]} + assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6, 6000) + assert rows["MT2"]["qty"] is None and rows["MT2"]["amount"] is None # 없는 키 = 빈칸 + for case in (inputs, {**inputs, "material_qty": {"MT1": 6}}): + assert _run_ts(case, USER) == compute_base(case, USER) + + +def test_두_번_셈_후보_줄_빠지고_수량_역전개(monkeypatch): + calls = [] + + def fake(root, inputs, user): + calls.append([m["key"] for m in inputs["materials"]]) + base = compute_base(inputs, user) + bill = {"rows": [{"kind": "item", "qty": 3, "ref": {"tab": "sg", "key": "SG1"}}]} + return {**base, "sg": SG, "daega_blocks": [], "machine_blocks": MACHINE, "bill": bill} + + monkeypatch.setattr(router_mod, "compute_sheets", fake) + inputs = {"labor": [], "machines": [], "materials": list(MATERIALS)} + sheets, summary = router_mod.compute_twice(Path("."), inputs, USER) + assert calls[0] == [m["key"] for m in MATERIALS] + assert calls[1] == ["MT1", "MT3", "MO1", "b08:pipe_800"] # 후보 MT2 뺌 + assert inputs["material_qty"] == {"MT1": 6.0} # 설계내역서 3 × 호표 2 + rows = {r["key"]: r for r in sheets["material"]} + assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6.0, 6000) + assert [r["qty"] for r in summary["sum_material"]] == [rows["MT1"]["qty"]] # 집계표와 같은 값 + + +def test_화면_비고_쓰는_곳(): + src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8") + for label in ( + "`대가 ${b.no}호표`", + "`산근 ${b.no}호표`", + "`중기 ${n + 1}호표`", + '"내역 자재대"', + ): + assert label in src + assert 'text("uses", "비고(쓰는 곳)"' in src and 'text("origin", "비고"' not in src From 8cd87f48b027e910fd4be0432bb988e706799a4d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:58:15 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(b09):=20=EC=9E=90=EC=9E=AC=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EB=8C=80=EB=B9=84=ED=91=9C=20=EB=A8=B8=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20=EA=B2=AC=EC=A0=81=20=E3=80=8C=EB=A9=B4=E3=80=8D=20?= =?UTF-8?q?=EB=BA=8C=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EC=B6=9C=EC=B2=98=20?= =?UTF-8?q?=E3=80=8C=EB=A9=B4=E3=80=8D=20=E2=86=92=20=E3=80=8C=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=E3=80=8D=20=C2=B7=20=EA=B2=AC=EC=A0=81=EC=84=9C=20=3D?= =?UTF-8?q?=20=EA=B2=AC=EC=A0=81=20=EC=95=84=EB=9E=98=20(183-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 출처 묶음 둘째 칸 머리 「출처」(값 = M01 면수 · 유가 기준일 그대로 · 관급 출처 열 그대로) - 견적 묶음 = 단가 · 견적서(맨 끝 견적서 열 지움) · 견적 면 수기 칸 · 고침 갈래 지움(저장 quote_page 는 그대로 읽힘) - 시험: test_183_2_b09_compare_head · test_171_1 안내 글 판정 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 21 +++++++------- .../tester/test_171_1_manual_input_label.py | 2 +- .../tester/test_183_2_b09_compare_head.py | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_183_2_b09_compare_head.py diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index f5267e0cc..d81d42d73 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -6,8 +6,9 @@ * 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`) * * 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈 - * · 견적 단가 · 면 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도) - * · 「견적서」 칸 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) + * · 견적 단가 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도) + * · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) + * · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음) * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 * ========================================================================== */ @@ -149,26 +150,24 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { text("name", "품명", 180), text("spec", "규격", 140), text("unit", "단위", 56), - ...SOURCES.flatMap((s) => [money(`p:${s}`, "단가", [s]), text(`g:${s}`, "면", 64, [s])]), + // 183-2 — 견적 = 단가 · 견적서 · 그 밖 = 단가 · 출처(면수 값) + ...SOURCES.flatMap((s) => [ + money(`p:${s}`, "단가", [s]), + s === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s]), + ]), money("lowest", "최저"), money("applied", "적용 단가", ["적용"]), text("applied_from", "출처", 80, ["적용"]), text("supply", "관급 · 별도", 80), - text("files", "견적서", 72), ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", - editable: (_i, key) => key === "applied" || key === "p:견적" || key === "g:견적", + hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", + editable: (_i, key) => key === "applied" || key === "p:견적", onEdit: (i, key, value) => { const row = out[i]; if (!row) return; const cell = mine(String(row.key)); - if (key === "g:견적") { - if (value.trim()) cell.quote_page = value.trim(); - else delete cell.quote_page; - return put(String(row.key), cell); - } const v = parseNumber(value); if (v === undefined) return; const field = key === "applied" ? "applied" : key === "p:견적" ? "quote" : null; diff --git a/resources/tester/test_171_1_manual_input_label.py b/resources/tester/test_171_1_manual_input_label.py index c8a37513c..2969bb99c 100644 --- a/resources/tester/test_171_1_manual_input_label.py +++ b/resources/tester/test_171_1_manual_input_label.py @@ -14,7 +14,7 @@ SPOTS = { "B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Sangeun.py": ['f"수기 줄 {'], - "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 · 면 = 수기 입력"], + "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"], "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'], "ui_template/ui_template_locale_b2.ts": [ "수량 원천: 수기 입력(직접비)", diff --git a/resources/tester/test_183_2_b09_compare_head.py b/resources/tester/test_183_2_b09_compare_head.py new file mode 100644 index 000000000..3040fe43a --- /dev/null +++ b/resources/tester/test_183_2_b09_compare_head.py @@ -0,0 +1,29 @@ +"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」(값 그대로) · 견적서 = 견적 아래. + +소스 시험(열 정의 · 고칠 칸) — 셈 · 저장(`quote_page` 남음)은 안 바뀜(test_166_4). +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SRC = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8") + + +def _compare() -> str: + start = SRC.index("export const buildCompare") + return SRC[start : SRC.index("export const buildMaterial")] + + +def test_견적은_단가_견적서_그_밖은_단가_출처(): + body = _compare() + assert ( + 's === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s])' in body + ) + assert '"면"' not in body # 면 머리 없음 + assert body.count('text("files", "견적서"') == 1 # 맨 끝 견적서 열 지움 + + +def test_견적_면_수기_칸_없앰(): + body = _compare() + assert 'key === "g:견적"' not in body and "quote_page" not in body + assert "견적 단가 = 수기 입력" in body and "견적 단가 · 면" not in body