diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts index 113de4888..6830e7acd 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Loft.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts @@ -69,6 +69,8 @@ const POINT_GAP_M = 0.3; // 늘리면 더 매끈 · 더 뜸. const LIFT_SIGMA_M = 3; const LIFT_CAP_M = 0.5; +/** 성토 덩이 끝(성토 아닌 행)에서 이 누가거리 안은 뜸을 0 까지 줄인다(smoothstep · 176-1 · 절토와 톱니 없게). */ +const LIFT_TAPER_M = 3; /** 구조물 구멍 둘레 이만큼 안 격자 점은 뺀다(구멍 둘레 실오라기 막음 · m). */ const HOLE_CLEAR_M = 0.3; /** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */ @@ -348,9 +350,21 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { (r) => fill(r) && stationAt(chainages[r]) >= 0, fill, ); + // 가장 가까운 성토 아닌 행까지 누가거리(앞 · 뒤) — 덩이 끝 줄이기. + const gap = new Float64Array(n).fill(Infinity); + for (let r = 0, last = -Infinity; r < n; r += 1) { + if (!fill(r)) last = chainages[r]; + else gap[r] = chainages[r] - last; + } + for (let r = n - 1, next = Infinity; r >= 0; r -= 1) { + if (!fill(r)) next = chainages[r]; + else gap[r] = Math.min(gap[r], next - chainages[r]); + } for (let r = 0; r < n; r += 1) { if (!fill(r)) continue; - toeOff[si][r] = Math.max(-LIFT_CAP_M, Math.min(LIFT_CAP_M, smooth[r] - raw[r])); + const t = Math.min(1, gap[r] / LIFT_TAPER_M); + const off = Math.max(-LIFT_CAP_M, Math.min(LIFT_CAP_M, smooth[r] - raw[r])); + toeOff[si][r] = off * t * t * (3 - 2 * t); raw[r] += toeOff[si][r]; } }); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Tin.ts b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts index b48e6fd37..8baab4e3e 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Tin.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts @@ -38,8 +38,9 @@ const FALLBACK_RGB = [0.6, 0.6, 0.6]; /** 꼬리표 없는 설계 삼각형을 절성토로 칠할 높이 차(m). */ const TINT_MIN_M = 0.01; const GRID_M = 2; -/** 설계 구역 · 구멍 둘레에 이만큼 붙은 지형 정점은 안으로 본다(m). */ -const ON_BOUNDARY_M = 1e-3; +/** 설계 구역 · 구멍 둘레에서 이만큼 안 지형 정점은 빼고 잇는다(m · 176-1) — 비탈 끝 뜸이 짧은 턱 · 홈이 되지 않게 + * 잇는 띠를 1.5~2.5 m 로 늘림. 변 판정(boundary.touches)은 그대로. */ +export const SEAM_M = 1.5; /** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다). */ class RoadStrip extends CellRegion { @@ -88,8 +89,8 @@ function harvestPart( if (!state[v]) { const x = position.getX(v); const z = position.getZ(v); - // 경계 선 위 정점도 안으로(제약이 점을 관통하지 않게). - state[v] = inRegion(x, z) || boundary.passesNear(x, z, ON_BOUNDARY_M) ? 1 : 2; + // 경계 선 위 · 둘레 SEAM_M 안 정점도 안으로(제약이 점을 관통하지 않게 · 잇는 띠 완만). + state[v] = inRegion(x, z) || boundary.passesNear(x, z, SEAM_M) ? 1 : 2; } return state[v] === 1; }; @@ -209,11 +210,12 @@ export function tinTerrain( segment += 1; } }); + // SEAM_M 만큼 넓힘 — 둘레 띠 안 정점을 쓰는 삼각형이 상자 밖이라 남아 틈이 되지 않게. const box = { - minX: Math.min(outer.minX, holes.minX), - maxX: Math.max(outer.maxX, holes.maxX), - minZ: Math.min(outer.minZ, holes.minZ), - maxZ: Math.max(outer.maxZ, holes.maxZ), + minX: Math.min(outer.minX, holes.minX) - SEAM_M, + maxX: Math.max(outer.maxX, holes.maxX) + SEAM_M, + minZ: Math.min(outer.minZ, holes.minZ) - SEAM_M, + maxZ: Math.max(outer.maxZ, holes.maxZ) + SEAM_M, }; const root = new THREE.Group(); diff --git a/resources/tester/helper_115_8_corridor_loft.cjs b/resources/tester/helper_115_8_corridor_loft.cjs index a99c2d037..c122162db 100644 --- a/resources/tester/helper_115_8_corridor_loft.cjs +++ b/resources/tester/helper_115_8_corridor_loft.cjs @@ -57,6 +57,7 @@ const { taubin } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Grid")); 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; @@ -257,7 +258,7 @@ function gapEdges(tris, out, original) { return { gaps, maxGapLen }; } -/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 1 mm 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */ +/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 SEAM_M 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */ function lostVertices(tris, before, out) { const region = new PSLG.LoopIndex([out.design.outer, ...out.design.holes]); const border = new PSLG.SegGrid(2); @@ -274,7 +275,7 @@ function lostVertices(tris, before, out) { for (let v = 0; v < before.length / 3; v += 1) { const x = before[v * 3]; const z = before[v * 3 + 2]; - if (region.contains(x, z) || border.passesNear(x, z, 1e-3)) continue; + if (region.contains(x, z) || border.passesNear(x, z, SEAM_M)) continue; if (!present.has(key(x, before[v * 3 + 1], z))) lost += 1; } return lost; @@ -450,6 +451,8 @@ function daylightPoints(out, bounds) { let sectionErr = 0; for (const side of [1, -1]) { for (let s = 0.05; s <= 12; s += 0.05) { + // 비탈 끝(s 9) 바깥 이음 띠(SEAM_M + 격자 0.5 m)는 지형 굴곡을 긴 삼각형으로 펴므로 뺀다(176-1). + if (s > 9 && s < 9 + SEAM_M + 0.5) continue; const z = tin(0, -side * (HALF + s)); // 씬 x = 20 − 20 if (z === null) continue; sectionErr = Math.max(sectionErr, Math.abs(z + 100 - Math.max(ROAD_Z - s / 1.5, 94))); diff --git a/resources/tester/helper_176_1_seam.cjs b/resources/tester/helper_176_1_seam.cjs new file mode 100644 index 000000000..31138097d --- /dev/null +++ b/resources/tester/helper_176_1_seam.cjs @@ -0,0 +1,263 @@ +/* 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())); diff --git a/resources/tester/test_176_1_seam.py b/resources/tester/test_176_1_seam.py new file mode 100644 index 000000000..6de7ecf0d --- /dev/null +++ b/resources/tester/test_176_1_seam.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +"""PLAN 176-1 — B05 3D 성토 비탈 끝 깨짐(172 뒤) 고침 검사. + +도우미(helper_176_1_seam.cjs) — 직선 80 m · 성토(0~40 m) → 절토(60~80 m) · 지반 사인 물결(진폭 1 m). +비탈 끝 바깥 SEAM_M(1.5 m) 안 지형 점 빼서 잇는 띠 완만 · 성토 덩이 끝 3 m 안 뜸 0 까지 줄임. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_176_1_seam.cjs")], + capture_output=True, + text=True, + timeout=240, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_seam_band_gentle(r): + # 성토 비탈 끝을 쓰는 원지반 삼각형 경사 p99 < 45°(1 mm 띠 = 54.7°) + assert r["seam"] == 1.5 + assert r["seamTris"] > 100, r + assert r["seamP99"] < 45, r + + +def test_run_end_lift_tapered(r): + # 성토 덩이 가운데는 한도까지 뜸(물결이 실제로 재어짐) · 절토로 바뀌는 끝은 0 근처 + assert r["midOff"] > 0.4, r + assert r["endX"][0] > 45 and r["endX"][1] > 45, r + assert r["endOff"] < 0.02, r + + +def test_no_gap_no_failed_constraint(r): + assert r["gaps"] == 0, r + assert r["stats"]["failed"] == 0, r