Skip to content

Repository files navigation

LuaCAD

Solid 3D CAD modeling with Lua.

Write parametric 2D and 3D models in Lua and export them to 3MF, STL, OBJ, PLY, OFF, AMF, or SCAD, or render them straight to a PNG. Existing OpenSCAD files open too — LuaCAD evaluates the .scad language itself, with no OpenSCAD installation involved.

Screenshot of LuaCAD Studio previewing the MuSHR racecar model, a 43-part assembly of 490,802 triangles, beside the Lua script that builds it

LuaCAD embeds Lua 5.4 in a Rust engine that evaluates CSG operations directly (via Manifold) or generates SCAD code for external rendering.

Installation

Via Homebrew

Installs the prebuilt luacad and luacad-studio binaries, so nothing has to be compiled:

brew install ad-si/tap/luacad

Via Crates

cargo install luacad  # CLI for running and converting LuaCAD scripts
cargo install luacad-studio  # GUI desktop app with live 3D preview

From source

Requires Rust.

git clone https://github.com/ad-si/LuaCAD.git
cd LuaCAD
make install

Build requirements

Both crates vendor their C/C++ dependencies, so no system libraries need to be installed — but a C++ compiler and CMake must be available to build them:

  • luacad builds Manifold and Clipper2
  • luacad-studio additionally builds OpenCSG, which needs OpenGL development headers (on Debian/Ubuntu: libgl1-mesa-dev, libx11-dev, libxcb1-dev, libxkbcommon-dev, libxrandr-dev, libwayland-dev)

Usage

CLI

luacad convert model.lua output.3mf   # Convert to 3MF
luacad convert model.lua output.stl   # Convert to STL
luacad convert model.lua output.scad  # Export as SCAD for OpenSCAD
luacad watch model.lua output.3mf     # Rebuild on file changes
luacad render model.lua preview.png   # Render to a PNG image
luacad info model.lua                 # Print triangle counts and bounding box
luacad lint model.lua                 # Lint with selene (also takes directories)
luacad run model.lua                  # Execute (side-effects only)

Every subcommand also takes a .scad file wherever it takes a .lua one:

luacad convert model.scad output.3mf  # Mesh an OpenSCAD file
luacad render model.scad preview.png  # Render one to a PNG
luacad info model.scad                # Its triangle count and bounding box

convert and watch infer the format from the output extension; --format <fmt> overrides it. --via-openscad hands the export to an installed OpenSCAD binary instead of building the mesh with Manifold. render shades flat by default, so the tessellation stays visible; --smooth turns that off. --raytrace renders with a path tracer instead of the rasterizer: soft shadows and ambient occlusion, at a few seconds per image. Path-traced renders always shade smooth (creases stay sharp). --samples N sets the path tracer's samples per pixel (default: 128); more samples mean less noise at proportionally longer render times.

luacad --help lists the subcommands, and luacad --version (or -v) prints the version — see Version information.

Studio

luacad-studio            # Reopen the file from the last session
luacad-studio model.lua  # Open a file
luacad-studio --help     # Show the command line options and exit
luacad-studio --version  # Show the version and exit

Desktop app with a code editor and 3D viewport. Edit Lua code on the right, see the model update on the left. The viewport draws the CSG tree itself through OpenCSG, so a boolean shows up as soon as the script runs, without waiting for a mesh to be built for it. Settings → About shows the same version information as --version, plus the target the binary was built for.

Version information

Both binaries accept -v / --version and -h / --help, and exit immediately:

$ luacad --version
luacad 1.1.0 (v1.1.0-3-g23a0ea2-dirty)

A binary built from a git checkout appends git describe --always --dirty --tags to the crate version, so a local build can be traced back to the commit it came from — the hash alone when the checkout has no tags, with -dirty marking a modified working tree. Released binaries and installs from crates.io print the plain version (luacad 1.1.0). Set LUACAD_GIT_DESCRIBE at build time to pin the suffix (or to drop it, with an empty value) for reproducible builds:

LUACAD_GIT_DESCRIBE=nixpkgs cargo build --release

Playground

https://luacad.ad-si.com/playground

The same engine, compiled to WebAssembly and running in the browser — no installation, and nothing is uploaded. Write a script, see the model, download it as STL, 3MF, OBJ, PLY, OFF or AMF. Copy link puts the script in the URL fragment, which makes a model shareable without a server ever seeing it.

Building it locally needs Emscripten and a Rust toolchain with the wasm32-unknown-emscripten target. The dev shell brings both:

nix develop        # Or: source <emsdk>/emsdk_env.sh
make test-wasm     # Build the module and check that it still runs a script
make serve-website # Serve the site at http://localhost:8000/playground/

Without Nix, install the target with rustup target add wasm32-unknown-emscripten.

The deployed copy is rebuilt by CI on every push to main.

Example

my_cube = cube { size = { 1, 2, 3 } }

function my_sphere(radius)
  return sphere({ r = radius }):translate(5, 0, 0)
end

model = my_cube + my_sphere(2)

render(model)

Equivalent OpenSCAD:

module my_cube() {
  cube(size=[1,2,3]);
}

module my_sphere(radius) {
  translate([5,0,0]) sphere(r = radius);
}

union() {
  my_cube();
  my_sphere(2);
}

More examples in the examples/ directory.

Check out the website to see all supported features!

For easier usage, LuaCAD has full support for the Belfry OpenSCAD Library v2. You can use its functions like this:

