From 1c112d3b3c2b5d1cfa1459bcc7c05d9603057175 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:09:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(b05):=203D=20=EB=8F=84=EC=88=98=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EB=8F=8C=EB=B6=99=EC=9E=84=20=EB=9D=A0=20=EC=86=94?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=E2=80=94=20=EC=9C=A0=EC=B6=9C=20=EC=84=B1?= =?UTF-8?q?=ED=86=A0=20=EC=A1=B0=EA=B0=81=20=EC=8A=A4=EC=9C=95=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=3D=20=EC=9C=A0=EC=B6=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=ED=8F=AD=20=C2=B7=20=ED=95=B4=EC=8B=9C=20?= =?UTF-8?q?=ED=82=A4=20=C2=B7=20=E2=91=A2=20=EA=B5=AC=EB=A9=8D=20=EC=95=88?= =?UTF-8?q?=20=EB=9A=AB=EB=A6=BC(135-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단면 = B06 횡단 띠와 한 벌(Wall_Hatch chuteBandOutline 로 떼어 냄 · appendChuteBand 가 같이 씀) - 관 = 유출 벽 길이 · BOX = 구체 폭(축 고정) · 세월교 = 월류 폭 · 물넘이 포장(ford_chute) = 포장 폭 · 주인 측점 하나 - 구조물 종류 chute · 색 · 끝 마구리 = 마디마다 사다리꼴 · 해시 = 고른 때만 ch 키(안 고른 측점 저장본 그대로) - ③ structureTopLoops 가 chute 를 뺌 - 새 시험 test_135_3_chute_solid(+ 도우미) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- B05_Profile/B05_Profile_UI_Corridor_Mesh.ts | 26 +- .../B05_Profile_UI_Corridor_Structures.ts | 21 +- .../B05_Profile_UI_Corridor_Structures_Box.ts | 15 +- ...05_Profile_UI_Corridor_Structures_Chute.ts | 54 ++++ ...B05_Profile_UI_Corridor_Structures_Hash.ts | 15 +- .../B05_Profile_UI_Corridor_Tin_Design.ts | 8 +- .../B06_Section_UI_Cross_Wall_Hatch.ts | 60 ++-- resources/tester/helper_135_3_chute_solid.cjs | 270 ++++++++++++++++++ resources/tester/test_135_3_chute_solid.py | 59 ++++ 9 files changed, 495 insertions(+), 33 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts create mode 100644 resources/tester/helper_135_3_chute_solid.cjs create mode 100644 resources/tester/test_135_3_chute_solid.py diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index 425d36170..5f216cc58 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -202,6 +202,8 @@ const STRUCTURE_COLORS: Record = { 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; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index c85bb404a..db11c649d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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) => { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts index 182f5c684..65f01341d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts @@ -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; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts new file mode 100644 index 000000000..fdf929128 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts @@ -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, + 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, +): { 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; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index d99b5f4c2..7ecfc5c77 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -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 { + return chute ? ["ch", chute.structure, chute.thickness_m] : []; +} /** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */ export function structureHashParts(section: CrossSection): Array { @@ -18,6 +27,7 @@ export function structureHashParts(section: CrossSection): Array `${wall}:${span.length_m},${span.before_m},${span.after_m}`) .join("|"), section.design?.revet_link_detached === true ? "detached" : "", + ...chuteParts(culvert.chute), ]; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts b/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts index 126498dbf..bf7a36f9c 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts @@ -34,14 +34,18 @@ export interface TinDesign { }; } -/** 구조물 솔리드 윗면 외곽 고리(모델 좌표 · 점마다 윗면 높이) — 관은 뺀다(다른 부재가 감쌈). */ +/** + * 구조물 솔리드 윗면 외곽 고리(모델 좌표 · 점마다 윗면 높이) — 관은 뺀다(다른 부재가 감쌈) · + * 도수로 · 돌붙임 띠도 뺀다(성토사면 위에 얹힘 — 구멍을 내면 사면이 뚫림 · 135-3). + */ export function structureTopLoops( structures: ReadonlyArray, ): Array> { const loops: Array> = []; 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; diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts index 9f35159bf..2be9fbf51 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts @@ -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"); diff --git a/resources/tester/helper_135_3_chute_solid.cjs b/resources/tester/helper_135_3_chute_solid.cjs new file mode 100644 index 000000000..d55aba449 --- /dev/null +++ b/resources/tester/helper_135_3_chute_solid.cjs @@ -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)); diff --git a/resources/tester/test_135_3_chute_solid.py b/resources/tester/test_135_3_chute_solid.py new file mode 100644 index 000000000..ad11e4e6e --- /dev/null +++ b/resources/tester/test_135_3_chute_solid.py @@ -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