Skip to content
FlywheelCAD
Manual pages

FlywheelCAD

User Manual

A script-backed parametric CAD tool — from your first sketch to a mated, colored, exportable assembly. Every chapter covers both the 3D-view workflow and the Python (cad) API that backs it.

1. Getting Started & the Document Model

This chapter sets up the vocabulary and mental model the rest of the manual assumes: what kind of app FlywheelCAD is, how the GUI and the Python script relate to each other, and what a document actually is on disk. Later chapters (Sketching, Selection, Bodies, Components) build on this foundation.

1.1 What FlywheelCAD is

FlywheelCAD is a Python-first, constraint-driven CAD application. You draw and edit sketches interactively in the UI, and you can script everything in .py files — plain Python. Either way, geometry is solved by constraints and dimensions, and closed sketch areas become 3D bodies (extrude/revolve) that you combine with boolean operations. The pipeline is always the same: 2D sketch → region → 3D solid. You draw constrained 2D geometry on a plane, identify a closed region, then extrude/revolve/loft it into a body; bodies then combine via booleans and transforms. Units are unitless numbers — treat them as millimetres — and angles are always in degrees.

The workflow is intentionally hybrid: UI for fast interaction, Python for reproducibility and reuse.

1.2 The script ⇄ GUI duality

A FlywheelCAD document is a Python script. Opening a document re-executes the script to rebuild the geometry; saving writes the script. There is no separate binary model — the script is the source of truth. The script doesn't manipulate the model directly: each cad.*(...) call emits a command, and the host runs the accumulated commands in order — solving 2D sketches with a constraint solver, then building the 3D solids.

Every script starts the same way:

# the entire boilerplate
from flywheelcad import *
cad = FlywheelCAD()

When executed, this Python script emits intermediate CAD commands to stdout; the app parses and executes those commands. In practice this means the UI and the script panel are two views onto the same thing — a reasonable way to work is to rough in geometry and constraints in the UI, then treat the script panel as the source of truth for repeatability, moving reusable generation logic out into sibling helper modules as a design grows (see §1.5).

1.3 3D mode and Sketch mode GUI

The window has five main parts:

PartContents
Sidebar (left)Filterable list of Bodies, Components, and Sketches, each row with a trailing eye toggle for visibility (section headers carry a bulk toggle). A body row can also carry two small warning glyphs before the eye: a yellow triangle for a suspicious mesh topology, and an orange down-arrow when the body meshed below the resolution it asked for (§8.5). Toggle the sidebar itself from the leading toolbar button.
ToolbarA mode pull-down ("3D Model" / "Sketch: <plane>"), then a labeled tool cluster that follows the mode — body operations (Combine, Inspect, Transform, Insert, Mate, Section) in 3D mode; drawing tools, Trim, Constrain/Dimension dropdowns, and the Construction toggle in sketch mode — each with a "More" (⋯) menu for the rest, and a Script Editor toggle at the trailing edge.
Main canvasEither the 3D scene view or the 2D sketch editor. Selecting something raises a floating read-only Properties Inspector card: for a body, its type, quality, cell size (when one is set), triangle count and export flag, plus a Warning row for a suspicious mesh topology and a Mesh row whenever the last mesh came out at a different resolution than requested (§8.5); for a sketch, the element and selection counts and the solver's DOF reading. It is read-only — to change a body's quality, cell size or export flag, use Body ▸ Inspect / Edit… (§8.6).
Script editor (right)The script pane with the Execute button; its width is remembered across hide/show.
Status bar (bottom)Sketch-solver badge, selection info, the perspective/orthographic toggle, a self-hiding “N bodies meshed below requested” chip (§8.5), and the document-wide Mesh quality menu.

The main canvas is in one of two modes at a time:

Switch views from the View menu: ⌘0 is the 3D model, ⌘1–⌘9 open sketch planes in sidebar order (further planes live in an "Other Sketches" submenu). The View menu also holds the projection toggle, Zoom to Fit (⇧⌘F), and the Mesh Quality submenu. The toolbar's mode pull-down offers the same plane list plus the two "New Sketch…" commands.

This section is only an orientation. Drawing tools, constraint/dimension entry, and selection behavior in each mode are covered in the Sketching and Selection chapters.

1.4 Running scripts & the busy state

The right panel's script editor has an Execute button. Running the script re-executes it from scratch: the script emits intermediate CAD commands to stdout, and the app parses and executes those commands to rebuild the document's sketches and bodies.

The button's full behaviour is clear & run: the existing model is discarded and rebuilt from the script text as it currently stands. There is no partial-apply — the script is the definition, so running it is how any edit takes effect, whether you typed it yourself or the GUI appended it for you.

What the busy state looks like, and what it holds

While solving or replaying, the sketch canvas shows an overlay rather than a beachball-style block: Executing Script... while a script is being replayed, Computing... while the solver is working. The window stays responsive throughout.

Two things behave differently while a run is in flight:

Why re-running the old script is the whole recovery story

Because the script is the document (§1.2), there is no separate undo stack to unwind — restoring the last good state is just another replay. This is worth knowing when a script fails: the version you are looking at afterwards is the last one that ran clean, not the text in the editor, which still holds your unsuccessful edit.

Editing the script. That callout is about the model — the geometry has no undo stack of its own. The script pane, by contrast, is an ordinary text editor: ⌘Z and ⇧⌘Z undo and redo your typing, and ⌘F opens a find bar (⌥⌘F adds a replace row; Replace and Replace All are undoable with ⌘Z). Matching is a plain case-insensitive substring search — tick Case sensitive to change that. Operations performed in the 3D view or the sketch canvas are not undoable: they rewrite the script, and the way back is to edit the script text.

1.5 Documents: flat .py vs .fwcad bundles

A document is saved in one of two formats. The API is identical either way.

FormatWhat it is
Flat .pyA single script file. Its helper modules and vendored lib/ folder live next to it in the enclosing folder.
.fwcad project bundleA folder (shown as one document icon in Finder) that packages the script as main.py together with every file it imports. A bundle is self-contained, so it moves, copies, and shares as a single item without leaving its dependencies behind.

New documents start as bundles. Everything else works the same in both formats — the bundle's main.py is the script you edit, and its folder is the working directory that imports resolve against.

File-menu commands for bundles (all operate on the focused document):

1.6 A minimal first script Script

Every script starts with the same two-line boilerplate, then follows the 2D sketch → region → 3D solid pipeline from §1.1. This one sketches a circle on the xy plane, turns it into a region, and extrudes it into a cylinder:

from flywheelcad import *
cad = FlywheelCAD()

cad.with_sketch("xy")                 # draw on the standard XY plane
p1 = cad.point2d(0, 0)                # a 2D point, named "p1" from the LHS
c1 = cad.circle2d(p1, radius=10)      # circle: center point + radius

r1 = region(loop=[c1], inside=(0, 0)) # closed region from the circle

body1 = cad.extrude("xy", r1, 5)      # extrude the region 5 units

Run it (or open the document) and the app parses the emitted commands, solves the sketch, and builds body1 as a 10-unit-radius, 5-unit-tall cylinder.

1.7 Settings: per-user preferences (⌘,) GUI

FlywheelCAD ▸ Settings… (⌘,) opens a single three-tab window shared by every open document. Everything in it is a per-user, per-Mac preference about how the app looks and feels — not a property of any document. Nothing here is written into your script, and nothing here travels with a file you send to someone else. That boundary is deliberate: a document default with no cad.* call behind it would silently evaporate the moment the file was opened on another machine, so anything that must survive the trip lives in the script instead (per-body quality=, cad.set_cell_size, cad.set_color, export=).

General

SettingWhat it does
Numeric decimalsHow many decimal places numbers are shown with on screen. Display only — saved scripts always keep full precision, so lowering this never rounds your geometry.
Log verbosityThe minimum severity a message needs to reach the app log. Lower it when you're diagnosing a run; raise it to quieten the console.
Precision cell sizeThe finest cell the precision tier will ever ask for, in millimetres. Default 0.1, clamped to the range 0.01–5.0. Because it is a floor, it only bites on parts small enough for the tier to reach it — the shipped help text puts that boundary at "parts up to roughly 13 cm"; anything larger is limited by Precision's cell budget instead and will not change no matter what you type here (§8.3).
Restore Defaults…Returns every setting in every tab to its default, behind a confirmation. Your documents are not touched. Only the declared preference keys are removed, so unrelated state (script-consent approvals, window restoration) survives.

Viewport

SettingWhat it does
Point marker sizeThe on-screen radius (2.0–12.0 pt) of sketch points, body topology points and component anchors. It is an absolute screen size, not a multiplier, so markers stay the same size at any zoom.
Starting projectionPerspective or Orthographic for newly opened documents and windows. A window that is already open keeps whatever it has — toggle that one from its own status-bar projection button, which this preference never overrides.
Sketch planesA grid of colour wells: Line, Fill and Border for XY, YZ, ZX and the first two custom planes. The line colour also tints that plane's sketch points and area fills.
Markers & selectionColours for anchor points, selection fill and selection border. The selection border colour also tints the glow around a selected point or anchor.
Viewport backgroundFollow the system light/dark appearance, or pick a fixed custom colour (which then does not follow the appearance).
Restore Colour Defaults…Resets only the seven colour settings on this tab; the point-marker size and every other tab are left alone.

Interaction

SettingWhat it does
Orbit / Pan / Zoom sensitivityThree 0.1×–5.0× multipliers on the 3D camera gestures. 1.0× is the viewport's built-in feel. Orbit is a mouse/trackpad drag, pan is the scroll wheel, and zoom is ⌥+scroll or a trackpad pinch — plain scrolling pans, it does not zoom.
Snap sketch drags to a grid + Grid stepAn interactive pointer aid while dragging in a 2D sketch, with a step from 0.01 to 10 mm. It is a pointer aid only: coordinates are always committed to 2 decimal places regardless, so turning the grid off never changes what gets stored. Unchecking the box parks your chosen step so re-checking restores it.
Which setting travels with the file?

Only what a cad.* call can express. Settings ▸ General ▸ Precision cell size is the clearest example of the split: it is a machine-local floor, so the same document can legitimately mesh differently on a different Mac. cad.set_cell_size(body, size) (§8.4) is the portable route that travels with the file — reach for it whenever a particular body needs a particular resolution everywhere, not just on your machine.

2. Sketch Elements & Planes

This chapter covers the 2D geometry primitives you sketch with, and the planes they live on. Locking those primitives down with geometric and dimensional constraints is Chapter 3; turning a closed sketch into a 3D body (regions) is Chapter 4.

2.1 Sketch context & the built-in planes

cad.with_sketch(plane_name) sets the current sketch context — every 2D primitive you create afterward (point2d, line2d, circle2d, …) lands on that plane, until you call with_sketch again. There are three standard planes, plus any custom planes you define (§2.7):

PlaneNotes
xyStandard plane.
yzStandard plane.
zxStandard plane.
custom name (e.g. endface, station1)Created with cad.create_sketch_plane(...) — see §2.7.

Plane names are suffix-free — write xy, never xy_plane.

Each plane has an origin point. Calling with_sketch ensures that origin's reference exists and injects it into your script as a bare variable — origin_xy, origin_yz, origin_zx for the standard planes, or origin_<planeName> for a custom one — so you can use it immediately without assigning it yourself:

cad.with_sketch("xy")
c1 = cad.circle2d(origin_xy, 20)   # origin_xy exists as soon as with_sketch("xy") ran
2D sketch coordinates vs. 3D reference points

cad.point2d(x, y) and every other 2D primitive live in the current sketch plane's 2D coordinates. cad.point(x, y, z) (§2.2) is a different thing — a fixed point in world 3D coordinates, independent of any sketch context, used to define custom planes rather than to draw.

2.2 Points

Two calls create points, in two different coordinate spaces:

CallCoordinate spaceTypical use
cad.point2d(x, y)Current sketch plane's 2D coordinates.Endpoints for line2d, circle2d, arc2d, ellipse2d, spline2d.
cad.point(x, y, z, name=None)World 3D coordinates.Defining points for cad.create_sketch_plane(...) (§2.7).
cad.with_sketch("xy")
p1 = cad.point2d(0, 0)
p2 = cad.point2d(40, 0)
l1 = cad.line2d(p1, p2)

anchor = cad.point(10, 0, 5)   # a fixed 3D reference point, not tied to any sketch

cad.point takes an optional name= to control the script name it's emitted under, instead of an auto-generated one.

name= works almost everywhere Script

Normally a call's script name is inferred from the variable you assign it to — p1 = cad.point2d(0, 0) becomes the point named p1 — and when there is no assignment to read, the app mints one (p1, l3, body2, …). Nearly every call that returns a Ref also takes an explicit name= to override both: the 2D primitives (point2d, line2d, circle2d, ellipse2d, arc2d, spline2d), text, fillet, the tangency constraints that return a point (circle_tangent_line, ellipse_tangent_line), variable, every body producer (extrude, revolve, loft, multi_loft, sweep, the three booleans, offset, the transforms, instance), the path builders (helix, path, path3d), section/project/project_point, and create_sketch_plane.

Reach for it when the name matters to something else: a body you want to address by a stable string from another module, an element you plan to reference with ref("..."), or geometry built inside a loop or a helper function where there is no single assignment for the app to read. cad.trim is the notable exception — it derives its result names from the element it trims — as are the constraints that return nothing at all.

2.3 Lines & construction geometry

cad.line2d(p1, p2, construction=False) draws a line between two 2D points on the current sketch.

p1 = cad.point2d(-20, -10)
p2 = cad.point2d(20, -10)
l1 = cad.line2d(p1, p2)

Pass construction=True to any 2D primitive — not just lines — to make it a construction element: rendered dashed and excluded from region detection. Use construction geometry for guides, axes, and symmetry lines.

b = cad.point2d(0, -20)
t = cad.point2d(0, 20)
axis = cad.line2d(b, t, construction=True)   # a symmetry axis, not part of any region

In the sketch toolbar, the Construction toggle (shortcut X) does the same for elements you draw interactively — dashed, excluded from area detection.

2.4 Circles, arcs & ellipses

CallNotes
cad.circle2d(center, radius, construction=False)center is a 2D point.
cad.arc2d(center, start, end, clockwise=False, construction=False)start and end are points that lie on the arc's circle around center; clockwise selects the sweep direction.
cad.ellipse2d(focus1, focus2, point, construction=False)Defined by two foci plus one point that lies on the ellipse's rim.
c1 = cad.circle2d(origin_xy, 20)

center = cad.point2d(0, 0)
start = cad.point2d(10, 0)
end = cad.point2d(0, 10)
a1 = cad.arc2d(center, start, end, clockwise=False)

f1 = cad.point2d(-8, 0)
f2 = cad.point2d(8, 0)
rim = cad.point2d(0, 6)
e1 = cad.ellipse2d(f1, f2, rim)

2.5 Splines

cad.spline2d(points, closed=False, construction=False) draws a cubic spline through an ordered list of 2D points. closed=True joins the last point back to the first.

p0 = cad.point2d(0, 0)
p1 = cad.point2d(10, 15)
p2 = cad.point2d(30, 10)
p3 = cad.point2d(40, 0)
s1 = cad.spline2d([p0, p1, p2, p3], closed=False)
Drawing interactively

While drawing a spline in the sketch canvas, press Enter to finish it.

2.6 Text Script

cad.text(string, size=10.0, font="Helvetica", at=(0.0, 0.0), angle=0.0, direction=None, distance=None, align="left", quality=None, export=None, plane=None) renders string as sketch geometry using a system font's glyph outlines. size is the target cap height in millimeters, calibrated against the font's actual measured cap height (not its raw em-square), so results look right across different fonts.

Two modes, selected by distance:

plane= builds the text on that sketch plane without switching the ambient current sketch — handy for placing a label on a face while you keep working elsewhere. If that plane isn't the one shown in the 2D sketch view, the new outline splines won't be visible there until you cad.with_sketch(...) to it; a solid-mode body still meshes and renders in 3D regardless.

align ("left" | "center" | "right") positions the rendered run relative to at, the baseline-origin point in the current sketch's mm coordinates. angle (degrees, CCW-positive) rotates the whole run in the sketch plane about at; direction=(dx, dy) is sugar that sets angle from a vector, e.g. direction=(0, 1) runs the text "up" the +Y axis.

cad.with_sketch("xy")
letters = cad.text("HELLO", size=10, at=(0, 0))
# outline mode: letters is a list of 5 Refs, one per glyph's outer contour

label = cad.text("A1", size=8, distance=2, align="center")
# solid mode: extrudes each glyph 2mm deep, one body per glyph

cad.text returns one Ref per character position in string: the glyph's outer contour spline in outline mode, or the glyph's extruded body in solid mode. A position with no visible glyph (e.g. a space) creates nothing, so its Ref simply won't resolve if used. Hole/counter contours are still created and individually addressable as <name>_<i>_h<k> even though they aren't returned.

Glyphs are not unioned

Solid mode returns one body per glyph, never a single merged label. Call cad.bool_union(...) on the returned bodies if you need one solid.