bosl.cuboid { {40, 40, 40}, rounding = 2 }
bosl.regular_prism { 5, r = 10, h = 25 }
bosl.spur_gear { circ_pitch = 5, teeth = 20, thickness = 5 }

All 712 of its geometry functions are reimplemented in LuaCAD, so they render, preview and export to a mesh without OpenSCAD or BOSL2 installed. Exporting to .scad still writes the BOSL2 call itself, which keeps the exported file as short as the script that produced it.

What is deliberately left out is the part of BOSL2 that exists to work around OpenSCAD not being a real language — fnliterals.scad, strings.scad, utility.scad, structs.scad and the BOSL1 compatibility names. Lua has closures, a string library and tables of its own.

Two things read differently here, because a shape in LuaCAD is a value rather than a child of the call that made it:

-- Attaching is a function of two shapes, not a wrapper around one.
local slab = bosl.cuboid { {40, 40, 10} }
local post = bosl.attach(slab, bosl.cyl { d = 10, h = 20 }, bosl.TOP)

-- A tag rides on the shape, and diff() reads it off the list.
bosl.diff { slab, post, bosl.tag(bosl.cyl { d = 5, h = 60 }, "remove") }

surface(), scad() and import() of a DXF file do still need OpenSCAD; luacad names them rather than exporting a file without them.

Why Lua?

The OpenSCAD language is limited and has several issues:

  • Confusing functions vs modules
  • Weird variable scoping
  • Not a well establised programming language
    • Bad editor support
    • Limited documentation
    • Limited libraries
    • Bad error messages
    • Bad performance

Therefore, a real programming language should be used and it should ideally be interpreted and have good operator overloading support

  • Julia - Too complex
  • Python - Too slow and while easy to get started, hard to master

Lua is a better fit:

  • Well-established, embeddable language
  • Operator overloading for natural CSG syntax (a + b, a - b)
  • Consistent semantics and good performance
  • Similar syntax to OpenSCAD's language
  • Already used in other CAD software (LibreCAD, Autodesk Netfabb)

Supported Export Formats

Every value your script returns becomes its own 3MF object, so slicers like BambuStudio load them as individually movable objects without needing "Split to Objects". Label them with :name(…) to control how they appear in the object list:

return
  cube(10, 10, 10):name("base"),
  sphere(6):translate(25, 0, 0):name("knob")

The other formats cannot express separate objects, so they flatten everything into a single mesh.

A 2D shape is output in its own right — render it without extruding first:

render(square { 60, 40, center = true } - circle { r = 8 })

It exports to SCAD as the 2D calls it is made of, and previews as a flat area on the build plate, in the CLI's PNG renderer, in Studio and in the playground. The mesh formats need a solid and say so, since an outline has no volume to print — give it a linear_extrude(height) for those.

Supported Import Formats

import() reads every mesh format LuaCAD writes — 3MF, STL, OBJ, PLY, OFF and AMF — and returns a solid you can transform and combine like any primitive:

bracket = import("bracket.stl")
render(bracket - cylinder { r = 3, h = 30 }:translate(5, 5, -5))

SVG and DXF return a 2D sketch instead, ready to extrude:

render(import("logo.svg"):linear_extrude(2))

An SVG keeps its physical units (mm, cm, in, …); unitless coordinates are read at 72 dpi, the default OpenSCAD's import() uses, and the y-axis is flipped so the drawing keeps the orientation it has on screen.

Only geometry is read; colors, materials and texture coordinates are dropped. A DXF sketch reaches the SCAD tree only, so exporting one to a mesh needs --via-openscad.

Opening OpenSCAD Files

A .scad file works anywhere a .lua one does — on the command line and in Studio, where it opens through File → Open or by dropping it on the window. LuaCAD parses and evaluates the OpenSCAD language itself, so nothing has to be installed and --via-openscad is not involved:

luacad convert bracket.scad bracket.3mf
luacad render bracket.scad bracket.png

The language support is a vendored copy of OpenRSCAD's front end, a clean-room reimplementation of OpenSCAD 2021.01 — modules, functions, include/use, for, list comprehensions, recursion, dynamically scoped $ variables, the modifier characters, and the standard library. include and use resolve relative to the file, then against OPENSCADPATH.

Both languages produce the same internal tree, so an opened .scad file reaches every export format, the PNG renderer, the path tracer and Studio's live preview unchanged. luacad convert model.scad out.scad round-trips it, with modules inlined and $fn/$fa/$fs resolved to facet counts.

Three constructs cannot be carried across exactly and report a warning rather than quietly differing: linear_extrude with a non-uniform scale (LuaCAD scales both axes together), resize(auto = …), and an import() in a format LuaCAD's own import() cannot read. Anything OpenSCAD itself does differently is listed in OpenRSCAD's compatibility register.

Editing is still Lua's job: Studio does not lint .scad buffers, and the browser playground remains Lua-only.

Text

text() outlines a system font into a sketch, and text3d() extrudes it in one step:

render(text("LuaCAD", { size = 12, halign = "center" }):linear_extrude(2))
render(text3d("v1.0", { size = 8, depth = 1.5 }))

Fonts are looked up by family, optionally with a style — "DejaVu Sans" or "DejaVu Sans:style=Bold" — and an unknown family falls back to the default sans-serif face. Layout is a single line of glyph advances plus kerning pairs; ligatures and complex scripts need --via-openscad, which runs the text through OpenSCAD's shaping engine instead.

Related

Other CAD software with programmatic model creation:

History

The initial Lua implementation was done by Michael Lutz at thechillcode/Lua_CAD. The project was later rewritten in Rust.

Releases

Used by

Contributors

Languages