Skip to main content

Surfaces

Not every shape starts as a solid. Car bodies, consumer products and turbine blades are designed as surfaces first, then closed or thickened into solids. smlibpy models surfaces as Shells: one or more faces that do not enclose a volume.

Open curves make surfaces​

The operations that turn sketches into solids turn open curves into surfaces:

from smlibpy import *

arc = ThreePointArc((0, 0, 0), (10, 5, 0), (20, 0, 0))
wall = extrude(arc, 10) # a curved wall

profile = Spline((5, 0, 0), (8, 0, 10), (4, 0, 20), (6, 0, 30))
vase = revolve(profile, Z) # surface of revolution

hull = loft([Spline((0, 0, 0), (10, 2, 0), (20, 0, 0)),
Spline((0, 0, 10), (10, 6, 10), (20, 0, 10)),
Spline((0, 0, 20), (10, 2, 20), (20, 0, 20))]) # through three curves

ribbon = sweep(Line((-2, 0, 0), (2, 0, 0)), Helix(pitch=20, height=40, radius=10))

Freeform surfaces​

surface makes a NURBS surface from a grid of points, passing through them, or, with interpolate=False, using them as control points for a smoother result:

import math

grid = [[(x, y, 5 * math.sin(x / 12) * math.cos(y / 12)) for x in range(0, 61, 10)]
for y in range(0, 41, 10)]
terrain = surface(grid)
smooth = surface(grid, interpolate=False)

A cloud of points in any order, as a scanner gives them, gets a surface fitted to it: it comes close to the points instead of passing through each one. controls sets the number of control points (default: about one per 8 points) and smoothing how much the surface resists bending; for noisy points, take fewer control points or more smoothing. The cloud must not fold over itself (no overhangs, seen from its best-fit plane), and the surface's normal points up:

import numpy as np

rng = np.random.default_rng(1)
xy = rng.uniform(-30, 30, (2000, 2))
cloud = np.column_stack([xy, 6 * np.exp(-(xy ** 2).sum(axis=1) / 300)])
scan = surface(cloud) # 3 ms
print(round(max(scan.distance_to(p) for p in cloud), 4)) # 0.001: within about 0.001 mm of every point
noisy = cloud + rng.normal(0, 0.1, cloud.shape) * [0, 0, 1]
fair = surface(noisy, controls=12) # follows the shape, not the noise

report=True says how far the points lie from the surface, each to its closest point on it: a cloud that is not a height field gets a surface that misses it by millimetres without an error, and the report shows it (1500 points of a tube r 10 around 288 degrees: max_distance 7.37):

fair, fit = surface(noisy, controls=12, report=True)
print(round(fit.rms_distance, 2), round(fit.max_distance, 2)) # 0.09 0.36: the noise

nurbs_surface builds a surface from its NURBS data, as other programs, files or papers give it: control points in rows, knot vectors, degrees and weights (rational surfaces, any degree, knots that are not uniform or do not repeat at the ends). It is the inverse of a face's nurbs():

net = [[(0, 0, 0), (0, 10, 0), (0, 20, 0)],
[(10, 0, 5), (10, 10, 8), (10, 20, 5)],
[(20, 0, 0), (20, 10, 0), (20, 20, 0)]]
dome = nurbs_surface(net, degrees=2, weights=[[1, 1, 1], [1, 0.5, 1], [1, 1, 1]])
print(round(dome.bbox().max.z, 6)) # 2.571429 = 18/7: the middle weight pulls less

ruled(a, b) joins two curves with straight lines:

twist = ruled(Line((0, 0, 0), (20, 0, 0)), Line((0, 20, 10), (20, 20, -10)))

From surfaces to solids​

thicken gives a surface a wall thickness:

panel = terrain.thicken(2)
print(panel.volume)

stitch sews faces and sheets along common edges into a closed solid, or into a larger sheet if they don't close:

box = Box(10, 10, 10)
sheets = [f.to_shell() for f in box.faces()]
solid = stitch(sheets) # a Part again

