Skip to content

Latest commit

 

History

373 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SCADwright

SCADwright is a Python library for designing 3D parts and assemblies: you write Python; SCADwright generates an OpenSCAD source file that renders into STL (or any other format OpenSCAD supports).

What is this and why does it exist?

While OpenSCAD offers a straight-forward and easy path to programmatic 3d design, it is severely limited in ways that rapidly get annoying once your project grows beyond a few parts.

SCADwright keeps the basic OpenSCAD model and primitives, but SCADwright goes way beyond just a python wrapper, offering additional functionality not easily possible with OpenSCAD:

and more.

While simple projects very strongly resemble OpenSCAD code (easy to be productive immediately), as your projects grow in complexity, SCADwright allows a graceful transition to more complex features, without any hard syntactic or conceptual boundaries. Styles can be mixed and matched in the same project.

I have put significant effort into refining the UX of SCADwright: the more advanced constructs use a syntax that's neither OpenSCAD nor quite standard object-oriented python. Instead, the goal is to ruthlessly eliminate boiler plate, and make constructs simple, elegant, and highly expressive.

SCADwright optimizes for common cases, for those with little background in object-oriented python or advanced OpenSCAD, but retains full python capabilities and a low-level interface for exceptional cases.

SCADwright calls OpenSCAD only at render time. The Python side requires sympy (pulled in automatically by pip) and nothing else. I've taken some care to make emitted SCAD human-readable.

If you're comparing SCADwright against SolidPython, PythonSCAD, CadQuery, Build123d, or other Python+CAD tools, see How is SCADwright different? for a side-by-side.

The quick start / organizing a project guide is the best place to see the power of SCADwright in action, but there's also full documentation and examples projects.

A picture is worth 250+ lines of OpenSCAD

Rocket

Less than 60 lines of SCADwright gives you: parabolic ogive nose, bulged body, three parabolic-swept fins with rounded edges, a flared nozzle, a tapered helicoid stem with an almond cross-section, a filleted M2-counterbored baseplate, and correctly spaced text (both raised and inset) on a doubly curved surface.

SCADwright addresses 16 annoying limitations of OpenSCAD:

1. Components make what they know accessible externally; modules can't.

When you write a parametric module in OpenSCAD — say a bracket with mount-hole positions — the caller has no way to ask where those holes are. You are forced to compute the offsets outside the object, or in two places, or hard-code them.

In SCADwright, parametric parts are Python classes called Components. The caller can read any attribute of other Components freely, and use that information to construct other parts to fit.

from scadwright import Component
from scadwright.primitives import cube

class Bracket(Component):
    equations = "width, height > 0"

    def build(self):
        return cube([self.width, self.width, self.height])

b = Bracket(width=80, height=5)
print(b.width)               # readable; no geometry built yet

For scenarios where multiple parts rely on the same mechanical interface, or when you want to separate direct measurements from your code, SCADWright offers Specs.

2. Define a component's equations once, call it with any combination of sufficient arguments, the framework handles the rest; no boilerplate. Components are as nice to write as they are to call.

Consider a hollow tube has an outer diameter, an inner diameter, and a wall thickness, linked by od = id + 2*thk.

In OpenSCAD you either write three modules (tube_by_id_thk, tube_by_od_thk, tube_by_id_od) or one module with conditional logic. The relationship lives in a comment; the code just enumerates cases. And if a wall thickness must be positive, you write an assert() that fires at render time — after you've already waited.

In SCADwright, you write a Component, declaring relationships and constraints together as equations.

When you want to use the Component, supply whatever combination of arguments you want. The framework automatically works out a solution, if it can. Otherwise, you get a specific error: malformed (not an equation), insufficient (provided arguments can't solve the equations), inconsistent (constraint failed, or provided arguments generate inconsistent solutions), and ambiguous (multiple discrete solutions, usually fixable with a >0 constraint).

You don't even need to isolate a variable on the left of the equation like you do with programming languages.

from scadwright import Component

class Tube(Component):
    equations = """
        od - id = 2*thk                    # structural relationship: solve for the missing one
        h, id, od, thk > 0                 # constraints
    """

    def build(self): ...

Tube(h=10, id=8, thk=1)      # od solved = 10
Tube(h=10, id=8, od=10)      # thk solved = 1
Tube(h=10, od=10, thk=1)     # id solved = 8
Tube(h=10, id=8, thk=-1)     # ValidationError: thk must be positive

Trigonometry uses degrees, like OpenSCAD.

class RightTriangle(Component):
    equations = """
        tan(alpha) = opp/adj
        cos(alpha) = adj/hyp
    """

    def build(self): ...

RightTriangle(opp=3, adj=4)         # solves for alpha = 36.87, hyp = 5
RightTriangle(hyp=5, alpha=36.87)   # solves for opp = 3, adj = 4

Equations aren't limited to scalar arithmetic. Either side can be any Python expression: build a tuple with a comprehension, pick between values with a conditional, read fields and items off the inputs.

class BatteryHolder(Component):
    spec = Param(BatterySpec)
    equations = """
        count:int > 0                                             # constraint 
        wall_thk, clearance, end_clearance > 0                    # constraint 
        pitch = spec.d + 2*clearance                              # field read on a namedtuple input
        outer_w = count * pitch + 2*end_clearance                 # arithmetic
        positions = tuple(i*pitch for i in range(count))          # tuple from a comprehension
        len(positions:tuple) = count                              # redundant consistency
    """

Equations also handle optional inputs. Prefix a name with ? to mark it optional; arithmetic that bottoms out on an unset value silently skips, while the exactly_one / at_least_one / at_most_one / all_or_none helpers and ? inside conditionals enforce or branch on presence explicitly.

class FilletedBracket(Component):
    equations = """
        w, l, thk > 0
        exactly_one(?r, ?r_frac)                   # absolute radius or fraction-of-thk; one and only one
        edge = ?r if ?r else ?r_frac * thk         # synthesize the active value
        edge < thk / 2                             # downstream check uses the synthesized name
    """

    def build(self): ...

FilletedBracket(w=40, l=20, thk=4, r=1)               # absolute
FilletedBracket(w=40, l=20, thk=4, r_frac=0.25)       # 25% of thk
FilletedBracket(w=40, l=20, thk=4)                    # ValidationError: exactly_one(?r, ?r_frac) — neither set
FilletedBracket(w=40, l=20, thk=4, r=1, r_frac=0.25)  # ValidationError: both set

3. No more EPS on every union/diff; the framework handles it

In OpenSCAD, when two shapes share a face in a difference() or union(), the result has artifacts unless you manually extend the shapes by a tiny epsilon. Every project defines eps = 0.01 and litters it through every cut and join.

SCADwright automatically eliminates the need for epsilon overlap.

from scadwright.boolops import difference, union
from scadwright.primitives import cube, cylinder

box = cube([20, 20, 10])
part = difference(box, cylinder(h=10, r=3).through(box))     # through-hole, no manual eps

through(parent) detects which faces of the cutter are flush with the parent and extends them automatically. For joints, attach(fuse=True) overlaps parts at the contact face.

4. SCADwright's Component library lets you focus on your part, not the parts it's made of

OpenSCAD has no built-in module library, only primitives.

SCADwright offers a shape library with 50+ ready-made Components across geometric, mechanical, fastener, gear, and print-oriented categories. These eliminate sub-tasks, so you don't have to roll your own tubes, rounded rectangles, screw holes, or look up M3 bolt head diameters, hex profiles, and clearance hole sizes each time. Did I mention gears?

Shape library

from scadwright.shapes import Tube, SpurGear, Bolt, HexNut, HoneycombPanel, Bearing

cap = Tube(h=10, id=8, thk=1)                     # od solved: 10
gear = SpurGear(module=2, teeth=20, h=5)          # involute profile; .pitch_r readable on the instance
bolt = Bolt(size="M3", length=10)                 # ISO dimensions from data tables
bearing = Bearing.of("608")                       # 8x22x7, ready for fit-check
panel = HoneycombPanel(size=(80, 60, 3), cell_size=8, wall_thk=1)

Every shape is a Component — you can read its computed dimensions, attach other parts to it, and pass it into boolean operations. See the shape library docs for the full catalog.

Fit tolerances flow project-wide. Set Clearances(sliding=0.05, press=0.08, snap=0.2, finger=0.2) once on your Design class and every AlignmentPin, PressFitPeg, SnapPin, and TabSlot inherits automatically; override per-scope, per-Component, or per-call. See Clearances.

5. SCADwright lets you build reusable transforms in addition to reusable objects

In OpenSCAD you can't write cube(10).chamfer_top(depth=1) — there's no way to add a transform that works on any shape.

In SCADwright, register a transform once and it becomes a method on every shape:

from scadwright.boolops import minkowski
from scadwright.primitives import cube, sphere
from scadwright.transforms import transform

@transform("chamfer_top")
def chamfer_top(node, *, depth):
    return minkowski(node, sphere(r=depth, fn=8))

part = cube([10, 10, 5]).chamfer_top(depth=1)

6. Putting text on a plate, cylinder, cone, or funnel is easy - one operator

In OpenSCAD, putting a label on a part means doing the math yourself: build the 2D text(), linear_extrude it, then translate/rotate it onto the face — and that only works on a flat face. Wrapping a label around a cylinder or up the side of a funnel means hand-rolling per-glyph placement around an arc, or giving up.

SCADwright provides add_text() as a chained method on every shape. One call places raised or inset text on any flat face, cylindrical wall, conical wall, or disk rim:

from scadwright.primitives import cube, cylinder
from scadwright.shapes import Tube, Funnel

plate = cube([60, 30, 2], center="xy")
plate.add_text(label="HELLO", relief=0.5,  on="top",   font_size=8)              # raised on a flat face
plate.add_text(label="v1.0",  relief=-0.3, on="top",   font_size=4, offset=(0, -8))  # inset, offset within the face

cyl = cylinder(h=20, r=10)
cyl.add_text(label="BRAND", relief=0.4, on="outer_wall", font_size=4,
             angle="front")                                                      # wrapped around the cylinder

Funnel(h=30, bot_od=20, top_od=40, thk=2).add_text(
    label="0.5L", relief=0.4, on="outer_wall", font_size=4, text_orient="slant", # wraps a tapered cone
)

Tube(h=30, od=24, thk=2).add_text(
    label="LOT 7", relief=-0.3, on="inner_wall", font_size=4,                    # text on the inside surface
)

cylinder(h=10, r=15).add_text(label="MAX 5L", relief=0.4, on="top", font_size=3) # arc-wrapped along the rim

relief is signed: positive raises, negative cuts (and cuts deeper than the wall punch through).

Multi-line labels stack the right way for each surface — vertically on a face, axially on a wall, radially on a rim — and the host's anchors survive the call, so labels chain and attach() still works afterwards.

See add_text() for the full reference.

wrap_2d() does the same for a 2D profile (an imported SVG logo, a polygon, an outline), placing it as raised or inset relief on a flat face or a curved wall:

from scadwright.primitives import cylinder, scad_import

logo = scad_import("logo.svg", bbox=((0, 0, 0), (124, 106, 0)))   # bbox = the imported mm extent
cylinder(h=80, r=25).wrap_2d(profile=logo, relief=-0.8, on="outer_wall", size=60)  # wrapped, inset

projection="wrap" keeps proportions on a cylinder; projection="flat" presses the profile straight onto a sphere, cone, or other curved wall at uniform depth. See wrap_2d() for the full reference.

7. Clean separation between variants for printing, display, integration testing, etc.

Often the best way to print a part is very different from how you want to see it. A part might need supports, or to be re-oriented, or cut in half for printing. For display, you might want to see parts mated together or show stand-in hardware.

In OpenSCAD this becomes commented-out blocks, duplicated files, or fragile flags.

SCADwright has a Design class with named @variant methods:

from scadwright.boolops import union
from scadwright.design import Design, run, variant

class Widget(Design):
    box = MyBox()
    lid = MyLid(box=box)

    @variant(fn=128, default=True)
    def print(self):
        return union(self.box, self.lid.right(80))

    @variant(fn=48)
    def display(self):
        return union(self.box, self.lid.up(self.box.height))

if __name__ == "__main__":
    run()
scadwright build widget.py --variant=print
scadwright build widget.py --variant=display

Animating between variants is a one-liner using morph (as long as same parts appear in the variants).

8. Attaching shapes, components, or parts to each other is easy, without extra offset calculation

In OpenSCAD, stacking a lid on a box means computing translate([0, 0, box_height]) by hand. If you add a spacer or change a dimension, every downstream offset needs updating.

SCADwright's attach() method lets you position parts by naming which faces should touch:

from scadwright.primitives import cube, cylinder

plate = cube([40, 40, 2])
peg   = cylinder(h=10, r=3).attach(plate)                   # bottom on top
cap   = cube([8, 8, 2]).attach(peg, on="top")                # cap on top of peg

Insert a spacer between any two parts and nothing downstream needs to change. Components can declare custom named anchors for semantically meaningful attachment points.

See Attaching shapes for the full attach() reference, and Anchors for declaring custom attachment points on Components.

9. Parts know their bounds without needing to render, allowing print-bed tests and overlap tests

In OpenSCAD, the only way to know how big something is (whether it fits on your print bed, whether two parts overlap, whether a lid is wider than its box) is to render it and eyeball the result.

SCADwright computes bounding boxes from the AST, without rendering. You can query them, assert against them, and use them to position parts relative to each other:

from scadwright import bbox
from scadwright.asserts import assert_fits_in, assert_no_collision

bb = bbox(my_widget)
print(bb.size)                             # (width, length, height)

# Or as a chained property on any shape — no import needed:
print(my_widget.bbox.size)

assert_fits_in(my_widget, [200, 200, 50])  # fits on the print bed?
assert_no_collision(box, lid)              # parts don't overlap?

Note: bboxes have limitations - it's the smallest cube that fits your part. But it's a hell of a lot better than nothing, and going further verges into rewriting OpenSCAD.

10. Centering Components is easy and straightforward

In OpenSCAD, center=true works on primitives but not on modules. If your module builds a shape at the origin and you want it centered, you compute the offset yourself. Every module that needs centering reinvents the same translate-by-half-size logic.

In SCADwright, every Component accepts center= as a constructor kwarg — same syntax as cube(center=...), with per-axis control:

from scadwright.shapes import UShapeChannel

u = UShapeChannel(wall_thk=2, channel_length=50, channel_width=10, center="xy")
u.outer_width                              # still readable — it's still a Component

The Component author doesn't write any centering code. The framework computes the bounding box after build() and translates the requested axes to the origin. For Components where the geometric center isn't the right reference point, override center_origin() to return a custom one.

11. Transforms chain naturally, starting with the object being affected

In OpenSCAD, the verb comes before the noun: you write the rotate-then-translate first, then the shape they apply to. Reading the code, you have to scan to the end of a line to see what's actually moving.

SCADwright puts the shape first. Operations chain off the shape:

from scadwright.primitives import cube

cube([10, 20, 30]).up(5).rotate([0, 45, 0]).red()

12. Error types and messages provide detail on what caused the error and where

OpenSCAD's error messages typically point at the rendered output, not your source.
Tracking down which call produced a bad value is manual.

SCADwright errors carry the file and line of your call:

from scadwright.primitives import cube

cube([-5, 10, 10])
# ValidationError: cube size[0] must be non-negative, got -5.0 (at widget.py:42)

Component equation errors name the equation that failed and the values that didn't line up — so an over-specified or inconsistent call doesn't make you re-read the equations block to find which line broke:

Tube(h=10, id=8, thk=1, od=11)        # over-specified AND inconsistent: 8 + 2*1 ≠ 11
# ValidationError: Tube.equations[0]: equation violated: `od - id = 2*thk` (lhs=3.0, rhs=2.0)

13. Scripts can declare command-line parameters, allowing fully programmatic part design

OpenSCAD takes -D foo=10, but scripts can't say what parameters they accept, what types they expect, or what defaults to use. The contract lives in comments.

SCADwright scripts declare parameters explicitly:

from scadwright import arg, render
from scadwright.boolops import difference
from scadwright.primitives import cube, cylinder

width = arg("width", default=40, type=float, help="widget width in mm")

MODEL = difference(
    cube([width, width, 20], center="xy"),
    cylinder(h=22, r=5, center=True),
)

render(MODEL, "widget.scad")
scadwright build widget.py --width=80
scadwright build widget.py --help          # lists arguments with defaults

For inputs that don't fit nicely on the command line (a list of holes with positions and diameters, or a parts table), put them in a JSON file and read it inside the script with from_json(). Run with scadwright build widget.py --from-json holes.json.

14. Resolution ($fn) is managed for you, but easily overridable

In OpenSCAD, you either set $fn globally (too coarse) or pass it to every single primitive call (tedious and easy to miss one). There's no middle ground.

In SCADwright, resolution (fn, fa, fs) flows automatically through the hierarchy. Set it once at the level that makes sense and every primitive below inherits it:

from scadwright.shapes import Tube

# Per-instance: pass fn when constructing a Component
cap = Tube(h=10, id=8, thk=1, fn=64)

# Per-variant: set fn in the @variant decorator and every Component
# and primitive built inside that variant inherits it
@variant(fn=48, default=True)
def print(self):
    return self.housing     # all primitives inside get fn=48

# Per-scope: wrap any block of code
with resolution(fn=128):
    high_res_part = difference(sphere(r=10), sphere(r=8))

No declaration needed on the Component side — fn is accepted by every Component automatically and flows into the resolution context for its build() method.

15. Manufacturing-specific fudges are easy to add, track, and keep separate from design considerations

The need to occasionally lie about a part's dimensions to manufacture what you really want is a sad fact of reality.

In OpenSCAD, you might write d = 14.5 + 0.3 — but now other parts read your lie literally, and a year later you have no idea what that meant.

SCADwright provides Specs: small frozen classes that hold shared dimensions and run the same equations block Components use.

Inside that block, adjustments layer corrections with +=, -=, *=, /= on their own lines with comments, separate from the design value:

from scadwright import Spec

class CamMount(Spec):
    equations = """
        cam_barrel_od = 60
        surround = 10
        cam_barrel_od + surround <= 70   # rule sees cam_barrel_od = 60, not the post-adjust 60.35
        cam_barrel_od += 0.3   # printer X-axis overshoot
        cam_barrel_od += 0.05  # extra slop for the o-ring
    """

CamMount.cam_barrel_od                       # 60.35

The fudge-lies needed to make cam_barrel_od come out right don't interfere with design constraints.

The chain is queryable to see what adjustments are applied and where they come from.

CamMount.adjustments_for("cam_barrel_od")
# [Adjustment(line=4, delta=0.3, comment='printer X-axis overshoot'),
#  Adjustment(line=5, delta=0.05, comment='extra slop for the o-ring')]

Specs can take optional ? inputs (printer profile, material), and passing different ones in each variant lets one project produce calibrated builds for several printers from a single source.

16. You know if a part has changed

OpenSCAD has no way to write a regression test that says "this part hasn't changed since I last reviewed it." You either re-render and visually compare, or trust that your edit didn't break anything.

SCADwright hashes the geometry tree so you can pin a part's shape in a unit test:

from scadwright import tree_hash

def test_widget_geometry_pinned():
    assert tree_hash(Widget(width=40)) == "a1b2c3d4e5f6..."

If any dimension, transform, or boolean op changes, the hash changes and the test fails — before you ever open OpenSCAD.

Quick example

from scadwright import render
from scadwright.boolops import difference
from scadwright.primitives import cube, cylinder

body = cube([40, 40, 20], center="xy")
hole = cylinder(h=22, r=5, center=True, fn=64)

part = difference(
    body,
    hole.right(10),
    hole.left(10),
)

render(part, "widget.scad")

Run with python widget.py (writes widget.scad) or use the CLI: scadwright build widget.py. Open the result in OpenSCAD to render.

Quick example (with a Component)

When a part has named dimensions and relationships between them, wrap it in a Component:

from scadwright import Component, render
from scadwright.boolops import difference
from scadwright.primitives import cylinder

class Tube(Component):
    equations = """
        od = id + 2*thk
        h, id, od, thk > 0
    """

    def build(self):
        return difference(
            cylinder(h=self.h, r=self.od / 2),
            cylinder(h=self.h + 2, r=self.id / 2).down(1),
        )

t = Tube(h=30, id=20, thk=2)      # od solved = 24.0
print(t.od)                        # 24.0 — readable without rendering
render(t, "tube.scad")

Quick example (with a Component and @variant)

Building on the Tube above, add a Design with variants — a display view showing the tube upright, and a print view that halves it into two concave-down pieces spaced apart for the print bed:

from scadwright import Component, bbox
from scadwright.boolops import difference, union
from scadwright.design import Design, run, variant
from scadwright.primitives import cylinder

class Tube(Component):
    equations = """
        od = id + 2*thk
        h, id, od, thk > 0
    """

    def build(self):
        return difference(
            cylinder(h=self.h, r=self.od / 2),
            cylinder(h=self.h + 2, r=self.id / 2).down(1),
        )

class MyTube(Tube):
    h = 30
    id = 20
    thk = 2

class TubeProject(Design):
    tube = MyTube()

    @variant(fn=64, default=True)
    def display(self):
        return self.tube

    @variant(fn=64)
    def print(self):
        half = self.tube.halve([0, -1, 0])          # cut in half along Y
        spacing = bbox(half).size[1] + 5
        return union(
            half,                                    # concave side down
            half.forward(spacing),
        )

if __name__ == "__main__":
    run()
scadwright build tube.py                     # display variant (default)
scadwright build tube.py --variant=print     # two halves, bed-ready

Install

Download the latest release from the releases page and unzip it, then from inside the unzipped directory:

pip install '.[lsp,curved-text]'

The scadwright command becomes available. [lsp] adds editor integration; [curved-text] adds proportional glyph spacing on curved surfaces. Both are recommended, but optional.

Dependencies

SCADwright requires sympy.

Three optional extras layer in deeper functionality:

  • pip install 'scadwright[lsp]' — adds pygls for the language server. Editors with an LSP client get inline diagnostics, completion, hover, goto-definition, and project-wide rename inside equations blocks. See Editor integration below.
  • pip install 'scadwright[curved-text]' — adds freetype-py for proportional glyph spacing in add_text on cylindrical, conical, and rim surfaces. Without it, those paths fall back to a uniform-width heuristic.
  • pip install -e '.[dev]' — adds pytest for running the test suite from a clone. Combine with [lsp] if you also want to work on the language server: pip install -e '.[dev,lsp]'.

Editor integration

If you try hard enough to eliminate boiler plate, sooner or later you wind up with a DSL (design-specific language) (or you reinvent a worse form of LISP). While it's tempting to embrace that in an ecstatic frenzy of ascetic pursuit, the hangover is a total lack of IDE support for your weird syntax. The world, it seems, does not appreciate beauty.

So I wrote us a Language Server Protocol (LSP).

Any editor with an LSP client gets full inline support for equations = """...""" blocks: red squiggles for every error the resolver would raise, completion for the curated math/builtins namespace and the surrounding class's declarations, hover with declared types and defaults, goto-definition, document symbols, and project-wide rename that updates cross-file references atomically.

Install with pip install 'scadwright[lsp]', then point your editor at scadwright lsp. Configs for Vim, Neovim, Helix, Emacs (eglot / lsp-mode), Sublime, Zed, VSCode, and PyCharm Pro live in docs/lsp_setup.md.

This project also includes VSCode and PyCharm extensions. Both add toolbar buttons (Preview / Render / Kill OpenSCAD) on Python files that import scadwright, plus syntax coloring inside equations blocks. Clicking Preview is enough to see the results of a change: as long as the generated filename is the same (same script, same variant), an already-open OpenSCAD window auto-reloads.

VS Code

The VSCode extension bundles the LSP client. Install the [lsp] extra in your project venv and the extension spawns scadwright lsp automatically. The extension also provides the three Preview / Render / Kill toolbar actions and TextMate coloring inside equations blocks. See vscode/README.md for install steps and settings.

PyCharm

The PyCharm plugin provides the three toolbar actions plus PyCharm-native equations coloring and curated-namespace autocomplete (math functions, builtins, cardinality helpers, type tags, and constants, with auto-paren insertion on callables). PyCharm Community has no built-in LSP client, so the richer LSP features aren't available there. PyCharm Pro users can run scadwright lsp alongside the native plugin for diagnostics, Param-aware completion, hover, and project-wide rename — see docs/lsp_setup.md.

SCADwright and modeling with AI

Compared to OpenSCAD, building a model with AI in SCADwright (and the MCP below) is faster and lets you take bigger steps than just building in OpenSCAD directly.

Claude Code summarized the key benefits of its working in SCADwright over OpenSCAD as:

  • equations and constraints catch a lot of lazy or under-specified mistakes with explicit errors - ones that, in OpenSCAD, might otherwise might result in a less scrutable error or slip through entirely
  • higher level abstractions (like attach(fuse=True), through(), custom transforms, add_text(), etc.) are closer to what the user describes and require less interpretation/translation into code
  • AI is much better writing at python than openscad, reflecting the relative frequency of python in training data

Whichever AI assistant you use, dropping the style guide into its context steers generated code away from generic-Python habits toward SCADwright's idioms.

MCP

If you're developing with Claude Code, install the OpenSCAD MCP server. It gives Claude the ability to render your .scad output, visually inspect the result, and catch geometry errors without you having to open OpenSCAD yourself. SCADwright's generated SCAD is fully compatible — Claude can build your script, render it through the MCP, and iterate on the design in a tight feedback loop.

Is this AI generated slop?

I've been working on the specification for this for years, long before vibe-coding was a thing.

Is there AI generated code in here? Yes. It's 2026 and I have a job. Sometimes a life too.

Is this one-shot-slop? No. It's the result of hundreds, maybe thousands, of incremental iterations, and a fair amount of hand-coding and human-writing (including this whole page here).

Pretty much every part of SCADwright has gone through at least 5-6 major revisions, reducing duplicate and boilerplate code, making the constructs intuitive and naively simple, and working through hard framework trade-offs, in detail through examples. I've been writing code longer than Python has been a language (and I've been writing serious code longer than Python has been a serious language) - I wouldn't put my name on something that's dogshit or poorly thought out. Hell, I even went to the effort to make the emitted SCAD human-readable.

