diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index f6e241919..1f12fa437 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -6,7 +6,8 @@ * (`ford_slope_deg` → `slope_pct`)로 지대 낮은 쪽 한 경사로 기울여 낸 면 · 설계 중심점이 그 위. * 노면 밖은 설계선 그대로 — 노면 끝에서 성토 · 절토 사면이 이어짐(늘어나지 못한 자리 = 성토). * · 포장층 = 노면공 포장 형상(`appendPavementOverlay`)을 노견까지. - * · 월류 높이선(회색 점선) = 노면 위로 월류 높이(`depth_m`)만큼 · 노면과 같은 각도. + * · 월류 높이선(회색 점선) = 노면 위로 월류 높이(`depth_m`)만큼 · 노면과 같은 각도 · 성토 쪽은 성토선을 + * 점선으로 늘여 이음(315-2). * · 도수로 · 돌붙임 띠 = 유출(지대 낮은 쪽) 성토사면. * ========================================================================== */ @@ -119,15 +120,12 @@ export function appendFordPavementOverlay( if (typeof spec.chainage_m === "number") el.setAttribute("data-structure-id", `pipe-${spec.chainage_m.toFixed(2)}`); }); - // 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) + // 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) · 성토 쪽 = 성토선 연장(315-2) const depth = spec.depth_m ?? 0; if (depth > 0) svg.append( line( - [ - `${x(edges.left.offset_m)},${y(edges.left.elevation_m + depth)}`, - `${x(edges.right.offset_m)},${y(edges.right.elevation_m + depth)}`, - ], + overflowLine(design, depth).map((p) => `${x(p.offset)},${y(p.elevation)}`), "b06-chart__ford-deck-plan", ), ); @@ -145,6 +143,35 @@ export function fordEdges(design: CrossDesign): CrossDesign["road_edges"] | unde return reach.side === "left" ? { ...edges, left: end } : { ...edges, right: end }; } +/** + * 월류 높이선(315-2) — 노면 양 끝 위 월류 높이 · 노면과 같은 각도. 성토 쪽(노면 끝 밖 설계선이 내려감)은 + * 성토 사면선을 노면 끝 너머 위 · 안쪽으로 늘여 월류 높이선과 만나는 자리에서 끝 → 노면 끝까지 그 연장선. + * 절토 쪽 · 늘인 노면 끝(294-3)은 노면 끝 위 그대로. 좌(+offset) → 우 차례. + */ +export function overflowLine(design: CrossDesign, depth: number): OffsetPoint[] { + const edges = fordEdges(design); + if (!edges) return []; + const { left, right } = edges; + const span = Math.abs(left.offset_m - right.offset_m); + const side = (edge: typeof left, other: typeof left): OffsetPoint[] => { + const top = { offset: edge.offset_m, elevation: edge.elevation_m + depth }; + const outward = Math.sign(edge.offset_m - other.offset_m) || 1; + const next = (design.design_line ?? []) + .filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6) + .sort((a, b) => (a.offset_m - b.offset_m) * outward)[0]; + const cut = edge.offset_m === design.tilt_reach?.offset_m; // 늘인 끝 = 원지반(294-3 · 절토) + if (cut || !next || next.elevation_m >= edge.elevation_m - 1e-6 || span < 1e-9) return [top]; + // 안쪽 d m 에서 성토선 = edge + fill·d · 월류선 = edge + depth + road·d → d = depth / (fill − road) + const fill = (edge.elevation_m - next.elevation_m) / Math.abs(next.offset_m - edge.offset_m); + const road = (other.elevation_m - edge.elevation_m) / span; + if (fill <= road + 1e-9) return [top]; + const d = Math.min(depth / (fill - road), span); + const meet = { offset: edge.offset_m - outward * d, elevation: edge.elevation_m + fill * d }; + return [{ offset: edge.offset_m, elevation: edge.elevation_m }, meet]; + }; + return [...side(left, right), ...side(right, left).reverse()]; +} + /** 원지반 꺾은선의 그 오프셋 표고(밖은 끝값). */ function groundZ(ground: readonly OffsetPoint[], offset: number): number | null { if (!ground.length) return null; @@ -193,16 +220,16 @@ export function outletFillLine( /** * 물넘이 도수로 · 돌붙임 선(286-3 · 294-4) — 낮은 쪽 끝단에서 성토사면 따라 · 길이 비움 = 원지반 닿으면 끝 · - * 길이 넣음 = 그 길이(사면보다 길면 원지반 따라 이어) · 양측 절토(늘인 노면 끝이 원지반) = 길이 없으면 안 그림. - * 횡단 띠 · 3D 띠가 같이 씀(그림 전용). + * 길이 넣음 = 그 길이(사면보다 길면 원지반 따라 이어 · 315-1 이은 조각까지 한 선) · 양측 절토(늘인 노면 끝이 + * 원지반) = 길이 없으면 안 그림. 횡단 띠 · 3D 띠가 같이 씀(그림 전용). */ export function fordChuteLine( design: CrossDesign, chute?: { length_m?: number }, ground?: readonly OffsetPoint[], ): OffsetPoint[] { - return ( - chuteRun([{ points: outletFillLine(design, ground) }], chute?.length_m, ground)[0]?.points ?? [] + return chuteRun([{ points: outletFillLine(design, ground) }], chute?.length_m, ground).flatMap( + (run, i) => (i ? run.points.slice(1) : run.points), ); } diff --git a/resources/tester/helper_315_ford_chute_overflow.cjs b/resources/tester/helper_315_ford_chute_overflow.cjs new file mode 100644 index 000000000..f32a65852 --- /dev/null +++ b/resources/tester/helper_315_ford_chute_overflow.cjs @@ -0,0 +1,83 @@ +/* 315 물넘이포장 도수로 · 돌붙임 길이(성토) · 월류 높이선 성토 쪽 연장 헬퍼 — 진짜 TS 를 Node 로 돌려 JSON 한 벌로. */ +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 = []; + this.dataset = {}; + this.style = {}; + } + 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 pave = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts")); +const hatch = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts")); +const fillGround = hatch.groundLine(input.fillSamples); +const dipGround = hatch.groundLine(input.dipSamples); +const chute = (design, ground, length) => + pave.fordChuteLine(design, length ? { length_m: length } : {}, ground); +// 그린 SVG — 띠(돌붙임) · 월류 높이선 점 수 +const svg = new Node("g"); +pave.appendFordPavementOverlay( + svg, + { + span_m: 8, + depth_m: 0.3, + slope_pct: 5, + chute: { structure: "돌붙임", thickness_m: 0.3, length_m: 6 }, + }, + input.fillDesign, + (o) => o, + (e) => e, + fillGround, +); +const deck = svg.children.find((k) => k.attrs.class === "b06-chart__ford-deck-plan"); +const band = svg.children.find((k) => k.attrs.class === "b06-chart__culvert-pitching"); + +const out = { + fillNone: chute(input.fillDesign, fillGround), + fillShort: chute(input.fillDesign, fillGround, 1), + fillLong: chute(input.fillDesign, fillGround, 6), + dipNone: chute(input.dipDesign, dipGround), + dipLong: chute(input.dipDesign, dipGround, 2), + fillOverflow: pave.overflowLine(input.fillDesign, 0.3), + dipOverflow: pave.overflowLine(input.dipDesign, 0.3), + fillEdges: pave.fordEdges(input.fillDesign), + dipEdges: pave.fordEdges(input.dipDesign), + deckPoints: deck?.attrs.points ?? null, + bandPoints: band?.attrs.points ?? null, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py index 0762b3afe..326a53977 100644 --- a/resources/tester/test_287_ford_pavement_followup.py +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -75,8 +75,10 @@ def test_포장층_노견까지_점선은_노면_위(ts): # 포장층 하나 = 노견 끝(±3)까지 · 윗면 = 설계 노면 · 두께 0.2 assert len(ts["pavePoints"]) == 1 assert _pts(ts["pavePoints"][0]) == pytest.approx([3, 100.15, -3, 99.85, -3, 99.65, 3, 99.95]) - # 월류 높이선 = 노면 + 0.4 · 같은 각도 - assert _pts(ts["planPoints"][0]) == pytest.approx([3, 100.55, -3, 100.25]) + # 월류 높이선 = 노면 + 0.4 · 같은 각도 · 우(성토) = 성토선 연장과 만난 자리 → 노면 끝(315-2) + d = 0.4 / (2.65 / 4 - 0.3 / 6) + meet = [-3 + d, 99.85 + 2.65 / 4 * d] + assert _pts(ts["planPoints"][0]) == pytest.approx([3, 100.55, *meet, -3, 99.85]) assert ts["inflowLeft"] is True # 노면 높은 쪽 = 유입 · 도수로 띠 = 낮은 쪽 diff --git a/resources/tester/test_315_ford_chute_overflow.py b/resources/tester/test_315_ford_chute_overflow.py new file mode 100644 index 000000000..9dea00363 --- /dev/null +++ b/resources/tester/test_315_ford_chute_overflow.py @@ -0,0 +1,161 @@ +"""315 물넘이포장 횡단 — 도수로 · 돌붙임 길이(성토) · 월류 높이선 성토 쪽 연장(요구 211-1). + +못박는 것 + - 315-1 길이 없음 = 성토는 원지반까지(그대로) · 길이 > 성토 사면 = 사면 끝에서 원지반 따라 이어 그 길이 + (이은 조각까지 한 선 · 옛 흠 = 첫 조각만 그림) · 길이 < 사면 = 사면 따라 그 길이 · 절토(늘인 노면 끝이 + 원지반) = 그대로(길이 없음 = 안 그림 · 길이 = 원지반 따라). + - 315-2 월류 높이선 — 절토 쪽 = 노면 끝 위 월류 높이(그대로) · 성토 쪽 = 성토 사면선을 노면 끝 너머 위 · + 안쪽으로 점선 연장해 월류 높이선과 만나는 자리에서 끝(같은 점선 양식 한 벌). + +진짜 TS 는 `helper_315_ford_chute_overflow.cjs` 가 Node 로 돌림. 화면은 사용자 확인. +""" + +from __future__ import annotations + +import json +import math +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)) +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 +from test_b06_cross_design_mirror import _dip_ground # noqa: E402 + +KW = { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "paved": True, + "tilted": True, + "tilt_pct": 5.0, + "standard": STANDARD_CROSS_SECTION, +} +DEPTH = 0.3 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _fill_samples() -> list[dict]: + """좌 = 절토(원지반이 노면 위) · 우(낮은 쪽) = 성토 — 원지반 100 + 0.35·o.""" + return [ + {"offset_m": o / 2, "elevation_m": round(100.0 + 0.35 * o / 2, 4), "valid": True} + for o in range(-40, 41) + ] + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s315") / "in.json" + payload = { + "fillSamples": _fill_samples(), + "fillDesign": compute_cross_design(_fill_samples(), 100.4, **KW), + "dipSamples": _dip_ground(), + "dipDesign": compute_cross_design(_dip_ground(), 100.4, **KW), + } + source.write_text(json.dumps(payload), encoding="utf-8") + proc = subprocess.run( + ["node", str(HERE / "helper_315_ford_chute_overflow.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 _length(line: list[dict]) -> float: + return sum( + math.hypot(b["offset"] - a["offset"], b["elevation"] - a["elevation"]) + for a, b in zip(line, line[1:]) + ) + + +def _on_ground(p: dict) -> bool: + return p["elevation"] == pytest.approx(100.0 + 0.35 * p["offset"], abs=1e-3) + + +def test_길이_없음_성토는_원지반까지(ts): + line = ts["fillNone"] + assert line[0]["offset"] == pytest.approx(-2.0) # 낮은 쪽(우) 노견 끝 + assert _on_ground(line[-1]) and not _on_ground(line[0]) + assert all(b["elevation"] < a["elevation"] for a, b in zip(line, line[1:])) + + +def test_성토보다_긴_길이는_원지반_따라_이어_그_길이(ts): + slope = _length(ts["fillNone"]) + line = ts["fillLong"] + assert slope < 6 + assert _length(line) == pytest.approx(6.0, abs=1e-6) # 옛 흠 = 사면 길이에서 멈춤 + toe = ts["fillNone"][-1] + beyond = [p for p in line if p["offset"] < toe["offset"] - 1e-6] + assert beyond and all(_on_ground(p) for p in beyond) # 사면 끝 너머 = 원지반 위 + assert line[: len(ts["fillNone"])] == ts["fillNone"] # 앞은 사면 그대로 · 한 선 + + +def test_짧은_길이는_사면_따라_그_길이(ts): + line = ts["fillShort"] + assert _length(line) == pytest.approx(1.0, abs=1e-6) + assert not any(_on_ground(p) for p in line) + + +def test_절토_쪽_그대로(ts): + assert ts["dipNone"] == [] # 늘인 노면 끝이 원지반 · 길이 없음 = 안 그림 + line = ts["dipLong"] # 길이 = 늘인 끝에서 원지반 따라 그 길이 + assert line[0]["offset"] == pytest.approx(ts["dipEdges"]["right"]["offset_m"], abs=1e-6) + assert _length(line) == pytest.approx(2.0, abs=1e-6) + + +def test_그린_띠도_긴_길이(ts): + nums = [float(v) for pair in ts["bandPoints"].split() for v in pair.split(",")] + offsets = nums[0::2] + assert min(offsets) < ts["fillNone"][-1]["offset"] - 1 # 띠가 사면 끝 너머 원지반까지 + + +def test_월류선_절토_쪽은_노면_끝_위(ts): + edges = ts["fillEdges"] + first = ts["fillOverflow"][0] # 좌 = 절토 + assert first["offset"] == pytest.approx(edges["left"]["offset_m"]) + assert first["elevation"] == pytest.approx(edges["left"]["elevation_m"] + DEPTH) + dip = ts["dipOverflow"] # 양측 절토 = 두 점 그대로 + assert len(dip) == 2 + assert dip[-1]["offset"] == pytest.approx(ts["dipEdges"]["right"]["offset_m"]) + assert dip[-1]["elevation"] == pytest.approx(ts["dipEdges"]["right"]["elevation_m"] + DEPTH) + + +def test_월류선_성토_쪽은_성토선_점선_연장과_만남(ts): + edges = ts["fillEdges"] + left, right = edges["left"], edges["right"] + *_, meet, end = ts["fillOverflow"] + # 끝 = 우 노면 끝(성토 사면 시작) · 그 앞 교점 = 노면 안쪽 + assert end == pytest.approx({"offset": right["offset_m"], "elevation": right["elevation_m"]}) + assert right["offset_m"] < meet["offset"] < left["offset_m"] + # 교점은 월류 높이선 위 + road = (left["elevation_m"] - right["elevation_m"]) / (left["offset_m"] - right["offset_m"]) + over = right["elevation_m"] + DEPTH + road * (meet["offset"] - right["offset_m"]) + assert meet["elevation"] == pytest.approx(over, abs=1e-9) + # 교점 → 노면 끝 = 성토 사면과 같은 물매(사면선의 연장) + slope = ts["fillNone"] + fill = (slope[0]["elevation"] - slope[1]["elevation"]) / ( + slope[0]["offset"] - slope[1]["offset"] + ) + ext = (meet["elevation"] - end["elevation"]) / (meet["offset"] - end["offset"]) + assert ext == pytest.approx(fill, abs=1e-9) + # 그린 점선 = 같은 점(같은 양식 한 벌) + assert ts["deckPoints"].split()[-2:] == [ + f"{meet['offset']},{meet['elevation']}", + f"{end['offset']},{end['elevation']}", + ]