Files
fluencyCAD/sdf/d3.py

1667 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import time
import logging
import functools
import numpy as np
import operator
import warnings
import copy
# internal modules
from . import dn, d2, ease, mesh, errors, util
from .units import units
# external modules
import scipy.optimize
from scipy.linalg import LinAlgWarning
import rich
import matplotlib.pyplot as plt
# Constants
logger = logging.getLogger(__name__)
ORIGIN = np.array((0, 0, 0))
X = np.array((1, 0, 0))
Y = np.array((0, 1, 0))
Z = np.array((0, 0, 1))
UP = Z
DOWN = -Z
RIGHT = X
LEFT = -X
BACK = Y
FRONT = -Y
# SDF Class
_ops = {}
class SDF3:
def __init__(self, f):
self.f = f
def __call__(self, p):
return self.f(p).reshape((-1, 1))
def __getattr__(self, name):
if name in _ops:
f = _ops[name]
return functools.partial(f, self)
raise AttributeError
def __or__(self, other):
return union(self, other)
def __and__(self, other):
return intersection(self, other)
def __sub__(self, other):
return difference(self, other)
def fillet(self, r):
newSelf = copy.deepcopy(self)
newSelf._r = r
return newSelf
def radius(self, *args, **kwargs):
return self.fillet(*args, **kwargs)
def k(self, *args, **kwargs):
return self.fillet(*args, **kwargs)
def r(self, *args, **kwargs):
return self.fillet(*args, **kwargs)
def chamfer(self, c):
newSelf = copy.deepcopy(self)
newSelf._c = c
return newSelf
def c(self, *args, **kwargs):
return self.chamfer(*args, **kwargs)
def generate(self, *args, **kwargs):
return mesh.generate(self, *args, **kwargs)
@errors.alpha_quality
def closest_surface_point(self, point):
def distance(p):
# root() wants same input/output dims (yeah...)
return np.repeat(self.f(np.expand_dims(p, axis=0)).ravel()[0], 3)
dist = self.f(np.expand_dims(point, axis=0)).ravel()[0]
optima = dict()
with warnings.catch_warnings():
warnings.simplefilter(
"ignore", (LinAlgWarning, RuntimeWarning, UserWarning)
)
for method in (
# loosely sorted by speed
"lm",
"broyden2",
"df-sane",
"hybr",
"broyden1",
"anderson",
"linearmixing",
"diagbroyden",
"excitingmixing",
"krylov",
):
try:
optima[method] = (
opt := scipy.optimize.root(
distance, x0=np.array(point), method=method
)
)
except Exception as e:
pass
opt.zero_error = abs(opt.fun[0])
opt.zero_error_rel = opt.zero_error / dist
opt.dist_error = np.linalg.norm(opt.x - point) - dist
opt.dist_error_rel = opt.dist_error / dist
logger.debug(f"{method = }, {opt = }")
# shortcut if fit is good
if (
np.allclose(opt.fun, 0)
and abs(opt.dist_error / dist - 1) < 0.01
and opt.dist_error < 0.01
):
break
def cost(m):
penalty = (
# unsuccessfulness is penaltied
(not optima[m].success)
# a higher status normally means something bad
+ abs(getattr(optima[m], "status", 1))
# the more we're away from zero, the worse it is
# „1mm of away from boundary is as bad as one status or success step”
+ optima[m].zero_error
# the distance error can be quite large e.g. for non-uniform scaling,
# and methods often find weird points, it makes sense to compare to the SDF
+ optima[m].dist_error
)
logger.debug(f"{m = :20s}: {penalty = }")
return penalty
best_root = optima[best_method := min(optima, key=cost)]
closest_point = best_root.x
if (
best_root.zero_error > 1
or best_root.zero_error_rel > 0.01
or best_root.dist_error > 1
or best_root.dist_error_rel > 0.01
):
warnings.warn(
f"Closest surface point to {point} acc. to method {best_method!r} seems to be {closest_point}. "
f"The SDF there is {best_root.fun[0]} (should be 0, that's {best_root.zero_error} or {best_root.zero_error_rel*100:.2f}% off).\n"
f"Distance between {closest_point} and {point} is {np.linalg.norm(point - closest_point)}, "
f"SDF says it should be {dist} (that's {best_root.dist_error} or {best_root.dist_error_rel*100:.2f}% off)).\n"
f"The root finding algorithms seem to have a problem with your SDF, "
f"this might be caused due to operations breaking the metric like non-uniform scaling.",
errors.SDFCADWarning,
)
return closest_point
@errors.alpha_quality
def surface_intersection(self, start, direction=None):
"""
``start`` at a point, move (back or forth) along a line following a
``direction`` and return surface intersection coordinates.
.. note::
In case there is no intersection, the result *might* (not sure
about that) return the point on the line that's closest to the
surface 🤔.
Args:
start (3d vector): starting point
direction (3d vector or None): direction to move into, defaults to
``-start`` (”move to origin”).
Returns:
3d vector: the optimized surface intersection
"""
if direction is None:
direction = -start
def transform(t):
return start + t * direction
def distance(t):
# root() wants same input/output dims (yeah...)
return np.repeat(self.f(np.expand_dims(transform(t), axis=0)).ravel()[0], 3)
dist = self.f(np.expand_dims(start, axis=0)).ravel()[0]
optima = dict()
with warnings.catch_warnings():
warnings.simplefilter(
"ignore", (LinAlgWarning, RuntimeWarning, UserWarning)
)
for method in (
# loosely sorted by speed
"lm",
"broyden2",
"df-sane",
"hybr",
"broyden1",
"anderson",
"linearmixing",
"diagbroyden",
"excitingmixing",
"krylov",
):
try:
optima[method] = (
opt := scipy.optimize.root(distance, x0=[0], method=method)
)
except Exception as e:
pass
opt.zero_error = abs(opt.fun[0])
opt.zero_error_rel = opt.zero_error / dist
opt.point = transform(opt.x[0])
logger.debug(f"{method = }, {opt = }")
# shortcut if fit is good
if np.allclose(opt.fun, 0):
break
def cost(m):
penalty = (
# unsuccessfulness is penaltied
(not optima[m].success)
# a higher status normally means something bad
+ abs(getattr(optima[m], "status", 1))
# the more we're away from zero, the worse it is
# „1mm of away from boundary is as bad as one status or success step”
+ optima[m].zero_error
)
logger.debug(f"{m = :20s}: {penalty = }")
return penalty
best_root = optima[best_method := min(optima, key=cost)]
closest_point = transform(best_root.x[0])
if best_root.zero_error > 1 or best_root.zero_error_rel > 0.01:
warnings.warn(
f"Surface intersection point from {start = } to {direction = }, acc. to method {best_method!r} seems to be {closest_point}. "
f"The SDF there is {best_root.fun[0]} (should be 0, that's {best_root.zero_error} or {best_root.zero_error_rel*100:.2f}% off).\n"
f"The root finding algorithms seem to have a problem with your SDF, "
f"this might be caused due to operations breaking the metric like non-uniform scaling "
f"or just because there is no intersection...",
errors.SDFCADWarning,
)
return closest_point
@errors.alpha_quality
def minimum_sdf_on_plane(self, origin, normal, return_point=False):
"""
Find the minimum SDF distance (not necessarily the real distance if you
have non-uniform scaling!) on a plane around an ``origin`` that points
into the ``normal`` direction.
Args:
origin (3d vector): a point on the plane
normal (3d vector): normal vector of the plane
return_point (bool): whether to also return the closest point (on
the plane!)
Returns:
float: the (minimum) distance to the plane
float, 3d vector : distance and closest point (on the plane!) if
``return_point=True``
"""
basemat = np.array(
[(e1 := _perpendicular(normal)), (e2 := np.cross(normal, e1))]
).T
def transform(t):
return origin + basemat @ t
def distance(t):
return self.f(np.expand_dims(transform(t), axis=0)).ravel()[0]
optima = dict()
with warnings.catch_warnings():
warnings.simplefilter(
"ignore", (LinAlgWarning, RuntimeWarning, UserWarning)
)
for method in (
"Nelder-Mead",
"Powell",
"CG",
"BFGS",
"L-BFGS-B",
"TNC",
"COBYLA",
"SLSQP",
"trust-constr",
):
try:
optima[method] = (
opt := scipy.optimize.minimize(
distance, x0=[0, 0], method=method
)
)
opt.point = transform(opt.x)
logger.debug(f"{method = }, {opt = }")
except Exception as e:
logger.error(f"{method = } error {e!r}")
best_min = optima[min(optima, key=lambda m: optima[m].fun)]
if return_point:
return best_min.fun, best_min.point
else:
return best_min.fun
@errors.alpha_quality
def extent_in(self, direction):
"""
Determine the largest distance from the origin in a given ``direction``
that's still within the object.
Args:
direction (3d vector): the direction to check
Returns:
float: distance from origin
"""
# create probing points along direction to check where object ends roughly
# object ends when SDF is only increasing (not the case for infinite repetitions e.g.)
probing_points = np.expand_dims(np.logspace(0, 9, 30), axis=1) * direction
d = self.f(probing_points) # get SDF value at probing points
n_trailing_ascending = util.n_trailing_ascending_positive(d)
if not n_trailing_ascending:
return np.inf
if (ratio := n_trailing_ascending / d.size) < 0.5:
warnings.warn(
f"extent_in({direction = !r}): "
f"Only {n_trailing_ascending}/{d.size} ({ratio*100:.1f}%) of probed points in "
f"{direction = } have ascending positive SDF distance values. "
f"This can be caused by infinite objects. "
f"Result of might be wrong. ",
errors.SDFCADWarning,
)
faraway = probing_points[
-n_trailing_ascending + 1
] # choose first point after which SDF only increases
closest_surface_point = self.closest_surface_point_to_plane(
origin=faraway, normal=direction
)
extent = np.linalg.norm(closest_surface_point)
return extent
@property
@errors.alpha_quality
def bounds(self):
"""
Return X Y Z bounds based on :any:`extent_in`.
Return:
6-sequence: lower X, upper X, lower Y, upper Y, lower Z, upper Z
"""
return tuple(
np.sign(d.sum()) * self.extent_in(d) for d in [-X, X, -Y, Y, -Z, Z]
)
@errors.alpha_quality
def closest_surface_point_to_plane(self, origin, normal):
"""
Find the closest surface point to a plane around an ``origin`` that points
into the ``normal`` direction.
Args:
origin (3d vector): a point on the plane
normal (3d vector): normal vector of the plane
Returns:
3d vector : closest surface point
"""
distance, plane_point = self.minimum_sdf_on_plane(
origin=origin, normal=normal, return_point=True
)
return self.surface_intersection(start=plane_point, direction=normal)
@errors.alpha_quality
def move_to_positive(self, direction=Z):
return self.translate(self.extent_in(-direction) * direction)
def cut(self, direction=UP, point=ORIGIN, at=None, return_cutouts=False, **kwargs):
"""
Split an object along a direction and return resulting parts.
Args:
direction (3d vector): direction to cut into
point (3d vector): point to perform the cut at
at (sequence of float): where to perform the cuts along the
``direction`` starting at ``point``. Defaults to ``[0]``,
meaning it only splits at ``point``.
k (float): passed to :any:`intersection`
return_cutouts (bool): whether to return the used cutout masks as
second value
Returns:
sequence of SDFs: the parts
two sequences of SDFs: the parts and cutouts if ``return_cutouts=True``
"""
direction = np.array(direction).reshape(3)
direction = _normalize(direction)
point = np.array(point).reshape(3)
if at is None:
at = [0]
at = [-np.inf] + list(at) + [np.inf]
parts = []
cutouts = []
for start, end in zip(at[:-1], at[1:]):
cuts = []
if np.isfinite(start):
cuts.append(plane(normal=direction, point=point + start * direction))
if np.isfinite(end):
cuts.append(plane(normal=-direction, point=point + end * direction))
if len(cuts) > 1:
cutout = intersection(*cuts)
elif len(cuts) == 1:
cutout = cuts[0]
else:
cutout = self
cutouts.append(cutout)
parts.append(intersection(self, cutout, **kwargs))
if return_cutouts:
return parts, cutouts
else:
return parts
def chamfercut(self, size, at=ORIGIN, direction=Z, e=ease.linear):
"""
Chamfer (and then cut) an object along a plane
Args:
size (float): size to chamfer along ``direction``.
at (3d point): A point on the plane where to chamfer at. Defaults
to ORIGIN.
direction (3d vector): direction to chamfer to. Defaults to Z.
e (ease.Easing): the easing to use. Will be scaled with ``size``.
"""
direction = direction / np.linalg.norm(direction)
result = self.stretch(at, at - size * direction)
result = result.modulate_between(at + size * direction, at, e=-size * e)
result &= plane(direction, point=at)
return result
def volume(
self, bounds=None, step=None, timeout=3, convergence_precision=0.01, plot=False
):
"""
Approximate the volume by sampling the SDF and counting the inside
hits.
Args:
bounds (6-sequence, optional): bounds as returned by `extent_in()`
step (float, optional): The (single) step size in all dimensions to use.
The default is an iterative process gradually refining the grid until
it looks like it converges.
timeout (float, optional): stop iterating after this time
convergence_precision (float, optional): relative volume variance
threshold to allow stopping iteration. For example `0.05` means that if the
last couple iterations don't vary more than 5% of the current
volume estimate, stop.
plot (bool, optional): whether to plot the process after completion
Returns:
float : the volume approximation
"""
vols = []
ns = []
bounds = bounds or self.bounds
if not np.all(np.isfinite(bounds) & ~np.isnan(bounds)):
raise errors.SDFCADInfiniteObjectError(
f"Object has {bounds = }, can't approximate volume with that. "
f"You can specify bounds=(x,x,y,y,z,z) manually."
)
xl, xu, yl, yu, zl, zu = bounds
# expand bounds a bit outwards
xl -= (dx := abs(xu - xl)) * 0.05
xu += dx * 0.05
yl -= (dy := abs(yu - yl)) * 0.05
yu += dy * 0.05
zl -= (dz := abs(zu - zl)) * 0.05
zu += dz * 0.05
steps = (
np.linspace(min(dx, dy, dz) / 10, min(dx, dy, dz) / 1000, 100)
if step is None
else [step]
)
_steps = []
_samples = []
_hits = []
_vols = []
_times = []
_variances = []
_relvariances = []
time_start = time.perf_counter()
for step in steps:
if time.perf_counter() - time_start > timeout:
logger.warning(f"volume(): timeout of {timeout}s hit")
if not _vols:
_vols.append(np.nan)
break
time_before = time.perf_counter()
_steps.append(step)
grid = np.stack(
np.meshgrid(
np.arange(bounds[0], bounds[1] + step, step),
np.arange(bounds[2], bounds[3] + step, step),
np.arange(bounds[4], bounds[5] + step, step),
),
axis=-1,
)
d = self.f(grid.reshape(-1, 3)).reshape(grid.shape[:-1])
_samples.append(samples := d.size)
_hits.append(hits := np.sum(d < 0))
_vols.append(vol := hits * (cellvol := step**3))
_times.append(time.perf_counter() - time_before)
# _variances.append(np.var(_vols[-5:]))
_variances.append(abs(max(_vols[-5:]) - min(_vols[-5:])))
_relvariances.append(_variances[-1] / _vols[-1])
if len(_variances) > 5 and _relvariances[-1] < convergence_precision:
logger.info(f"volume(): convergence_precision hit")
break
if plot:
fig, axes = plt.subplots(nrows=5, sharex=True)
axes[-1].set_xlabel("cell side size")
axes[0].scatter(_steps, _vols)
axes[0].set_ylabel("est. volume")
axes[1].scatter(_steps, np.array(_hits) / np.array(_samples))
axes[1].set_ylabel("hit ratio")
axes[2].scatter(_steps, np.array(_times))
axes[2].set_ylabel("time estimation took")
axes[3].scatter(_steps, _variances)
axes[3].set_ylabel("volume variance of last couple steps bigger than...")
axes[4].scatter(_steps, _relvariances)
axes[4].set_ylabel("rel variance of last couple steps bigger than...")
plt.show()
return _vols[-1]
def save(
self,
path="out.stl",
screenshot=False,
add_text=None,
openscad=False,
plot=True,
antialiasing=True,
plot_kwargs=None,
**kwargs,
):
mesh.save(path, self, **{**dict(samples=2**18), **kwargs})
print(f"💾 Saved mesh to {path!r} ({os.stat(path).st_size} bytes)")
if openscad:
with open((p := f"{path}.scad"), "w") as fh:
fh.write(
f"""
import("{path}");
""".strip()
)
print(f"💾 Saved OpenSCAD viewer script to {p!r}")
if plot or screenshot:
try:
import pyvista as pv
except ImportError as e:
print(
f"To use the plotting functionality, "
f"install pyvista and trame (pip install pyvista trame). "
f"While trying to import them, the following error occured:\n{e!r}"
)
return
# pv.set_jupyter_backend("client")
plotter = pv.Plotter()
# axes = pv.Axes(show_actor=True, actor_scale=2, line_width=5)
# plotter.camera = pv.Camera()
# plotter.camera.position = (-1, -1, 1)
# plotter.add_actor(axes.actor)
plotter.enable_parallel_projection()
if antialiasing:
plotter.enable_anti_aliasing(aa_type="msaa")
m = pv.read(path)
plotter.add_mesh(m)
# xl, xu, yl, yu, zl, zu = m.bounds
# plotter.add_ruler(
# pointa=[0, 0, 0],
# pointb=[max(0, xu), 0, 0],
# number_minor_ticks=10,
# title="X",
# )
# plotter.add_ruler(
# pointa=[0, 0, 0],
# pointb=[0, max(0, yu), 0],
# number_minor_ticks=10,
# title="Y",
# )
# plotter.add_ruler(
# pointa=[0, 0, 0],
# pointb=[0, 0, max(0, zu)],
# number_minor_ticks=10,
# title="Z",
# )
plotter.add_axes()
if add_text:
if isinstance(add_text, str):
add_text = dict(text=add_text)
plotter.add_text(**add_text)
with warnings.catch_warnings():
warnings.simplefilter(action="ignore", category=UserWarning)
if screenshot:
if not isinstance(screenshot, str):
screenshot = f"{path}.png"
plotter.screenshot(screenshot)
print(f"🖼️ Saved screenshot to {screenshot!r}")
if plot:
try:
plotter.show(**(plot_kwargs or {}))
except TypeError as e:
logger.error(f"While showing the plotter: {e!r}, trying again")
plotter.show(**(plot_kwargs or {}))
else:
plotter.close()
def show_slice(self, *args, **kwargs):
return mesh.show_slice(self, *args, **kwargs)
def sdf3(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return SDF3(f(*args, **kwargs))
return wrapper
def op3(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return SDF3(f(*args, **kwargs))
_ops[f.__name__] = wrapper
return wrapper
def op32(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return d2.SDF2(f(*args, **kwargs))
_ops[f.__name__] = wrapper
return wrapper
# Helpers
def _length(a):
return np.linalg.norm(a, axis=1)
def _normalize(a):
return a / np.linalg.norm(a)
def _dot(a, b):
return np.sum(a * b, axis=1)
def _vec(*arrs):
return np.stack(arrs, axis=-1)
def _perpendicular(v):
if v[1] == 0 and v[2] == 0:
if v[0] == 0:
raise ValueError("zero vector")
else:
return np.cross(v, [0, 1, 0])
return np.cross(v, [1, 0, 0])
_min = np.minimum
_max = np.maximum
# Primitives
@sdf3
def sphere(radius=0, diameter=0, r=0, d=0, center=ORIGIN):
if bool(radius := max(radius, r)) == bool(diameter := max(diameter, d)):
raise ValueError(f"Specify either radius or diameter")
if not radius:
radius = diameter / 2
def f(p):
return _length(p - center) - radius
return f
@sdf3
def plane(normal=UP, point=ORIGIN):
normal = _normalize(normal)
def f(p):
return np.dot(point - p, normal)
return f
@sdf3
def slab(
x0=None,
y0=None,
z0=None,
x1=None,
y1=None,
z1=None,
dx=None,
dy=None,
dz=None,
**kwargs,
):
# How to improve this if/None madness?
if dx is not None:
if x0 is None:
x0 = -dx / 2
if x1 is None:
x1 = dx / 2
if dy is not None:
if y0 is None:
y0 = -dy / 2
if y1 is None:
y1 = dy / 2
if dz is not None:
if z0 is None:
z0 = -dz / 2
if z1 is None:
z1 = dz / 2
fs = []
if x0 is not None:
fs.append(plane(X, (x0, 0, 0)))
if x1 is not None:
fs.append(plane(-X, (x1, 0, 0)))
if y0 is not None:
fs.append(plane(Y, (0, y0, 0)))
if y1 is not None:
fs.append(plane(-Y, (0, y1, 0)))
if z0 is not None:
fs.append(plane(Z, (0, 0, z0)))
if z1 is not None:
fs.append(plane(-Z, (0, 0, z1)))
return intersection(*fs, **kwargs)
@sdf3
def box(size=1, center=ORIGIN, a=None, b=None):
if a is not None and b is not None:
a = np.array(a)
b = np.array(b)
size = b - a
center = a + size / 2
return box(size, center)
size = np.array(size)
def f(p):
q = np.abs(p - center) - size / 2
return _length(_max(q, 0)) + _min(np.amax(q, axis=1), 0)
return f
@sdf3
def rounded_box(size, radius):
size = np.array(size)
def f(p):
q = np.abs(p) - size / 2 + radius
return _length(_max(q, 0)) + _min(np.amax(q, axis=1), 0) - radius
return f
@sdf3
def wireframe_box(size, thickness):
size = np.array(size)
def g(a, b, c):
return _length(_max(_vec(a, b, c), 0)) + _min(_max(a, _max(b, c)), 0)
def f(p):
p = np.abs(p) - size / 2 - thickness / 2
q = np.abs(p + thickness / 2) - thickness / 2
px, py, pz = p[:, 0], p[:, 1], p[:, 2]
qx, qy, qz = q[:, 0], q[:, 1], q[:, 2]
return _min(_min(g(px, qy, qz), g(qx, py, qz)), g(qx, qy, pz))
return f
@sdf3
def torus(r1, r2):
def f(p):
xy = p[:, [0, 1]]
z = p[:, 2]
a = _length(xy) - r1
b = _length(_vec(a, z)) - r2
return b
return f
@sdf3
def capsule(a, b, radius=None, diameter=None):
if (radius is not None) == (diameter is not None):
raise ValueError(f"Specify either radius or diameter")
if radius is None:
radius = diameter / 2
a = np.array(a)
b = np.array(b)
ba = b - a
babadot = np.dot(ba, ba)
r = radius if hasattr(radius, "__call__") else lambda h: radius
def f(p):
pa = p - a
h = np.clip(np.dot(pa, ba) / babadot, 0, 1).reshape((-1, 1))
return _length(pa - np.multiply(ba, h)) - r(h.reshape(-1))
return f
def pieslice(angle, centered=False):
"""
Make a pie slice starting at X axis, rotated ``angle`` in mathematically
positive direction. Infinite in Z direction.
Args:
angle: the angle to use
centered: center the slice at X axis
"""
angle = angle % units("360°")
if angle <= units("180°"):
s = plane(Y) & plane(-Y).rotate(angle)
else:
s = plane(Y) | plane(-Y).rotate(angle)
if centered:
s = s.rotate(-angle / 2)
return s
@sdf3
def cylinder(radius=0, r=0, diameter=0, d=0):
if bool(radius := max(radius, r)) == bool(diameter := max(diameter, d)):
raise ValueError(f"Specify either radius or diameter")
if not radius:
radius = diameter / 2
def f(p):
return _length(p[:, [0, 1]]) - radius
return f
@sdf3
def capped_cylinder(a, b, radius=None, diameter=None):
if (radius is not None) == (diameter is not None):
raise ValueError(f"Specify either radius or diameter")
if radius is None:
radius = diameter / 2
a = np.array(a)
b = np.array(b)
def f(p):
ba = b - a
pa = p - a
baba = np.dot(ba, ba)
paba = np.dot(pa, ba).reshape((-1, 1))
x = _length(pa * baba - ba * paba) - radius * baba
y = np.abs(paba - baba * 0.5) - baba * 0.5
x = x.reshape((-1, 1))
y = y.reshape((-1, 1))
x2 = x * x
y2 = y * y * baba
d = np.where(
_max(x, y) < 0,
-_min(x2, y2),
np.where(x > 0, x2, 0) + np.where(y > 0, y2, 0),
)
return np.sign(d) * np.sqrt(np.abs(d)) / baba
return f
@sdf3
def rounded_cylinder(ra, rb, h):
def f(p):
d = _vec(_length(p[:, [0, 1]]) - ra + rb, np.abs(p[:, 2]) - h / 2 + rb)
return _min(_max(d[:, 0], d[:, 1]), 0) + _length(_max(d, 0)) - rb
return f
@sdf3
def capped_cone(a, b, ra, rb):
a = np.array(a)
b = np.array(b)
def f(p):
rba = rb - ra
baba = np.dot(b - a, b - a)
papa = _dot(p - a, p - a)
paba = np.dot(p - a, b - a) / baba
x = np.sqrt(papa - paba * paba * baba)
cax = _max(0, x - np.where(paba < 0.5, ra, rb))
cay = np.abs(paba - 0.5) - 0.5
k = rba * rba + baba
f = np.clip((rba * (x - ra) + paba * baba) / k, 0, 1)
cbx = x - ra - f * rba
cby = paba - f
s = np.where(np.logical_and(cbx < 0, cay < 0), -1, 1)
return s * np.sqrt(
_min(cax * cax + cay * cay * baba, cbx * cbx + cby * cby * baba)
)
return f
@sdf3
def rounded_cone(r1, r2, h):
def f(p):
q = _vec(_length(p[:, [0, 1]]), p[:, 2])
b = (r1 - r2) / h
a = np.sqrt(1 - b * b)
k = np.dot(q, _vec(-b, a))
c1 = _length(q) - r1
c2 = _length(q - _vec(0, h)) - r2
c3 = np.dot(q, _vec(a, b)) - r1
return np.where(k < 0, c1, np.where(k > a * h, c2, c3))
return f
@sdf3
def ellipsoid(size):
size = np.array(size)
def f(p):
k0 = _length(p / size)
k1 = _length(p / (size * size))
return k0 * (k0 - 1) / k1
return f
@sdf3
def pyramid(h):
def f(p):
a = np.abs(p[:, [0, 1]]) - 0.5
w = a[:, 1] > a[:, 0]
a[w] = a[:, [1, 0]][w]
px = a[:, 0]
py = p[:, 2]
pz = a[:, 1]
m2 = h * h + 0.25
qx = pz
qy = h * py - 0.5 * px
qz = h * px + 0.5 * py
s = _max(-qx, 0)
t = np.clip((qy - 0.5 * pz) / (m2 + 0.25), 0, 1)
a = m2 * (qx + s) ** 2 + qy * qy
b = m2 * (qx + 0.5 * t) ** 2 + (qy - m2 * t) ** 2
d2 = np.where(_min(qy, -qx * m2 - qy * 0.5) > 0, 0, _min(a, b))
return np.sqrt((d2 + qz * qz) / m2) * np.sign(_max(qz, -py))
return f
# Platonic Solids
@sdf3
def tetrahedron(r):
def f(p):
x = p[:, 0]
y = p[:, 1]
z = p[:, 2]
return (_max(np.abs(x + y) - z, np.abs(x - y) + z) - r) / np.sqrt(3)
return f
@sdf3
def octahedron(r):
def f(p):
return (np.sum(np.abs(p), axis=1) - r) * np.tan(np.radians(30))
return f
@sdf3
def dodecahedron(r):
x, y, z = _normalize(((1 + np.sqrt(5)) / 2, 1, 0))
def f(p):
p = np.abs(p / r)
a = np.dot(p, (x, y, z))
b = np.dot(p, (z, x, y))
c = np.dot(p, (y, z, x))
q = (_max(_max(a, b), c) - x) * r
return q
return f
@sdf3
def icosahedron(r):
r *= 0.8506507174597755
x, y, z = _normalize(((np.sqrt(5) + 3) / 2, 1, 0))
w = np.sqrt(3) / 3
def f(p):
p = np.abs(p / r)
a = np.dot(p, (x, y, z))
b = np.dot(p, (z, x, y))
c = np.dot(p, (y, z, x))
d = np.dot(p, (w, w, w)) - x
return _max(_max(_max(a, b), c) - x, d) * r
return f
# Shapes
def lerp(x1, x2, t):
return (1 - t) * x1 + t * x2
def bezier_via_lerp(p1, p2, p3, p4, t):
t = np.array(t).reshape(-1, 1)
p12 = lerp(p1, p2, t)
p23 = lerp(p2, p3, t)
p34 = lerp(p3, p4, t)
p1223 = lerp(p12, p23, t)
p2334 = lerp(p23, p34, t)
return lerp(p1223, p2334, t)
@sdf3
def bezier(
p1=ORIGIN,
p2=10 * X,
p3=10 * Y,
p4=10 * Z,
radius=None,
diameter=None,
steps=20,
**kwargs,
):
"""
Generate a single bezier curve :any:`capsule_chain` from four
control points with a fixed or variable thickness
Args:
p1, p2, p3, p4 (point vectors): the control points. Segment will start at p1 and end at p4.
radius,diameter (float or callable): either a fixed number as radius/diameter or a
callable taking a number within [0;1] and returning a radius/diameter, e.g.
the easing function :any:`ease.linear` or
``radius=ease.linear.between(10,2)`` for a linear transition
between radii/diameters.
**kwargs: handed to :any:`capsule_chain`
"""
if (radius is not None) == (diameter is not None):
raise ValueError(f"Specify either radius or diameter")
if radius is None:
radius = diameter / 2
if isinstance(radius, (float, int)):
radius = ease.constant(radius)
points = bezier_via_lerp(p1, p2, p3, p4, (t := np.linspace(0, 1, steps)))
lengths = np.linalg.norm(np.diff(points, axis=0), axis=1)
# TODO: better steps taking curvature and changing radius into account
t_eq = np.interp(
np.arange(0, lengths.sum() + radius.mean / 4, radius.mean / 4),
# np.linspace(0, lengths.sum(), steps),
np.hstack([0, np.cumsum(lengths)]),
t,
)
points = bezier_via_lerp(p1, p2, p3, p4, t_eq)
return capsule_chain(points, radius=radius, **kwargs)
def capsule_chain(points, radius=None, diameter=None, **kwargs):
if (radius is not None) == (diameter is not None):
raise ValueError(f"Specify either radius or diameter")
if radius is None:
radius = diameter / 2
lengths = np.linalg.norm(np.diff(points, axis=0), axis=1)
cumlengths = np.hstack([0, np.cumsum(lengths)])
relcumlengths = cumlengths / lengths.sum()
return union(
*[
capsule(
p1,
p2,
radius=(radius[a:b] if isinstance(radius, ease.Easing) else radius),
)
for p1, p2, a, b in zip(
points[0:-1], points[1:], relcumlengths[0:-1], relcumlengths[1:]
)
],
**kwargs,
)
def Thread(
pitch=5,
diameter=20,
offset=1,
left=False,
):
"""
An infinite thread
"""
angleperz = -(2 * np.pi) / pitch
if left:
angleperz *= -1
thread = (
cylinder(swipediameter := diameter / 2 - offset)
.translate(X * offset)
.twist(angleperz)
)
thread.diameter = diameter
thread.pitch = pitch
thread.offset = offset
thread.left = left
return thread
def Screw(
length=40,
head_shape=None,
head_height=10,
k_tip=10,
k_head=0,
**threadkwargs,
):
if head_shape is None:
head_shape = d2.hexagon(30)
if not (thread := threadkwargs.pop("thread", None)):
thread = Thread(**threadkwargs)
k_tip = np.clip(k_tip, 0, min(thread.diameter, length)) or None
k_head = np.clip(k_head, 0, thread.diameter) or None
head = head_shape.extrude(head_height).translate(-Z * head_height / 2)
return head | (thread & slab(z0=0) & slab(z1=length).k(k_tip)).k(k_head)
def RegularPolygonColumn(n, r=1):
ri = r * np.cos(np.pi / n)
return intersection(
*[slab(y0=-ri).rotate(a, Z) for a in np.arange(0, 2 * np.pi, 2 * np.pi / n)]
)
# Positioning
@op3
def translate(other, offset):
def f(p):
return other(p - offset)
return f
@op3
def scale(other, factor):
try:
x, y, z = factor
except TypeError:
x = y = z = factor
s = (x, y, z)
m = min(x, min(y, z))
def f(p):
return other(p / s) * m
return f
def rotation_matrix(angle, axis=Z):
"""
Euler-Rodriguez Formula for arbitrary-axis rotation:
https://en.m.wikipedia.org/wiki/Rodrigues%27_rotation_formula
"""
try:
angle = angle.to("radians").m
except AttributeError:
pass
x, y, z = _normalize(axis)
s = np.sin(angle)
c = np.cos(angle)
m = 1 - c
# code moved from mfogleman's rotate() below
return np.array(
[
[m * x * x + c, m * x * y + z * s, m * z * x - y * s],
[m * x * y - z * s, m * y * y + c, m * y * z + x * s],
[m * z * x + y * s, m * y * z - x * s, m * z * z + c],
]
).T
# Alternative matrix construction (slightly slower than the above):
# kx, ky, kz = _normalize(axis)
# K = np.array([[0, -kz, ky], [kz, 0, -kx], [-ky, kx, 0]])
# return np.identity(3) + np.sin(angle) * K + (1 - np.cos(angle)) * (K @ K)
# Using np.linalg.matrix_power(K,2) is not faster
@op3
def rotate(other, angle, vector=Z):
matrix = rotation_matrix(axis=vector, angle=angle)
def f(p):
# np.dot(p, matrix) actually rotates *backwards*,
# (matrix @ p) or np.dot(matrix,p) would rotate
# forwards but that doesn't work with the shapes here.
# In this case, rotating backwards is actually what we want:
# we want to inverse the rotation to look up the SDF there
return other(np.dot(p, matrix))
return f
@op3
def rotate_to(other, a, b):
a = _normalize(np.array(a))
b = _normalize(np.array(b))
dot = np.dot(b, a)
if dot == 1:
return other
if dot == -1:
return rotate(other, np.pi, _perpendicular(a))
angle = np.arccos(dot)
v = _normalize(np.cross(b, a))
return rotate(other, angle, v)
@op3
def orient(other, axis):
# quick fix that probably is a problem in rotate_to()
axis = axis * np.array([-1, -1, 1])
return rotate_to(other, UP, axis)
@op3
def circular_array(other, count, offset=0):
other = other.translate(X * offset)
da = 2 * np.pi / count
def f(p):
x = p[:, 0]
y = p[:, 1]
z = p[:, 2]
d = np.hypot(x, y)
a = np.arctan2(y, x) % da
d1 = other(_vec(np.cos(a - da) * d, np.sin(a - da) * d, z))
d2 = other(_vec(np.cos(a) * d, np.sin(a) * d, z))
return _min(d1, d2)
return f
# Alterations
@op3
def rotate_stretch(
sdf,
start=X,
stop=None,
axis=None,
angle=None,
stretch=ease.linear,
shear=None,
fling=None,
thicken=None,
transition=None,
transition_ease=ease.linear,
symmetric=False,
verbose=False,
):
"""
Grab the object at point ``a``, rotate it around and ``axis`` and stretch
it to point ``b``, optionally modulating the thickness, shearing in the
rotation axis or flinging in the direction of 'centrifual force' along the
way.
Args:
start (point vector): the starting control point. Defaults to ``X``.
stop (point vector): the end control point. Must not be collinear to ``a``.
axis (vector): the axis. auto-determined from a and b if not given.
Specify this if a and b are collinear (e.g. opposite) or if you
don't like the direction of rotation of the default. Defaults to Z.
angle (float or unit): how far to rotate. Defaults to a full rotation
(``2*np.pi`` or ``units("360°")``) if ``stop`` is not given. More
than a full rotation is not possible.
symmetric (bool, optional): perform the stretch symmetrically in both
directions.
stretch (Easing): easing to apply in the stretching direction. Using an
easing that doesn't start at (0,0) and end at (1,1) does not make much
sense here.
shear (Easing): easing to apply for shearing in the rotation axis direction
fling (Easing): easing to apply for moving in the outwards direction
thicken (Easing): easing to apply to modify the thickness while stretching
transition (SDF): transition to another SDF along the way
transition_ease (Easing): easing for transition
Examples
--------
.. code-block:: python
from sdf import *
# make a quarter capsule sweep
# (”grab X, stretch it around origin to Y”)
capsule(ORIGIN,30*X,10).rotate_stretch(X,Y).save()
# make one edge of a box round
# (”grab X, stretch it around origin to Y”)
box(10).rotate_stretch(X,Y).save()
# full revolve of a capsule
# (”grab X, rotate it around Z axis to -X, symmetrically”)
capsule(ORIGIN, 30*X,10).rotate_stretch(X,-X,Z,symmetric=True).save()
# make a saddle-like thing by using an easing have the stretch look 'stretchy'
capsule(ORIGIN,30*X,10).rotate_stretch(X,-X+Y,symmetric=True,e=ease.out_sine).save()
"""
start = _normalize(start)
if stop is None:
axis = Z if axis is None else axis
angle = angle or 2 * np.pi
else: # so: stop is not None
if axis is not None or angle is not None:
raise errors.SDFCADError(
f"rotate_stretch(): Too many constraints. Check arguments."
)
stop = _normalize(stop)
if axis is None:
axis = np.cross(start, stop) # rotation axis
if np.allclose(L := np.linalg.norm(axis), 0):
raise errors.SDFCADError(
f"rotate_stretch(): "
f"Your stretch points {start = } and {stop = } are collinear "
f"(cross product length is {L}). "
f"Either fix this or specify a rotation axis= manually. "
)
else:
axis = axis / L
# matrix projecting a 3D p onto 2D rotation plane coordinates
basemat = np.array([start, _normalize(np.cross(axis, start))])
def angle_on_plane(p):
p_on_plane = (basemat @ p.T).T
if verbose:
# rich.print(f"angle_on_plane(): {p = }")
rich.print(f"angle_on_plane(): {p_on_plane = }")
angle = np.arctan2(p_on_plane[:, 1], p_on_plane[:, 0])
if not symmetric:
angle += 2 * np.pi
angle %= 2 * np.pi
return angle
if angle is None and stop is not None:
angle = angle_on_plane(np.array([stop]))[0]
if hasattr(angle, "to"):
angle = angle.to("radians").m
if verbose:
rich.print(f"{start,stop = }")
rich.print(f"{angle = }, {np.rad2deg(angle) = }")
rich.print(f"{axis = }")
rich.print(f"{basemat = }")
if symmetric: # mirror
# stretch = stretch.mirror(x=0, copy=True) # not this one 🤷
shear = shear.mirror(x=0, copy=True)
fling = fling.mirror(x=0, copy=True)
thicken = thicken.mirror(x=0, copy=True)
def f(p):
if verbose:
rich.print(f"{p = }")
p_on_plane = (basemat @ p.T).T
if verbose:
rich.print(f"{p_on_plane = }")
# project p onto rotation plane
p_angle_on_plane = angle_on_plane(p)
if verbose:
rich.print(f"{p_angle_on_plane = }")
p_angle_fraction = np.clip(p_angle_on_plane / angle, -1 if symmetric else 0, 1)
if verbose:
rich.print(f"{p_angle_fraction = }")
back_angle = stretch(p_angle_fraction) * angle
verbose and rich.print(f"{back_angle = }")
# the coordinates at which we'll look for the SDF
plook = p # start with original points
# ↔️ FLINGING outwards
# get 3D vector of p projected onto plane
if fling:
outwards = p_on_plane @ basemat
verbose and rich.print(f"{outwards = }")
verbose and rich.print(f"{np.linalg.norm(outwards, axis=1) = }")
outwards = outwards / np.linalg.norm(outwards, axis=1)[:, np.newaxis]
if verbose:
rich.print(f"{p_on_plane @ basemat = }")
rich.print(f"{outwards = }")
rich.print(f"{fling(p_angle_fraction) = }")
flinging = (fling(p_angle_fraction) * outwards.T).T
verbose and rich.print(f"{flinging = }")
plook = plook - flinging
# ↕️ SHEARING in rotation axis direction
if shear:
plook = plook - (shear(p_angle_fraction) * np.expand_dims(axis, axis=1)).T
verbose and rich.print(f"after shear: {plook = }")
# 🔄 ROTATION around rotation axis
# create many rotation matrices (along 3rd dim) for all angles
rotmatrix = rotation_matrix(axis=axis, angle=-back_angle)
# (this is a slow Python loop, I wonder how to optmize this 🤔)
plook = np.array([m @ p for p, m in zip(plook, rotmatrix)])
if verbose:
rich.print(f"after rotation: {plook = }")
rich.print(f"{thicken(p_angle_fraction).T = }")
rich.print(f"{plook = }")
rich.print(f"{sdf(plook) = }")
# get SDF at (reverse) flinged -> sheared -> rotated position
distance = sdf(plook)
verbose and rich.print(f"{distance = }")
# THICKENING
if thicken:
thickening = np.expand_dims(thicken(p_angle_fraction), axis=1)
verbose and rich.print(f"{thickening = }")
distance = distance - thickening
if transition is not None:
t = transition_ease(p_angle_fraction)[:, None]
verbose and rich.print(f"{t = }")
verbose and rich.print(f"{transition(plook) = }")
distance = t * transition(plook) + (1 - t) * distance
verbose and rich.print(f"{distance = }")
return distance
return f
@op3
def elongate(other, size):
def f(p):
q = np.abs(p) - size
x = q[:, 0].reshape((-1, 1))
y = q[:, 1].reshape((-1, 1))
z = q[:, 2].reshape((-1, 1))
w = _min(_max(x, _max(y, z)), 0)
return other(_max(q, 0)) + w
return f
@op3
def twist(other, k):
def f(p):
x = p[:, 0]
y = p[:, 1]
z = p[:, 2]
c = np.cos(k * z)
s = np.sin(k * z)
x2 = c * x - s * y
y2 = s * x + c * y
z2 = z
return other(_vec(x2, y2, z2))
return f
@op3
def twist_between(sdf, a, b, e=2 * np.pi * ease.in_out_cubic):
"""
Twist an object between two control points
Args:
a, b (vectors): the two control points
e (scalar function): the angle to rotate, will be called with
values between 0 (at control point ``a``) and 1 (at control point
``b``). Its result will be used as rotation angle in radians.
"""
# unit vector from control point a to b
ab = (ab := b - a) / (L := np.linalg.norm(ab))
def f(p):
# project current point onto control direction, clip and apply easing
angle = e(np.clip((p - a) @ ab / L, 0, 1))
# move to origin ”-a”, then rotate, then move back ”+a”
# create many rotation matrices (along 3rd dim) for all angles
matrix = rotation_matrix(axis=ab, angle=-angle)
# apply rotation matrix to points moved back to origin
# (this is a slow Python loop, I wonder how to optmize this 🤔)
rotated = np.array([m @ p for p, m in zip((p - a), matrix)])
# move rotated points back to where they were
return sdf(rotated + a)
return f
@op3
def bend(other, k):
def f(p):
x = p[:, 0]
y = p[:, 1]
z = p[:, 2]
c = np.cos(k * x)
s = np.sin(k * x)
x2 = c * x - s * y
y2 = s * x + c * y
z2 = z
return other(_vec(x2, y2, z2))
return f
@op3
def bend_linear(other, p0, p1, v, e=ease.linear):
p0 = np.array(p0)
p1 = np.array(p1)
v = -np.array(v)
ab = p1 - p0
def f(p):
t = np.clip(np.dot(p - p0, ab) / np.dot(ab, ab), 0, 1)
t = e(t).reshape((-1, 1))
return other(p + t * v)
return f
@op3
def bend_radial(other, r0, r1, dz, e=ease.linear):
def f(p):
x = p[:, 0]
y = p[:, 1]
z = p[:, 2]
r = np.hypot(x, y)
t = np.clip((r - r0) / (r1 - r0), 0, 1)
z = z - dz * e(t)
return other(_vec(x, y, z))
return f
@op3
def transition_linear(f0, f1, p0=-Z, p1=Z, e=ease.linear):
p0 = np.array(p0)
p1 = np.array(p1)
ab = p1 - p0
def f(p):
d1 = f0(p)
d2 = f1(p)
t = np.clip(np.dot(p - p0, ab) / np.dot(ab, ab), 0, 1)
t = e(t).reshape((-1, 1))
return t * d2 + (1 - t) * d1
return f
@op3
def transition_radial(f0, f1, r0=0, r1=1, e=ease.linear):
def f(p):
d1 = f0(p)
d2 = f1(p)
r = np.hypot(p[:, 0], p[:, 1])
t = np.clip((r - r0) / (r1 - r0), 0, 1)
t = e(t).reshape((-1, 1))
return t * d2 + (1 - t) * d1
return f
@op3
def wrap_around(other, x0, x1, r=None, e=ease.linear):
p0 = X * x0
p1 = X * x1
v = -Y
if r is None:
r = np.linalg.norm(p1 - p0) / (2 * np.pi)
def f(p):
x = p[:, 0]
y = p[:, 1]
z = p[:, 2]
d = np.hypot(x, y) - r
d = d.reshape((-1, 1))
a = np.arctan2(y, x)
t = (a + np.pi) / (2 * np.pi)
t = e(t).reshape((-1, 1))
q = p0 + (p1 - p0) * t + v * d
q[:, 2] = z
return other(q)
return f
# 3D => 2D Operations
@op32
def slice(other):
# TODO: support specifying a slice plane
# TODO: probably a better way to do this
s = slab(z0=-1e-9, z1=1e-9)
a = other & s
b = other.negate() & s
def f(p):
p = _vec(p[:, 0], p[:, 1], np.zeros(len(p)))
A = a(p).reshape(-1)
B = -b(p).reshape(-1)
w = A <= 0
A[w] = B[w]
return A
return f
# Common
union = op3(dn.union)
union_legacy = op3(dn.union_legacy)
difference = op3(dn.difference)
difference_legacy = op3(dn.difference_legacy)
intersection = op3(dn.intersection)
intersection_legacy = op3(dn.intersection_legacy)
blend = op3(dn.blend)
negate = op3(dn.negate)
dilate = op3(dn.dilate)
erode = op3(dn.erode)
shell = op3(dn.shell)
repeat = op3(dn.repeat)
mirror = op3(dn.mirror)
modulate_between = op3(dn.modulate_between)
stretch = op3(dn.stretch)
shear = op3(dn.shear)