diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 83c18eafd..00de70541 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -48,6 +48,7 @@ import { import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment"; import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard"; import { chuteBands, fordPavementChute } from "./B05_Profile_UI_Corridor_Structures_Chute"; +import { isChuteSegment } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -588,8 +589,8 @@ export function buildCorridorStructures( wall.role, ); } - // 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 유출 벽 길이(135-3). - chuteBands(layout.outletFill.segments, section.culvert?.chute).forEach((band) => + // 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3). + chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment).forEach((band) => pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"), ); // 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다). diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts index fdf929128..20cbd37d8 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts @@ -19,14 +19,16 @@ interface FillSegment { points: OffsetPoint[]; } -/** 유출 성토 조각들 → 띠 단면 폴리곤들. 상자를 안 골랐거나 두께가 없으면 빈 목록. */ +/** 유출 성토 조각들 → 띠 단면 폴리곤들. 상자를 안 골랐거나 두께가 없으면 빈 목록. + * `keep` = 띠가 서는 조각 — 기본 성토만(박스 · 세월교 본체 끝) · 관 · 흙막이는 `isChuteSegment`(152-3). */ export function chuteBands( segments: ReadonlyArray, chute: ChuteSpec | null | undefined, + keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut", ): OffsetPoint[][] { if (!chute) return []; return segments.flatMap((segment) => { - if (segment.kind === "cut") return []; + if (!keep(segment)) return []; const band = chuteBandOutline(segment.points, chute.thickness_m); return band ? [band.outline] : []; }); diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index fd5820f08..d9e4ff93a 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -162,7 +162,7 @@ export function appendBoxOverlay( layer, layout.sides .filter((side) => side.wingRole === "outlet") - .flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)), + .flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut")), box.chute, x, toDisplayY, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 45a79ad0c..a8fedd0e0 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -22,7 +22,11 @@ import type { WallLayout, } from "./B06_Section_UI_Cross_Culvert_Geom"; import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation"; -import { appendChuteBand, appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch"; +import { + appendChuteBand, + appendWallHatch, + isChuteSegment, +} from "./B06_Section_UI_Cross_Wall_Hatch"; // 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가 // 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다. @@ -110,11 +114,12 @@ export function appendCulvertOverlay( : `${name} 절토선 — 3도 선이 원지반을 못 만나 절토 경사로 원지반까지`; }; const drawOutletFill = (): void => { - // 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 연동 카드엔 안 그림 + // 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) + // · 연동 카드엔 안 그림 if (culvert.chute && !linked) appendChuteBand( layer, - layout.outletFill.segments.filter((s) => s.kind !== "cut").map((s) => s.points), + layout.outletFill.segments.filter(isChuteSegment), culvert.chute, x, toDisplayY, diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index fc9f3324c..f5577ca81 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -136,7 +136,7 @@ export function appendFordOverlay( layer, layout.sides .filter((side) => side.role === "outlet") - .flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)), + .flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut")), ford.chute, x, toDisplayY, diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 6dc7ab614..f833f5624 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -182,7 +182,7 @@ export function appendFordPavementOverlay( "b06-chart__ford-deck", ), ); - if (spec.chute) appendChuteBand(svg, [outletFillLine(design)], spec.chute, x, y); + if (spec.chute) appendChuteBand(svg, [{ points: outletFillLine(design) }], spec.chute, x, y); return true; } diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts index 2be9fbf51..2a982696c 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts @@ -349,14 +349,23 @@ export function chuteBandOutline( return { top, normals, outline: [...top, ...bottom.reverse()] }; } +/** 띠가 서는 유출 조각(152-3) — 성토 조각 + 내림 · 수평 절토 조각(3도 절토선 · 끝 ≤ 시작). + * 3도 선이 못 만나 복합경사로 오르는 절토 조각은 뺌. B06 관 · 흙막이 띠와 3D 띠가 같은 거름. */ +export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }): boolean { + if (segment.kind !== "cut") return true; + const { points } = segment; + return points.length > 1 && points[points.length - 1].elevation <= points[0].elevation + 1e-6; +} + /** * 도수로 · 돌붙임 띠(90-2) — 유출 성토사면 선(윗면 = 사면선)을 사면 안쪽(아래)으로 두께만큼 오프셋한 * 띠 · 안에 돌(흙막이 돌과 같은 그리기 · 두께 인자). 선마다 한 띠 · 그림만(면적 · 터파기 무관). * 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 오버레이가 부름(B07 은 오버레이를 따라옴). + * 절토 조각(`kind: "cut"` · 152-3)은 3도 절토선 따라. */ export function appendChuteBand( layer: SVGElement, - lines: OffsetPoint[][], + lines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>, chute: { structure: string; thickness_m: number }, x: (offset: number) => number, toDisplayY: (elevation: number) => number, @@ -365,15 +374,16 @@ export function appendChuteBand( if (!(thickness > 0)) return; const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)]; - for (const raw of lines) { - const shape = chuteBandOutline(raw, thickness); + for (const line of lines) { + const shape = chuteBandOutline(line.points, thickness); if (!shape) continue; const { top, normals, outline } = shape; const band = document.createElementNS(SVG_NS, "polygon"); band.setAttribute("points", outline.map((p) => px(p).join(",")).join(" ")); band.setAttribute("class", "b06-chart__culvert-pitching"); const title = document.createElementNS(SVG_NS, "title"); - title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · 성토사면 따라 원지반까지`; + const along = line.kind === "cut" ? "3도 절토선" : "성토사면"; + title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 원지반까지`; band.append(title); clipSeq += 1; const clipId = `b06-chute-clip-${clipSeq}`; diff --git a/resources/tester/helper_135_3_chute_solid.cjs b/resources/tester/helper_135_3_chute_solid.cjs index d55aba449..befdea96a 100644 --- a/resources/tester/helper_135_3_chute_solid.cjs +++ b/resources/tester/helper_135_3_chute_solid.cjs @@ -49,7 +49,7 @@ const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); const { buildCorridorStructures } = loadTs(B05("B05_Profile_UI_Corridor_Structures")); const { structureHashParts } = loadTs(B05("B05_Profile_UI_Corridor_Structures_Hash")); const { structureTopLoops } = loadTs(B05("B05_Profile_UI_Corridor_Tin_Design")); -const { chuteBandOutline } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch")); +const { chuteBandOutline, isChuteSegment } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch")); const { computeBoxLayout } = loadTs(B06("B06_Section_UI_Cross_Box_Geom")); const { computeFordLayout } = loadTs(B06("B06_Section_UI_Cross_Ford")); const { outletFillLine } = loadTs(B06("B06_Section_UI_Cross_Ford_Pavement")); @@ -181,7 +181,7 @@ function check(name, section, expectedBands, expectedLength, strip) { const { culvertLayoutOf } = loadTs(B05("B05_Profile_UI_Corridor_Structures")); const layout = culvertLayoutOf(s); const bands = (layout?.outletFill.segments ?? []) - .filter((g) => g.kind !== "cut") + .filter(isChuteSegment) // 묻힌 끝 3도 절토 조각도(152-3) .map((g) => chuteBandOutline(g.points, 0.3)?.outline) .filter(Boolean); const spec = s.culvert.outlet; diff --git a/resources/tester/helper_152_3_chute_cut.cjs b/resources/tester/helper_152_3_chute_cut.cjs new file mode 100644 index 000000000..9bbaf3331 --- /dev/null +++ b/resources/tester/helper_152_3_chute_cut.cjs @@ -0,0 +1,112 @@ +/* 152-3 도수로 띠 절토 조각 헬퍼 — 묻힌 유출 끝 3도 절토선에도 띠 · 오르막 복합경사 절토엔 띠 없음. + * 화면 TS(`_Cross_Wall_Hatch` 거름 · 단면 · B05 3D `chuteBands`)를 그 자리에서 돌림 · 짝 = `test_152_3_chute_cut.py`. + * 입력 = 139-4 와 같은 픽스처(박스 352.14 · 배관 85.052 원지반 고친 사본). + */ +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 STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } }; + +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, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts")); +const { isChuteSegment, chuteBandOutline } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch")); +const { chuteBands } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures_Chute.ts"), +); +const fixture = (name) => + JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", name), "utf8")).cross_sections; +const clone = (v) => JSON.parse(JSON.stringify(v)); +const r3 = (v) => Math.round(v * 1000) / 1000; +const CHUTE = { structure: "돌붙임", thickness_m: 0.3 }; + +/** 유출 조각마다 — 종류 · 오르막 · 띠 섬 · 띠 윗면이 그 선 · 아랫면이 선 아래(두께). */ +function study(section, list) { + const culvert = computeStoredLayouts(section, list).culvert; + const segments = culvert.outletFill.segments; + const kept = segments.filter(isChuteSegment); + return { + segments: segments.map((g) => { + const [a, b] = [g.points[0], g.points[g.points.length - 1]]; + const band = isChuteSegment(g) ? chuteBandOutline(g.points, CHUTE.thickness_m) : null; + const top = band?.top ?? []; + const bottom = band ? band.outline.slice(top.length) : []; + return { + kind: g.kind ?? "fill", + rises: b.elevation > a.elevation + 1e-6, + band: !!band, + topIsLine: !!band && top.length === g.points.length, + below: band + ? r3( + Math.min(...top.map((p, i) => p.elevation - bottom[bottom.length - 1 - i].elevation)), + ) + : null, + }; + }), + // 3D 띠(관 · 흙막이 = isChuteSegment) · 기본 거름(박스 · 세월교 본체 끝 = 성토만) + bands3d: chuteBands(segments, CHUTE, isChuteSegment).length, + bands3dFillOnly: chuteBands(segments, CHUTE).length, + kept: kept.length, + }; +} + +const out = {}; +const bodies = fixture("b06_73_4_body_guard_sections.json"); +out.box = study( + bodies.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6), + bodies, +); + +const pipes = fixture("b07_cross_overlay_sections.json"); +const pipe = pipes.find((s) => s.culvert && !s.culvert.hidden_pipe); +const base = computeStoredLayouts(pipe, pipes).culvert; +const tail = base.outletFill.segments[base.outletFill.segments.length - 1]; +const start = tail.points[0]; +const outward = Math.sign(tail.points[1].offset - start.offset) || 1; +function reshaped(rise, slope) { + const s = clone(pipe); + for (const p of s.samples) { + const t = ((p.offset_m ?? 0) - start.offset) * outward; + if (t >= 0 && typeof p.elevation_m === "number") + p.elevation_m = start.elevation + rise + slope * t; + } + return study( + s, + pipes.map((x) => (x === pipe ? s : x)), + ); +} +out.pipeBase = study(pipe, pipes); +out.pipeDown = reshaped(0.3, -0.15); // 3도 선이 원지반을 만남 +out.pipeUp = reshaped(0.3, 0.1); // 오르막 → 복합경사 절토 +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_90_chute_band.cjs b/resources/tester/helper_90_chute_band.cjs index 13708c8f2..d61db73a6 100644 --- a/resources/tester/helper_90_chute_band.cjs +++ b/resources/tester/helper_90_chute_band.cjs @@ -49,7 +49,7 @@ const run = (line) => { const layer = new Node("g"); appendChuteBand( layer, - [line], + [{ points: line }], { structure: "돌붙임", thickness_m: 0.3 }, (o) => o * SCALE, (e) => -e * SCALE, diff --git a/resources/tester/test_152_3_chute_cut.py b/resources/tester/test_152_3_chute_cut.py new file mode 100644 index 000000000..01421f9d3 --- /dev/null +++ b/resources/tester/test_152_3_chute_cut.py @@ -0,0 +1,65 @@ +"""152-3 도수로 · 돌붙임 띠 — 묻힌 유출 끝 3도 절토선(내림 · 수평)에도 띠 · 오르막 복합경사 절토엔 없음. + +화면 TS 를 그 자리에서 돌린 값(`helper_152_3_chute_cut.cjs`) · B06 관 · 흙막이 띠와 3D 띠가 같은 거름. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_152_3_chute_cut.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def _src(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_묻힌_흙막이_유출_3도_선에_띠(out: dict) -> None: + (cut,) = out["box"]["segments"] + assert cut["kind"] == "cut" and not cut["rises"] + assert cut["band"] and cut["topIsLine"] # 윗면 = 3도 선 + assert cut["below"] == pytest.approx(0.3, abs=0.01) # 선 아래 두께 + assert out["box"]["bands3d"] == 1 and out["box"]["bands3dFillOnly"] == 0 + + +def test_배관_묻힌_끝_내림이면_띠_오르막이면_없음(out: dict) -> None: + down = out["pipeDown"]["segments"] + assert [s["kind"] for s in down] == ["fill", "cut"] + assert all(s["band"] for s in down) + up = out["pipeUp"]["segments"] + assert up[-1]["kind"] == "cut" and up[-1]["rises"] and not up[-1]["band"] + assert out["pipeDown"]["bands3d"] == 2 and out["pipeUp"]["bands3d"] == 1 + + +def test_성토만인_측점은_그대로(out: dict) -> None: + base = out["pipeBase"] + assert base["bands3d"] == base["bands3dFillOnly"] == 2 + + +def test_거름_한_벌_배선() -> None: + culvert = _src("B06_Section/B06_Section_UI_Cross_Culvert.ts") + assert "layout.outletFill.segments.filter(isChuteSegment)," in culvert + corridor = _src("B05_Profile/B05_Profile_UI_Corridor_Structures.ts") + assert ( + "chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment)" in corridor + ) + hatch = _src("B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts") + assert '"3도 절토선"' in hatch + # 박스 · 세월교 본체 끝은 성토만(3도 규칙 안 넓힘) + for name in ("B06_Section_UI_Cross_Box.ts", "B06_Section_UI_Cross_Ford.ts"): + assert 'side.fillSegments.filter((s) => s.kind !== "cut")' in _src(f"B06_Section/{name}")