It closes gaps up to tolerance (default 1e-4 of the size of the pieces), sewing edges that meet closely first, and leaves wider ones open; report=True says what it did. An open result has its normals made to agree with the first piece's, so it thickens; flipped() turns a sheet over:

body, report = stitch(sheets, report=True)
print(report.sewn_edges, report.open_edges, report.max_gap) # 12 0 0.0
floor = ruled(Line((0, 0, 0), (10, 0, 0)), Line((0, 10, 0), (10, 10, 0))) # normal +Z
wall = ruled(Line((0, 10, 10), (10, 10, 10)), Line((0, 10, 0), (10, 10, 0))) # normal +Y
corner = stitch(floor, wall) # the wall turned to -Y, toward the floor's side
print(corner.thicken(1).volume) # 190.0
print(corner.flipped().thicken(1).volume) # 210.0: the other side

Any face of a part becomes a separate surface with face.to_shell(), or a sketch with face.to_sketch() when it is planar.

Splitting and trimming​

A sheet splits a part along its surface, and a part trims a sheet:

sheet = surface([[(x, y, 2 + 3 * math.sin(x / 6)) for y in np.linspace(-15, 15, 7)]
for x in np.linspace(-15, 15, 13)])
upper, lower = Box(20, 20, 20).split(sheet, keep="both") # "above" is where the normals point
inside = sheet & Cylinder(5, 30) # the piece of the sheet inside the cylinder: a Shell
outside = sheet - Cylinder(5, 30) # the sheet with a hole
print(round(inside.area + outside.area - sheet.area, 9)) # 0.0

