feat(b05): 3D 도수로 · 돌붙임 띠 솔리드 — 유출 성토 조각 스윕 · 길이 = 유출 구조물 폭 · 해시 키 · ③ 구멍 안 뚫림(135-3)

- 단면 = B06 횡단 띠와 한 벌(Wall_Hatch chuteBandOutline 로 떼어 냄 · appendChuteBand 가 같이 씀)
- 관 = 유출 벽 길이 · BOX = 구체 폭(축 고정) · 세월교 = 월류 폭 · 물넘이 포장(ford_chute) = 포장 폭 · 주인 측점 하나
- 구조물 종류 chute · 색 · 끝 마구리 = 마디마다 사다리꼴 · 해시 = 고른 때만 ch 키(안 고른 측점 저장본 그대로)
- ③ structureTopLoops 가 chute 를 뺌
- 새 시험 test_135_3_chute_solid(+ 도우미)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
2026-10-09 15:09:17 +09:00
co-authored by Claude Opus 5.5
parent 5e6787aadd
commit 1c112d3b3c
9 changed files with 495 additions and 33 deletions
+23 -3
View File
@@ -202,6 +202,8 @@ const STRUCTURE_COLORS: Record<CorridorStructure["kind"], number> = {
revet: 0xc9a86a,
basin: 0x8d93a3,
pipe: 0x4f83cc,
// 도수로 · 돌붙임 띠(135-3) — 돌빛 회갈.
chute: 0x8b7d6b,
};
/** 단면 폴리곤(offset/표고)을 스윕 프레임 열에 앉혀 노선을 따라 로프트한 솔리드
@@ -232,9 +234,27 @@ function structureLoftGeometry(
});
const indices: number[] = [];
const lastBase = (ringCount - 1) * count;
for (let i = 1; i < count - 1; i += 1) {
indices.push(0, i, i + 1); // 앞 마구리(부채꼴 — 단면 폴리곤은 볼록 사각형이다)
indices.push(lastBase, lastBase + i + 1, lastBase + i); // 뒷 마구리(반대 감김)
if (structure.kind === "chute") {
// 도수로 · 돌붙임 띠(135-3) = 윗면 n 점 + 아랫면 n 점(거꾸로) — 꺾인 띠는 볼록이 아니라
// 부채꼴이 띠 밖으로 삐져나온다 · 마디마다 사다리꼴로 막는다.
const half = count / 2;
for (let i = 0; i + 1 < half; i += 1) {
const [a, b, c, d] = [i, i + 1, count - 2 - i, count - 1 - i];
indices.push(a, b, c, a, c, d);
indices.push(
lastBase + a,
lastBase + c,
lastBase + b,
lastBase + a,
lastBase + d,
lastBase + c,
);
}
} else {
for (let i = 1; i < count - 1; i += 1) {
indices.push(0, i, i + 1); // 앞 마구리(부채꼴 — 단면 폴리곤은 볼록 사각형이다)
indices.push(lastBase, lastBase + i + 1, lastBase + i); // 뒷 마구리(반대 감김)
}
}
for (let ring = 0; ring < ringCount - 1; ring += 1) {
const a0 = ring * count;
@@ -50,6 +50,7 @@ import {
} from "./B05_Profile_UI_Corridor_Structures_Path";
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";
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
export interface StructureFrame {
@@ -73,7 +74,8 @@ export interface RouteFrame {
/** 구조물 솔리드 하나 — 직렬화 가능한 평문 데이터(코리도 저장본에 그대로 담긴다). */
export interface CorridorStructure {
chainage_m: number;
kind: "revet" | "basin" | "pipe";
/** `chute` = 도수로 · 돌붙임 띠(135-3 · 유출 성토사면 위 · ③ 구멍 안 냄). */
kind: "revet" | "basin" | "pipe" | "chute";
/**
* 부재키 — B06이 쓰는 이름 그대로(`inlet`·`outlet`·`extra{i}`·`bextra{i}`·`own`·
* `basin`·`pipe`). 3D에서 부재 하나를 고르고 그 선택을 B06 카드·조정창으로 넘기는
@@ -283,7 +285,8 @@ export function buildCorridorStructures(
const revetLayout = section.culvert
? null
: computeRevetmentLayout(section, section.design?.revet_adjust?.own);
if (!layout && !fordLayout && !section.box && !revetLayout) continue;
const paveChute = fordPavementChute(section, crossSections);
if (!layout && !fordLayout && !section.box && !revetLayout && !paveChute) continue;
const chainage = section.chainage_m;
const stationFrame: StructureFrame = {
cx: section.center_x,
@@ -321,7 +324,7 @@ export function buildCorridorStructures(
// ⚠ `ringsAt` 는 `pushSwept` 보다 **먼저** 선언한다 — 아래 기슭막이 단 반복이 `pushSwept` 를 곧바로
// 부르므로 뒤에 두면 ReferenceError(TDZ)로 그 구간 3D 코리도가 통째로 안 만들어진다.
const pushSwept = (
kind: "revet" | "basin",
kind: "revet" | "basin" | "chute",
points: Array<{ offset: number; elevation: number }>,
beforeM: number,
afterM: number,
@@ -447,6 +450,9 @@ export function buildCorridorStructures(
if (section.box) {
solids.push(...boxSolids(section, stationFrame, boxAdjustOf(section)));
}
paveChute?.bands.forEach((band) =>
pushSwept("chute", band, paveChute.halfM, paveChute.halfM, null, "chute"),
);
if (fordLayout) {
// 구체는 월류 폭만큼 도로 방향으로 이어지고 기준 측점 전후로 절반씩 걸친다.
@@ -477,6 +483,11 @@ export function buildCorridorStructures(
}
}
pushSwept("basin", fordLayout.slabBridge, half, half, null, "body");
// 도수로 · 돌붙임 띠 — 유출 측벽 성토 조각 · 날개 뿌리 사이(월류 폭)(135-3).
chuteBands(
fordLayout.sides.filter((side) => side.role === "outlet").flatMap((s) => s.fillSegments),
section.ford?.chute,
).forEach((band) => pushSwept("chute", band, half, half, null, "chute"));
// 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면**
// (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은
// 틀린 자리: 거긴 날개벽 투영이 만든 에이프런 끝이다). 발치는 바닥판 밑면,
@@ -583,6 +594,10 @@ export function buildCorridorStructures(
wall.role,
);
}
// 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 유출 벽 길이(135-3).
chuteBands(layout.outletFill.segments, section.culvert?.chute).forEach((band) =>
pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"),
);
// 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다).
// 값이 없는 단은 고정 기본 10m(5/5)로 선다(`tierSpanOf`).
layout.extraWalls.forEach((wall, i) => {
@@ -16,6 +16,7 @@ import { computeBoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box";
import type { BoxAdjust } from "../B06_Section/B06_Section_UI_Cross_Box";
import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures";
import { buildWingSolids } from "./B05_Profile_UI_Corridor_Structures_Wing";
import { chuteBands } from "./B05_Profile_UI_Corridor_Structures_Chute";
/** 단면 폴리곤 한 장 — [offset, elevation] 목록. */
export type SectionPolygon = Array<[number, number]>;
@@ -176,6 +177,18 @@ export function boxSolids(
chainage + halfSpan,
];
const bodyRings = straightRings(bodyChainages, chainage, stationFrame);
// 도수로 · 돌붙임 띠 — 유출 날개 쪽 성토 조각 · 날개 뿌리 사이(구체 폭) · 구체처럼 축 고정(135-3).
const chuteRings = straightRings([bodyChainages[0], bodyChainages[3]], chainage, stationFrame);
const chutes: CorridorStructure[] = chuteBands(
layout.sides.filter((side) => side.wingRole === "outlet").flatMap((side) => side.fillSegments),
box.chute,
).map((band) => ({
chainage_m: chainage,
kind: "chute",
key: "chute",
polygon: band.map((p) => [p.offset, p.elevation] as [number, number]),
rings: chuteRings,
}));
const solids: CorridorStructure[] = [
// 상판·저판은 구체 폭 전체에 걸친다. 좌·우 표고가 다르면 폴리곤째 기울어 있다.
{
@@ -259,7 +272,7 @@ export function boxSolids(
});
}
solids.push(...wingSolids(section, layout, stationFrame));
solids.push(...wingSolids(section, layout, stationFrame), ...chutes);
return solids;
}
@@ -0,0 +1,54 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Structures_Chute.ts
* 3D **도수로 · 돌붙임 띠** 단면(계획서 135-3) — 순수 기하, Three.js 무의존.
*
* · 단면 = B06 횡단 띠와 같은 계산(`chuteBandOutline`) — 유출 성토 조각(사면선)을 사면 안쪽으로
* 두께만큼 오프셋한 폴리곤. 선(성토 조각)마다 한 장.
* · 스윕 길이 = 유출 구조물 폭 — 관 = 유출 벽 길이 · BOX · 세월교 = 날개 뿌리 사이(본체 폭) ·
* 물넘이 포장 = 포장 폭. 스윕은 `_Structures.ts` 가 벽과 같은 규칙으로 한다.
* ========================================================================== */
import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { OffsetPoint } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { chuteBandOutline } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
import { outletFillLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
/** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */
interface FillSegment {
kind?: string;
points: OffsetPoint[];
}
/** 유출 성토 조각들 → 띠 단면 폴리곤들. 상자를 안 골랐거나 두께가 없으면 빈 목록. */
export function chuteBands(
segments: ReadonlyArray<FillSegment>,
chute: ChuteSpec | null | undefined,
): OffsetPoint[][] {
if (!chute) return [];
return segments.flatMap((segment) => {
if (segment.kind === "cut") return [];
const band = chuteBandOutline(segment.points, chute.thickness_m);
return band ? [band.outline] : [];
});
}
/**
* 물넘이 포장 띠(136-2 `ford_chute`) — 유출측 성토사면 선 하나. 포장이 걸친 옆 측점도 같은 제원을
* 받으므로 **주인 측점**(포장 자리에 가장 가까운 측점) 하나에서만 낸다 · 길이 = 포장 폭.
*/
export function fordPavementChute(
section: CrossSection,
sections: ReadonlyArray<CrossSection>,
): { bands: OffsetPoint[][]; halfM: number } | null {
const pave = section.ford_pavement;
if (!pave?.chute || !section.design) return null;
const target = pave.chainage_m;
if (typeof target === "number") {
const owner = sections.reduce((best, item) =>
Math.abs(item.chainage_m - target) < Math.abs(best.chainage_m - target) ? item : best,
);
if (owner !== section) return null;
}
const bands = chuteBands([{ points: outletFillLine(section.design) }], pave.chute);
return bands.length ? { bands, halfM: Math.max(pave.span_m, 0.5) / 2 } : null;
}
@@ -8,7 +8,16 @@
* 원래 파일이 그대로 다시 내보내므로 기존 import 경로는 유효하다.
* ========================================================================== */
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
import type {
ChuteSpec,
CrossSection,
CulvertSideSpec,
} from "../B06_Section/B06_Section_Api_Fetch";
/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */
function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> {
return chute ? ["ch", chute.structure, chute.thickness_m] : [];
}
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
export function structureHashParts(section: CrossSection): Array<string | number> {
@@ -18,6 +27,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
const fordPave = section.ford_pavement;
if (fordPave) {
extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? "");
extras.push(...chuteParts(fordPave.chute));
}
const ownRevet = section.revetment;
if (ownRevet) {
@@ -54,6 +64,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
const value = section.design?.box_adjust?.[side];
return value ? `${side}:${value.lengthM},${value.riseM}` : "";
}),
...chuteParts(box.chute),
);
}
const ford = section.ford;
@@ -72,6 +83,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
ford.wing_out.slab_extend_m,
wall("inlet"),
wall("outlet"),
...chuteParts(ford.chute),
);
}
const culvert = section.culvert;
@@ -120,5 +132,6 @@ export function structureHashParts(section: CrossSection): Array<string | number
.map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`)
.join("|"),
section.design?.revet_link_detached === true ? "detached" : "",
...chuteParts(culvert.chute),
];
}
@@ -34,14 +34,18 @@ export interface TinDesign {
};
}
/** 구조물 솔리드 윗면 외곽 고리(모델 좌표 · 점마다 윗면 높이) — 관은 뺀다(다른 부재가 감쌈). */
/**
* 구조물 솔리드 윗면 외곽 고리(모델 좌표 · 점마다 윗면 높이) — 관은 뺀다(다른 부재가 감쌈) ·
* 도수로 · 돌붙임 띠도 뺀다(성토사면 위에 얹힘 — 구멍을 내면 사면이 뚫림 · 135-3).
*/
export function structureTopLoops(
structures: ReadonlyArray<CorridorStructure>,
): Array<Array<[number, number, number]>> {
const loops: Array<Array<[number, number, number]>> = [];
for (const structure of structures) {
const rings = structure.rings;
if (structure.kind === "pipe" || !rings || rings.length < 2) continue;
if (structure.kind === "pipe" || structure.kind === "chute" || !rings || rings.length < 2)
continue;
let run: Array<{
x: number;
y: number;
+37 -23
View File
@@ -315,6 +315,40 @@ export function appendWallHatch(
if (wall.retaining) drawDrains(group, wall, x, toDisplayY);
}
/**
* 도수로 · 돌붙임 띠 단면(90-2) — 윗면 = 사면선(겹친 점 뺌) · 변마다 아래쪽(사면 안쪽) 단위 법선 ·
* 둘레 = 윗면 → 두께만큼 오프셋한 아랫면(거꾸로). 횡단 그림과 B05 3D 띠 솔리드가 같은 것을 쓴다.
*/
export function chuteBandOutline(
raw: OffsetPoint[],
thickness: number,
): { top: OffsetPoint[]; normals: OffsetPoint[]; outline: OffsetPoint[] } | null {
if (!(thickness > 0)) return null;
const top = raw.filter(
(p, i) =>
i === 0 ||
Math.hypot(p.offset - raw[i - 1].offset, p.elevation - raw[i - 1].elevation) > 1e-6,
);
if (top.length < 2) return null;
// 변마다 아래쪽(사면 안쪽) 단위 법선
const normals = top.slice(1).map((p, i) => {
const dx = p.offset - top[i].offset;
const dz = p.elevation - top[i].elevation;
const length = Math.hypot(dx, dz);
const sign = dx / length > 0 ? -1 : 1;
return { offset: (sign * -dz) / length, elevation: (sign * dx) / length };
});
// 꺾임은 두 오프셋 선의 교점(마이터 · 너무 뾰족하면 두 배까지)
const bottom = top.map((p, i) => {
const a = normals[Math.max(i - 1, 0)];
const b = normals[Math.min(i, normals.length - 1)];
const sum = { offset: a.offset + b.offset, elevation: a.elevation + b.elevation };
const k = thickness / Math.max(sum.offset * a.offset + sum.elevation * a.elevation, 0.5);
return { offset: p.offset + sum.offset * k, elevation: p.elevation + sum.elevation * k };
});
return { top, normals, outline: [...top, ...bottom.reverse()] };
}
/**
* 도수로 · 돌붙임 띠(90-2) — 유출 성토사면 선(윗면 = 사면선)을 사면 안쪽(아래)으로 두께만큼 오프셋한
* 띠 · 안에 돌(흙막이 돌과 같은 그리기 · 두께 인자). 선마다 한 띠 · 그림만(면적 · 터파기 무관).
@@ -332,29 +366,9 @@ export function appendChuteBand(
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 top = raw.filter(
(p, i) =>
i === 0 ||
Math.hypot(p.offset - raw[i - 1].offset, p.elevation - raw[i - 1].elevation) > 1e-6,
);
if (top.length < 2) continue;
// 변마다 아래쪽(사면 안쪽) 단위 법선
const normals = top.slice(1).map((p, i) => {
const dx = p.offset - top[i].offset;
const dz = p.elevation - top[i].elevation;
const length = Math.hypot(dx, dz);
const sign = dx / length > 0 ? -1 : 1;
return { offset: (sign * -dz) / length, elevation: (sign * dx) / length };
});
// 꺾임은 두 오프셋 선의 교점(마이터 · 너무 뾰족하면 두 배까지)
const bottom = top.map((p, i) => {
const a = normals[Math.max(i - 1, 0)];
const b = normals[Math.min(i, normals.length - 1)];
const sum = { offset: a.offset + b.offset, elevation: a.elevation + b.elevation };
const k = thickness / Math.max(sum.offset * a.offset + sum.elevation * a.elevation, 0.5);
return { offset: p.offset + sum.offset * k, elevation: p.elevation + sum.elevation * k };
});
const outline = [...top, ...bottom.reverse()];
const shape = chuteBandOutline(raw, 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");
@@ -0,0 +1,270 @@
/* PLAN 135-3 — B05 3D 도수로 · 돌붙임 띠 솔리드 헬퍼. 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
* 입력 = 73-4 픽스처(박스 352.14 · 세월교 173.087 · 일반 180) · 결과 = 표준출력 JSON. 짝 = test_135_3_chute_solid.py.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const cache = new Map();
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
three: require(path.join(ROOT, "config", "node_modules", "three")),
};
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
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);
return box.exports;
}
const B05 = (f) => path.join(ROOT, "B05_Profile", `${f}.ts`);
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 { 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"));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
);
const clone = (v) => JSON.parse(JSON.stringify(v));
const at = (c) => clone(fixture.cross_sections.find((s) => Math.abs(s.chainage_m - c) < 0.6));
const CHUTE = { structure: "돌붙임", thickness_m: 0.3 };
/** 원지반을 내려 유출 쪽이 성토가 되게 한다(픽스처는 절토 측점). */
const lower = (s, dz) => {
s.samples.forEach((p) => {
p.z -= dz;
p.elevation_m -= dz;
});
return s;
};
/** 직선 노선 — 가장 가까운 측점 프레임에서 앞쪽으로 편다. */
const frameAtOf = (sections) => (atM) => {
const s = sections.reduce((b, x) =>
Math.abs(x.chainage_m - atM) < Math.abs(b.chainage_m - atM) ? x : b,
);
const [lx, ly] = s.frame.left_xy;
const along = atM - s.chainage_m;
return {
cx: s.center_x - ly * along,
cy: s.center_y + lx * along,
leftX: lx,
leftY: ly,
designZ: s.design?.design_elevation_m ?? null,
};
};
const chutesOf = (sections) =>
buildCorridorStructures(sections, frameAtOf(sections)).filter((s) => s.kind === "chute");
const lengthOf = (solid) => {
const a = solid.rings[0];
const b = solid.rings[solid.rings.length - 1];
return Math.round(Math.hypot(b.cx - a.cx, b.cy - a.cy) * 1000) / 1000;
};
const flat = (pts) => pts.map((p) => [p.offset, p.elevation]);
const same = (a, b) =>
a.length === b.length &&
a.every(([o, e], i) => Math.abs(o - b[i][0]) + Math.abs(e - b[i][1]) < 1e-9);
const out = {};
function check(name, section, expectedBands, expectedLength, strip) {
const without = clone(section);
strip(without);
const withChute = section;
const solids = chutesOf([withChute]);
const all = buildCorridorStructures([withChute], frameAtOf([withChute]));
const allWithout = buildCorridorStructures([without], frameAtOf([without]));
out[name] = {
count: solids.length,
expected: expectedBands.length,
sameShape: solids.every((s, i) => expectedBands[i] && same(s.polygon, flat(expectedBands[i]))),
keys: [...new Set(solids.map((s) => s.key))],
lengths: solids.map(lengthOf),
expectedLength,
holesSame: structureTopLoops(all).length === structureTopLoops(allWithout).length,
otherSolidsSame: all.length - solids.length === allWithout.length,
};
}
// ① BOX(흙막이 없는) — 유출 날개 쪽 성토 조각 · 길이 = 구체 폭(span_m)
{
const s = lower(at(352.14), 15);
delete s.culvert;
const plain = clone(s);
s.box.chute = CHUTE;
const layout = computeBoxLayout(s, s.samples);
const bands = layout.sides
.filter((side) => side.wingRole === "outlet")
.flatMap((side) => side.fillSegments.filter((g) => g.kind !== "cut"))
.map((g) => chuteBandOutline(g.points, 0.3)?.outline)
.filter(Boolean);
// 해시 — 고르면 바뀌고 · 안 고르면 그대로(빈 칸을 안 붙임)
out.hash = {
changed: structureHashParts(s).join("|") !== structureHashParts(plain).join("|"),
plainNoChute: !structureHashParts(plain).includes("ch"),
};
// 비교용 원본은 chute 없는 꼴로
check("box", s, bands, s.box.span_m, (x) => delete x.box.chute);
out.box.noChuteNoSolid = chutesOf([plain]).length === 0;
}
// ② 세월교(흙막이 없는) — 유출 측벽 성토 조각 · 길이 = 월류 폭(span_m)
{
const s = lower(at(173.087), 15);
delete s.culvert;
s.ford.chute = CHUTE;
const stored = s.design?.ford_adjust;
const layout = computeFordLayout(s, s.samples, {
inlet: { heightM: null, lateralM: 0, slopeM: 0, ...(stored?.inlet ?? {}) },
outlet: { heightM: null, lateralM: 0, slopeM: 0, ...(stored?.outlet ?? {}) },
});
const bands = layout.sides
.filter((side) => side.role === "outlet")
.flatMap((side) => side.fillSegments.filter((g) => g.kind !== "cut"))
.map((g) => chuteBandOutline(g.points, 0.3)?.outline)
.filter(Boolean);
check("ford", s, bands, Math.max(s.ford.span_m, 0.5), (x) => delete x.ford.chute);
}
// ③ 물넘이 포장(136-2 ford_chute) — 유출측 성토사면 선 · 길이 = 포장 폭 · 주인 측점 하나만
{
const s = at(180);
s.ford_pavement = { chainage_m: 180, span_m: 4, depth_m: 0.3, slope_pct: null, chute: CHUTE };
const band = chuteBandOutline(
outletFillLine(s.design).map((p) => p),
0.3,
)?.outline;
check("pave", s, band ? [band] : [], 4, (x) => delete x.ford_pavement.chute);
const neighbour = at(160);
neighbour.ford_pavement = clone(s.ford_pavement);
const pair = [neighbour, s];
out.pave.ownerOnly = chutesOf(pair).length === out.pave.count;
}
// ④ 관(배수관 세트) — 유출 성토 조각(단 사이 포함) · 길이 = 유출 벽 길이(전 5 + 후 5)
{
const s = lower(at(352.14), 15);
delete s.box;
delete s.culvert.body;
s.culvert.hidden_pipe = false;
s.culvert.chute = CHUTE;
const { culvertLayoutOf } = loadTs(B05("B05_Profile_UI_Corridor_Structures"));
const layout = culvertLayoutOf(s);
const bands = (layout?.outletFill.segments ?? [])
.filter((g) => g.kind !== "cut")
.map((g) => chuteBandOutline(g.points, 0.3)?.outline)
.filter(Boolean);
const spec = s.culvert.outlet;
check(
"pipe",
s,
bands,
(spec.revet_before_m ?? 5) + (spec.revet_after_m ?? 5),
(x) => delete x.culvert.chute,
);
}
// ⑥ 3D 메시 — 띠 끝 마구리 = 마디마다 사다리꼴(부채꼴 아님) · 색 있음
{
const s = lower(at(352.14), 15);
delete s.box;
delete s.culvert.body;
s.culvert.hidden_pipe = false;
s.culvert.chute = CHUTE;
const solid = chutesOf([s])[0];
// 꺾인 띠(점 셋) 로 갈아 끼워 마구리 삼각형 수를 센다
solid.polygon = chuteBandOutline(
[
{ offset: -6, elevation: 832 },
{ offset: -9, elevation: 830 },
{ offset: -12, elevation: 830 },
],
0.3,
).outline.map((p) => [p.offset, p.elevation]);
const { createCorridorGroup } = loadTs(B05("B05_Profile_UI_Corridor_Mesh"));
const group = createCorridorGroup(
{ ribbons: [], outline: { chainages: [], left: [], right: [] }, caps: [], structures: [solid] },
{
x: [s.center_x - 100, s.center_x + 100],
y: [s.center_y - 100, s.center_y + 100],
z: [800, 900],
},
);
const mesh = group.children.find((c) => c.name.startsWith("corridor-structure:chute"));
const count = solid.polygon.length;
const rings = solid.rings.length;
// 마구리 삼각형 넓이 합(부호 없음) = 띠 넓이 × 2 — 부채꼴이면 오목한 자리에서 넘침
const pos = mesh.geometry.getAttribute("position");
const capTris = 4 * (count / 2 - 1);
let capArea = 0;
for (let t = 0; t < capTris; t += 1) {
const v = [0, 1, 2].map((k) => [pos.getX(t * 3 + k), pos.getY(t * 3 + k), pos.getZ(t * 3 + k)]);
const u = v[1].map((x, k) => x - v[0][k]);
const w = v[2].map((x, k) => x - v[0][k]);
const c = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
capArea += Math.hypot(...c) / 2;
}
let bandArea = 0;
solid.polygon.forEach(([x1, y1], i) => {
const [x2, y2] = solid.polygon[(i + 1) % count];
bandArea += x1 * y2 - x2 * y1;
});
out.mesh = {
capAreaRatio: Math.round((capArea / Math.abs(bandArea)) * 1000) / 1000,
found: Boolean(mesh),
colored: Boolean(mesh?.material?.color),
triangles: mesh ? mesh.geometry.getAttribute("position").count / 3 : 0,
expected: 4 * (count / 2 - 1) + 2 * count * (rings - 1),
};
}
// ⑤ ③ 구멍 — 띠 윗면이 평평한 자리(단 사이 수평)여도 구멍 고리를 안 냄 · 같은 꼴 벽이면 냄
{
const s = lower(at(352.14), 15);
delete s.culvert;
s.box.chute = CHUTE;
const flatBand = chuteBandOutline(
[
{ offset: -8, elevation: 830 },
{ offset: -12, elevation: 830 },
],
0.3,
).outline.map((p) => [p.offset, p.elevation]);
const flat = chutesOf([s]).map((x) => ({ ...x, polygon: flatBand }));
out.flatTop = {
asWall: structureTopLoops(flat.map((x) => ({ ...x, kind: "revet" }))).length,
asChute: structureTopLoops(flat).length,
};
}
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""PLAN 135-3 — B05 3D 도수로 · 돌붙임 띠 솔리드 검사.
도우미(helper_135_3_chute_solid.cjs)가 73-4 픽스처로 관 · BOX · 세월교 · 물넘이 포장 띠를 세워 결과(JSON)를 낸다.
단면 = B06 횡단 띠와 같은 계산 · 길이 = 유출 구조물 폭 · 해시 키 · ③ 구멍(structureTopLoops)이 띠를 안 뚫음.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_135_3_chute_solid.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.mark.parametrize("name", ["pipe", "box", "ford", "pave"])
def test_band_solid_matches_b06_and_spans_outlet_width(out, name):
r = out[name]
assert r["count"] == r["expected"] >= 1, r # 유출 성토 조각마다 한 장
assert r["sameShape"] is True # 단면 = B06 chuteBandOutline 그대로
assert r["keys"] == ["chute"]
assert all(abs(length - r["expectedLength"]) < 1e-3 for length in r["lengths"]), r
assert r["otherSolidsSame"] is True # 다른 구조물 솔리드는 그대로
assert r["holesSame"] is True # ③ 구멍 수 그대로
def test_no_chute_no_band_and_hash(out):
assert out["box"]["noChuteNoSolid"] is True
assert out["hash"] == {
"changed": True,
"plainNoChute": True,
} # 고르면 만료 · 안 고르면 해시 그대로
assert out["pave"]["ownerOnly"] is True # 걸친 옆 측점은 안 냄
def test_hole_loops_skip_chute(out):
assert out["flatTop"]["asWall"] > 0 and out["flatTop"]["asChute"] == 0
def test_mesh_caps_follow_band(out):
r = out["mesh"]
assert r["found"] and r["colored"]
assert r["triangles"] == r["expected"]
# 앞 · 뒤 마구리 넓이 합 / (띠 넓이 × 2) = 1 — 꺾인 띠도 안 삐져나옴(부채꼴이면 약 4)
assert abs(r["capAreaRatio"] - 1) < 0.01