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:
@@ -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] : [];
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user