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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions bindings/module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -340,6 +341,36 @@ std::pair<nb::dict, nb::object> 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<oscadeval::ColoredBody> 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<std::string> 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<oscadeval::ManifoldCache> manifoldCache, bool profile) {
std::unordered_map<std::string, oscadeval::Value> vp = toViewportParams(viewportParams);
Expand Down Expand Up @@ -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<Geometry>();
geom->bodies = std::move(bodies);
return nb::make_tuple(bodiesToList(geom->bodies), echoList, idSpansToDict(idSpans), csgTreeToPy(csgTree),
profileResultToPy(profileResult), dyn, dynExplicit, geom);
}

// ------------------------------------------------------------------------
Expand Down Expand Up @@ -721,6 +754,19 @@ NB_MODULE(_openscad_cpp_evaluator, m) {
.def(nb::init<>())
.def("request", &FastContinueSignal::request);

nb::class_<Geometry>(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).");
Expand Down
103 changes: 98 additions & 5 deletions include/openscad_cpp_evaluator/export.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,51 @@

#include "openscad_cpp_evaluator/colored_body.hpp"

#include <array>
#include <cstdint>
#include <string>
#include <vector>

namespace oscadeval {

// The colour every object falls back to when the script gave none.
inline constexpr std::array<float, 4> 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<float> verts; // xyz triples
std::vector<uint32_t> tris; // vertex-index triples
std::array<float, 4> color = kDefaultExportColor;
std::vector<std::array<float, 4>> 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<ExportObject> splitBodiesForExport(const std::vector<ColoredBody>& bodies,
std::vector<int>* 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
Expand All @@ -28,10 +68,34 @@ std::vector<std::string> checkExportBodies(const std::vector<ColoredBody>& bodie

void writeStl(const std::string& path, const std::vector<ColoredBody>& 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<ColoredBody>& 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<ColoredBody>& 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<ExportObject>& 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<ExportObject>& 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<ExportObject>& 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<ExportObject>& objects);

// OFF (Object File Format): header "OFF", "$verts $tris 0", vertex lines,
// then "3 i j k" per triangle (0-indexed, count-prefixed). Mirrors
Expand All @@ -46,6 +110,35 @@ void writeOff(const std::string& path, const std::vector<ColoredBody>& 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<ColoredBody>& bodies);
void writeThreeMf(const std::string& path, const std::vector<ExportObject>& 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<std::string> exportModel(const std::string& path, const std::vector<ColoredBody>& bodies,
const ExportOptions& opts = {});

// The extensions exportModel understands, lower-case and dot-prefixed.
const std::vector<std::string>& exportExtensions();

} // namespace oscadeval
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 44 additions & 1 deletion python/openscad_cpp_evaluator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]


Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down
Loading