diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts index 93394cbd8..113de4888 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Loft.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts @@ -8,7 +8,8 @@ * ① · ② 처럼 행마다 지형과 다시 맞추지 않는다. * ③ 비탈 끝 선 = 평면에서 고르고 곡선 안쪽 꼬임은 포락선으로(`_Loft_Toe.ts`) — 원뿔 · 톱니 없음. * ④ 사면 격자 = 노선 1 m × 빗면 약 1 m(꺾임은 격자선) · 높이 = 단면 보간 + 드레이프(비탈 끝 높이를 - * 지형에 맞춘 보정량을 노견 끝 0 → 비탈 끝 1) · Taubin 면 평활화(`_Loft_Grid.ts`) → 실오라기 없음. + * 지형에 맞춘 보정량을 노견 끝 0 → 비탈 끝 1 · 성토는 노선 따라 고름) · Taubin 면 평활화 + * (`_Loft_Grid.ts`) → 실오라기 없음. * 지형 이음 · 구조물 구멍 · 구역 색 · 망 = 제안 ② 기계(`_Tin.ts` tinTerrain) 그대로 — 설계 점 원천만 바꿈. * 음영 = 사면만 꺾임 법선(`_Loft_Shade.ts`). * ========================================================================== */ @@ -49,7 +50,7 @@ import { stationLofts, } from "./B05_Profile_UI_Corridor_Loft_Profile"; import { smoothSlopeNormals } from "./B05_Profile_UI_Corridor_Loft_Shade"; -import { gauss, smoothToe } from "./B05_Profile_UI_Corridor_Loft_Toe"; +import { gauss, pinBack, smoothToe } from "./B05_Profile_UI_Corridor_Loft_Toe"; import { taubin, type GridRow } from "./B05_Profile_UI_Corridor_Loft_Grid"; // ponytail: 이보다 짧은 비탈은 없는 것으로(10 cm) — 부채꼴 끝 0.2 m 행 사이 실오라기 삼각형을 막음. @@ -63,6 +64,11 @@ const CURVE_SIGMA_M = 6; // ponytail: 설계 점 사이 최소 0.3 m — 곡선 안쪽에서 몰리는 격자 · 비탈 끝 점을 솎아 실오라기를 막음 · // 늘리면 곡선 안쪽 사면이 성겨짐. const POINT_GAP_M = 0.3; +// ponytail: 성토 비탈 끝 높이(드레이프 보정량) 노선 따라 가우스 σ 3 m · 지형과 차 ±0.5 m 까지(172-1) — 비탈 끝 +// 아래 지형 굴곡이 빗면 골 · 마루로 번지는 주름을 줄임 · 3D 참고용이라 측점 사이 비탈 끝이 지형에서 뜸 · +// 늘리면 더 매끈 · 더 뜸. +const LIFT_SIGMA_M = 3; +const LIFT_CAP_M = 0.5; /** 구조물 구멍 둘레 이만큼 안 격자 점은 뺀다(구멍 둘레 실오라기 막음 · m). */ const HOLE_CLEAR_M = 0.3; /** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */ @@ -294,6 +300,8 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { const reachOf = [0, 1].map(() => new Float64Array(n)); const shownOf = [0, 1].map(() => new Float64Array(n)); const liftOf = [0, 1].map(() => new Float64Array(n)); + /** 고른 비탈 끝 높이 − 지형(성토 · |값| ≤ LIFT_CAP_M). */ + const toeOff = [0, 1].map(() => new Float64Array(n)); [0, 1].forEach((si) => { const sign = si === 0 ? 1 : -1; const has = new Uint8Array(n); @@ -330,6 +338,21 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { shownOf[si][r] = toe.shown[r]; keyOf[si][r] = v.key; } + // 성토 비탈 끝 높이 고르기(172-1) — 성토 행끼리 · 측점 행은 B06 그대로 · 절토는 지형 그대로. + const fill = (r: number): boolean => keyOf[si][r] >= 0 && views[si][r]?.kind === 0; + const raw = liftOf[si]; + const smooth = pinBack( + chainages, + raw, + gauss(chainages, raw, LIFT_SIGMA_M, fill), + (r) => fill(r) && stationAt(chainages[r]) >= 0, + fill, + ); + 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])); + raw[r] += toeOff[si][r]; + } }); /** 행 · 측 사면 비율 u 자리 — 평면(노면 끝 → 비탈 끝 직선) · 높이(단면 + 드레이프). */ const placeAt = (r: number, si: number, v: View, u: number): [number, number, number] => { @@ -343,13 +366,14 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { /** 비탈 끝까지 닿은 행(지형 위 끝 · 둘레) — 포락선에서 멈춘 행은 안쪽 골 · 마루 점. */ const daylit = (si: number, r: number): boolean => shownOf[si][r] >= 1 - 1e-9; - // 격자 행 — 처음 · 끝 · 측점 · 1 m 걸음 · 단면 보기가 바뀌는 자리(성토 ↔ 절토 끊음) 앞뒤. + // 격자 행 — 처음 · 끝 · 측점 · 1 m 걸음 · 단면 보기가 바뀌는 자리(성토 ↔ 절토 끊음) 앞뒤 — 측점은 + // 앞 행 없이(측점 행이 앞뒤 짝 꼭짓점을 다 담음 · 0.2 m 덧행이 평활화에서 측점 꺾임을 키움). const mark = new Uint8Array(n); let previous = -Infinity; for (let r = 0; r < n; r += 1) { const c = rows[r].chainage_m; const changed = r > 0 && [0, 1].some((si) => keyOf[si][r] !== keyOf[si][r - 1]); - if (changed) mark[r - 1] = 1; + if (changed && stationAt(c) < 0) mark[r - 1] = 1; if ( r === 0 || r === n - 1 || @@ -399,7 +423,8 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { // 격자 행 비탈 끝은 남김 · 포락선에서 멈춘 점(골 · 마루)은 격자 행이라도 몰리면 솎음. if ((!mark[r] || !daylit(si, r)) && crowded(x, y)) continue; const ground = daylit(si, r) ? groundAt(x, y) : null; - daylight[si][r] = place(x, y, limited(x, y, ground ?? line), tagOf(v.kind)); + const toe = ground === null ? line : ground + toeOff[si][r]; + daylight[si][r] = place(x, y, limited(x, y, toe), tagOf(v.kind)); } }); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft_Toe.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft_Toe.ts index a153c2745..397b94e2c 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Loft_Toe.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft_Toe.ts @@ -41,7 +41,7 @@ export interface ToeLine { folded: number; } -/** 가우스 이동 평균(누가거리 기준 · ±3σ) — `only` 가 거짓인 행은 그대로(셈에는 든다). */ +/** 가우스 이동 평균(누가거리 기준 · ±3σ) — `only` 가 거짓인 행은 그대로(셈에서도 뺀다). */ export function gauss( chainage: ArrayLike, value: ArrayLike, @@ -58,6 +58,7 @@ export function gauss( let sum = 0; let weight = 0; for (let q = lo; q < n && chainage[q] <= c + 3 * sigma; q += 1) { + if (only && !only(q)) continue; const w = Math.exp(-((chainage[q] - c) ** 2) / (2 * sigma * sigma)); sum += w * value[q]; weight += w; @@ -67,6 +68,36 @@ export function gauss( return out; } +/** 고정 행(측점) 되돌림 — 고르기로 고정 행에 생긴 차를 고정 행 사이로 부드럽게(smoothstep) 나눠 + * 되돌림 → 고정 행 = 원래 값 · `only` 행만 고침. */ +export function pinBack( + chainage: ArrayLike, + raw: ArrayLike, + smooth: Float64Array, + pinned: (r: number) => boolean, + only: (r: number) => boolean, +): Float64Array { + const n = chainage.length; + const pins: number[] = []; + for (let r = 0; r < n; r += 1) if (pinned(r)) pins.push(r); + const out = Float64Array.from(smooth); + if (!pins.length) return out; + let p = 0; + for (let r = 0; r < n; r += 1) { + if (!only(r)) continue; + while (p + 1 < pins.length && pins[p + 1] <= r) p += 1; + const a = pins[p]; + const b = pins[Math.min(p + 1, pins.length - 1)]; + const da = raw[a] - smooth[a]; + const db = raw[b] - smooth[b]; + const span = chainage[b] - chainage[a]; + const t = r <= a || span <= 1e-9 ? 0 : Math.min(1, (chainage[r] - chainage[a]) / span); + const w = t * t * (3 - 2 * t); + out[r] = smooth[r] + da + (db - da) * w; + } + return out; +} + /** 한 측 비탈 끝 — 거리 상한 · 고르기 · 측점 고정 → 포락선(엇갈린 사면 선은 먼 쪽이 멈춤). */ export function smoothToe(rows: ToeRows): ToeLine { const n = rows.chainage.length; @@ -74,29 +105,17 @@ export function smoothToe(rows: ToeRows): ToeLine { Math.min(e, rows.maxReach[r]), ); for (let r = 0; r < n; r += 1) if (!rows.has[r]) raw[r] = 0; - const reach = gauss(rows.chainage, raw, REACH_SIGMA_M); - // 측점 고정 — 측점에서 고르기로 생긴 차를 측점 사이로 부드럽게(smoothstep) 나눠 되돌림. - const pins: number[] = []; // 비탈 없는 측점(0)도 고정점 — 이웃 측점의 큰 비탈이 번져 들지 않게. - for (let r = 0; r < n; r += 1) if (rows.pinned[r]) pins.push(r); - if (pins.length) { - const smooth = Float64Array.from(reach); - let p = 0; - for (let r = 0; r < n; r += 1) { - if (!rows.has[r]) continue; - while (p + 1 < pins.length && pins[p + 1] <= r) p += 1; - const a = pins[p]; - const b = pins[Math.min(p + 1, pins.length - 1)]; - const da = raw[a] - smooth[a]; - const db = raw[b] - smooth[b]; - const span = rows.chainage[b] - rows.chainage[a]; - const t = - r <= a || span <= 1e-9 ? 0 : Math.min(1, (rows.chainage[r] - rows.chainage[a]) / span); - const w = t * t * (3 - 2 * t); - reach[r] = Math.min(rows.maxReach[r], Math.max(0, reach[r] + da + (db - da) * w)); - } + const reach = pinBack( + rows.chainage, + raw, + gauss(rows.chainage, raw, REACH_SIGMA_M), + (r) => rows.pinned[r] > 0, + (r) => rows.has[r] > 0, + ); + for (let r = 0; r < n; r += 1) { + reach[r] = rows.has[r] ? Math.min(rows.maxReach[r], Math.max(0, reach[r])) : 0; } - for (let r = 0; r < n; r += 1) if (!rows.has[r]) reach[r] = 0; // 포락선 — 사면 선끼리 엇갈린 자리에서 그 자리까지 더 먼 선이 멈춘다(같으면 뒤 행). const ex = (r: number): number => rows.ax[r] + rows.nx[r] * reach[r]; diff --git a/resources/tester/helper_172_1_loft_wrinkle.cjs b/resources/tester/helper_172_1_loft_wrinkle.cjs new file mode 100644 index 000000000..3f7c44d7b --- /dev/null +++ b/resources/tester/helper_172_1_loft_wrinkle.cjs @@ -0,0 +1,298 @@ +/* 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 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 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) })); diff --git a/resources/tester/test_115_10_loft_smooth.py b/resources/tester/test_115_10_loft_smooth.py index 708bc228e..95e75e35c 100644 --- a/resources/tester/test_115_10_loft_smooth.py +++ b/resources/tester/test_115_10_loft_smooth.py @@ -55,7 +55,7 @@ def test_no_gap_no_failed_constraint(results): def test_fixed_edges_and_station_section(results): r = results["straight"] - assert r["drapeErr"] < 1e-4 # 비탈 끝 = 지형 위(고정) + assert r["drapeErr"] <= 0.5 + 1e-6 # 비탈 끝 = 지형 ±0.5 m(172-1 성토 고르기) assert r["sectionErr"] < 1e-4 and r["offsetErr"] < 1e-4 # 측점 단면 · 비탈 끝 = B06 assert results["berm"]["sectionErr"] < 1e-4 diff --git a/resources/tester/test_115_8_corridor_loft.py b/resources/tester/test_115_8_corridor_loft.py index e6e291ca4..3c3dcbcd8 100644 --- a/resources/tester/test_115_8_corridor_loft.py +++ b/resources/tester/test_115_8_corridor_loft.py @@ -42,7 +42,9 @@ def test_station_section_matches_b06(results): def test_daylight_draped_on_terrain(results): - assert results["straight"]["drapeErr"] < 1e-4 # 비탈 끝 높이 = 망 지형 + assert ( + results["straight"]["drapeErr"] <= 0.5 + 1e-6 + ) # 비탈 끝 높이 = 망 지형 ±0.5 m(172-1 성토 고르기) def test_no_gap_and_terrain_outside_untouched(results): diff --git a/resources/tester/test_172_1_loft_wrinkle.py b/resources/tester/test_172_1_loft_wrinkle.py new file mode 100644 index 000000000..4c695a5ff --- /dev/null +++ b/resources/tester/test_172_1_loft_wrinkle.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""PLAN 172-1 — B05 3D 제안 ③ 성토 사면 종단 주름 줄임 검사. + +도우미(helper_172_1_loft_wrinkle.cjs) — 직선 성토 · 비탈 끝 아래 지형 사인 물결(파장 5 m). +비탈 끝 높이 노선 따라 고르기(σ 3 m · 지형 ±0.5 m) · 측점 단면 = B06 · 측점 앞 덧행 없음 · 틈 0. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_172_1_loft_wrinkle.cjs")], + capture_output=True, + text=True, + timeout=240, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_mid_slope_wrinkle_halved(results): + # 사면 가운데 종단 2차 차분 p90 — 드레이프 그대로(지형 물결의 반)의 절반 밑 + for name in ("gentle", "rough"): + r = results[name] + assert r["midSamples"] > 50, (name, r) + assert r["midD2P90"] < 0.5 * r["refD2P90"], (name, r) + + +def test_toe_within_cap(results): + for name in ("gentle", "rough"): + r = results[name] + assert r["toes"] > 100, (name, r) + assert r["toeOff"] <= 0.5 + 1e-6, (name, r) # 비탈 끝 = 지형 ±0.5 m + assert r["stationToeOff"] < 1e-4, (name, r) # 측점 비탈 끝 = 지형(B06) + assert results["rough"]["toeOff"] > 0.4 # 물결이 크면 한도까지 뜸 + + +def test_station_section_kept(results): + for name in ("gentle", "rough"): + assert results[name]["sectionErr"] < 1e-4, name + + +def test_no_row_before_station(results): + for name in ("gentle", "rough"): + assert results[name]["preRowPoints"] == 0, name + + +def test_no_gap_no_failed_constraint(results): + for name in ("gentle", "rough"): + assert results[name]["gaps"] == 0, name + assert results[name]["stats"]["failed"] == 0, name