Measuring and analysis
Measures
from smlibpy import *
part = Box(40, 30, 20) - Cylinder(6, 30)
part.volume # 24000 - pi * 36 * 20
part.area # total surface area
part.center() # center of mass
part.bbox() # BoundBox: .min, .max, .size, .center
part.is_valid() # a closed, manifold solid with a positive volume, faces that meet?
part.info() # counts and bounding box in one dict
face = part.faces().top().one()
face.area, face.center(), face.normal_at(), face.geom_type
edge = part.edges().circles().first
edge.length, edge.radius, edge.arc_center, edge.point_at(0.25), edge.tangent_at(0.25)
Areas, volumes, centers and inertia are integrated over the exact geometry, not a mesh, to
seven significant digits (smlibpy.shapes.ACCURACY, 1e-7): volumes and areas to 1e-7 of
their value, centers to 1e-7 of the part's size (its bounding box diagonal), the inertia to
1e-7 of its largest principal moment. The integration estimates its error for every one of
these numbers and refines until the estimate is ten times below that; measured against
closed forms and converged references, the results are right to 1e-11 and better. Edge
lengths are integrated to 1e-12, and point_at follows the arc length as closely.
A part keeps its measures: volume, area, center() and mass_properties() share one
integration, the first of them pays for it: a few milliseconds for most parts, 105 ms for the
spring of examples/spring.py with its 4 x 1024 knot spans, 0.6 s for a surface through
105 x 105 points thickened to a solid (46,000 knot spans on its top face; the kernel's own
integration takes 0.4 s there at 1e-7 and is 8e-8 off).
Mass properties
mp = part.mass_properties(density=7.85e-3) # steel in g/mm³ gives grams
mp.mass
mp.center # center of mass
mp.inertia # 3x3 inertia tensor about the center of mass
mp.principal_moments
mp.principal_axes
precise = part.mass_properties(density=7.85e-3, accuracy=1e-10)
mp.inertia_about((20, 15, 10)) # the tensor about another point (parallel axes)
mp.moment_about(Axis((0, 0, 0), (0, 0, 1))) # about an axis: a shaft's, a pivot's
mp.radii_of_gyration # sqrt(principal moment / mass), per principal axis
accuracy (1e-10 to 1e-2) sets the bound above for one call. It costs little: the spring
takes 105 ms at 1e-7 and at 1e-10, 44 ms at 1e-4. A coarse accuracy saves little on dense
free-form faces, though: every knot span gets at least 7 x 7 points, and the thickened surface
above takes 0.6 s at any accuracy.
Section properties
The second moments of area of a profile, for beams, frames and extrusions, come from its
sketch. They are about the centroid, in the axes of the sketch plane (sp.plane, the plane
moved to the centroid):
sp = (Rectangle(40, 20) - Circle(5)).section_properties()
sp.area # 721.46
sp.ixx, sp.iyy, sp.ixy # 26175.79, 106175.79, 0: integral of y², x², x y over the area
sp.polar # ixx + iyy
sp.section_moduli # (2617.58, 5308.79): ixx / c_y and iyy / c_x, c to the farthest fiber
sp.radii_of_gyration # (6.023, 12.131)
angle = Rectangle(30, 5, align=Align.MIN) + Rectangle(5, 40, align=Align.MIN)
sa = angle.section_properties()
sa.centroid # Vector(8.26923, 13.2692, 0)
sa.principal_moments # (13276.7, 61442.9): ascending
sa.principal_axes # their directions, in the plane
They are integrated as the mass properties of a thin slab of the region (to the same 1e-7), less the slab's own thickness term.
How exact a body is
The numbers are as exact as the body. Where the kernel fits a surface or an intersection
curve (fitted chamfers, G2 and G3 blends and variable fillets, the offset faces of a shell, the
cut ends of a sweep, two cylinders crossing), neighbouring faces meet only to within about 1e-6 to
4e-5 mm, and the volume of the body is defined only to that: measured turned by 90 degrees, a pin
with G2 blends on its rims (Cylinder(3, 10), 1) differs by 3.4e-6, two crossing cylinders of the
same radius (Cylinder(5, 40), fused) by 4e-8, the shelled bottle of examples/bottle.py by
2.7e-6. (Chamfers of full circles between coaxial faces are exact cones: the flange of
examples/flange.py differs by 2e-15.) Bodies whose edges are lines and circles close up exactly
and agree with their formulas to 1e-10 and better. Compare against formulas with a relative
tolerance of 1e-6, or 1e-5 for sweeps, shells and offsets of curved faces.
A face the integration cannot converge on stops it after a budget of surface points (far more
than a sound face of as many knot spans takes): one that crosses the seam of its own surface, a
broken body that no public operation makes, raises KernelError ("face 0 of the body crosses
the seam of its surface ...") in under a second, where it ran for minutes. Any other face that
runs out gives its result with the warning that the accuracy was not reached.
The integration measures the gaps as well: it integrates the volume with three fields, the
kernel's (0, 0, z) and (x, 0, 0) and (0, y, 0), which give the same volume only for faces that
close up. Where they differ by more than 1e-5 (or the accuracy asked for, if that is coarser),
volume, area, center() and mass_properties() say so in a RuntimeWarning with the size
of the uncertainty, and give the volume of the faces as they are. A plate 2 thick folded at a
radius of 0.2 and unfolded again gets 6e-4. A warning that the integration did not reach the
accuracy asked for looks the same; each comes once per part. From 1e-2 the faces do not bound
one solid (a Boolean result that left a face out: 0.25), and is_valid() is False.
What is wrong with a body
is_valid() says whether a part is a closed solid with a positive volume whose faces meet;
check() runs the kernel's own checks of every face, edge and vertex and names what is wrong,
and where: each Problem has the face, edge or vertex (shape), a message, the value
measured and the tolerance it was held against. For imported, stitched and healed bodies:
sides = Box(20, 20, 20).faces()
lifted = stitch([f.to_shell().translate(0, 0, 0.002 if f.normal_at().z > 0.5 else 0) for f in sides],
tolerance=0.01) # the top face sewn on 0.002 above the walls
print(lifted.is_valid(), lifted.is_valid(strict=True)) # True False
for problem in lifted.check():
print(problem.test, problem.shape, round(problem.value, 6)) # Bad Vertex Gap Vertex(..., 10) 0.002
print(round(max(e.tolerance for e in lifted.edges()), 6)) # 0.004
is_valid(strict=True) also requires an empty check(). check(level) runs the structure tests
(0), also the geometry (1) or all (2, the default); some milliseconds on generated parts. The
tests do not see a body that runs through itself. edge.tolerance and vertex.tolerance are
the kernel's tolerances (1e-5 on parts smlibpy makes).
Distances
a = Box(10, 10, 10)
b = Sphere(3).translate(20, 0, 0)
a.distance_to(b) # 12.0
a.distance_to((0, 0, 30)) # to a point
a.closest_point((0, 0, 30)) # Vector(0, 0, 5)
cp = a.closest_points(b) # the nearest points themselves, and the faces they lie on
cp.point, cp.other_point # Vector(5, 0, 0), Vector(17, 0, 0)
cp.distance, cp.face, cp.other_face
a.distance_to(Line((20, -50, 3), (20, 50, 3))) # 15.0: to an edge (a wire, a face) as well
The distance between bodies is exact, also between faces that are nearly parallel: the closest
points are refined on the faces' own parameters until they settle, and distance_to warns if
they do not. Edges and wires go the same way: from the curve's nearest samples, Newton's method
on the curve's parameter and the face's. A face counts as the sheet it is; an edge, a wire or a
face also measures to a body (edge.distance_to(part)).
Faces, edges and wires have their closest points, and the parameters there:
top = part.faces().top().one()
top.closest_point((0, 0, 30)) # Vector(6, 0, 10): on the face, not in its hole
u, v = top.uv_at((5, 5, 30)) # its parameters as fractions of the face's parameter box
top.position_at(u, v) # the point there; position_at(0.5, 0.5) is the box's middle
edge = part.edges().circles().first
edge.closest_point((10, 0, 0)) # Vector(6, 0, -10)
edge.param_at_point((0, 10, 10)) # 0.25: the fraction of the length, as point_at takes it
Curvature
bore = part.faces().cylinders().one()
c = bore.curvature_at() # at a point inside the face, or the one nearest to a point given
c.k1, c.k2 # 0, -1/6: principal curvatures, negative where the face is hollow
c.dir1, c.dir2, c.normal # their directions and the outward normal
c.mean, c.gaussian
Torus(20, 5).faces()[0].min_radius() # 5: the smallest radius of curvature on the face
edge.curvature_at(0.5) # 1/6: 1 / radius of a curve
Curvatures come from the exact surface, signed by the face's outward normal: a ball is positive,
a bore negative, a plane 0. min_radius() is exact for planes (inf), cylinders and spheres;
other faces are sampled on a 24 x 24 grid of their parameters and refined from the sharpest
points; points within 1e-2 of a pole (by the ratio of the tangents) are left out, where the
derivatives do not give the curvature to all its digits. Compare it with a wall thickness
(thicken inward) or a cutter's radius.
A face's surface itself: its NURBS data (the whole surface, untrimmed; exact for every kind, rational for circles), and its curves of constant parameter, clipped to the face:
n = Cylinder(5, 20).faces().cylinders()[0].nurbs()
n.degrees, len(n.knots[0]), len(n.points), len(n.points[0]) # (2, 1), 12, 9 x 2 control points
top.iso(u=0.5) # two lines: the hole is in the way
bore.iso(v=0.5)[0].length # 37.699: the circle around the bore
torus_face = Torus(20, 5).faces()[0]
torus_face.major_radius, torus_face.radius # 20, 5
Cone(10, 4, 20).faces().filter_by(lambda f: f.geom_type == "cone")[0].cone_angle # 16.7: half the opening
Continuity
How two faces meet along an edge: "G0" at a crease, "G1" where they meet tangentially (a
circular fillet's border), "G2" where their curvature across the edge agrees too (a fillet
with continuity="G2"), None where they leave a gap. The edge is checked at 16 points (or
samples=) for the largest gap, angle between the normals and jump of the curvature across it:
box = Box(30, 30, 30)
edge = box.edges().filter_by(Z).sort_by(X).last
round_part = box.fillet(edge, 5)
blend = box.fillet(edge, 5, continuity="G2")
c = round_part.edges().g1().first.continuity()
c.level, c.angle, c.curvature_jump # 'G1', 0.0, 0.2: tangent, but 1/5 against 0
len(blend.edges().g2()) # 2: both borders of the G2 blend
Polyline((0, 0), (10, 0), (10, 10)).continuity().level # 'G0': the joints of a wire
g1() selects the edges that are at least tangent, g2() the curvature-continuous ones. The
defaults: G1 within 0.5 degrees (the kernel's own tolerance, also smooth()'s), G2 within 1% of
the larger curvature (or 1e-3 / length, where both are nearly flat); angle_tolerance= and
curvature_tolerance= take others. smooth() and convexity look along the whole edge as well: an
edge whose faces meet tangentially only at its middle is not smooth.
Rays
raycast gives the exact points where a ray meets a body (or a face, or a mesh), nearest
first, each with its distance, the face and the outward normal there. What a line of sight
sees first and wall thicknesses along a direction come from it:
part = Box(40, 30, 20) - Cylinder(5, 30)
hits = part.raycast((-50, 0, 0), (1, 0, 0))
print([round(h.distance, 6) for h in hits]) # [30.0, 45.0, 55.0, 70.0]: in, hole, out of it, out
print(hits[1].face.geom_type, hits[1].normal) # cylinder Vector(1, 0, 0): into the hole
top = part.raycast(Axis((10, 8, 50), (0, 0, -1)))[0]
print(top.point) # Vector(10, 8, 10)
To select faces with a ray instead, and chain on, use part.faces().hit_by(origin, direction)
(see Selecting).
Many rays at once (height maps, depth images, visibility, sampling) go through raycast_many:
the first hit of each ray, or None. It traces the rays through a mesh of the part with the
kernel's ray tracer and moves each hit onto the exact face: on a plate with 144 holes 0.06 ms a
ray, where raycast takes 0.8 ms (the first call also meshes the part, 2.4 s there); on a mesh
it is the tracer alone:
import numpy as np
xs, ys = np.meshgrid(np.linspace(-20, 20, 41), np.linspace(-15, 15, 31))
origins = np.column_stack([xs.ravel(), ys.ravel(), np.full(xs.size, 50.0)])
hits = part.raycast_many(origins, (0, 0, -1)) # one direction for all rays
print(sum(h is None for h in hits)) # 69: the rays through the hole
first = part.mesh().raycast_many(origins, (0, 0, -1)) # on the triangles
Inside or outside
classify says where a point lies: in the material, outside (a cavity is outside), or on the
surface, with the vertex, edge or face it lies on. contains is True inside and on the surface.
They are exact; the parity of raycast hits is not where a ray grazes a face or passes through
an edge (a ray tangent to a cylinder hits it once):
part = Box(40, 30, 20) - Cylinder(5, 30)
print(part.classify((10, 8, 0))) # ('inside', None)
print(part.classify((20, 0, 0))[0]) # on: the right side
print(part.contains((10, 8, 0)), part.contains((0, 0, 0))) # True False: in the hole
cells = Box(20, 20, 20).merge(Cylinder(4, 30))
print(round(cells.cell_at((0, 0, 0)).volume, 3)) # 1005.31: the cell of a merged part there
Intersections and projections
box = Box(20, 20, 20)
pipe = Cylinder(4, 40).rotate(X, 30)
box.intersection_curves(pipe) # where the surfaces cross, as edges
box.intersection_curves(Plane.XY.offset(5)) # where a plane cuts the faces ([] where nothing meets)
Sphere(10).silhouette((0, 0, 1)) # the contour seen along a direction: one circle
box.project(CenterArc((0, 0, 30), 5, 0, 360), (0, 0, -1)) # curve onto the faces below
box.outline(Plane.XZ) # the visible edges projected to a plane
seen, hidden = (box - pipe).outline(Plane.XZ, hidden=True) # and the hidden ones
Line((0, 0), (10, 10)).intersections(Line((0, 10), (10, 0))) # [Vector(5, 5, 0)]
Line((0, 0, -20), (0, 0, 20)).intersections(box) # where it enters and leaves the box
Line((0, 0, -20), (0, 0, 20)).intersection_params(box) # [0.25, 0.75] along the line
project fits its curves within 1e-7 of the size of the body and the curve. It gives pieces on
every face the direction meets, the far side and faces seen edge-on too; nearest=True keeps
what the curve sees along the direction, cut where another part of the body hides it. A curve
that misses the body gives an empty list. On a single face, Face.project projects along the
face's normals (each point to its nearest point of the surface), Face.iso gives the curves of
constant surface parameter, and Face.wrap lays a flat drawing onto the face keeping its lengths
along the face's parameter lines, as a label on a can. A curve across a closed face's seam comes
back as one edge from all three:
Sphere(10).project(CenterArc((0, 0, 30), 5, 0, 360), nearest=True) # the top piece only
side = Cylinder(10, 40).faces().cylinders()[0]
side.project(Line((-30, -15, 5), (30, -15, -5))) # onto the cylinder along its normals
side.iso(v=0.5) # the circle half way up
side.wrap(Text("SMLIB", 6), (0, -10, 0), direction=(1, 0, 0)) # lettering around the cylinder
intersections of an edge or a wire takes edges, wires, faces and bodies, gives the points in
order along the edge, and an empty list where there are none. A point counts when it lies within
tolerance of the other shape: 1e-6 by default, or 1e-9 of the size of the two where that is
larger, so lines that miss each other by 0.0005 do not cross. Where two curves overlap, the
overlap's two ends count; where an edge lies on a face, the points where it starts and stops
lying on the face. self_intersections() gives the points where an edge crosses itself.
closest_point(p) and param_at_point(p) give the nearest point of an edge and its fraction along it.
Curvature of edges
s = Spline((0, 0), (10, 5), (20, -3), (30, 4))
s.curvature_at(0.5), s.radius_at(0.5) # 1/radius and radius at a fraction of the length
r, t = s.min_radius(), s.min_radius_at() # 3.9975 at t 0.677: (20.78, -3.17, 0)
s.inflections() # [0.484]: where it changes the side it bends to
w = Wire([Line((0, 0), (10, 0)), Spline((10, 0), (20, 5), (30, 0))])
w.min_radius() # a wire: over its edges (corners do not count)
Spline((0, 0), (1, 1), (2, 0), (30, 0)).bbox().max.y # 1.0069: exact boxes of free curves
min_radius() is the smallest radius of curvature (a float, as a face's min_radius()), and
min_radius_at() where it is, found by the kernel's global solver, not by sampling (it agrees
with OpenCASCADE to 1e-15): a sweep profile that reaches farther from the path than this, or an
offset larger than it on the inner side, folds over itself. A line gives inf, an arc its radius. Sweep errors about the path's curvature
give the path's smallest radius, where it is and how far the profile reaches. The box of a free
curve (one not taken from a body) is exact: its ends, its corners and the points where its
tangent is perpendicular to an axis.
Draft and parting lines
A part that is molded or cast must come out of its mold: every face needs draft, a slight
angle towards the direction in which its mold half moves away. draft_analysis measures the
draft angle of every face for a pull direction and sorts the faces into four groups:
positive (at least min_angle everywhere), insufficient (some draft, but less),
vertical (parallel to the direction) and undercut (turned away from it somewhere).
core = RoundedRectangle(60, 40, 5).extrude(20, taper=3)
up = core.draft_analysis(Z, min_angle=1)
print(len(up.positive), len(up.vertical), len(up.undercut)) # 9 0 1: the walls, the top; the bottom
print(Box(40, 30, 20).draft_analysis(Z).vertical.__len__()) # 4 walls that need draft
The groups are selections of the part's faces, and up.angles(face) gives the least and the
greatest angle of a face in degrees. The bottom of the core turns away from Z: in a two-part
mold it belongs to the other half, which draft_analysis(-Z) checks. Faces without enough draft
get it with part.draft(faces, angle, neutral_plane) (see Features).
parting_line finds where the two halves meet: where the surface turns from facing the
direction to facing away from it, through the middle of curved faces where needed. The lines
are closed wires of exact curves, a circle around a sphere's equator:
equator = Sphere(10).parting_line(Z)[0]
print([e.geom_type for e in equator.edges()], round(equator.length, 6)) # ['circle', 'circle'] 62.831853
print(core.parting_line(Z)[0].bbox().max.z) # 0.0: the core's bottom rim
Meshes
tessellate (or shape.mesh()) turns exact shapes into triangles, with a tolerance on the
distance to the true surface:
mesh = part.mesh(tolerance=0.01)
mesh.vertices, mesh.triangles # numpy arrays
mesh.volume, mesh.area, len(mesh)
A part with a face that stays straight across while it twists along (a twisted part, a ruled
loft between different sections, the walls of a thickened free-form sheet) is divided until
all its faces are flat within the tolerance. Its mesh is right, but it takes many more
triangles: 2076 for Box(20, 20, 100).twist(90), where the box has 12.
The points of a mesh are double precision, and its corner normals are those of the exact surface (at the apex of a cone, where each line of the cone has a normal of its own, the normal beside the corner).
Closed meshes from anywhere (tessellate, import_stl, Mesh(...)) take +, - and &,
for meshes that have no exact part behind them, such as scans. Every result is checked (closed,
a volume the operation can give), and where the kernel fails, which still happens for some
meshes, it raises ModelingError. With exact parts, do the Boolean on the parts and mesh the
result:
corners = Box(20, 20, 20).mesh() - Sphere(12).mesh() # a mesh Boolean
print(corners.is_closed(), round(corners.volume, 2)) # True 1631.68
blob = (Box(20, 20, 20) - Sphere(12)).mesh(tolerance=0.01) # exact first: closer to 1616.28
print(round(blob.volume, 2))
A mesh Boolean's triangles take the normals of the operand triangles they lie on (turned on the inside of a cut), so its result shades as its operands do; meshes without normals give a result without.
max_edge bounds the length of the triangles' edges, for evenly sized triangles (simulation
meshes, deformation, sampling) where the tolerance alone leaves a flat face two long triangles.
tessellate, mesh and the mesh exports (export_stl, export_obj, export_glb,
export_gltf, export_usd) take it:
plate = Box(100, 100, 2) - Cylinder(5, 10)
print(len(plate.mesh(0.1)), len(plate.mesh(0.1, max_edge=10))) # 144 706: edges of 100, under 13
With max_edge every face is meshed by the kernel's grid; a part with a twisted face, which the
grid meshes too far off (see above), gets a RuntimeWarning saying how far, and a smaller
max_edge brings the grid nearer.
Measuring and cutting meshes
Meshes of scans and STL files have no exact part behind them; they are measured and cut on their
triangles. mass_properties gives the mass, center and inertia of what a closed mesh encloses,
exact for its triangles (the same MassProperties as a part's); centroid is its center of mass
(center() is the middle of the bounding box):
scan = (Box(40, 30, 20) - Cylinder(6, 40)).mesh(0.01)
mp = scan.mass_properties(density=7.85e-3) # steel, g/mm³
print(round(mp.mass, 2), scan.centroid) # 170.66 Vector(0, 0, 0)
section(plane) cuts a mesh: the wires where its triangles cross the plane, chained into loops
(outer loops counterclockwise seen from the plane's normal, holes clockwise, as the faces of a
part's section). slices(step) cuts it into layers, a section in the middle of each, and
Part.slices does the same for exact parts (a Sketch per layer):
loops = scan.section(Plane.XY)
print([round(w.length, 3) for w in loops]) # [140.0, 37.69]
region = make_face(loops[0]) - make_face(loops[1])
print(round(region.area, 2)) # 1087.01 (exact: 1086.9)
print(len(scan.slices(5)), len((Box(40, 30, 20) - Cylinder(6, 40)).slices(5))) # 4 4
A plane through a flat part of a mesh (the top face of a box's mesh) cuts it just beside that, on the side where the cut encloses more.
Fewer triangles, closed meshes, faceted parts
decimate thins a mesh out with the kernel's quadric decimation, which keeps a closed mesh
closed: give the share of the triangles to remove, about how many to leave, or how far the result
may be from the mesh (tolerance=, measured by smlibpy at the points of both meshes; the kernel's
own "maximum error" is not a distance). Edges sharper than feature_angle (30 degrees) are kept
longest and stay sharp in the normals; no triangle is left folded over its neighbours. fill_holes closes the holes of an open mesh;
repaired also merges points that lie on each other, leaves out triangles without an area or
repeated, turns triangles that face the wrong way, and says what it did. to_part (or
Part.from_mesh) makes a faceted part of a closed mesh, for exact Booleans and STEP files:
neighbouring triangles in one plane become one face, and a mesh that would give more than
max_faces faces (5000) raises, since Booleans on many faces get slow:
ball = Sphere(10).mesh(0.001)
print(len(ball), len(ball.decimate(0.9))) # 136872 13674 (about; the kernel varies a little)
print(len(ball.decimate(tolerance=0.01)) < 15000) # True: within 0.01 of the fine mesh
holed = Mesh(scan.vertices, scan.triangles[1:]) # a triangle missing
fixed, report = holed.repaired()
print(fixed.is_closed(), report.filled_holes) # True 1
block = scan.to_part() # faceted: 102 faces, the bore's facets and 6
print(block.is_valid(), round(block.volume, 3)) # True 21740.176 (the mesh's volume)
print(round((block - Box(10, 10, 40).translate(20, 15, 0)).volume, 3)) # 21240.176