Sketches
A Sketch is a planar region: one or more faces on a plane, possibly with holes. Most parts
begin as a sketch that is extruded or revolved. Sketches are drawn in the XY plane around the
origin and placed afterwards; every operation returns a new sketch and leaves its input as it
was. Lines stay lines and arcs stay circles through every 2D operation, so areas, radii and the
faces of the parts made from them are exact.
There are three ways to make the outline: standard shapes, the Profile pen for outlines drawn
segment by segment, and shapes built from other geometry (Hull, Stroke, make_face).
Shapes
Every shape is centered on the origin (an ArcSlot by the center of its arc; a Polygon keeps
its points where they are). They share their options: rotation turns the shape about its
center (degrees, counterclockwise), align moves it relative to the origin, and the shapes with
corners take fillet (round every corner with that radius) or chamfer (bevel every corner by
that length).
from smlibpy import *
Rectangle(40, 20) # width along X, height along Y
Rectangle(80, 50, fillet=6) # rounded corners
Rectangle(40, 20, chamfer=3, rotation=30)
Circle(10)
Ellipse(15, 8, rotation=30)
Slot(30, 10) # 30 long overall, 10 wide
ArcSlot(40, 6, 60, rotation=15) # along an arc of radius 40 around the origin
RegularPolygon(10, 6) # hexagon, corners at radius 10
RegularPolygon(10, 6, inscribed=True) # hexagon 20 across the flats
Triangle(40, 20) # base 40, height 20, apex above the middle
Triangle(30, 40, apex=0) # right angle at the left end of the base
Trapezoid(40, 15, 60, fillet=2) # base 40, height 15, 60 degree sides
Polygon((0, 0), (30, 0), (30, 10), (10, 25), (0, 25)) # points stay where they are
align puts a corner or a side on the origin instead of the center:
Rectangle(40, 20, align=Align.MIN) has its lower left corner there. RoundedRectangle(w, h, r)
is the older name of Rectangle(w, h, fillet=r).
Drawing an outline with Profile
Profile is a pen. It starts at a point with a heading (degrees counterclockwise from +X) and
draws lines and arcs one after the other, each from where the previous one ended. That suits
outlines with tangent arcs, rounded inner corners, revolve sections and symmetric parts, which
would be awkward to build from shapes.
section = (Profile((0, 0))
.line_to(12, 0) # to a point
.line_to(y=4) # straight up to y = 4
.fillet(1.5) # round the corner the pen is at
.line_to(x=6)
.line_to(y=20)
.chamfer(1)
.line_to(x=0)
.close()) # back to the start: a Sketch
bushing = section.revolve(Axis((0, 0, 0), (0, 1, 0)))
The moves:
| call | draws |
|---|---|
line_to(x, y), line_to(x=...), line_to(y=...) | a line to a point, or to an x or y value with the other coordinate kept |
line_by(dx, dy) | a line by an offset |
forward(length), forward(x=...), forward(y=...) | a line along the heading, for a length or until x or y reaches a value |
turn(angle), turn_to(heading) | no line: the next one starts at a corner |
arc(radius, angle) | an arc tangent to the heading; a positive angle turns left |
arc_to(x, y) | a tangent arc that ends at the point |
arc_to(x, y, through=(px, py)) | an arc through a point |
fillet(radius), chamfer(length) | round or bevel the corner at the pen, between the segment before and the one after |
mirror(axis) | add the mirror image of everything drawn so far |
close() returns the sketch and wire() the open path, for example as a sweep path. Every call
returns a new Profile, so a drawing can branch into variants, and point and heading tell
where the pen is:
base = Profile((0, 0)).line_to(20, 0).line_to(20, 10)
square = base.line_to(0, 10).close()
round_top = base.arc_to(0, 10).close()
print(base.point, base.heading) # Vector(20, 10, 0) 90.0
path = Profile((0, 0)).line_to(20, 0).fillet(5).line_to(20, 20).wire()
pipe = sweep(Circle(1), path)
Symmetric outlines are drawn as one half, from the mirror axis around to the axis again, and
completed with mirror. The corners rounded on the half are rounded on the other half too:
bracket = (Profile((0, 0))
.line_to(30, 0).line_to(y=6)
.line_to(x=10).fillet(4)
.line_to(y=30)
.arc(10, 90) # a quarter circle over the top
.mirror(Y)
.close())
close() checks the outline. A crossing, a fillet that does not fit or an arc that cannot be
drawn raises a ModelingError that names the segment by the call that drew it and gives the
numbers, for example fillet(20) at (10, 0) does not fit: it needs 20 along segment 1, line_to(10, 0), which is 10 long; use a smaller radius.
Combining
plate = Rectangle(60, 40, fillet=6) - Circle(8) - GridLocations(44, 24, 2, 2) * Circle(2.5)
cross = Rectangle(40, 8) + Rectangle(8, 40)
lens = Circle(10).translate(-5, 0) & Circle(10).translate(5, 0)
+ unites, - subtracts, & intersects and ^ keeps what is in exactly one of them; by name
they are fuse, cut, intersect and xor, as methods and functions (a.cut(b),
cut(a, b)). The sketches must lie in the same plane. A list or a pattern on the right side is
combined in one step, which is fast even for hundreds of holes. A result can be empty (cutting
away everything, or intersecting regions that do not meet); extruding an empty sketch raises.
Hulls and strokes
Hull makes the convex outline around points and shapes, as if a string were pulled tight
around them; around circles it follows the arcs exactly. Stroke makes the region a pen of
some width covers along a path: lines and arcs exactly, splines and ellipses within 1e-6 of
the path's size.
lever = Hull(Circle(10), Pos(60, 0) * Circle(6)) - Circle(5) - Pos(60, 0) * Circle(3)
slot = Stroke(Line((10, 5), (40, 20)), 6) # a slot between two centers
rib = Stroke(Polyline((0, 0), (30, 0), (30, 20)), 3, ends="flat", corners="sharp")
groove = Stroke(Spline((0, 0), (20, 8), (40, 0)), 4) # area 188.283
Changing a sketch
offset grows or shrinks a region; holes move the other way. The corners that the offset pulls
apart become arcs (corners="round"), stay sharp ("sharp") or are cut ("chamfer"). Parts
that grow into each other merge, thin parts vanish when shrinking, and shrinking away the
whole region raises.
outline = Rectangle(80, 50, fillet=6)
gasket = outline.offset(3) - outline # a 3 wide band around the outline
frame = outline - outline.offset(-4, corners="sharp")
fillet and chamfer change corners. Without a selection they change every sharp corner;
otherwise they take vertices of the sketch, selected by geometry like the edges of a part:
convex() for outside corners, concave() for inside corners (also the corners of a hole),
or by position.
angle = Polygon((0, 0), (40, 0), (40, 8), (8, 8), (8, 30), (0, 30))
angle = angle.fillet(4, angle.vertices().concave()) # the inside corner
angle = angle.chamfer(1, angle.vertices().convex()) # the five outside corners
tab = Rectangle(30, 10).fillet(5, Rectangle(30, 10).vertices().right())
Corners next to splines and ellipses are rounded and beveled too: the arc's center lies at the radius from both curves, and the curves are cut where it touches them.
bottle = make_face(Wire([Line((0, 0), (10, 0)), Spline((10, 0), (14, 4), (12, 8), (10, 10)),
Line((10, 10), (0, 10)), Line((0, 10), (0, 0))]))
rounded = bottle.fillet(2) # all four corners, two of them at the spline
mirror reflects a sketch about an axis in its plane (or in a plane). The mirrored sketch
keeps facing the same way, so it extrudes to the same side:
half = Polygon((0, 0), (20, 0), (20, 5), (5, 20), (0, 20))
whole = half + half.mirror(Y)
From curves
Closed curves you already have, splines or edges taken from a part, become a region with
make_face; further closed wires are holes:
outline = Polyline((0, 0), (50, 0), (50, 10), (20, 30), (0, 30), close=True)
bolt_hole = Wire([CenterArc((12, 12), 4, 0, 360)])
bracket = make_face(outline, bolt_hole)
blob = make_face(Spline((0, 0), (10, 5), (0, 10), (-10, 5), closed=True))
The curve constructors (Line, CenterArc, ThreePointArc, RadiusArc, TangentArc,
Spline, Bezier, ...) are described in the API reference. Among them are
conic arcs and flat spirals, exact where NURBS can be (a conic is one rational quadratic), and
edges made from NURBS data, the inverse of edge.nurbs():
reflector = ParabolicArc((0, 0), 25, -40, 40) # y = x^2 / 100 from x = -40 to 40, focus (0, 25)
waist = HyperbolicArc((0, 0), 10, 20, -30, 30) # x^2/10^2 - y^2/20^2 = 1; revolve about Y
blend = ConicArc((0, 0), (10, 0), (10, 10), 0.6) # a rho conic: 0.5 a parabola, more is fuller
coil = Spiral(2, 20, 5) # flat: from radius 2 to 20 in 5 turns
n = Spline((0, 0), (10, 5), (20, 0)).nurbs()
pulled = Edge.from_nurbs(n._replace(points=[*n.points[:2], n.points[2] + Vector(0, 3), *n.points[3:]]))
A closed Spline is as smooth at its seam as everywhere else (curvature continuous), and
splines through thousands of points take milliseconds.
Cutting, joining and extending curves
Edges and wires are cut where they meet other shapes, trimmed, extended and joined. Positions
along an edge are fractions of its length, as point_at takes them, or points on it; the
pieces keep their curve, so a trimmed arc is still an arc with its radius and center.
s = Spline((0, 0), (10, 10), (20, 0))
x = s.intersections(Line((0, 4), (20, 4))) # [(2.254, 4, 0), (17.746, 4, 0)]
left, rest = s.split(x[0]) # the lengths add up to s.length
middle = s.trim(x[0], x[1]) # the arch between the two crossings
s.param_at_point(x[0]), s.closest_point((10, 20)) # 0.1553, (10, 10, 0)
arc = CenterArc((0, 0), 10, 0, 180).trim(0.25, 0.75) # an arc from 45 to 135 degrees
longer = Line((0, 0), (10, 0)).extend(2, end="both") # (-2, 0) to (12, 0)
runout = s.extend(5, end="both", continuity="G1") # straight on along the end tangents
rail = FilletPolyline((0, 0), (30, 0), (30, 20), radius=5).to_edge() # one B-spline edge
a, b = Polyline((0, 0), (10, 0), (10, 10)).split(0.25) # 5 and 15 long
intersections(other)takes an edge, a wire, a face, a body or a list of them, and gives the points in order along the edge;intersection_params(other)gives their fractions, ready forsplitandtrim. Where two curves run along each other, the overlap's two ends count. No point is an empty list. Points withintolerance(1e-6, or 1e-9 of the size of the two where that is larger) count as on the other shape and as one point.extendcontinues lines and arcs (circles, ellipses) as themselves, other curves straight along the end tangent (continuity="G1"), with the end curvature ("G2") or as their own polynomial goes on ("natural"), by exactly the distance given.Wire.to_edge()joins edges that run end to end into one B-spline: a single rail forsweepandloft, a single SPLINE in DXF.
Parallel curves, fitted curves, lines and arcs
offset moves an edge or a wire sideways in its plane: an open one to its left for a positive
distance (seen from the side the plane's normal points to; for a wire in XY, from above), a
closed one outward. Lines and arcs stay exact; splines and ellipses are offset within 1e-6 of
the wire's size. The corners the offset pulls apart are rounded, extended (corners="sharp")
or cut ("chamfer"), as for sketches; where the sides cross, they are cut there.
path = Polyline((0, 0), (40, 0), (40, 30))
wall = path.offset(2) # inside the turn: 66 long
outer = path.offset(-2, corners="sharp") # outside: 74 long, a sharp corner
rail = Spline((0, 0), (20, 8), (40, 0)).offset(3) # a parallel spline, 3 to the left
inner = outline.offset(-1) # 1 inside a closed outline
An offset that would fold over itself raises ModelingError with the place: a curve that bends
more tightly than the distance on that side (its smallest radius of curvature, min_radius(),
is below it), an edge that vanishes between two corners, or parts of the wire that lie closer
together than the distance. The offset of the region a closed wire bounds, which merges and
drops such parts, is Sketch.offset.
fit_curve makes a smooth curve near many points, with as few control points as keep it within
a tolerance of every point: for scans, digitized outlines and sampled curves, where Spline
passes through every point and needs a control point for each. Spline also takes a direction
for every point.
import math
scan_points = [(x / 10, 8 * math.sin(x / 90) + 0.01 * math.sin(37.3 * x)) for x in range(600)]
outline = fit_curve(scan_points, tolerance=0.05) # 12 control points (Spline: 602)
s = Spline((0, 0), (10, 0), (20, 10), tangents=[(1, 0), (1, 1), (0, 1)])
to_arcs turns an edge or wire into lines and circular arcs within a tolerance (default 0.01
mm), joined tangentially, for cutters and DXF readers that take only LINE and ARC; export_dxf(..., curves="arcs") writes splines and ellipses so.
cut = Spline((0, 0), (10, 5), (20, -3), (30, 4)).to_arcs(0.01) # 26 arcs (52 for 0.001)
Editing curves
through bends an edge so that it passes through a point (and there in a direction, if
given), with the smallest move of its control points and its ends and end directions kept;
match moves its ends and sets its directions there, bending the rest smoothly. Wire.fillet
rounds the corners of a wire as Sketch.fillet rounds a region's: between lines and arcs
exactly, next to splines and ellipses through their offsets.
path = Spline((0, 0), (20, 10), (40, 0))
higher = path.through((20, 15)) # through (20, 15), the ends where they were
level = path.through((20, 15), tangent=(1, 0)) # and running along X there
snapped = path.match(end=(45, 5), end_tangent=(1, 0)) # the end moved, the shape kept
route = Wire([Line((0, 0), (10, 0)), ThreePointArc((10, 0), (13, 5), (10, 10)), Line((10, 10), (0, 10))])
smooth = route.fillet(2) # every corner, also those at the arc
A point given to fillet that is not a corner, or a corner that is smooth, raises
ModelingError, as for sketches.
Placing a sketch
Sketches move like any shape. Usually they go onto a plane or onto a face of a part:
on_front = Rectangle(20, 10).on(Plane.front) # stands up in XZ
up_high = Plane.XY.offset(15) * Circle(5) # Plane * sketch is the same as .on()
part = Box(50, 30, 10)
on_face = Slot(20, 6).on(part.faces().top()) # centered on the top face
off_center = (Pos(10, 5) * Circle(3)).on(part.faces().top())
On a face, the sketch's X axis follows world X where it can; on vertical faces its Y axis
points up. face.plane shows the exact frame.
Text
Text turns lettering into a sketch with the exact outlines of a font. size is the height of
the capital letters in millimeters, the nominal height of technical lettering, so Text("H", 10)
is exactly 10 mm tall. Round letters reach a little above and below the capitals, and
descenders such as the tail of a g go below the baseline.
label = Text("SMLIB 42", 8)
print(len(label.faces()), round(label.area, 2)) # 7 pieces, 118.7 mm²
Straight parts of the letters are lines; each curved run is one B-spline that passes through the
font's own points, so the outlines are the font's, not an approximation of them. Counters are
holes: an o is one face with one hole, a B has two, an i is two faces. Fonts fill their
outlines by the nonzero rule, and contours that overlap are united.
The text is centered on the origin. align places its layout box, which runs from the start to
the end of the line and from the baseline to the cap height: Align.MIN puts the start of the
text on x = 0 and the baseline on y = 0, Align.MAX the end and the cap height. Labels aligned
this way share their baseline whatever letters they contain.
two_lines = Text("SMLIB\nNURBS", 5, align=Align.MIN, line_spacing=1.6) # last baseline on y = 0
wide = Text("A B C", 5, spacing=1.0) # 1 mm more between letters
A line break starts a new line; line_spacing is the distance between baselines as a multiple of
size. spacing adds millimeters between neighbouring letters, and the font's kerning pairs
(AV, To) are applied unless kerning=False.
Text is engraved or embossed like any sketch: place it on a face, extrude it, and subtract or add the result. The volume changes by the text's area times the depth.
plate = Box(70, 24, 5)
top = plate.faces().top()
raised = plate + Text("SMLIB 42", 8).on(top).extrude(0.8)
engraved = plate - Text("SMLIB 42", 8).on(top).extrude(-0.5)
path= sets one line of text along an edge or wire in the XY plane. Each letter is placed upright
on the curve and turned with it; the letters are not bent. The text reads in the direction of the
path and stands on its left side, so a clockwise arc carries it over the top of a circle:
ring = Text("ALONG AN ARC", 4, path=CenterArc((0, 0), 30, 170, -160))
The default font is Lato Regular, which is bundled under the SIL Open Font License and covers
Latin-1, Polish and common symbols such as °, ±, µ and Ø. font= takes the path of a TrueType
or OpenType file (.ttf, .otf, .ttc, .woff, .woff2), or a Font that load_font read.
Fonts are read without other packages: web fonts as browsers download them (WOFF2), CFF fonts with
accents built from two glyphs (seac), and variable fonts (CFF2), of which the default instance is
drawn. A character the font does not have raises an error that names it. Each character is one
glyph: ligatures, right-to-left scripts and combining accents are not handled.
Measuring
s = Rectangle(40, 20, fillet=4) - Circle(5)
s.area # 707.725666
s.perimeter # 144.548668: outline and hole
s.center() # area centroid
s.bbox()
len(s.faces()), len(s.faces()[0].inner_wires())
[e.geom_type for e in s.edges()] # 'line' and 'circle'
From here, sketches become parts: parts.