feat(b06,b08): 다단 벽 몸이 성토 폐회로에도 든 겹침을 줄 사유로 드러냄 — 고치지 않음(2026-09-06 확정 「구조물 면적 안 뺌」 · 판정은 사용자)
서버 Node 가 벽 몸 ∩ 성토 폐회로 넓이를 재 design.extra_walls[].fill_overlap_m2 로 남기고 B08 다단 줄 비고에 「벽 몸 N㎡ × 연장 ≈ ㎥ 두 번 셈」 · 258.12 1단 1.335㎡ × 10m ≈ 13.3㎥ · 금액 변화 0 · 미확정 단은 안 붙임 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) # 설계선이 지반 그대로인 자리
|
||||
@@ -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], {}))
|
||||
|
||||
Reference in New Issue
Block a user