Skip to content

Own the whole export pipeline, colour and mesh repair included - #93

Merged
revarbat merged 1 commit into
mainfrom
export-pipeline
Aug 13, 2026
Merged

Own the whole export pipeline, colour and mesh repair included#93
revarbat merged 1 commit into
mainfrom
export-pipeline

Conversation

@revarbat

Copy link
Copy Markdown
Member

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. They disagreed:

before (this repo) real OpenSCAD
cube(100); cube(100,center=true); → 3MF 2 objects, 24 tris 1 object, 36 tris
OBJ flat v/f, no o, no usemtl, no .mtl
per-triangle colour dropped

Ported from exporters.py, behaviour-for-behaviour

  • splitBodiesForExport — the implicit top-level union, cut into objects that never share volume: one per colour (later shape wins an overlap, painter's order), then one per connected component.
  • Per-triangle colour, which is what 3MF's model is built for — 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 is written faithfully with no volume split.
  • New writers: PLY, VRML97, X3D 3.3 (Interchange profile), ASCII STL, and OBJ with a companion .mtl.
  • exportModel() — one entry point, format from the extension, returning warnings rather than logging them, and carrying the repair policy the GUI used to apply itself (sliver stripping + mesh check on the merged mesh; per-body checks for multi-object formats). Open shells keep their triangles rather than being dropped.

Two rules came across intact that 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 — not by assuming which paths are no-ops.
  • 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, silently destroying those colours.

Python gets a handle, not 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. Evaluator stashes an opaque Geometry handle as .geometry, alongside csg_tree/profile_result, so evaluate()'s own 2-tuple result is unchanged and render-only callers never see it.

bodies, id_to_node = ev.evaluate(path)      # unchanged
warnings = oce.export_model("m.3mf", ev.geometry)

Measured honestly: this is not faster today. Like-for-like on Dalek (224k triangles) it is 1045 ms against Python's 976 ms. The ~146 ms rebuild saving is real in isolation 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.

Verification

  • Output 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.
  • 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 my new CLI format cases introduced (they wrote and deleted a shared cube.scad, so under ctest -j they destroyed each other's input).

CLI now reaches every format: -o out.{stl,obj,off,3mf,ply,wrl,x3d}, plus --ascii-stl.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@revarbat
revarbat merged commit 469d98b into main Aug 13, 2026
3 checks passed
@revarbat
revarbat deleted the export-pipeline branch August 13, 2026 08:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant