feat(b06): 도수로 · 돌붙임 띠 = 묻힌 유출 끝 3도 절토 조각까지(152-3)

- 띠 조각 거름 한 벌 isChuteSegment — 성토 + 내림 · 수평 절토 조각 · 오르막 복합경사 절토 뺌
- B06 관 · 흙막이 띠 · B05 3D 띠가 같은 거름 · 박스 · 세월교 본체 끝은 성토만 그대로
- 띠 툴팁 절토 조각 = 「3도 절토선 따라」 · 새 시험 test_152_3_chute_cut

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-09 19:13:04 +09:00
co-authored by Claude Opus 5.5
parent f8a13e234d
commit 1f638cd1c5
11 changed files with 212 additions and 17 deletions
@@ -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 사용자 — 단마다 연장이 다르다).
@@ -19,14 +19,16 @@ interface FillSegment {
points: OffsetPoint[];
}
/** 유출 성토 조각들 → 띠 단면 폴리곤들. 상자를 안 골랐거나 두께가 없으면 빈 목록. */
/** 유출 성토 조각들 → 띠 단면 폴리곤들. 상자를 안 골랐거나 두께가 없으면 빈 목록.
* `keep` = 띠가 서는 조각 — 기본 성토만(박스 · 세월교 본체 끝) · 관 · 흙막이는 `isChuteSegment`(152-3). */
export function chuteBands(
segments: ReadonlyArray<FillSegment>,
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] : [];
});
+1 -1
View File
@@ -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,
+8 -3
View File
@@ -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,
+1 -1
View File
@@ -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,
@@ -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;
}
+14 -4
View File
@@ -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}`;
@@ -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;
+112
View File
@@ -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));
+1 -1
View File
@@ -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,
+65
View File
@@ -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}")