Fit CAD
fit_cad(mesh, path) turns the fitted patches of a closed triangle skin into a CAD solid: every patch that fit_primitives accepted becomes a plane, sphere, cylinder, cone or torus face trimmed by the patch boundary, a rolling-ball fillet or blend (of one radius or a radius varying along it) or a helical thread face its own feature face, a chamfer, a conic blend, a tapered or variable-pitch thread or a corner where blends meet its own face too, any other patch no primitive explains a freeform B-spline face, the faces are sewn into a solid that OpenCASCADE checks, and the solid is written as STEP. It is the last step of skin to CAD, after segment and fit_primitives, and it needs the optional OpenCASCADE backend.
import meshioplusplus as mp
skin = mp.repair(mp.read("part.stl"))
seg = mp.segment(skin, curvature_tolerance=1.0, min_patch_cells=20)
fit = mp.fit_primitives(seg, tolerance=2e-3)
solid, report = mp.fit_cad(fit, "part.step", return_report=True)
report["valid"], report["num_cylinder"], report["num_demoted"]
report["volume_ratio"], report["max_deviation"] # against the skin
Unlike the faceted step writer, whose solid is one flat face per triangle, the STEP file fit_cad writes carries CYLINDRICAL_SURFACE, CONICAL_SURFACE, SPHERICAL_SURFACE and TOROIDAL_SURFACE faces a CAD tool can select, dimension and edit. A skin without segment:patch or the fit:patch_* table is segmented and fitted first with the default options; a noisy scan wants a curvature test and a min_patch_cells in the tens, so run those two yourself, or pass them to the CLI.
How the solid is built
- Faces. A patch whose fit was accepted, and whose type is in
types, becomes one face on its fitted surface. A patch no primitive explains is tried, in order, as a rolling-ball fillet between two analytic faces (fillets), as a helical thread face (threads; then of varying pitch or on a taper), as a chamfer or conic blend between two analytic faces (fillets) and, withfreeform(the default), as a B-spline face; once the freeform faces exist, a patch between two fitted faces, one of them freeform, is tried as a rolling-ball blend against them, and a corner patch among three or more fitted faces as a vertex blend (fillets). Every other surface cell (a type left out, a freeform fit that failed, a cell with no patch) becomes planar facets, one per cell, as the faceted writer writes them. - Edges. Where two patches meet, the mesh edges between them form chains from one junction (a point where three or more patches meet) to the next; a closed chain is split in two. Between two analytic faces the edge is the branch of the two surfaces' exact intersection that lies within the tolerance of the chain, when
exact_edgesis set and one does (a plane meeting a cylinder gives a circle). Otherwise, notably where a fillet meets its wall tangentially and no intersection is stable, the edge is a B-spline through the chain, smoothed and moved onto both surfaces, kept only if it stays within the tolerance of both. Beside a facet, the edge is the mesh's own straight edge. - Vertices. A junction among analytic faces only is moved onto all of their surfaces; one beside a facet keeps the mesh position.
- Solid. Each face gets parameter-space curves that are continuous around its loops (so a boundary running along a cylinder's seam or a torus's equator does not fall apart), OpenCASCADE adds the seams and repairs the faces, sews them and closes each shell into a solid, one per edge-connected piece of the skin. A patch with no boundary at all, a whole sphere or torus, is its surface's natural face.
Freeform faces
A patch fit_primitives left unexplained (no accepted primitive, or one with non-finite parameters) gets a B-spline surface fitted to it, so a smooth blend, a dent or a styled panel becomes one CAD face instead of hundreds of facets:
- A parametrisation. The patch is flattened over its best-fit plane (principal components) or, when that folds, unrolled around a cylinder: the axis is the direction its normals are most perpendicular to (the smallest eigenvector of Σ nnᵀ), the radius a least-squares circle fit of the points around it, the angle cut at the widest gap. A flattening whose triangles do not all keep one orientation is not a parametrisation and is not used. A patch that folds over both (a doubly curled one, or a band closed around an axis) is, with
freeform_split(the default), split into pieces that one of them does parametrise. - A height field. The offset from the plane or the cylinder is resampled on a grid over the flattened bounding box, interpolated inside the patch's triangles and continued from the nearest boundary point outside them, so the surface extends past the patch's trim.
- The fit.
GeomAPI_PointsToBSplineSurfacefits the grid (iso-parametric, C2, degrees up tofreeform_degree, default 3, valid 3–8). The grid starts near √n points a side and doubles up tofreeform_max_control(default 64, valid 4–1024) until every patch vertex lies within half the tolerance and every facet centre within the tolerance; a patch that never gets there, or a skin too coarse for any smooth surface to pass within it, stays faceted. - The face. The surface is oriented by the facet normals and goes through the same trimming, sewing, check and demotion as an analytic face; its
fit_cad:typeis 6 (kFitCadBsplineType), and the report'snum_bsplinecounts it.
freeform=False keeps every unexplained patch faceted.
Splitting curled patches
A patch that folds over both flattenings is halved at the area-weighted median of n · e, where e is the direction its normals spread along most (the principal axis of the area-weighted Σ nnᵀ): a cut along an iso-line of the normal, a smooth curve on a smooth surface. Each half is split into its edge-connected components, and a component that still folds is halved again, up to four times. Each piece that now flattens becomes its own freeform face under a new patch label, numbered after the largest existing one in the order of its lowest facet, so fit_cad:patch tells the pieces apart; a piece under 16 triangles, or one still folding at the fourth halving, stays faceted. The edges between pieces are approximated B-splines like any other tangent junction. The split is deterministic and needs no OpenCASCADE (detail/patch_flatten); num_split_patches counts the pieces, and freeform_split=False keeps such a patch faceted, as before.

