Skip to content

Add OpenSCAD file support (.scad files) - #20

Merged
ad-si merged 8 commits into
mainfrom
claude/scad-file-support-i5nv6p
Aug 21, 2026
Merged

Add OpenSCAD file support (.scad files)#20
ad-si merged 8 commits into
mainfrom
claude/scad-file-support-i5nv6p

Conversation

@ad-si

@ad-si ad-si commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Summary

LuaCAD can now open and evaluate OpenSCAD .scad files directly, alongside its existing Lua support. This is implemented by vendoring the OpenRSCAD language front-end (lexer, parser, and evaluator) and adapting it to LuaCAD's geometry pipeline.

Key Changes

  • Three new crates (luacad-scad-syntax, luacad-scad-ir, luacad-scad-eval): Vendored from OpenRSCAD to provide complete OpenSCAD language support

    • luacad-scad-syntax: Lexer (logos-based) and recursive-descent parser producing a typed AST
    • luacad-scad-ir: CSG intermediate representation (tree of geometry operations)
    • luacad-scad-eval: Tree-walk interpreter with optional bytecode VM fast-path, plus support for text(), color(), and customizer schema extraction
  • New scad_import module (crates/luacad/src/scad_import.rs): Adapter between OpenRSCAD's CSG tree and LuaCAD's geometry pipeline

    • Reads .scad files from disk with include/use resolution against OPENSCADPATH
    • Lowers OpenRSCAD nodes to CsgGeometry so downstream (meshing, export, rendering) works unchanged
    • Reports fidelity differences as warnings
  • CLI and Studio integration:

    • load_scad_file() and load_scad() public APIs for programmatic use
    • is_scad_file() helper to detect .scad extension
    • Updated help text and file handling to accept both .lua and .scad
    • Studio's "Run" button now executes either Lua or OpenSCAD depending on file type
  • Geometry export updates:

    • ScadNode::Polygon now carries optional paths field for contour index lists (OpenSCAD's polygon(points, paths))
    • Cross-section materialization handles both simple point lists and multi-contour polygons

Implementation Details

  • The evaluator includes a bytecode VM for function-call-heavy expressions (opt-in fast path; tree-walk remains the reference semantics)
  • Font support via fontdb for text() rendering; system fonts are registered on demand
  • Customizer schema extraction from source comments (clean-room implementation from public docs)
  • All three crates include Apache 2.0 + MIT dual licensing (matching OpenRSCAD)
  • Separate rustfmt.toml files in each crate preserve upstream formatting conventions

https://claude.ai/code/session_01D3KhNt8JmPPjSZgFNdTW5T

claude added 8 commits August 20, 2026 21:47
Adds a lexer, parser, and evaluator for the OpenSCAD language, taken from
OpenRSCAD (https://github.com/matthova/openrscad) at a084615. Upstream is a
clean-room reimplementation: its grammar comes from public documentation and
black-box observation of the OpenSCAD CLI, with no OpenSCAD source consulted.
It is Apache-2.0 OR MIT, so it composes with LuaCAD's AGPL.

Only the front half is taken. The evaluator lowers a .scad program to a CSG
tree that is nearly variant-for-variant LuaCAD's own ScadNode, so upstream's
geometry crate is dropped and Manifold keeps doing the meshing.

Vendored rather than depended on: the upstream crates are not published to
crates.io, so a git dependency would make luacad itself unpublishable and
break `cargo install luacad`. This follows what luacad-manifold-sys and
luacad-prime-core already do, including renaming the packages back to their
upstream names at the dependency site so the sources stay diffable.

Deviations from upstream, each marked in the source and in the crate READMEs:

- No bundled fonts. Upstream ships the twelve Liberation faces OpenSCAD uses
  so glyphs match byte-for-byte; that is 4 MB of TTFs, and LuaCAD's own text()
  is system-font-only anyway. Both paths now resolve a family the same way.
- Font resolution is fallible. Without a bundle to fall back on, a machine
  with no fonts is an ordinary outcome, so with_face() returns None and text()
  warns and emits no geometry instead of panicking the process.
- png 0.18 instead of 0.17, to match the rest of the workspace.
- The font-dependent tests asserted on Liberation's exact glyph metrics. They
  now register system fonts, assert on resolution behavior and glyph shape
  rather than metrics, and skip when nothing is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3KhNt8JmPPjSZgFNdTW5T
scad_import turns openrscad_ir::Node into ScadNode, so a .scad file joins the
pipeline where a Lua script does and everything downstream works unchanged:
Manifold meshing, all six mesh formats, .scad round-trip, the PNG renderer,
the path tracer, and Studio's preview.

Nearly every IR variant has a ScadNode counterpart. The cases that do not:

- $fn/$fa/$fs. The IR keeps all three per curved primitive; ScadNode stores a
  resolved count, so the OpenSCAD fragment formula is applied here. For
  rotate_extrude that needs the profile's reach from the axis, which is
  measured by materializing the profile.
- polygon(paths). ScadNode::Polygon gains an optional `paths` field, resolved
  even-odd. Without it every hole would fill in — including the counters in
  text(), which the evaluator lowers to contours rather than a text node.
- Multi-child hull() becomes a hull of the union, which is the same set.
- import() arrives as bytes, so it is read with mesh_import and emitted as a
  polyhedron. That also carries the mesh into formats with no way to
  reference an external file.
- Provenance (editor-to-preview source spans) is dropped.

Three constructs cannot be carried exactly and warn instead of silently
differing: linear_extrude with a non-uniform scale (LuaCAD scales both axes
together), resize(auto=), and an import in a format mesh_import cannot read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3KhNt8JmPPjSZgFNdTW5T
run, info, convert, watch and render each read the input file themselves and
hand it to the Lua engine. They now go through one load_model(), which picks
the front end from the extension, so an OpenSCAD file works everywhere a
LuaCAD script does — including `convert model.scad out.scad`, which
round-trips through the ScadNode tree with modules inlined.

echo() output goes to stdout and anything the adapter could not carry across
exactly goes to stderr as a warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3KhNt8JmPPjSZgFNdTW5T
The open dialog and drag-and-drop take .scad alongside .lua, and the build
picks its front end from the open file's extension. Everything past that
point is unchanged, because both languages produce ScadNode trees: the
viewport, the CSG tree, and every export already read them.

execute_lua_code is now execute_source, since it no longer only runs Lua.

Two things stay language-specific. selene only knows Lua, so an OpenSCAD
buffer is left unlinted instead of being reported as one long syntax error.
And syntect ships no OpenSCAD definition, so the editor highlights .scad with
the C rules — the closest fit for its comments, braces, numbers and strings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3KhNt8JmPPjSZgFNdTW5T
Integration tests for what only shows up once real files and the export stack
are involved: multi-file include/use resolution, import(), meshing, exporting
to all six formats, and the .scad round-trip.

One of them caught a real bug. mesh_import yields Manifold's winding, but
ScadNode::Polyhedron faces wind the way OpenSCAD writes them and the
materializer reverses them — so an imported mesh came in inside-out, with a
negative volume. The adapter now reverses to cancel that out.

Each vendored crate gets its own empty rustfmt.toml. rustfmt resolves config
from the file upwards, so this shadows the workspace's 2-space/80-column style
and keeps those sources formatted like upstream, and diffable against it.
render_text() gains a type alias for its return, which clippy's
type_complexity wanted once it became an Option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3KhNt8JmPPjSZgFNdTW5T
Publishing luacad now needs luacad-scad-syntax, -ir and -eval on crates.io
first. luacad-prime-core was already missing from the list even though luacad
depends on it through the default raytrace feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3KhNt8JmPPjSZgFNdTW5T
The lint job runs clippy with --deny warnings, and 1.98 added two lints that
Studio trips. Both are behavior-preserving:

- FrameInputGenerator::generate drained its event buffer into a fresh Vec.
  mem::take hands the buffer over and leaves an empty one behind, which is
  what draining did, minus the reallocation.
- compute_face_normals walked chunks_exact(3). as_chunks::<3>() types each
  group as a fixed [_; 3], so its three indexes are checked at compile time
  rather than at runtime; a trailing partial triangle is still dropped.

Neither file belongs to the OpenSCAD work in this branch — these fail the
same way on main, and are fixed here so the PR's lint job can go green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3KhNt8JmPPjSZgFNdTW5T
@ad-si
ad-si merged commit ae1a4f7 into main Aug 21, 2026
14 checks passed
@ad-si
ad-si deleted the claude/scad-file-support-i5nv6p branch August 21, 2026 11:37
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.

2 participants