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
35 changes: 29 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1237,21 +1237,44 @@ grep for `ponytail:`.
`text_metrics.hpp`'s `measureText`/`textAlignOffset` plus `Evaluator::fontProvider()`) are
implemented here too.
- `include/openscad_cpp_evaluator/mesh_import.hpp`, `src/import/mesh_import.cpp` — STL (ASCII +
binary, exact-match vertex welding)/OBJ/OFF/3MF mesh loaders, returning plain `(verts, tris)` —
no `Evaluator`/`Value` dependency, so these are reusable from both `import()` contexts below.
binary)/OBJ/OFF/3MF mesh loaders, returning plain `(verts, tris)` — no `Evaluator`/`Value`
dependency, so these are reusable from both `import()` contexts below. **Every mesh loader welds
vertices at exactly the same position** (`weldVertices`, or `appendWelded` for one object of
many), so a closed solid written as a triangle soup or with a duplicated vertex list imports
closed. The granularity is OpenSCAD 2026.02.01's, checked by running it: the **whole file** for
STL, OBJ (across `o`/`g` groups -- two cube halves in separate `o` groups with their own vertex
copies import as one closed cube) and OFF (one mesh); **each object** for 3MF and AMF (two open
halves in separate objects stay open; OpenSCAD reports each not closed). OpenSCAD welds OBJ at
import, but OFF and single-object 3MF only when the mesh becomes a solid (a lone import exported
there keeps the raw soup) -- welding at import gives the same solid. Exact positions only: the
near-coincident soups (1e-9, 1e-7) OpenSCAD closes through its "repair and reconstruct" fallback
stay open here without `repair=true`. `weldVertices` also rejects a face index past the vertex
list (OBJ/3MF used to hand one to Manifold unchecked). The 3MF reader skips `<triangles>`, which
its `<triangle` search used to read as one degenerate triangle per mesh.
- `src/import/scene_import.cpp` — AMF, X3D and VRML97 import, the three multi-object formats export
writes that nothing could read back (current upstream OpenSCAD removed its AMF importer, so all
three go beyond it). Geometry only, like the loaders above, but each format's scene structure is
honoured: X3D/VRML `Transform`s nest and apply in full (`T·C·R·SR·S·SR⁻¹·C⁻¹`), `DEF`/`USE`
resolves, `ccw FALSE` flips winding, `IndexedFaceSet` polygons fan-triangulate (a last polygon
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
node parser that steps over `PROTO`/`EXTERNPROTO` declarations and `ROUTE` (VRML 1.0 is refused by
name). AMF's
`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
(`appendWelded`), so a closed object written as a triangle soup imports closed, as in
OpenSCAD -- per object, never across objects, and never near-coincident vertices. X3D and VRML,
which OpenSCAD cannot import, weld each Shape's geometry node the same way (in
`emitIndexedFaces`), matching AMF/3MF's per-object rule; a `USE`d Shape is a separate mesh.
`Switch` imports only its `whichChoice` child (VRML `choice[k]`, X3D's k-th child element;
nothing at -1, the spec default, or out of range) and `LOD` only its first level (VRML `level[0]`,
X3D's first child). Prototypes are **not expanded**, but no longer silently: X3D does not walk
`ProtoDeclare`/`ExternProtoDeclare` (a declaration's body is a template -- it used to be imported
once as plain geometry with `IS` ignored) and counts those and `ProtoInstance` as skipped; VRML
records every `PROTO`/`EXTERNPROTO` name and counts an instance of one as `PROTO instance`, without
walking its fields. Both reach the user as the usual `skipped what is not a mesh: ...` warning.
Expanding them (substituting `IS` fields into a copy of the body per instance) is still to do.
What is not imported — primitives (Box, Sphere, ...), `Inline`, prototypes, 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/
zip_stored.hpp` (+ `src/zip_stored.cpp`), `src/builtins/import.cpp` — `import()`'s two faces:
Expand Down
24 changes: 17 additions & 7 deletions include/openscad_cpp_evaluator/mesh_import.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,29 @@ struct LoadedMesh {
};

// Each throws std::runtime_error with a message suitable for wrapping into
// "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.
// "import: {what}" on parse/IO failure. OBJ/OFF fan-triangulate >3-gon faces.
//
// Every loader merges vertices at exactly the same position, at the
// granularity OpenSCAD 2026.02.01 does (or, for X3D/VRML, which it cannot
// import, the same as AMF): the whole file for STL, OBJ and OFF; each
// object for 3MF and AMF; each Shape's geometry for X3D and VRML. Where the
// unit is an object, two open halves in separate objects stay open (an
// OBJ's `o` groups are not objects in this sense: OpenSCAD welds across
// them). Near-coincident vertices are never merged.
//
// weldVertices merges exactly-equal positions; every position keeps its
// first index. Throws on a face index past the vertex list.
LoadedMesh weldVertices(const std::vector<std::array<double, 3>>& verts, const std::vector<std::array<int, 3>>& tris);
// Welds `part` and appends it to `out`, keeping `out`'s earlier objects apart.
void appendWelded(LoadedMesh& out, const LoadedMesh& part);
LoadedMesh loadStl(const std::string& path);
LoadedMesh loadObj(const std::string& path);
LoadedMesh loadOff(const std::string& path);
LoadedMesh loadThreeMf(const std::string& path);
// scene_import.cpp: the three multi-object formats export writes. AMF (plain
// or zipped, `unit` scaled to mm), X3D and VRML97 (Transforms applied,
// DEF/USE resolved, polygons fan-triangulated). Geometry only, like the rest.
// or zipped, `unit` ignored as OpenSCAD ignores it), X3D and VRML97
// (Transforms applied, DEF/USE resolved, polygons fan-triangulated).
// Geometry only, like the rest.
LoadedMesh loadAmf(const std::string& path);
LoadedMesh loadX3d(const std::string& path);
LoadedMesh loadVrml(const std::string& path);
Expand Down
49 changes: 39 additions & 10 deletions src/import/mesh_import.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "openscad_cpp_evaluator/zip_stored.hpp"

#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstring>
#include <fstream>
Expand Down Expand Up @@ -69,9 +70,10 @@ std::string attrValue(std::string_view tag, std::string_view attrName) {

} // 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.
// Exact-match vertex welding: every position keeps its first index. Only
// bit-identical coordinates merge; near-coincident ones stay apart (that is
// repair=true's job). A face index past the vertex list is an error here
// rather than a bad read.
LoadedMesh weldVertices(const std::vector<std::array<double, 3>>& verts, const std::vector<std::array<int, 3>>& tris) {
std::map<std::array<double, 3>, int> seen;
LoadedMesh out;
Expand All @@ -87,11 +89,24 @@ LoadedMesh weldVertices(const std::vector<std::array<double, 3>>& verts, const s
remap[i] = idx;
}
}
const auto at = [&](int i) {
if (i < 0 || static_cast<size_t>(i) >= verts.size())
throw std::runtime_error("face index " + std::to_string(i) + " is past its " + std::to_string(verts.size()) +
" vertices");
return remap[static_cast<size_t>(i)];
};
out.tris.reserve(tris.size());
for (const auto& t : tris) out.tris.push_back({remap[t[0]], remap[t[1]], remap[t[2]]});
for (const auto& t : tris) out.tris.push_back({at(t[0]), at(t[1]), at(t[2])});
return out;
}

