/* PLAN 172-1 단위검증 헬퍼 — B05 3D 제안 ③ 성토 사면 종단 주름(비탈 끝 높이 고르기 · 측점 앞 덧행 없앰). * 직선 노선 · 성토(노면 100 · 지반 94 · 1:1.5) · 지반에 노선 방향 사인 물결(파장 5 m · 측점 자리 0). * 트랜스파일 틀 · 합성 측점 · 격자 지형 · 틈 셈은 helper_115_8_corridor_loft.cjs 와 같다. * 결과는 표준출력에 JSON 한 벌. */ const fs = require("fs"); const path = require("path"); const ROOT = path.join(__dirname, "..", ".."); const NM = path.join(ROOT, "config", "node_modules"); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); const THREE = require(path.join(NM, "three")); const LIBS = { three: THREE, delaunator: require(path.join(NM, "delaunator", "index.js")), "three/examples/jsm/utils/BufferGeometryUtils.js": require( path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js"), ), "@kninnug/constrainautor": { __esModule: true, default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")), }, }; const { loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { if (LIBS[request]) return LIBS[request]; if (request.startsWith("@ui/") || request.startsWith("@config/")) { return new Proxy({}, { get: () => () => undefined }); } const target = request.startsWith("@util/") ? path.join(ROOT, "common_util", request.slice(6)) : path.join(path.dirname(full), request); if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); }, }); const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); const { buildCorridor } = loadTs(B05("logic/B05_Profile_Corridor_Build")); const { loftTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Loft")); const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split")); const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain")); const PSLG = loadTs(B05("logic/B05_Profile_UI_Corridor_Tin_Pslg")); const HALF = 3; const ROAD_Z = 100; const GROUND_Z = 94; const TOE = HALF + (ROAD_Z - GROUND_Z) * 1.5; // 측점 비탈 끝 오프셋 12 m const LENGTH = 60; const STATIONS = [0, 20, 40, 60]; const WAVE_M = 5; const r6 = (v) => Math.round(v * 1e6) / 1e6; function station(chainage) { const pts = [ [-40, GROUND_Z], [-TOE, GROUND_Z], [-HALF, ROAD_Z], [-2.5, ROAD_Z], [2.5, ROAD_Z], [HALF, ROAD_Z], [TOE, GROUND_Z], [40, GROUND_Z], ]; const samples = []; for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: GROUND_Z }); return { station_id: `S${chainage}`, chainage_m: chainage, label: "", kind: "regular", center_x: chainage, center_y: 0, center_z: GROUND_Z, frame: { left_xy: [0, 1] }, samples, design: { section_mode: "both_fill", road_edges: { left: { offset_m: HALF, elevation_m: ROAD_Z }, right: { offset_m: -HALF, elevation_m: ROAD_Z }, }, carriageway_edges: { left: { offset_m: 2.5, elevation_m: ROAD_Z }, right: { offset_m: -2.5, elevation_m: ROAD_Z }, }, design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), ditch: { type: "none" }, ditch_enabled: false, ditch_side: "left", }, }; } const BOUNDS = { x: [-10, 70], y: [-30, 30], z: [85, 110] }; const OX = (BOUNDS.x[0] + BOUNDS.x[1]) / 2; const OY = (BOUNDS.y[0] + BOUNDS.y[1]) / 2; const OZ = (BOUNDS.z[0] + BOUNDS.z[1]) / 2; const X0 = -10; const X1 = 70; const Y0 = -30; const Y1 = 30; const STEP = 0.5; function gridTerrain(heightAt) { const nx = Math.round((X1 - X0) / STEP) + 1; const ny = Math.round((Y1 - Y0) / STEP) + 1; const positions = new Float32Array(nx * ny * 3); for (let j = 0; j < ny; j += 1) { for (let i = 0; i < nx; i += 1) { const v = j * nx + i; const x = X0 + i * STEP; const y = Y0 + j * STEP; positions[v * 3] = x - OX; positions[v * 3 + 1] = heightAt(x, y) - OZ; positions[v * 3 + 2] = -(y - OY); } } const index = []; for (let j = 0; j < ny - 1; j += 1) { for (let i = 0; i < nx - 1; i += 1) { const a = j * nx + i; if ((i + j) % 2) index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); else index.push(a, a + nx + 1, a + nx, a, a + 1, a + nx + 1); } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute( "color", new THREE.BufferAttribute(new Uint8Array(nx * ny * 4).fill(200), 4, true), ); geometry.setIndex(index); return new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true })); } const key = (x, y, z) => `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(z * 1e4)}`; const pkey = (x, z) => `${Math.round(x * 1e4)},${Math.round(z * 1e4)}`; const fkey = (x, z) => pkey(Math.fround(x), Math.fround(z)); /** 한 번만 쓰인 변 중 지형 바깥 테두리 · 노면 끝 · 구멍 둘레가 아닌 것(틈). */ function gapCount(root, out) { const pslg = out.design.pslg; const tagAt = new Map(); for (let i = 0; i < pslg.count; i += 1) tagAt.set(fkey(pslg.x[i], pslg.z[i]), pslg.tag[i]); const onFrame = (p) => { const x = p[0] + OX; const y = -p[2] + OY; return ( Math.abs(x - X0) < 1e-3 || Math.abs(x - X1) < 1e-3 || Math.abs(y - Y0) < 1e-3 || Math.abs(y - Y1) < 1e-3 ); }; const uses = new Map(); root.traverse((child) => { if (!(child instanceof THREE.Mesh)) return; const p = child.geometry.getAttribute("position"); const idx = child.geometry.getIndex(); const count = idx ? idx.count : p.count; for (let t = 0; t < count; t += 3) { const tri = [0, 1, 2].map((k) => { const v = idx ? idx.getX(t + k) : t + k; return [p.getX(v), p.getY(v), p.getZ(v)]; }); const [a, b, c] = tri; if (Math.abs((b[0] - a[0]) * (c[2] - a[2]) - (c[0] - a[0]) * (b[2] - a[2])) < 1e-9) continue; for (let k = 0; k < 3; k += 1) { const ka = key(...tri[k]); const kb = key(...tri[(k + 1) % 3]); const e = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; const entry = uses.get(e) ?? { n: 0, a: tri[k], b: tri[(k + 1) % 3] }; entry.n += 1; uses.set(e, entry); } } }); let gaps = 0; for (const { n, a, b } of uses.values()) { if (n !== 1) continue; const ta = tagAt.get(pkey(a[0], a[2])); const tb = tagAt.get(pkey(b[0], b[2])); if (ta === PSLG.TAG_ANCHOR && tb === PSLG.TAG_ANCHOR) continue; if (ta === PSLG.TAG_HOLE && tb === PSLG.TAG_HOLE) continue; if (onFrame(a) && onFrame(b)) continue; gaps += 1; } return gaps; } /** TIN 조각 씬 높이 조회기. */ function tinHeights(out) { const p = out.root.getObjectByName("terrain-tin-mesh").geometry.getAttribute("position"); const tris = []; for (let t = 0; t < p.count; t += 3) tris.push([0, 1, 2].map((k) => [p.getX(t + k), p.getY(t + k), p.getZ(t + k)])); return (x, y) => { const sx = x - OX; const sz = -(y - OY); for (const [a, b, c] of tris) { const d = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]); if (Math.abs(d) < 1e-12) continue; const w0 = ((b[2] - c[2]) * (sx - c[0]) + (c[0] - b[0]) * (sz - c[2])) / d; const w1 = ((c[2] - a[2]) * (sx - c[0]) + (a[0] - c[0]) * (sz - c[2])) / d; const w2 = 1 - w0 - w1; if (w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9) continue; return w0 * a[1] + w1 * b[1] + w2 * c[1] + OZ; } return null; }; } const p90 = (values) => { const s = values.map(Math.abs).sort((a, b) => a - b); return s[Math.floor(0.9 * (s.length - 1))]; }; /** 물결 진폭 amp 로 한 번 — 사면 가운데 종단 2차 차분 · 비탈 끝 뜸 · 측점 단면 · 측점 앞 행 · 틈. */ function run(amp) { const ripple = (x) => amp * Math.sin((2 * Math.PI * x) / WAVE_M); const terrain = gridTerrain((x) => GROUND_Z + ripple(x)); const sections = STATIONS.map(station); const build = buildCorridor(sections, [ { x: 0, y: 0 }, { x: LENGTH, y: 0 }, ]); build.sections = sections; build.structures = []; const split = new TerrainBandSplit(terrain, { minX: -1e4, maxX: 1e4, minZ: -1e4, maxZ: 1e4 }); const heightIndex = new TerrainHeightIndex(terrain); const out = loftTerrain(split, build, BOUNDS, heightIndex); const pslg = out.design.pslg; const tin = tinHeights(out); const groundAt = (x, y) => heightIndex.heightAt(x - OX, -(y - OY)) + OZ; // 사면 가운데(u 0.5) 종단 높이 2차 차분(1 m 걸음 · 측점 ±1 m 뺌) — 드레이프 그대로(지형 물결의 반)와 견줌. const mid = HALF + (TOE - HALF) / 2; const d2 = []; const ref = []; for (const side of [1, -1]) { for (let x = 2; x <= LENGTH - 2; x += 1) { if (STATIONS.some((c) => Math.abs(c - x) <= 1)) continue; const z = [x - 1, x, x + 1].map((xx) => tin(xx, side * mid)); if (z.some((v) => v === null)) continue; d2.push(z[0] - 2 * z[1] + z[2]); ref.push(0.5 * (ripple(x - 1) - 2 * ripple(x) + ripple(x + 1))); } } // 비탈 끝(설계 구역 둘레 · 성토) 높이 − 지형 · 측점 자리는 따로. let toeOff = 0; let stationToeOff = 0; let toes = 0; const outer = new Set(out.design.outer.map(([x, z]) => `${x},${z}`)); for (let i = 0; i < pslg.count; i += 1) { if (pslg.tag[i] !== PSLG.TAG_FILL || !outer.has(`${pslg.x[i]},${pslg.z[i]}`)) continue; const x = pslg.x[i] + OX; const y = -pslg.z[i] + OY; const off = Math.abs(pslg.y[i] + OZ - groundAt(x, y)); toeOff = Math.max(toeOff, off); if (STATIONS.some((c) => Math.abs(c - x) < 1e-6)) stationToeOff = Math.max(stationToeOff, off); toes += 1; } // 측점 20 단면 = B06(1:1.5 → 비탈 끝에서 지반). let sectionErr = 0; for (const side of [1, -1]) { for (let s = 0.05; s <= TOE - HALF - 0.05; s += 0.05) { const z = tin(20, side * (HALF + s)); if (z !== null) sectionErr = Math.max(sectionErr, Math.abs(z - (ROAD_Z - s / 1.5))); } } // 측점 앞 0.2 m 격자 행 — 사면 안쪽(노면 끝 · 비탈 끝 아닌) 성토 점이 측점 0.3 m 앞 안에 있으면 셈. let preRowPoints = 0; for (let i = 0; i < pslg.count; i += 1) { if (pslg.tag[i] !== PSLG.TAG_FILL) continue; const x = pslg.x[i] + OX; const s = Math.abs(-pslg.z[i] + OY) - HALF; if (s < 0.5 || s > TOE - HALF - 1) continue; if (STATIONS.some((c) => x > c - 0.3 && x < c - 1e-6)) preRowPoints += 1; } return { stats: out.stats, gaps: gapCount(out.root, out), midD2P90: r6(p90(d2)), refD2P90: r6(p90(ref)), midSamples: d2.length, toeOff: r6(toeOff), stationToeOff: r6(stationToeOff), toes, sectionErr: r6(sectionErr), preRowPoints, }; } process.stdout.write(JSON.stringify({ gentle: run(0.4), rough: run(1.0) }));