Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-14 15:25:04 +09:00
6 changed files with 271 additions and 32 deletions
@@ -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)
]
+47 -17
View File
@@ -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<string, number>;
}
/**
@@ -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<string, number> = {};
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;
}
+56 -8
View File
@@ -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),
)
@@ -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) # 설계선이 지반 그대로인 자리
+34
View File
@@ -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], {}))