Body ▸ Text… GUI opens a sheet that authors the same cad.text(...) line for you: Text, Size (cap height, mm), Font, Align, At (pre-filled from a single selected sketch point), Extrude depth (blank = flat outline), and an Angle field under Advanced. The sheet always targets the current sketch plane — switch planes first (⌘1–⌘9 or the mode pull-down) if you want the text elsewhere.

2.7 Custom sketch planes

cad.create_sketch_plane(p1, p2, p3, name=None) defines a new sketch plane from three points: its origin is p1, and its normal is (p2 - p1) × (p3 - p1). The three points may be body-topology points (e.g. body1.v0), existing sketch points, or standalone reference points made with cad.point(...).

From body-topology points — after an extrude/revolve, a body exposes its far-face vertices as attributes (body.v0, body.v1, …; holes: body.h0_v0; symmetric extrudes split into body.pos_v0/body.neg_v0):

box = cad.extrude("xy", rect_region, 20)
endface = cad.create_sketch_plane(box.v0, box.v1, box.v2, name="endface")
cad.with_sketch("endface")

From explicit coordinates — useful for laying out a series of parametric planes, e.g. one plane per spanwise station before a cad.loft(...):

p1 = cad.point(0, 0, 0)
p2 = cad.point(100, 0, 0)
p3 = cad.point(0, 0, 20)
station1 = cad.create_sketch_plane(p1, p2, p3, name="station1")
cad.with_sketch("station1")

In the GUI, Sketch ▸ New Sketch from Coordinates… GUI opens a dialog with a sketch name and three X/Y/Z rows (origin + two points); it creates the three points and the plane, logging them exactly as above. Sketch ▸ New Sketch from 3 Selected Points does the same from three points selected in the 3D view.

Which vertices does a body expose?

The exact set of body.vN names depends on the body. When unsure, prefer building reference geometry with cad.point(x, y, z) and create_sketch_plane instead of guessing a vertex name.

3. Constraints, Dimensions & Variables

A freshly-drawn sketch is just points floating where you clicked. This chapter makes it parametric: constraints pin geometric relationships (horizontal, tangent, concentric…), dimensions pin numeric values (a length, a radius, an angle), and variables let one number drive several dimensions at once so the whole sketch resizes from a single edit.

3.1 How the sketch solver thinks

Every 2D primitive you draw — point2d, line2d, circle2d, arc2d, ellipse2d, spline2d — starts out with free coordinates. Constraints and dimensions are equations laid on top of those coordinates; the solver adjusts whatever is still free until every equation is satisfied at once. A plain cad.variable(30.0) — no fixed, no driving — is treated the same way: an ordinary least-squares unknown that the solver may move to make the rest of the sketch consistent, rather than a value you set. §3.4 covers the two ways to take a value out of the solver's hands.

Because it's a solve and not a fixed recipe, touching one dimension can ripple through the whole sketch. Editing a driving variable with cad.update(...) re-solves the sketch's constraints afterward; cad.ensure_convergence() forces that solve to happen immediately, without waiting for the next edit, so a script can inspect fully-solved positions before continuing.

In the GUI, the Sketch Mode toolbar's Constraint menu and Dimension menu apply these to selected entities, and the Variables panel manages named dimension variables (and their display precision). None of that is separate, hidden state: each action still appends or edits a line in your script and re-runs it, the same as everywhere else in FlywheelCAD. A practical workflow is to rough in geometry in the UI, add constraints and dimensions from those dropdowns, name the shared dimensions in the Variables panel, and treat the resulting script as the source of truth for repeatability.

3.2 Geometric constraints

