Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

18 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FidgetPy

Python bindings for Fidget — a library for fast, JIT-compiled Signed Distance Field (SDF) evaluation.

Installation

pip install maturin numpy
git clone "https://github.com/mkeeter/fidget"
cd fidgetpy && maturin develop

Quick start

import 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)

Modules

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, …

Meshing

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.

Evaluation

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)

Shading

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))

VM and F-Rep serialisation

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)

Rendering

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.

Custom variables

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])

About

Fidget Python bindings

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages