Python bindings for Fidget — a library for fast, JIT-compiled Signed Distance Field (SDF) evaluation.
pip install maturin numpy
git clone "https://github.com/mkeeter/fidget"
cd fidgetpy && maturin developimport fidgetpy as fp
import fidgetpy.shape as fps
# Build shapes from primitives and combine them
sphere = fps.sphere(1.0)
box = fps.box(1.0, 1.0, 1.0)
scene = fp.ops.smooth_union(sphere, box.translate(0.8, 0.0, 0.0), 0.2)
# Mesh it — returns a Mesh with .vertices and .triangles
m = fp.mesh(scene, depth=6)
# Or write straight to PLY
fp.mesh(scene, output_file="scene.ply", depth=6)| Module | What's in it |
|---|---|
fp |
x/y/z(), var(), eval(), eval_grad(), mesh(), to_vm/frep(), from_vm/frep() |
fp.shape (fps) |
Primitives: sphere, box, cylinder, torus, cone, capsule, … |
fp.ops (fpo) |
Boolean and blending ops: union, intersection, smooth_union, onion, … |
fp.math (fpm) |
Math helpers: clamp, mix, sin, atan2, hsl, translate, rotate, gradient, normal, diffuse, … |
m = fp.mesh(fps.sphere(1.0), depth=5)
print(m.vertices.shape, m.triangles.shape)
m.save("sphere.ply")Key parameters: depth (default 4), bounds_min/bounds_max or center/scale, numpy, threads.
import numpy as np
pts = np.array([[0, 0, 0], [1, 0, 0]], dtype=np.float32)
vars = [fp.x(), fp.y(), fp.z()]
# Evaluate SDF at points → (N,) array
vals = fp.eval(fps.sphere(1.0), pts, variables=vars)
# Evaluate SDF + gradient → (N, 4) array [val, dx, dy, dz]
# The gradient of an SDF is the surface normal
grad = fp.eval_grad(fps.sphere(1.0), pts, variables=vars) # (N, 4)fp.math — symbolic shading composes directly with other SDF expressions:
import fidgetpy.math as fpm
shape = fps.sphere(1.0)
# Surface normal components in [-1, 1]
nx, ny, nz = fpm.normal(shape)
# Lambertian diffuse — returns an SDF expression, free to compose
light = fpm.diffuse(shape, light_dir=(1, 2, 3))fp.eval_grad — exact normals via forward-mode automatic differentiation:
import numpy as np
m = fp.mesh(shape, depth=6)
# Returns (N, 4): [sdf_value, dx, dy, dz]
grad = fp.eval_grad(shape, m.vertices.astype(np.float32),
variables=[fp.x(), fp.y(), fp.z()]) # (N, 4)
normals = grad[:, 1:] # (N, 3)
brightness = np.clip(normals @ [0.6, 0.8, 0.0], 0, 1)
m.save("diffuse.ply", colors=np.stack([brightness] * 3, axis=1))sphere = fps.sphere(1.0)
vm_str = fp.to_vm(sphere) # → str
fp.to_vm(sphere, output_file="sphere.vm")
frep_str = fp.to_frep(sphere) # → str
fp.to_frep(sphere, output_file="sphere.frep")
reimported = fp.from_vm(vm_str)fp.eval() evaluates an SDF over any grid of points, so a custom renderer is
just a function that builds that grid and calls it. Here is a complete
orthographic sphere-tracer — camera looks along −Z, marching one step per
fp.eval() call, with normals from fp.eval_grad() at the hit points:
import numpy as np
import fidgetpy as fp
import fidgetpy.shape as fps
def render(shape, width=256, height=256,
camera=(0.0, 0.0, 3.0), target=(0.0, 0.0, 0.0), scale=2.0,
steps=64, eps=1e-3):
"""
Orthographic sphere-trace render, camera looking along -Z.
Args:
shape: fidgetpy SDF expression.
width, height: image resolution in pixels.
camera: camera position (x, y, z) — only Z is used (orthographic).
target: world-space point to centre the view on.
scale: half-width of the view in world units.
steps: maximum sphere-trace iterations per ray.
eps: surface-hit threshold.
Returns:
dict with (height, width) arrays:
'hit' bool — True where a ray reached the surface.
'depth' float — world Z of the hit point.
'normal' (H,W,3) — surface normal at the hit point.
"""
N = width * height
xs = np.linspace(target[0] - scale, target[0] + scale, width, dtype=np.float32)
ys = np.linspace(target[1] + scale, target[1] - scale, height, dtype=np.float32)
xx, yy = np.meshgrid(xs, ys)
pts = np.stack([xx.ravel(), yy.ravel(),
np.full(N, camera[2], dtype=np.float32)], axis=1)
alive = np.ones(N, dtype=bool)
hit = np.zeros(N, dtype=bool)
for _ in range(steps):
if not np.any(alive):
break
idx = np.where(alive)[0]
d = np.asarray(fp.eval(shape, pts[idx],
variables=[fp.x(), fp.y(), fp.z()]), dtype=np.float32)
hit_now = d < eps
past = pts[idx, 2] < float(target[2]) - 8.0
hit[idx[hit_now]] = True
alive[idx[hit_now | past]] = False
marching = ~hit_now & ~past
pts[idx[marching], 2] -= d[marching]
# Normals at hit points via eval_grad
grad = np.zeros((N, 4), dtype=np.float32)
if np.any(hit):
grad[hit] = np.asarray(fp.eval_grad(shape, pts[hit],
variables=[fp.x(), fp.y(), fp.z()]), dtype=np.float32)
return {
'hit': hit.reshape(height, width),
'depth': pts[:, 2].reshape(height, width),
'normal': grad[:, 1:].reshape(height, width, 3),
}
sphere = fps.sphere(1.0)
img = render(sphere, width=256, height=256, camera=(0, 0, 3), scale=1.5)
# Diffuse shading with a single directional light
light = np.array([0.6, 0.8, 0.0])
brightness = np.clip(img['normal'] @ light, 0.0, 1.0)
brightness[~img['hit']] = 0.0 # background stays black
# Write as grayscale PGM (no extra dependencies)
pixels = (brightness * 255).astype(np.uint8)
with open("sphere_render.pgm", "wb") as f:
f.write(f"P5 256 256 255\n".encode())
f.write(pixels.tobytes())render() returns hit, depth, and normal as plain numpy arrays — pipe them
into matplotlib, save as an image, or combine them with other SDF evaluations.
radius_var = fp.var("radius")
shape = fps.cylinder(radius_var, 2.0)
fp.mesh(shape,
bounds_min=[-3, -3, -3], bounds_max=[3, 3, 3],
depth=5,
variables=[fp.x(), fp.y(), fp.z(), radius_var],
variable_values=[0.0, 0.0, 0.0, 1.5])