Constraints take existing sketch elements and relate them; none of them take a numeric value (that's a dimension, §3.3) and most return nothing — the relationship is the effect. A few — the tangency constraints — construct and return the tangency point itself, since the solver needs somewhere to anchor it.

CallWhat it constrains
cad.horizontal(line)Line is horizontal.
cad.vertical(line)Line is vertical.
cad.parallel(line1, line2)Two lines are parallel.
cad.perpendicular(line1, line2)Two lines meet at 90°.
cad.collinear(line1, line2)Two lines lie on the same infinite line.
cad.coincident(p0, p1)Two points become one — see callout below.
cad.point_on_line(point, line)Point lies on a line.
cad.point_on_circle(point, circle)Point lies on a circle's circumference.
cad.point_on_ellipse(point, ellipse)Point lies on an ellipse's boundary.
cad.point_on_plane(point, plane)Point lies on a plane.
cad.fixed(element)Pins a point, line, circle, arc, ellipse, or spline's own positional degrees of freedom to its current position. Unlike the sketch origin's hard pin this is an ordinary constraint: it counts toward the DOF badge, and deleting the line frees the element again. It does not out-rank a conflicting constraint — the solver splits the difference — but the sketch badge then reads conflicting rather than silently showing moved geometry. An arc pins its centre and radius, not its endpoints.
cad.concentric(circle1, circle2)Two circles share a center.
cad.equal_lines(line1, line2)Two lines have equal length.
cad.equal_radius(circle1, circle2)Two circles have equal radius.
tp = cad.circle_tangent_line(circle, line, anchor=None)Circle is tangent to a line; returns the tangency point.
cad.circle_tangent_circle(circle1, circle2)Two circles are tangent to each other.
tp = cad.ellipse_tangent_line(ellipse, line, anchor=None)Ellipse is tangent to a line; returns the tangency point.
cad.spline_tangent_line(spline, line, control_point=None, control_point_index=None)Spline is tangent to a line. The two optional keywords select which control point the tangency is anchored at — control_point= takes a point ref, control_point_index= an integer position in the spline's control-point list. Omit both to let the app choose; the sketch toolbar's own spline-tangent tool writes control_point_index=.
cad.symmetry_line(p1, p2, symmetry_line)Two points are mirror images of each other about a line.
cad.symmetry_plane(point1, point2, plane)Two points are mirror images of each other about a plane.

A worked pair: a slot whose two ends are equal circles, centred on a construction line, mirrored about it, and pinned so the solver cannot drift the whole assembly.

cad.with_sketch("xy")
axis  = cad.line2d(cad.point2d(-30, 0), cad.point2d(30, 0), construction=True)
left  = cad.circle2d(cad.point2d(-20, 0), 6)
right = cad.circle2d(cad.point2d( 20, 0), 6)

cad.equal_radius(left, right)              # same hole size, one dimension drives both
cad.point_on_line(left, axis)              # a circle where a point is expected means its centre
cad.point_on_line(right, axis)
cad.symmetry_line(left, right, axis)
cad.fixed(axis)                            # pin the axis so the pair cannot slide as a unit

The relational constraints, each on a minimal figure:

# coincident: two separately-drawn points become one point
a = cad.point2d(0, 0)
b = cad.point2d(0.2, 0)
cad.coincident(a, b)

# collinear: two segments share one infinite line
cad.collinear(cad.line2d(a, cad.point2d(10, 0)),
              cad.line2d(cad.point2d(20, 1), cad.point2d(30, 1)))

# concentric: two circles share a centre
cad.concentric(cad.circle2d(cad.point2d(0, 40), 10),
               cad.circle2d(cad.point2d(1, 41), 4))

# equal_lines: two edges hold the same length
cad.equal_lines(cad.line2d(cad.point2d(0, 60), cad.point2d(10, 60)),
                cad.line2d(cad.point2d(0, 70), cad.point2d(9, 70)))

# point_on_ellipse / ellipse_tangent_line
e  = cad.ellipse2d(cad.point2d(-6, 90), cad.point2d(6, 90), cad.point2d(0, 96))
cad.point_on_ellipse(cad.point2d(6, 90), e)
guide = cad.line2d(cad.point2d(-20, 100), cad.point2d(20, 100))
tp = cad.ellipse_tangent_line(e, guide)    # returns the tangency point

# spline_tangent_line: anchor the tangency at one control point
sp = cad.spline2d([cad.point2d(0, 120), cad.point2d(10, 128), cad.point2d(20, 120)])
cad.spline_tangent_line(sp, guide, control_point_index=0)

Out-of-plane relations take a plane rather than a line:

p1 = cad.point(0, 0, 5)
p2 = cad.point(0, 0, -5)
cad.point_on_plane(p1, "xy")               # hold a 3D point on a plane
cad.symmetry_plane(p1, p2, "xy")           # and mirror a pair across it

cad.merge_points is the canonical spelling of the weld that cad.coincident performs (see the callout below): it replaces one point with the other, so the sketch ends with a single point and no residual constraint. Reach for it by name when the duplicate was a mistake rather than a relationship; the result is the same either way.

keep = cad.point2d(0, 0)
dupe = cad.point2d(0, 0)
cad.merge_points(dupe, keep)               # dupe's references are rebound to keep
cad.horizontal(line=l1)
cad.vertical(line=l2)
cad.perpendicular(line1=l1, line2=l2)
cad.point_on_circle(point=p1, circle=c1)

tangent_pt = cad.circle_tangent_line(circle=c1, line=ground)  # c1 rests tangent on `ground`
cad.circle_tangent_circle(circle1=outer, circle2=satellite)    # `satellite` kisses `outer`

cad.vertical(line=mirror_axis)
cad.symmetry_line(p1=sym_left, p2=sym_right, symmetry_line=mirror_axis)

cad.horizontal(l1);  cad.parallel(l1, l2)   # positional form, also accepted
Keyword is the canonical form

The keyword spelling above is what the app writes into a document when you place a constraint in the GUI, so a hand-written script and a GUI-authored one read identically. The positional form is accepted by every constraint too, and is what the bundled Samples/ and Library/ scripts use — but for the same-typed argument pairs (parallel, perpendicular, collinear, equal_lines, concentric, equal_radius) keyword is the safer habit: a transposed positional call binds silently, a keyword call fails loudly.

Coincident is a merge, not a residual constraint

cad.coincident(p0, p1) is an alias for cad.merge_points(p0, p1): it welds the two points into one shared identity rather than adding an equation the solver keeps re-checking. After the call, both variables refer to the same point.

3.3 Dimensions

Dimensions pin a numeric value onto geometry — a length, a radius, an angle, a distance. The value can be a plain number, a cad.variable(...) (§3.4), or a linear expression built from variables with +, -, *, /.

CallPins
cad.distance(p0, p1, distance)Point-to-point distance. Accepts positional (p0, p1, value) or keyword (p0=, p1=, distance=) form.
cad.length(line, length)A line's length.
cad.radius(circle, radius)A circle's radius.
cad.angle(line1, line2, angle)Angle between two lines, in degrees.
cad.point_line_distance(point, line, distance)Perpendicular point-to-line distance.
cad.fraction(refA, refB, multiplier)Ties two variables in a fixed ratio, A = multiplier × B. Takes the two variable refs and the multiplier.

A ratio between two variables, and the plane origin fetched explicitly:

width  = cad.variable(60, driving=True)
height = cad.variable(30)
cad.fraction(height, width, 0.5)             # height is always half the width

o = cad.origin("xy")                         # same point as the injected origin_xy
cad.coincident(cad.point2d(0, 0), o)

Dimension labels carry a position of their own. Dragging one in the sketch writes a cad.label_offset(key, dx, dy) line, where key identifies the dimension and dx/dy nudge the label away from its default spot. It moves annotation only — no geometry, no constraint, no effect on the solve.

cad.label_offset("length:l1", 4, -2)         # shift that dimension's label
cad.length(line=l1, length=40)
cad.distance(p0=a, p1=b, distance=25)
cad.distance(a, b, 25)                       # positional form, also accepted
cad.radius(circle=c1, radius=8)
cad.angle(line1=l1, line2=l2, angle=90)
cad.point_line_distance(point=p, line=l1, distance=5)

# a dimension's value may be an expression of variables (+ - * /)
d1 = cad.variable(80.0)
d2 = cad.variable(12.0)
cad.length(line=l2, length=d1 * 2 + d2)

Prefer these explicit cad.length / cad.radius / cad.distance calls for dimensions in Python rather than trying to encode a value some other way — it keeps the script's intent legible and matches what the Dimension menu itself writes.

3.4 Variables & parametric dimensions

cad.variable(value=None, fixed=False, driving=False, name=None) declares a scalar you can bind into one or more dimensions. name= fixes the name it appears under in the Variables panel and the script, rather than letting it be inferred from the assignment (§2.2). Its two boolean flags decide how much control the solver keeps over it:

FormBehavior
cad.variable(100.0, fixed=True)Permanently pinned constant — it never moves.
cad.variable(30.0, driving=True)Pinned during solves (drives the geometry bound to it) but editable afterward via cad.update(...) or the Variables panel; constraints re-solve after each edit.
cad.variable(30.0)Free solved parameter — an ordinary solver unknown the solver may move on its own. Use this for reference/derived values, not for a dimension that should drive geometry.
w = cad.variable(240.0, fixed=True)     # a permanent constant
cad.length(line=l1, length=w)

pitch = cad.variable(30.0, driving=True) # editable later
cad.length(line=l2, length=pitch * 0.5)

cad.update({pitch: {"value": 45.0}})     # edits `pitch`; sketch re-solves
cad.update({p1: {"x": 10.0, "y": 20.0}}) # plain points update the same way
fixed and driving are mutually exclusive

Passing fixed=True and driving=True to the same cad.variable(...) call raises a ValueError — a constant that is also an editable dimension is a contradiction, so FlywheelCAD refuses it outright rather than picking one silently.

Variables outside dimensions

A cad.variable, or an expression of variables, is also accepted where a primitive or a placement takes a number. There it is resolved to its number when the script runs; only a dimension keeps it as a live expression.

WhereWhat happens
cad.point2d(x, y) · cad.point(x, y, z)Resolved to numbers. A 2D coordinate is the solver's starting guess, not a dimension, so no constraint is added — dimension the point if it must follow the variable.
cad.circle2d(center, radius)The radius is resolved and a cad.radius(circle, radius) dimension carrying the expression is added after the circle, so editing the variable resizes it. Linear expressions only: w * h or a / b between two variables builds the right circle but keeps no link.
region(inside=…)Each coordinate is resolved, as a constructor's is.
cad.move · cad.rotate · cad.scale · cad.offset · cad.pattern_linear · cad.pattern_circularEvery coordinate and scalar is resolved. The document records the number, so editing the variable afterwards does not move the body.
cad.instance · cad.mate_coincident · cad.mate_align · polar() · ring() · grid()Placement coordinates, angles and offsets are resolved the same way.
cad.extrude(…, offset=…)Kept live, like the extrude distance.
twist= · draft= · edge_radius=Plain numbers only. A variable is refused with an error naming the argument.
w    = cad.variable(80.0, driving=True)
hole = cad.variable(9.0, driving=True)
c  = cad.point2d(w * 0.35, 25)      # a starting guess at x = 28 — no constraint added
h1 = cad.circle2d(c, hole / 2)      # radius 4.5, plus cad.radius(circle=h1, radius=hole / 2): linked

** has no spelling in a document expression, so a ** 2 collapses to its number and the link is lost. A variable declared without value= has no number until the solver runs, so it cannot be resolved in these positions; the error names the argument and says why. Numeric strings such as "12" are refused wherever a number is expected.

3.5 Editing operations: fillet, trim, mirror, pattern, merge

Fillet

cad.fillet(line1, line2, radius, name=None) rounds the corner where two elements — lines, arcs, or a mix of both — meet at a shared endpoint. Both elements retract to their tangent points and a tangent arc of radius bridges the gap; tangency and radius stay constrained, so the fillet remains valid across re-solves rather than being baked in once. The original variables keep referring to the now-retracted elements, and the call returns the new arc — feed it into a region loop between them, e.g. region(loop=[l1, f1, l2, ...]).

f1 = cad.fillet(l1, l2, radius=4)   # line-line corner
f2 = cad.fillet(l3, a1, radius=2)   # line-arc corner also works

FlywheelCAD rejects a fillet when the join is already tangent-continuous (there's no corner to round) or when the requested radius doesn't leave part of both elements intact.

In the GUI, Modify ▸ Fillet Corner… GUI opens the radius sheet for the corner implied by your current selection; the same command sits in the Sketch Mode toolbar's More (⋯) menu as Fillet…. The menu item is disabled until the selection actually describes a fillettable corner, so a greyed-out Fillet Corner… means "these two elements don't meet at a shared endpoint", not "the feature is missing".

Trim

cad.trim(element, near) trims an element in place at its nearest intersection. The original variable is updated to the surviving piece; the call returns (element, split_ref), where split_ref is only valid when the trim actually produced a second piece. A trim tool is also available directly on the Sketch Mode toolbar, and as Modify ▸ Trim (T).

l1, l1_split = cad.trim(l1, near=(12, 4))

Mirror

cad.mirror_elements(elements, symmetry_line) mirrors a list of sketch elements across a line in the current sketch, producing their reflected counterparts.

cad.mirror_elements([l1, l2, c1], symmetry_line=axis)   # mirror three elements across `axis`

Modify ▸ Mirror (M) GUI — also in the Sketch Mode toolbar's More (⋯) menu — does exactly the same thing from a selection, and it appends exactly one cad.mirror_elements(...) line no matter how many elements you mirrored. Select the elements you want reflected and then, last, the line to reflect them across; the command reads the final selected element as the symmetry line. Lines, circles, ellipses and arcs are mirrorable, and the menu item stays disabled until the selection satisfies both rules (at least one mirrorable element, and a line selected last), so the refusals below are ordinarily unreachable from the GUI:

Each mirrored copy is addressable as <source>_m — mirroring l1 gives you l1_m. Mirror the same source again (across a second axis, say) and the next copies take _m2, _m3, and so on, so repeat mirrors never collide and every reflected element stays individually nameable from the script.

Pattern

Modify ▸ Pattern… GUI — also in the Sketch Mode toolbar's More (⋯) menu — repeats the selected geometry in a circle around a centre point, and the copies stay linked: move the source and every copy follows, in the same solve, with no re-run.

Select the elements you want repeated and then, last, the point to rotate them around — the same "the last thing you select is the operand" convention Mirror uses for its symmetry line. Then give the panel two numbers:

The step is deliberately a per-copy angle rather than a sweep, because it is the number the document keeps: it lands in a cad.variable(..., driving=True), so editing it in the Variables panel re-fans the whole ring immediately, with no re-execution. That is the same recipe the "Repeating sketch geometry" callout in §10.1 spells out by hand — the menu item simply writes it for you.

Points, lines, circles, arcs and ellipses. Anything the pattern cannot copy — a spline, or a selected area — is refused with a sentence naming it, rather than being silently skipped while the rest of your selection is patterned.

A patterned ring adds no redundant equation, whichever of those kinds it copies, so the solver badge stays quiet on a sketch that is genuinely fine. Getting there took a fix and a measurement rather than an assumption. Every ellipse in every document used to carry one redundant equation of its own, patterned or not — the point that defines an ellipse's rim was being constrained to lie on the ellipse it defines, which is always true and so contributed an equation that could never move anything. Arcs were long assumed to have the same problem; measuring showed they never did, and a bare arc has always been exactly determined.

Arcs are linked slightly differently from everything else, and the emitted loop shows it. Most copied geometry is held by a two-constraint "star" per defining point: a construction spoke from the pattern centre, an equal_lines fixing its radius and an angle fixing its bearing. An arc cannot use that for all three of its points, because creating the copy introduces a radius of its own and pins both endpoints to it — two constraints where the star has already accounted for the geometry, which would leave every copy carrying one redundant equation. So the loop stars the arc's centre and one endpoint, and holds the other endpoint with a single angle between the source arc's centre-to-endpoint construction line and the copy's:

r0 = cad.line2d(ac, aend, construction=True)      # source radius line, once
for k1, k2 in enumerate(ring(6, step=60)[1:], start=1):
    # ... the usual star for the centre and the START point ...
    pend = cad.point2d(*polar(rEnd, aEnd + k2))   # seeded, NOT starred
    ak = cad.arc2d(pc, pstart, pend)
    rk = cad.line2d(pc, pend, construction=True)
    cad.angle(line1=r0, line2=rk, angle=step * k1)

If the endpoint it would normally use is unavailable — because it is the pattern centre, or because a neighbouring patterned arc already relies on it — the loop simply uses the arc's other endpoint instead. Only when both are unavailable does it fall back to the plain star for that arc and accept one redundant equation, rather than refusing your selection: the geometry is correct either way.

What it writes is an ordinary, readable for loop — not an opaque command — so the count is a number in the script you can edit afterwards:

d1 = cad.variable(60, driving=True)
l2 = cad.line2d(p0, p1, construction=True)
for k1, k2 in enumerate(ring(6, step=60)[1:], start=1):
    p3 = cad.point2d(*polar(10, 0 + k2, center=(0, 0)))
    l4 = cad.line2d(p0, p3, construction=True)
    cad.equal_lines(line1=l2, line2=l4)
    cad.angle(line1=l2, line2=l4, angle=d1 * k1)

The construction lines are the pattern's scaffolding — one from the centre to each copied point — and the two constraints per point are what hold the ring together: equal_lines keeps every copy the same distance out, and the signed angle puts it k steps around. Each copied defining point costs one construction line and two constraints, so patterning a rectangle pays for all four corners; the count is capped to keep that bill finite.

Two refusals worth knowing, because both are about geometry rather than typing:

Merge points

cad.merge_points(source, target) welds two sketch points into one shared identity — the canonical form of what cad.coincident(p0, p1) (§3.2) spells as a constraint. Modify ▸ Merge Points GUI does the same for the points selected in the sketch canvas. Because it is a merge rather than a residual equation, there is nothing for the solver to keep re-checking afterwards: the two variables simply refer to the same point from then on.

3.6 Solver diagnostics

FlywheelCAD's sketch solver doesn't expose a separate diagnostics API beyond the pieces already covered in this chapter — the vocabulary for keeping a sketch well-determined is the fixed / driving / free choice from §3.4. Pin what must never move with fixed=True, pin-but-tunable values with driving=True, and leave everything else free so the solver has room to satisfy the constraints you've added — an under-constrained sketch simply has more free coordinates than equations pinning them, and the solver settles on whichever consistent configuration is closest to where you drew it.

cad.ensure_convergence() forces a solve of the current sketch on demand, useful when a script needs fully-settled coordinates before it keeps building rather than waiting for the next dimension or update to trigger one.

cad.update({width: {"value": 45}})
cad.ensure_convergence()                     # settle now, before reading positions
body = cad.extrude("xy", profile, 10)

The one case the API validates directly and loudly is the contradiction in §3.4: fixed=True together with driving=True raises a ValueError at the cad.variable(...) call itself. For anything else — a script that runs without error but produces geometry that didn't change the way you expected — check for plain Python mistakes first, prefer the explicit dimension calls in §3.3 over improvising a value some other way, and use the script panel's error line to locate the runtime or validation failure quickly.

4. Regions — from Sketch to Solid

A region is the closed 2D area a sketch hands to a 3D operation — extrude, revolve, loft. Getting the loop right is what most first-time failures come down to: this chapter is the fast path through it.

4.1 What a region is

A region is identified by a directed boundary loop, optional holes, and an optional inside point:

r = cad.region(
    loop=[l1, l2, l3, l4],     # ordered boundary; consecutive elements share endpoints
    holes=[[hole_circle]],     # list of hole loops (each a list of elements)
    inside=(x, y),             # a point KNOWN to be inside the material (2D coords)
)

region(...) (the top-level free function) and cad.region(...) (the method) are equivalent — both build the same region object and take identical loop=/holes=/inside= keywords, and an optional name= label. loop= is the only required argument; calling region() with no loop raises region requires loop=[...] immediately, before the script even reaches the CAD engine.

A region isn't geometry itself — it's an instruction for how to read the sketch. Pass it straight into a body call:

body = cad.extrude("xy", region(loop=[l1, l2, l3, l4]), 5)

4.2 Loops, holes & inside points

KeywordMeaning
loop=[...]The outer boundary, as an ordered list of sketch elements. Consecutive elements must share endpoints, forming a closed cycle. Required.
holes=[[...], ...]Zero or more hole loops. Each hole is its own list of elements (a list of lists) and cuts material out of the outer boundary.
inside=(x, y)A point known to be inside the material, in 2D sketch coordinates. Disambiguates when one boundary could enclose two different areas (e.g. concentric circles, or two faces sharing a chord).

A square with a circular hole through it:

body = cad.extrude("xy", region(loop=[l1, l2, l3, l4], holes=[[c1]]), 5)

A full disk, bounded by a single circle:

body = cad.extrude("xy", region(loop=[c1], inside=(0, 0)), 5)
When to add inside

When the loop is unambiguous you can omit inside, but including it is safer. Always include inside for any region with holes or with multiple candidate faces — and put it clearly in the solid material, never inside a hole.

4.3 Direction & seam cancellation

A loop is directed: list the boundary elements in traversal order. Every element has an intrinsic direction — lines run p1 → p2, circles/arcs/ellipses run counter-clockwise, splines follow control-point order. If an element runs against its natural direction for this particular loop, reverse it:

CallEffect
rev(element)Marks the element as traversed against its intrinsic direction in this loop. Serializes as -element (e.g. rev(l4) → -l4).

A face picked out of a circle crossed by chords — down the left arc, east along l10, then back up l7 and west along l4 against their intrinsic p1→p2 directions:

body = cad.extrude("xy", region(loop=[c1, l10, rev(l7), rev(l4)]), 5)

Compare that to the full-disk case above — region(loop=[c1], inside=(0, 0)). Leaving the internal chords out of the loop entirely (rather than tracing around them) is what makes it the whole disk instead of one wedge: the chord seams that would otherwise bound adjacent faces cancel, and only the outer arc c1 remains as the boundary.

Loop matching is forgiving about where you start

Matching a loop against the sketch is rotation-invariant (any starting element works) and reversal-lenient (a fully reversed, hand-written clockwise cycle still matches). What it will not tolerate is a loop that doesn't close, or elements that don't resolve — see §4.4.

4.4 Common region errors

SymptomCause / fix
region requires loop=[...] Called region()/cad.region() with no loop=. Raised immediately in Python — loop is the only mandatory keyword.
No region traces loop (...) Runtime error from the CAD engine. Loop element names must resolve to existing sketch elements in the active plane; the loop must actually list the boundary elements of a closed cycle (rotation-invariant and reversal-lenient, but it still has to close); and if several regions trace the same loop, a missing or misplaced inside point can pick the wrong one.
Loop silently fails to include an axis/guide line Construction elements (construction=True) are excluded from region detection. Never put a construction element in a region loop — build the loop from real geometry only.
Construction elements never belong in a loop

An element created with construction=True (guides, axes, symmetry lines — rendered dashed) is excluded from region detection entirely; it cannot appear in a loop= or holes= list. This trips up revolve axes especially: build the profile loop from ordinary lines, and if you also want a construction axis line, offset the profile so it doesn't touch it.

axis = cad.line2d(b, t, construction=True)   # fine as a revolve axis...
# ...but never reference `axis` inside a region loop= or holes= list

5. Body Generation

This chapter turns the regions you built in Chapter 4 into 3D bodies, then combines and repositions them. Every call below returns a body ref you reuse downstream — as a boolean input, a transform source, or a mate-anchor host.

5.1 Extrude

cad.extrude(plane, region, distance, direction=None, quality=None, export=None, edge_radius=None, twist=None, twist_center=None, offset=None, draft=None) pushes a region straight off its sketch plane along the plane normal.

ParameterMeaning
distanceExtrude height. Required.
direction=NoneOne-way, along +normal. direction="symmetric" extrudes both ways from the sketch plane.
offsetShifts the whole extrude off the sketch plane along the normal before it grows; 0 (default) starts on the plane. Negative is allowed. Not the same thing as cad.offset(body, …) (§5.6), which grows/shrinks an existing body's surface.
draftTaper angle in degrees, measured from the base (sketch-side) face: positive slopes the walls outward (far face larger), negative inward.
twistTotal twist in degrees of the cross-section over the full extrude height.
twist_center2D pivot point the twist rotates about. Defaults to the region centroid.
edge_radiusRounds every sharp edge of the body uniformly — the rims where the profile meets the caps and the profile's own corners alike. Omit or 0 for sharp (default).
quality, export, namePer-body quality override ("preview"/"standard"/"high"/"ultra"/"precision" — see §8.3), export=False to exclude the body from mesh export (§8.7), and name= to fix the script name explicitly (§2.2). export= must be a real bool or omitted.
body = cad.extrude("xy", region(loop=[l1, l2, l3, l4]), 10.0,
                   edge_radius=1.5, draft=3, twist=45)

5.2 Revolve

cad.revolve(plane, region, axis_line, angle, quality=None, export=None, edge_radius=None) sweeps a region around an axis line drawn in the same sketch. axis_line is a (construction) line; angle is in degrees — 360 makes a full body of revolution, less than 360 a partial one. edge_radius rounds every sharp edge of the body, same as extrude.

# Cup profile revolved 180 degrees about l1 (the left edge, x = 0)
rbody = cad.revolve("xy", region(loop=[l1, l2, l3, l4]), l1, 180.0, edge_radius=2.0)

5.3 Loft & multi-loft

Two lofting calls, both bridging profiles on different sketch planes with a single solid:

CallEffect
cad.loft(start_plane, start_region, end_plane, end_region, quality=None, export=None, edge_radius=None)Bridges exactly two profiles, on two (possibly non-parallel) planes. end_plane/end_region are required — omitting either raises an error.
cad.multi_loft(planes, regions, quality=None, export=None, edge_radius=None)One smooth solid through N ≥ 2 sections as a single body with no internal seams. planes and regions are parallel lists, ordered first→last, and must be the same length.
Prefer multi_loft for N > 2

For three or more sections, cad.multi_loft(...) is preferred over chaining several 2-section cad.loft(...) calls and unioning them — it builds one seamless body instead of stacking boolean joints.

cad.loft's four profile arguments are keyword-only in practice

cad.loft is the one command with no positional fallback anywhere in the app. A positional call runs through python, but the app's own single-line text grammar refuses it — which shows up as the mesh-quality picker refusing to change that body's quality. Always spell the four out.

lbody = cad.loft(start_plane="xy", start_region=region(loop=[c1]),
                 end_plane="zx", end_region=region(loop=[c2]), edge_radius=1.0)

mbody = cad.multi_loft(
    [plane0, plane1, plane2],
    [region0, region1, region2],
)

5.4 Sweep, helix & paths

A sweep drags a 2D profile along a 3D path. Build the path first — with cad.helix, cad.path, or cad.path3d — then pass it to cad.sweep(...). A path call returns a path handle, not a body.

cad.helix(radius, pitch, turns, axis_origin=(0,0,0), axis_dir=(0,0,1), start_angle=0.0, left_handed=False, name=None)

A helical path about an axis. radius/pitch are the coil radius and axial rise per full turn; turns may be fractional; start_angle is the initial angular offset in degrees (like every other angle in the API) of the path's θ=0 radial direction; left_handed flips the winding sense (the axial rise along axis_dir stays monotonic either way).

cad.path(plane, chain, name=None)

An open, directed rail on a sketch plane: lines/arcs/splines end-to-end, e.g. chain=[l1, rev(a2), s3] (rev(...) traverses an element against its intrinsic direction, exactly as in a region loop). Unlike a region loop, the chain is open — it never closes, and a closed element (full circle/ellipse, closed spline) cannot appear in it.

rail = cad.path("xy", chain=[l1, rev(a2), s3])

The plane is passed positionally and the chain by keyword — the form the app writes when you build a rail in the GUI.

Keep the rail off the profile

The profile anchors to the sketch origin (below); the rail's position is independent of it. A rail element that passes through the profile's boundary (e.g. a chain line crossing the wire circle) splits the profile's detected region and silently changes the swept section.

cad.path3d(points, closed=False, up=None, name=None)

A free 3D curve through named point refs (results of cad.point(x,y,z), not raw tuples). A non-uniform (chordal) Catmull-Rom curve is fit through the points, then framed with a rotation-minimizing frame (RMF) so the section does not spuriously twist along a curving, non-planar rail. up optionally seeds the section's start orientation (e.g. a rectangular tube's flat), re-orthogonalized against the first tangent; the default is a deterministic auto-perpendicular direction. closed=True is not yet supported and raises an error.

cad.sweep(plane=None, region=None, path=None, sections=None, twist=0.0, quality=None, name=None, export=None, edge_radius=None)

Pass exactly one of two profile forms:

twist adds a total twist in degrees of the section over the whole path length, spun about the profile origin (useful for orienting e.g. a thread tooth or a rectangular wire flat).

Profile-anchoring convention — the #1 sweep mistake

The profile's 2D (x, y) coordinates are used AS-AUTHORED, UNCHANGED, as the local (u, v) offset from the path at every station — the sketch plane supplies 2D geometry only, not 3D placement. A profile drawn at the sketch origin (0, 0) rides exactly on the path (a spring's wire circle, centered at the sketch origin, ends up centered on the helix — the coil radius lives entirely in the path, via cad.helix(radius=...), not in the profile). A profile drawn off-center stays that far from the path at every station. Do not also draw the profile out at the coil radius — that double-counts it. At each station, (u, v) maps to the path frame's (normal, binormal), where normal is radially outward from the helix axis.

# A spring: wire circle drawn CENTERED AT THE SKETCH ORIGIN.
cad.with_sketch("xy")
wire = cad.circle2d(cad.point2d(0, 0), 1.2)      # profile radius, NOT the coil radius
path1 = cad.helix(radius=8, pitch=4, turns=5)    # coil radius lives on the path
spring = cad.sweep("xy", region(loop=[wire]), path1)
# A taper: big circle -> small circle along a straight rail, via sections=
p1 = cad.point(0, 0, 0)
p2 = cad.point(0, 0, 20)
rail = cad.path3d(points=[p1, p2])
cad.with_sketch("xy")
big = cad.circle2d(cad.point2d(0, 0), 5)
cad.with_sketch("zx")
small = cad.circle2d(cad.point2d(0, 0), 1)
taper = cad.sweep(path=rail, sections=[
    sweep_section(fraction=0.0, plane="xy", region=region(loop=[big])),
    sweep_section(fraction=1.0, plane="zx", region=region(loop=[small])),
])

5.5 Boolean operations

cad.bool_union, cad.bool_difference, and cad.bool_intersection all share the signature (*bodies, quality=None, export=None, blend=None, radius=None, name=None) — variadic (at least two bodies), keeping the result and consuming the inputs.

