// 기슭막이 벽 경로선(B05_Profile_UI_Corridor_Structures_Path) 자체검증 — 로직 복제. // 연동을 푼 측점이 있으면 벽이 노선과 평행이 아니라 자기 경로를 따라간다(2026-08-30). // 실행: node tmp/tests/test_revet_wall_path.mjs import assert from "node:assert/strict"; /* ── 복제: buildWallPath / splineValue / polygonAt ───────────────────── */ function wallPolygonOf(layout, key) { const wall = key.startsWith("bextra") ? layout.basinExtras[Number(key.slice(6))] : key.startsWith("extra") ? layout.extraWalls[Number(key.slice(5))] : layout.walls.find((candidate) => candidate.role === key); if (!wall || wall.points.length < 3) return null; return wall.points.map((point) => [point.offset, point.elevation]); } function buildWallPath(base, others, key) { const path = [base]; for (const other of others) { const polygon = wallPolygonOf(other.layout, key); if (!polygon || polygon.length !== base.polygon.length) continue; path.push({ at: other.at, polygon }); } if (path.length < 2) return null; return path.sort((a, b) => a.at - b.at); } function splineValue(values, ats, at) { const last = values.length - 1; if (last < 1) return values[0]; if (at <= ats[0]) return values[0]; if (at >= ats[last]) return values[last]; let i = 0; while (i < last - 1 && ats[i + 1] < at) i += 1; const span = ats[i + 1] - ats[i]; const t = span <= 1e-9 ? 0 : (at - ats[i]) / span; const p0 = i > 0 ? values[i - 1] : values[i]; const p1 = values[i]; const p2 = values[i + 1]; const p3 = i + 2 <= last ? values[i + 2] : values[i + 1]; const t2 = t * t; const t3 = t2 * t; return ( 0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3) ); } function polygonAt(path, at) { const ats = path.map((control) => control.at); return path[0].polygon.map((_vertex, index) => [ splineValue( path.map((control) => control.polygon[index][0]), ats, at, ), splineValue( path.map((control) => control.polygon[index][1]), ats, at, ), ]); } /* ── 시험 자료 — 사각 벽 하나. 소유 측점 200, 연동 해제 측점 205(0.5m 바깥). ── */ const wall = (offset) => ({ role: "outlet", points: [ { offset, elevation: 10 }, { offset, elevation: 12 }, { offset: offset + 0.5, elevation: 12 }, { offset: offset + 0.5, elevation: 10 }, ], }); const base = { at: 200, polygon: wallPolygonOf({ walls: [wall(3)] }, "outlet") }; const others = [{ at: 205, layout: { walls: [wall(3.5)], extraWalls: [], basinExtras: [] } }]; const path = buildWallPath(base, others, "outlet"); assert.ok(path, "제어점 2개면 경로선이 생겨야 한다"); assert.equal(path.length, 2); assert.deepEqual( path.map((control) => control.at), [200, 205], "제어점은 누가거리 오름차순", ); // 끝점은 그 측점 값 그대로. assert.equal(polygonAt(path, 200)[0][0], 3); assert.equal(polygonAt(path, 205)[0][0], 3.5); // 범위 밖은 끝값 유지(외삽 금지 — 벽이 연장 밖으로 새면 안 된다). assert.equal(polygonAt(path, 190)[0][0], 3); assert.equal(polygonAt(path, 220)[0][0], 3.5); // 사이는 두 값 사이(제어점 2개 = 선형). const mid = polygonAt(path, 202.5)[0][0]; assert.ok(Math.abs(mid - 3.25) < 1e-9, `중간 보간 어긋남: ${mid}`); // 표고는 두 측점이 같으므로 보간해도 그대로. assert.equal(polygonAt(path, 202.5)[0][1], 10); // 꼭짓점 수는 링마다 같아야 로프트가 된다. assert.equal(polygonAt(path, 202.5).length, base.polygon.length); // 제어점 3개 — 가운데가 튀면 스플라인이 그 값을 지난다(경로선이 측점을 통과). const three = buildWallPath(base, [ { at: 203, layout: { walls: [wall(4)], extraWalls: [], basinExtras: [] } }, { at: 205, layout: { walls: [wall(3.5)], extraWalls: [], basinExtras: [] } }, ], "outlet"); assert.equal(three.length, 3); assert.equal(polygonAt(three, 203)[0][0], 4, "제어 측점 자리는 그 측점 값 그대로"); // 꼭짓점 수가 다른 측점은 제어점에서 빠진다(로프트 토폴로지 보호). const ragged = buildWallPath(base, [ { at: 205, layout: { walls: [{ role: "outlet", points: wall(3.5).points.slice(0, 3) }], extraWalls: [], basinExtras: [], }, }, ], "outlet"); assert.equal(ragged, null, "꼭짓점 수가 어긋나면 경로선 없이 소유 폴리곤 고정"); // 다단 키도 같은 규칙으로 짝짓는다. const tierBase = { at: 200, polygon: wallPolygonOf({ walls: [], extraWalls: [wall(5)] }, "extra0") }; const tierPath = buildWallPath( tierBase, [{ at: 205, layout: { walls: [], extraWalls: [wall(5.4)], basinExtras: [] } }], "extra0", ); assert.ok(tierPath && Math.abs(polygonAt(tierPath, 205)[0][0] - 5.4) < 1e-9); console.log("test_revet_wall_path.mjs OK");