void appendWelded(LoadedMesh& out, const LoadedMesh& part) {
const LoadedMesh w = weldVertices(part.verts, part.tris);
const int base = static_cast<int>(out.verts.size());
out.verts.insert(out.verts.end(), w.verts.begin(), w.verts.end());
for (const auto& t : w.tris) out.tris.push_back({base + t[0], base + t[1], base + t[2]});
}

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 Expand Up @@ -162,7 +177,11 @@ LoadedMesh loadObj(const std::string& path) {
for (size_t i = 1; i + 1 < idx.size(); ++i) out.tris.push_back({idx[0], idx[i], idx[i + 1]});
}
}
return out;
// OpenSCAD welds an OBJ's exactly-equal vertices across the whole file,
// `o`/`g` boundaries included (checked, 2026.02.01: two halves of a cube
// in separate `o` objects, each with its own vertex copies, import as
// one closed cube).
return weldVertices(out.verts, out.tris);
}

LoadedMesh loadOff(const std::string& path) {
Expand Down Expand Up @@ -231,7 +250,10 @@ LoadedMesh loadOff(const std::string& path) {
}
for (int k = 1; k + 1 < cnt; ++k) out.tris.push_back({faceIdx[0], faceIdx[static_cast<size_t>(k)], faceIdx[static_cast<size_t>(k) + 1]});
}
return out;
// An OFF is one mesh, and OpenSCAD merges its exactly-equal vertices
// when the mesh becomes a solid (checked, 2026.02.01: a triangle soup
// or a duplicated vertex list takes part in CSG as a closed solid).
return weldVertices(out.verts, out.tris);
}

