Implemented. Exports part of the object tree to a file, allowing multiple files
from one .scad script. Unlike the GUI's single export (--output, File → Export
menu), export() runs during script evaluation in the OCCT worker process —
no IPC changes required.
Syntax
// Two files from one script
export("part1.stl") { cube(10); }
export("part2.step") { rotate([0,0,45]) cylinder(r=5, h=20); }
// fmt can be explicit — the file name is used exactly as given
// (never modified): write to /dev/null, no extension, custom names all work
export(file="gear", fmt="dxf") { ... }
// All formats: step, stl, dxf (EXT_TO_FMT in make_writer)
The export node is a transparent container (an SCLPart3 without shape):
display()recurses into children → exported geometry stays visible in the viewportget_children()flattens it insideunion()etc. — behaves likegroup()- The node itself contributes nothing to the tree shape
Design decisions (agreed)
- Syntax —
export(file) { children }. Exports the block as a subtree. - Timing — write during evaluation.
_module_scopeevaluates children before writing (parse_blockthen kwargs), so the subtree is fully compiled atwriter.Write()time. Errors elsewhere in the tree don't affect it. %background children — skipped (pattern ofunion(), scl_context.py:2589), so the file matches what's visually "solid".exportis bcad-specific (OpenSCAD warns "Ignoring unknown module call 'export'").
Implementation
Files
| File | Change |
|---|---|
scl.py |
export_module_definition (~356) + _handle_export (~2360, before _handle_surface) + _builtin_modules entry (has_block: True, func_id: FUNC_EXPORT) |
progress_helper.py |
FUNC_EXPORT = 37 + name "export" |
docs/scl.md |
documentation |
tests/test_stl_resolution.scad |
STL resolution regression test (uses export/import/capture/num_faces) |
Handler (pattern: _handle_color, scl.py:1787)
def _handle_export(self, node):
sid, line, args, block = node['id'], node['line'], node['args'], node.get('block', [])
with self._module_scope(SCLPart3, "export", sid, line, args,
export_module_definition['args'], block) as (ctx, top):
filepath = self.find_variable_value('file', line) or ""
fmt = self.find_variable_value('fmt', line) or None
if not filepath:
ctx.warn("export: missing file parameter")
else:
base = os.path.dirname(os.path.abspath(self.path)) # relative → script dir
if not os.path.isabs(filepath):
filepath = os.path.join(base, filepath)
writer, filepath = self.make_writer(filepath, fmt) # fmt from extension
if writer is not None:
for c in ctx.children:
if c.modifier & Modifier.BACKGROUND:
continue
c.display(writer) # colors → STEP automatically
try:
writer.Write(filepath) # fresh writer per call
except Exception as e:
warning(f"export failed (...): {e}")
_child_keys = _collect_child_cache_keys(ctx) # required (assert)
if _child_keys is not None:
ctx._cache_key = ShapeCache.instance().compute_key(
'export', {'file': filepath, 'fmt': str(fmt)}, _child_keys)
Key mechanics
- Dispatch — no lexer/parser changes:
exportis not inreserved, parses as a genericcall→stat_call→_builtin_moduleslookup (scl.py:3951). - Module definition — like
import_module_definition(scl.py:352): argsfile(string, default"") andfmt(string, defaultNone). - Cache key — mandatory:
_collect_child_cache_keysasserts non-None (scl.py:121-127). export node contributes to parent's cache key → parent rebuilds when exported content changes (correct). - Autocomplete/calltips — picked up automatically from
_builtin_modules(bcode_editor.py iterates it). - Writers are stateful — STL accumulates shapes, STEP accumulates into an OCAF
document. A fresh writer per
export()call.
Behavior notes
- Relative path resolves against
self.pathdirectory — which during eval is the file containing the export statement (per-statement switch, incl.includes). - File name is never modified. With an explicit
fmtthe format is determined by the parameter; the file name is used exactly as written (e.g./dev/null, names without an extension, custom extensions). Withoutfmt, the format is detected from the extension (make_writer). make_writerreturns(None, filepath)on unknown fmt → must check.- STL
Writecreates an empty file (not an exception) if no valid geometry; STEP raises on failure (wrapped in try/except → warning). - Empty block → warn "no geometry to export".
- CLI
--testmode: export() DOES write files (script executes). This is intentional and used by tests/test_stl_resolution.scad. !root modifier only affectsself.contextaftereval_scope— a script-levelexport()runs during eval and is unaffected.
Scoping constraints (3-phase evaluation)
eval_scope (scl.py:3075) runs in 3 phases: assignments (phase 1) execute BEFORE
all non-assignment statements (phase 2). Consequences for tests/usage:
- A
capture("x")statement runs in phase 2, so a top-level assignmentn = num_faces(x)(phase 1) readsxas undef. Useassert(...)directly — assert is a phase-2 statement. $fn/$fa/$fs/$feare read bymake_writer()at export time (phase 2), and a scope's phase-1 assignment collapse means each scope has a single final value. To test several mesh densities, give each density its ownif (true)block.
STL resolution semantics (in make_writer)
The exported STL mesh honors $fn/$fa/$fs/$fe (OpenSCAD semantics, approximated
by OCCT):
$fn > 0→ang = 2π / max($fn, 3)- else →
ang = radians($fa) lin = min($fs, $fe)(positive values only; fallback 0.5)
Linear deflection only shows on large parts (angular dominates cylinder walls);
planar faces are unaffected. hull() uses the same min($fs, $fe) rule
(scl_context.py:2068).
Testing plan
A single .scad with two export() calls (different formats) + one % background
child inside an export block; verify both files exist and the % child is absent.