CallResult
cad.bool_union(a, b, ...)Union of all bodies passed.
cad.bool_difference(box, tool, ...)First body minus the rest.
cad.bool_intersection(a, b, ...)Overlap of all bodies passed.

blend/radius shape the seam where two bodies meet:

ValueEffect
omitted / "sharp"Default hard boolean edge. radius must not be set.
blend="smooth"Rounded fillet (polynomial smooth-min/max) at the seam. Requires exactly two bodies and a numeric radius > 0.
blend="chamfer"Flat 45° bevel at the seam. Requires exactly two bodies and a numeric radius > 0.
u = cad.bool_union(a, b, c)                             # more than two is fine
d = cad.bool_difference(box, tool, blend="smooth", radius=2)
i = cad.bool_intersection(a, b, blend="chamfer", radius=1.5)
A sharp boolean over a blended operand has a resolution ceiling

A sharp bool_difference/bool_intersection that cuts through an operand which is itself a blend="smooth"/"chamfer" result (or an offset) meshes on a CPU-only adaptive mesher whose base grid is capped at a fixed 192 cells/axis regardless of quality=. Once the body's true cell demand materially exceeds that cap, neither quality= nor set_cell_size refines it further — a warning in the script console reports when the cap binds, so a capped mesh is never silent.

5.6 Offset & shell

cad.offset(body, distance=None, quality=None, name=None) grows or shrinks a body's surface uniformly. distance is required — positive expands outward, negative shrinks inward — and omitting it raises offset requires distance immediately, in Python, before the call reaches the CAD engine. Unlike the transforms, cad.offset takes no export=; set that afterwards with cad.set_export(...) (§8.7) if you need to exclude the result.

There is no dedicated shell call; hollow a body by differencing a shrunk copy from the original:

big = cad.offset(box, 2)     # grow outward in every direction
small = cad.offset(box, -2)  # shrink inward
shell = cad.bool_difference(big, small)   # hollow shell

5.7 Body transforms

These operate on any body — including a component instance ref — and each returns a new body; the source stays, so they duplicate-and-transform rather than move in place. Every one that returns a body also accepts quality=, export= and name= (§2.2); cad.delete_body returns nothing and takes none of them.

CallEffect
cad.move(body, translate=None, quality=None, export=None, name=None)Translate by the (x, y, z) tuple translate. Keyword-only — see the warning below.
cad.rotate(body, axis=(0,0,1), angle=0, center=(0,0,0), quality=None, export=None, name=None)Rotate angle° about axis through center.
cad.scale(body, factor=None, sx=None, sy=None, sz=None, quality=None, export=None, name=None)Uniform scale via factor, or per-axis via sx/sy/sz.
cad.mirror_body(body, plane="yz", quality=None, export=None, name=None)Reflect across a sketch plane (xy/yz/zx, or a custom plane).
cad.copy(body, quality=None, export=None, name=None)Duplicate in place (identity transform).
cad.delete_body(body)Delete a body. Boolean/offset bodies that consume it are deleted too.
cad.move takes translate=, never dx/dy/dz

Everything after body is keyword-only. Two older shapes are both refused with a TypeError that names the replacement, rather than being silently reinterpreted: the positional form cad.move(b, 10, 0, 0), and the scalar-keyword form that passed dx=, dy= and dz= as three separate arguments. This matters more than it looks: some saved documents contain the positional form, so a file that has never been hand-edited can still need this one edit before it runs. See §10.2.

b2 = cad.move(b, translate=(10, 0, 0))
b3 = cad.rotate(b, axis=(0, 0, 1), angle=45)
b4 = cad.scale(b, factor=2)
b5 = cad.mirror_body(b, plane="yz")
b6 = cad.copy(b)
cad.delete_body(b)
Same calls place component instances

This is the canonical reference for move/rotate/scale/mirror_body/copy/delete_body — the identical calls also reposition component instances in an assembly. See Chapter 7 for the instance-placement workflow (anchors, mates, and the cad.instance(..., translate=, angle=, mirror_plane=, scale=) placement transform).

5.8 Patterns

A pattern is one script line that produces many bodies: count - 1 copies of a seed body, with the seed left where it is. The point of the opcode — as opposed to a hand-written loop of cad.move(...) calls — is that the whole array has a single editable line. Change count, spacing or angle there and every copy follows.

CallEffect
cad.pattern_linear(body, direction=(1,0,0), count=2, spacing=10, quality=None, export=None, name=None)Copies at i × spacing along the (normalized) direction, i = 1…count-1. Returns [seed, copy_1, …].
cad.pattern_circular(body, axis=(0,0,1), count=4, angle=360, center=(0,0,0), quality=None, export=None, name=None)Copies rotated about axis through center, sharing the TOTAL sweep angle. Returns [seed, copy_1, …].
row = cad.pattern_linear(peg, direction=(1, 0, 0), count=4, spacing=10, name="row")
ring = cad.pattern_circular(bolt, axis=(0, 0, 1), count=6, angle=360, name="ring")
fan = cad.pattern_circular(blade, axis=(0, 0, 1), count=3, angle=90, center=(20, 0, 0), name="fan")

count includes the seed. count=4 is the seed plus three copies (at 10, 20 and 30 mm above), and it must be between 2 and 1000 — for a bigger repeat, write a python loop, which also gives you per-command cancellation. direction is normalized, so only its direction matters and neighbours are always spacing mm apart; a negative spacing patterns backwards.

angle is the total sweep, and ±360 behaves differently

angle is the whole span the pattern covers, not a per-copy step, and must be non-zero and no more than a full turn in either direction. A full circle (exactly ±360) steps by angle / count, so the last copy stops one step short of the seed instead of landing on top of it. Any partial fan steps by angle / (count - 1), so a copy sits at each end of the sweep: angle=90, count=3 puts copies at 45° and 90°. The switch happens at exactly ±360 — angle=359.9 is a fan, not a circle. This is the same convention Fusion 360 uses.

The copies are ordinary bodies named row_1, row_2, … after the variable you assign to — or after an explicit name=; one of the two is required. They are also injected as bare python names, so cad.hide(row_2) works, and the call returns [seed, copy_1, …] with the seed first. If the assignment is inside a loop, or the same variable is assigned more than once elsewhere in the file, the base name itself is numbered too (row_1, with copies row_1_1, row_1_2, …) — pick a distinct name per pattern to avoid it.

What you can and cannot do to one copy

Colour, visibility, export and cell size are all per-copy: cad.set_color(row[2], "red"), cad.set_export("row_1", False), cad.set_cell_size("row_1", 0.5). What you cannot do is change ONE copy's quality from the Body Inspector — a copy has no creation line of its own for the picker to rewrite, so it refuses and says so. That is the same limitation the per-glyph bodies of a solid cad.text(...) have. Edit the pattern line instead. If a name the pattern needs (say row_1) is already in use, the whole pattern is refused with a message naming the clash — rename the pattern.

6. Selection & the 3D View

The 3D view is where you inspect the model — orbit around it, check a boolean result, confirm a hole lines up. It's also where you pick the points that feed custom sketch planes, mates, and dimensions, so the same click can be inspection or input depending on what you do next.

6.1 Navigating the 3D view GUI

The main canvas shows either the 3D scene view or the 2D sketch editor. In 3D mode you orbit, pan, and zoom the model, select bodies and topology points, and run boolean operations on selected bodies.

Camera gestures

GestureWhat it does
Drag (mouse or trackpad)Orbit — swings the camera around the model.
Scroll, no modifierPan — slides the camera across its own X/Y. It does not zoom.
⌥+scroll, or a trackpad pinchZoom (dolly) — moves the camera along its view direction.
Double-click a sketch planeOpens that sketch and switches into Sketch mode.
Long-press the modelOpens the Points popover listing points hidden behind geometry (§6.2).
ClickSelects. ⇧- or ⌘-click adds to the selection instead of replacing it.
Esc (in a sketch)Cancels the current draw operation — or, with nothing being drawn, returns you to the 3D view.
Scroll pans, it does not zoom

