From 23c77aff526017f32e39d361ea61ca549919878d Mon Sep 17 00:00:00 2001 From: Revar Desmera Date: Thu, 13 Aug 2026 00:18:50 -0700 Subject: [PATCH] Own the whole export pipeline, colour and mesh repair included Export was split between two implementations: this repo wrote STL/OBJ/OFF/ 3MF as flat geometry, while BelfrySCAD's exporters.py held the actual truth about colour and mesh repair in ~400 lines of Python the CLI never saw. The two disagreed. `cube(100); cube(100, center=true);` exported here as two overlapping objects of 24 triangles where real OpenSCAD writes one of 36 -- top level is an implicit union -- and OBJ came out with no `o` groups, no materials and no .mtl at all. Ported from exporters.py, behaviour-for-behaviour: * splitBodiesForExport: the implicit top-level union, cut into objects that never share volume. One object per colour, later shape winning any overlap (painter's order), then one per connected component. * Per-triangle colour, which 3MF's model is built for -- its 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 is written faithfully with no volume split. * PLY, VRML97 and X3D 3.3 (Interchange profile) writers, plus ASCII STL and OBJ with a companion .mtl. * exportModel(): one entry point, format from the extension, returning the warnings to surface rather than logging them. It also carries the repair policy the GUI used to apply itself -- sliver stripping and the mesh check on the merged mesh, per-body checks for the multi-object formats -- and keeps open shells' triangles rather than dropping them. The two subtle rules came across intact, both of which look like details and are not: a per-triangle colour array indexes the triangle list it was built against, so it is carried only when the result's triangles compare equal rather than assuming which paths are no-ops; and the per-colour claim skips the subtraction when bounding boxes cannot overlap, because `A - disjoint B` returns A's volume but reorders its triangle list. Python gets an opaque Geometry handle instead of another array round-trip. bodyToDict flattens every Manifold into numpy for the renderer, which is all the renderer needs, but export has to do real CSG -- rebuilding Manifolds from those arrays costs ~146ms on a 224k-triangle model and loses Manifold's provenance. Evaluator stashes the handle as `.geometry` alongside csg_tree/profile_result, so evaluate()'s own 2-tuple result is unchanged and callers that only render never see it. Measured honestly: like-for-like on Dalek (224k triangles) this is 1045ms against Python's 976ms, i.e. no faster today. The rebuild saving is real but swamped by the mesh checks and the split, which dominate and which both sides hand to the same Manifold library. The handle is kept for the architecture and the provenance, not for a speedup it does not deliver. Output verified identical to the Python implementation across all six formats on a two-tone-plus-transparent model -- triangle counts, object counts, per-triangle colour indices, material counts -- and the union bug now matches real OpenSCAD exactly (36 triangles, 1 object). 1481 tests pass, green across three consecutive -j8 runs after fixing a test-isolation bug the new CLI format cases introduced: they wrote and deleted a shared cube.scad, so under ctest -j they destroyed each other's input. Co-Authored-By: Claude Opus 5 --- bindings/module.cpp | 50 +- include/openscad_cpp_evaluator/export.hpp | 103 ++- pyproject.toml | 2 +- python/openscad_cpp_evaluator/__init__.py | 45 +- src/export.cpp | 790 +++++++++++++++++++++- tests/test_cli.cpp | 73 +- tests/test_import_export.cpp | 20 +- tools/cli/cli_lib.cpp | 31 +- 8 files changed, 1051 insertions(+), 63 deletions(-) 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; }