- TIN 비탈 끝 둘레 1.5 m 안 지형 정점 뺌(SEAM_M) · 상자도 같은 만큼 넓힘(틈 0) - 성토 덩이 끝 누가거리 3 m 안 toeOff smoothstep 으로 0 까지 - helper_115_8 공사 밖 판정 · 측점 단면 비교에서 이음 띠 뺌 · 새 시험 test_176_1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
264 lines
10 KiB
JavaScript
264 lines
10 KiB
JavaScript
/* PLAN 176-1 단위검증 헬퍼 — B05 3D 성토 비탈 끝 이음 띠(SEAM_M) · 성토 덩이 끝 뜸 줄이기.
|
|
* 직선 노선 80 m · 측점 0 · 20 · 40 성토(노면 100 · 지반 94 · 1:1.5) · 60 · 80 절토(지반 104 · 1:1).
|
|
* 지반에 노선 방향 사인 물결(진폭 1 m · 파장 10 m · 측점 자리 0) → 성토 비탈 끝이 한도까지 뜸.
|
|
* 트랜스파일 틀 · 격자 지형 · 틈 셈은 helper_172_1_loft_wrinkle.cjs 와 같다. 결과는 표준출력 JSON.
|
|
*/
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const ROOT = path.join(__dirname, "..", "..");
|
|
const NM = path.join(ROOT, "config", "node_modules");
|
|
const ts = require(path.join(NM, "typescript"));
|
|
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 cache = new Map();
|
|
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 },
|
|
}).outputText;
|
|
const box = { exports: {} };
|
|
cache.set(full, box.exports);
|
|
const localRequire = (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"));
|
|
};
|
|
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
|
return box.exports;
|
|
}
|
|
|
|
const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
|
|
const { buildCorridor } = loadTs(B05("B05_Profile_UI_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("B05_Profile_UI_Corridor_Tin_Pslg"));
|
|
|
|
const { SEAM_M } = loadTs(B05("B05_Profile_UI_Corridor_Tin"));
|
|
|
|
const HALF = 3;
|
|
const ROAD_Z = 100;
|
|
const LENGTH = 80;
|
|
const STATIONS = [0, 20, 40, 60, 80];
|
|
const AMP = 1;
|
|
const WAVE_M = 10;
|
|
const r6 = (v) => Math.round(v * 1e6) / 1e6;
|
|
|
|
/** 측점 단면 — 성토(지반 94 · 1:1.5) 또는 절토(지반 104 · 1:1). */
|
|
function station(chainage) {
|
|
const fill = chainage <= 40;
|
|
const ground = fill ? 94 : 104;
|
|
const toe = HALF + Math.abs(ROAD_Z - ground) * (fill ? 1.5 : 1);
|
|
const pts = [
|
|
[-40, ground],
|
|
[-toe, ground],
|
|
[-HALF, ROAD_Z],
|
|
[-2.5, ROAD_Z],
|
|
[2.5, ROAD_Z],
|
|
[HALF, ROAD_Z],
|
|
[toe, ground],
|
|
[40, ground],
|
|
];
|
|
const samples = [];
|
|
for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: ground });
|
|
return {
|
|
station_id: `S${chainage}`,
|
|
chainage_m: chainage,
|
|
label: "",
|
|
kind: "regular",
|
|
center_x: chainage,
|
|
center_y: 0,
|
|
center_z: ground,
|
|
frame: { left_xy: [0, 1] },
|
|
samples,
|
|
design: {
|
|
section_mode: fill ? "both_fill" : "both_cut",
|
|
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, 90], y: [-30, 30], z: [85, 115] };
|
|
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 = 90;
|
|
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;
|
|
}
|
|
|
|
/** 성토 비탈 끝 둘레 점 · 이음 띠 경사 · 덩이 끝 뜸 · 틈. */
|
|
function run() {
|
|
const base = (x) => (x <= 40 ? 94 : x >= 60 ? 104 : 94 + (x - 40) / 2);
|
|
const terrain = gridTerrain((x) => base(x) + AMP * Math.sin((2 * Math.PI * x) / WAVE_M));
|
|
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 groundAt = (x, y) => heightIndex.heightAt(x - OX, -(y - OY)) + OZ;
|
|
|
|
// 성토 비탈 끝(설계 둘레 · 성토 꼬리표) — 측마다 높이 − 지형 · 덩이 끝(가장 큰 x) 점.
|
|
const outer = new Set(out.design.outer.map(([x, z]) => pkey(x, z)));
|
|
const rim = new Set();
|
|
const ends = { 1: { x: -Infinity, off: 0 }, [-1]: { x: -Infinity, off: 0 } };
|
|
let midOff = 0;
|
|
for (let i = 0; i < pslg.count; i += 1) {
|
|
if (pslg.tag[i] !== PSLG.TAG_FILL || !outer.has(pkey(pslg.x[i], pslg.z[i]))) continue;
|
|
rim.add(pkey(pslg.x[i], pslg.z[i]));
|
|
const x = pslg.x[i] + OX;
|
|
const y = -pslg.z[i] + OY;
|
|
const off = Math.abs(pslg.y[i] + OZ - groundAt(x, y));
|
|
if (x > 5 && x < 35) midOff = Math.max(midOff, off);
|
|
const end = ends[y > 0 ? 1 : -1];
|
|
if (x > end.x) Object.assign(end, { x, off });
|
|
}
|
|
|
|
// 이음 띠 — 성토 비탈 끝 점을 쓰는 원지반 색 삼각형의 경사(°).
|
|
const mesh = out.root.getObjectByName("terrain-tin-mesh");
|
|
const p = mesh.geometry.getAttribute("position");
|
|
const keep = mesh.userData.keepColor;
|
|
const slopes = [];
|
|
for (let t = 0; t < p.count / 3; t += 1) {
|
|
if (keep[t * 3]) continue;
|
|
const v = [0, 1, 2].map((k) => [p.getX(t * 3 + k), p.getY(t * 3 + k), p.getZ(t * 3 + k)]);
|
|
if (!v.some(([x, , z]) => rim.has(pkey(x, z)))) continue;
|
|
// 지반 바탕이 평탄한 성토 구간만(지형 자체 경사 ≤ 32°) — 40~60 m 오르막은 지형이 45° 넘음.
|
|
const cx = (v[0][0] + v[1][0] + v[2][0]) / 3 + OX;
|
|
if (cx < 2 || cx > 38) continue;
|
|
const u = [0, 1, 2].map((k) => v[1][k] - v[0][k]);
|
|
const w = [0, 1, 2].map((k) => v[2][k] - v[0][k]);
|
|
const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
|
|
const len = Math.hypot(...n);
|
|
if (len < 1e-9) continue;
|
|
slopes.push((Math.acos(Math.abs(n[1]) / len) * 180) / Math.PI);
|
|
}
|
|
slopes.sort((a, b) => a - b);
|
|
return {
|
|
seam: SEAM_M,
|
|
stats: out.stats,
|
|
gaps: gapCount(out.root, out),
|
|
rimPoints: rim.size,
|
|
seamTris: slopes.length,
|
|
seamP99: r6(slopes[Math.floor(0.99 * (slopes.length - 1))] ?? 0),
|
|
midOff: r6(midOff),
|
|
endX: [r6(ends[1].x), r6(ends[-1].x)],
|
|
endOff: r6(Math.max(ends[1].off, ends[-1].off)),
|
|
};
|
|
}
|
|
|
|
process.stdout.write(JSON.stringify(run()));
|