/* 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("logic/B05_Profile_UI_Corridor_Structures_Hash")); const { structureTopLoops } = loadTs(B05("logic/B05_Profile_UI_Corridor_Tin_Design")); 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")); 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(isChuteSegment) // 묻힌 끝 3도 절토 조각도(152-3) .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));