Two Fusion 360 commands, Fit Cylinder and Fit Plane, that fit a primitive to a patch of surface you paint with a brush — the 3-matic Mark + Fit workflow, in Fusion.
Fusion can already tell you the radius of a face it knows is a cylinder. This is for the other case: a scan, an STL, a mesh from segmentation, a face that is only roughly cylindrical, or a region that spans several faces. You paint the part of the surface you mean, and it reports the best-fit primitive and how far the real surface deviates from it.
Works on mesh bodies and solids alike — both are fitted through their display mesh, so a marked patch can cross face boundaries without caring where they are.
- Clone or copy this folder anywhere.
- Run
powershell -File install.ps1. It copies the add-in into%APPDATA%\Autodesk\Autodesk Fusion 360\API\AddIns\FitPrimitives. - In Fusion: Utilities → ADD-INS → Scripts and Add-Ins → Add-Ins →
Fit Primitives→ tick Run on Startup → Run. - Two buttons appear in Inspect.
No dependencies. It runs on Fusion's bundled Python, stdlib only.
| Do this | To |
|---|---|
| drag left mouse | paint |
| hold Shift while painting | flip the paint through setting while held |
| hold Ctrl while painting | erase |
[ and ] |
shrink and grow the brush |
| Clear painted area | start the patch again |
Brush diameter is in millimetres of model, not pixels, so it means the same thing at any zoom. The white ring on the surface is the brush.
The brush marks the region of surface under it, not the triangles it touches. That distinction is the whole difference between a usable tool and a misleading one. Fusion's display mesh for a plain BRep cylinder has side facets spanning the entire height of the part, so a tool that marks whole triangles would take a 5 mm dab and mark 40 mm of surface — and then fit to geometry you never painted. Here each triangle carries a lattice of sample points in its own plane, and a dab marks the lattice points the brush sphere actually covers. Measured: a 4 mm brush puts its furthest mark 2.003 mm from the centre and spans 3.34 mm of that 40 mm facet.
Paint through the part off, a dab marks only the surface facing you: a triangle is taken only if its normal opposes the ray, which is what keeps the brush off the far wall of a tube. On, the dab is a plain sphere and goes straight through, marking the far side as well. Shift flips whichever way the checkbox is set, so either can be the default and the other stays one key away.
Paint on is either the first body you touch — the patch then stays on it however far you drag — or all visible bodies at once, for a feature that spans several.
A brush has to keep up with the mouse, and three things stood in the way. All three
were measured, not guessed — tools/fp.py stroke replays a 200-dab drag through the
exact path the mouse takes and times each stage.
viewport.refresh() costs 3.5 ms; setting a transform costs 0.16 ms. So the
brush ring is built once as a unit circle and repositioned by writing a 4×4 matrix,
and nothing forces a viewport redraw per event — Fusion repaints on its own while
the pointer moves.
Creating custom graphics costs about 5 ms per call regardless of size. One
addMesh per dab was the single biggest cost, so new quads are batched and flushed
every 40 ms instead.
Fitting on every dab is a Levenberg-Marquardt solve over the whole patch, tens of times a second — and writing the result into the dialog wipes every custom graphics group, forcing a full rebuild. The fit now runs once, when the button lifts.
Net: 13.1 ms → 1.84 ms per mouse event, with the fit taking ~70 ms once at the end of a stroke.
Two related fixes: mouseDrag does not reliably carry the button in args.button,
so gating on it silently dropped the whole drag and only the first click painted —
the button is latched on mouseDown instead. And a nearly flat patch still yields
a cylinder, one with an enormous radius; drawing it put a metre-wide wireframe into
a scene of centimetre parts and made the camera appear to lurch, so a fit that weak
is reported but not drawn.
Live, as you paint:
- cylinder — diameter, axis direction, a point on the axis, the painted length, and how much of the circumference you covered;
- plane — normal, a point on it, and the patch size;
- both — rms and worst deviation of the painted surface from the fitted primitive. That is the number that says whether the thing you painted really is a cylinder, and it is why this is more useful than a measurement.
If you have painted less than 45 degrees around a cylinder it says so. A narrow strip fits happily and reports a confident radius that is barely constrained by the data; being told is the difference between a measurement and a guess.
Create chooses construction geometry (a construction axis for a cylinder, a construction plane for a plane), a body, both, or nothing at all if you only wanted the numbers. The two commands default differently, and remember separately: Fit Cylinder makes the axis and a translucent cylinder, because a fitted cylinder sitting on the surface it came from is how you judge the fit at a glance; Fit Plane makes the construction plane only, because a surface patch just buries it.
The Cylinder diameter field holds the diameter the cylinder will be built at. It is refilled with the fitted diameter every time you finish a stroke, so the natural order — paint, look, adjust, create — does what you expect, and painting more never leaves a stale number in the box.
Type over it to build a nominal size instead. The report then tells you how far the painted surface actually sits from the size you chose:
Diameter 24.6420 mm (radius 12.3210)
Creating at 24.0000 mm - against that size the painted surface is rms 0.3210, worst 0.3403 mm
Deviation rms 0.0062 mm, worst 0.0193 mm
That second line is the one that matters when a measured value is about to be replaced by a round one: it says what rounding costs. The axis is unaffected — only the radius changes.
Everything it makes is named Fit_Cylinder_<body> or Fit_Plane_<body>,
after the body the patch was painted on — the construction geometry and the body
alike. Created bodies get a glass appearance — a local copy of Glass (Bronze),
tinted 0/238/250 at roughness 1.0, transparent distance 30, IOR 1.52 — plus an
explicit opacity = 0.5. Both are needed: the appearance alone renders close to
opaque in the shaded viewport, and a fitted cylinder sits exactly on the surface it
came from, so without real transparency it hides the very thing you are checking it
against.
Asking for construction geometry creates construction geometry and nothing else.
That is worth stating because Fusion makes it awkward: construction geometry built
from raw coordinates — setByPlane, setByLine — is documented as direct-modelling
only and does fail in a parametric design. But it is accepted inside a BaseFeature
edit, which is a direct-modelling context — measured, not assumed — so that is where
it is built, and no helper body is ever created. The (empty) base feature has to
stay: deleting it takes the construction geometry with it, also measured.
Bodies this tool creates are tagged with an attribute and excluded from painting.
A fitted cylinder encloses the body it was fitted to, so without that the next stroke
would land on the tool's own output and the next fit would come back named
Fit_Cylinder_Fit_Cylinder_….
fp_fit.py is pure stdlib with no adsk import, so it is proved outside Fusion:
py -3.10 tests/test_fit.py, 57 checks against synthetic patches with a known answer.
Two decisions in there are worth knowing about, because both are cases where the obvious choice is quietly wrong:
The cylinder axis is seeded from the uncentred normal moment. Every normal of a
cylinder is perpendicular to its axis, so with M = sum(w n nᵀ) the axis is M's
smallest eigenvector. Using the centred covariance of the normals instead looks
equivalent, and on perfect data it is — both are exact. The difference is the
eigenvalue gap protecting the axis: s²/12 uncentred against s⁴/720 centred, for a
patch sweeping s radians. Measured on a 25° strip with 0.6° of noise on the
normals — less than any real tessellation carries — the centred seed lands 81.7°
from the true axis and the uncentred one 0.12°. At 10° of sweep the centred seed
returns a random answer, confidently. The tests measure the gap ratio and confirm the
predicted 315×.
The fit uses the marked samples, each weighted by its lattice cell area — every one of them inside the region you painted. On a mesh body the samples are exactly on the surface, because the triangles are the surface, so there is no approximation at all: this is the case that matters for scan and segmentation work.
On a BRep body the samples lie on Fusion's display tessellation, so a sample in the middle of a coarse facet sits inside the true face by the usual chord amount. Measured on the test cylinders: 12.34 mm reads 12.32, 7.70 mm reads 7.69 — about 0.15%, and it shows up honestly in the reported deviation rather than hiding. If you want the exact number for a face Fusion already knows is a cylinder, use Measure; this tool is for the surfaces it doesn't.
There is a related trap that the offline tests keep honest: fitting triangle
centroids rather than surface points under-reports a radius by r(1 − cos(θ/2))
per facet — 0.12 mm on a 20-facet 10 mm cylinder, measured in tests/test_fit.py.
The fit itself is geometric least squares — it minimises the true distance to the surface, not an algebraic residual — by Levenberg-Marquardt over five parameters for the cylinder (two for the axis direction, two for its position across the axis, one for the radius; position along the axis is not observable and would make the system singular) and by the smallest principal axis for the plane.
FitPrimitives.py add-in shell: lifecycle and the dev bridge, nothing else
fp_core.py painting, picking, graphics, outputs, the command and its
handlers; re-imported on every event
fp_fit.py the geometry. No adsk import, so it can be tested offline
install.ps1 push into Fusion's AddIns folder
resources/ toolbar icons
tools/ dev-bridge client, icon generator
tests/ offline tests, no Fusion required
fp_core.py and fp_fit.py hot-reload — re-imported on every command event, so
pushing them takes effect on the next click; only FitPrimitives.py needs a
Stop/Run. Because of that reload nothing may keep state in a module global between
events: per-invocation state lives in the _fp_state module, and dropdown choices
are read back by item label, never by a remembered index.
Fusion has no headless mode and no CLI, so the only way to test an add-in
automatically is from inside a running Fusion. Creating a devbridge.on file next to
the add-in starts a folder watcher that runs jobs on Fusion's main thread.
Every stroke goes through one function, paint_at(viewport_point, modifiers), and
the mouse handlers are three-line wrappers around it — so the bridge drives the exact
same path a real drag does:
py -3.10 tools/fp.py make_test # scratch doc with the awkward cases
py -3.10 tools/fp.py env # design type, camera, bodies
py -3.10 tools/fp.py ray '{"at": [800, 450]}' # unproject and hit-test one pixel
py -3.10 tools/fp.py open '{"kind": "cylinder"}'
py -3.10 tools/fp.py paint '{"points": [[800, 450]], "diameter": 8}'
py -3.10 tools/fp.py paint_world '{"points": [[0,-12.34,20]], "diameter": 6}'
py -3.10 tools/fp.py samples '{"from_mm": [0,-12.34,20]}' # are the marks in the brush
py -3.10 tools/fp.py capture # PNG of the viewport, overlay included
py -3.10 tools/fp.py mouse_log # did Fusion deliver mouse events
tools/livetest.py runs the whole acceptance suite against a live Fusion: it builds
a scratch document of known geometry, aims the camera, paints through the real
unproject → ray → hit → brush path, and checks the fitted numbers against the truth.
39 checks.
Off by default. Nothing in normal operation depends on it.
MIT — see LICENSE.