diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 02c08f8e..d032de2d 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -246,8 +246,15 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int: rows = output.get("areas") mass_haul = output.get("mass_haul") # 선 다단 벽 목록(④) — 목록째 얹음(수가 아니라 `_AREA_KEYS` 로는 못 거름). 주인 측점엔 빈 목록도. + # 관 기준벽 벽 몸 겹침(㉡) — 역할별 ㎡ 한 벌. 빈 dict 도 실어 옛 값을 지움. extra_walls = [ - (float(row["chainage_m"]), {"extra_walls": row["extra_walls"]}) + ( + float(row["chainage_m"]), + { + "extra_walls": row["extra_walls"], + "wall_fill_overlap": row.get("wall_fill_overlap") or {}, + }, + ) for row in output.get("extra_walls") or [] if isinstance(row, dict) and isinstance(row.get("extra_walls"), list) ] diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index faa6c802..726ef730 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,15 @@ 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; +} + +export interface ExtraWallRow { + chainage_m: number; + extra_walls: BuiltExtraWall[]; + wall_fill_overlap: Record; } /** @@ -243,17 +262,18 @@ export interface BuiltExtraWall { * * 단 수·높이는 지형이 정해 요청보다 적게 설 수 있음 — 그래서 요청 칸(`extra_wall_counts`)이 아니라 * 기하가 세운 결과를 남김(관 연장 `pipe_length_m` 과 같은 길). 주인 측점에는 빈 목록도 실어 옛 값을 지움. + * `wall_fill_overlap` — 관 기준벽 역할별 벽 몸 ∩ 성토 폐회로(㎡). B08 기준벽 줄 비고. */ -export function extraWallRows( - sections: readonly CrossSection[], -): Array<{ chainage_m: number; extra_walls: BuiltExtraWall[] }> { - const rows: Array<{ chainage_m: number; extra_walls: BuiltExtraWall[] }> = []; +export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[] { + const rows: ExtraWallRow[] = []; for (const section of sections) { 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,10 +292,20 @@ 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, }); }); } - rows.push({ chainage_m: section.chainage_m, extra_walls: walls }); + // 관 기준벽(유입·유출)도 같은 겹침 — 다단에만 사유가 뜨면 「다단만 문제」로 읽힘(㉡ 2026-09-14). + const wall_fill_overlap: Record = {}; + for (const wall of culvert.walls) { + if (areaInput && (wall.role === "inlet" || wall.role === "outlet")) { + wall_fill_overlap[wall.role] = Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3)); + } + } + rows.push({ chainage_m: section.chainage_m, extra_walls: walls, wall_fill_overlap }); } return rows; } diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index 79a9f060..ded2e7b5 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -328,16 +328,36 @@ 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}㎥ 를 벽과 성토에 두 번 셈 · " + "벽 뒤 막자갈 자리도 성토선 아래라 더 겹침" +) + + +#: 관 기준벽(유입·유출) 벽 몸 겹침 ㎡ — `design.wall_fill_overlap` {inlet, outlet}(서버 Node 가 잼, ㉡ 2026-09-14). +#: 호출부(`Router_Material`)를 안 늘리려고 다단 목록 통에 `base` 표시 칸으로 실어 옴 — 다단 줄은 거름. +WALL_OVERLAP_KEY = "wall_fill_overlap" def extra_walls_from_designs(designs: list[dict[str, Any]] | None) -> dict[float, list[dict]]: - """측점별 「선 다단 벽 목록」 — 목록이 있는 측점만(빈 목록도 담음 — 「다단 없음」).""" + """측점별 「선 다단 벽 목록」 — 목록이 있는 측점만(빈 목록도 담음 — 「다단 없음」). + 기준벽 겹침 값이 있으면 `{"base": 역할, "fill_overlap_m2": ㎡}` 칸을 뒤에 붙임.""" found: dict[float, list[dict]] = {} for row in designs or []: design = row.get("design") if isinstance(row, dict) else None - walls = design.get(EXTRA_WALLS_KEY) if isinstance(design, dict) else None - if isinstance(walls, list): - found[round(float(row.get("chainage_m") or 0.0), 3)] = walls + if not isinstance(design, dict): + continue + walls = design.get(EXTRA_WALLS_KEY) + overlaps = design.get(WALL_OVERLAP_KEY) + if not isinstance(walls, list) and not isinstance(overlaps, dict): + continue + entries = list(walls) if isinstance(walls, list) else [] + if isinstance(overlaps, dict): + entries += [{"base": role, "fill_overlap_m2": area} for role, area in overlaps.items()] + found[round(float(row.get("chainage_m") or 0.0), 3)] = entries return found @@ -431,6 +451,12 @@ def facility_structures( walls[0][3].append(NOTE_SIDE_UNKNOWN.format(side=side)) foundation = options.get("foundation" if legacy else "revet_foundation") kept = {k: v for k, v in options.items() if not k.startswith(("inlet_", "outlet_"))} + station_walls = _extra_walls_at(extra_walls, chainage) + base_overlap = { + str(entry["base"]): _num_or_zero(entry.get("fill_overlap_m2")) + for entry in station_walls + if entry.get("base") + } for role, label, values, notes, withheld, filled in walls: before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"]) row = child_row(base, "revetment", label, f"{role}_revet") @@ -438,6 +464,18 @@ def facility_structures( if legacy and _blank(values["height_m"]): # 높이는 설계자 입력(계획홍수위 + 여유고) — 없으면 줄만 서고 금액 밖. reasons.append(own_height_unconfirmed()) + # 좌·우 한 벽으로 합친 독립 기슭막이는 그 측점에 선 벽 몸 전부. + overlap = ( + sum(base_overlap.values()) + if legacy and len(walls) == 1 + else base_overlap.get(role, 0.0) + ) + if overlap > 0 and not reasons: # 미확정 벽은 금액에 안 듦 — 두 번 셀 금액이 없음 + 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, @@ -451,7 +489,9 @@ def facility_structures( parents = {role: (values, filled) for role, _label, values, _notes, _w, filled in walls} counts = {"outlet": 0, "basin": 0} first_unset: dict[str, int] = {} - for tier in _extra_walls_at(extra_walls, chainage): + for tier in station_walls: + if tier.get("base"): + continue side = "basin" if str(tier.get("side")) == "basin" else "outlet" counts[side] += 1 if not tier.get("height_set"): @@ -471,6 +511,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 +533,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..6cd5c457 100644 --- a/resources/tester/test_b08_extra_walls.py +++ b/resources/tester/test_b08_extra_walls.py @@ -125,6 +125,40 @@ 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: + """2026-09-14 브레인 ㉡ — 같은 겹침이 관 기준벽에도 있음. 다단에만 뜨면 「다단만 문제」로 읽힘. + 258.12 실측 유출 벽 몸 1.417㎡ 가 성토 폐회로 안.""" + designs = [ + {"chainage_m": 85.0, "design": {"extra_walls": [], "wall_fill_overlap": {"outlet": 1.417}}} + ] + rows = facility_structures([POINT], extra_walls_from_designs(designs)) + by_label = { + row["attachment_label"]: " ".join(row["notes"]) + for row in rows + if row.get("notes") is not None + } + outlet = next(text for label, text in by_label.items() if "유출" in label) + inlet = next(text for label, text in by_label.items() if "유입" in label) + assert "성토 면적에도" in outlet and "1.42㎡" in outlet and "14.2㎥" in outlet + assert "성토 면적에도" not in inlet # 잰 값이 없는 벽엔 지어내지 않음 + assert not _tiers(rows) # 기준벽 값이 다단 줄로 새지 않음 + assert "wall_fill_overlap" in LAYOUTS and '"wall_fill_overlap"' in PREBUILD + + def test_목록이_없으면_종전과_같다() -> None: assert not _tiers(facility_structures([POINT])) assert not _tiers(facility_structures([POINT], {}))