mirror of
https://github.com/Nexoniarz/Fry3DX.git
synced 2026-11-03 04:24:26 +00:00
Fuzz test (tests/fuzz_test.cpp): random shapes through random edits and combines, broken scene and model files; in check and CI. Fixed what it found: Manifold given repeated or non-finite points (crash; the BSP takes those now), a damaged file's non-finite numbers (now 0), extruding both sides of a plane (NaN)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b95e436d10
commit
9918d9a770
13 changed files with 294 additions and 6 deletions
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
|
|
@ -32,6 +32,11 @@ jobs:
|
|||
run: |
|
||||
cmake --preset portable -DGLSLC=$ANDROID_NDK_HOME/shader-tools/linux-x86_64/glslc
|
||||
cmake --build build/portable
|
||||
- name: Test
|
||||
# The modelling core, the scripts, and the fuzz test (random shapes, edits, broken files).
|
||||
run: |
|
||||
cmake --build build/portable --target check
|
||||
build/portable/bin/fuzz_test 2000
|
||||
- name: Package
|
||||
run: APPIMAGETOOL=$PWD/appimagetool python3 tools/package.py linux appimage
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
|
|
|||
|
|
@ -58,4 +58,4 @@ add_custom_target(content ALL
|
|||
enable_testing()
|
||||
add_subdirectory(tests EXCLUDE_FROM_ALL)
|
||||
add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} --output-on-failure
|
||||
DEPENDS mesh_test script_test USES_TERMINAL VERBATIM)
|
||||
DEPENDS mesh_test script_test fuzz_test USES_TERMINAL VERBATIM)
|
||||
|
|
|
|||
BIN
core.35446
Normal file
BIN
core.35446
Normal file
Binary file not shown.
BIN
core.35463
Normal file
BIN
core.35463
Normal file
Binary file not shown.
BIN
core.35933
Normal file
BIN
core.35933
Normal file
Binary file not shown.
BIN
core.35947
Normal file
BIN
core.35947
Normal file
Binary file not shown.
BIN
core.36411
Normal file
BIN
core.36411
Normal file
Binary file not shown.
BIN
core.36564
Normal file
BIN
core.36564
Normal file
Binary file not shown.
|
|
@ -343,11 +343,18 @@ std::optional<Mesh> combineManifold(const Mesh& a, const Mesh& b, Combine op) {
|
|||
manifold::MeshGL g;
|
||||
g.numProp = 3;
|
||||
g.vertProperties.reserve(m.verts.size() * 3);
|
||||
for (const glm::vec3& v : m.verts) g.vertProperties.insert(g.vertProperties.end(), {v.x, v.y, v.z});
|
||||
for (const glm::vec3& v : m.verts) {
|
||||
if (!std::isfinite(v.x) || !std::isfinite(v.y) || !std::isfinite(v.z)) return std::nullopt; // (it can't take those)
|
||||
g.vertProperties.insert(g.vertProperties.end(), {v.x, v.y, v.z});
|
||||
}
|
||||
for (size_t f = 0; f < m.faces.size(); ++f) {
|
||||
if (m.faces[f].size() < 3) return std::nullopt; // (a line: not a solid)
|
||||
const auto& fv = m.faces[f];
|
||||
if (fv.size() < 3) return std::nullopt; // (a line: not a solid)
|
||||
for (size_t i = 0; i < fv.size(); ++i) // (a point twice round a face, or one that isn't there: it can't take that either)
|
||||
if (fv[i] < 0 || size_t(fv[i]) >= m.verts.size() || std::find(fv.begin() + long(i) + 1, fv.end(), fv[i]) != fv.end()) return std::nullopt;
|
||||
for (const auto& t : triangulate(m, int(f)))
|
||||
g.triVerts.insert(g.triVerts.end(), {uint32_t(t[0]), uint32_t(t[1]), uint32_t(t[2])}), g.faceID.push_back(firstFace + uint32_t(f));
|
||||
if (t[0] == t[1] || t[1] == t[2] || t[0] == t[2]) return std::nullopt;
|
||||
else g.triVerts.insert(g.triVerts.end(), {uint32_t(t[0]), uint32_t(t[1]), uint32_t(t[2])}), g.faceID.push_back(firstFace + uint32_t(f));
|
||||
}
|
||||
if (g.triVerts.empty()) return std::nullopt;
|
||||
g.runOriginalID = {manifold::Manifold::ReserveIDs(1)}; // (so it keeps the faces given, not its own numbering)
|
||||
|
|
|
|||
|
|
@ -289,13 +289,14 @@ std::vector<int> extrudeFaces(Mesh& m, const std::set<int>& faces, float distanc
|
|||
}
|
||||
}
|
||||
// One flat piece moves as one, along its normal; a bent one, each point along its own.
|
||||
region = glm::length(region) > 1e-12f ? glm::normalize(region) : glm::vec3(0, 0, 1);
|
||||
// (faces facing opposite ways, a plane's two sides: theirs cancel out; the first one's way)
|
||||
region = glm::length(region) > 1e-12f ? glm::normalize(region) : faces.empty() ? glm::vec3(0, 0, 1) : faceNormal(m, *faces.begin());
|
||||
bool flat = true;
|
||||
for (int f : faces) flat &= glm::dot(faceNormal(m, f), region) > 0.99f;
|
||||
std::map<int, int> moved;
|
||||
std::vector<int> made;
|
||||
for (auto& [v, n] : along) {
|
||||
const glm::vec3 dir = flat ? region : glm::normalize(n);
|
||||
const glm::vec3 dir = flat || glm::length(n) < 1e-9f ? region : glm::normalize(n);
|
||||
moved[v] = int(m.verts.size());
|
||||
made.push_back(moved[v]);
|
||||
m.verts.push_back(m.verts[size_t(v)] + dir * distance);
|
||||
|
|
|
|||
|
|
@ -423,6 +423,18 @@ std::optional<Scene> parseScene(std::istream& in, const std::string& path, std::
|
|||
p = std::find_if(s.folders.begin(), s.folders.end(), [&](const Folder& g) { return g.id == p; })->parent;
|
||||
}
|
||||
for (Object& o : s.objects) o.folder = idAt(o.folder);
|
||||
// A number that isn't one (a file damaged, or edited by hand: "1e999", "nan"): nothing there.
|
||||
auto finite = [](glm::vec3& v, glm::vec3 instead) {
|
||||
if (!std::isfinite(v.x) || !std::isfinite(v.y) || !std::isfinite(v.z)) v = instead;
|
||||
};
|
||||
for (Object& o : s.objects) {
|
||||
for (glm::vec3& v : o.mesh.verts) finite(v, glm::vec3(0.0f));
|
||||
finite(o.position, glm::vec3(0.0f)), finite(o.scale, glm::vec3(1.0f));
|
||||
if (!std::isfinite(o.rotation.w) || !std::isfinite(o.rotation.x) || !std::isfinite(o.rotation.y) || !std::isfinite(o.rotation.z) || glm::length(o.rotation) < 1e-6f)
|
||||
o.rotation = glm::quat(1, 0, 0, 0);
|
||||
else
|
||||
o.rotation = glm::normalize(o.rotation);
|
||||
}
|
||||
for (Object& o : s.objects) o.cuts = o.cuts >= 1 && o.cuts <= s.objects.size() && &s.objects[o.cuts - 1] != &o ? s.objects[o.cuts - 1].id : 0u;
|
||||
return s;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,3 +6,7 @@ add_executable(script_test script_test.cpp)
|
|||
target_link_libraries(script_test PRIVATE fry_script)
|
||||
target_compile_definitions(script_test PRIVATE FRY_SCRIPTS_DIR="${PROJECT_SOURCE_DIR}/content/Fry3DX/scripts" FRY_PLUGINS_DIR="${PROJECT_SOURCE_DIR}/content/Fry3DX/plugins")
|
||||
add_test(NAME script_test COMMAND script_test)
|
||||
# Random shapes, edits and broken files thrown at the core: nothing may crash (the same each run).
|
||||
add_executable(fuzz_test fuzz_test.cpp)
|
||||
target_link_libraries(fuzz_test PRIVATE fry_core)
|
||||
add_test(NAME fuzz_test COMMAND fuzz_test)
|
||||
|
|
|
|||
259
tests/fuzz_test.cpp
Normal file
259
tests/fuzz_test.cpp
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// Shaking the modelling core: random shapes put through random edits, combined at random, and
|
||||
// files made of nonsense or with bits knocked out of them read. Nothing may crash or hang, every
|
||||
// face must point at points that are there, and shapes closed going into a combine come out
|
||||
// closed. The same each run (a fixed seed); fuzz_test N for more rounds, fuzz_test N S another seed.
|
||||
#include "import.h"
|
||||
#include "mesh.h"
|
||||
#include "scene.h"
|
||||
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
using namespace fry;
|
||||
|
||||
namespace {
|
||||
|
||||
std::mt19937 rng;
|
||||
int failures = 0;
|
||||
std::string lastEdit; // (what the last edit was, exactly, for when it went wrong)
|
||||
|
||||
// A mesh as an OBJ (to look at what went wrong).
|
||||
void saveObj(const Mesh& m, const std::string& path) {
|
||||
std::ofstream out(path);
|
||||
for (const glm::vec3& v : m.verts) out << "v " << v.x << " " << v.y << " " << v.z << "\n";
|
||||
for (const auto& f : m.faces) {
|
||||
out << "f";
|
||||
for (int v : f) out << " " << v + 1;
|
||||
out << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
float uniform(float lo, float hi) { return std::uniform_real_distribution<float>(lo, hi)(rng); }
|
||||
int pick(int lo, int hi) { return std::uniform_int_distribution<int>(lo, hi)(rng); }
|
||||
|
||||
void fail(int round, const char* what, const char* step) {
|
||||
++failures;
|
||||
std::printf("FAIL round %d: %s (after %s)\n", round, what, step);
|
||||
}
|
||||
|
||||
// Every face has 2 or more points and each is a point of the mesh; no point is NaN or infinite.
|
||||
bool whole(const Mesh& m) {
|
||||
for (const glm::vec3& v : m.verts)
|
||||
if (!std::isfinite(v.x) || !std::isfinite(v.y) || !std::isfinite(v.z)) return false;
|
||||
for (const auto& f : m.faces) {
|
||||
if (f.size() < 2) return false;
|
||||
for (int v : f)
|
||||
if (v < 0 || size_t(v) >= m.verts.size()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Mesh randomShape() {
|
||||
Mesh m;
|
||||
switch (pick(0, 6)) {
|
||||
case 0: m = makeCube(uniform(0.1f, 2)); break;
|
||||
case 1: m = makeCylinder(pick(3, 40), uniform(0.05f, 1), uniform(0.05f, 2)); break;
|
||||
case 2: m = makeSphere(pick(3, 32), pick(2, 16), uniform(0.05f, 1)); break;
|
||||
case 3: m = makeCone(pick(3, 30), uniform(0.05f, 1), uniform(0.05f, 2)); break;
|
||||
case 4: m = makeTorus(pick(3, 32), pick(3, 16), uniform(0.2f, 1), uniform(0.02f, 0.19f)); break;
|
||||
case 5: m = makeFrustum(pick(3, 24), uniform(0.05f, 1), uniform(0.0f, 1), uniform(0.05f, 2)); break;
|
||||
default: m = makePlane(uniform(0.1f, 2)); break;
|
||||
}
|
||||
glm::mat4 t = glm::translate(glm::mat4(1.0f), glm::vec3(uniform(-0.5f, 0.5f), uniform(-0.5f, 0.5f), uniform(-0.5f, 0.5f)));
|
||||
t = glm::rotate(t, uniform(0, 6.3f), glm::normalize(glm::vec3(uniform(-1, 1), uniform(-1, 1), uniform(0.1f, 1))));
|
||||
t = glm::scale(t, glm::vec3(uniform(0.3f, 2), uniform(0.3f, 2), uniform(0.3f, 2)));
|
||||
transform(m, t);
|
||||
return m;
|
||||
}
|
||||
|
||||
std::set<int> someFaces(const Mesh& m) {
|
||||
std::set<int> s;
|
||||
if (m.faces.empty()) return s;
|
||||
for (int i = pick(1, 4); i > 0; --i) s.insert(pick(0, int(m.faces.size()) - 1));
|
||||
return s;
|
||||
}
|
||||
std::set<int> somePoints(const Mesh& m) {
|
||||
std::set<int> s;
|
||||
if (m.verts.empty()) return s;
|
||||
for (int i = pick(1, 6); i > 0; --i) s.insert(pick(0, int(m.verts.size()) - 1));
|
||||
return s;
|
||||
}
|
||||
std::pair<int, int> someEdge(const Mesh& m) {
|
||||
const auto e = edges(m);
|
||||
return e.empty() ? std::pair(0, 0) : e[size_t(pick(0, int(e.size()) - 1))];
|
||||
}
|
||||
|
||||
// One random edit; its name, for when something's wrong after it.
|
||||
const char* randomEdit(Mesh& m) {
|
||||
if (m.faces.empty()) return "nothing";
|
||||
switch (pick(0, 17)) {
|
||||
case 0: {
|
||||
const auto fs = someFaces(m);
|
||||
const float d = uniform(-0.3f, 0.5f);
|
||||
lastEdit = "extrude " + std::to_string(d) + " faces";
|
||||
for (int f : fs) lastEdit += " " + std::to_string(f);
|
||||
extrudeFaces(m, fs, d);
|
||||
return "extrude";
|
||||
}
|
||||
case 1: insetFaces(m, someFaces(m), uniform(0, 0.95f)); return "inset";
|
||||
case 2: flipFaces(m, someFaces(m)); return "flip";
|
||||
case 3: deleteFaces(m, someFaces(m)); return "delete faces";
|
||||
case 4: deleteVerts(m, somePoints(m)); return "delete points";
|
||||
case 5: mergeByDistance(m, uniform(0, 0.2f)); return "merge by distance";
|
||||
case 6: mergeToCenter(m, somePoints(m)); return "merge to centre";
|
||||
case 7: {
|
||||
std::set<std::pair<int, int>> e;
|
||||
for (int i = pick(1, 3); i > 0; --i) e.insert(someEdge(m));
|
||||
bevelEdges(m, e, uniform(0.001f, 0.2f), pick(1, 4));
|
||||
return "bevel";
|
||||
}
|
||||
case 8:
|
||||
if (m.faces.size() < 3000) subdivide(m, pick(0, 1), uniform(0, 180));
|
||||
return "subdivide";
|
||||
case 9: loopCut(m, someEdge(m), pick(1, 3)); return "loop cut";
|
||||
case 10: smoothPoints(m, somePoints(m), uniform(0, 1), pick(1, 3)); return "smooth points";
|
||||
case 11: pushPoints(m, somePoints(m), uniform(-0.2f, 0.2f)); return "push points";
|
||||
case 12: closeHoles(m); return "close holes";
|
||||
case 13: faceOutward(m); return "face outward";
|
||||
case 14: repairForPrint(m); return "repair";
|
||||
case 15: mergeFlatFaces(m); return "merge flat faces";
|
||||
case 16: {
|
||||
const auto fs = someFaces(m);
|
||||
if (fs.size() >= 2) bridgeFaces(m, *fs.begin(), *fs.rbegin());
|
||||
return "bridge";
|
||||
}
|
||||
default: {
|
||||
const int f = pick(0, int(m.faces.size()) - 1);
|
||||
if (m.faces[size_t(f)].size() >= 3) cutHole(m, f, holeOutline(m, f, faceCenter(m, f), uniform(0.01f, 0.4f), pick(3, 24)), uniform(-1, 0.5f));
|
||||
return "hole";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever comes out, looked at the ways the app will (it mustn't crash those either).
|
||||
void lookAt(const Mesh& m) {
|
||||
for (size_t f = 0; f < m.faces.size(); ++f)
|
||||
if (m.faces[f].size() >= 3) (void)triangulate(m, int(f));
|
||||
(void)checkForPrint(m);
|
||||
(void)cornerNormals(m, true, 30);
|
||||
(void)sliceAt(m, (boundsMin(m).z + boundsMax(m).z) * 0.5f);
|
||||
bool loop = false;
|
||||
(void)alongPath(pathOf(m, loop), loop, 7);
|
||||
}
|
||||
|
||||
// Bytes knocked about: some changed, some cut out, some put in twice.
|
||||
std::string mangle(std::string s) {
|
||||
for (int i = pick(1, 12); i > 0 && !s.empty(); --i) {
|
||||
const size_t at = size_t(pick(0, int(s.size()) - 1));
|
||||
switch (pick(0, 3)) {
|
||||
case 0: s[at] = char(pick(0, 255)); break;
|
||||
case 1: s.erase(at, size_t(pick(1, 40))); break;
|
||||
case 2: s.insert(at, s.substr(at, size_t(pick(1, 40)))); break;
|
||||
default: s.resize(at); break;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const int rounds = argc > 1 ? std::max(1, std::atoi(argv[1])) : 150;
|
||||
rng.seed(argc > 2 ? unsigned(std::atoi(argv[2])) : 20261021u);
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
int combined = 0, closedIn = 0;
|
||||
for (int r = 0; r < rounds; ++r) {
|
||||
// Edits, one after another, on one shape.
|
||||
Mesh m = randomShape();
|
||||
for (int k = pick(1, 6); k > 0; --k) {
|
||||
const Mesh before = m;
|
||||
const char* step = randomEdit(m);
|
||||
if (!whole(m)) {
|
||||
if (std::getenv("FRY_FUZZ_KEEP")) {
|
||||
saveObj(before, std::getenv("FRY_FUZZ_KEEP")), std::printf(" %s\n", lastEdit.c_str());
|
||||
for (const glm::vec3& v : m.verts) std::printf(" (%g %g %g)", v.x, v.y, v.z);
|
||||
std::printf("\n");
|
||||
}
|
||||
fail(r, "a face points at a point that isn't there, or a point isn't a number", step);
|
||||
break;
|
||||
}
|
||||
lookAt(m);
|
||||
}
|
||||
// Two shapes combined: closed going in, closed coming out.
|
||||
Mesh a = randomShape();
|
||||
const Mesh b = randomShape();
|
||||
const bool bothClosed = checkForPrint(a).closed() && checkForPrint(b).closed();
|
||||
const Combine op = Combine(pick(0, 2));
|
||||
const bool any = combine(a, b, op);
|
||||
++combined;
|
||||
if (!whole(a)) fail(r, "combined: broken", "combine");
|
||||
else if (bothClosed && any) {
|
||||
++closedIn;
|
||||
const PrintCheck c = checkForPrint(a);
|
||||
if (!c.closed()) {
|
||||
std::printf(" (op %d: %d open edges, %zu faces)\n", int(op), c.openEdges, a.faces.size());
|
||||
fail(r, "two closed shapes combined into one that isn't closed", "combine");
|
||||
}
|
||||
}
|
||||
lookAt(a);
|
||||
// Spun, as the Spin window does (an open shape: a plane).
|
||||
Mesh s = spin(makePlane(uniform(0.1f, 1)), glm::vec3(uniform(-1, 1), 0, 0), glm::vec3(0, 0, 1), uniform(-720, 720), pick(1, 64), uniform(-1, 1));
|
||||
if (!whole(s)) fail(r, "spun: broken", "spin");
|
||||
}
|
||||
// Files: nonsense, and real ones with bits knocked out, read as each kind.
|
||||
const auto dir = std::filesystem::temp_directory_path() / "fry3dx_fuzz";
|
||||
std::filesystem::create_directories(dir);
|
||||
Scene sc;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
Object o;
|
||||
o.id = sc.nextId++, o.name = "o" + std::to_string(i), o.mesh = randomShape(), o.mirror = i, o.cuts = i == 2 ? 1 : 0;
|
||||
sc.objects.push_back(o);
|
||||
}
|
||||
const std::string sceneFile = sceneText(sc);
|
||||
std::string objFile = "o thing\n";
|
||||
for (const glm::vec3& v : sc.objects[0].mesh.verts) objFile += "v " + std::to_string(v.x) + " " + std::to_string(v.y) + " " + std::to_string(v.z) + "\n";
|
||||
for (const auto& f : sc.objects[0].mesh.faces) {
|
||||
objFile += "f";
|
||||
for (int v : f) objFile += " " + std::to_string(v + 1);
|
||||
objFile += "\n";
|
||||
}
|
||||
std::string stl = "solid x\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid x\n";
|
||||
const int files = std::max(30, rounds);
|
||||
for (int i = 0; i < files; ++i) {
|
||||
const std::string text = mangle(sceneFile);
|
||||
std::ofstream(dir / "last.fry", std::ios::binary) << text; // (left there if it crashes: the file that did it)
|
||||
std::istringstream in(text);
|
||||
if (auto s = parseScene(in, "fuzz.fry")) {
|
||||
for (const Object& o : s->objects)
|
||||
if (!whole(o.mesh)) fail(i, "a scene read with a broken object", "parseScene");
|
||||
(void)withCutsMade(*s);
|
||||
}
|
||||
const char* ext[] = {".obj", ".stl", ".3mf", ".glb", ".gltf"};
|
||||
const int kind = pick(0, 4);
|
||||
std::string bytes = kind == 0 ? mangle(objFile) : kind == 1 ? mangle(stl) : std::string();
|
||||
if (bytes.empty())
|
||||
for (int n = pick(0, 600); n > 0; --n) bytes += char(pick(0, 255));
|
||||
if (kind == 2 && pick(0, 1)) bytes = "PK\x03\x04" + bytes; // (looks like a zip at first)
|
||||
if (kind == 3 && pick(0, 1)) bytes = std::string("glTF\x02\x00\x00\x00", 8) + bytes;
|
||||
const auto path = (dir / ("f" + std::string(ext[kind]))).string();
|
||||
std::ofstream(path, std::ios::binary) << bytes;
|
||||
for (const ImportedMesh& im : importModel(path))
|
||||
if (!whole(im.mesh)) fail(i, "a file read into a broken mesh", ext[kind]);
|
||||
std::vector<uint8_t> raw(bytes.begin(), bytes.end());
|
||||
(void)inflate(raw.data(), raw.size(), size_t(pick(0, 4096)));
|
||||
}
|
||||
std::filesystem::remove_all(dir);
|
||||
const double s = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
||||
std::printf("%d rounds, %d combines (%d of closed shapes), %d files: %s in %.1f s\n", rounds, combined, closedIn, files * 2, failures ? "FAILED" : "passed", s);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
Loading…
Reference in a new issue