feat(b06,b07): 맹암거 측점 횡단 — 측구 없음 · 암거 경사로 지대 낮은 쪽 기울기 · 노면 아래 암거 높이 돌 무늬 형상 (288-1)

- 서버 B06_Section_Engine_Tilt — 기울기 자리(물넘이 범위 + 맹암거 기준 측점 · 경사 = 경사각도 · 암거 경사 · 비면 표준 횡단경사) · 맹암거 제원 얹기(attach_underdrains) · 상세 · 재생성 응답에 얹음
- TS 짝 common_util_structure_underdrain(초안 구조물로 다시 얹음) · 계획선 편집 재계산도 맹암거 자리 기울임
- 형상(B06_Section_UI_Cross_Underdrain) — 노면을 높이만큼 아래로 옵셋 · 성토 쪽 = 성토 사면과 만남 · 절토 쪽 = 절토 사면을 늘여 만남 · 안 = 돌 무늬 · 높이 비면 안 그림 · B06 카드 · B07 같은 그림
- tilt_ranges · tilt_args 는 Router_Design 에서 새 모듈로 옮김(700줄)
- 시험 test_288 새로 · 286_1 문자열 대조

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-12 21:12:47 +09:00
co-authored by Claude Opus 5.5
parent 75050815fa
commit fcf7daecb6
14 changed files with 569 additions and 26 deletions
@@ -0,0 +1,87 @@
/* 288 맹암거 횡단 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로.
* 입력 = argv[2] JSON 파일 { structures, sections }.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const load = (file) => {
if (cache.has(file)) return cache.get(file);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(file, box.exports);
const near = (m) => {
if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`));
if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`));
if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`));
return new Proxy({}, { get: () => () => {} });
};
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
};
class Node {
constructor(tag) {
this.tag = tag;
this.attrs = {};
this.children = [];
}
setAttribute(k, v) {
this.attrs[k] = String(v);
}
getAttribute(k) {
return this.attrs[k] ?? null;
}
append(...kids) {
this.children.push(...kids);
}
}
globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) };
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const util = load(path.join(ROOT, "common_util", "common_util_structure_underdrain.ts"));
const draw = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Underdrain.ts"));
const specs = util.underdrainSpecsFrom(input.structures);
const sections = input.sections.map((s) => ({ ...s }));
const attached = util.attachUnderdrainSpecs(sections, specs);
// 지대 낮은 쪽(우)으로 5 % 기울인 노면 · 좌 = 절토(오름) · 우 = 성토(내림) · 암거 높이 1 m
const design = {
road_edges: {
left: { offset_m: 3, elevation_m: 100.15 },
right: { offset_m: -3, elevation_m: 99.85 },
},
design_line: [
{ offset_m: 6, elevation_m: 102.9 },
{ offset_m: 3, elevation_m: 100.15 },
{ offset_m: -3, elevation_m: 99.85 },
{ offset_m: -7, elevation_m: 97.2 },
],
};
const svg = new Node("g");
const group = draw.appendUnderdrainOverlay(
svg,
{ structure_id: "ud-1", chainage_m: 40, height_m: 1, slope_pct: 5 },
design,
(o) => o,
(e) => e,
);
const body = group?.children.find((k) => k.attrs.class === "b06-chart__underdrain-body");
process.stdout.write(
JSON.stringify({
specs,
attached,
sections: sections.map((s) => s.underdrain ?? null),
outline: draw.underdrainOutline(design, 1),
noHeight: draw.underdrainOutline(design, null),
drew: !!group,
bodyId: body?.attrs["data-structure-id"] ?? null,
stones: group?.children.filter((k) => k.tag === "g").flatMap((g) => g.children).length ?? 0,
}),
);
@@ -138,7 +138,7 @@ def test_저장_뒤_서버가_물넘이_측구를_맞춤():
router = _read("B06_Section", "B06_Section_Router_Design.py")
assert "stored_tilt(design) == tilt" in router
refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts")
assert "tilted: Boolean(section.ford_pavement)," in refresh
assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh
CHUTE = {"ford_chute_1_structure": "돌붙임", "ford_chute_1_thickness": 30}
@@ -0,0 +1,143 @@
"""288 맹암거 횡단 형상(요구 183-3 · 9).
못박는 것
- 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 암거 경사로 지대 낮은 쪽 한 경사(물넘이 286 · 287 길 —
서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted`) · 제원 얹기 서버 `attach_underdrains` ↔ TS
`attachUnderdrainSpecs` 같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과
만남 · 절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림.
진짜 TS 는 `helper_288_underdrain.cjs` 가 Node 로 돌림. 화면은 사용자 확인.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import B06_Section.B06_Section_Engine_Tilt as tilt # noqa: E402
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
STRUCTURES = [
{
"structure_id": "ud-1",
"type_id": "cross_underdrain",
"placement": "point",
"chainage_m": 40.0,
"options": {"culvert_height_m": 0.8, "culvert_slope_pct": 4},
},
{ # 경사 · 높이 빈 맹암거 — 표준 횡단경사 · 형상 없음
"structure_id": "ud-2",
"type_id": "cross_underdrain",
"placement": "point",
"chainage_m": 80.0,
"options": {},
},
{
"structure_id": "x-1",
"type_id": "cross_drain_exposed",
"placement": "point",
"chainage_m": 60.0,
},
]
SECTIONS = [{"chainage_m": c} for c in (20.0, 40.0, 60.0, 80.0)]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture()
def project(monkeypatch, tmp_path) -> Path:
items = [StructureInstance(**s) for s in STRUCTURES]
monkeypatch.setattr(tilt, "load_structures", lambda _root: (1, items))
monkeypatch.setattr(tilt, "load_culvert_sets", lambda _root: {})
return tmp_path
@pytest.fixture(scope="module")
def ts(tmp_path_factory) -> dict:
source = tmp_path_factory.mktemp("s288") / "in.json"
source.write_text(
json.dumps({"structures": STRUCTURES, "sections": SECTIONS}), encoding="utf-8"
)
proc = subprocess.run(
["node", str(HERE / "helper_288_underdrain.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_맹암거_자리_기울기_서버(project):
ranges = tilt.tilt_ranges(project)
assert ranges == [(40.0, 40.0, 4.0), (80.0, 80.0, None)]
assert tilt.tilt_args(40.0, ranges) == {"tilted": True, "tilt_pct": 4.0}
assert tilt.tilt_args(80.01, ranges) == {"tilted": True, "tilt_pct": None}
assert tilt.tilt_args(60.0, ranges) == {"tilted": False, "tilt_pct": None}
def test_제원_얹기_서버_TS_같은_값(project, ts):
sections = [dict(s) for s in SECTIONS]
assert tilt.attach_underdrains(project, sections) == 2 == ts["attached"]
assert [s.get("underdrain") for s in sections] == ts["sections"]
assert ts["sections"][1] == {
"structure_id": "ud-1",
"chainage_m": 40.0,
"height_m": 0.8,
"slope_pct": 4.0,
}
def test_맹암거_측점_설계_측구_없음_낮은_쪽_기울기():
samples = [
{"offset_m": o / 2, "elevation_m": round(100.0 + 0.35 * o / 2, 4), "valid": True}
for o in range(-24, 25)
]
kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"}
got = compute_cross_design(samples, 100.4, tilted=True, tilt_pct=4.0, **kwargs)
assert got["ditch_enabled"] is False and got["cross_slope_pct"] == 4.0
left, right = (got["road_edges"][k]["elevation_m"] for k in ("left", "right"))
assert left > right # 지반이 좌로 오름 = 우가 낮음
def test_형상_옵셋선_성토_절토와_만남(ts):
outline = [(p["offset"], p["elevation"]) for p in ts["outline"]]
expected = [
(3, 100.15), # 좌 노면 끝
(-3, 99.85), # 우 노면 끝
(-4.632653, 98.768367), # 우 성토 사면 ∩ 옵셋선
(1.846154, 99.092308), # 좌 절토 사면을 늘인 선 ∩ 옵셋선
]
assert len(outline) == len(expected)
for got, want in zip(outline, expected):
assert got == pytest.approx(want, abs=1e-5)
assert ts["noHeight"] is None
assert ts["drew"] is True and ts["stones"] > 0
def test_횡단도_B07_이_맹암거를_그림():
view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
assert (
"appendUnderdrainOverlay(plotLayer, section.underdrain, section.design, x, toDisplayY);"
in view
)
b07 = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_Cross_Overlay.ts").read_text(
encoding="utf-8"
)
assert "appendUnderdrainOverlay(svg, section.underdrain, design, x, y);" in b07
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh