Skip to content

Latest commit

 

History

History
119 lines (84 loc) · 5.56 KB

File metadata and controls

119 lines (84 loc) · 5.56 KB

Custom transforms

Custom transforms let you add new verbs to the language. Once you register one, it becomes a method on every shape — you can chain it just like the built-in translate or red.

Imports used on this page:

from scadwright.transforms import transform

Defining a transform

Use the @transform("name") decorator on a function. The function signature is fixed:

  • First parameter: the shape being transformed. Positional, any name (node by convention).
  • A * separator, then all options as keyword-only parameters.
  • Return: the new shape.
@transform("chamfer_top")
def chamfer_top(node, *, depth):
    return minkowski(node, sphere(r=depth, fn=8))

# Now every shape has a .chamfer_top method:
part = cube([10, 10, 5]).chamfer_top(depth=1)

The function above wraps a shape in a minkowski with a small sphere, which rounds its edges. After registration, cube(...).chamfer_top(depth=1) works on any shape — primitives, components, anything.

If you forget the * separator, registration raises a clear error at import time — you don't have to remember.

Hoisted vs inline

When you use the same transform multiple times with the same options, SCADwright generates one OpenSCAD module and calls it at each spot:

union(
    cube([10, 10, 5]).chamfer_top(depth=1),
    cube([6, 6, 8]).translate([15, 0, 0]).chamfer_top(depth=1),
)

The output SCAD has one module chamfer_top_<hash>(depth) { ... } definition and two short calls to it. This keeps the rendered file small and readable.

There's a catch. To produce the module, SCADwright runs your function once with a placeholder in place of the shape. That means your function must treat its shape argument opaquely — don't read .size, .r, .width, or any other attribute off it. If you try, you get a clear AttributeError pointing at the fix:

@transform("bad")
def bad(node, *, pad):
    return cube(node.size[0] + pad)   # AttributeError at emit time

If you need to read child attributes, pass inline=True:

@transform("frame", inline=True)
def frame(node, *, pad):
    # inline=True means we get the real shape here, not a placeholder.
    x, y, z = node.size
    return difference(
        cube([x + 2*pad, y + 2*pad, z], center=True),
        node,
    )

Inline transforms skip the module-hoisting machinery and call your function at every use site with the actual shape. They can't share a module across calls (so the SCAD output is a bit larger when used repeatedly), but your function has full access to the shape's attributes.

Rule of thumb: keep transforms non-inline by default. Only switch to inline=True when you genuinely need to inspect the shape.

Listing registered transforms

list_transforms()         # ["chamfer_top", "frame", ...]

Returns the names of all transforms registered so far.

Decoration transforms

Pass decoration=True for transforms that add something to a host (labels, decals, logos) without replacing it. Decoration transforms preserve the host's anchors, so chaining more decorations or calling attach() afterward still finds the host's named faces:

@transform("emboss", inline=True, decoration=True)
def emboss(node, *, label, depth):
    flat = text(label, size=8).linear_extrude(height=depth)
    return union(node, flat.up(bbox(node).max[2]))

Without decoration=True, the host's custom anchors disappear after the transform — the same as if you'd written the union or difference yourself. The built-in add_text() uses this flag.


Pattern: face-relative transforms

A common pattern for transforms that operate on a specific face of a shape (e.g. cutting a port through a wall, adding a boss to a face): accept an on parameter, use bbox() to find the face plane, and position the operation in face-local coordinates. Naming the kwarg on matches the convention used by attach() and add_text() — the same word for "which face of this shape" everywhere it appears.

@transform("port_cutout", inline=True)
def port_cutout(node, *, on, at_along, at_z, width, height):
    b = bbox(node)
    if on in ("+x", "-x", "rside", "lside"):
        cutter = cube([b.size[0] + 2, width, height], center=True)
        x_pos = b.max[0] if on in ("+x", "rside") else b.min[0]
        cutter = cutter.translate([x_pos, at_along, at_z])
    # ... similar for +y/-y, +z/-z ...
    return difference(node, cutter)

This keeps the face-resolution logic inside the transform — callers just write body.port_cutout(on="rside", at_along=12, ...) and don't think about coordinates. The electronics-case example uses this pattern extensively.


Under the hood

  • Two registrations with the same name raise SCADwrightError.
  • The hashed module name combines the transform name and a hash of its keyword arguments, so the same transform with different options gets distinct modules.
  • For transforms that need raw control over emitted SCAD (custom emit logic, not just AST composition), there's a Transform subclass form. It's the escape hatch the decorator is built on; most users won't need it.
  • Inline transforms repeat the full geometry at every call site in the generated SCAD. If you apply an inline transform many times (e.g. once per mount hole), consider switching to a non-inline transform to keep the output compact.
  • The node.<transform_name>(...) method lookup is provided by overriding __getattr__ on the base node class. If you ever name a transform the same as an existing built-in method (e.g. translate), the built-in wins.