Skip to main content

Simple 3D mesh generation with Python based on signed distance functions

Project description

📢 Note

This is my fork of fogleman/sdf. This fork is available on GitHub (so it's clear where I forked it from) and on GitLab, where I'll do the deployments, automatic tests and docs at some point. See here for a an effective diff since forking.

📹 Video: My Talk OpenSCAD vs PythonSDF (🇩🇪 German) at the Tübix2023 Linux Day

I added so many things that it doesn't really make sense to just open a PR upstream. Probably I'll just maintain my own fork.

Documentation of the new features is lacking entirely 😅

Here is a rough list:

  • sdf.ease got a huge revamp - easings can now be manipulated and combined easily:
    ease.linear.plot()             # show what it looks like
    ease.Easing.plot(ease.linear, ease.in_cubic, ease.smoothstep) # show multiple for comparison
    3 * ease.linear.symmetric + 1  # This makes a triangle starting at 1 and going as high as 4 in the center
    ease.smoothstep[0.2:0.8]       # zoom into function
    ease.linear + ease.in_sine     # add or multiply functions
    ease.linear.append(ease.in_cubic.reverse) # stitch functions together
    # etc, much more, try 'python -m sdf.ease' for a non-comprehensive overview
    
  • New objects:
    • There's now bezier()-curves! 🥳
    • There's now Thread()s 🔩
      • just a twisted offset infinite cylinder, very nice to 3D print due to the smoothness
      • sphere(10)-Thread().dilate(0.4) makes a nice thread hole with tolerance
      • Screw() with head for convenience
    • pieslice(): a vertically infinite pie slice, useful to cut out parts
  • New operations:
    • mirror() an object at an arbitrary point into an any direction
    • stretch() an object from here to there
    • shear() an object from between two points along a direction
    • modulate_between(): modify an object's thickness between two points with an easing function
    • twist_between(): twist an object between two points with a variable rotation angle specified by an easing function
    • chamfer(): chamfer along a plane
    • shell() can now also do inner and outer shell, not just around boundary Shape analysis (not very precise and reliable yet):
    • .bounds et al.: finding boundaries/closest surface points/intersections via optimization of the SDF
    • volume(): brute-force-approximate the volume Monte-Carlo-style
  • Many usability fixes here and there
    • save() is now properly interruptible, no more zombie worker threads
    • save() now optionally shows the mesh with pyvista (useful in Notebooks)
    • docstrings are shown properly in interactive Python shells
    • k() now creates a copy and doesn't modify the object itself

Some things on my TODO list:

  • [ ] proper PyPI upload
  • [ ] add tests!
  • [ ] auto-generated sphinx documentation
    • [ ] auto-generated examples with code besides renders
  • [ ] importing SVGs 🤔
  • [ ] exporting SVGs (with marching squares / contourplots for 2D SDFs)
  • [ ] importing STLs 🤔

coverage report

PyPI version

Downloads

sdfCAD

Generate 3D meshes based on SDFs (signed distance functions) with a dirt simple Python API.

Special thanks to Michael Fogleman for initializing this codebase in GitHub:fogleman/sdf.

Special thanks also to Inigo Quilez for his excellent documentation on signed distance functions:

Example

Here is a complete example that generates the model shown. This is the canonical Constructive Solid Geometry example. Note the use of operators for union, intersection, and difference.

from sdf import *

f = sphere(1) & box(1.5)

c = cylinder(0.5)
f -= c.orient(X) | c.orient(Y) | c.orient(Z)

f.save('out.stl')

Yes, that's really the entire code! You can 3D print that model or use it in a 3D application.

More Examples

Have a cool example? Submit a PR!

gearlike.py knurling.py blobby.py weave.py
gearlike knurling blobby weave
gearlike knurling blobby weave

Requirements

Note that the dependencies will be automatically installed by when following the directions below.

  • Python 3
  • matplotlib
  • meshio
  • numpy
  • Pillow
  • scikit-image
  • scipy
  • (pyvista, trame, jupyter, ...)

📥 Installation

Quick install with pip:

# optionally make a virtualenv
python -m venv sdf-venv
source sdf-venv/bin/activate
# from PyPI: together with the dependencies to run within Jupyter
pip install 'sdfcad[jupyter]'
# latest development version
pip install 'sdfcad[jupyter] @ git+https://gitlab.com/nobodyinperson/sdfCAD'
# for headless operation
pip install git+https://gitlab.com/nobodyinperson/sdfCAD

To hack on:

git clone https://gitlab.com/nobodyinperson/sdfCAD
cd sdfCAD
poetry install --all-extras --with=dev
poetry shell # enter a shell in the virtual environment

Confirm that it works:

python examples/example.py # should generate a file named out.stl

File Formats

sdf natively writes binary STL files. For other formats, meshio is used (based on your output file extension). This adds support for over 20 different 3D file formats, including OBJ, PLY, VTK, and many more.

Viewing the Mesh

sdfCAD is best worked with in Jupyter Lab:

# launch jupyter lab, this should open your browser with Jupyter Lab
jupyter lab

Alternatively, you can just .save() your object and open the resulting file in your Mesh viewer of choice, e.g.:

  • MeshLab
  • OpenSCAD (save(openscad=True) creates a .scad file you can open. OpenSCAD can also auto-reload if the file changes.)
  • Michael Fogleman's meshview

API

In all of the below examples, f is any 3D SDF, such as:

f = sphere()

Bounds

The bounding box of the SDF is automatically estimated. Inexact SDFs such as non-uniform scaling may cause issues with this process. In that case you can specify the bounds to sample manually:

f.save('out.stl', bounds=((-1, -1, -1), (1, 1, 1)))

Resolution

The resolution of the mesh is also computed automatically. There are two ways to specify the resolution. You can set the resolution directly with step:

f.save('out.stl', step=0.01)
f.save('out.stl', step=(0.01, 0.02, 0.03)) # non-uniform resolution

Or you can specify approximately how many points to sample:

f.save('out.stl', samples=2**24) # sample about 16M points

By default, samples=2**18 is used.

Tip: Use the default resolution while developing your SDF. Then when you're done, crank up the resolution for your final output.

Batches

The SDF is sampled in batches. By default the batches have 32**3 = 32768 points each. This batch size can be overridden:

f.save('out.stl', batch_size=64) # instead of 32

The code attempts to skip any batches that are far away from the surface of the mesh. Inexact SDFs such as non-uniform scaling may cause issues with this process, resulting in holes in the output mesh (where batches were skipped when they shouldn't have been). To avoid this, you can disable sparse sampling:

f.save('out.stl', sparse=False) # force all batches to be completely sampled

Worker Threads

The SDF is sampled in batches using worker threads. By default, multiprocessing.cpu_count() worker threads are used. This can be overridden:

f.save('out.stl', workers=1) # only use one worker thread

Without Saving

You can of course generate a mesh without writing it to an STL file:

points = f.generate() # takes the same optional arguments as `save`
print(len(points)) # print number of points (3x the number of triangles)
print(points[:3]) # print the vertices of the first triangle

If you want to save an STL after generate, just use:

write_binary_stl(path, points)

Visualizing the SDF

The save() method automatically shows the object with pyvista if available (e.g. when you installed with the jupyter extra as described above).

# Turn automatic plot visualization off with this:
sphere().save(plot=False)

In Jupyter, you can try to use client-rendering, which is faster:

import pyvista as pv
pv.set_jupyter_backend("client")

You can plot a visualization of a 2D slice of the SDF using matplotlib. This can be useful for debugging purposes.

f.show_slice(z=0)
f.show_slice(z=0, abs=True) # show abs(f)

You can specify a slice plane at any X, Y, or Z coordinate. You can also specify the bounds to plot.

Note that matplotlib is only imported if this function is called, so it isn't strictly required as a dependency.


How it Works

The code simply uses the Marching Cubes algorithm to generate a mesh from the Signed Distance Function.

This would normally be abysmally slow in Python. However, numpy is used to evaluate the SDF on entire batches of points simultaneously. Furthermore, multiple threads are used to process batches in parallel. The result is surprisingly fast (for marching cubes). Meshes of adequate detail can still be quite large in terms of number of triangles.

The core "engine" of the sdf library is very small and can be found in mesh.py.

In short, there is nothing algorithmically revolutionary here. The goal is to provide a simple, fun, and easy-to-use API for generating 3D models in our favorite language Python.

Files

  • sdf/d2.py: 2D signed distance functions
  • sdf/d3.py: 3D signed distance functions
  • sdf/dn.py: Dimension-agnostic signed distance functions
  • sdf/ease.py: Easing functions that operate on numpy arrays. Some SDFs take an easing function as a parameter.
  • sdf/mesh.py: The core mesh-generation engine. Also includes code for estimating the bounding box of an SDF and for plotting a 2D slice of an SDF with matplotlib.
  • sdf/progress.py: A console progress bar.
  • sdf/stl.py: Code for writing a binary STL file.
  • sdf/text.py: Generate 2D SDFs for text (which can then be extruded)
  • sdf/util.py: Utility constants and functions.

SDF Implementation

It is reasonable to write your own SDFs beyond those provided by the built-in library. Browse the SDF implementations to understand how they are implemented. Here are some simple examples:

@sdf3
def sphere(radius=1, center=ORIGIN):
    def f(p):
        return np.linalg.norm(p - center, axis=1) - radius
    return f

An SDF is simply a function that takes a numpy array of points with shape (N, 3) for 3D SDFs or shape (N, 2) for 2D SDFs and returns the signed distance for each of those points as an array of shape (N, 1). They are wrapped with the @sdf3 decorator (or @sdf2 for 2D SDFs) which make boolean operators work, add the save method, add the operators like translate, etc.

@op3
def translate(other, offset):
    def f(p):
        return other(p - offset)
    return f

An SDF that operates on another SDF (like the above translate) should use the @op3 decorator instead. This will register the function such that SDFs can be chained together like:

f = sphere(1).translate((1, 2, 3))

Instead of what would otherwise be required:

f = translate(sphere(1), (1, 2, 3))

Remember, it's Python!

Remember, this is Python, so it's fully programmable. You can and should split up your model into parameterized sub-components, for example. You can use for loops and conditionals wherever applicable. The sky is the limit!

See the customizable box example for some starting ideas.


Easings

Many transformations in sdfCAD take an optional easing parameter e. This is a scalar function that takes an input between 0 and 1 and output another scalar, typically also between 0 and 1. For convenience, there are many predefined easing functions available, the most important ones probably being ease.linear and ease.smoothstep. Easings can be scaled, added, multiplied, chained, zoomed, etc. to customize them. An easing function can be plotted via its plot() method (e.g. ease.in_out_cubic.plot()) Here is an example of some operations:

ease.Easing.plot(
    ease.linear,
    ease.smoothstep,
    ease.smoothstep.chain(),
    0.5 * ease.in_out_cubic.reverse,
    ease.out_elastic.symmetric,
    ease.smoothstep.symmetric,
    ease.smoothstep.between(-0.5,1),
    ease.smoothstep[0.1:0.75]
    -3*ease.linear + ease.smoothstep
)

easings

Function Reference

3D Primitives

sphere

sphere(radius=1, center=ORIGIN)

f = sphere() # unit sphere
f = sphere(2) # specify radius
f = sphere(1, (1, 2, 3)) # translated sphere

box

box(size=1, center=ORIGIN, a=None, b=None)

f = box(1) # all side lengths = 1
f = box((1, 2, 3)) # different side lengths
f = box(a=(-1, -1, -1), b=(3, 4, 5)) # specified by bounds

rounded_box

rounded_box(size, radius)

f = rounded_box((1, 2, 3), 0.25)

wireframe_box

wireframe_box(size, thickness)

f = wireframe_box((1, 2, 3), 0.05)

torus

torus(r1, r2)

f = torus(1, 0.25)

capsule

capsule(a, b, radius)

f = capsule(-Z, Z, 0.5)

capped_cylinder

capped_cylinder(a, b, radius)

f = capped_cylinder(-Z, Z, 0.5)

rounded_cylinder

rounded_cylinder(ra, rb, h)

f = rounded_cylinder(0.5, 0.1, 2)

capped_cone

capped_cone(a, b, ra, rb)

f = capped_cone(-Z, Z, 1, 0.5)

rounded_cone

rounded_cone(r1, r2, h)

f = rounded_cone(0.75, 0.25, 2)

ellipsoid

ellipsoid(size)

f = ellipsoid((1, 2, 3))

pyramid

pyramid(h)

f = pyramid(1)

Platonic Solids

tetrahedron

tetrahedron(r)

f = tetrahedron(1)

octahedron

octahedron(r)

f = octahedron(1)

dodecahedron

dodecahedron(r)

f = dodecahedron(1)

icosahedron

icosahedron(r)

f = icosahedron(1)

Infinite 3D Primitives

The following SDFs extend to infinity in some or all axes. They can only effectively be used in combination with other shapes, as shown in the examples below.

plane

plane(normal=UP, point=ORIGIN)

plane is an infinite plane, with one side being positive (outside) and one side being negative (inside).

f = sphere() & plane()

slab

slab(x0=None, y0=None, z0=None, x1=None, y1=None, z1=None, k=None)
slab(dx=None, dy=None, dz=None) # symmetric version

slab is useful for cutting a shape on one or more axis-aligned planes.

f = sphere() & slab(dz=1, x0=0)

cylinder

cylinder(radius)

cylinder is an infinite cylinder along the Z axis.

f = sphere() - cylinder(0.5)

Text

Yes, even text is supported!

Text

text(font_name, text, width=None, height=None, pixels=PIXELS, points=512)

FONT = 'Arial'
TEXT = 'Hello, world!'

w, h = measure_text(FONT, TEXT)

f = rounded_box((w + 1, h + 1, 0.2), 0.1)
f -= text(FONT, TEXT).extrude(1)

Note: PIL.ImageFont, which is used to load fonts, does not search for the font by name on all operating systems. For example, on Ubuntu the full path to the font has to be provided. (e.g. /usr/share/fonts/truetype/freefont/FreeMono.ttf)

Images

Image masks can be extruded and incorporated into your 3D model.

Image Mask

image(path_or_array, width=None, height=None, pixels=PIXELS)

IMAGE = 'examples/butterfly.png'

w, h = measure_image(IMAGE)

f = rounded_box((w * 1.1, h * 1.1, 0.1), 0.05)
f |= image(IMAGE).extrude(1) & slab(z0=0, z1=0.075)

Positioning

translate

translate(other, offset)

f = sphere().translate((0, 0, 2))

scale

scale(other, factor)

Note that non-uniform scaling is an inexact SDF.

f = sphere().scale(2)
f = sphere().scale((1, 2, 3)) # non-uniform scaling

rotate

rotate(other, angle, vector=Z)

f = capped_cylinder(-Z, Z, 0.5).rotate(pi / 4, X)

orient

orient(other, axis)

orient rotates the shape such that whatever was pointing in the +Z direction is now pointing in the specified direction.

c = capped_cylinder(-Z, Z, 0.25)
f = c.orient(X) | c.orient(Y) | c.orient(Z)

Boolean Operations

The following primitives a and b are used in all of the following boolean operations.

a = box((3, 3, 0.5))
b = sphere()

The named versions (union, difference, intersection) can all take one or more SDFs as input. They all take an optional k parameter to define the amount of smoothing to apply. When using operators (|, -, &) the smoothing can still be applied via the .k(...) function.

union

f = a | b
f = union(a, b) # equivalent

difference

f = a - b
f = difference(a, b) # equivalent

intersection

f = a & b
f = intersection(a, b) # equivalent

smooth_union

f = a | b.k(0.25)
f = union(a, b, k=0.25) # equivalent

smooth_difference

f = a - b.k(0.25)
f = difference(a, b, k=0.25) # equivalent

smooth_intersection

f = a & b.k(0.25)
f = intersection(a, b, k=0.25) # equivalent

Repetition

repeat

repeat(other, spacing, count=None, padding=0)

repeat can repeat the underlying SDF infinitely or a finite number of times. If finite, the number of repetitions must be odd, because the count specifies the number of copies to make on each side of the origin. If the repeated elements overlap or come close together, you may need to specify a padding greater than zero to compute a correct SDF.

f = sphere().repeat(3, (1, 1, 0))

circular_array

circular_array(other, count, offset)

circular_array makes count copies of the underlying SDF, arranged in a circle around the Z axis. offset specifies how far to translate the shape in X before arraying it. The underlying SDF is only evaluated twice (instead of count times), so this is more performant than instantiating count copies of a shape.

f = capped_cylinder(-Z, Z, 0.5).circular_array(8, 4)

Miscellaneous

blend

blend(a, *bs, k=0.5)

f = sphere().blend(box())

dilate

dilate(other, r)

f = example.dilate(0.1)

erode

erode(other, r)

f = example.erode(0.1)

shell

shell(other, thickness)

f = sphere().shell(0.05) & plane(-Z)

elongate

elongate(other, size)

f = example.elongate((0.25, 0.5, 0.75))

twist

twist(other, k)

f = box().twist(pi / 2)

twist_between

slab(z0=0, dx=5, dy=20, z1=30,k=2).twist_between(
    a = 5*Z,     # start to twist here
    b = 25 * Z,  # stop twisting here
    # smoothstep funtion between 0° and 80°
    e=units("80°").to("radians").m * ease.smoothstep
    ).save()

stretch

Stretch an object from here to there.

# stretch a sphere vertically to make a capsule
sphere(10).stretch(ORIGIN, 20*Z).save()
# specify symmetric=True to stretch equally in the opposite direction

shear

Grab a point and move it into a direction, keeping another point fix. This can be used to perform a shearing operation.

box([20,10,50]).shear(fix=-15*Z, grab=15*Z, move=-10*X, e=ease.smoothstep).save()

The easing function can be used to scale the movement along the way, e.g. to make a notch.

box([20,10,50]).shear(-15*Z, 15*Z, -5*X, e=ease.smoothstep.symmetric).save()

modulate_between

Modulate the thickness between two points.

# make a cylinder
(cylinder(10) & slab(dz=50)).modulate_between(
    -20 * Z, # start modulating here
    20 * Z,  # stop  modulating here
    # easing to apply.
    # symmetric smoothstep function for negative dip.
    e=-5 * ease.smoothstep.symmetric,
).save()

chamfer

This is a wrapper around stretch() and modulate_between() to facilitate a chamfer cut along a plane.

sphere(10).chamfer(2,at=ORIGIN, direction=-Z).save()

bend

bend(other, k)

f = box().bend(1)

bend_linear

bend_linear(other, p0, p1, v, e=ease.linear)

f = capsule(-Z * 2, Z * 2, 0.25).bend_linear(-Z, Z, X, ease.in_out_quad)

bend_radial

bend_radial(other, r0, r1, dz, e=ease.linear)

f = box((5, 5, 0.25)).bend_radial(1, 2, -1, ease.in_out_quad)

transition_linear

transition_linear(f0, f1, p0=-Z, p1=Z, e=ease.linear)

f = box().transition_linear(sphere(), e=ease.in_out_quad)

transition_radial

transition_radial(f0, f1, r0=0, r1=1, e=ease.linear)

f = box().transition_radial(sphere(), e=ease.in_out_quad)

wrap_around

wrap_around(other, x0, x1, r=None, e=ease.linear)

FONT = 'Arial'
TEXT = ' wrap_around ' * 3
w, h = measure_text(FONT, TEXT)
f = text(FONT, TEXT).extrude(0.1).orient(Y).wrap_around(-w / 2, w / 2)

2D to 3D Operations

extrude

extrude(other, h)

f = hexagon(1).extrude(1)

extrude_to

extrude_to(a, b, h, e=ease.linear)

f = rectangle(2).extrude_to(circle(1), 2, ease.in_out_quad)

revolve

revolve(other, offset=0)

f = hexagon(1).revolve(3)

3D to 2D Operations

slice

slice(other)

f = example.translate((0, 0, 0.55)).slice().extrude(0.1)

2D Primitives

circle

line

rectangle

rounded_rectangle

equilateral_triangle

hexagon

rounded_x

polygon

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sdfcad-0.1.3.tar.gz (43.5 kB view details)

Uploaded Source

Built Distribution

sdfcad-0.1.3-py3-none-any.whl (38.2 kB view details)

Uploaded Python 3

File details

Details for the file sdfcad-0.1.3.tar.gz.

File metadata

  • Download URL: sdfcad-0.1.3.tar.gz
  • Upload date:
  • Size: 43.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.6.1 CPython/3.11.5 Linux/5.4.109+

File hashes

Hashes for sdfcad-0.1.3.tar.gz
Algorithm Hash digest
SHA256 af93c3fd034b09bd922698e11321dcaeb6b927969bd4aee6cc5114fa0cb6abf9
MD5 cc500acad742cf1ea5074aafa469bd2b
BLAKE2b-256 538f27915f925f73d1d05b445c44eae67a3c9e6be9bdd981b3a5e2a86d037d21

See more details on using hashes here.

File details

Details for the file sdfcad-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: sdfcad-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 38.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.6.1 CPython/3.11.5 Linux/5.4.109+

File hashes

Hashes for sdfcad-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 b50f16830b73f65c2a54546f0f26c8df7a3ba66fc0a693f59170eeb64eac1597
MD5 bca9f71d893aa18e0b3ddff297c97d65
BLAKE2b-256 82df5f3e6fc3f39ec68e6a376522a447864953d4c74225e1413e4e1bade09654

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page