diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index faa6c802..e954d9ef 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -10,7 +10,7 @@ * DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다. * ========================================================================== */ -import { computeStructureAreas } from "@util/common_util_cross_structure_areas"; +import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross_structure_areas"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const"; @@ -149,6 +149,22 @@ export const STRUCTURE_ROW_KEYS = [ "pipe_length_m", ] as const; +/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */ +function areaInputOf(section: CrossSection, layouts: StoredLayouts) { + const trim = trimOfLayouts(layouts); + const designLine = layouts.design.design_line; + if (!trim || !Array.isArray(designLine) || designLine.length < 2) return null; + const ground = section.samples + .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") + .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + return { + designLine: designLine as Array<{ offset_m: number; elevation_m: number }>, + ground, + trim, + }; +} + /** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */ function areaRowOf( section: CrossSection, @@ -181,17 +197,11 @@ function areaRowOf( typeof pipeLengthM === "number" && pipeLengthM > 0 ? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) } : null; - const trim = trimOfLayouts(layouts); + const areaInput = areaInputOf(section, layouts); + if (!areaInput) return pipeRow; const design = layouts.design; - if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return pipeRow; - const ground = section.samples - .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") - .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) - .sort((a, b) => a.offset - b.offset); const areas = computeStructureAreas({ - designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, - ground, - trim, + ...areaInput, rockBoundaryOffsetM: typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, }); @@ -236,6 +246,9 @@ export interface BuiltExtraWall { form_set: boolean; before_m: number; after_m: number; + /** 벽 몸 중 성토 폐회로 안에 든 넓이(㎡) — 구조물 면적을 안 빼서(2026-09-06 확정) 성토에도 셈. + * B08 이 「두 번 셈」 사유로 올림(2026-09-14 브레인). 면적을 못 내는 측점은 null. */ + fill_overlap_m2: number | null; } /** @@ -252,8 +265,10 @@ export function extraWallRows( if (!section.culvert) continue; const owner = pipeOwnerChainage(section, sections); if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue; - const culvert = computeStoredLayouts(section, sections)?.culvert; - if (!culvert) continue; + const layouts = computeStoredLayouts(section, sections); + const culvert = layouts?.culvert; + if (!layouts || !culvert) continue; + const areaInput = areaInputOf(section, layouts); const walls: BuiltExtraWall[] = []; const built: Array<["outlet" | "basin", WallLayout[], WallAdjust[]]> = [ ["outlet", culvert.extraWalls, culvert.revetShift.extras], @@ -272,6 +287,9 @@ export function extraWallRows( form_set: section.design?.revet_adjust?.[key]?.m != null, before_m: span.beforeM, after_m: span.afterM, + fill_overlap_m2: areaInput + ? Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3)) + : null, }); }); } diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index 79a9f060..b9a150fd 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -328,6 +328,13 @@ UNCONFIRMED_EXTRA_HEIGHT = ( UNCONFIRMED_EXTRA_FORM = "다단 형태를 안 정해 기준벽의 기본 형태 「{form}」로 섰음" #: 첫 미확정 단 아래는 횡단도 면적도 없는 것으로 봄(B06 `confirmedOnly`) — 같은 선에서 끊음. UNCONFIRMED_EXTRA_ABOVE = "윗단({above}단)이 미확정이라 이 단도 미확정 — 윗단 높이를 적으면 섬" +#: 벽 몸이 성토 폐회로에도 듦(2026-09-06 사용자 확정 「구조물 면적 안 뺌」) — 고치지 않고 사유로(2026-09-14 브레인). +#: 넓이는 서버 Node 가 횡단도 벽 몸 ∩ 성토 폐회로로 잼(`wallBodyInFillM2` · `fill_overlap_m2`). +NOTE_FILL_OVERLAP = ( + "⚠ 벽 몸 자리가 성토 면적에도 들어 있음(2026-09-06 확정 「구조물 면적 안 뺌」) — 이 측점 횡단도 " + "벽 몸 {area:.2f}㎡ × 연장 {length:g}m ≈ {volume:.1f}㎥ 를 벽과 성토에 두 번 셈 · " + "벽 뒤 막자갈 자리도 성토선 아래라 더 겹침" +) def extra_walls_from_designs(designs: list[dict[str, Any]] | None) -> dict[float, list[dict]]: @@ -471,6 +478,16 @@ def facility_structures( reasons.append(UNCONFIRMED_EXTRA_FORM.format(form=form)) label = f"{EXTRA_SIDE_LABELS[side]} 다단 기슭막이 {counts[side]}단" row = child_row(base, "revetment", label, str(tier.get("key") or label)) + tier_notes = [ + f"ⓘ 횡단도에 선 다단 벽 — 높이 {height:g}m · 형태 {form} · 전 {before:g}/후 {after:g}m" + ] + overlap = _num_or_zero(tier.get("fill_overlap_m2")) + if overlap > 0 and not reasons: # 미확정 단은 면적에도 금액에도 안 듦 + tier_notes.append( + NOTE_FILL_OVERLAP.format( + area=overlap, length=before + after, volume=overlap * (before + after) + ) + ) row.update( start_m=chainage - before, end_m=chainage + after, @@ -483,9 +500,7 @@ def facility_structures( "after_m": after, "foundation": foundation, }, - notes=[ - f"ⓘ 횡단도에 선 다단 벽 — 높이 {height:g}m · 형태 {form} · 전 {before:g}/후 {after:g}m" - ], + notes=tier_notes, withheld=False, unconfirmed=" · ".join(reasons), ) diff --git a/common_util/common_util_cross_structure_areas.ts b/common_util/common_util_cross_structure_areas.ts index f3ec9fc5..3f569edd 100644 --- a/common_util/common_util_cross_structure_areas.ts +++ b/common_util/common_util_cross_structure_areas.ts @@ -72,12 +72,10 @@ function interpolator( }; } -/** - * 구조물이 선 뒤의 절·성토 면적. 트림 바깥은 구조물이 그리는 폴리라인을 따르고, 그 선이 - * 없으면 트림 경계 표고에서 끊어 **지반선에 붙인다**(그 바깥은 손대지 않은 원지반이라 - * 면적이 0이 된다). - */ -export function computeStructureAreas(input: StructureAreaInput): StructureAreaResult | null { +/** 폐회로의 위(실제로 그려지는 설계선)·아래(지반선) 보간기 — 면적과 벽 겹침이 같은 선을 봄. */ +function drawnProfile( + input: StructureAreaInput, +): { drawnZ: (offset: number) => number; ground: (offset: number) => number } | null { const design = interpolator( input.designLine.map((point) => ({ offset: point.offset_m, elevation: point.elevation_m })), ); @@ -117,6 +115,19 @@ export function computeStructureAreas(input: StructureAreaInput): StructureAreaR } return design(offset); }; + return { drawnZ, ground }; +} + +/** + * 구조물이 선 뒤의 절·성토 면적. 트림 바깥은 구조물이 그리는 폴리라인을 따르고, 그 선이 + * 없으면 트림 경계 표고에서 끊어 **지반선에 붙인다**(그 바깥은 손대지 않은 원지반이라 + * 면적이 0이 된다). + */ +export function computeStructureAreas(input: StructureAreaInput): StructureAreaResult | null { + const profile = drawnProfile(input); + if (!profile) return null; + const { drawnZ, ground } = profile; + const { trim } = input; // 적분 격자 = 지반 샘플 ∪ 설계선 꼭짓점 ∪ 트림 경계 ∪ 구조물 폴리라인 꼭짓점. // 꺾이는 자리를 모두 넣어야 사다리꼴 적분이 모서리를 잘라먹지 않는다. @@ -154,3 +165,39 @@ export function computeStructureAreas(input: StructureAreaInput): StructureAreaR cutRockAreaM2: cutRock, }; } + +/** + * 벽 몸 도형(볼록 다각형) 중 **성토 폐회로 안에 든 넓이**(㎡). + * 폐회로는 구조물 면적을 안 뺌(2026-09-06 확정)이라 벽 몸이 성토에도 셈 — 고치지 않고 그 겹침을 + * 재서 수량 사유로 올림(2026-09-14 브레인). 세로줄로 잘라 [max(몸 밑, 지반), min(몸 위, 설계선)] 을 쌓음. + */ +export function wallBodyInFillM2( + points: Array<{ offset: number; elevation: number }>, + input: StructureAreaInput, + steps = 400, +): number { + const profile = drawnProfile(input); + if (!profile || points.length < 3) return 0; + const offsets = points.map((point) => point.offset); + const lo = Math.min(...offsets); + const hi = Math.max(...offsets); + if (!(hi > lo)) return 0; + const dx = (hi - lo) / steps; + let area = 0; + for (let index = 0; index < steps; index += 1) { + const x = lo + (index + 0.5) * dx; + const hits: number[] = []; + points.forEach((p, k) => { + const q = points[(k + 1) % points.length]; + if ((p.offset - x) * (q.offset - x) > 0 || Math.abs(q.offset - p.offset) < 1e-12) return; + hits.push( + p.elevation + ((q.elevation - p.elevation) * (x - p.offset)) / (q.offset - p.offset), + ); + }); + if (hits.length < 2) continue; + const top = Math.min(Math.max(...hits), profile.drawnZ(x)); + const bottom = Math.max(Math.min(...hits), profile.ground(x)); + if (top > bottom) area += (top - bottom) * dx; + } + return area; +} diff --git a/resources/tester/test_b06_wall_body_in_fill.py b/resources/tester/test_b06_wall_body_in_fill.py new file mode 100644 index 00000000..69370ff1 --- /dev/null +++ b/resources/tester/test_b06_wall_body_in_fill.py @@ -0,0 +1,73 @@ +"""벽 몸 중 **성토 폐회로 안에 든 넓이** — `wallBodyInFillM2` (2026-09-14 브레인 「사유로 드러낼 것」). + +폐회로 면적은 구조물 면적을 안 뺌(2026-09-06 사용자 확정)이라 벽 몸이 성토에도 들어감. +그 겹침을 재서 B08 줄 사유로 올림 — 고치지 않음. 실제 코드를 컴파일해 Node 로 돌림. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +MODULE = PROJECT_ROOT / "common_util" / "common_util_cross_structure_areas.ts" + +# 지반 0 · 트림 밖(offset 5~20)은 표고 1.0 수평 성토선 · 벽 몸 offset 10~11 · 표고 −0.5~1.5 +# ⇒ 성토 안(지반 0 ~ 선 1.0)에 든 몸 = 1 × 1 = 1.0㎡ · 지반 아래·선 위는 안 셈. +_RUNNER = """ +const { wallBodyInFillM2 } = require(process.argv[3]); +const { writeFileSync } = require("node:fs"); +const line = []; +for (let o = -20; o <= 20; o += 1) line.push({ offset: o, elevation: 0 }); +const input = { + designLine: line.map((p) => ({ offset_m: p.offset, elevation_m: 0 })), + ground: line, + trim: { minOffset: -5, maxOffset: 5, maxSlope: { points: [{ offset: 5, elevation: 1 }, { offset: 20, elevation: 1 }] } }, +}; +const body = [ + { offset: 10, elevation: -0.5 }, + { offset: 10, elevation: 1.5 }, + { offset: 11, elevation: 1.5 }, + { offset: 11, elevation: -0.5 }, +]; +const outside = body.map((p) => ({ offset: p.offset - 30, elevation: p.elevation })); +writeFileSync(process.argv[2], JSON.stringify({ inFill: wallBodyInFillM2(body, input), outside: wallBodyInFillM2(outside, input) })); +""" + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_벽_몸_중_성토_안_넓이만_잰다(tmp_path: Path) -> None: + out = tmp_path / "out" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + "--ignoreConfig", + "--target", + "es2022", + "--module", + "commonjs", + "--skipLibCheck", + "--outDir", + str(out), + str(MODULE), + ], + cwd=str(PROJECT_ROOT), + check=False, + capture_output=True, + ) + compiled = next(out.rglob("common_util_cross_structure_areas.js"), None) + assert compiled is not None, "tsc 실패" + (out / "runner.cjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "result.json" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(out / "runner.cjs"), str(result), str(compiled)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + produced = json.loads(result.read_text(encoding="utf-8")) + assert produced["inFill"] == pytest.approx(1.0, abs=1e-3) + assert produced["outside"] == pytest.approx(0.0, abs=1e-9) # 설계선이 지반 그대로인 자리 diff --git a/resources/tester/test_b08_extra_walls.py b/resources/tester/test_b08_extra_walls.py index 791844d2..661dd5c9 100644 --- a/resources/tester/test_b08_extra_walls.py +++ b/resources/tester/test_b08_extra_walls.py @@ -125,6 +125,20 @@ def test_미확정_단은_성토를_안_깎는다() -> None: assert "applied.h == null" in geom +def test_벽_몸이_성토에도_든_겹침을_사유로_드러냄() -> None: + """2026-09-14 브레인 — 폐회로는 구조물 면적을 안 뺌(2026-09-06 확정)이라 벽 몸이 성토에도 셈. + 고치지 않고 사유로: 258.12 실측 1단 벽 몸 1.335㎡ 가 성토 폐회로 안.""" + walls = [dict(WALLS[0], fill_overlap_m2=1.336, before_m=5.0, after_m=5.0)] + note = " ".join(_tiers(facility_structures([POINT], {85.0: walls}))[0]["notes"]) + assert "성토 면적에도" in note and "1.34㎡" in note and "13.4㎥" in note + bare = " ".join(_tiers(facility_structures([POINT], {85.0: WALLS[:1]}))[0]["notes"]) + assert "성토 면적에도" not in bare # 값이 없으면(옛 저장본) 지어내지 않음 + + +def test_서버가_벽_몸_겹침을_잰다() -> None: + assert "wallBodyInFillM2(" in LAYOUTS and "fill_overlap_m2" in LAYOUTS + + def test_목록이_없으면_종전과_같다() -> None: assert not _tiers(facility_structures([POINT])) assert not _tiers(facility_structures([POINT], {}))