Fit Primitives

A Fusion 360 add-in that fits a cylinder or a plane 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 spanning 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.

The brush marks a region, not triangles

This is the distinction the whole tool turns on. 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 takes a 5 mm dab and marks 40 mm of surface — then fits to geometry you never painted.

marking triangles marking the region brush is 5 mm 40 mm gets marked brush is 5 mm 5 mm gets marked
Each triangle carries a lattice of sample points in its own plane; a dab marks the 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 a 40 mm facet.

Painting

drag left mousepaint
hold Shiftflip paint through while held
hold Ctrlerase
[ ]shrink and grow the brush

Brush diameter is in millimetres of model, not pixels, so it means the same thing at any zoom. Paint through 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.

What it reports

Live, as you paint: for a cylinder the diameter, axis, a point on it, the painted length and how much of the circumference you covered; for a plane the normal and the patch size. For both, the rms and worst deviation of the painted surface from the fitted primitive — the number that says whether the thing you painted really is a cylinder.

Paint less than 45° around a cylinder and it says so. A narrow strip fits happily and reports a confident radius that the data barely constrains: measured, a 12° arc reported 10.40 mm where the truth was 12.34.

Changing the diameter

The cylinder diameter field is refilled with the fitted value after every stroke. Type over it to build a nominal size, and the report tells you what rounding costs:

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

Two things the fit gets right that are easy to get wrong

The axis seed is the uncentred normal moment

Every normal of a cylinder is perpendicular to its axis, so with M = Σ w n nᵀ the axis is M's smallest eigenvector. The centred covariance of the normals looks equivalent, and on perfect data it is — both are exact. The difference is the eigenvalue gap protecting the axis, and it is large:

seedgap for a patch sweeping s radians 25° strip, 0.6° normal noise
uncentreds² / 12 0.12° from the true axis
centreds⁴ / 720 81.7° from the true axis

That noise level is less than any real tessellation carries. 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 surface points, not triangle centroids

Centroids of a tessellated cylinder sit inside the true surface, so fitting them under-reports the radius by r(1 − cos(θ/2)) per facet — 0.12 mm on a 20-facet 10 mm cylinder, measured.

On a mesh body the samples are exactly on the surface, because the triangles are the surface — no approximation at all, which is the case that matters for scan work. On a BRep body they lie on the display tessellation, so a radius reads about 0.15% small; that shows up honestly in the reported deviation.

Keeping a drag smooth

A brush has to keep up with the mouse. Three costs stood in the way, all measured by replaying a 200-dab drag through the exact path the mouse takes:

per mouse eventbeforeafter
paint (ray, hit, sampling)1.0 ms1.0 ms
drawing the painted quads5.3 ms0.25 ms
brush ring4.9 ms0.14 ms
total13.1 ms1.84 ms

The geometry was never the problem. viewport.refresh() costs 3.5 ms against 0.16 ms for a transform write, and creating any custom graphics entity costs ~5 ms regardless of size. So the ring is built once and moved by a matrix, quads are batched, nothing forces a redraw per event, and the fit runs once when the button lifts instead of on every dab.

What it creates

A construction axis or plane, a body, both, or nothing. The two commands default differently and remember separately: Fit Cylinder makes the axis and a translucent cylinder, because one sitting on the surface it came from is how you judge the fit at a glance; Fit Plane makes the plane only, because a surface patch just buries it.

Everything is named Fit_Cylinder_<body> or Fit_Plane_<body> after the body it was fitted to, and created bodies are excluded from painting — a fitted cylinder encloses its own source, so otherwise the next stroke lands on the tool's own output.

Asking for construction geometry creates construction geometry and nothing else. The API reference calls setByPlane and setByLine direct-modelling only, and they do fail in a parametric design — but they are accepted inside a BaseFeature edit, which is a direct-modelling context. Measured, not assumed. So no helper body is ever created.

Install

git clone <this repo>
powershell -File install.ps1

Then in Fusion: Utilities → ADD-INS → Scripts and Add-Ins → Add-Ins → Fit Primitives → Run. Two buttons appear in Inspect. No dependencies — it runs on Fusion's bundled Python, stdlib only.

Testing

The geometry 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. Fusion has no headless mode, so the rest is proved inside a running Fusion through a folder-watching dev bridge: py -3.10 tools/livetest.py 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.