OpenSCAD's animation feature drives a special variable $t from 0 to 1 over a configurable timeline; you reference $t inside transforms and sizes to make geometry change frame-by-frame. OpenSCAD's view also reads top-of-file $vpr/$vpt/$vpd/$vpf globals as the default camera.
If you already have two or more variants and just want the motion between them (an assembly drop, an open/close swing), reach for Morph: one line, no $t math. The tools here are for making a single model change continuously over the timeline, or for a path morph can't express.
SCADwright exposes both via scadwright.animation:
from scadwright.animation import t, cond, viewpointt() returns a SymbolicExpr standing for OpenSCAD's $t. Arithmetic with it (and Python numbers) builds an expression tree that emits as SCAD source instead of being resolved to a Python float at build time:
from scadwright.primitives import cube
from scadwright.animation import t
cube(10).rotate([0, 0, t() * 360]) # full turn over the animationemits:
rotate([0, 0, $t * 360]) {
cube([10, 10, 10], center=false);
}
To run the animation: open the .scad in OpenSCAD, then View → Animate, set FPS and Steps, and $t advances from 0 to 1 across the timeline.
To produce frame PNGs without opening the OpenSCAD GUI, build the .scad and pass it to OpenSCAD's --animate flag:
scadwright build widget.py -o widget.scad
openscad --animate 100 --imgsize 800,600 -o frame.png widget.scad
ffmpeg -i frame%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4OpenSCAD names the frames frame00000.png through frame00099.png with a 5-digit zero pad, so they sort lexically and ffmpeg's frame%05d.png pattern picks them up directly.
- Transform operands:
translate,rotate,scale,mirror(vector elements and the scalar angle of axis-anglerotate). - Primitive sizes:
cubesize dimensions,sphereradius/diameter,cylinderheight/radius/diameter. - Viewpoint fields (see below).
Anywhere else, SCADwright expects a Python number — passing a SymbolicExpr raises ValidationError with a clear message. Validators like positive=True short-circuit when the value is symbolic (no way to check at build time).
SymbolicExpr overloads + - * / % (mixed with Python numbers in either order), unary -, and ** (which emits as pow(a, b) since SCAD has no **). Comparisons (<, <=, >, >=, ==, !=) return SymbolicExprs too — they're for use with cond(), not if:
t() < 0.5 # a SymbolicExpr representing "$t < 0.5"
if t() < 0.5: # TypeError — won't silently misbehave
...For values that depend on a condition involving $t, use cond() to emit a SCAD ternary:
from scadwright.animation import t, cond
# Ping-pong: 0 → 1 over the first half, 1 → 0 over the second half.
ping = cond(t() < 0.5, 2 * t(), 2 - 2 * t())
cube(1).translate([ping * 50, 0, 0])emits:
translate([($t < 0.5 ? 2 * $t : 2 - 2 * $t) * 50, 0, 0]) {
cube([1, 1, 1], center=false);
}
cond() is a value-level switch; it doesn't replace whole shapes. To swap one shape for another, you can cond between scaling factors (e.g. scale by 0 to hide).
from scadwright import render
from scadwright.animation import viewpoint, t
with viewpoint(rotation=[60, 0, 30], distance=200, target=[0, 0, 0]):
render(MODEL, "out.scad")emits at the top of out.scad:
$vpr = [60, 0, 30];
$vpt = [0, 0, 0];
$vpd = 200;
Fields:
| Kwarg | SCAD var | Meaning |
|---|---|---|
rotation |
$vpr |
Euler rotation [x, y, z] in degrees |
target |
$vpt |
Point the camera looks at, [x, y, z] |
distance |
$vpd |
Distance from target |
fov |
$vpf |
Vertical field-of-view in degrees |
Any field left as None is omitted; OpenSCAD picks its default.
Viewpoint fields accept SymbolicExpr, so you can build a turntable:
with viewpoint(rotation=[60, 0, t() * 360], distance=200):
render(MODEL, "turntable.scad")Nested calls merge — an inner None falls back to the outer block's value, not to OpenSCAD's default:
with viewpoint(rotation=[60, 0, 30], distance=100):
with viewpoint(distance=200):
# rotation is still [60, 0, 30] from the outer block,
# distance is 200 from the inner.
render(MODEL, "closer.scad")t() works inside a variant body, so an animated display variant can render alongside a static print:
from scadwright.animation import t, viewpoint
from scadwright.design import Design, run, variant
class AnimatedWidget(Design):
widget = MyWidget()
@variant(fn=48, default=True)
def print(self):
return self.widget
@variant(fn=48)
def display(self):
with viewpoint(rotation=[60, 0, 30], distance=200):
return self.widget.rotate([0, 0, t() * 360])
if __name__ == "__main__":
run()For animations specifically across existing variants (the print → display swing is the canonical case; chains of three or more stages also work), see Morph — a one-line declarative API that derives the animation from the variants you already have, rather than hand-rolling t() math.