Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1247,7 +1247,10 @@ grep for `ponytail:`.
without `-1` counts), and X3D's `IndexedTriangleSet`/`TriangleSet` are read. X3D goes through a
small real XML parser (comments, CDATA, DOCTYPE, entities); VRML through a tokenizer + generic
node parser that steps over `PROTO`/`EXTERNPROTO`/`ROUTE` (VRML 1.0 is refused by name). AMF's
`unit` scales to mm, and a zipped AMF (`PK` header) is read through `readStoredZipEntryBySuffix`.
`unit` is ignored (OpenSCAD reads millimetres whatever it says), a zipped AMF (`PK` header) is read
through `readStoredZipEntryBySuffix`, and each AMF object's vertices are welded by exact position
(`weldVertices`, STL's), so a closed object written as a triangle soup imports closed, as in
OpenSCAD -- per object, never across objects, and never near-coincident vertices.
What is not a mesh — primitives (Box, Sphere, ...), `Inline`, AMF constellations — lands in
`LoadedMesh::warnings`, which both `import()` faces emit as `import: '<file>': ...`.
- `include/openscad_cpp_evaluator/import_builtin.hpp`, `include/openscad_cpp_evaluator/
Expand Down
3 changes: 3 additions & 0 deletions include/openscad_cpp_evaluator/mesh_import.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ struct LoadedMesh {
// "import: {what}" on parse/IO failure. Mirrors _load_stl/_load_obj/
// _load_off/_load_3mf exactly (including STL's exact-match vertex welding
// and OBJ/OFF's fan triangulation of >3-gon faces).
// Merges vertices at exactly equal positions (STL's corners, an AMF
// object's duplicated vertices); every position keeps its first index.
LoadedMesh weldVertices(const std::vector<std::array<double, 3>>& verts, const std::vector<std::array<int, 3>>& tris);
LoadedMesh loadStl(const std::string& path);
LoadedMesh loadObj(const std::string& path);
LoadedMesh loadOff(const std::string& path);
Expand Down
24 changes: 12 additions & 12 deletions src/import/mesh_import.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ std::vector<std::string> splitWs(const std::string& s) {
return out;
}

std::string attrValue(std::string_view tag, std::string_view attrName) {
const std::string needle = std::string(attrName) + "=\"";
const size_t pos = tag.find(needle);
if (pos == std::string_view::npos) return "0";
const size_t start = pos + needle.size();
const size_t end = tag.find('"', start);
if (end == std::string_view::npos) return "0";
return std::string(tag.substr(start, end - start));
}

} // namespace

// Exact-match vertex welding (STL has no shared-index concept; each
// triangle carries its own private corner copies) -- matches
// _weld_stl_vertices's np.unique(axis=0) exact-equality behavior.
Expand All @@ -80,18 +92,6 @@ LoadedMesh weldVertices(const std::vector<std::array<double, 3>>& verts, const s
return out;
}

std::string attrValue(std::string_view tag, std::string_view attrName) {
const std::string needle = std::string(attrName) + "=\"";
const size_t pos = tag.find(needle);
if (pos == std::string_view::npos) return "0";
const size_t start = pos + needle.size();
const size_t end = tag.find('"', start);
if (end == std::string_view::npos) return "0";
return std::string(tag.substr(start, end - start));
}

} // namespace

LoadedMesh loadStl(const std::string& path) {
const std::vector<uint8_t> bytes = readWholeFile(path);
// OpenSCAD's wording for every file it cannot read as STL.
Expand Down
19 changes: 14 additions & 5 deletions src/import/scene_import.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -653,13 +653,13 @@ LoadedMesh loadAmf(const std::string& path) {
LoadedMesh out;
for (std::string_view object : segments(xml, "object")) {
const std::string_view verticesBlock = elementText(object, "vertices");
const int base = static_cast<int>(out.verts.size());
LoadedMesh obj;
for (std::string_view vertex : segments(verticesBlock, "vertex")) {
const std::string_view c = elementText(vertex, "coordinates");
out.verts.push_back({amfNumber(elementText(c, "x"), "x") * unit, amfNumber(elementText(c, "y"), "y") * unit,
obj.verts.push_back({amfNumber(elementText(c, "x"), "x") * unit, amfNumber(elementText(c, "y"), "y") * unit,
amfNumber(elementText(c, "z"), "z") * unit});
}
const int count = static_cast<int>(out.verts.size()) - base;
const int count = static_cast<int>(obj.verts.size());
for (std::string_view volume : segments(object, "volume")) {
for (std::string_view tri : segments(volume, "triangle")) {
std::array<int, 3> t{};
Expand All @@ -669,11 +669,20 @@ LoadedMesh loadAmf(const std::string& path) {
if (i < 0 || i >= count)
throw std::runtime_error("AMF: triangle vertex " + std::to_string(i) + " is past its object's " +
std::to_string(count) + " vertices");
t[k] = base + i;
t[k] = i;
}
out.tris.push_back(t);
obj.tris.push_back(t);
}
}
// OpenSCAD treats vertices at exactly the same position as one
// (checked, 2026.02.01): a closed object written as a triangle soup,
// or with its vertices listed twice, imports as a closed solid.
// Within each object only -- two open halves in separate objects
// stay open there too. Near-coincident vertices are not merged.
obj = weldVertices(obj.verts, obj.tris);
const int base = static_cast<int>(out.verts.size());
out.verts.insert(out.verts.end(), obj.verts.begin(), obj.verts.end());
for (const auto& t : obj.tris) out.tris.push_back({base + t[0], base + t[1], base + t[2]});
}
if (xml.find("<constellation") != std::string::npos) {
out.warnings.push_back("AMF constellations are not applied: each object is imported where it was modeled");
Expand Down
93 changes: 93 additions & 0 deletions tests/test_import_export.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

#include "test_helpers.hpp"

#include <algorithm>
#include <filesystem>
#include <map>
#include <fstream>
#include <gtest/gtest.h>
#include <manifold/manifold.h>
Expand Down Expand Up @@ -267,6 +269,97 @@ TEST(ImportModuleContext, ZippedAmfImports) {
EXPECT_NEAR(e.bodies[0].body->Volume(), 1.0, 1e-9);
}

namespace {

using Tri = std::array<std::array<double, 3>, 3>;

// The unit cube at (x, y, z) as 12 outward triangles, two per face, the
// faces in -z, +z, -y, +x, +y, -x order.
std::vector<Tri> cubeTris(double x, double y, double z) {
const int idx[12][3] = {{0, 3, 2}, {0, 2, 1}, {4, 5, 6}, {4, 6, 7}, {0, 1, 5}, {0, 5, 4},
{1, 2, 6}, {1, 6, 5}, {2, 3, 7}, {2, 7, 6}, {3, 0, 4}, {3, 4, 7}};
const double c[8][3] = {{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}, {0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}};
std::vector<Tri> out;
for (const auto& t : idx) {
Tri tri;
for (int k = 0; k < 3; ++k) tri[k] = {c[t[k]][0] + x, c[t[k]][1] + y, c[t[k]][2] + z};
out.push_back(tri);
}
return out;
}

// One <object> per entry, every triangle carrying its own three vertex
// copies: a triangle soup, as an exporter that never shares vertices writes.
std::string soupAmf(const std::vector<std::vector<Tri>>& objects) {
std::string amf = "<?xml version=\"1.0\"?>\n<amf unit=\"millimeter\">";
for (const auto& tris : objects) {
amf += "<object id=\"1\"><mesh><vertices>";
for (const Tri& t : tris)
for (const auto& p : t)
amf += "<vertex><coordinates><x>" + std::to_string(p[0]) + "</x><y>" + std::to_string(p[1]) +
"</y><z>" + std::to_string(p[2]) + "</z></coordinates></vertex>";
amf += "</vertices><volume>";
for (size_t i = 0; i < tris.size(); ++i)
amf += "<triangle><v1>" + std::to_string(3 * i) + "</v1><v2>" + std::to_string(3 * i + 1) + "</v2><v3>" +
std::to_string(3 * i + 2) + "</v3></triangle>";
amf += "</volume></mesh></object>";
}
return amf + "</amf>";
}

} // namespace

// OpenSCAD treats an AMF object's vertices at exactly the same position as
// one vertex (checked, 2026.02.01), so a closed object written as a soup
// imports as a closed solid. Without the weld it was an open surface with
// 36 boundary edges, drawn but unable to take part in CSG.
TEST(ImportModuleContext, AmfTriangleSoupWeldsIntoAClosedSolid) {
const Imported r = importText("soup.amf", soupAmf({cubeTris(0, 0, 0)}));
EXPECT_NEAR(r.volume, 1.0, 1e-9);
EXPECT_FALSE(anyContains(r.messages, "not a closed solid")) << ::testing::PrintToString(r.messages);
}

// A 3x3x1 slab with a 1x1 hole through it (eight unit cubes around the
// middle, the faces between neighbours left out), as a soup: closed, genus 1.
TEST(ImportModuleContext, AmfSoupWithAThroughHoleWeldsClosed) {
std::map<std::vector<std::array<double, 3>>, std::vector<Tri>> faces;
std::map<std::vector<std::array<double, 3>>, int> uses;
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) {
if (i == 1 && j == 1) continue;
const std::vector<Tri> c = cubeTris(i, j, 0);
for (size_t f = 0; f < c.size(); f += 2) {
std::vector<std::array<double, 3>> key{c[f][0], c[f][1], c[f][2], c[f + 1][0], c[f + 1][1], c[f + 1][2]};
std::sort(key.begin(), key.end());
key.erase(std::unique(key.begin(), key.end()), key.end());
faces[key] = {c[f], c[f + 1]};
++uses[key];
}
}
std::vector<Tri> ring;
for (const auto& [key, tris] : faces)
if (uses[key] == 1) ring.insert(ring.end(), tris.begin(), tris.end());
const auto path = tempPath("ring.amf");
std::ofstream(path, std::ios::binary) << soupAmf({ring});
std::vector<std::string> messages;
Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");",
[&](const std::string& m) { messages.push_back(m); });
std::filesystem::remove(path);
ASSERT_EQ(e.bodies.size(), 1u);
ASSERT_TRUE(e.bodies[0].body);
EXPECT_NEAR(e.bodies[0].body->Volume(), 8.0, 1e-9);
EXPECT_EQ(e.bodies[0].body->Genus(), 1);
EXPECT_FALSE(anyContains(messages, "not a closed solid")) << ::testing::PrintToString(messages);
}

// The weld is per object, as in OpenSCAD: a cube split into two objects,
// each half its triangles, is two open surfaces, not one closed cube.
TEST(ImportModuleContext, AmfWeldsWithinAnObjectOnly) {
const std::vector<Tri> c = cubeTris(0, 0, 0);
const Imported r = importText("halves.amf", soupAmf({{c.begin(), c.begin() + 6}, {c.begin() + 6, c.end()}}));
EXPECT_TRUE(anyContains(r.messages, "not a closed solid")) << ::testing::PrintToString(r.messages);
}

TEST(ImportModuleContext, UnsupportedExtensionErrors) {
const auto path = tempPath("unsupported.xyz");
std::ofstream(path) << "x";
Expand Down
Loading