This is the one navigation habit worth unlearning if you come from another CAD package, where scroll is almost always zoom. Here a bare scroll pans; zoom is ⌥+scroll or a pinch. Settings ▸ Interaction has separate Orbit, Pan and Zoom sensitivity sliders (1.0× is the viewport's built-in feel).

The camera treats +Z as up, matching the coordinate convention the rest of the app uses, so orbiting keeps the model's vertical axis vertical rather than letting the view roll freely.

Clicks are ignored while a script is running

While a run owns the model, picking is deliberately refused: the geometry on screen still belongs to the previous run, so a click would resolve against a scene that is about to be replaced. Wait for the run to finish (§1.4) and click again. Orbiting, panning and zooming stay available throughout — only picking is held.

6.2 Selecting points & areas GUI

Click an entity to select it. Dragging a selection window left-to-right is a containment selection (only entities fully inside the window); dragging right-to-left is a crossing selection (anything the window touches). Clicking inside a closed region in select mode makes an area selection — selected areas are tinted.

Picking the individual points that feed a custom plane (§6.4) or a mate (chapter 7) works the same way everywhere in the app: ⌘-click each point in the 3D view to add it to the selection.

Points hidden behind geometry

A long-press on the model opens a "Points" popover listing points occluded by other geometry, so you're never stuck trying to click a point that's behind a face.

If an area won't select

Check that the loop is actually closed, that its boundary elements aren't construction geometry (construction is excluded from area detection), and that intersections/trim states are still valid — especially right after an edit.

6.3 Body topology points

After an extrude or revolve, a body exposes its far-face vertices as named, selectable points — the same points you can pick in the 3D view (§6.2) and pass to create_sketch_plane in a script (§2.7).

PatternWhat it names
body.v0, body.v1, …Far-face vertices of a plain extrude/revolve.
body.h0_v0, …Vertices of a hole (per-hole vertex sets).
body.pos_v0 / body.neg_v0A symmetric extrude splits its vertices into positive/negative halves.

The exact set of names depends on the body. When you're not sure what a body exposes, it's more reliable to build reference geometry with cad.point(x, y, z) and use that instead.

6.4 Custom planes from topology GUI

You don't have to type coordinates to define a custom sketch plane. Select three points in the 3D view — topology points (§6.3), existing sketch points, or standalone reference points — then use Sketch ▸ New Sketch from 3 Selected Points. It creates the plane from that selection and switches into the new sketch, logging the equivalent script lines: the point references, a create_sketch_plane(p1, p2, p3, name=...) call, and a with_sketch(...) to enter it.

Pick the points in the order you want them to act as p1, p2, p3 — the plane's origin lands on the first point, and its normal follows from all three, exactly as create_sketch_plane computes it (§2.7).

Sketch ▸ New Sketch from Coordinates… is the typed alternative: a dialog with a sketch name and three X/Y/Z rows (origin plus two points), for when you want a plane at explicit coordinates rather than picked off existing geometry — useful for laying out a series of parametric planes, e.g. one station per spanwise cut before a loft.

6.5 What selection drives

Selected points and entities are the input to several other workflows, covered in their own chapters:

7. Components, Placement & Mates

7.1 The mental model

FlywheelCAD assemblies are built from components — reusable parts defined once — that you instance (place copies of) into a design and then position with placement transforms or mates. Whether you work in the 3D view or type Python, the result is the same: lines in your script. Understanding that equivalence is the key to the whole workflow.

7.1.1 Components, instances, anchors

TermWhat it is
ComponentA reusable part definition — its own little sub-design. Built with with cad.component("name"):. Defining one does not place it.
InstanceA placed copy of a component in the assembly, created with cad.instance(...). You can place the same component many times; each instance is independent.
ExportA body inside a component that cad.component_export(...) marks as assembly-visible. A component with no export contributes no geometry to instances.
AnchorA named reference point promoted out of a component with cad.component_export_point("name", point). Each instance exposes it in assembly space as <instance>.<anchor> — the handles you mate to.
AssemblyThe top-level design that instances live in. Mates and cross-part geometry are resolved here.
Key idea

A component that is never instanced in a run is transparent — it builds straight into the top-level design. So a component file opened and run on its own behaves exactly like a normal part; instancing is what scopes it into a sub-design.

7.1.2 Everything is a script line

The GUI never holds hidden assembly state. Inserting a part, mating two points, or placing a component all do one thing: append (or edit) a line in your script and re-run it. Every dialog that changes geometry shows you the exact line it is about to write before you confirm. This means you can always drop to the script to fine-tune what the GUI produced, and anything the GUI can build, you can hand-write.

7.2 Inserting components

There are two GUI paths to bring a part in (from an installed library, or from a loose .py file) and the underlying script primitives they emit. All GUI paths copy the part into your project so the document stays self-contained.

7.2.1 From a library GUI

Reusable catalog parts (motors, bearings, fasteners) come from component libraries — folders of ordinary Python component modules. The standard library ships inside the app. Add your own by dropping a folder (downloaded or git cloned) into:

~/Documents/FlywheelCAD/Libraries/   # one folder per library

Library parts export named anchors ready to mate against. The standard stepper, for example, exposes mount_face, shaft_tip, shaft_base, and bolt_0…bolt_3:

cad.mate_coincident(bracket_1, "hole_0", "inst1.bolt_0")

7.2.2 From a loose file GUI

Two commands bring a loose .py that is not part of a library into the project, always by copying it in (never referencing it in place):

No need to save first

On a brand-new, unsaved document, inserting creates a temporary working folder automatically. Its contents travel with the document and land inside your .fwcad the first time you save. Use File ▸ Show Project Contents to see the copied files.

7.2.3 Defining a component Script

Define a part once inside a with cad.component(...) block. Components are defined flat — never nest with blocks — and nested by reference (see §7.2.5).

with cad.component("bracket"):
    cad.with_sketch("xy")
    # ... sketch + region ...
    body = cad.extrude("xy", r, 5)
    cad.set_color(body, "steel", finish="metallic")
    cad.component_export(body)                              # assembly-visible result
    cad.component_export_point("hole", cad.point(10, 0, 0)) # named anchor for mating
CallEffect
cad.component_export(body)Marks a body as a visible part of the component. A multi-body component exports several; each surfaces on an instance as <instance>_<name>.
cad.component_export(body, name="friendly")Same, but gives the export a friendly label used in that <instance>_<name> part name.
cad.component_export_point("hole", pt)Promotes a reference point; each instance exposes it as <instance>.hole (e.g. b1.hole), usable in assembly geometry, as a mate anchor, and as a target for other parts.
cad.component("wing", label="NEMA 17 40mm")Optional label= gives a human-friendly display name shown in the UI instead of the raw component name.

7.2.4 Parametric components Script

Build a distinct component per parameter set, memoized by its parameters, so the same parameters always reuse one build:

def make_wing(span, chord):
    def build():
        cad.with_sketch("xy")
        # ... use span / chord ...
        cad.component_export(skin)
    return cad.parametric("wing", build, span=span, chord=chord)

WING = make_wing(span=46, chord=18)   # returns a component name

cad.parametric(prefix, build, **params) builds the component once per unique parameter set and returns its name (a handle for cad.instance). The build callable takes no arguments and emits the component's geometry and exports. A reserved label= keyword sets a display name and never affects the memoization key. Related helpers: cad.component_name(prefix, *parts) builds the deterministic name, and cad.has_component(name) reports whether one was already emitted this run.

name = cad.component_name("bracket", 40, 6)  # the deterministic name for these params
if not cad.has_component(name):              # emit the geometry only once per run
    cad.parametric("bracket", build_bracket, length=40, thickness=6)
arm = cad.instance(name)

7.2.5 Instancing a component Script

cad.instance(component, …) places a component into the assembly and returns a body ref that is usable anywhere a body is — booleans, transforms, set_color, and as a mate anchor host.

b1 = cad.instance("bracket")                            # at the origin
b2 = cad.instance("bracket", translate=(50, 0, 0))      # placed
b3 = cad.instance("bracket", mirror_plane="zx")         # mirrored
b4 = cad.instance(WING, scale=2, angle=10, axis=(0,0,1))# transforms compose
jig = cad.instance("jig", export=False)                 # excluded from mesh export

Nesting is by instancing: place another component inside a with cad.component block and re-export the instance — panel = cad.instance(WING); cad.component_export(panel). Larger projects split components into sibling .py files that an assembly file imports; the folder is on the import path, so import fuselage resolves a sibling fuselage.py.

7.2.6 Where the files live: vendoring & bundles

Inserting a part is copy-on-use: the module is copied ("vendored") into your document folder under lib/<library>/, so the document stays self-contained and a later library update never silently changes your design. The vendored file carries a provenance header and shows up as a read-only library tab in the script panel — don't edit it; to customize a part, copy the file up into your document folder as a normal user module.

Where that lib/ folder physically lands depends on which of the two document formats you're in — beside the script for a flat .py, inside the package for a .fwcad bundle. Both formats, and the File menu commands that convert between them, are covered in §1.5; nothing about vendoring differs between them.

7.2.7 The rest of the Component menu GUI

§7.2.1 and §7.2.2 cover the two ways to bring a part in. The same Component menu also holds the commands for authoring parts and for maintaining your libraries:

CommandWhat it does
New Component…Asks where to put a new .py and what to call it, writes a starter component file there, and opens it in its own document window. A component file opened on its own behaves like an ordinary part (§7.1) — you model in it directly and mark results with cad.component_export(...).
Browse / Place Components…Opens a panel listing the components this document already defines, and places an instance of the one you pick with the placement transform you choose (§7.3.1). This is the GUI route to cad.instance(...) for components you wrote yourself, as opposed to catalog parts.
Make Component from Design…Turns the current document into a component: it factors your script into a new <name>.py component file beside the document, rewrites main.py into a short assembly that instances it, and re-runs. Every exportable body in the design becomes an export of the new component; a body that isn't bound to a script variable can't be exported and is reported in the console rather than silently dropped. It refuses outright if the design has no exportable body at all.
Insert from Library… / Insert Component from File… / Update from LibraryThe insertion commands — see §7.2.1 and §7.2.2.
Reveal Libraries Folder in FinderCreates ~/Documents/FlywheelCAD/Libraries/ if it isn't there yet and opens it in Finder — the folder you drop a downloaded or git cloned library into (§7.2.1).
Export Selected Body…Marks the selected body as assembly-visible, i.e. writes a cad.component_export(...) line. It prompts for a friendly label, pre-filled with the body's own variable name; leaving it unchanged just exports the body under that name. Only available while a component is open for editing.
Export Selected Point as Anchor…Promotes the selected point to a named anchor — a cad.component_export_point("<name>", ...) line — so every instance exposes it as <instance>.<name> to mate against. It needs exactly one body topology point selected (a point that already has a script name of its own, like body1_v0); a raw sketch point or a multi-selection leaves the command disabled. Only available while a component is open for editing.
Mate Points…The mate builder — see §7.4.3.
Component edit mode

The two Export Selected… commands only make sense while you are inside a component, so the menu tells you when that is: while a component is open for editing, the Component menu shows a non-clickable Editing “<name>” line at the top. If those two commands are greyed out, check that line first — the usual reason is that you are looking at the assembly, not the component.

7.3 Moving & placing components

There are three ways to position a part, in increasing order of "let the software figure it out": a fixed placement transform baked into the instance, standalone body transforms, and mates that position a part relative to others (§7.4). FlywheelCAD is script-backed — there is no free-drag of an instance in the viewport; positioning is expressed as script lines (which the mate tools write for you).

7.3.1 Placement transform on the instance

Any combination of scale / rotate (axis+angle, optional center) / mirror_plane / translate may be passed to cad.instance together. They compose in a fixed order — scale first, translate last (scale → rotate → mirror → translate), regardless of the order you write the keywords.

KeywordMeaning
translate=(dx,dy,dz)Move the placed part.
axis=(x,y,z), angle=degRotate about axis by angle degrees; optional center=(x,y,z) (defaults to the origin).
mirror_plane="zx"Reflect across a plane.
scale=2Uniform scale factor.
export=FalseExclude this instance (and all its parts) from mesh export (§8.7). This is persisted; a later cad.set_export(...) on a specific part still wins by script order. Must be a real bool.
name="…"Fix the instance's script name explicitly instead of letting it be inferred or auto-generated (§2.2).
Per-instance quality is accepted, then dropped — loudly

cad.instance(...) does take a quality= keyword, but it does not do what it looks like it does. The call does not fail; it prints a warning to stderr and then ignores the value, because a per-instance quality has no persisted home on the single cad.instance(...) line and would therefore vanish the next time the document was opened. A placed part inherits its mesh quality from its component definition, so set quality on the bodies inside the component instead. The warning reads:

FlywheelCAD: cad.instance(..., quality='ultra') ignored — per-instance mesh
quality is not persisted. A placed part inherits its quality from the component
definition; set quality inside the component instead.

The Body Inspector refuses the same edit on an instance part for the same reason (§8.6). This is a deliberate "no silent loss" guard, not a bug — but it does mean a quality= on an instance line is dead weight, and worth deleting when you find one.

Placement blocks solved mates

An instance placed with a rotate, mirror, or non-unit scale cannot also carry a solved cad.mate (§7.4.5). Use a snap mate, or drop the placement and let the mate do the positioning.

7.3.2 Body transforms

These operate on any body (including an instance ref) and each returns a new body — the source stays, so they duplicate-and-transform rather than move in place. Every one that returns a body also accepts quality=, export= and name=; cad.delete_body takes none of them. This table is the same one as §5.7, repeated here because placing a part is where you reach for it most; §5.7 carries the fuller notes.

CallEffect
cad.move(body, translate=None, quality=None, export=None, name=None)Translate by the (x, y, z) tuple translate. Keyword-only — the positional and dx=/dy=/dz= forms are refused (§5.7).
cad.rotate(body, axis=(0,0,1), angle=0, center=(0,0,0), quality=None, export=None, name=None)Rotate angle° about axis through center.
cad.scale(body, factor=None, sx=None, sy=None, sz=None, quality=None, export=None, name=None)Uniform factor, or per-axis sx/sy/sz.
cad.mirror_body(body, plane="yz", quality=None, export=None, name=None)Reflect across a sketch plane (xy/yz/zx or a custom plane).
cad.copy(body, quality=None, export=None, name=None)Duplicate in place (identity transform).
cad.pattern_linear(body, direction=(1,0,0), count=2, spacing=10, quality=None, export=None, name=None)count - 1 copies along a direction; count includes the seed (§5.8).
cad.pattern_circular(body, axis=(0,0,1), count=4, angle=360, center=(0,0,0), quality=None, export=None, name=None)count - 1 copies around an axis; angle is the total sweep (§5.8).
cad.delete_body(body)Delete a body. Booleans/offsets that consume it are deleted too.
Placement transform vs. body transform

Use the cad.instance(..., translate=…, angle=…) placement when you want a part to arrive already positioned. Use cad.move/cad.rotate when you want a transformed copy of an existing body while keeping the original (e.g. a mirrored twin, an offset duplicate).

7.3.3 Placement vs. mates: which to use

7.4 Mates

A mate positions one instance relative to points in the assembly, and is re-evaluated on every run so the instance follows its target. There are three flavors — two closed-form "snaps" and one solved pose.

7.4.1 Anchors & targets

Mates connect an instance's anchor (something the component exported) to a target (any point in the assembly). Valid targets include:

An instance ref exposes its exported anchors as attributes: if b1 = cad.instance("bracket") and the component did cad.component_export_point("hole", …), then b1.hole is that anchor in assembly space. Multi-body export parts surface as <instance>_<name>; exported points surface as <instance>.<name>.

7.4.2 The four mate types

cad.mate_coincident(instance, anchor, target, offset=None) — closed-form snap

Moves instance so its exported anchor coincides with target, plus an optional (dx,dy,dz) offset. Position only; orientation is untouched.

cad.mate_coincident(b2, "hole", b1.hole, offset=(0, 0, 5))

cad.mate_align(instance, anchor1, anchor2, target1, target2, offset=None) — snap + orient

Positions and orients: anchor1 coincides with target1 (+ optional offset), and the direction anchor1→anchor2 is rotated to become parallel to target1→target2. The instance moves; the two targets do not.

# b2's "a1" snaps onto t1 (+offset); the a1->a2 direction on b2
# is rotated parallel to the t1->t2 direction.
cad.mate_align(b2, "a1", "a2", t1, t2, offset=(0, 0, 0))

cad.mate(instance, anchor, target) — solved 6-DOF pose

A constraint the assembly solver satisfies. Unlike the snaps (which each fire once, in isolation), several cad.mate(...) on one instance are solved together as a rigid 6-DOF pose — e.g. three point mates fully fix both position and orientation. There is no offset= (the snaps have one).

cad.mate(b2, "p0", target0)
cad.mate(b2, "p1", target1)
cad.mate(b2, "p2", target2)   # three points -> full pose

cad.mate_axis(instance, anchor1, anchor2, target1, target2) — solved collinear axes

The second solved kind, and the only mate that deliberately leaves the part loose: it holds the instance axis anchor1→anchor2 collinear with the assembly axis target1→target2 and does nothing else — the anchors are never dropped onto the targets, so the part can still slide along that shared axis and spin about it. That is the shape a rail, a shaft or a bolt wants. It drives the same 6 DOFs as cad.mate, so the two solve together; there is no offset=.

# b2's a1->a2 axis is held collinear with the t1->t2 axis.
cad.mate_axis(b2, "a1", "a2", t1, t2)
# optional: pin the slide too, with a point ON that axis.
cad.mate(b2, "a1", point_on_axis)
Choosing a mate

Snap (mate_coincident / mate_align) = a direct, closed-form move you can reason about exactly, great for "put this hole on that hole." Solved (cad.mate, cad.mate_axis) = let several constraints jointly determine a rigid pose, great for fully locating a part from three known point correspondences — or, with mate_axis, for locating it only as far as you actually want to.

A ring of instances that follows its source

There is no "pattern instances" command, and none is needed. A mate reads its target's position live — the solver moves the instance onto the target, never the target onto the instance — so if the targets are themselves constraint-linked sketch points, the whole ring becomes parametric:

step = cad.variable(90, driving=True)
hub = cad.point2d(0, 0)
seat0 = cad.point2d(40, 0, name="seat0")
l0 = cad.line2d(hub, seat0, construction=True)
for k, angle in enumerate(ring(4)):
    if k == 0:
        continue
    sk = cad.point2d(*polar(40, angle), name="seat%d" % k)
    lk = cad.line2d(hub, sk, construction=True)
    cad.equal_lines(line1=l0, line2=lk)
    cad.angle(line1=l0, line2=lk, angle=step * k)

for k in range(4):
    inst = cad.instance(peg)
    cad.mate(inst, "base", "seat%d" % k)

Drag seat0 and every part follows. Edit step in the variables panel and the ring re-fans — parts included — with no re-run. The first half is the constraint-linked sketch pattern described under "Repeating sketch geometry" above; this just points mates at it.

One mate pins position, not orientation. A coincident mate is three residuals against the instance's six placement DOFs, so the copies all keep the seed's orientation — a ring of parts that all face the same way. If you want them to turn with the ring, export a second anchor and use cad.mate_axis.

mate_axis leaves two DOFs open

Where the part sits along the shared axis, and how it is rolled about it, are decided by the solver's minimum-perturbation bias rather than by you. It is stable for a given script, but if the position matters, pin it with a cad.mate onto a point that lies on the axis.

7.4.3 Mating from the 3D view GUI

You don't have to hand-write a mate. ⌘-click the points involved in the 3D view (a long-press on the model opens a "Points" popover for points hidden behind geometry, so you're never stuck), then click the link icon in the 3D toolbar — or use Component ▸ Mate Points, which does the same. A confirmation dialog shows the mate in plain English and the exact script line it will append; Mate appends it and re-runs, Cancel leaves the script untouched.

Which mate you get depends on how many points you selected:

Points selectedMate producedRules
2 points snap — mate_coincident Exactly one of the two must be a component-instance anchor; that anchor moves onto the other point (another anchor, a body-topology point, or a sketch point).
4 points snap + orient — mate_align, or solved collinear axes — mate_axis Select two anchors on the same instance, then two other points, in order: anchor1, anchor2, target1, target2. An Align / Axis control at the top of the dialog picks between the two, defaulting to Align: Align moves and rotates the instance so anchor1 lands on target1 and anchor1→anchor2 becomes parallel to target1→target2; Axis instead appends a persisted cad.mate_axis that keeps the two axes collinear and leaves the slide and spin free.
Selection order matters for align

For the 4-point align, order is what the dialog's summary reflects — double-check it there before confirming. Re-selecting a point (⌘-click it off, then on again) moves it to the end of the order. The GUI never sets an offset=; add one by editing the appended mate_align line.

7.4.4 Targets are references the solver never moves

A mate's target is a reference the solver never moves: the instance is pulled onto the target, never the target onto the instance. A free cad.point2d used as a target stays exactly where your sketch put it. This is what makes mate chains predictable — but also why chaining solved mates is restricted (below).

7.4.5 Mates the app refuses

A few combinations cannot be made to work — they look like they build but land the part in the wrong place, sometimes by tens of millimetres. FlywheelCAD refuses them identically whether you build them in the viewport or type them: the mate dialog disables Mate and shows the reason, and a script containing the same shape fails the run with that same reason rather than rendering something wrong.

Refused shapeWhy it's wrong
cad.mate on an instance placed with rotate / mirror / non-unit scale The solve rebuilds a pure rigid R|t with rotation seeded at identity, so the placement is silently discarded.
cad.mate and mate_coincident/mate_align on the same instance The solve re-poses the instance after the snap ran, silently discarding the snap.
cad.mate onto an anchor of an instance that is itself solved-mated (a chain, either order) The mate captures the target's pre-solve position and keeps it forever — the part lands where its target used to be.
mate_coincident/mate_align onto an anchor of a solved-mated instance The snap runs against the target's pre-solve pose, and the solve then moves the target away.
cad.mate onto an instance's own anchor Degenerate: it chases a target driven by the very DOFs it drives.
cad.mate inside a with cad.component(...) block Solved mates are assembly-scoped: the solve only sweeps top-level instances, so it would never solve. Use a snap mate inside the component, or mate the component's instance in the assembly.
cad.mate_axis whose two anchors are the same component-space point A zero-length axis has no direction to hold. (Every row above binds cad.mate_axis too — it is a solved mate.)

Warned, not refused: if a cad.mate_axis's two targets collapse onto one point — something an edit elsewhere can cause — the run names that mate, leaves it out of the solve for that run, and builds everything else. Move a target apart and the next run picks it back up.

Always fine: mating onto an anchor of an instance that has no solved mate — that is what most assemblies do (cad.mate_coincident(arm, "hub", servo_inst.spline_tip)). Two identical cad.mate lines are a warning, not an error: the duplicate is dropped before the solve and the geometry is unaffected.

Opening a document that contains a refused shape

If an existing document contains one of these shapes, opening it shows an empty model plus the reason rather than a wrong-looking assembly. The script itself is untouched — fix the offending line and re-run.

7.5 Worked example: a small assembly

Define a bracket component, place a stepper from the library, and mate the bracket's hole onto the stepper's bolt circle. This is the shape most assemblies take — components defined/inserted first, then instanced, then mated (emit in dependency order).

# 1. A user component with an exported anchor
with cad.component("bracket"):
    cad.with_sketch("xy")
    # ... sketch a plate + hole, build `plate` ...
    cad.component_export(plate)
    cad.component_export_point("hole_0", cad.point(10, 0, 0))

# 2. A catalog part from the standard library (what Insert from Library writes)
from lib.standard.steppers import stepper
motor = stepper(cad, size=17)
motor_inst = cad.instance(motor)                 # anchors: mount_face, bolt_0..3, shaft_tip...

# 3. Place the bracket and snap its hole onto the motor's first bolt
bracket_1 = cad.instance("bracket")
cad.mate_coincident(bracket_1, "hole_0", motor_inst.bolt_0, offset=(0, 0, 2))

To also orient the bracket, give it a second anchor and use a 4-point align (or select the four points in the viewport and click the link icon):

cad.mate_align(bracket_1, "hole_0", "edge", motor_inst.bolt_0, motor_inst.bolt_1)

8. Appearance, Quality & Export

This chapter covers how bodies look (color and finish), how visible they are in the 3D view, how finely they mesh — both as a document-wide default and per body — how the app tells you when a mesh came out coarser than requested, and how bodies leave the app as a 3MF, STL, OBJ or glTF file.

8.1 Colors & finishes

cad.set_color(body, color, finish=None) sets a body's display color and optional finish. It is display-only — it never affects geometry. Every body op also returns a chainable ref, so body.set_color(...) works the same way:

cad.set_color(body, "#C62828", finish="glossy")    # hex
cad.set_color(body, "red")                          # named color (defaults to glossy)
cad.extrude("xy", r, 10).set_color((0.2, 0.4, 1.0), finish="metallic")  # chainable, RGB tuple
Color formNotes
"#RRGGBB"Hex.
"#RRGGBBAA"Hex with alpha; alpha is translucency.
"#RGB"Short hex.
"red", "steel", "gold", …Named color.
(r, g, b) / (r, g, b, a)Tuple. Channels may be 0–1 floats or 0–255 ints — inferred from the max value in the tuple.

finish is one of "matte", "glossy", "metallic", "glass" (translucent); it's optional and defaults to glossy when omitted.

Components and export

A color set inside a component propagates to every instance of it. Colors are written into the 3MF export as base-material colors, so what you set here is what a downstream 3MF consumer sees too.

8.2 Body visibility

cad.hide(*bodies) and cad.show(*bodies) hide or reveal one or more bodies in the 3D view. Each argument may be a BodyRef/Ref or a body-name string (e.g. a component instance part that has no Python variable). Visibility persists across save/reopen, and a hidden body is not exported until it is shown again (§8.7). At least one body is required — calling either with no arguments is an error.

cutter = cad.bool_difference(block, tool)
cad.hide(scaffold_a, "tail_i_fin")
cad.show(scaffold_a)                         # reveal it again; a later line wins
Explicit hide only

A boolean automatically hides the operands it consumes — those do not need, and must not get, their own cad.hide(...) line. Placed after a boolean line, cad.show(operand) re-reveals a consumed operand; its later position in the script wins.