Fillets, blends and threads
Features that no primitive explains are recognised from the patches around them, before the freeform fallback or, for a blend, after it. The fits need no OpenCASCADE (detail/feature_fit); the faces they give are then trimmed, sewn, checked and demoted like any other.
- Rolling-ball fillets (
fillets, type 7). A patch that runs between two analytic faces (the two neighbours sharing the most boundary, together at least 60% of it) is tried as a constant-radius fillet between them: each interior vertex, moved byragainst or along its normal, should be a ball centre atrfrom both primitives. The radius minimises that misfit over a log-spaced scan of[tolerance / 2, diagonal]refined by golden section, for a convex (centres inside) and a concave (centres outside) ball. The face is the pipe of that radius around the spine where the two surfaces, each offset byrtowards the centres, meet (GeomFill_Pipearound theGeomAPI_IntSSbranch nearest the centres, kept whole and periodic when the centres go most of the way round it). It covers a straight rounded edge, a swept fillet along a curved edge (the foot of a leaning boss, along an ellipse) and a ring. A constant-radius fillet between coaxial surfaces is a torus, whichfit_primitivesalready fits; this stage only sees what it left unexplained. - Fillets of varying radius (
fillets, type 7, counted innum_variable_fillet). When no one radius explains the patch, each interior vertex gets its own ball radius, and the radii become a law along the rail, the patch's boundary on the first surface: the parameter of a vertex is where its own ball touches that surface, a Chebyshev series (a Fourier series round a closed rail) of the lowest degree up to 8 that explains the radii within a quarter of the tolerance. The face is a B-spline through cross-sections of the ball family: along the rail, the ball touching both surfaces at the law's radius is solved for in the plane through the rail point, and each section is the arc of the ball's characteristic circle from one foot to the other, widened by 15% each way, the sections running a tenth of the rail past each open end so the face trims inside its surface. - Blends beside freeform faces (
fillets, type 7, counted innum_blend). Once the freeform faces exist, a patch between two fitted faces, at least one of them freeform (plane–B-spline or B-spline–B-spline), is tried as a rolling ball against those surfaces, with the radius found per vertex and fitted as a law exactly as above, constant or varying; a freeform patch can become a blend this way. The neighbours are taken as the freeform stage left them, so the result does not depend on the order the patches are tried in. A ball resting on a nearly flat stretch of a fitted face touches it where its normal happens to point, which wanders far more than the face does, so the balls' centres and radii, not their feet, set the face: smooth laws are fitted through the solved centres (judged across the spine, as a wavering rail stretches the arc length along it) and the arcs ride on them. A blend stays tangent to its fitted neighbours, which lie within half the tolerance of the skin, so a vertex on the blend's own boundary may lie the whole tolerance from it. - Helical thread faces (
threads, type 8). A patch beside an accepted cylinder or cone is first tried as a straight-flank thread about its axis: the ruled helicoidz = c + k θ + t ρ(kthe pitch over 2π,tthe flank's slope), one least-squares solve once each vertex's angle is unwrapped over the patch's edges; the face is a B-spline through a grid on it. A patch that is not such a flank, or that has no cylinder or cone beside it, is tried as a helical surface of any profile. Without an axis, the screw comes from the patch itself, as the screw motion whose velocity field is most nearly tangent to it (Pottmann and Randrup, Rotational and helical surface approximation for reverse engineering, Computing 60, 1998): the smallest eigenvector of a normal-weighted 6 × 6 moment, reduced to 3 × 3, refused when a plane, cylinder or sphere leaves more than one screw nearly free, or when the advance vanishes (a surface of revolution). The axis and the advance are then refined together by Levenberg-Marquardt on the residual of the profile: each vertex, with the screw's advance taken off its height, lands in the plane through the axis, and the profile is a Chebyshev series of the lowest degree up to 8,ζ(ρ)orρ(ζ), within 0.4 of the tolerance. A straight profile is a flank; any other (a rounded root or crest) is counted innum_rounded_thread. A trapezoidal or ISO thread then comes back as cylinders (root and crest) and two helical flanks, and a thread with rounded roots and crests, with no cylinder anywhere, as four helical faces.
Every patch must span at least half a turn to be a thread (less of a helical surface is as well something else). A candidate face must keep every patch vertex within half the tolerance and every facet centre within it, as a freeform face must. Edges beside a fillet, a blend or a thread face skip the exact intersection, which is slow and unstable between tangent or long B-spline surfaces, and go straight to the approximated B-spline. num_fillet and num_helical count the faces, and of them num_variable_fillet, num_blend and num_rounded_thread the new kinds; fillets=False and threads=False leave such patches to the freeform fit.





Chamfers, conic blends, vertex blends and tapered threads
Since v17.2.0 (roadmap 6.3) four more kinds of patch get their own faces. Each is tried only where every fit above has failed for that patch, so a skin they do not concern builds the same solid, to the byte, as before; their fits are in detail/feature_fit too.
- Chamfers (
fillets, type 10,num_chamfer) and conic blends (fillets, type 7,num_conic_blend). A patch between two analytic faces (the two neighbours sharing the most boundary, together at least 60% of it) that no rolling ball and no thread explains is tried as a blend of the edge between them with a cross-section other than the rolling ball's circle. Its rails are the patch's boundary edges across from each neighbour; its cross-sections lie in the planes normal to the edge the two surfaces would meet along (n₁ × n₂, through each point of the first rail), from the first rail to where that plane cuts the second. When every interior vertex lies within 0.4 of the tolerance of its section's chord, the patch is a chamfer, of constant or varying width (a chamfer between two planes of varying width is a twisted ruled surface, a constant one a plane, and a chamfer coaxial with a cylinder a cone: those two stay the primitivesfit_primitivesfits). Otherwise each section is taken as a rational quadratic from rail to rail through the apex where the two surfaces' tangent lines meet, the vertex's weightw = b₁ / (2 √(b₀ b₂))read off its barycentric coordinates in that triangle, and a weight law of the lowest degree (Chebyshev, Fourier round a closed rail) whose conics pass within 0.4 of the tolerance of every vertex makes it a conic blend: an elliptic, parabolic or hyperbolic section tangent to both faces, of legs that may differ and change along it. The face is a B-spline interpolating the sections, each widened by 15% and carried a tenth of the rail past an open end. - Tapered and variable-pitch threads (
threads, type 8,num_tapered_thread,num_variable_pitch_thread). A straight flank beside a cone is the same ruled helicoid as a flank of a straight thread (with an apparent pitch), so it is built as before and reported tapered. A flank whose straight helicoid fails is refitted with one more column,z = c + k θ + q (θ − θ₀)² + t ρ, a pitch changing linearly along it, and kept when the pitch changes by more than the tolerance across the patch. A patch no constant screw explains is refitted as a helical surface of any profile on a generalised screw, its advanceg(θ) = k θ + q (θ − θ₀)²and its profile moved out byτ g(θ)as it advances, a thread cut on a cone whose radius grows byτper unit length:τstarts at a neighbouring cone's half-angle and is refined with the axis, the advance andqby the same Levenberg-Marquardt profile fit. A trapezoidal pipe thread then comes back as cones and two tapered flanks, a thread whose pitch grows as cylinders and two variable-pitch flanks, and a rounded one on a taper as four helical faces, the rounded root and crest tapered. - Vertex blends (
fillets, type 9,num_vertex_blend). After the freeform and blend stages, a patch that is unexplained or freeform, whose boundary is one closed loop around three or more fitted faces (each met along one run) and creased nowhere (no facet across it turns more than 20°), is tried as the corner where blends meet: a plate surface (GeomPlate_BuildPlateSurface) through its vertices, tangent (G1) to each neighbour's surface along the loop, approximated by a B-spline. It is kept when it passes the freeform face's test and its normal stays within 3° of every neighbour's along the loop. This covers a setback corner, three blends of different radii or sections meeting at one vertex; a corner of three equal rolling balls is a sphere, whichfit_primitivesalready fits.
Segmentation is the condition: a conic blend is tangent to both its faces and its curvature varies across it, so no curvature tolerance of segment isolates it alone; label such a skin's patches yourself (segment:patch, from the CAD faces it was tessellated from, say), as the fixtures behind the tests do. A chamfer meets its faces at creases and segments by angle.



