diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cb3268d..16eb0e1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index dbd60f3..575fc3e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/core.35446 b/core.35446 new file mode 100644 index 0000000..f679297 Binary files /dev/null and b/core.35446 differ diff --git a/core.35463 b/core.35463 new file mode 100644 index 0000000..9f663b1 Binary files /dev/null and b/core.35463 differ diff --git a/core.35933 b/core.35933 new file mode 100644 index 0000000..7b0c47c Binary files /dev/null and b/core.35933 differ diff --git a/core.35947 b/core.35947 new file mode 100644 index 0000000..0acfa6a Binary files /dev/null and b/core.35947 differ diff --git a/core.36411 b/core.36411 new file mode 100644 index 0000000..e815dfa Binary files /dev/null and b/core.36411 differ diff --git a/core.36564 b/core.36564 new file mode 100644 index 0000000..f450b61 Binary files /dev/null and b/core.36564 differ diff --git a/src/boolean.cpp b/src/boolean.cpp index 45f3ecc..d9cd62b 100644 --- a/src/boolean.cpp +++ b/src/boolean.cpp @@ -343,11 +343,18 @@ std::optional 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) diff --git a/src/mesh.cpp b/src/mesh.cpp index 6738a64..e828a4e 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -289,13 +289,14 @@ std::vector extrudeFaces(Mesh& m, const std::set& 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 moved; std::vector 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); diff --git a/src/scene.cpp b/src/scene.cpp index 10f4717..ad60cb4 100644 --- a/src/scene.cpp +++ b/src/scene.cpp @@ -423,6 +423,18 @@ std::optional 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; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f6c49ab..4576576 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/fuzz_test.cpp b/tests/fuzz_test.cpp new file mode 100644 index 0000000..e6e07c1 --- /dev/null +++ b/tests/fuzz_test.cpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(lo, hi)(rng); } +int pick(int lo, int hi) { return std::uniform_int_distribution(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 someFaces(const Mesh& m) { + std::set 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 somePoints(const Mesh& m) { + std::set 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 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> 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 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(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; +}