8.3 Document mesh quality

cad.set_quality(quality) sets the running document's default mesh-quality tier — the fallback for any body that doesn't pass its own quality= argument. It bakes into curve tessellation (sagitta) at the moment each body is created, and into the dual-contouring cell size at mesh time.

Each tier is really two numbers: the cell size it aims for (how fine a dual-contouring lattice it would like) and the cell budget, a cap on how many cells it may use along any one axis. Whichever of the two binds first wins — so on a small body the cell size decides, and on a large one the budget does, which is why a big part can look coarse at a tier that renders a small part beautifully.

TierCell sizeCell budget (per axis)Speed / detail
"preview"2.0 mm64Fast, coarse — use while iterating.
"standard"0.5 mm128Default working quality — what a document uses when nothing sets anything else.
"high"0.25 mm200Finer.
"ultra"0.1 mm256Slow, finest of the four everyday tiers.
"precision"0.1 mm floorup to 1289 (requested)"Precision" in the UI — for the rare large body whose fine detail survives no other tier. Precision bodies mesh one at a time; prefer a per-body quality="precision" over setting it document-wide (see below).
Precision is a per-body tool, not a default

"precision" is accepted (per-body and in cad.set_quality) since 2026-07-29 — earlier builds refuse a document that sets it. Precision bodies mesh one at a time, so applying it document-wide to a substantial model means tens of minutes of meshing. Put quality="precision" on the one body that needs it instead. In the GUI, Precision is offered document-wide in View ▸ Mesh Quality and the status bar's Mesh quality menu, and per-body in the Body Inspector's Quality picker (§8.6).

What precision's two numbers actually mean

Precision's 0.1 mm is a floor, not a target: it is the finest cell the tier will ever ask for, and the budget only ever makes cells coarser than that, never finer. One consequence is worth internalising because it saves a lot of confused benchmarking: precision is identical to ultra for any body small enough that ultra already reaches 0.1 mm. The two tiers share the same floor. Precision differs only where the budget binds — that is, on large parts — which is exactly the case it exists for. If a 20 mm bracket looks the same at Ultra and Precision, nothing is broken; that is the design.

The floor itself is adjustable in Settings ▸ General ▸ Precision cell size (§1.7), and because it is a floor, moving it only affects parts small enough to reach it — the setting's own help text puts that boundary at roughly 13 cm. It is a per-Mac preference and is not saved with the document; cad.set_cell_size (§8.4) is the portable equivalent.

Precision's 1289 is a request and a ceiling, not a promise. A 1289³ lattice is an enormous amount of memory, and whether a given machine can serve it depends on the machine and on the body. So a precision body big enough for the budget to matter is measured first: the app searches for the largest lattice, up to 1289, whose predicted memory this device can actually serve for that body, and meshes at that. On a smaller machine a big body therefore commonly gets a smaller-but-fitting budget rather than the full 1289 — and that is a deliberate, chosen outcome, not a failure. If even the fitted budget turns out not to fit, the older behaviour still applies underneath: the quality ladder steps the body down a tier (precision → ultra → high → …) until it does.

In every one of those cases the body is geometrically correct — it is a correct mesh at a coarser resolution, never a wrong shape. What changes is only how much fine detail survives. When the resolution the body actually got differs from what you asked for, the app tells you rather than leaving you to guess; that is what §8.5 is about.

Why the picker sometimes seems to do nothing

A per-body quality= wins over the document picker, by design — that is the whole point of the per-body override, and it is how the precision tier is meant to be used. What used to be confusing was that nothing said so: you would select Ultra document-wide, nothing would change, and the picker looked dead. The status bar's Mesh quality menu now carries a disabled footnote row for exactly this — “N bodies use their own quality — unaffected by this picker” — and its tooltip repeats the count. The Body Inspector says the same thing per body: a pinned body reads “Pinned to Ultra for this body — the document picker (currently High) does not apply”, and an unpinned one reads “Inheriting the document default (currently High)”.

