diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index ee225258d..278c79a05 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -226,26 +226,29 @@ def _side_spec( return spec -def _chute(values: dict[str, Any]) -> dict[str, Any] | None: - """도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) · - 구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림. TS 짝 `chute`.""" - head = re.compile(r"^chute_add_(\d+)_structure$") +def _chute(values: dict[str, Any], head: str = "chute_add") -> dict[str, Any] | None: + """도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 · + 두께(cm → m · 빈 값 30 cm) · 구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림. + TS 짝 `chute`.""" + pattern = re.compile(rf"^{head}_(\d+)_structure$") picked = sorted( (int(hit.group(1)), str(value).strip()) for key, value in values.items() - if (hit := head.match(key)) and str(value or "").strip() + if (hit := pattern.match(key)) and str(value or "").strip() ) if not picked: return None n, structure = picked[0] - cm = _number(values.get(f"chute_add_{n}_thickness"), None) + cm = _number(values.get(f"{head}_{n}_thickness"), None) cm = cm if cm and cm > 0 else CHUTE_THICKNESS_CM return {"structure": structure, "thickness_m": round(cm / 100.0, 3)} -def _with_chute(spec: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]: +def _with_chute( + spec: dict[str, Any], values: dict[str, Any], head: str = "chute_add" +) -> dict[str, Any]: """도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2).""" - chute = _chute(values) + chute = _chute(values, head) if chute: spec["chute"] = chute return spec @@ -502,16 +505,20 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: used = degree if degree is not None else fallback slope = round(math.tan(math.radians(used)) * 100.0, 4) if used is not None else legacy span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M - return { - "type": "ford_pavement", - "span_m": span, - # 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). - "depth_m": depth if depth and depth > 0 else None, - "slope_pct": slope, - "defaulted": _defaulted( - values, [("ford_width_m", "월류 폭", span), ("ford_slope_deg", "경사각", fallback)] - ), - } + return _with_chute( + { + "type": "ford_pavement", + "span_m": span, + # 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). + "depth_m": depth if depth and depth > 0 else None, + "slope_pct": slope, + "defaulted": _defaulted( + values, [("ford_width_m", "월류 폭", span), ("ford_slope_deg", "경사각", fallback)] + ), + }, + values, + "ford_chute", + ) def _box_set(options: dict[str, Any] | None) -> dict[str, Any]: diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 6f5e86a39..6dc7ab614 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -15,6 +15,7 @@ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; const SVG_NS = "http://www.w3.org/2000/svg"; /** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */ @@ -31,6 +32,8 @@ export interface FordPavementSpec { slope_pct: number | null; /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ defaulted?: string[]; + /** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠. */ + chute?: { structure: string; thickness_m: number }; } interface Edge { @@ -179,9 +182,34 @@ export function appendFordPavementOverlay( "b06-chart__ford-deck", ), ); + if (spec.chute) appendChuteBand(svg, [outletFillLine(design)], spec.chute, x, y); return true; } +/** + * 유출측(`inflowSign` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라 + * 내려가는 동안만(오르면 절토라 멈춤). 성토가 없으면 빈 선(띠 안 그림). + */ +export function outletFillLine(design: CrossDesign): OffsetPoint[] { + const edges = design.road_edges; + if (!edges || !design.design_line?.length) return []; + const outward = design.section_mode !== "right_cut" ? -1 : 1; // +offset = 좌측 · 유입 반대 + const edge = + outward > 0 + ? Math.max(edges.left.offset_m, edges.right.offset_m) + : Math.min(edges.left.offset_m, edges.right.offset_m); + const points = design.design_line + .filter((p) => (p.offset_m - edge) * outward >= -1e-6) + .sort((a, b) => (a.offset_m - b.offset_m) * outward); + const line: OffsetPoint[] = []; + for (const p of points) { + const last = line[line.length - 1]; + if (last && p.elevation_m > last.elevation + 1e-6) break; + line.push({ offset: p.offset_m, elevation: p.elevation_m }); + } + return line; +} + /** * 물넘이 자리 노면 · 측구 설계선을 걷는다(66-2 · 66-3) — 노면은 파인 바닥(실선)과 옛 계획고(점선)가 * 대신하고, 측구는 서지 않음. `from` = 설계선 그리기 앞의 자식 수(그 뒤에 붙은 선만 본다). diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 90a9243c5..48b66ddc8 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -6,7 +6,7 @@ * 캐시(`section.culvert`·조정창 제어기)만 고친다 — 영구저장은 [저장]·[확정] 몫이다. * ========================================================================== */ -import { guardSet, pipeDiameterM } from "@util/common_util_culvert_sets"; +import { chute, guardSet, pipeDiameterM } from "@util/common_util_culvert_sets"; import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; @@ -51,6 +51,7 @@ export function applyPipeOptionsToCache( patch: Record, whole = true, ): void { + syncChute(ctx, chainageM, patch, whole); // 물넘이포장 — 월류 높이 · 경사각을 캐시 제원에 얹음(66-2 · 66-3) · 폭 절반 걸친 옆 카드도 const paved = (ctx.detail()?.cross_sections ?? []).filter( (section) => @@ -163,6 +164,36 @@ export function applyPipeOptionsToCache( } } +/** + * 도수로 · 돌붙임 상자 → 그 관 지점 제원(관 · BOX · 세월교 · 흙막이 · 물넘이 포장)의 `chute` 갈아 끼움 + * (136-1) · 바뀐 카드만 다시 그림. 일부만 온 되얹기는 상자가 있을 때만 얹음(없음 = 모름). + */ +function syncChute( + ctx: PipeOptionsContext, + chainageM: number, + patch: Record, + whole: boolean, +): void { + const found = { chute: chute(patch), pave: chute(patch, "ford_chute") }; + for (const section of ctx.detail()?.cross_sections ?? []) { + let changed = false; + for (const [spec, next] of [ + [section.culvert, found.chute], + [section.box, found.chute], + [section.ford, found.chute], + [section.ford_pavement, found.pave], + ] as const) { + if (!spec || (!whole && !next)) continue; + if (Math.abs((spec.chainage_m ?? section.chainage_m) - chainageM) >= PIPE_MATCH_M) continue; + if (JSON.stringify(spec.chute ?? null) === JSON.stringify(next)) continue; + if (next) spec.chute = { ...next }; + else delete spec.chute; + changed = true; + } + if (changed) ctx.refreshCard(section.chainage_m); + } +} + /** 세월교 · 박스 흙막이 세트를 폼 칸 전부로 다시 지음(73-4) — 흙막이가 없으면 걷음. */ function syncBodyGuard( owner: CrossSection, diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 95e78bd6a..8a97e8c2e 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -240,6 +240,10 @@ def _revets( ) +# 도수로/돌붙임 잇기 → 옵션 머리 — 물넘이 포장(`ford_chute` · 136-2)도 같은 열 +CHUTE_HEADS = {"gchuteadd": "chute_add", "gfordpavechute": "ford_chute"} + + def _chutes( sheet: _Sheet, at: float, @@ -249,9 +253,10 @@ def _chutes( ) -> None: """돌붙임 — 붙임 형식 · 면적이 다 있을 때만(설계자가 넣음).""" for link, combo in links.items(): - if not link.startswith("gchuteadd"): + tag = next((t for t in CHUTE_HEADS if link.startswith(t)), None) + if not tag: continue - head = f"chute_add_{link[len('gchuteadd') :]}_" + head = f"{CHUTE_HEADS[tag]}_{link[len(tag) :]}_" bond = str(options.get(f"{head}bond") or "") area = _num(options.get(f"{head}area")) if options.get(f"{head}structure") != "돌붙임" or not bond or not area: @@ -449,6 +454,7 @@ def _drain( combos = {part: links.get(link) or _combo(group.get(link)) for part, link in required.items()} if type_id == "ford_pavement": _pavement(sheet, at, base, combos, names) + _chutes(sheet, at, options, links, pipe_names) return if type_id == "box_culvert": empty_height = _box(sheet, at, base, combos, names, box_length) diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 8c7fef06b..64e167b35 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -126,27 +126,33 @@ function sideSpec( return spec; } -/** 도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) · 구조를 안 - * 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */ +/** 도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 · 두께 + * (cm → m · 빈 값 30 cm) · 구조를 안 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */ export function chute( values: Record, + head = "chute_add", ): { structure: string; thickness_m: number } | null { + const pattern = new RegExp(`^${head}_(\\d+)_structure$`); const picked = Object.entries(values) .flatMap(([key, value]) => { - const hit = /^chute_add_(\d+)_structure$/.exec(key); + const hit = pattern.exec(key); const structure = String(value ?? "").trim(); return hit && structure ? [[Number(hit[1]), structure] as const] : []; }) .sort((a, b) => a[0] - b[0]); if (!picked.length) return null; const [n, structure] = picked[0]; - const cm = num(values[`chute_add_${n}_thickness`], null); + const cm = num(values[`${head}_${n}_thickness`], null); return { structure, thickness_m: round((cm && cm > 0 ? cm : CHUTE_THICKNESS_CM) / 100, 3) }; } /** 도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2). */ -function withChute(spec: CulvertSetSpec, values: Record): CulvertSetSpec { - const found = chute(values); +function withChute( + spec: CulvertSetSpec, + values: Record, + head = "chute_add", +): CulvertSetSpec { + const found = chute(values, head); if (found) spec.chute = found; return spec; } @@ -417,17 +423,21 @@ function fordPavementSet( : null; const used = degree ?? fallback; const span = width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M; - return { - type: "ford_pavement", - span_m: span, - // 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). - depth_m: depth && depth > 0 ? depth : null, - slope_pct: used !== null ? round(Math.tan((used * Math.PI) / 180) * 100, 4) : legacy, - defaulted: defaulted(values, [ - ["ford_width_m", "월류 폭", span], - ["ford_slope_deg", "경사각", fallback], - ]), - }; + return withChute( + { + type: "ford_pavement", + span_m: span, + // 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). + depth_m: depth && depth > 0 ? depth : null, + slope_pct: used !== null ? round(Math.tan((used * Math.PI) / 180) * 100, 4) : legacy, + defaulted: defaulted(values, [ + ["ford_width_m", "월류 폭", span], + ["ford_slope_deg", "경사각", fallback], + ]), + }, + values, + "ford_chute", + ); } /** BOX암거 1개소의 세트 제원(구체 + 날개벽 연장). */ diff --git a/resources/tester/helper_136_chute_form_cache.cjs b/resources/tester/helper_136_chute_form_cache.cjs new file mode 100644 index 000000000..c06e7abc0 --- /dev/null +++ b/resources/tester/helper_136_chute_form_cache.cjs @@ -0,0 +1,96 @@ +/* 136-1 폼 → 캐시 chute 헬퍼 — `applyPipeOptionsToCache`(B06_Section_UI_Page_Pipe_Options.ts)를 그 자리에서 + * 트랜스파일해 돌림 · 도수로/돌붙임 상자가 관 · BOX · 세월교 · 물넘이 포장 제원 `chute` 에 바로 얹히는지 · + * 다시 그린 카드. 짝 = `test_136_chute_form_cache.py`. 로더는 `helper_b06_draft_pipe_replay.cjs` 와 같음. + */ +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 session = new Map(); +const fakeStorage = { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; +globalThis.sessionStorage = fakeStorage; +globalThis.localStorage = fakeStorage; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith("@config/")) + return loadTs(path.join(ROOT, "config", `${request.slice(8)}.ts`)); + return request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const options = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts")); + +const box = (at) => ({ + chainage_m: 120, + top_thickness_m: 0.3, + inner_height_m: 2, + slab_thickness_m: 0.3, + wing_in: {}, + wing_out: {}, + _at: at, +}); +const detail = { + cross_sections: [ + { chainage_m: 85, culvert: { chainage_m: 85.05, diameter_m: 1, inlet: {}, outlet: {} } }, + { chainage_m: 118, box: box(118) }, + { chainage_m: 120, box: box(120) }, + { chainage_m: 160, ford: { chainage_m: 160, diameter_m: 1, wing_in: {}, wing_out: {} } }, + { chainage_m: 196, ford_pavement: { chainage_m: 200, span_m: 9, depth_m: 0.3 } }, + { chainage_m: 200, ford_pavement: { chainage_m: 200, span_m: 9, depth_m: 0.3 } }, + ], +}; +let refreshed = []; +const ctx = { + detail: () => detail, + ford: { setPipe: () => {}, setWing: () => {} }, + box: { setBody: () => {}, setWing: () => {} }, + revetOffset: { update: () => {}, adjustFor: () => ({ h: null, m: null }) }, + overrideOptions: () => {}, + refreshCard: (at) => refreshed.push(at), +}; +const at = (i) => detail.cross_sections[i]; +const step = (chainage, patch, whole = true) => { + refreshed = []; + options.applyPipeOptionsToCache(ctx, chainage, patch, whole); + return { + pipe: at(0).culvert.chute ?? null, + box: [at(1).box.chute ?? null, at(2).box.chute ?? null], + ford: at(3).ford.chute ?? null, + pave: [at(4).ford_pavement.chute ?? null, at(5).ford_pavement.chute ?? null], + refreshed: [...new Set(refreshed)].sort((a, b) => a - b), + }; +}; + +const out = { + pipeAdd: step(85.05, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 40 }), + pipeSame: step(85.05, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 40 }), + replayNoChute: step(85.05, { pipe_diameter_mm: 1000 }, false), + pipeRemove: step(85.05, { pipe_diameter_mm: 1000 }), + boxAdd: step(120, { chute_add_1_structure: "도수로" }), + fordAdd: step(160, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 50 }), + paveAdd: step(200, { ford_chute_1_structure: "돌붙임", chute_add_1_structure: "도수로" }), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py index ec364dfaf..fe920b3ac 100644 --- a/resources/tester/test_120_b08_drain_summary.py +++ b/resources/tester/test_120_b08_drain_summary.py @@ -82,7 +82,14 @@ POINTS = [ { "chainage_m": 500.0, "facility": "ford_pavement", - "options": {"ford_width_m": 8, "ford_height_m": 1.0}, + "options": { + "ford_width_m": 8, + "ford_height_m": 1.0, + # 물넘이 포장 도수로/돌붙임(136-2) — 관 · 박스 · 세월교와 같은 열 + "ford_chute_1_structure": "돌붙임", + "ford_chute_1_bond": "찰붙임", + "ford_chute_1_area": 12, + }, }, ] STRUCTURES = [ @@ -177,6 +184,7 @@ def test_물넘이_H별_월류폭(out): pave = _col(out, "pipe:pave|1.0") assert pave["group"] == ["물넘이포장", "월류 폭", "H=1.0"] and pave["label"] == "m" assert _cell(out, 500, "pipe:pave|1.0") == 8 + assert _cell(out, 500, "pipe:chute|찰붙임") == 12 def test_개거_노출형_맹암거_개소(out): diff --git a/resources/tester/test_136_chute_form_cache.py b/resources/tester/test_136_chute_form_cache.py new file mode 100644 index 000000000..775499efd --- /dev/null +++ b/resources/tester/test_136_chute_form_cache.py @@ -0,0 +1,55 @@ +"""136-1 폼 → 캐시 chute — 좌측 패널 도수로/돌붙임 상자가 [저장] 전에 횡단 제원에 바로 얹힘. + +관 · BOX(폭 걸친 측점 둘) · 세월교 · 물넘이 포장(`ford_chute_*`) · 지움 · 일부만 온 되얹기. +화면 TS 를 그 자리에서 돌린 값(`helper_136_chute_form_cache.cjs`). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_136_chute_form_cache.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_관_상자_넣으면_바로_chute_와_카드(out: dict) -> None: + assert out["pipeAdd"]["pipe"] == {"structure": "돌붙임", "thickness_m": 0.4} + assert 85 in out["pipeAdd"]["refreshed"] + # 같은 값이면 chute 때문에 다시 그리진 않음(관 길의 카드 갱신만) + assert out["pipeSame"]["pipe"] == out["pipeAdd"]["pipe"] + + +def test_되얹기는_상자_없으면_그대로_칸_전부면_지움(out: dict) -> None: + assert out["replayNoChute"]["pipe"] == {"structure": "돌붙임", "thickness_m": 0.4} + assert out["pipeRemove"]["pipe"] is None + + +def test_박스는_걸친_측점_모두_세월교도(out: dict) -> None: + assert out["boxAdd"]["box"] == [{"structure": "도수로", "thickness_m": 0.3}] * 2 + assert {118, 120} <= set(out["boxAdd"]["refreshed"]) + assert out["fordAdd"]["ford"] == {"structure": "돌붙임", "thickness_m": 0.5} + assert out["fordAdd"]["refreshed"] == [160] + # 다른 관 지점은 안 건드림 + assert out["fordAdd"]["pipe"] is None and out["fordAdd"]["box"][0] is not None + + +def test_물넘이_포장은_ford_chute_만(out: dict) -> None: + assert out["paveAdd"]["pave"] == [{"structure": "돌붙임", "thickness_m": 0.3}] * 2 + assert out["paveAdd"]["refreshed"] == [196, 200] diff --git a/resources/tester/test_52_3_structure_spec_ford_pavement.py b/resources/tester/test_52_3_structure_spec_ford_pavement.py index e183dd48c..e5a020a1e 100644 --- a/resources/tester/test_52_3_structure_spec_ford_pavement.py +++ b/resources/tester/test_52_3_structure_spec_ford_pavement.py @@ -131,3 +131,70 @@ def test_정의_그룹_경사각도(): ) as f: groups = json.load(f)["그룹"]["ford_pavement"] assert [(g["id"], g["이름"]) for g in groups] == [("gfordpaveslope", "경사각도")] + + +OVERLAY = r""" +const fs = require("fs"), path = require("path"); +const ROOT = process.argv[1]; +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +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 req = (r) => (r.startsWith(".") ? load(path.join(path.dirname(file), `${r}.ts`)) : ({})); + new Function("exports", "module", "require", js)(box.exports, box, req); + 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); } + set textContent(v) { this.text = v; } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; +const pave = load(path.join(ROOT, "B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts")); +// +offset = 좌측 · 좌 성토 1:1.5 · 우 절토 오름 — 유입 = 좌(left_cut 아님) → 유출 = 우 +const design = (mode) => ({ + section_mode: mode, cross_slope_pct: 2, design_elevation_m: 100, pavement_thickness_m: 0.2, + road_edges: { left: { offset_m: 2.5, elevation_m: 100 }, right: { offset_m: -2.5, elevation_m: 100 } }, + design_line: [ + { offset_m: 7, elevation_m: 97 }, { offset_m: 2.5, elevation_m: 100 }, + { offset_m: -2.5, elevation_m: 100 }, { offset_m: -5.5, elevation_m: 98 }, + { offset_m: -6.5, elevation_m: 98 }, { offset_m: -8, elevation_m: 99 }, + ], +}); +const spec = { span_m: 9, depth_m: 0.3, slope_pct: 2, chute: { structure: "돌붙임", thickness_m: 0.3 } }; +const run = (mode, withChute = true) => { + const svg = new Node("g"); + pave.appendFordPavementOverlay(svg, withChute ? spec : { ...spec, chute: undefined }, design(mode), (o) => o, (e) => e); + const band = svg.children.find((k) => k.attrs.class === "b06-chart__culvert-pitching"); + return band ? band.attrs.points : null; +}; +process.stdout.write(JSON.stringify({ + outLeft: pave.outletFillLine(design("left_cut")), outRight: pave.outletFillLine(design("right_cut")), + band: run("left_cut"), none: run("left_cut", false), +})); +""" + + +def test_유출측_성토사면_띠_136_2(): + proc = subprocess.run( + ["node", "-e", OVERLAY, ROOT], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert proc.returncode == 0, proc.stderr + got = json.loads(proc.stdout) + # 유입 = 좌 → 유출 = 우(− 바깥) · 노견에서 내려가는 동안만(평지 · 오름에서 멈춤) + assert got["outLeft"] == [ + {"offset": -2.5, "elevation": 100}, + {"offset": -5.5, "elevation": 98}, + {"offset": -6.5, "elevation": 98}, + ] + # right_cut = 유입 우 → 유출 좌 성토 1:1.5 + assert got["outRight"] == [{"offset": 2.5, "elevation": 100}, {"offset": 7, "elevation": 97}] + assert got["band"] and got["band"].startswith("-2.5,100 -5.5,98") + assert got["none"] is None diff --git a/resources/tester/test_b06_culvert_sets_mirror.py b/resources/tester/test_b06_culvert_sets_mirror.py index 58f858823..c46295fcc 100644 --- a/resources/tester/test_b06_culvert_sets_mirror.py +++ b/resources/tester/test_b06_culvert_sets_mirror.py @@ -100,7 +100,14 @@ POINTS = [ { "chainage_m": 140.0, "facility": "ford_pavement", - "options": {"ford_height_m": 0.3, "ford_slope_deg": 5}, + "options": { + "ford_height_m": 0.3, + "ford_slope_deg": 5, + # 물넘이 포장 도수로/돌붙임(136-2) — `ford_chute_*` · 관 키(`chute_add_*`)는 안 읽음 + "ford_chute_1_structure": "돌붙임", + "ford_chute_1_thickness": 40, + "chute_add_1_structure": "도수로", + }, }, {"chainage_m": 160.0, "facility": "ford_pavement", "options": {"ford_height_m": 0.2}}, ] @@ -213,3 +220,5 @@ def test_ford_box_guard_attached_as_culvert() -> None: assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3} assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45} assert "chute" not in sections[40.0]["culvert"] + assert sections[140.0]["ford_pavement"]["chute"] == {"structure": "돌붙임", "thickness_m": 0.4} + assert "chute" not in sections[160.0]["ford_pavement"]