LoadedMesh loadThreeMf(const std::string& path) {
Expand All @@ -248,26 +270,33 @@ LoadedMesh loadThreeMf(const std::string& path) {
const std::string_view segment(xml.data() + meshStart, meshEnd - meshStart);
searchPos = meshEnd + 7;

const int base = static_cast<int>(out.verts.size());
LoadedMesh obj;
for (size_t p = 0;;) {
const size_t vStart = segment.find("<vertex", p);
if (vStart == std::string_view::npos) break;
const size_t vEnd = segment.find('>', vStart);
if (vEnd == std::string_view::npos) break;
const std::string_view tag = segment.substr(vStart, vEnd - vStart + 1);
out.verts.push_back({std::stod(attrValue(tag, "x")), std::stod(attrValue(tag, "y")), std::stod(attrValue(tag, "z"))});
obj.verts.push_back({std::stod(attrValue(tag, "x")), std::stod(attrValue(tag, "y")), std::stod(attrValue(tag, "z"))});
p = vEnd + 1;
}
for (size_t p = 0;;) {
const size_t tStart = segment.find("<triangle", p);
if (tStart == std::string_view::npos) break;
if (tStart + 9 < segment.size() && std::isalpha(static_cast<unsigned char>(segment[tStart + 9]))) { // <triangles>, not a triangle
p = tStart + 9;
continue;
}
const size_t tEnd = segment.find('>', tStart);
if (tEnd == std::string_view::npos) break;
const std::string_view tag = segment.substr(tStart, tEnd - tStart + 1);
out.tris.push_back({base + std::stoi(attrValue(tag, "v1")), base + std::stoi(attrValue(tag, "v2")),
base + std::stoi(attrValue(tag, "v3"))});
obj.tris.push_back({std::stoi(attrValue(tag, "v1")), std::stoi(attrValue(tag, "v2")), std::stoi(attrValue(tag, "v3"))});
p = tEnd + 1;
}
// Welded per object, as OpenSCAD makes each 3MF object a solid on
// its own (checked, 2026.02.01): a soup object closes, but two open
// halves in separate objects stay open.
appendWelded(out, obj);
}
return out;
}
Expand Down
70 changes: 56 additions & 14 deletions src/import/scene_import.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,24 @@
// through import()). Each format's scene structure IS honoured, though:
// X3D/VRML Transforms (translation, rotation, scale, center,
// scaleOrientation) nest and apply, DEF/USE reuse resolves, `ccw FALSE`
// flips winding, and polygons are fan-triangulated. AMF's `unit` scales to
// millimetres, and a zipped AMF is read like 3MF.
// flips winding, and polygons are fan-triangulated. AMF's `unit` is
// ignored, as OpenSCAD ignores it (its numbers are read as millimetres),
// and a zipped AMF is read like 3MF.
//
// Vertices at exactly the same position are merged (weldVertices) within
// each AMF object and within each X3D/VRML Shape's geometry, never across
// them -- so a closed object written as a triangle soup imports closed, and
// two open halves in separate objects stay open. AMF's granularity is
// OpenSCAD's (checked, 2026.02.01); OpenSCAD cannot import X3D or VRML, so
// theirs follows AMF and 3MF, where each object is a solid on its own.
//
// Switch imports only its whichChoice child (none at the default -1) and
// LOD only its first level.
//
// What is skipped says so, through LoadedMesh::warnings: X3D/VRML
// primitives (Box, Sphere, ...), Inline references, and AMF constellations.
// primitives (Box, Sphere, ...), Inline references, prototypes (not
// expanded: X3D ProtoDeclare/ExternProtoDeclare/ProtoInstance, VRML
// instances of a PROTO/EXTERNPROTO type), and AMF constellations.
// None of them is a mesh, and inventing one would be a different model from
// the file.
#include "openscad_cpp_evaluator/mesh_import.hpp"
Expand Down Expand Up @@ -143,14 +156,14 @@ Mat transformNode(const std::vector<double>& t, const std::vector<double>& r, co
void emitIndexedFaces(LoadedMesh& out, const std::vector<double>& points, const std::vector<double>& index,
const Mat& m, bool ccw, const std::string& what) {
const int n = static_cast<int>(points.size() / 3);
const int base = static_cast<int>(out.verts.size());
for (int i = 0; i < n; ++i) out.verts.push_back(apply(m, points[i * 3], points[i * 3 + 1], points[i * 3 + 2]));
LoadedMesh part;
for (int i = 0; i < n; ++i) part.verts.push_back(apply(m, points[i * 3], points[i * 3 + 1], points[i * 3 + 2]));
std::vector<int> poly;
const auto flush = [&]() {
for (size_t k = 1; k + 1 < poly.size(); ++k) {
std::array<int, 3> tri{base + poly[0], base + poly[k], base + poly[k + 1]};
std::array<int, 3> tri{poly[0], poly[k], poly[k + 1]};
if (!ccw) std::swap(tri[1], tri[2]);
out.tris.push_back(tri);
part.tris.push_back(tri);
}
poly.clear();
};
Expand All @@ -165,6 +178,9 @@ void emitIndexedFaces(LoadedMesh& out, const std::vector<double>& points, const
poly.push_back(i);
}
flush();
// One geometry node is one Shape's mesh: welded on its own, like an AMF
// or 3MF object.
appendWelded(out, part);
}

// IndexedTriangleSet / TriangleSet: every three indices (or points) are one
Expand Down Expand Up @@ -331,6 +347,17 @@ class X3dWalker {
for (const auto& c : n.children) geometry(resolve(*c), m);
} else if (n.name == "Inline") {
++skipped["Inline"];
} else if (n.name == "Switch") {
// Only the chosen child; whichChoice defaults to -1, nothing.
const std::vector<double> w = nums(n, "whichChoice");
const long k = w.empty() ? -1 : static_cast<long>(w[0]);
if (k >= 0 && static_cast<size_t>(k) < n.children.size()) walk(*n.children[static_cast<size_t>(k)], m);
} else if (n.name == "LOD") {
if (!n.children.empty()) walk(*n.children[0], m); // the first, most detailed level
} else if (n.name == "ProtoDeclare" || n.name == "ExternProtoDeclare" || n.name == "ProtoInstance") {
// Prototypes are not expanded: a declaration's body is a template,
// not geometry, and an instance has nothing to place without it.
++skipped[n.name];
} else {
for (const auto& c : n.children) walk(*c, m);
}
Expand Down Expand Up @@ -400,13 +427,17 @@ class VrmlParser {
public:
explicit VrmlParser(const std::string& text) { tokenize(text); }

// Node type names a PROTO or EXTERNPROTO declares.
std::set<std::string> protos;

std::vector<VNodePtr> parseScene() {
std::vector<VNodePtr> nodes;
while (i_ < toks_.size()) {
const std::string& t = toks_[i_];
if (t == "ROUTE") {
i_ += 4; // ROUTE a.b TO c.d
} else if (t == "PROTO" || t == "EXTERNPROTO") {
protos.insert(peek(1));
i_ += 2;
skipBalanced("[", "]");
if (t == "PROTO") {
Expand Down Expand Up @@ -538,10 +569,23 @@ class VrmlWalker {
public:
LoadedMesh out;
std::map<std::string, int> skipped;
std::set<std::string> protos;

void walk(const VNodePtr& n, const Mat& m) {
if (!n) return;
if (n->type == "Transform") {
if (protos.count(n->type)) {
// Prototypes are not expanded, so an instance's geometry is lost.
++skipped["PROTO instance"];
} else if (n->type == "Switch") {
// Only the chosen child; whichChoice defaults to -1, nothing.
const std::vector<double> w = nums(*n, "whichChoice");
const long k = w.empty() ? -1 : static_cast<long>(w[0]);
const auto& choice = kids(*n, "choice");
if (k >= 0 && static_cast<size_t>(k) < choice.size()) walk(choice[static_cast<size_t>(k)], m);
} else if (n->type == "LOD") {
const auto& level = kids(*n, "level");
if (!level.empty()) walk(level[0], m); // the first, most detailed level
} else if (n->type == "Transform") {
const Mat t = transformNode(nums(*n, "translation"), nums(*n, "rotation"), nums(*n, "scale"),
nums(*n, "center"), nums(*n, "scaleOrientation"));
for (const auto& c : kids(*n, "children")) walk(c, mul(m, t));
Expand All @@ -550,8 +594,8 @@ class VrmlWalker {
} else if (n->type == "Inline") {
++skipped["Inline"];
} else {
// Group, Anchor, Billboard, Collision, Switch (choice), LOD (level)
// and anything else holding nodes: walk every node-valued field.
// Group, Anchor, Billboard, Collision and anything else holding
// nodes: walk every node-valued field.
for (const auto& [name, f] : n->fields) {
for (const auto& c : f.nodes) walk(c, m);
}
Expand Down Expand Up @@ -679,10 +723,7 @@ LoadedMesh loadAmf(const std::string& path) {
// 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]});
appendWelded(out, obj);
}
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 All @@ -706,6 +747,7 @@ LoadedMesh loadVrml(const std::string& path) {
VrmlParser parser(text);
const std::vector<VNodePtr> scene = parser.parseScene();
VrmlWalker walker;
walker.protos = parser.protos;
for (const VNodePtr& n : scene) walker.walk(n, identity());
if (!walker.skipped.empty()) walker.out.warnings.push_back(skippedWarning(walker.skipped));
return std::move(walker.out);
Expand Down
Loading
Loading