The footnote lives only on the status-bar picker. View ▸ Mesh Quality deliberately carries no such row, so if you change quality from the View menu, the skip is invisible — when a document-wide change appears to have no effect, check the status-bar picker (or the body's own Inspector) before assuming something is wrong.

from flywheelcad import *
cad = FlywheelCAD()
cad.set_quality("high")   # must come before the first body-creating command

body1 = cad.extrude("xy", region, 10)                 # meshes (and tessellates) at "high"
body2 = cad.extrude("xy", region2, 5, quality="ultra") # per-body override still wins
Ordering is enforced, not a suggestion

cad.set_quality(...) must appear before the first body-creating command anywhere in the script — extrude, revolve, loft, multi_loft, sweep, bool_union/bool_difference/bool_intersection, offset, move/rotate/scale/mirror_body/copy, instance, text — including one created inside a with cad.component(...) block. A component that is never instanced builds straight into the document, and even an instanced component's sub-design is built with whatever quality is active at the moment its block opens; a set_quality(...) line after the block can never reach it. Put the call before any with cad.component(...) block if you want it applied to that component's bodies. A script may call it at most once, and never inside a component block — that is refused with a "document-scoped setting" error, since components inherit the assembly's quality automatically. An unrecognized value is a hard error, never a silent fallback. A headless render's --quality flag overrides this document setting.

Imported modules never leak their own setting

cad.set_quality(...) only takes effect when it runs as the document itself — i.e. the file is the one actually being executed (__name__ == "__main__"), same as running that file directly. A set_quality(...) line inside a component module has no effect when that module is imported by an assembly: it is silently ignored, with a warning printed to stderr, because every FlywheelCAD() in one process shares one command stream — a naive component-side call would otherwise leak into whichever document imports it. Write cad.set_quality(...) in the assembly, not in a component file meant to be imported; the importing document's own quality already reaches the component's bodies automatically. The component's own set_quality(...) still applies when that file is opened and run standalone.

8.3b Document presentation unit

cad.set_units(units) sets the running document's presentation unit — "mm", "cm", or "in" — for GUI entry/readout conversion and 3MF export only.

Geometry never changes

A document set to "in" still authors cad.extrude("xy", region, 10) for a 10 MM extrude. Your script's own numeric literals, the solver, and every cache stay millimetres no matter what unit the document is set to — this is a presentation choice, not a unit conversion of the model. What changes: dimension sheets and the Body Inspector show and accept lengths in the document's unit (fields are labelled, e.g. "Distance (in)"), readouts carry an explicit unit suffix ("2 in"), and .3mf export emits the matching unit="…" attribute with every vertex converted. Angle fields (dimension angles, the revolve angle) are never converted — degrees are not a length. The variables panel and expression fields stay bare millimetres too, since they are script entities, not a display surface.

cad.set_units("in")                       # no ordering requirement — may appear anywhere
p1 = cad.point2d(0, 0)
c1 = cad.circle2d(p1, 12.5)               # still 12.5 MM, not 12.5 inches
body1 = cad.extrude("xy", region, 10)     # still a 10mm extrude
No ordering rule — unlike quality

Nothing reads the document's unit while a command executes, so cad.set_units(...) may appear before or after any body-creating command — there is no "before the first body" rule the way cad.set_quality(...) has. A script may still call it at most once, and it is not allowed inside a with cad.component(...) block — components inherit the assembly's unit automatically. An unrecognized value is a hard error, never a silent fallback to "mm".

It is a document setting, not a component one, with the same __name__ == "__main__" behaviour as cad.set_quality(...): a set_units(...) line inside a component module has no effect when that module is imported by an assembly (silently ignored, with a stderr warning); the component's own line still applies when that file is opened and run standalone. The View ▸ Document Units menu logs (or updates) this line and applies it, the same way the Mesh Quality menu does, so the choice is part of the document and survives save/reopen. STL and OBJ export always stay millimetres, regardless of this setting — the slicer ecosystem's own convention.

8.3c Document default cell size

cad.set_default_cell_size(cell_size) sets the dual-contouring cell size, in millimetres, for every body that does not set its own — the document-wide sibling of the per-body cad.set_cell_size(body, cell_size) in §8.4 below.

cad.set_default_cell_size(0.2)            # must come before the first body
p1 = cad.point2d(0, 0)
c1 = cad.circle2d(p1, 12.5)
body1 = cad.extrude("xy", cad.region(loop=[c1], inside=(0, 0)), 10)

Which cell size a body actually gets, most specific first: its own cad.set_cell_size(...), then this document default, then the Precision floor in Settings (Precision quality only), then the quality tier’s own cell size.

A whole-document commitment

Asking for a cell size also raises that body’s cell budget, exactly as a per-body set_cell_size does — without that, the tier’s volume budget would simply coarsen a fine value straight back and nothing would change. So a fine default costs memory and time on every body, not just the one you had in mind; it is the same class of commitment as setting the whole document to Ultra. The console warns, per body, where the budget still clamps the request. If only one body needs a finer cell, set it on that body instead.

Ordering matters here, unlike the document unit: a swept body bakes its path density from this value the moment the sweep command runs, so the line must appear before the first body-creating command — the same rule cad.set_quality(...) has, for the same reason. A script may call it at most once, and it is not allowed inside a with cad.component(...) block (components inherit the assembly’s default automatically). A value below 1e-6 mm — or zero, or negative — is refused outright, never silently clamped, and the message is the same one the View ▸ Default Cell Size… sheet shows. Leaving the line out means “let the quality tier decide”; a headless render’s --quality flag overrides it exactly as it overrides cad.set_quality(...). The View ▸ Default Cell Size… menu logs (or updates) this line — or removes it, with Clear — and re-runs the script, so the choice is part of the document and survives save/reopen.

8.4 Per-body resolution

Sampled meshing (booleans, offsets, lofts, rounded/drafted extrudes) uses a quality tier whose cell budget is relative to the body's size — a large body gets large cells, so small features (grooves, pockets, thin rods) can lose detail even at quality="ultra". cad.set_cell_size(body, cell_size) gives such a body an absolute dual-contouring cell size in model units instead, overriding the quality tier's relative budget. It's chainable as body.set_cell_size(cell_size).

wing = cad.bool_difference(skin, cutter)
cad.set_cell_size(wing, 0.8)      # 0.8-unit cells regardless of body size
wing.set_cell_size(0.8)           # chainable form

It applies to sampled (DC) meshing only — exactly-meshed plain extrudes/revolves ignore it, since they're already exact. The value is clamped to a hard total-cell cap; the console warns when the clamp engages. There is also a hard minimum: a cell size below 1e-6 mm (one nanometre) is refused outright rather than accepted, because at that scale it is always a typo'd exponent or a unit mix-up rather than modelling intent. The same floor is enforced by the Body Inspector's Cell size field (§8.6), so the GUI can never author a line the next run would reject.

A body may carry both a quality tier and a cell size. The explicit cell size wins for the cell size, and the total-cell budget becomes the larger of the two — setting a cell size never lowers the ceiling the quality tier already granted. The one exception is the adaptive-boolean path (chapter 5.5): a sharp boolean over a blended or offset operand has its own fixed 192-cells/axis cap that neither quality= nor set_cell_size can raise. When that cap is what decided a body's resolution, the app says so on the body itself — see §8.5.

Unlike the Precision cell floor in Settings (§1.7), which is a per-Mac preference, cad.set_cell_size is a line in your script: it travels with the file and produces the same resolution on any machine that can afford it. When a particular body must be meshed at a particular resolution everywhere, this is the call to reach for.

8.5 Mesh outcome notices — when a body meshes coarser than you asked

"The body is on screen" and "the body is what you asked for" are two different claims, and for a long time only the first was visible. A body that quietly meshed at a lower resolution than its tier requested looks, from the outside, exactly like a modelling mistake: an edge that should be crisp is soft, a groove you know is there doesn't show. Mesh outcome notices exist so you can tell those two situations apart at a glance. A body that meshed below its request is not a modelling error and usually not something you can fix by re-drawing — it is a device or route limit, and this is how you find out that's what happened.

Where the notices appear

SurfaceWhat it shows
Status-bar chipA self-hiding orange down-arrow item reading “N bodies meshed below requested” (“1 body …” in the singular). Its tooltip names up to five of the affected bodies and adds “…and N more” beyond that. It is a document-level honesty surface, not a button — there is no action on it that the sidebar and Inspector don't already offer. It shows in both 3D and sketch mode, because a downgraded body stays downgraded while you're editing a sketch. When nothing is affected the chip is simply absent, so it costs no chrome on a healthy document.
Sidebar badgeA small orange down-arrow on the affected row in the sidebar's Bodies list (and on instance-part rows), with the full notice text as its tooltip. This is what turns the chip's count into a location. It is a deliberately different glyph from the yellow warning triangle that flags a suspicious mesh topology, so the two are never confused at a glance: the triangle says "this mesh may be malformed", the down-arrow says "this mesh is fine but coarser than requested".
Properties InspectorA Mesh row in the floating read-only card, carrying the full sentence. It is orange for the two degraded outcomes and plain secondary text for the informational one. The same sentence also appears under the Quality picker in the editable Body Inspector (§8.6), which is deliberate — the person choosing a quality is exactly the person who needs to know why the last mesh came out the way it did.

The three outcomes

Three distinct things can happen, and they mean genuinely different things.

1. Quality downgraded. The requested tier could not be served on this device at all, so the body stepped down the ladder (precision → ultra → high → standard → preview) until one fit. The notice reads:

Meshed at ultra instead of precision: this body needs more memory
than is available at precision on this device.

This is badged as degraded — it shows in the chip, on the sidebar row, and in orange in the Inspector. The body is correct; it is coarser than you asked. The practical responses are to reduce what you're asking for (a lower tier costs less and may be indistinguishable at the size that matters), to split a very large body, or to accept it.

2. Adaptive resolution capped. This one is worth reading carefully, because it is the one where continuing to turn the dial genuinely will not help. A sharp bool_difference/bool_intersection whose operand is itself a blend="smooth"/"chamfer" result — or an offset — routes through a CPU-only adaptive boolean mesher (§5.5) whose base grid is fixed at 192 cells per axis, no matter what tier or cell size you name. When that fixed grid materially undershoots what you asked for, the notice reads:

Meshed by the adaptive boolean mesher at ~41% of the requested resolution
(its base grid is capped); once capped, raising quality or set_cell_size
does not refine this body further.

The percentage is that body's actual linear cell count against its requested one. The last clause is the actionable part: once this notice fires, quality="ultra", quality="precision" and cad.set_cell_size(...) are all no-ops for this body. Turning them up costs you meshing time and buys nothing. What does change the outcome is changing the shape of the operation, because the cap belongs to the route, not to the body:

This notice is also badged as degraded, since the request could not be met. It carries no "actual tier" figure, because 192 is a raw cell count on the adaptive mesher's own grid and not a quality tier — naming a tier would misdescribe what capped it.

3. Resolution fitted. The body was meshed at the largest cell budget this device can actually serve for it, which was smaller than the tier's own budget — the machine-fitted budget described in §8.3. The notice reads:

Meshed at precision with a 704^3-cell budget instead of 1289^3: that is
the largest lattice this device can serve for this body. The body is correct.

with a distinct wording when the fit was driven all the way down to its own lower clamp and even that is predicted to be tight. This outcome is informational and deliberately not badged: no chip, no sidebar arrow, Inspector text in plain secondary colour rather than orange. The reasoning is that the request was met — at a budget chosen for the machine rather than discovered by failing — and on a memory-constrained Mac it is the routine result of any large precision body. Badging a routine, correct outcome as a failure would train you to ignore the badge, which is precisely what would make the other two useless.

Reading the three at a glance

Chip or orange arrow present ⇒ the request could not be met (outcome 1 or 2). No badge but a grey Mesh row in the Inspector ⇒ the request was met, at a budget fitted to your machine (outcome 3). And in all three cases the geometry is correct — the only thing at stake is how much fine detail the triangles preserve.

8.6 The Body Inspector GUI

The floating Properties Inspector card (§1.3) is read-only. To change a body's per-body settings from the GUI, select exactly one body and use Body ▸ Inspect / Edit…, which opens a separate modal sheet. What it offers depends on the body:

All body types then get the same three controls:

ControlNotes
QualityA picker offering Default (global) plus every tier including Precision — per-body is the supported way to reach an expensive tier. Underneath it, a caption states which way this body currently resolves: “Pinned to Ultra for this body — the document picker (currently High) does not apply”, or “Inheriting the document default (currently High)”. When the last mesh produced an outcome notice (§8.5), its sentence appears here too.
Cell sizeThe per-body absolute cell size of §8.4. Leave it blank to use the tier's own cell — the placeholder shows what blank actually means for this body's effective quality (“Default (0.1)” for an Ultra body, “Default (0.5)” for a Standard one), so it never misstates the inherited value. Non-numeric, zero, negative and sub-nanometre entries are rejected in place with a message rather than accepted.
Include in exportThe same flag as cad.set_export / export= (§8.7). Disabled while the body is hidden — a hidden body is not exported at all, and the tooltip says so; show the body to make the flag apply.

Cancel closes without touching anything. Save applies the edit — which, because a FlywheelCAD document is its script, means editing the script line that created the body.

Save refuses loudly rather than editing the wrong line

Saving is an all-or-nothing operation: if any part of it cannot be done safely, nothing is changed — not the script, not the model, not even the settings that would have been fine. The sheet stays open and shows the reason, so the edit is never silently lost. The refusals you can actually hit:

All of this is deliberate. The alternative to refusing is guessing which line to rewrite, and a wrong guess silently rewrites geometry you didn't ask it to touch. A refusal you can read and act on is strictly better than a plausible-looking edit in the wrong place.

8.7 Mesh export: 3MF, STL, OBJ & glTF

Four mesh formats — glTF in both of its containers — ship on the Body menu, all fed by the same set of bodies and the same save-panel flow:

CommandFormatWhat it carries
Body ▸ Export to 3MF… (⇧⌘E).3mfThe richest of the print formats. A ZIP archive of XML, written in millimetres, with one named object per body and each distinct display colour written into a shared <basematerials> group that objects reference — so a downstream 3MF consumer sees the colours you set with cad.set_color (§8.1), including translucency. This is the format to use when body identity and colour matter, e.g. multi-material printing.
Body ▸ Export to STL….stlBinary STL — geometry only, by design. There is no colour, no material, and no per-body identity: the format has no per-solid boundary marker, so several bodies are concatenated into a single triangle soup. Choose it for a plain single-material print or for a tool that only understands STL, and don't expect the colours or body names to survive.
Body ▸ Export to OBJ….obj (+ .mtl)Wavefront OBJ text. It keeps bodies separate as one o <name> group each, and when any exported body carries a colour it also writes a sibling .mtl file next to the .obj holding one material per distinct colour (diffuse Kd, plus d for translucency), with uncoloured bodies given an explicit neutral material of their own. Vertices and normals are written; there are no texture coordinates.
Body ▸ Export to GLB….glbglTF 2.0 in its single-file binary container — the web, AR and game-engine interchange format. Written in metres (millimetres × 0.001) and +Y-up, the glTF convention, whatever the document unit; the app's Z-up frame is rotated on the way out so an upright body stands upright in a glTF viewer. One named mesh and node per body, the mesher's own smooth normals, and — uniquely among these formats — a PBR material per body that carries both the colour and the finish set with cad.set_color: metallic and roughness factors per finish, and glass (or any translucent colour) as a blended material. Bodies with no colour set get the app's default grey.
Body ▸ Export to glTF….gltf (+ .bin)The same glTF 2.0 content as a readable JSON file plus a binary .bin sidecar written next to it. Choose it when a pipeline wants to inspect or edit the scene description; choose .glb to hand someone one file.

3MF, STL and OBJ do not carry the finish ("matte"/"glossy"/"metallic"/"glass") — for them it is a viewport shading property, and translucency reaches 3MF and OBJ only because it rides on the colour's alpha channel. glTF carries it: the finish becomes the material's metallic and roughness factors, and a translucent colour becomes a blended material.

All of them export the selected bodies when there is a selection in the 3D view, and every exportable body otherwise — the same rule the headless CLI follows. The two halves of that rule behave differently, and it is worth knowing which you are in: with no selection, visibility and the export flag both decide — the flag picks among the visible bodies, so a body with export off is left out, and a hidden body is left out whatever its flag says (§8.2), which is how a boolean's consumed operands drop out of the file on their own; with an explicit selection the app ships exactly the bodies you picked, hidden ones included, which is what makes "select this one part and export just it" work on a part that is normally excluded. If any exported body's mesh looks topologically suspicious, or its geometry no longer matches the script, a warning alert naming up to five of them (plus a count of the rest) appears first — and whenever that alert appears, its first line also says how many hidden bodies the file leaves out; dismissing it takes you to the save panel, where you can still back out. A hidden count on its own raises no alert: on a model built from booleans that would be a modal before every save. Exporting with nothing to write says so plainly rather than doing nothing, and distinguishes the two reasons — nothing built yet, or everything built is hidden.

Choosing what gets exported

cad.set_export(body, value=True) sets whether a body is included in the export. body may be a BodyRef/Ref or a body-name string (e.g. a component instance part whose name has no Python variable), and it's chainable as body.set_export(value). Despite the historical name, the flag governs every mesh format, not just 3MF — a body excluded here is absent from the STL, the OBJ and the glTF too, whenever you export without a selection.

cad.set_export(part, False)
part.set_export(False)          # chainable form
value must be a real bool

value must be an actual True or False. A string ("true", "false", "no") or a number (0, 1) raises ValueError naming the offending argument — there is no coercion, and no truthiness test. That is a deliberate fail-loud policy rather than strictness for its own sake: under coercion, export="no" reads as truthy and silently includes the body you were trying to exclude, while set_export(b, 1) once read as False and excluded one you wanted. Both are silent, plausible, and only discovered when the exported file is wrong. An immediate ValueError costs one edit; a silently wrong export costs a print. The same rule applies to the export= keyword on every body producer — with one difference: export='s declared default is None, meaning "absent, inherit the document default", so None is accepted there and only there.

Export can also be set at creation time: most body ops take an export=False keyword (excludes that body), and cad.instance(component, export=False) excludes the whole instance — and all of its parts — at once. In the GUI, the Include in export toggle in the Body Inspector (§8.6) sets the same flag for one body.

Later call wins, by script order

These are all the same underlying flag, set at different points in the script, so whichever call runs last for a given body decides its export state. A cad.set_export(part, True) line after cad.instance("jig", export=False) re-exports that one part even though the instance itself was created with export off.

Two deliberate policies worth knowing: a hidden body is not exported — cad.hide(...) (or the eye control) takes it out of the file whatever its export flag says, and the flag applies again the moment cad.show(...) brings the body back, which is how a boolean's consumed operands leave the export with no set_export line of their own (exporting a selection is the exception: it ships exactly the bodies you selected, hidden ones included); and a body with export off still renders normally in the 3D view, with no warning badge — the flag affects only what lands in the exported file.

9. Section & Projection

Section and projection derive 2D geometry back out of a 3D body — a cross-section curve or a projected outline — drawn onto a sketch plane for reference or as the basis of a drawing view.

9.1 Cross-sections

cad.section(body, plane) cuts body at plane and draws the resulting curve onto that sketch plane, returning a Ref.

s = cad.section(box, "yz")   # cross-section curve at a plane

What comes out depends on the body and the cut:

CaseResult
Analytic body (an extrude — including booleans, transforms and component instances of one), cut by a plane parallel to its profileTRUE circles/arcs/lines — exact geometry, not an approximation.
Anything else (non-analytic body, or an analytic body cut at another angle)A sampled closed spline.
Plane misses the body entirelyNo geometry.

9.2 Projections

cad.project(body, plane) projects body's edges onto plane and draws the result onto that sketch plane, returning a Ref.

sp = cad.project(box, "yz")   # silhouette projection onto a plane
CaseResult
Analytic body viewed along its extrusion axisTRUE circles/arcs/lines for every profile edge — holes and internal features included. A drawing-style view, with no hidden-line removal.
Anything elseA sampled silhouette spline.
No hidden-line removal

project draws every profile edge it finds, including ones a real hidden-line drawing view would suppress behind other geometry.

9.3 Projected points

cad.project_point(point, plane) projects a single 3D point onto plane, returning a Ref. point takes anything that resolves to a 3D point — a body's topology vertex (body.v0, see §6.3) or a reference point from cad.point(x, y, z).

pp = cad.project_point(box.v0, "zx")

9.4 Refresh behavior

section, project, and project_point are snapshots, not live geometry: they capture the body as it stood when the line ran, and only update on a full re-run of the script — not incrementally as you edit other parts of the model.

10. Reference & Troubleshooting

A one-stop index of the cad API and the failure modes you'll actually hit — read this chapter when you know roughly what you want and just need the exact call, or when a script won't run and you need to know why.

10.1 API quick reference

All calls are on cad = FlywheelCAD() unless noted. This is an index, not a tutorial — each group cross-references the chapter with the full explanation.

Sketch context & planes

CallPurpose
cad.with_sketch(plane_name)Set the active sketch plane ("xy"/"yz"/"zx" or a custom plane name). All subsequent 2D geometry lands here until you switch. Auto-injects origin_<plane> as a usable point.
cad.origin(plane_name)Get the origin point Ref for a plane — o = cad.origin("xy"). Normally you just use the auto-injected origin_xy / origin_yz / origin_zx / origin_<custom> variable instead.
cad.create_sketch_plane(p1, p2, p3, name=None)Custom plane: origin = p1, normal = (p2-p1) × (p3-p1). Points may be body topology points, sketch points, or cad.point(...) refs.

Elements (geometry creation)

CallPurpose
cad.point(x, y, z, name=None)Fixed 3D reference point (world coordinates) — for custom planes, anchors, sweep paths.
cad.point2d(x, y, name=None)2D point on the current sketch.
cad.line2d(p1, p2, construction=False, name=None)Line between two 2D points.
cad.circle2d(center, radius, construction=False, name=None)Circle from a center point + radius.
cad.ellipse2d(focus1, focus2, point, construction=False, name=None)Ellipse from two foci + a point on the rim.
cad.arc2d(center, start, end, clockwise=False, construction=False, name=None)Arc; endpoints lie on the circle defined by center+start.
cad.spline2d(points, closed=False, construction=False, name=None)Cubic spline through a list of points.
cad.text(string, size=10.0, font="Helvetica", at=(0,0), angle=0.0, direction=None, distance=None, align="left", quality=None, export=None, plane=None, name=None)Render text as sketch geometry (outline mode) or, with distance>0, one extruded body per glyph. size is cap height in mm. plane= builds on that plane without switching the current sketch. Returns one Ref per character position.
cad.fillet(line1, line2, radius, name=None)Round the shared-endpoint corner of two lines/arcs with a tangent arc; both inputs retract, returns the new arc.
cad.trim(element, near=(x, y))Trim in place at the nearest intersection; returns (element, element_1_ref) — the second ref is valid only when a split occurred.
Construction elements

construction=True on any 2D primitive makes it a guide/axis line: rendered dashed, excluded from region detection. Never put a construction element in a region loop.

name= overrides the script name that would otherwise be inferred from the assignment (or auto-generated when there is none) — see §2.2. cad.trim is the one element call without it: its results are named after the element it trims.

Geometric constraints

No return value except where noted.

CallPurpose
cad.horizontal(line) / cad.vertical(line)Pin a line to horizontal/vertical.
cad.parallel(line1, line2)Two lines stay parallel.
cad.perpendicular(line1, line2)Two lines stay perpendicular.
cad.collinear(line1, line2)Two lines stay collinear.
cad.coincident(p0, p1)Merge two points into one (alias for merge_points) — coincidence is realized by shared identity, not a residual constraint.
cad.point_on_line(point, line)Point lies on a line.
cad.point_on_circle(point, circle) / cad.point_on_ellipse(point, ellipse) / cad.point_on_plane(point, plane)Point lies on a circle/ellipse/plane.
cad.fixed(element)Pins a point/line/circle/arc/ellipse/spline's own positional degrees of freedom to its current position. An ordinary constraint, not the sketch origin's hard pin: it counts toward the DOF badge, and deleting the line frees the element again. It does not out-rank a conflicting constraint (the solver splits the difference and the badge reads conflicting). An arc pins its centre and radius, not its endpoints.
cad.concentric(circle1, circle2)Two circles share a center.
cad.equal_lines(line1, line2) / cad.equal_radius(circle1, circle2)Equal-length / equal-radius constraint.
tp = cad.circle_tangent_line(circle, line, anchor=None, name=None)Circle tangent to a line; returns the tangent point ref. name= names that point.
cad.circle_tangent_circle(circle1, circle2)Two circles tangent.
tp = cad.ellipse_tangent_line(ellipse, line, anchor=None, name=None)Ellipse tangent to a line; returns the tangent point ref. name= names that point.
cad.spline_tangent_line(spline, line, control_point=None, control_point_index=None)Spline tangent to a line. control_point= (a point ref) or control_point_index= (an integer) picks which control point the tangency anchors at; omit both to let the app choose.
cad.symmetry_line(...) / cad.symmetry_plane(...)Symmetry constraint about a line/plane.

Dimensions, variables & updates

CallPurpose
d = cad.variable(value=None, fixed=False, driving=False, name=None)Declare a scalar. fixed=True = permanent constant; driving=True = pinned during solves but editable via cad.update/Variables panel; neither = free solved unknown. fixed and driving together is an error.
cad.distance(p0, p1, distance)Pin point-to-point distance.
cad.length(line, length)Pin a line's length.
cad.radius(circle, radius)Pin a circle's radius.
cad.angle(line1, line2, angle=None)Pin the angle between two lines (degrees).
cad.point_line_distance(point, line, distance)Pin perpendicular point-to-line distance.
cad.merge_points(source, target)Weld two points (canonical form; coincident is the constraint-style alias).
cad.mirror_elements(elements, symmetry_line)Mirror a list of sketch elements across a construction line.
cad.update(changes)Push edits, e.g. cad.update({d2: {"value": 45.0}}) or cad.update({p1: {"x": 10.0, "y": 20.0}}); geometry bound to the target re-solves after.
cad.ensure_convergence()Force a solve of the current sketch.

Dimension values accept linear expressions of variables: cad.length(line=l1, length=d1 * 2 + d2).

Regions

CallPurpose
region(loop=[...], holes=[[...]], inside=(x, y), name=None) / cad.region(...)Build a region intent for a body command: a directed outer boundary loop, optional hole loops, and an optional material inside point that disambiguates twin regions tracing the same loop. loop= is required. name= (label only) mints no Ref.
rev(element)Mark a loop element as traversed against its intrinsic direction (serializes as "-name").
ref(name)Build a symbolic Ref by name, for an element name that's valid in command space but not bound to a Python variable.

Loop matching is rotation-invariant and reversal-lenient. Intrinsic directions: lines run p1→p2, circles/arcs/ellipses run counter-clockwise, splines follow control-point order.

Bodies (3D)

CallPurpose
cad.extrude(plane, region, distance, direction=None, quality=None, export=None, edge_radius=None, twist=None, twist_center=None, offset=None, draft=None, name=None)Extrude a region. direction="symmetric" extrudes both ways; draft tapers walls (deg, + = outward); twist rotates the cross-section over the height; offset shifts the body off the plane; edge_radius rounds every sharp edge.
cad.revolve(plane, region, axis_line, angle, quality=None, export=None, edge_radius=None, name=None)Revolve a region about an axis line. angle=360 for a full solid.
cad.loft(start_plane, start_region, end_plane, end_region, quality=None, export=None, edge_radius=None, name=None)Loft between two profiles on two (possibly non-parallel) planes.
cad.multi_loft(planes, regions, quality=None, export=None, edge_radius=None, name=None)One seamless body through ≥2 sections — preferred over chaining 2-section lofts + unions (no internal seams, fewer bodies).
path1 = cad.helix(radius, pitch, turns, axis_origin=(0,0,0), axis_dir=(0,0,1), start_angle=0.0, left_handed=False, name=None)Helical sweep path (a PATH HANDLE, not a body). Coil radius/rise live here — not in the profile.
rail = cad.path(plane, chain, name=None)Open planar path from a line/arc/spline chain (rev(...) for reversed elements); a closed element cannot appear in it.
rail = cad.path3d(points, closed=False, up=None, name=None)3D free-curve path through named 3D points (chordal Catmull-Rom + rotation-minimizing frame). closed=True not yet supported.
cad.sweep(plane=None, region=None, path=None, sections=None, twist=0.0, quality=None, name=None, export=None, edge_radius=None)Sweep a profile (plane+region) or a sections=[sweep_section(...), ...] list (mutually exclusive) along path. Profile (x, y) is used unchanged as the local (u, v) offset from the path — draw it centered at the sketch origin to ride ON the path.
ring(count, step=None, start=0.0)Angles in degrees for count items around a circle — a free function, not a cad. method, and it emits nothing. step defaults to 360 / count, the closed ring (the last item stops one step short of the seed instead of on top of it); pass an explicit step for a partial fan. The parameter is a STEP rather than a total sweep because a constraint-linked pattern drives its ring from a cad.variable holding exactly that step — see the pattern recipe below.
polar(radius, angle, center=(0.0, 0.0))The (x, y) at radius and angle degrees around center. The companion to ring(...): ring gives the angle a constraint is driven to, polar gives the position to draw the copy at. Angles run counter-clockwise from the sketch plane's +u axis — the same convention the solver measures in.
grid(nx, ny, dx, dy, origin=(0.0, 0.0))nx*ny (x, y) offsets for a rectangular grid, row-major with x varying fastest, seed first. Emits nothing.
sweep_section(fraction, plane, region)One keyed cross-section for cad.sweep(sections=[...]) — a free function, not a cad. method. Fractions must be strictly increasing across the list, first entry 0.0, last 1.0. Holes are not supported in this form. Two sections that differ only in size must sit on different planes, or area detection reads the smaller as a hole in the larger instead of as its own profile. (The older bare section(...) spelling now raises a NameError naming this replacement — it collided with the unrelated cad.section(body, plane) method, §9.1.)
Repeating sketch geometry so the copies stay linked

There is no cad. pattern command for sketch geometry, and there does not need to be: a visible loop plus two ordinary constraints per copy makes a repetition that is parametric — move the source and every copy follows in the same solve, and the spacing is an editable dimension. Modify ▸ Pattern… writes exactly the circular form of this block for you from a selection (§3.5); the recipe below is what it emits, and what to write by hand for the row and grid forms it does not cover.

Draw one construction line from the centre to the source and one to each copy, then constrain each copy to the source (never to its predecessor — a chain accumulates error and a ring will not close):

cad.with_sketch("xy")
step = cad.variable(60, driving=True)        # the editable dimension
c  = cad.point2d(0, 0)
p0 = cad.point2d(25, 0)
l0 = cad.line2d(c, p0, construction=True)

for k, angle in enumerate(ring(6)):
    if k == 0:
        continue                              # k = 0 is the source itself
    x, y = polar(25, angle)
    pk = cad.point2d(x, y)
    lk = cad.line2d(c, pk, construction=True)
    cad.equal_lines(line1=l0, line2=lk)                    # same radius as the source
    cad.angle(line1=l0, line2=lk, angle=step * k)          # k steps around from it

Editing step in the variables panel re-fans the whole ring with no re-run, because step * k stays a live expression as long as step is driving=True. Declare it fixed=True instead and the arithmetic is folded to a constant at execution time, so the ring only re-fans on a re-run.

For a row, the construction line runs from the source rather than from a centre — lk = cad.line2d(p0, pk, construction=True), not cad.line2d(c, pk, ...) — and the two constraints become cad.length(line=lk, length=spacing * k) plus cad.angle(line1=l_ref, line2=lk, angle=0) against a reference line. Anchoring the row on anything other than the source is the one change that quietly costs you the property this whole recipe is for: the copies stop following when the source moves. Use a zero angle, not cad.parallel(...) — parallel is direction-ambiguous, so a copy is free to settle on the wrong side of the source. For a grid, nest two of these: row seeds off the source, then column members off their own row seed.

Each copy costs one construction line and two constraints per defining point, so a patterned rectangle pays for all four corners. Keep the counts modest.

Body topology points

Extrude/revolve bodies expose their far-face vertices as attributes: body1.v0, body1.neg_h0_v3, etc. (holes: body1.h0_v0; symmetric extrudes split into pos_v0/neg_v0). Usable as create_sketch_plane defining points or as reference geometry.

Booleans & transforms

Booleans are variadic and keep-result (consume their inputs); transforms are keep-both (source stays, a new body is returned). Every call here that returns a body accepts quality=, name= and — except cad.offset — export=; cad.delete_body takes none of them.

CallPurpose
cad.bool_union(*bodies, quality=None, export=None, blend=None, radius=None, name=None)Union ≥2 bodies. blend="smooth" (fillet) / "chamfer" (45° bevel) + radius shape the seam; omit both for hard edges.
cad.bool_difference(*bodies, quality=None, export=None, blend=None, radius=None, name=None)First body minus the rest.
cad.bool_intersection(*bodies, quality=None, export=None, blend=None, radius=None, name=None)Overlap of ≥2 bodies.
cad.offset(body, distance=None, quality=None, name=None)Grow (+distance) or shrink (-distance) a body's surface uniformly. distance is required — omitting it raises offset requires distance. Note this one takes no export=.
cad.move(body, translate=None, quality=None, export=None, name=None)Translate by the (x, y, z) tuple; returns a new body. Keyword-only after body — the positional and dx=/dy=/dz= forms are refused (§5.7, §10.2).
cad.rotate(body, axis=(0,0,1), angle=0, center=(0,0,0), quality=None, export=None, name=None)Rotate angle° about axis through center; new body.
cad.scale(body, factor=None, sx=None, sy=None, sz=None, quality=None, export=None, name=None)Uniform (factor) or per-axis scale; new body.
cad.mirror_body(body, plane="yz", quality=None, export=None, name=None)Reflect across a sketch plane; new body.
cad.copy(body, quality=None, export=None, name=None)Duplicate in place (identity transform).
cad.pattern_linear(body, direction=(1,0,0), count=2, spacing=10, quality=None, export=None, name=None)One line, count - 1 new bodies along direction; count includes the seed, 2…1000. Returns [seed, copy_1, …] (§5.8).
cad.pattern_circular(body, axis=(0,0,1), count=4, angle=360, center=(0,0,0), quality=None, export=None, name=None)One line, count - 1 new bodies around axis; angle is the TOTAL sweep, and an exact ±360 steps by angle/count where a partial fan steps by angle/(count-1) (§5.8).
cad.delete_body(body)Delete a body; boolean/offset bodies built from it are deleted too.

Components, instances & mates

CallPurpose
with cad.component(name, label=None):Begin a component sub-design (context manager). A never-instanced component builds straight into the top-level design. label= sets a human-friendly UI display name.
cad.component_name(prefix, *parts)Deterministic, identifier-safe name from a prefix + parameter values — the memoization key parametric relies on.
cad.has_component(name)True if a component with this name was already emitted this run.
cad.parametric(prefix, build, *, label=None, **params)Build a component once per unique parameter set (memoized by params); returns the component name. build is a zero-arg callable that emits the geometry + exports.
cad.component_export(body, name=None)Mark a body assembly-visible (inside a component block). name="friendly" makes a multi-export part surface as <instance>_<name>.
cad.component_export_point(name, point)Promote a named anchor; each instance exposes it as <instance>.<name>.
cad.instance(component, translate=None, mirror_plane=None, scale=None, axis=None, angle=None, center=None, quality=None, export=None, name=None)Place a component; returns a body ref usable anywhere a body is. Transforms compose scale→rotate→mirror→translate regardless of keyword order. quality= is accepted but dropped — the call succeeds, prints a stderr warning, and ignores the value, because per-instance quality has no persisted home on this line; a placed part inherits quality from its component definition, so set it there (§7.3.1). export=False is persisted, and must be a real bool. name= fixes the instance's script name.
cad.mate_coincident(instance, anchor, target, offset=None)Closed-form snap: anchor coincides with target (+ optional offset). Position only.
cad.mate_align(instance, anchor1, anchor2, target1, target2, offset=None)Snap + orient: anchor1→target1 (+offset), and anchor1→anchor2 is rotated parallel to target1→target2.
cad.mate_axis(instance, anchor1, anchor2, target1, target2)Solved: the axis anchor1→anchor2 is held collinear with target1→target2, with slide and spin left free. No offset=.
cad.mate(instance, anchor, target)Solved 6-DOF pose; several on one instance solve together (e.g. three point mates fully fix position + orientation). No offset=. For the combinations the run refuses outright — chained solved mates, a solved mate on a rotated/mirrored/scaled placement, a solved mate inside a component block — see §7.4.5.

Appearance, quality & export

CallPurpose
cad.set_color(body, color, finish=None)Display color ("#RRGGBB"/"#RRGGBBAA", a name, or an (r,g,b[,a]) tuple) + optional finish ("matte"/"glossy"/"metallic"/"glass"); written into 3MF export (colour) and glTF/GLB export (colour and finish, as a PBR material). Colors set inside a component propagate to every instance.
cad.set_cell_size(body, cell_size)Absolute dual-contouring cell size (model units) for sampled meshing (booleans, offsets, lofts, rounded/drafted extrudes) — overrides the quality tier's size-relative cell budget so small features keep detail. Clamped to a hard total-cell cap above, and refused below 1e-6 mm (one nanometre). Exactly-meshed plain extrudes/revolves ignore it. Unlike the Precision cell floor in Settings, this travels with the file.
cad.set_export(body, value=True)Include/exclude a body from mesh export — 3MF, STL, OBJ and glTF alike. value must be a real bool; a string or an int raises ValueError rather than coercing (§8.7).
cad.hide(*bodies)Hide bodies from the 3D view (persists across save/reopen) and out of the export. A boolean already auto-hides the operands it consumes — don't also hide those explicitly.
cad.show(*bodies)Re-reveal a previously hidden body, putting it back into the export; its later script position wins.
cad.set_quality(quality)Document-wide default mesh quality ("preview"/"standard"/"high"/"ultra"/"precision") for bodies without their own quality=. Must appear before the first body-creating command anywhere in the script (including inside a component block); at most once; not allowed inside a with cad.component(...) block itself; only takes effect when its own file is the one actually running.
cad.set_units(units)Document-wide presentation unit ("mm"/"cm"/"in") — GUI entry/readout and 3MF export only; geometry stays millimetres always, and glTF/GLB export is metres whatever the unit. No ordering requirement; at most once; not allowed inside a with cad.component(...) block; only takes effect when its own file is the one actually running.
cad.set_default_cell_size(cell_size)Document-wide default dual-contouring cell size in mm, for bodies without their own cad.set_cell_size(...). Raises every body's cell budget, so a fine value is a whole-document cost. Must appear before the first body-creating command; at most once; not allowed inside a with cad.component(...) block; minimum 1e-6 mm; only takes effect when its own file is the one actually running.
Chainable form

set_color, set_cell_size, and set_export are also available as chainable BodyRef methods: body.set_color(...), body.set_cell_size(...), body.set_export(...) — identical effect to the cad.*(body, ...) form.

Section & projection

CallPurpose
cad.section(body, plane, name=None)Cross-section of body at plane, drawn onto that sketch plane. Not to be confused with the free sweep_section(...) builder above — different call, different job. Analytic bodies (extrudes, incl. booleans/transforms/instances of them, cut parallel to their profile) yield true circles/arcs/lines; otherwise a sampled closed spline. A plane that misses the body yields no geometry.
cad.project(body, plane, name=None)Project body's edges onto plane. Analytic bodies viewed along their extrusion axis yield true primitives for every profile edge (holes/bosses included, no hidden-line removal); otherwise a sampled silhouette spline.
cad.project_point(point, plane, name=None)Project a single point onto plane.

10.2 Common errors

FlywheelCAD's API is deliberately fail-loud: rather than coercing a value, guessing at an intent, or quietly ignoring an argument, it stops and says so. Each error below is nearly always a one-line edit, because the message names its own replacement. The tables cover the errors you will actually meet, grouped by where they come from.

Renamed and removed spellings

SymptomFix
AttributeError: '<old name>' is no longer supported. Use '<new name>' instead. The spelling in your script is not a live method, and the message names the one that is. Several families use snake_case (point_on_line, point_on_circle, point_on_plane, point_on_ellipse, point_line_distance, equal_lines, equal_radius, circle_tangent_line, circle_tangent_circle, ellipse_tangent_line, spline_tangent_line, symmetry_plane, symmetry_line); the sketch mirror became cad.mirror_elements; and the component markers became cad.component_export / cad.component_export_point, so that "export" no longer named both a component's assembly-visible result and the 3MF/STL/OBJ inclusion flag. The message always names the exact replacement — read it and substitute. Nothing about the arguments changed.
NameError: 'section' is no longer supported. Use 'sweep_section' instead. The free cross-section builder for cad.sweep(sections=[...]) is sweep_section(...) (§5.4). It carries a distinct name because cad.section(body, plane) (§9.1) is a completely unrelated method that would otherwise share it in the same call position. Rename the builder calls inside your sections= list; the arguments are the same.
TypeError: cad.move(body, dx, dy, dz) is no longer supported; use cad.move(body, translate=(x, y, z)) Check this one first on a document you did not write today. cad.move is keyword-only after the body, and both the positional form and the dx=/dy=/dz= keyword form raise. It is worth calling out because saved documents can contain the positional form, so a file that has never been hand-edited can still hit this on its first run. The fix is mechanical: cad.move(b, 10, 0, 5) becomes cad.move(b, translate=(10, 0, 5)). The error is raised in Python with a targeted message rather than as a bare argument-count TypeError, precisely so it can name the replacement.
TypeError: ... is no longer supported; use mirror_plane=... An instance placement's old mirror keyword. Spell it mirror_plane="zx" (§7.3.1).

Values the API refuses rather than coerces

SymptomFix
ValueError: cad.set_export(): value must be True or False, got 'false' Pass a real Python True/False. Strings and integers are refused outright — no truthiness, no string comparison — because the coercions were actively dangerous: "no" is truthy and would have included the body you meant to exclude, and 1/0 once read backwards. The same rule applies to the export= keyword on every body producer, where None is additionally allowed and means "absent — inherit the document default" (§8.7).
Invalid finish 'shiny' for set_color (use matte, glossy, metallic, or glass) Use one of the four finishes exactly (§8.1). An unrecognized finish is not quietly rendered glossy; omitting finish= entirely is what gives you the glossy default.
Invalid color 'C62828' for set_color (use #RRGGBB, #RRGGBBAA, or a color name) Hex colours need the leading # — "#C62828", not "C62828". Without it the value is looked up as a colour name, finds nothing, and is refused. The accepted forms are #RGB, #RRGGBB, #RRGGBBAA, a named colour, or an (r, g, b[, a]) tuple (§8.1).
Unknown mesh quality 'hgih' for quality= — use preview, standard, high, ultra, precision. A typo'd per-body quality=. All five tiers are legal per body — per-body is the supported route to an expensive tier. There is no silent fallback to the default, which is the point: a mis-typed tier that silently meshed at "standard" is exactly the class of quiet wrongness this refusal exists to kill. The document-wide sibling reads ... for set_quality() — use preview, standard, high, ultra, precision.
set_cell_size() cell size 1e-09 is below the 1e-06 mm (one nanometre) minimum — likely a typo'd exponent. Use a value at or above 1e-06. A sub-nanometre cell is never modelling intent; it is a typo'd exponent or a millimetre/metre mix-up. Pick a real cell size (§8.4). A zero or negative value is refused separately with set_cell_size() requires a positive cell size. The Body Inspector's Cell size field enforces the same floor, so the GUI can never write a line the next run would reject.

Document-setting rules

These three concern cad.set_quality(...), which is a document setting and is policed as one (§8.3) — cad.set_default_cell_size(...) (§8.3c) is policed by the identical rule shapes, with the call name swapped in the message. A script that breaks more than one rule is told about the first.

SymptomFix
cad.set_quality(...) must appear before the document's first body-creating command (extrude 'body1'). … Move the call above every body producer. If you can't find that command in the file, read the rest of the message: it explains that an import of a component module runs that module's top-level code — including any bodies it builds — before anything later in your file, so no position after the import can help. Move the import below cad = FlywheelCAD() instead.
A script may call cad.set_quality(...) at most once; found 2 calls. Delete the extras. The setting is a single document-wide default, not something to re-set as the script proceeds; per-body quality= is how you vary it.
cad.set_quality(...) is a document-scoped setting and cannot appear inside a component (`with cad.component(...)`) block — components inherit the assembly's mesh quality automatically. Move it to the top level of the script. Exactly what it says. Related but not an error: a cad.set_quality(...) inside a module that another document imports is silently ignored with a stderr warning rather than refused, because it would otherwise leak into whichever document imported it. It still applies when that file is opened and run on its own (§8.3).

Everything else

SymptomFix
Unknown sketch context '...' Use a canonical plane name (xy, yz, zx) or an existing custom sketch name. Avoid the legacy _plane suffix.
No region traces loop (...) Loop element names must resolve to existing sketch elements on the active plane. The loop must list boundary elements in traversal order (rotation-invariant, reversal-lenient — use rev(e) for an element traversed against its intrinsic direction). inside is optional but required to disambiguate when several regions trace the same loop; it must lie in the intended region's material, not inside a hole.
Helper module import fails Confirm the helper .py is in the same folder as the running .py, or otherwise reachable on PYTHONPATH. Confirm the module/file name matches the import name exactly.
Script runs but geometry didn't change Check for plain Python mistakes first. Prefer explicit API calls (cad.length, cad.radius, cad.distance, etc.) for dimensions. Use the script panel's error line to locate runtime/validation failures quickly.
Changing the document mesh quality appears to do nothing Some bodies pin their own quality=, which wins over the document picker by design. The status bar's Mesh quality menu says how many, in a footnote row; the View menu deliberately does not, so the skip is invisible there (§8.3).
A body looks coarser than the quality you asked for, with no error at all Not a modelling mistake and usually not a script problem. Look for the orange “N bodies meshed below requested” chip in the status bar, the orange down-arrow on the body's sidebar row, or the Mesh row in the Properties Inspector — one of them will name the reason (§8.5). If it says the adaptive boolean mesher capped it, raising quality= or set_cell_size will not help; restructure the boolean instead.
The Body Inspector refuses to save an edit It refuses rather than rewriting a line it can't uniquely and safely identify — an ambiguous assignment, a value that comes from a cad.variable or an expression, a multi-line or imported creation command, or a setting with no home on that body's line. The reason is shown in the sheet, and nothing at all is applied. §8.6 lists each case and what to do about it.
Opening a document shows an empty model plus a reason The document contains a mate combination the app refuses, because that combination cannot place the part correctly (§7.4.5). The script itself is untouched — fix the offending line and re-run.

10.3 Pitfalls checklist

Run down this list before trusting a generated or hand-written script:

10.4 Workflow recommendations

  1. Rough in geometry in the UI.
  2. Add constraints and dimensions from the dropdowns.
  3. Open the Variables panel and name shared dimensions.
  4. Use the script panel as the source of truth for repeatability.
  5. Move reusable generation logic into sibling helper modules.
  6. Keep feature scripts small and composable by import.