sheet.intersect(part) and sheet.cut(part) are & and - by name. part & sheet is sheet & part; part + sheet and part - sheet raise TypeError (a sheet encloses no volume: split the part, or imprint the sheet's lines into its faces).

Offset surfaces​

offset moves a sheet along its face normals and keeps it a sheet: the inner skin of a molded panel, a clearance surface, a tool path surface. Where faces meet, they are extended or trimmed to meet again, as Part.offset does; a face of a part gives a sheet of its offset surface.

skin = extrude(CenterArc((0, 0), 10, 0, 180), 20) # normals point away from the axis
print(round(skin.offset(2).area, 4)) # 753.9822 = pi * 12 * 20
cup = stitch([f for f in Box(20, 20, 20).faces() if f.normal_at().z < 0.5])
print(round(cup.offset(-1).area, 1)) # 1692.0: 18 x 18 and four 18 x 19
lid = Box(20, 20, 20).faces().top().one().offset(5) # the top face's copy, 5 above it

The faces must share their edges to move together; join separate faces with stitch first. smlibpy checks the result: every face at the distance from the sheet, on the side the distance points to. An offset beyond the radius of a concave curvature raises ModelingError.

thicken and the function thicken(surface, t, both=True) make a solid of a sheet instead: both puts half of the thickness on each side.

Blends between surfaces​

fillet rounds the edges of a part. Two sheets that are no part of one body, two crossing pipes or a panel and a post, get a round blend with surface_fillet: a ball of the radius rolls between the two surfaces, on the side of each that sides says (1: where its normal points, -1: the other side). The blend is a sheet of its own:

floor = Rectangle(40, 40).faces()[0] # normal +Z
wall = Rectangle(40, 40).on(Plane.XZ).faces()[0] # normal -Y
rounding = surface_fillet(floor, wall, 5) # above the floor, on the -Y side of the wall
print(round(rounding.area, 4)) # 314.1593: a quarter circle, 40 long

pipe_a = Cylinder(5, 60).faces().cylinders()[0]
pipe_b = Cylinder(4, 60).rotate(X, 90).faces().cylinders()[0]
joint = surface_fillet(pipe_a, pipe_b, 1.5) # around both places where they cross

trim=True cuts both faces back to the blend's long edges and sews the three into one sheet, ready to thicken:

one = surface_fillet(floor, wall, 5, trim=True)
print(len(one.faces()), round(one.area, 4)) # 3 1514.1593: 600 + 600 + the blend
plate = one.thicken(1)

shape="chamfer" makes the blend flat: straight across between the lines where the round of the same radius meets the surfaces. radius2 gives a chamfer a second distance, from b (for faces at right angles: its edge on b lies radius from a, its edge on a lies radius2 from b):

bevel = surface_fillet(floor, wall, 5, shape="chamfer")
print(round(bevel.area, 4)) # 282.8427: 40 long, 5 sqrt(2) wide
steep = surface_fillet(floor, wall, 2, radius2=4, shape="chamfer")
print(round(steep.area, 4)) # 178.8854: from y -4 on the floor to z 2 on the wall

Bridges between sheets​

Two sheets that do not meet get a smooth bridge between two of their border edges with blend: a sheet that leaves one across its edge and reaches the other across its edge, tangent to both (continuity="G1"), with the same curvature across the edges too ("G2", the default) or also the same change of curvature ("G3"). tension sets how far it runs on in each sheet's direction before it turns, as a part of the gap (one number, or one per edge). The blend joins the nearest ends of the two edges, whichever way they run, and its normal follows the first sheet; join=True sews it to both sheets:

low = Rectangle(20, 20).translate(-20, 0).faces()[0].to_shell()
high = Rectangle(20, 20).on(Plane.XY.offset(10)).translate(20, 0, 0).faces()[0].to_shell()
edge_low = low.edges().filter(lambda e: abs(e.center().x + 10) < 1e-9).one()
edge_high = high.edges().filter(lambda e: abs(e.center().x - 10) < 1e-9).one()
bridge = blend(edge_low, edge_high) # G2: flat where it meets the flat sheets
print(round(bridge.area, 3)) # 466.054
step = blend(edge_low, edge_high, tension=(0.5, 2), join=True)
print(len(step.faces()), step.thicken(1).is_valid()) # 3 True

Two closed edges, such as the rims of two tubes, are bridged all round: each point of the first edge is joined to the point of the second at the same fraction of its length, counted from the point nearest the first edge's start and running the same way round. The blend comes in two faces sewn along two seams (the kernel offsets no face that closes around), as smooth across them as elsewhere, so the sewn sheet thickens:

wide = Cylinder(10, 20).faces().cylinders()[0].to_shell() # z -10..10
narrow = Cylinder(6, 20).translate(0, 0, 40).faces().cylinders()[0].to_shell() # z 30..50
top = wide.edges().filter(lambda e: e.center().z > 5).one()
bottom = narrow.edges().filter(lambda e: e.center().z < 35).one()
neck = blend(top, bottom, join=True) # 4 faces, the two far rims free
print(neck.thicken(1).is_valid()) # True

Trimming sheets with curves​

trim cuts a sheet along curves that lie on it, and keeps the side of a point, or with keep="split" both sides as faces of their own. The curves come from where sheets meet (intersection_curves), from a surface_fillet's long edges, or from project: a hole or an outline along a direction is the outline projected onto the sheet first. A curve may run across several faces of the sheet; each one runs from border to border, or closes a loop. face.split(curves) gives the pieces of one face:

f = lambda x, y: 5 + 0.01 * (x - 20) ** 2 - 0.01 * (y - 15) ** 2
saddle = surface([[(x, y, f(x, y)) for y in range(0, 31, 5)] for x in range(0, 41, 5)])
outline = saddle.project(CenterArc((20, 15, 50), 5, 0, 360), (0, 0, -1))
holed = saddle.trim(outline, keep=(2, 2, 0)) # a point outside the outline: the hole
disk = saddle.trim(outline, keep=(20, 15, 0)) # a point inside: the disk
print(round(holed.area + disk.area, 3), round(saddle.area, 3)) # 1248.607 1248.607

halves = one.trim(one.intersection_curves(Rectangle(100, 100).on(Plane.YZ).faces()[0].to_shell()), "split")
print(len(halves.faces())) # 6: each of the 3 faces cut in two
pieces = floor.split(Line((-20, -20), (20, 20))) # two triangles of 800

To give a region of a part's face a face of its own (a label area, a parting line), imprint the curves on the part: features.

Filling holes and curve networks​

fill closes a loop of curves with a patch: four sides make a Coons patch, five and more an N-sided fill, three an N-sided fill through the curves, or, tangent to sheets, a Coons patch with one side shrunk to a point. Where the curves are border edges of sheets, the patch meets the sheets tangentially (tangent=False runs it through the curves only):

cut = Sphere(10) - Box(30, 30, 30).translate(0, 0, 8 + 15)
dome = stitch([f for f in cut.faces() if f.geom_type == "sphere"])
cap = fill(dome.edges().filter(lambda e: len(e.faces()) == 1)) # tangent to the sphere
ball = stitch(dome, cap)
print(type(ball).__name__, round(ball.volume, 2)) # Part 4141.58

sides = [Spline((0, 0, 0), (5, 0, 2), (10, 0, 0)), Spline((10, 0, 0), (10, 5, 1), (10, 10, 0)),
Spline((10, 10, 0), (5, 10, 2), (0, 10, 0)), Spline((0, 10, 0), (0, 5, 1), (0, 0, 0))]
patch = fill(sides) # a Coons patch, any order
print(round(patch.area, 4)) # 112.2176

Coons patches meet their sheets to 0.02 degrees; the kernel's N-sided fill (five sides and more) only to about 1.4 degrees. network builds a surface through crossing curves (a Gordon surface): u curves that each cross every v curve, the outer ones bounding it. The curves are made to cross at common parameters first, and the surface runs through them to 1e-7 of their size:

h = lambda x, y: math.sin(math.pi * x / 10) * (1 + 0.03 * y) + 0.5 * math.sin(math.pi * y / 10)
us = [Spline(*[(x, y, h(x, y)) for x in (0, 2, 5, 7.5, 10)]) for y in (0, 5, 10)]
vs = [Spline(*[(x, y, h(x, y)) for y in (0, 3, 5, 8, 10)]) for x in (0, 5, 10)]
skin = network(us, vs)

Extending and untrimming​

extend grows a sheet of one face past its sides (face.extend any face's surface), to make it reach across a part before it cuts or splits it, or to give a blend room: by distance along the surface, continuing it as it is ("natural": planes stay planes, cylinders and spheres the same ones) or along its tangents ("G1") or curvature ("G2"). face.untrimmed() is the whole surface a face lies on, without its holes and cut borders:

top = Box(10, 10, 10).faces().filter(lambda f: f.normal_at().z > 0.5)[0]
print(top.extend(3).area) # 256.0: a plane 16 x 16
wall = (Box(20, 20, 20) - Cylinder(4, 40)).faces().filter(lambda f: f.geom_type == "cylinder")[0]
print(round(wall.untrimmed().area, 2)) # 1005.31 = 2 pi 4 40

Matching edges and merging faces​

match_edge moves an edge of a sheet onto a curve: the edge then runs along the curve exactly, and its face bends to follow near the edge only (within extent, a part of the face across from the edge, default 0.25). Match one sheet's edge to another's to close a gap before stitch, or to a curve to give a sheet a styling line. Where the curve's ends are not the edge's, the edges next to it bend near those ends, and faces beyond them follow (with a crease along those edges: matching keeps no tangent across them). merge_faces makes two faces that share a whole side one face on one surface; the halves of a sheet split along a curve of its surface come back as the one face they were:

panel = surface([[(x, y, 2 * math.sin(x / 10)) for y in range(0, 25, 5)] for x in range(0, 35, 5)])
roof = surface([[(x, y, 2 * math.sin(x / 10) + 0.4) for y in range(21, 41, 5)] for x in range(0, 35, 5)])
edge = panel.edges().sort_by(Axis.Y)[-1] # y = 20; the roof starts at y = 21, 0.4 higher
closed = panel.match_edge(edge, roof.edges().sort_by(Axis.Y)[0])
body, report = stitch(closed, roof, tolerance=1e-6, report=True)
print(report.sewn_edges, report.max_gap) # 1 0.0

halves = panel.trim(panel.intersection_curves(Plane((15, 0, 0), (0, 1, 0), (1, 0, 0))), "split")
one = halves.merge_faces(*halves.faces())
print(len(halves.faces()), len(one.faces()), round(one.area, 6)) # 2 1 605.679201

Both work on faces bounded by the four sides of their surface (as surface, loft, ruled, fill, network and nurbs_surface make them); faces with holes or trimmed borders raise ValueError. intersection_curves takes a Plane too (above), and gives an empty list for bodies that do not meet.

Shaping faces: push, warp, flatten​

Three local edits change one face of a part or a sheet and leave its edges where they are (an edit that would move one raises ValueError): push moves a point of the face, exactly, with a smooth dent or bump around it; warp raises or sinks a region drawn on the face; flatten presses a region onto a plane. The face becomes a B-spline surface: its own surface plus the change, exactly, so a plane or a cylinder stays exactly what it was away from the edit:

lid = Box(60, 40, 6)
top = lid.faces().sort_by(Axis.Z)[-1]
dented = lid.push(top, (15, 0, 3), by=-1.5, radius=10) # 1.5 deep, 10 around
print(round(dented.volume, 2), dented.is_valid()) # 14333.33 True
pad = lid.warp(top, Rectangle(20, 16).translate(-15, 0).on(top), 2) # a raised pad, 2 at its top
print(round(pad.bbox().max.z, 9)) # 5.0
dome = surface([[(x, y, 8 - 0.01 * ((x - 20) ** 2 + (y - 20) ** 2)) for y in range(0, 41, 5)] for x in range(0, 41, 5)])
face = dome.faces()[0]
seat = [face.raycast((x, y, 50), (0, 0, -1))[0].point for x, y in ((15, 15), (25, 15), (25, 25), (15, 25))]
flat = dome.flatten(face, seat, Plane.XY.offset(7.5)) # flat at z 7.5 inside the seat
print(round(flat.faces()[0].raycast((20, 20, 50), (0, 0, -1))[0].point.z, 9)) # 7.5

push takes the move as by (a distance along the face's normal, or a vector) or to (a point), normal (the face's normal there afterwards), radius (how far along the face it changes; default: up to the nearest edge) and continuity ("G0", "G1", "G2": how the face stays joined to its neighbours where the change reaches its border). A region for warp and flatten is a closed wire on the face (on a curved face, project an outline onto it), a sketch placed on the face, or polygon points; the change stays within the rectangle around the region along the face's two directions, and continuity ("G2", "G1", "crease") sets how it meets the rest of the face. Unlike a Lattice (deform), which moves the whole part, these touch one face only, and the point or region exactly.

Curves as NURBS​

edge.nurbs() gives the degree, knots, control points and weights of any edge (arcs as exact rational curves), and Edge.from_nurbs makes an edge of such data (control points read from elsewhere, or moved; many spans). make_compatible turns curves into B-splines of one degree and one knot vector, whose control points correspond one to one, as code that builds surfaces between curves needs them; remove_knots takes the knots out again that a curve does not need:

a = Bezier((0, 0, 0), (1, 3, 0), (4, 3, 1), (6, 0, 0))
b = Spline((0, 0, 5), (2, 1, 5), (3, -1, 5), (4, 2, 5), (6, 0, 5))
ca, cb = make_compatible(a, b)
print(len(ca.nurbs().points), len(cb.nurbs().points)) # 7 7, the same knots
print(len(ca.remove_knots().nurbs().points)) # 4: the Bezier again

Quality​

SMLib's fillets come in curvature-continuous variants, which matter most on surfaces that reflect light:

body = Box(60, 40, 20)
body = body.fillet(body.edges(), 6, continuity="G2")

See features.