Checks and demotion
A face is not kept when OpenCASCADE's B-rep check rejects it, when sewing leaves an edge of it free, when its boundary cannot be built, or when it strays: the solid is re-meshed and a face with a point further than tolerance from the skin, or a patch with a skin point further than that from the solid, fails. A failed patch is demoted to facets and the solid is rebuilt; after max_demotion_rounds rounds (default 8) every remaining patch is faceted, which is always a valid solid for a closed skin. So a feature the fitter cannot explain, or a face the kernel cannot trim, comes back faceted, never silently wrong, and num_demoted counts the features dropped. MESHIOPLUSPLUS_LOG_LEVEL=debug logs why each patch was demoted.
tolerance defaults to three times the largest fit:patch_tolerance of an accepted patch, so it follows the scale fit_primitives worked at; with no accepted patch it is 1e-3 of the bounding-box diagonal. The re-meshing uses a chordal deflection of a quarter of the tolerance unless mesh_deflection says otherwise.
Output and report
The return value is the solid re-meshed by OpenCASCADE as one triangle block, with points welded where faces share them exactly, and Int64 cell data fit_cad:face (the B-rep face), fit_cad:type (0 a facet, else the fit:type value: 1 plane, 2 sphere, 3 cylinder, 4 cone, 5 torus; 6 a freeform B-spline, 7 a rolling-ball fillet, blend or conic blend, 8 a helical thread face, 9 a vertex blend, 10 a chamfer) and fit_cad:patch (the segment:patch label, -1 for a cell with no patch). With return_report=True the report holds:
| Key | Meaning |
|---|---|
valid | the solid passes OpenCASCADE's B-rep check |
tolerance | the tolerance applied |
volume, skin_volume, volume_ratio | the solid's volume, the skin's enclosed volume and their ratio |
max_deviation | the symmetric Hausdorff distance between the skin and the re-meshed solid |
num_faces, num_plane … num_torus, num_bspline, num_fillet, num_helical, num_faceted_faces | B-rep faces in all, by type (freeform B-splines, fillets and thread flanks included), and planar facets |
num_variable_fillet, num_blend | of the fillet faces, those whose radius varies along them, and the blends beside a freeform face |
num_rounded_thread | of the helical faces, those whose profile is not a straight line (a rounded root or crest) |
num_vertex_blend, num_chamfer | vertex blends and chamfers (v17.2.0) |
num_conic_blend | of the fillet faces, the conic blends (v17.2.0) |
num_tapered_thread, num_variable_pitch_thread | of the helical faces, those cut on a taper and those whose pitch varies (v17.2.0) |
num_split_patches | pieces that freeform patches folding over every flattening were split into |
num_patches, num_demoted | patches in the fit table, and accepted ones demoted to facets |
num_exact_edges, num_approx_edges | edges between analytic faces built as exact intersections and as B-splines |
num_solids | solids written, one per piece of the skin |
The STEP file is AP214 in millimetres. Its header timestamp is pinned and its product is named meshio++ fit_cad, so the same input writes the same bytes; the bytes do depend on the OpenCASCADE version. The work is serial, so nothing depends on the thread count.
The solid as NURBS
solid.info["brep"] (C++ FitCadResult::mBrep, a BrepInfo) holds the solid with every face converted to NURBS (BRepBuilderAPI_NurbsConvert): the surfaces, their trimming loops and the edges with the faces they bound, as a STEP read gives them. Every face of the solid is there, trimmed, and every trimming curve lies in its surface's parameter range. When the exact conversion does not deliver that (OpenCASCADE 7.9 throws on a trimmed cylinder or one wound several turns round, leaves a face that already was a B-spline without a surface, and can stop a converted surface short of its trims), the solid is kept as it is and each surface and edge that is not a B-spline is approximated by one in its own parameters, within 1e-6 of the bounding-box diagonal, so the trimming curves stay valid; planes and lines stay exact. Writing it as Kratos CAD JSON (mp.write("part.cad.json", solid), to-cad --cad-json) makes a scanned part Kratos IGA geometry.
Getting OpenCASCADE
Open CASCADE Technology is licensed under the GNU LGPL 2.1 with the additional exception in its OCCT_LGPL_EXCEPTION.txt. meshio++ stays MIT: the backend is off by default and bring-your-own (never vendored or downloaded), linked dynamically, and absent from the PyPI wheels, the lean WASM build and the release CLI binaries; the full WASM artifact links it statically, under a mixed licence it states. A build that enables it links an LGPL library; distributing that build carries the LGPL's obligations for OpenCASCADE, not for meshio++. OpenCASCADE 7.6 or later is supported (7.6 is Ubuntu 24.04's package; the CI leg tests it).
sudo apt-get install libocct-foundation-dev libocct-modeling-data-dev \
libocct-modeling-algorithms-dev libocct-data-exchange-dev libtbb-dev
CMAKE_ARGS="-DMESHIOPLUSPLUS_WITH_OCCT=ON" pip install meshioplusplus --no-binary meshioplusplusFrom source, build/configure.sh --with-occt (or -DMESHIOPLUSPLUS_WITH_OCCT=ON) finds OpenCASCADE through its CMake config; point OpenCASCADE_DIR or CMAKE_PREFIX_PATH at an install outside the default prefixes, and make its libraries findable at run time as for any shared library. Debian's packages export toolkit targets that link TBB, hence libtbb-dev.
Without the backend, fit_cad raises NotImplementedError (C++ Unsupported) naming -DMESHIOPLUSPLUS_WITH_OCCT=ON; there is no pure-Python fallback, because trimming, sewing and checking surfaces is a CAD kernel's work. meshioplusplus._fit_cad.has_occt(), fit_cad_has_occt() in C++, mio_has_occt() in C and the language surfaces below say in advance whether a build has it.
CLI
meshioplusplus to-cad IN OUT.step [--curvature-tolerance C] [--min-patch-cells N] \
[--fit-tolerance T] [--tolerance T] [--no-exact-edges] [--types plane,cylinder,...] \
[--mesh-deflection D] [--max-demotion-rounds N] [--no-freeform] [--freeform-degree P] \
[--freeform-max-control N] [--no-freeform-split] [--no-fillets] [--no-threads] [--mesh-out FILE] [--cad-json FILE] [--json] [--quiet]A skin without segment:patch is segmented with --curvature-tolerance and --min-patch-cells, and fitted with --fit-tolerance (relative, as in fit-primitives), before the solid is built. --mesh-out also writes the re-meshed solid, --cad-json the solid as Kratos CAD JSON; --no-freeform, --freeform-degree, --freeform-max-control and --no-freeform-split are the freeform options, --no-fillets and --no-threads the feature ones. Both CLIs exit with 2 when the solid fails the B-rep check, as check does for a failed gate.
meshioplusplus to-cad part.stl part.step --curvature-tolerance 1 --min-patch-cells 20 --fit-tolerance 2e-3Pipeline
{"Op": "FitCad", "Path": "part.step", "Tolerance": 0.0, "ExactEdges": true,
"Types": ["plane", "cylinder"], "MeshDeflection": 0.0, "MaxDemotionRounds": 8,
"Freeform": true, "FreeformDegree": 3, "FreeformMaxControl": 64,
"FreeformSplit": true, "Fillets": true, "Threads": true}The step writes the solid to Path when given, and the mesh continues as the re-meshed solid; it reports Valid, VolumeRatio, MaxDeviation, NumFaces, NumBspline, NumFillet, NumHelical, NumVariableFillet, NumBlend, NumRoundedThread, NumVertexBlend, NumChamfer, NumConicBlend, NumTaperedThread, NumVariablePitchThread, NumSplitPatches, NumFacetedFaces and NumDemoted. A Tolerance or MeshDeflection of 0 is automatic; an empty Types facets everything.
Other languages
mio_fit_cad_opts opts;
mio_fit_cad_opts_init(&opts);
opts.types = (1u << MIO_FIT_PLANE) | (1u << MIO_FIT_CYLINDER);
opts.freeform = 1; /* the default; freeform_degree, freeform_max_control */
mio_fit_cad_report report;
mio_fit_cad_report_ex report_ex; /* v17.2.0: num_vertex_blend, num_chamfer, ... */
if (mio_has_occt()) {
mio_mesh* solid = mio_fit_cad_ex(fitted, "part.step", &opts, &report, &report_ex);
}solid = fit%fit_cad(step_path='part.step', valid=ok, num_demoted=ndropped, stat=ierr)has_occt() && (r = fit_cad(fit; step_path="part.step"); r.valid)if (mio_has_occt()) cad <- mio_fit_cad(fit, step_path = "part.step")The C report carries num_bspline, num_fillet, num_helical, num_variable_fillet, num_blend, num_rounded_thread and num_split_patches, and mio_fit_cad_ex's extended report (mio_fit_cad_report_ex, v17.2.0, as mio_fit_cad_report has no room left) num_vertex_blend, num_chamfer, num_conic_blend, num_tapered_thread and num_variable_pitch_thread; Fortran's, Julia's and R's results carry all of them (freeform, freeform_degree, freeform_max_control, freeform_split, fillets and threads are their options; a zero-filled C struct keeps the last three off, mio_fit_cad_opts_init turns them on). The MCP server's fit_cad tool takes the same options plus curvature_tolerance, min_patch_cells, fit_tolerance and cad_json_output_path, and reports occt_available; without the backend it answers with the named error and occt_available: false. In the browser, the full WASM artifact links OpenCASCADE and runs fitCad(mesh, {stepPath, ...}), with the same options and report in camelCase and the solid's B-rep as info; the lean artifact throws by name.
Not supported
- Blends of a section that is no conic, a chamfer or conic blend beside a freeform face (only analytic neighbours are tried), and a corner patch whose loop meets a neighbour twice or at a crease: they come back freeform or faceted.
- Threads whose depth varies along them, or whose pitch varies other than linearly, and a tapered or variable-pitch thread with no cylinder or cone beside it that is also too far from one screw for the axis to be found from the patch itself: freeform or faceted, as is a thread patch spanning less than half a turn.
- A whole thread as one patch: a thread surface whose profile spans the full pitch (one patch over root, flanks and crest) has no single trimmed face to be;
segmentit at its creases or with a curvature test first. - Fitting a STEP or IGES model: those are read as their own NURBS B-rep, not refitted.