In parallel, I'm actually using this framework for my current 3d printing projects: the Bronica S2 lens housing and convex lens caliper attachment both leverage this framework and motivated its completion.

Functional Programming

I'm sure someone will accuse this project of pissing all over OpenSCAD's functional programming purity.

On the one hand, OpenSCAD isn't pure to begin with (Haskell syntax, but where's the type system and typeclasses?). On the other hand, SCADwright's equations syntax is pretty fucking FP (at least in the ways that FP isn't annoying).

At bottom, I'm far more interested in making SCADwright easy and widely usable than I am in expanding your consciousness through FP indoctrination.

Documentation

I've taken great care to produce excellent documentation that's easy to consume. This is not an AI-generated afterthought, but rather carefully written docs for producing expressive and powerful code simply.

Full documentation here. Documentation is along the lines of the OpenSCAD Language Reference. There's also a cheatsheet that parallels the OpenSCAD cheatsheet.

For a quick intro, see How to organize a project.

This framework also includes examples of projects at various levels of difficulty.

If you're comparing SCADwright against SolidPython, PythonSCAD, CadQuery, Build123d, or other Python+CAD tools, see How is SCADwright different? for a side-by-side.

About

Writing OpenSCAD right. SCADWright is python, matches OpenSCAD closely for simple cases, but allows graceful layering on of complexity: improved modules (that solve equations and share dimensions), custom transforms, anchors and attachment, auto-EPS, a shape library, and more.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages