diff --git a/bindings/module.cpp b/bindings/module.cpp index 1f1f64c..7c30108 100644 --- a/bindings/module.cpp +++ b/bindings/module.cpp @@ -22,6 +22,7 @@ #include "openscad_cpp_evaluator/eval_context.hpp" #include "openscad_cpp_evaluator/eval_use.hpp" #include "openscad_cpp_evaluator/evaluator.hpp" +#include "openscad_cpp_evaluator/export.hpp" #include "openscad_cpp_evaluator/mesh_check.hpp" #include "openscad_cpp_evaluator/manifold_cache.hpp" #include "openscad_cpp_evaluator/profile.hpp" @@ -340,6 +341,36 @@ std::pair dynStateToPy(const oscadeval::EvalContext& ctx) // message as a Python exception on ParseError/EvalError (nanobind maps // std::exception -> RuntimeError, whose str() is the already-formatted // "ERROR:..."/caret diagnostic; the facade re-raises as EvalError). +// The evaluated bodies, kept on the C++ side. +// +// bodyToDict flattens every Manifold into numpy arrays for the renderer, +// which is all the renderer needs -- but export has to do real CSG (the +// union, the per-colour claim, decompose), and rebuilding Manifolds from +// those arrays to do it costs ~146ms on a 224k-triangle model and throws +// away Manifold's own provenance. Handing back an opaque handle instead +// means geometry never round-trips through Python at all. +struct Geometry { + std::vector bodies; +}; + +// exportModel, with the path/format/warnings marshalling. Releases the GIL: +// a large export is seconds of Manifold work with no Python involved. +nb::list exportModelPy(const std::string& path, const Geometry& geom, const std::string& format, bool asciiStl, + bool stripSlivers) { + std::vector warnings; + { + nb::gil_scoped_release rel; + oscadeval::ExportOptions opts; + opts.format = format; + opts.asciiStl = asciiStl; + opts.stripSlivers = stripSlivers; + warnings = oscadeval::exportModel(path, geom.bodies, opts); + } + nb::list out; + for (const std::string& w : warnings) out.append(w); + return out; +} + nb::object evaluate(const std::string& path, nb::dict viewportParams, std::shared_ptr manifoldCache, bool profile) { std::unordered_map vp = toViewportParams(viewportParams); @@ -370,8 +401,10 @@ nb::object evaluate(const std::string& path, nb::dict viewportParams, nb::list echoList; for (const std::string& s : echoes) echoList.append(s); - return nb::make_tuple(bodiesToList(bodies), echoList, idSpansToDict(idSpans), csgTreeToPy(csgTree), - profileResultToPy(profileResult), dyn, dynExplicit); + auto geom = std::make_shared(); + geom->bodies = std::move(bodies); + return nb::make_tuple(bodiesToList(geom->bodies), echoList, idSpansToDict(idSpans), csgTreeToPy(csgTree), + profileResultToPy(profileResult), dyn, dynExplicit, geom); } // ------------------------------------------------------------------------ @@ -721,6 +754,19 @@ NB_MODULE(_openscad_cpp_evaluator, m) { .def(nb::init<>()) .def("request", &FastContinueSignal::request); + nb::class_(m, "Geometry", + "Opaque handle to the evaluated bodies, kept on the C++ side so export never has to " + "rebuild Manifolds from the renderer's flattened arrays. Hand it to export_model().") + .def("__len__", [](const Geometry& g) { return g.bodies.size(); }) + .def("is_empty", [](const Geometry& g) { return g.bodies.empty(); }); + + m.def("export_model", &exportModelPy, nb::arg("path"), nb::arg("geometry"), nb::arg("format") = std::string(), + nb::arg("ascii_stl") = false, nb::arg("strip_slivers") = true, + "Write `geometry` to `path`, format taken from the extension unless `format` says otherwise. " + "Returns the warnings to surface (open shells, mesh problems, slivers removed) rather than " + "logging them. Raises RuntimeError when there is no geometry, the format is unknown, or the " + "file cannot be opened."); + m.def("evaluate", &evaluate, nb::arg("path"), nb::arg("viewport_params"), nb::arg("manifold_cache") = nullptr, nb::arg("profile") = false, "Evaluate a .scad file; return (bodies, echoes, id_to_node, csg_tree, profile_result, dyn, dyn_explicit)."); diff --git a/include/openscad_cpp_evaluator/export.hpp b/include/openscad_cpp_evaluator/export.hpp index d8c3b14..0b87782 100644 --- a/include/openscad_cpp_evaluator/export.hpp +++ b/include/openscad_cpp_evaluator/export.hpp @@ -2,11 +2,51 @@ #include "openscad_cpp_evaluator/colored_body.hpp" +#include +#include #include #include namespace oscadeval { +// The colour every object falls back to when the script gave none. +inline constexpr std::array kDefaultExportColor = {0.8f, 0.8f, 0.8f, 1.0f}; + +// One object in a file format that can hold more than one -- 3MF, OBJ, +// VRML, X3D. `triColors` is empty for the ordinary flat-coloured object, +// or one RGBA per triangle when the surface came out of a multi-colour CSG +// merge (see splitBodiesForExport). +struct ExportObject { + std::vector verts; // xyz triples + std::vector tris; // vertex-index triples + std::array color = kDefaultExportColor; + std::vector> triColors; +}; + +// The implicit top-level union, cut into objects that never overlap. +// +// Top level in OpenSCAD is an implicit union, so `cube(100); cube(100, +// center=true);` is ONE solid -- writing the bodies as they arrive put both +// cubes in the file separately, overlapping, with their interior faces +// intact. Three rules, in order: +// +// 1. Union, never concatenate. Concatenating is only right while the +// bodies are disjoint; where two touch, each writes its own copy of +// the shared face and the result is non-manifold. +// 2. One object per colour, and no two objects share volume. Where +// differently-coloured solids overlap the LATER one owns the shared +// volume and the earlier is notched around it -- painter's order, so +// `color("red") body(); color("blue") detail();` leaves the detail +// whole. Only the invisible interior is affected: the visible surface +// is identical either way. +// 3. One object per connected component. +// +// A body Manifold rejected (an open shell is not a solid) can take part in +// none of that: it keeps its own triangles and its own object, and its +// 1-based index is appended to `openParts` for the caller to warn about. +std::vector splitBodiesForExport(const std::vector& bodies, + std::vector* openParts = nullptr); + // Writes a binary STL: composes every body's mesh into one solid (bodies // with no `.body` -- 2D-only sections -- or an empty Manifold are // skipped), per-triangle normals computed from vertex winding. Mirrors @@ -28,10 +68,34 @@ std::vector checkExportBodies(const std::vector& bodie void writeStl(const std::string& path, const std::vector& bodies); -// Wavefront OBJ: "v x y z" per vertex then "f i j k" per triangle -// (1-indexed). Mirrors export.py's write_obj (%.6g formatting). Same -// compose-and-throw-if-empty behavior as writeStl. -void writeObj(const std::string& path, const std::vector& bodies); +// OpenSCAD-compatible ASCII STL -- "solid OpenSCAD_Model" / one "facet +// normal .. outer loop .. vertex x3 .. endloop endfacet" per triangle / +// "endsolid". Format confirmed against real OpenSCAD's own -o out.stl. +void writeStlAscii(const std::string& path, const std::vector& bodies); + +// Wavefront OBJ: one `o` group per object, `usemtl` naming a material in a +// companion .mtl written alongside (so an OBJ export produces TWO files). +// A multi-coloured surface becomes runs of faces with a `usemtl` between +// them, in triangle order -- the only per-face colour OBJ has. +void writeObj(const std::string& path, const std::vector& objects); + +// Binary little-endian PLY, one flat mesh with per-vertex colour. PLY has +// no object concept, so the objects are concatenated; an object carrying +// per-triangle colour is unwelded (three vertices per triangle), since a +// vertex shared by two differently-coloured triangles has no single answer. +void writePly(const std::string& path, const std::vector& objects); + +// VRML97 ("#VRML V2.0 utf8"), one Shape per object. Per-face colour rides +// on a Color node with `colorPerVertex FALSE`. Neither this nor X3D can +// carry per-triangle alpha, so the object's base alpha applies to the whole +// shape through Material.transparency (which is 1 - alpha). +void writeVrml(const std::string& path, const std::vector& objects); + +// X3D 3.3, Interchange profile -- the XML encoding of what writeVrml emits, +// node for node. Interchange is the accurate claim: per Annex B it is +// Geometry3D level 2 (IndexedFaceSet), Rendering level 3 (Coordinate, +// Color) and Shape level 1 (Appearance, Material), exactly the nodes used. +void writeX3d(const std::string& path, const std::vector& objects); // OFF (Object File Format): header "OFF", "$verts $tris 0", vertex lines, // then "3 i j k" per triangle (0-indexed, count-prefixed). Mirrors @@ -46,6 +110,35 @@ void writeOff(const std::string& path, const std::vector& bodies); // Mirrors export.py's write_3mf's XML shape exactly (core + material // namespaces, %.6g vertex formatting); throws std::runtime_error if there's // no geometry to export. -void writeThreeMf(const std::string& path, const std::vector& bodies); +void writeThreeMf(const std::string& path, const std::vector& objects); + +// -- one entry point ------------------------------------------------------ + +struct ExportOptions { + // Empty means "decide from the path's extension". + std::string format; + // .stl only; binary otherwise. + bool asciiStl = false; + // Remove zero-area faces before writing the single-mesh formats. They + // break no topology, but slicers commonly discard them and are then + // left with the holes their removal opens. + bool stripSlivers = true; +}; + +// Writes `bodies` to `path`, choosing the writer from the extension (or +// opts.format) and applying the same repair/verification policy the GUI +// used to apply itself. Returns the warnings to surface -- open shells, +// mesh problems, sliver removal -- rather than logging them, so each front +// end can present them its own way. +// +// Nothing here refuses to write: a deliberately open surface is a +// legitimate export, and blocking a save the user asked for would be worse +// than saying so. Throws std::runtime_error only when there is no geometry +// at all, the format is unknown, or the file cannot be opened. +std::vector exportModel(const std::string& path, const std::vector& bodies, + const ExportOptions& opts = {}); + +// The extensions exportModel understands, lower-case and dot-prefixed. +const std::vector& exportExtensions(); } // namespace oscadeval diff --git a/pyproject.toml b/pyproject.toml index 99411e2..6993f47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "openscad_cpp_evaluator" -version = "0.30.0" +version = "0.31.0" description = "C++ OpenSCAD evaluator with Python bindings" readme = "README.md" requires-python = ">=3.12" diff --git a/python/openscad_cpp_evaluator/__init__.py b/python/openscad_cpp_evaluator/__init__.py index f51f6b6..9dd4d29 100644 --- a/python/openscad_cpp_evaluator/__init__.py +++ b/python/openscad_cpp_evaluator/__init__.py @@ -19,6 +19,7 @@ "Evaluator", "ColoredBody", "EvalError", "ParseError", "OscObject", "parse", "to_renderable_bodies", "ManifoldCache", "CallSiteProfile", "ProfileResult", "format_csg_tree", "bodies_from_dicts", "FastContinueSignal", "parse_ast", "parse_ast_string", "check_mesh", "strip_slivers", + "export_model", "export_extensions", ] @@ -288,6 +289,36 @@ def strip_slivers(verts, tris): return _ext.strip_slivers([float(v) for v in verts], [int(t) for t in tris]) +def export_model(path: str, geometry, format: str = "", ascii_stl: bool = False, + strip_slivers: bool = True) -> list: + """Write `geometry` to `path`. Returns the warnings to surface. + + `geometry` is the handle an Evaluator stashes on itself as + `.geometry` -- the evaluated bodies, still on the C++ side. Export has + to do real CSG (the implicit top-level union, the per-colour volume + claim, the connected-component split), and going through the flattened + arrays the renderer gets would mean rebuilding every Manifold first: + ~146ms on a 224k-triangle model, and Manifold's own provenance lost + along the way. + + Format comes from the extension unless `format` overrides it. Warnings + -- open shells, mesh problems, slivers removed -- are returned rather + than logged so each front end can present them its own way. Nothing + here refuses to write: a deliberately open surface is a legitimate + export. Raises EvalError when there is no geometry, the format is + unknown, or the file cannot be opened. + """ + try: + return list(_ext.export_model(path, geometry, format, ascii_stl, strip_slivers)) + except Exception as e: + raise EvalError(str(e)) from e + + +def export_extensions() -> list: + """The file extensions export_model understands, dot-prefixed.""" + return [".3mf", ".stl", ".obj", ".off", ".ply", ".wrl", ".x3d"] + + def check_mesh(verts, tris) -> dict: """Diagnose a triangle mesh against the manifoldness conditions. @@ -448,9 +479,21 @@ def evaluate(self, source_path: str, viewport_params: Optional[dict] = None): self._manifold_cache, self._return_hook, self._fast_continue_signal) self.csg_tree = [] self.profile_result = None + # The debugger path has no geometry handle: debug_evaluate + # returns bodies for display only, and a paused session is + # not something to export from. + self.geometry = None else: - body_dicts, echoes, id_spans, csg_tree, profile_result, dyn, dyn_explicit = _ext.evaluate( + (body_dicts, echoes, id_spans, csg_tree, profile_result, dyn, + dyn_explicit, geometry) = _ext.evaluate( source_path, vp, self._manifold_cache, self._profile) + # The evaluated bodies, still on the C++ side. Stashed like + # csg_tree/profile_result rather than returned, so + # evaluate()'s own 2-tuple result is unchanged -- callers + # that only render never need to know this exists. Hand it + # to export_model(); see its docstring for why export does + # not just rebuild from the arrays the renderer gets. + self.geometry = geometry if self._echo_fn: for line in echoes: self._echo_fn(line) diff --git a/src/export.cpp b/src/export.cpp index cf91f32..de93996 100644 --- a/src/export.cpp +++ b/src/export.cpp @@ -99,23 +99,88 @@ void writeStl(const std::string& path, const std::vector& bodies) { } } -void writeObj(const std::string& path, const std::vector& bodies) { - std::optional mesh = composeMesh(bodies); - if (!mesh) throw std::runtime_error("No geometry to export"); +void writeObj(const std::string& path, const std::vector& objects) { + // OBJ carries no colour of its own: `usemtl` names an entry in a .mtl + // sitting next to the .obj, so an OBJ export writes TWO files. A reader + // that ignores the mtllib still gets correct geometry. + const size_t dot = path.find_last_of('.'); + const size_t slash = path.find_last_of("/\\"); + const bool hasExt = dot != std::string::npos && (slash == std::string::npos || dot > slash); + const std::string mtlPath = (hasExt ? path.substr(0, dot) : path) + ".mtl"; + std::string mtlName = mtlPath; + if (slash != std::string::npos) mtlName = mtlPath.substr(slash + 1); + + // One material per distinct colour, first-seen order -- counting every + // colour a per-triangle object uses, not just its base. + std::vector> materials; + const auto materialFor = [&](const std::array& c) { + for (size_t i = 0; i < materials.size(); ++i) { + if (materials[i] == c) return i; + } + materials.push_back(c); + return materials.size() - 1; + }; + for (const ExportObject& o : objects) { + if (o.triColors.empty()) { + materialFor(o.color); + } else { + for (const auto& c : o.triColors) materialFor(c); + } + } std::ofstream out(path); if (!out) throw std::runtime_error("Could not open '" + path + "' for writing"); - - const size_t vertCount = mesh->vertProperties.size() / mesh->numProp; - for (size_t v = 0; v < vertCount; ++v) { - const size_t base = v * mesh->numProp; - out << "v " << formatG6(mesh->vertProperties[base]) << " " << formatG6(mesh->vertProperties[base + 1]) << " " - << formatG6(mesh->vertProperties[base + 2]) << "\n"; + if (!materials.empty()) out << "mtllib " << mtlName << "\n\n"; + + size_t offset = 1; // OBJ vertex indices are 1-based and file-global + size_t objIndex = 0; + for (const ExportObject& o : objects) { + ++objIndex; + out << "o object_" << objIndex << "\n"; + const size_t vertCount = o.verts.size() / 3; + for (size_t v = 0; v < vertCount; ++v) { + out << "v " << formatG6(o.verts[v * 3]) << " " << formatG6(o.verts[v * 3 + 1]) << " " + << formatG6(o.verts[v * 3 + 2]) << "\n"; + } + const size_t triCount = o.tris.size() / 3; + if (o.triColors.empty()) { + out << "usemtl color_" << (materialFor(o.color) + 1) << "\n"; + for (size_t t = 0; t < triCount; ++t) { + out << "f " << (o.tris[t * 3] + offset) << " " << (o.tris[t * 3 + 1] + offset) << " " + << (o.tris[t * 3 + 2] + offset) << "\n"; + } + } else { + // A multi-coloured surface becomes runs of faces with a usemtl + // between them, emitted in triangle order and only when the + // colour actually changes, so the face order still matches + // every other format's. + size_t current = static_cast(-1); + for (size_t t = 0; t < triCount; ++t) { + const size_t m = materialFor(o.triColors[t]); + if (m != current) { + out << "usemtl color_" << (m + 1) << "\n"; + current = m; + } + out << "f " << (o.tris[t * 3] + offset) << " " << (o.tris[t * 3 + 1] + offset) << " " + << (o.tris[t * 3 + 2] + offset) << "\n"; + } + } + out << "\n"; + offset += vertCount; } - out << "\n"; - for (size_t t = 0; t < mesh->triVerts.size() / 3; ++t) { - out << "f " << (mesh->triVerts[t * 3 + 0] + 1) << " " << (mesh->triVerts[t * 3 + 1] + 1) << " " - << (mesh->triVerts[t * 3 + 2] + 1) << "\n"; + out.close(); + + if (materials.empty()) return; + std::ofstream mtl(mtlPath); + if (!mtl) throw std::runtime_error("Could not open '" + mtlPath + "' for writing"); + for (size_t i = 0; i < materials.size(); ++i) { + const auto& c = materials[i]; + mtl << "newmtl color_" << (i + 1) << "\n"; + mtl << "Kd " << formatG6(std::clamp(c[0], 0.0f, 1.0f)) << " " << formatG6(std::clamp(c[1], 0.0f, 1.0f)) << " " + << formatG6(std::clamp(c[2], 0.0f, 1.0f)) << "\n"; + // d is opacity, not transparency -- 1 is solid. + if (c[3] < 1.0f) mtl << "d " << formatG6(std::clamp(c[3], 0.0f, 1.0f)) << "\n"; + mtl << "\n"; } } @@ -157,38 +222,63 @@ std::vector toBytes(const std::string& s) { return std::vector } // namespace -void writeThreeMf(const std::string& path, const std::vector& bodies) { +void writeThreeMf(const std::string& path, const std::vector& objects) { std::string resources; std::string build; int nextId = 1; bool any = false; - for (const auto& b : bodies) { - if (!b.body || b.body->IsEmpty()) continue; - const manifold::MeshGL mesh = b.body->GetMeshGL(); - const size_t triCount = mesh.triVerts.size() / 3; + for (const ExportObject& o : objects) { + const size_t triCount = o.tris.size() / 3; if (triCount == 0) continue; const int colorGroupId = nextId++; - const std::array rgba = b.color.value_or(std::array{0.8f, 0.8f, 0.8f, 1.0f}); - resources += ""; + std::vector palette; // distinct colours, first-seen + std::vector triPalette; // one palette index per triangle + if (o.triColors.empty()) { + palette.push_back(hexColor(o.color)); + } else { + // 3MF's own model is per-triangle SURFACE colour -- the spec is + // explicit that colour describes the surface, not the + // distribution of material through the volume -- so a body whose + // surface came out of a multi-colour CSG merge needs no volume + // split to be written faithfully. + triPalette.reserve(triCount); + for (const auto& c : o.triColors) { + const std::string h = hexColor(c); + auto it = std::find(palette.begin(), palette.end(), h); + if (it == palette.end()) { + triPalette.push_back(palette.size()); + palette.push_back(h); + } else { + triPalette.push_back(static_cast(it - palette.begin())); + } + } + } + + resources += ""; + for (const std::string& h : palette) resources += ""; + resources += ""; const int objectId = nextId++; resources += ""; - const size_t vertCount = mesh.vertProperties.size() / mesh.numProp; + const size_t vertCount = o.verts.size() / 3; for (size_t v = 0; v < vertCount; ++v) { - const size_t base = v * mesh.numProp; - resources += ""; + resources += ""; } resources += ""; for (size_t t = 0; t < triCount; ++t) { - resources += ""; + resources += " checkExportBodies(const std::vector& bodie return out; } + +// -- the object split ----------------------------------------------------- + +// Ported from BelfrySCAD's exporters.py, which was where the colour and +// mesh-repair rules had been worked out; that module is now a shim over +// this. Behaviour is meant to be identical, including the parts that look +// like details but are not -- see the bounding-box skip below. + +namespace { + +bool isExportable(const ColoredBody& b) { + // `%` is scenery: drawn so other things can be lined up against it, and + // excluded from booleans upstream, so letting it reach a file would put + // geometry there that no boolean ever accounted for. + return b.role != BodyRole::Background; +} + +void meshToArrays(const manifold::MeshGL& mesh, std::vector& verts, std::vector& tris) { + const size_t numProp = mesh.numProp ? mesh.numProp : 3; + const size_t vertCount = mesh.vertProperties.size() / numProp; + verts.clear(); + verts.reserve(vertCount * 3); + for (size_t v = 0; v < vertCount; ++v) { + const size_t base = v * numProp; + verts.push_back(mesh.vertProperties[base]); + verts.push_back(mesh.vertProperties[base + 1]); + verts.push_back(mesh.vertProperties[base + 2]); + } + tris = mesh.triVerts; +} + +bool boxesOverlap(const manifold::Manifold& a, const manifold::Manifold& b) { + const manifold::Box ba = a.BoundingBox(); + const manifold::Box bb = b.BoundingBox(); + return !(ba.max.x < bb.min.x || bb.max.x < ba.min.x || ba.max.y < bb.min.y || bb.max.y < ba.min.y || + ba.max.z < bb.min.z || bb.max.z < ba.min.z); +} + +// A colour key that keeps per-triangle-coloured bodies apart from +// everything, including each other. Their colours index their own triangle +// list, and a union would rewrite that list and lose them. +struct ColorKey { + bool perTriangle = false; + size_t index = 0; // position in `solids`, for the per-triangle case + bool hasColor = false; + std::array color{}; + + bool operator==(const ColorKey& o) const { + if (perTriangle != o.perTriangle) return false; + if (perTriangle) return index == o.index; + if (hasColor != o.hasColor) return false; + return !hasColor || color == o.color; + } +}; + +struct Claimed { + manifold::Manifold man; + std::optional> color; + std::vector> triColors; + std::vector sourceTris; // what triColors was indexed against + ColorKey key; +}; + +manifold::Manifold addAll(const std::vector& parts) { + if (parts.size() == 1) return parts[0]; + return manifold::Manifold::BatchBoolean(parts, manifold::OpType::Add); +} + +// `triColors` if it still lines up with `outTris`, else empty. +// +// A per-triangle colour array indexes the triangle list it was built +// against, and every boolean rewrites that list -- so the array can only +// survive an object whose triangles came through untouched. Rather than +// reason about which paths are no-ops, this checks: BatchBoolean over a +// single operand and Decompose() of a single component both return the +// triangles unchanged, and anything that actually cut geometry will not +// match. Falling back to empty costs the object its per-triangle detail and +// it exports in its base colour, which is what happened before any of this. +std::vector> carryTriColors(const std::vector>& triColors, + const std::vector& sourceTris, + const std::vector& outTris) { + if (triColors.empty() || sourceTris.empty()) return {}; + if (triColors.size() * 3 != sourceTris.size()) return {}; + if (sourceTris != outTris) return {}; + return triColors; +} + +} // namespace + +std::vector splitBodiesForExport(const std::vector& bodies, std::vector* openParts) { + struct Solid { + manifold::Manifold man; + std::optional> color; + std::vector> triColors; + std::vector tris; + }; + + std::vector solids; + std::vector loose; + + int index = 0; + for (const ColoredBody& cb : bodies) { + ++index; + if (!isExportable(cb)) continue; + if (cb.isDisplayOnly()) { + // Manifold rejected this one -- an open shell is not a solid -- + // so it can join no boolean. Its triangles are real geometry the + // user can see, so they are written as-is and reported. + ExportObject obj; + meshToArrays(*cb.rawMesh, obj.verts, obj.tris); + if (obj.tris.empty()) continue; + obj.color = cb.color.value_or(kDefaultExportColor); + if (cb.triColors) obj.triColors = *cb.triColors; + loose.push_back(std::move(obj)); + if (openParts) openParts->push_back(index); + continue; + } + if (!cb.body || cb.body->IsEmpty()) continue; + Solid s; + s.man = *cb.body; + s.color = cb.color; + if (cb.triColors) s.triColors = *cb.triColors; + s.tris = s.man.GetMeshGL().triVerts; + if (s.tris.empty()) continue; + solids.push_back(std::move(s)); + } + + std::vector claimedGroups; + if (!solids.empty()) { + std::vector keys; + keys.reserve(solids.size()); + for (size_t i = 0; i < solids.size(); ++i) { + ColorKey k; + k.perTriangle = !solids[i].triColors.empty(); + k.index = i; + k.hasColor = solids[i].color.has_value(); + if (k.hasColor) k.color = *solids[i].color; + keys.push_back(k); + } + const bool allSame = std::all_of(keys.begin(), keys.end(), [&](const ColorKey& k) { return k == keys[0]; }); + + if (allSame) { + // The common case by far, and it needs no per-body subtraction + // at all: one colour cannot overlap itself into a different + // answer. + std::vector parts; + parts.reserve(solids.size()); + for (const Solid& s : solids) parts.push_back(s.man); + Claimed c; + c.man = addAll(parts); + c.color = solids[0].color; + c.triColors = solids[0].triColors; + c.sourceTris = solids[0].tris; + c.key = keys[0]; + claimedGroups.push_back(std::move(c)); + } else { + // Reverse order + subtract-what-is-already-claimed is what makes + // the LATER body win: by the time an earlier one is reached, + // everything after it has already taken its volume. + std::optional claimed; + std::vector owned; + for (size_t n = solids.size(); n-- > 0;) { + Solid& s = solids[n]; + manifold::Manifold piece = s.man; + if (claimed) { + // Skipping the subtraction when the bounding boxes + // cannot overlap is not just a shortcut: `A - disjoint + // B` returns A's volume but REORDERS its triangle list, + // which throws away any per-triangle colours A carried. + // Most models are mostly disjoint parts, so without this + // a two-tone body lost its colours the moment any other + // differently-coloured body existed. + if (boxesOverlap(s.man, *claimed)) piece = s.man - *claimed; + } + claimed = claimed ? (*claimed + s.man) : s.man; + if (piece.IsEmpty()) continue; + Claimed c; + c.man = std::move(piece); + c.color = s.color; + c.triColors = s.triColors; + c.sourceTris = s.tris; + c.key = keys[n]; + owned.push_back(std::move(c)); + } + std::reverse(owned.begin(), owned.end()); + + // Same-coloured pieces merge into one object; distinct colours + // stay apart. Insertion-ordered so object order still follows + // the source. + for (Claimed& c : owned) { + auto it = std::find_if(claimedGroups.begin(), claimedGroups.end(), + [&](const Claimed& g) { return g.key == c.key; }); + if (it == claimedGroups.end()) { + claimedGroups.push_back(std::move(c)); + } else { + it->man = addAll({it->man, c.man}); + } + } + } + } + + std::vector out; + for (const Claimed& g : claimedGroups) { + // Decompose() is the rule-3 split. A single-component solid comes + // back as a one-element list, so there is no special case here. + std::vector parts = g.man.Decompose(); + if (parts.empty()) parts.push_back(g.man); + for (const manifold::Manifold& part : parts) { + if (part.IsEmpty()) continue; + ExportObject obj; + meshToArrays(part.GetMeshGL(), obj.verts, obj.tris); + if (obj.tris.empty()) continue; + obj.color = g.color.value_or(kDefaultExportColor); + obj.triColors = carryTriColors(g.triColors, g.sourceTris, obj.tris); + out.push_back(std::move(obj)); + } + } + out.insert(out.end(), std::make_move_iterator(loose.begin()), std::make_move_iterator(loose.end())); + return out; +} + + +// -- STL (ASCII), PLY, VRML, X3D ------------------------------------------ + +void writeStlAscii(const std::string& path, const std::vector& bodies) { + std::optional mesh = composeMesh(bodies); + if (!mesh) throw std::runtime_error("No geometry to export"); + + std::ofstream out(path); + if (!out) throw std::runtime_error("Could not open '" + path + "' for writing"); + + const auto vertexAt = [&](uint32_t vertIndex) -> Vec3f { + const size_t base = static_cast(vertIndex) * mesh->numProp; + return {mesh->vertProperties[base], mesh->vertProperties[base + 1], mesh->vertProperties[base + 2]}; + }; + const auto fmt = [](const Vec3f& v) { + return formatG6(v.x) + " " + formatG6(v.y) + " " + formatG6(v.z); + }; + + out << "solid OpenSCAD_Model\n"; + for (size_t t = 0; t < mesh->triVerts.size() / 3; ++t) { + const Vec3f v0 = vertexAt(mesh->triVerts[t * 3 + 0]); + const Vec3f v1 = vertexAt(mesh->triVerts[t * 3 + 1]); + const Vec3f v2 = vertexAt(mesh->triVerts[t * 3 + 2]); + out << " facet normal " << fmt(normalized(cross(sub(v1, v0), sub(v2, v0)))) << "\n"; + out << " outer loop\n"; + out << " vertex " << fmt(v0) << "\n"; + out << " vertex " << fmt(v1) << "\n"; + out << " vertex " << fmt(v2) << "\n"; + out << " endloop\n endfacet\n"; + } + out << "endsolid OpenSCAD_Model\n"; +} + +void writePly(const std::string& path, const std::vector& objects) { + // Flatten first so the header can state the counts up front. + std::vector verts; + std::vector colors; + std::vector faces; + for (const ExportObject& o : objects) { + const size_t vertCount = o.verts.size() / 3; + const size_t triCount = o.tris.size() / 3; + const int32_t base = static_cast(verts.size() / 3); + const auto rgb = [](const std::array& c, int i) { + return static_cast(std::clamp(static_cast(std::lround(c[i] * 255.0f)), 0, 255)); + }; + if (o.triColors.empty()) { + verts.insert(verts.end(), o.verts.begin(), o.verts.end()); + for (size_t v = 0; v < vertCount; ++v) { + colors.push_back(rgb(o.color, 0)); + colors.push_back(rgb(o.color, 1)); + colors.push_back(rgb(o.color, 2)); + } + for (uint32_t idx : o.tris) faces.push_back(base + static_cast(idx)); + } else { + // PLY puts colour on vertices, and a vertex shared by two + // differently-coloured triangles has no single answer -- so an + // object with per-triangle colour is unwelded: three vertices + // per triangle, each carrying that triangle's colour. Only the + // objects that need it pay for it. + for (size_t t = 0; t < triCount; ++t) { + for (int k = 0; k < 3; ++k) { + const uint32_t vi = o.tris[t * 3 + k]; + verts.push_back(o.verts[vi * 3]); + verts.push_back(o.verts[vi * 3 + 1]); + verts.push_back(o.verts[vi * 3 + 2]); + colors.push_back(rgb(o.triColors[t], 0)); + colors.push_back(rgb(o.triColors[t], 1)); + colors.push_back(rgb(o.triColors[t], 2)); + faces.push_back(static_cast(faces.size()) + base); + } + } + } + } + + std::ofstream out(path, std::ios::binary); + if (!out) throw std::runtime_error("Could not open '" + path + "' for writing"); + out << "ply\n" + << "format binary_little_endian 1.0\n" + << "comment Written by BelfrySCAD\n" + << "element vertex " << (verts.size() / 3) << "\n" + << "property float x\nproperty float y\nproperty float z\n" + << "property uchar red\nproperty uchar green\nproperty uchar blue\n" + << "element face " << (faces.size() / 3) << "\n" + << "property list uchar int vertex_indices\n" + << "end_header\n"; + for (size_t v = 0; v < verts.size() / 3; ++v) { + writeRaw(out, verts[v * 3]); + writeRaw(out, verts[v * 3 + 1]); + writeRaw(out, verts[v * 3 + 2]); + out.put(static_cast(colors[v * 3])); + out.put(static_cast(colors[v * 3 + 1])); + out.put(static_cast(colors[v * 3 + 2])); + } + for (size_t t = 0; t < faces.size() / 3; ++t) { + out.put(static_cast(3)); + writeRaw(out, faces[t * 3]); + writeRaw(out, faces[t * 3 + 1]); + writeRaw(out, faces[t * 3 + 2]); + } +} + +namespace { + +// (palette, one index per face) -- empty palette when the object is a +// single flat colour. Shared by VRML and X3D, which are the same scene +// graph in different syntax. +struct FaceColors { + std::vector> palette; + std::vector index; +}; + +FaceColors faceColors(const ExportObject& o) { + FaceColors fc; + if (o.triColors.empty()) return fc; + for (const auto& c : o.triColors) { + auto it = std::find_if(fc.palette.begin(), fc.palette.end(), [&](const std::array& p) { + return p[0] == c[0] && p[1] == c[1] && p[2] == c[2]; + }); + if (it == fc.palette.end()) { + fc.index.push_back(fc.palette.size()); + fc.palette.push_back(c); + } else { + fc.index.push_back(static_cast(it - fc.palette.begin())); + } + } + return fc; +} + +} // namespace + +void writeVrml(const std::string& path, const std::vector& objects) { + std::ofstream out(path); + if (!out) throw std::runtime_error("Could not open '" + path + "' for writing"); + out << "#VRML V2.0 utf8\n# Written by BelfrySCAD\n\n"; + for (const ExportObject& o : objects) { + const FaceColors fc = faceColors(o); + const float transparency = 1.0f - o.color[3]; + out << "Shape {\n appearance Appearance {\n material Material {\n"; + out << " diffuseColor " << formatG6(o.color[0]) << " " << formatG6(o.color[1]) << " " + << formatG6(o.color[2]) << "\n"; + // Neither VRML nor X3D can carry per-triangle alpha -- a Color node + // is RGB only -- so the object's base alpha applies to the shape. + if (transparency > 0.0f) out << " transparency " << formatG6(transparency) << "\n"; + out << " }\n }\n geometry IndexedFaceSet {\n solid TRUE\n"; + out << " coord Coordinate {\n point [\n"; + for (size_t v = 0; v < o.verts.size() / 3; ++v) { + out << " " << formatG6(o.verts[v * 3]) << " " << formatG6(o.verts[v * 3 + 1]) << " " + << formatG6(o.verts[v * 3 + 2]) << ",\n"; + } + out << " ]\n }\n"; + if (!fc.palette.empty()) { + out << " colorPerVertex FALSE\n color Color {\n color [\n"; + for (const auto& c : fc.palette) { + out << " " << formatG6(c[0]) << " " << formatG6(c[1]) << " " << formatG6(c[2]) << ",\n"; + } + out << " ]\n }\n colorIndex [\n"; + // colorIndex, unlike coordIndex, may contain no negative + // entries: -1 terminates a face there and means nothing here. + for (size_t i : fc.index) out << " " << i << ",\n"; + out << " ]\n"; + } + out << " coordIndex [\n"; + for (size_t t = 0; t < o.tris.size() / 3; ++t) { + out << " " << o.tris[t * 3] << " " << o.tris[t * 3 + 1] << " " << o.tris[t * 3 + 2] << " -1,\n"; + } + out << " ]\n }\n}\n\n"; + } +} + +void writeX3d(const std::string& path, const std::vector& objects) { + std::ofstream out(path); + if (!out) throw std::runtime_error("Could not open '" + path + "' for writing"); + out << "\n"; + out << "\n"; + out << "\n"; + out << " \n \n \n"; + out << " \n"; + for (const ExportObject& o : objects) { + const FaceColors fc = faceColors(o); + const float transparency = 1.0f - o.color[3]; + out << " \n \n 0.0f) out << " transparency=\"" << formatG6(transparency) << "\""; + out << " />\n \n"; + out << " \n \n"; + if (!fc.palette.empty()) { + out << " \n"; + } + out << " \n \n"; + } + out << " \n\n"; +} + + +// -- one entry point ------------------------------------------------------ + +namespace { + +std::string lowerExtension(const std::string& path) { + const size_t dot = path.find_last_of('.'); + const size_t slash = path.find_last_of("/\\"); + if (dot == std::string::npos || (slash != std::string::npos && dot < slash)) return ""; + std::string ext = path.substr(dot); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); + return ext; +} + +bool isMultiObject(const std::string& ext) { + return ext == ".3mf" || ext == ".obj" || ext == ".ply" || ext == ".wrl" || ext == ".x3d"; +} + +// The merged single mesh STL/OFF write, with the open shells kept. +// +// A union, not a concatenation: concatenating is right only while the bodies +// are disjoint -- where two touch, each writes its own copy of the shared +// face, and the file ends up with coincident duplicate faces and edges used +// by four triangles. A Menger sponge is 400 abutting cubes at level 2 and +// came out with 1784 non-manifold edges that way: valid-looking in a viewer, +// rejected or silently "repaired" by a slicer. +// +// Bodies Manifold rejected (an open shell is not a solid) cannot join the +// union, but their triangles are real geometry the user can see, so they are +// concatenated on rather than dropped and their index is reported. +std::optional mergeBodies(const std::vector& bodies, std::vector* openParts) { + std::vector solids; + std::vector loose; + int index = 0; + for (const ColoredBody& b : bodies) { + ++index; + if (b.role == BodyRole::Background) continue; + if (b.isDisplayOnly()) { + if (!b.rawMesh->triVerts.empty()) { + loose.push_back(&*b.rawMesh); + if (openParts) openParts->push_back(index); + } + continue; + } + if (b.body && !b.body->IsEmpty()) solids.push_back(*b.body); + } + if (solids.empty() && loose.empty()) return std::nullopt; + + manifold::MeshGL out; + out.numProp = 3; + uint32_t offset = 0; + const auto append = [&](const manifold::MeshGL& m) { + const size_t np = m.numProp ? m.numProp : 3; + const size_t vertCount = m.vertProperties.size() / np; + for (size_t v = 0; v < vertCount; ++v) { + out.vertProperties.push_back(m.vertProperties[v * np]); + out.vertProperties.push_back(m.vertProperties[v * np + 1]); + out.vertProperties.push_back(m.vertProperties[v * np + 2]); + } + for (uint32_t i : m.triVerts) out.triVerts.push_back(i + offset); + offset += static_cast(vertCount); + }; + if (!solids.empty()) { + append(manifold::Manifold::BatchBoolean(solids, manifold::OpType::Add).GetMeshGL()); + } + for (const manifold::MeshGL* m : loose) append(*m); + return out; +} + +void writeStlMesh(const std::string& path, const manifold::MeshGL& mesh, bool ascii) { + const size_t numProp = mesh.numProp ? mesh.numProp : 3; + const auto vertexAt = [&](uint32_t vertIndex) -> Vec3f { + const size_t base = static_cast(vertIndex) * numProp; + return {mesh.vertProperties[base], mesh.vertProperties[base + 1], mesh.vertProperties[base + 2]}; + }; + const size_t triCount = mesh.triVerts.size() / 3; + + if (ascii) { + std::ofstream out(path); + if (!out) throw std::runtime_error("Could not open '" + path + "' for writing"); + const auto fmt = [](const Vec3f& v) { return formatG6(v.x) + " " + formatG6(v.y) + " " + formatG6(v.z); }; + out << "solid OpenSCAD_Model\n"; + for (size_t t = 0; t < triCount; ++t) { + const Vec3f v0 = vertexAt(mesh.triVerts[t * 3 + 0]); + const Vec3f v1 = vertexAt(mesh.triVerts[t * 3 + 1]); + const Vec3f v2 = vertexAt(mesh.triVerts[t * 3 + 2]); + out << " facet normal " << fmt(normalized(cross(sub(v1, v0), sub(v2, v0)))) << "\n"; + out << " outer loop\n"; + out << " vertex " << fmt(v0) << "\n vertex " << fmt(v1) << "\n vertex " << fmt(v2) << "\n"; + out << " endloop\n endfacet\n"; + } + out << "endsolid OpenSCAD_Model\n"; + return; + } + + std::ofstream out(path, std::ios::binary); + if (!out) throw std::runtime_error("Could not open '" + path + "' for writing"); + char header[80] = {}; + out.write(header, sizeof(header)); + writeRaw(out, static_cast(triCount)); + for (size_t t = 0; t < triCount; ++t) { + const Vec3f v0 = vertexAt(mesh.triVerts[t * 3 + 0]); + const Vec3f v1 = vertexAt(mesh.triVerts[t * 3 + 1]); + const Vec3f v2 = vertexAt(mesh.triVerts[t * 3 + 2]); + const Vec3f n = normalized(cross(sub(v1, v0), sub(v2, v0))); + writeRaw(out, n.x); writeRaw(out, n.y); writeRaw(out, n.z); + writeRaw(out, v0.x); writeRaw(out, v0.y); writeRaw(out, v0.z); + writeRaw(out, v1.x); writeRaw(out, v1.y); writeRaw(out, v1.z); + writeRaw(out, v2.x); writeRaw(out, v2.y); writeRaw(out, v2.z); + writeRaw(out, static_cast(0)); + } +} + +void writeOffMesh(const std::string& path, const manifold::MeshGL& mesh) { + std::ofstream out(path); + if (!out) throw std::runtime_error("Could not open '" + path + "' for writing"); + const size_t numProp = mesh.numProp ? mesh.numProp : 3; + const size_t vertCount = mesh.vertProperties.size() / numProp; + const size_t triCount = mesh.triVerts.size() / 3; + out << "OFF\n" << vertCount << " " << triCount << " 0\n"; + for (size_t v = 0; v < vertCount; ++v) { + const size_t base = v * numProp; + out << formatG6(mesh.vertProperties[base]) << " " << formatG6(mesh.vertProperties[base + 1]) << " " + << formatG6(mesh.vertProperties[base + 2]) << "\n"; + } + for (size_t t = 0; t < triCount; ++t) { + out << "3 " << mesh.triVerts[t * 3 + 0] << " " << mesh.triVerts[t * 3 + 1] << " " << mesh.triVerts[t * 3 + 2] + << "\n"; + } +} + +} // namespace + +const std::vector& exportExtensions() { + static const std::vector exts = {".3mf", ".stl", ".obj", ".off", ".ply", ".wrl", ".x3d"}; + return exts; +} + +std::vector exportModel(const std::string& path, const std::vector& bodies, + const ExportOptions& opts) { + std::string ext = opts.format.empty() ? lowerExtension(path) : opts.format; + if (!ext.empty() && ext[0] != '.') ext = "." + ext; + const std::vector& known = exportExtensions(); + if (std::find(known.begin(), known.end(), ext) == known.end()) { + throw std::runtime_error("Unsupported export format '" + ext + "'"); + } + + std::vector warnings; + const auto reportOpen = [&](const std::vector& openParts) { + for (int n : openParts) { + warnings.push_back("part " + std::to_string(n) + + " is not a closed solid; its surface is written as-is, and most slicers will reject it."); + } + }; + + if (isMultiObject(ext)) { + // These keep the parts as separate objects, so each is checked on + // its own -- that is what the file contains. + for (std::string& w : checkExportBodies(bodies)) warnings.push_back(std::move(w)); + std::vector openParts; + const std::vector objects = splitBodiesForExport(bodies, &openParts); + reportOpen(openParts); + if (objects.empty()) throw std::runtime_error("No geometry to export"); + if (ext == ".3mf") { + writeThreeMf(path, objects); + } else if (ext == ".obj") { + writeObj(path, objects); + } else if (ext == ".ply") { + writePly(path, objects); + } else if (ext == ".wrl") { + writeVrml(path, objects); + } else { + writeX3d(path, objects); + } + return warnings; + } + + std::vector openParts; + std::optional mesh = mergeBodies(bodies, &openParts); + reportOpen(openParts); + if (!mesh) throw std::runtime_error("No geometry to export"); + + if (opts.stripSlivers) { + SliverStripReport report; + manifold::MeshGL stripped = stripSlivers(*mesh, report); + if (report.removed > 0) { + warnings.push_back("removed " + std::to_string(report.removed) + + " zero-area face(s) before writing."); + mesh = std::move(stripped); + } + } + + // Checked AFTER merging and stripping, because that is what gets + // written. Checking the parts instead passed a Menger sponge whose + // 160,000 cubes were each fine and whose file was riddled with + // duplicate faces. + const MeshDiagnosis d = checkMesh(*mesh); + if (!d.ok()) warnings.push_back("exported mesh " + d.summary()); + + if (ext == ".off") { + writeOffMesh(path, *mesh); + } else { + writeStlMesh(path, *mesh, opts.asciiStl); + } + return warnings; +} + } // namespace oscadeval diff --git a/tests/test_cli.cpp b/tests/test_cli.cpp index 86fe596..91e3f50 100644 --- a/tests/test_cli.cpp +++ b/tests/test_cli.cpp @@ -72,12 +72,81 @@ TEST(CliExportFormats, StlExport) { } TEST(CliExportFormats, ObjExport) { - auto src = writeScript("cube.scad", kCubeScript); + // Distinct script name: these tests delete their own input, and + // ctest -j runs them concurrently in one directory. + auto src = writeScript("cube_obj.scad", kCubeScript); auto out = src.parent_path() / "cube_out.obj"; std::filesystem::remove(out); std::ostringstream stdout_, stderr_; EXPECT_EQ(runCli({src.string(), "-o", out.string()}, std::cin, stdout_, stderr_), 0); - EXPECT_TRUE(readFile(out).rfind("v ", 0) == 0); + // OBJ now carries the object split, so the file opens with its mtllib + // and an `o` group rather than going straight to vertices, and a + // companion .mtl is written alongside for the colours. + const std::string obj = readFile(out); + EXPECT_TRUE(obj.rfind("mtllib ", 0) == 0) << obj.substr(0, 40); + EXPECT_NE(obj.find("\no object_1\n"), std::string::npos); + EXPECT_NE(obj.find("\nv "), std::string::npos); + EXPECT_NE(obj.find("\nf "), std::string::npos); + auto mtl = src.parent_path() / "cube_out.mtl"; + EXPECT_TRUE(std::filesystem::exists(mtl)); + std::filesystem::remove(mtl); + std::filesystem::remove(src); + std::filesystem::remove(out); +} + +TEST(CliExportFormats, PlyExport) { + // Distinct script name: these tests delete their own input, and + // ctest -j runs them concurrently in one directory. + auto src = writeScript("cube_ply.scad", kCubeScript); + auto out = src.parent_path() / "cube_out.ply"; + std::filesystem::remove(out); + std::ostringstream stdout_, stderr_; + EXPECT_EQ(runCli({src.string(), "-o", out.string()}, std::cin, stdout_, stderr_), 0); + EXPECT_TRUE(readFile(out).rfind("ply\nformat binary_little_endian 1.0\n", 0) == 0); + std::filesystem::remove(src); + std::filesystem::remove(out); +} + +TEST(CliExportFormats, VrmlExport) { + // Distinct script name: these tests delete their own input, and + // ctest -j runs them concurrently in one directory. + auto src = writeScript("cube_wrl.scad", kCubeScript); + auto out = src.parent_path() / "cube_out.wrl"; + std::filesystem::remove(out); + std::ostringstream stdout_, stderr_; + EXPECT_EQ(runCli({src.string(), "-o", out.string()}, std::cin, stdout_, stderr_), 0); + const std::string wrl = readFile(out); + // The version the full-colour front ends name. + EXPECT_TRUE(wrl.rfind("#VRML V2.0 utf8\n", 0) == 0); + EXPECT_NE(wrl.find("IndexedFaceSet"), std::string::npos); + std::filesystem::remove(src); + std::filesystem::remove(out); +} + +TEST(CliExportFormats, X3dExport) { + // Distinct script name: these tests delete their own input, and + // ctest -j runs them concurrently in one directory. + auto src = writeScript("cube_x3d.scad", kCubeScript); + auto out = src.parent_path() / "cube_out.x3d"; + std::filesystem::remove(out); + std::ostringstream stdout_, stderr_; + EXPECT_EQ(runCli({src.string(), "-o", out.string()}, std::cin, stdout_, stderr_), 0); + const std::string x3d = readFile(out); + EXPECT_NE(x3d.find(""), std::string::npos); + EXPECT_NE(x3d.find(" volume 8) exported to `path` in every // format under test, via the real evaluator pipeline (not a hand-built // mesh) so export.cpp itself is exercised too. -void writeCubeAs(const std::filesystem::path& path, void (*writer)(const std::string&, const std::vector&)) { +void writeCubeAs(const std::filesystem::path& path, const std::string& format = "") { + // Through exportModel rather than a writer directly: that is the entry + // point every front end uses, so it is the one worth exercising. Evaluated e = evalSrc("cube(2, center=true);"); - writer(path.string(), e.bodies); + ExportOptions opts; + opts.format = format; + exportModel(path.string(), e.bodies, opts); } Value asExpr(const std::string& code, Evaluator& ev) { @@ -39,7 +43,7 @@ Value asExpr(const std::string& code, Evaluator& ev) { TEST(ImportModuleContext, StlRoundTripPreservesVolume) { const auto path = tempPath("cube.stl"); - writeCubeAs(path, &writeStl); + writeCubeAs(path); Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].body.has_value()); @@ -49,7 +53,7 @@ TEST(ImportModuleContext, StlRoundTripPreservesVolume) { TEST(ImportModuleContext, ObjRoundTripPreservesVolume) { const auto path = tempPath("cube.obj"); - writeCubeAs(path, &writeObj); + writeCubeAs(path); Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].body.has_value()); @@ -59,7 +63,7 @@ TEST(ImportModuleContext, ObjRoundTripPreservesVolume) { TEST(ImportModuleContext, OffRoundTripPreservesVolume) { const auto path = tempPath("cube.off"); - writeCubeAs(path, &writeOff); + writeCubeAs(path); Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].body.has_value()); @@ -69,7 +73,7 @@ TEST(ImportModuleContext, OffRoundTripPreservesVolume) { TEST(ImportModuleContext, ThreeMfRoundTripPreservesVolume) { const auto path = tempPath("cube.3mf"); - writeCubeAs(path, &writeThreeMf); + writeCubeAs(path); Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].body.has_value()); @@ -83,7 +87,7 @@ TEST(ImportModuleContext, ThreeMfRoundTripPreservesVolume) { // files several times bigger than they need to be. TEST(ImportModuleContext, ThreeMfIsDeflateCompressed) { const auto path = tempPath("cube_compressed.3mf"); - writeCubeAs(path, &writeThreeMf); + writeCubeAs(path); std::ifstream in(path, std::ios::binary); ASSERT_TRUE(in); @@ -200,7 +204,7 @@ TEST(ImportModuleContext, EmptyMeshHasNoTrianglesErrors) { TEST(ImportExpressionContext, StlReturnsVnfShape) { const auto path = tempPath("cube_vnf.stl"); - writeCubeAs(path, &writeStl); + writeCubeAs(path); Evaluator ev; Value v = asExpr("import(\"" + path.generic_string() + "\")", ev); const auto& outer = std::get(v)->items; diff --git a/tools/cli/cli_lib.cpp b/tools/cli/cli_lib.cpp index 44c134e..8ef4130 100644 --- a/tools/cli/cli_lib.cpp +++ b/tools/cli/cli_lib.cpp @@ -31,6 +31,9 @@ std::string formatForPath(const std::string& explicitFormat, const std::string& if (ext == ".obj") return "obj"; if (ext == ".off") return "off"; if (ext == ".3mf") return "3mf"; + if (ext == ".ply") return "ply"; + if (ext == ".wrl") return "wrl"; + if (ext == ".x3d") return "x3d"; return ""; } @@ -211,6 +214,7 @@ std::vector collectDeclarations(const std::vector& args, std::istream& in, std::ostream& out, std::ostream& err) { std::string inputPath; std::string outputPath; + bool asciiStl = false; std::string format; std::string profilePath; std::string profileFormat = "text"; @@ -222,6 +226,8 @@ int runCli(const std::vector& args, std::istream& in, std::ostream& const std::string& arg = args[i]; if (arg == "-o" && i + 1 < args.size()) { outputPath = args[++i]; + } else if (arg == "--ascii-stl") { + asciiStl = true; } else if (arg == "--format" && i + 1 < args.size()) { format = args[++i]; } else if (arg == "--profile" && i + 1 < args.size()) { @@ -241,7 +247,7 @@ int runCli(const std::vector& args, std::istream& in, std::ostream& } } if (inputPath.empty() || outputPath.empty()) { - err << "usage: openscad-cpp-evaluator -o [--format stl|obj|off|3mf] " + err << "usage: openscad-cpp-evaluator -o [--format stl|obj|off|3mf|ply|wrl|x3d] [--ascii-stl] " "[--profile FILENAME [--profile-format text|csv] [--profile-sort self|cumulative|calls|name] " "[--profile-min-self SECONDS] [--profile-min-calls N]] [--debug]\n"; return 1; @@ -376,22 +382,17 @@ int runCli(const std::vector& args, std::istream& in, std::ostream& profileFile << formatProfileReport(inputPath, *evaluator.profileResult, profileOpts); } - // Checked before writing, warned rather than refused: a - // deliberately open surface is a legitimate export, and - // blocking a save would be worse than saying so. - for (const std::string& problem : checkExportBodies(bodies)) { + // exportModel owns the split, the repair policy and the + // per-format dispatch; the warnings come back rather than being + // logged so each front end presents them its own way. Warned + // rather than refused: a deliberately open surface is a + // legitimate export, and blocking a save would be worse. + ExportOptions opts; + opts.format = fmt; + opts.asciiStl = asciiStl; + for (const std::string& problem : exportModel(outputPath, bodies, opts)) { err << "WARNING: export: " << problem << "\n"; } - - if (fmt == "stl") { - writeStl(outputPath, bodies); - } else if (fmt == "obj") { - writeObj(outputPath, bodies); - } else if (fmt == "off") { - writeOff(outputPath, bodies); - } else { - writeThreeMf(outputPath, bodies); - } out << "Exported to " << outputPath << "\n"; return 0; }