- added sdf folder ( doesnt work via pip or git=)

This commit is contained in:
bklronin
2025-08-16 20:33:44 +02:00
parent d373b50644
commit 54261bb8fd
19 changed files with 3937 additions and 68 deletions

2
.idea/fluency.iml generated
View File

@@ -5,7 +5,7 @@
<sourceFolder url="file://$MODULE_DIR$/sdfcad" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/sdfcad" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="jdk" jdkName="Python 3.12 (fluency)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

2
.idea/misc.xml generated
View File

@@ -3,7 +3,7 @@
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.11 (fluency)" /> <option name="sdkName" value="Python 3.11 (fluency)" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (fluency)" project-jdk-type="Python SDK" /> <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (fluency)" project-jdk-type="Python SDK" />
<component name="PyCharmProfessionalAdvertiser"> <component name="PyCharmProfessionalAdvertiser">
<option name="shown" value="true" /> <option name="shown" value="true" />
</component> </component>

1
.idea/vcs.xml generated
View File

@@ -2,6 +2,5 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/sdfcad" vcs="Git" />
</component> </component>
</project> </project>

198
.idea/workspace.xml generated
View File

@@ -4,23 +4,14 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="init"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added screenshot">
<change afterPath="$PROJECT_DIR$/.idea/fluency.iml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/fluency.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/fluency.iml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/inspectionProfiles/Project_Default.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/inspectionProfiles/profiles_settings.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/vcs.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/vcs.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/modules.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/main.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/vcs.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/mesh_modules/simple_mesh.py" beforeDir="false" afterPath="$PROJECT_DIR$/mesh_modules/simple_mesh.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/requirements.txt" beforeDir="false" afterPath="$PROJECT_DIR$/requirements.txt" afterDir="false" />
<change afterPath="$PROJECT_DIR$/2dtest.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/fluencyb.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/main.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/meshtest.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/modules/gl_widget.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/modules/out.stl" afterDir="false" />
<change afterPath="$PROJECT_DIR$/side_fluency.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/vulkan.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/fluency.py" beforeDir="false" afterPath="$PROJECT_DIR$/fluency.py" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -35,6 +26,11 @@
</option> </option>
</component> </component>
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="structure" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
<option name="ROOT_SYNC" value="DONT_SYNC" /> <option name="ROOT_SYNC" value="DONT_SYNC" />
</component> </component>
@@ -48,7 +44,6 @@
&quot;associatedIndex&quot;: 6 &quot;associatedIndex&quot;: 6
}</component> }</component>
<component name="ProjectId" id="2aDywQvESFCKbJK4JUVHIhkN4S6" /> <component name="ProjectId" id="2aDywQvESFCKbJK4JUVHIhkN4S6" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true" />
<component name="ProjectViewState"> <component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
@@ -57,17 +52,23 @@
"keyToString": { "keyToString": {
"Python.2dtest.executor": "Run", "Python.2dtest.executor": "Run",
"Python.3d_windows.executor": "Run", "Python.3d_windows.executor": "Run",
"Python.Unnamed.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run", "Python.fluency.executor": "Run",
"Python.fluencyb.executor": "Run", "Python.fluencyb.executor": "Run",
"Python.gl_widget.executor": "Run", "Python.gl_widget.executor": "Run",
"Python.main.executor": "Run", "Python.main.executor": "Run",
"Python.meshtest.executor": "Run", "Python.meshtest.executor": "Run",
"Python.side_fluency.executor": "Run", "Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run", "Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true", "RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true", "RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "master", "git-widget-placeholder": "master",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/modules", "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings" "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
} }
}]]></component> }]]></component>
@@ -78,6 +79,8 @@
</component> </component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" /> <recent name="$PROJECT_DIR$/modules" />
</key> </key>
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
@@ -87,7 +90,7 @@
<component name="SharedIndexes"> <component name="SharedIndexes">
<attachedChunks> <attachedChunks>
<set> <set>
<option value="bundled-python-sdk-09665e90c3a7-d3b881c8e49f-com.jetbrains.pycharm.community.sharedIndexes.bundled-PC-233.15026.15" /> <option value="bundled-python-sdk-4c141bd692a7-e2d783800521-com.jetbrains.pycharm.community.sharedIndexes.bundled-PC-251.26927.90" />
</set> </set>
</attachedChunks> </attachedChunks>
</component> </component>
@@ -108,7 +111,143 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1703951701948</updated> <updated>1703951701948</updated>
</task> </task>
<option name="localTasksCounter" value="2" /> <task id="LOCAL-00002" summary="- Basic oop sketch widget implement">
<option name="closed" value="true" />
<created>1729958532384</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1729958532384</updated>
</task>
<task id="LOCAL-00003" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735563255455</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1735563255455</updated>
</task>
<task id="LOCAL-00004" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735585968733</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1735585968733</updated>
</task>
<task id="LOCAL-00005" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601610504</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1735601610504</updated>
</task>
<task id="LOCAL-00006" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601786207</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1735601786207</updated>
</task>
<task id="LOCAL-00007" summary="- changing compos for sketches works">
<option name="closed" value="true" />
<created>1735652081552</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1735652081552</updated>
</task>
<task id="LOCAL-00008" summary="- changing compos including sketches and bodies">
<option name="closed" value="true" />
<created>1735662119176</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1735662119176</updated>
</task>
<task id="LOCAL-00009" summary="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working">
<option name="closed" value="true" />
<created>1735685300102</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1735685300102</updated>
</task>
<task id="LOCAL-00010" summary="- delete sketch working&#10;- added mid point snap&#10;- added hovering line with distance">
<option name="closed" value="true" />
<created>1735763743346</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1735763743346</updated>
</task>
<task id="LOCAL-00011" summary="- Added new buttons and settings">
<option name="closed" value="true" />
<created>1735825176611</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1735825176611</updated>
</task>
<task id="LOCAL-00012" summary="- Added new buttons and settings">
<option name="closed" value="true" />
<created>1735842523870</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1735842523870</updated>
</task>
<task id="LOCAL-00013" summary="- Added construction lines switching&#10;- Moved callbacks into sketchwidget from main.&#10;- Changed reset on right click">
<option name="closed" value="true" />
<created>1739739664763</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1739739664763</updated>
</task>
<task id="LOCAL-00014" summary="- Added contrain displayed next to line&#10;- Slight change to point check from solver.">
<option name="closed" value="true" />
<created>1743193041868</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1743193041868</updated>
</task>
<task id="LOCAL-00015" summary="- Added enabling of midpsnap and prepared others&#10;- Show dimesnion on hover">
<option name="closed" value="true" />
<created>1743284173326</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1743284173326</updated>
</task>
<task id="LOCAL-00016" summary="- Fixed redraw when component changed">
<option name="closed" value="true" />
<created>1744555255868</created>
<option name="number" value="00016" />
<option name="presentableId" value="LOCAL-00016" />
<option name="project" value="LOCAL" />
<updated>1744555255868</updated>
</task>
<task id="LOCAL-00017" summary="- added MIT license">
<option name="closed" value="true" />
<created>1748764814845</created>
<option name="number" value="00017" />
<option name="presentableId" value="LOCAL-00017" />
<option name="project" value="LOCAL" />
<updated>1748764814845</updated>
</task>
<task id="LOCAL-00018" summary="- added screenshot">
<option name="closed" value="true" />
<created>1748765318267</created>
<option name="number" value="00018" />
<option name="presentableId" value="LOCAL-00018" />
<option name="project" value="LOCAL" />
<updated>1748765318267</updated>
</task>
<option name="localTasksCounter" value="19" />
<servers /> <servers />
</component> </component>
<component name="Vcs.Log.Tabs.Properties"> <component name="Vcs.Log.Tabs.Properties">
@@ -127,6 +266,21 @@
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </ignored-roots>
<MESSAGE value="init" /> <MESSAGE value="init" />
<option name="LAST_COMMIT_MESSAGE" value="init" /> <MESSAGE value="- Basic oop sketch widget implement" />
<MESSAGE value="- Renabled extrusion with new object system" />
<MESSAGE value="- Sketch projection partly works again :)" />
<MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" />
<MESSAGE value="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working" />
<MESSAGE value="- delete sketch working&#10;- added mid point snap&#10;- added hovering line with distance" />
<MESSAGE value="- Added new buttons and settings" />
<MESSAGE value="- Added construction lines switching&#10;- Moved callbacks into sketchwidget from main.&#10;- Changed reset on right click" />
<MESSAGE value="- Added contrain displayed next to line&#10;- Slight change to point check from solver." />
<MESSAGE value="- Added enabling of midpsnap and prepared others&#10;- Show dimesnion on hover" />
<MESSAGE value="- Fixed redraw when component changed" />
<MESSAGE value="- added MIT license" />
<MESSAGE value="- added screenshot" />
<option name="LAST_COMMIT_MESSAGE" value="- added screenshot" />
</component> </component>
</project> </project>

View File

@@ -9,7 +9,8 @@ from PySide6.QtCore import Qt, QPoint, Signal, QSize
from PySide6.QtWidgets import QApplication, QMainWindow, QSizePolicy, QInputDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QCheckBox, QPushButton, QButtonGroup from PySide6.QtWidgets import QApplication, QMainWindow, QSizePolicy, QInputDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QCheckBox, QPushButton, QButtonGroup
from Gui import Ui_fluencyCAD # Import the generated GUI module from Gui import Ui_fluencyCAD # Import the generated GUI module
from drawing_modules.vtk_widget import VTKWidget from drawing_modules.vtk_widget import VTKWidget
from drawing_modules.vysta_widget import PyVistaWidget import numpy as np
from drawing_modules.draw_widget_solve import SketchWidget from drawing_modules.draw_widget_solve import SketchWidget
from sdf import * from sdf import *
from python_solvespace import SolverSystem, ResultFlag from python_solvespace import SolverSystem, ResultFlag

View File

@@ -1,6 +1,6 @@
import numpy as np import numpy as np
from scipy.spatial import Delaunay, ConvexHull from scipy.spatial import Delaunay, ConvexHull
from shapely.geometry import Polygon, Point #from shapely.geometry import Polygon, Point
def alpha_shape(points, alpha): def alpha_shape(points, alpha):

View File

@@ -1,60 +1,61 @@
certifi==2025.1.31 asttokens==3.0.0
cffi==1.17.1 attrs==25.3.0
charset-normalizer==3.4.1 black==24.10.0
contourpy==1.3.1 click==8.2.1
contourpy==1.3.2
cycler==0.12.1 cycler==0.12.1
decorator==5.2.1
executing==2.2.0
flexcache==0.3 flexcache==0.3
flexparser==0.4 flexparser==0.4
fonttools==4.56.0 fonttools==4.58.1
freetype-py==2.5.1 h5py==3.13.0
hsluv==5.0.4
idna==3.10
imageio==2.37.0 imageio==2.37.0
ipython==9.3.0
ipython_pygments_lexers==1.1.1
jedi==0.19.2
kiwisolver==1.4.8 kiwisolver==1.4.8
lazy_loader==0.4 lazy_loader==0.4
markdown-it-py==3.0.0 markdown-it-py==3.0.0
matplotlib==3.10.1 matplotlib==3.10.3
matplotlib-inline==0.1.7
mdurl==0.1.2 mdurl==0.1.2
meshio==5.3.5 meshio==5.3.5
mypy_extensions==1.1.0
names==0.3.0 names==0.3.0
networkx==3.4.2 networkx==3.5
Nuitka==2.6.9 Nuitka==2.7.10
numpy==2.2.4 numpy==2.2.6
numpy-stl==3.2.0
ordered-set==4.1.0 ordered-set==4.1.0
packaging==24.2 packaging==25.0
panda3d-gltf==1.3.0 parso==0.8.4
panda3d-simplepbr==0.13.0 pathspec==0.12.1
pillow==11.1.0 pexpect==4.9.0
pillow==11.2.1
Pint==0.24.4 Pint==0.24.4
platformdirs==4.3.7 platformdirs==4.3.8
pooch==1.8.2 prompt_toolkit==3.0.51
pycparser==2.22 ptyprocess==0.7.0
pygame==2.6.1 pure_eval==0.2.3
Pygments==2.19.1 Pygments==2.19.1
pyparsing==3.2.3 pyparsing==3.2.3
PySide6_Essentials==6.8.3 PySide6==6.9.0
PySide6_Addons==6.9.0
PySide6_Essentials==6.9.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-solvespace==3.0.8 python_solvespace==3.0.8
python-utils==3.9.1
pyvista==0.44.2
pyvistaqt==0.11.2
QtPy==2.4.3
requests==2.32.3
rich==13.9.4 rich==13.9.4
scikit-image==0.25.2 scikit-image==0.25.2
scipy==1.15.2 scipy==1.15.3
scooby==0.10.0 sdfcad @ git+https://gitlab.com/nobodyinperson/sdfCAD@42505b5181c88dda2fd66ac9d387533fbe4145f3
sdfcad @ git+https://gitlab.com/nobodyinperson/sdfCAD@9bd4e9021c6ee7e685ee28e8a3a5d2d2c028190c shiboken6==6.9.0
shapely==2.1.0rc1
shiboken6==6.8.3
six==1.17.0 six==1.17.0
tifffile==2025.3.13 stack-data==0.6.3
trimesh==4.6.5 tifffile==2025.5.26
tripy==1.0.0 tokenize_rt==6.2.0
typing_extensions==4.13.0 traitlets==5.14.3
urllib3==2.3.0 typing_extensions==4.13.2
vispy==0.14.3 vtk==9.4.2
vtk==9.4.1 wcwidth==0.2.13
vulkan==1.3.275.1 xlrd==2.0.2
zstandard==0.23.0 zstandard==0.23.0

26
sdf/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
from . import d2, d3, ease
from .util import *
from .units import units
from .d2 import *
from .d3 import *
from .text import (
measure_image,
measure_text,
image,
text,
)
from .mesh import (
generate,
save,
sample_slice,
show_slice,
)
from .stl import (
write_binary_stl,
)

390
sdf/d2.py Normal file
View File

@@ -0,0 +1,390 @@
import functools
import numpy as np
import operator
import copy
from . import dn, d3, ease
# Constants
ORIGIN = np.array((0, 0))
X = np.array((1, 0))
Y = np.array((0, 1))
UP = Y
# SDF Class
_ops = {}
class SDF2:
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 sdf2(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return SDF2(f(*args, **kwargs))
return wrapper
def op2(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return SDF2(f(*args, **kwargs))
_ops[f.__name__] = wrapper
return wrapper
def op23(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return d3.SDF3(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)
_min = np.minimum
_max = np.maximum
# Primitives
@sdf2
def circle(radius=None, diameter=None, center=ORIGIN):
if (radius is not None) == (diameter is not None):
raise ValueError(f"Specify either radius or diameter")
if radius is None:
radius = diameter / 2
def f(p):
return _length(p - center) - radius
return f
@sdf2
def line(normal=UP, point=ORIGIN):
normal = _normalize(normal)
def f(p):
return np.dot(point - p, normal)
return f
@sdf2
def slab(x0=None, y0=None, x1=None, y1=None, r=None):
fs = []
if x0 is not None:
fs.append(line(X, (x0, 0)))
if x1 is not None:
fs.append(line(-X, (x1, 0)))
if y0 is not None:
fs.append(line(Y, (0, y0)))
if y1 is not None:
fs.append(line(-Y, (0, y1)))
return intersection(*fs, r=r)
@sdf2
def rectangle(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 rectangle(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
@sdf2
def rounded_rectangle(size, radius, center=ORIGIN):
try:
r0, r1, r2, r3 = radius
except TypeError:
r0 = r1 = r2 = r3 = radius
def f(p):
x = p[:, 0]
y = p[:, 1]
r = np.zeros(len(p)).reshape((-1, 1))
r[np.logical_and(x > 0, y > 0)] = r0
r[np.logical_and(x > 0, y <= 0)] = r1
r[np.logical_and(x <= 0, y <= 0)] = r2
r[np.logical_and(x <= 0, y > 0)] = r3
q = np.abs(p) - size / 2 + r
return (
_min(_max(q[:, 0], q[:, 1]), 0).reshape((-1, 1))
+ _length(_max(q, 0)).reshape((-1, 1))
- r
)
return f
@sdf2
def equilateral_triangle():
def f(p):
k = 3**0.5
p = _vec(np.abs(p[:, 0]) - 1, p[:, 1] + 1 / k)
w = p[:, 0] + k * p[:, 1] > 0
q = _vec(p[:, 0] - k * p[:, 1], -k * p[:, 0] - p[:, 1]) / 2
p = np.where(w.reshape((-1, 1)), q, p)
p = _vec(p[:, 0] - np.clip(p[:, 0], -2, 0), p[:, 1])
return -_length(p) * np.sign(p[:, 1])
return f
@sdf2
def hexagon(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
radius *= 3**0.5 / 2
def f(p):
k = np.array((3**0.5 / -2, 0.5, np.tan(np.pi / 6)))
p = np.abs(p)
p -= 2 * k[:2] * _min(_dot(k[:2], p), 0).reshape((-1, 1))
p -= _vec(
np.clip(p[:, 0], -k[2] * radius, k[2] * radius), np.zeros(len(p)) + radius
)
return _length(p) * np.sign(p[:, 1])
return f
@sdf2
def rounded_x(w, r):
def f(p):
p = np.abs(p)
q = (_min(p[:, 0] + p[:, 1], w) * 0.5).reshape((-1, 1))
return _length(p - q) - r
return f
def RegularPolygon(n, r=1):
ri = r * np.cos(np.pi / n)
return intersection(
*[slab(y0=-ri).rotate(a) for a in np.arange(0, 2 * np.pi, 2 * np.pi / n)]
)
@sdf2
def polygon(points):
points = [np.array(p) for p in points]
def f(p):
n = len(points)
d = _dot(p - points[0], p - points[0])
s = np.ones(len(p))
for i in range(n):
j = (i + n - 1) % n
vi = points[i]
vj = points[j]
e = vj - vi
w = p - vi
b = w - e * np.clip(np.dot(w, e) / np.dot(e, e), 0, 1).reshape((-1, 1))
d = _min(d, _dot(b, b))
c1 = p[:, 1] >= vi[1]
c2 = p[:, 1] < vj[1]
c3 = e[0] * w[:, 1] > e[1] * w[:, 0]
c = _vec(c1, c2, c3)
s = np.where(np.all(c, axis=1) | np.all(~c, axis=1), -s, s)
return s * np.sqrt(d)
return f
# Positioning
@op2
def translate(other, offset):
def f(p):
return other(p - offset)
return f
@op2
def scale(other, factor):
try:
x, y = factor
except TypeError:
x = y = factor
s = (x, y)
m = min(x, y)
def f(p):
return other(p / s) * m
return f
@op2
def rotate(other, angle):
s = np.sin(angle)
c = np.cos(angle)
m = 1 - c
matrix = np.array(
[
[c, -s],
[s, c],
]
).T
def f(p):
return other(np.dot(p, matrix))
return f
@op2
def circular_array(other, count):
angles = [i / count * 2 * np.pi for i in range(count)]
return union(*[other.rotate(a) for a in angles])
# Alterations
@op2
def elongate(other, size):
def f(p):
q = np.abs(p) - size
x = q[:, 0].reshape((-1, 1))
y = q[:, 1].reshape((-1, 1))
w = _min(_max(x, y), 0)
return other(_max(q, 0)) + w
return f
# 2D => 3D Operations
@op23
def extrude(other, h=np.inf):
def f(p):
d = other(p[:, [0, 1]])
w = _vec(d.reshape(-1), np.abs(p[:, 2]) - h / 2)
return _min(_max(w[:, 0], w[:, 1]), 0) + _length(_max(w, 0))
return f
@op23
def extrude_to(a, b, h, e=ease.linear):
def f(p):
d1 = a(p[:, [0, 1]])
d2 = b(p[:, [0, 1]])
t = e(np.clip(p[:, 2] / h, -0.5, 0.5) + 0.5)
d = d1 + (d2 - d1) * t.reshape((-1, 1))
w = _vec(d.reshape(-1), np.abs(p[:, 2]) - h / 2)
return _min(_max(w[:, 0], w[:, 1]), 0) + _length(_max(w, 0))
return f
@op23
def revolve(other, offset=0):
def f(p):
xy = p[:, [0, 1]]
# use horizontal distance to Z axis as X coordinate in 2D shape
# use Z coordinate as Y coordinate in 2D shape
q = _vec(_length(xy) - offset, p[:, 2])
return other(q)
return f
# Common
union = op2(dn.union)
difference = op2(dn.difference)
intersection = op2(dn.intersection)
blend = op2(dn.blend)
negate = op2(dn.negate)
dilate = op2(dn.dilate)
erode = op2(dn.erode)
shell = op2(dn.shell)
repeat = op2(dn.repeat)
mirror = op2(dn.mirror)
modulate_between = op2(dn.modulate_between)
stretch = op2(dn.stretch)

1666
sdf/d3.py Normal file

File diff suppressed because it is too large Load Diff

366
sdf/dn.py Normal file
View File

@@ -0,0 +1,366 @@
import itertools
from functools import reduce, partial
import warnings
from . import ease
import numpy as np
_min = np.minimum
_max = np.maximum
def distance_to_plane(p, origin, normal):
"""
Calculate the distance of a point ``p`` to the plane around ``origin`` with
normal ``normal``. This is dimension-independent, so e.g. the z-coordinate
can be omitted.
Args:
p (array): either [x,y,z] or [[x,y,z],[x,y,z],...]
origin (vector): a point on the plane
normal (vector): normal vector of the plane
Returns:
int: distance to plane
"""
normal = normal / np.linalg.norm(normal)
return abs((p - origin) @ normal)
def minimum(a, b, r=0):
if r:
Δ = b - a
h = np.clip(0.5 + 0.5 * Δ / r, 0, 1)
return b - Δ * h - r * h * (1 - h)
else:
return np.minimum(a, b)
def maximum(a, b, r=0):
if r:
Δ = b - a
h = np.clip(0.5 - 0.5 * Δ / r, 0, 1)
return b - Δ * h + r * h * (1 - h)
else:
return np.maximum(a, b)
def union(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
c = max(chamfer, c)
r = max(radius, r, fillet, f)
sqrt05 = np.sqrt(0.5)
def f(p):
sdfs_ = iter(sdfs)
d1 = next(sdfs_)(p)
for sdf in sdfs_:
d2 = sdf(p)
R = r or getattr(sdf, "_r", 0)
C = c or getattr(sdf, "_c", 0)
parts = (d1, d2)
if C:
parts = (minimum(d1, d2), (d1 + d2 - C) * sqrt05)
d1 = minimum(*parts, R)
return d1
return f
def intersection(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
c = max(chamfer, c)
r = max(radius, r, fillet, f)
sqrt05 = np.sqrt(0.5)
def f(p):
sdfs_ = iter(sdfs)
d1 = next(sdfs_)(p)
for sdf in sdfs_:
d2 = sdf(p)
R = r or getattr(sdf, "_r", 0)
C = c or getattr(sdf, "_c", 0)
parts = (d1, d2)
if C:
parts = (maximum(d1, d2), (d1 + d2 + C) * sqrt05)
d1 = maximum(*parts, R)
return d1
return f
def difference(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
c = max(chamfer, c)
r = max(radius, r, fillet, f)
sqrt05 = np.sqrt(0.5)
def f(p):
sdfs_ = iter(sdfs)
d1 = next(sdfs_)(p)
for sdf in sdfs_:
d2 = sdf(p)
R = r or getattr(sdf, "_r", 0)
C = c or getattr(sdf, "_c", 0)
parts = (d1, -d2)
if C:
parts = (maximum(d1, -d2), (d1 - d2 + C) * sqrt05)
d1 = maximum(*parts, R)
return d1
return f
def union_legacy(a, *bs, r=None):
def f(p):
d1 = a(p)
for b in bs:
d2 = b(p)
K = k or getattr(b, "_r", None)
if K is None:
d1 = _min(d1, d2)
else:
h = np.clip(0.5 + 0.5 * (d2 - d1) / K, 0, 1)
m = d2 + (d1 - d2) * h
d1 = m - K * h * (1 - h)
return d1
return f
def difference_legacy(a, *bs, r=None):
def f(p):
d1 = a(p)
for b in bs:
d2 = b(p)
K = k or getattr(b, "_r", None)
if K is None:
d1 = _max(d1, -d2)
else:
h = np.clip(0.5 - 0.5 * (d2 + d1) / K, 0, 1)
m = d1 + (-d2 - d1) * h
d1 = m + K * h * (1 - h)
return d1
return f
def intersection_legacy(a, *bs, r=None):
def f(p):
d1 = a(p)
for b in bs:
d2 = b(p)
K = k or getattr(b, "_r", None)
if K is None:
d1 = _max(d1, d2)
else:
h = np.clip(0.5 - 0.5 * (d2 - d1) / K, 0, 1)
m = d2 + (d1 - d2) * h
d1 = m + K * h * (1 - h)
return d1
return f
def blend(a, *bs, r=0.5):
def f(p):
d1 = a(p)
for b in bs:
d2 = b(p)
K = k or getattr(b, "_r", None)
d1 = K * d2 + (1 - K) * d1
return d1
return f
def negate(other):
def f(p):
return -other(p)
return f
def dilate(other, r):
def f(p):
return other(p) - r
return f
def erode(other, r):
def f(p):
return other(p) + r
return f
def shell(other, thickness=1, type="center"):
"""
Keep only a margin of a given thickness around the object's boundary.
Args:
thickness (float): the resulting thickness
type (str): what kind of shell to generate.
``"center"`` (default)
shell is spaced symmetrically around boundary
``"outer"``
the resulting shell will be ``thickness`` larger than before
``"inner"``
the resulting shell will be as large as before
"""
return dict(
center=lambda p: np.abs(other(p)) - thickness / 2,
inner=other - other.erode(thickness),
outer=other.dilate(thickness) - other,
)[type]
def modulate_between(sdf, a, b, e=ease.in_out_cubic):
"""
Apply a distance offset transition between two control points
(e.g. make a rod thicker or thinner at some point or add a bump)
Args:
a, b (vectors): the two control points
e (scalar function): the distance offset function, will be called with
values between 0 (at control point ``a``) and 1 (at control point
``b``). Its result will be subtracted from the given SDF, thus
enlarging the object by that value.
"""
# 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
offset = e(np.clip((p - a) @ ab / L, 0, 1))
return (dist := sdf(p)) - offset.reshape(dist.shape)
return f
def stretch(sdf, a, b, symmetric=False, e=ease.linear):
"""
Grab the object at point ``a`` and stretch the entire plane to ``b``.
Args:
a, b (point vectors): the control points
symmetric (bool): also stretch the same into the other direction.
e (Easing): easing to apply
Examples
========
.. code-block:: python
# make a capsule
sphere(5).stretch(ORIGIN, 10*Z).save() # same as capsule(ORIGIN, 10*Z, 5)
# make an egg
sphere(5).stretch(ORIGIN, 10*Z, e=ease.smoothstep[:0.44]).save()
"""
ab = (ab := b - a) / (L := np.linalg.norm(ab))
def f(p):
# s = ”how far are we between a and b as fraction?”
# if symmetric=True this also goes into the negative direction
s = np.clip((p - a) @ ab / L, -1 if symmetric else 0, 1)
# we return the sdf at a point 'behind' (p minus ...)
# the current point, but we go only as far back as the stretch distance
# at max
return sdf(p - (np.sign(s) * e(abs(s)) * L * ab[:, np.newaxis]).T)
return f
def shear(sdf, fix, grab, move, e=ease.linear):
"""
Grab the object at point ``grab`` and shear the entire plane in direction
``move``, keeping point ``fix`` in place. If ``move`` is orthogonal to the
direction ``fix``->``grab``, then this operation is a shear.
Args:
fix, grab (point vectors): the control points
move (point vector): direction to shear to
e (Easing): easing to apply
Examples
========
.. code-block:: python
# make a capsule
box([20,10,50]).shear(fix=-15*Z, grab=15*Z, move=-5*X, e=ease.smoothstep)
"""
ab = (ab := grab - fix) / (L := np.linalg.norm(ab))
def f(p):
# s = ”how far are we between a and b as fraction?”
s = (p - fix) @ ab / L
return sdf(p - move * np.expand_dims(e(np.clip(s, 0, 1)), axis=1))
return f
def mirror(other, direction, at=0):
"""
Mirror around a given plane defined by ``origin`` reference point and
``direction``.
Args:
direction (vector): direction to mirror to (e.g. :any:`X` to mirror along X axis)
at (3D vector): point to mirror at. Default is the origin.
"""
direction = direction / np.linalg.norm(direction)
def f(p):
projdir = np.expand_dims((p - at) @ direction, axis=1) * direction
# mirrored point:
# - project 'p' onto 'direction' (result goes into 'projdir' direction)
# - projected point is at 'at + projdir'
# - remember direction from projected point to the original point (p - (at + projdir))
# - from origin 'at' go backwards the projected direction (at - projdir)
# - from that target, move along the remembered direction (p - (at + projdir))
# - pmirr = at - projdir + (p - (at + projdir))
# - the 'at' cancels out, the projdir is subtracted twice from the point
return other(p - 2 * projdir)
return f
def repeat(other, spacing, count=None, padding=0):
count = np.array(count) if count is not None else None
spacing = np.array(spacing)
def neighbors(dim, padding, spacing):
try:
padding = [padding[i] for i in range(dim)]
except Exception:
padding = [padding] * dim
try:
spacing = [spacing[i] for i in range(dim)]
except Exception:
spacing = [spacing] * dim
for i, s in enumerate(spacing):
if s == 0:
padding[i] = 0
axes = [list(range(-p, p + 1)) for p in padding]
return list(itertools.product(*axes))
def f(p):
q = np.divide(p, spacing, out=np.zeros_like(p), where=spacing != 0)
if count is None:
index = np.round(q)
else:
index = np.clip(np.round(q), -count, count)
indexes = [index + n for n in neighbors(p.shape[-1], padding, spacing)]
A = [other(p - spacing * i) for i in indexes]
a = A[0]
for b in A[1:]:
a = _min(a, b)
return a
return f

637
sdf/ease.py Normal file
View File

@@ -0,0 +1,637 @@
# system modules
from dataclasses import dataclass
from typing import Callable
import itertools
import functools
import warnings
# external modules
import numpy as np
import scipy.optimize
@dataclass
@functools.total_ordering
class Extremum:
"""
Container for min and max in Easing
"""
pos: float
value: float
def __eq__(self, other):
return self.value == other.value
def __lt__(self, other):
return self.value < other.value
@dataclass
@functools.total_ordering
class Easing:
"""
A function defined on the interval [0;1]
"""
f: Callable[float, float]
name: str
def modifier(decorated_fun):
@functools.wraps(decorated_fun)
def wrapper(self, *args, **kwargs):
newfun = decorated_fun(self, *args, **kwargs)
arglist = ",".join(
itertools.chain(map(str, args), (f"{k}={v}" for k, v in kwargs.items()))
)
newfun.__name__ = f"{self.f.__name__}.{decorated_fun.__name__}({arglist})"
return type(self)(f=newfun, name=newfun.__name__)
return wrapper
def __repr__(self):
return self.name
def __str__(self):
return self.name
@functools.cached_property
def is_ascending(self):
return np.all(np.diff(self.f(np.linspace(0, 1, 100))) >= 0)
@functools.cached_property
def is_symmetric(self):
t = np.linspace(0, 0.5, 100)
return np.allclose(self.f(t), self.f(1 - t))
@property
@modifier
def reverse(self):
"""
Revert the function so it goes the other way round (starts at the end)
"""
return lambda t: self.f(1 - t)
@property
@modifier
def symmetric(self):
"""
Mirror and squash function to make it symmetric
"""
return lambda t: self.f(-2 * (np.abs(t - 0.5) - 0.5))
@modifier
def mirror(self, x=None, y=None, copy=False):
"""
Mirror function around an x and/or y value.
Args:
x (float): x value to mirror around
y (float): y value to mirror around
copy (bool): when mirroring around x, do copy-mirror
"""
if (x, y) == (None, None):
x = 0.5
def mirrored(t):
if x is not None:
t = 2 * x - t
if copy:
t = np.abs(-t)
if y is None:
return self.f(t)
else:
return y - self.f(t)
return mirrored
@modifier
def clip(self, min=None, max=None):
"""
Clip function at low and/or high values
"""
if min is None and max is None:
min = 0
max = 1
return lambda t: np.clip(self.f(t), min, max)
@modifier
def clip_input(self, min=None, max=None):
"""
Clip input parameter, i.e. extrapolate constantly outside the interval.
"""
if min is None and max is None:
min = 0
max = 1
return lambda t: self.f(np.clip(t, min, max))
@property
@modifier
def clipped(self):
"""
Clipped parameter and result to [0;1]
"""
return lambda t: np.clip(self(np.clip(t, 0, 1)), 0, 1)
@modifier
def append(self, other, e=None):
"""
Append another easing function and squish both into the [0;1] interval
"""
if e is None:
e = in_out_square
def f(t):
mix = e(t)
return self.f(t * 2) * (1 - mix) + other((t - 0.5) * 2) * mix
return f
@modifier
def prepend(self, other, e=None):
"""
Prepend another easing function and squish both into the [0;1] interval
"""
if e is None:
e = in_out_square
def f(t):
mix = e(t)
return other(t * 2) * (1 - mix) + self.f((t - 0.5) * 2) * mix
return f
@modifier
def shift(self, offset):
"""
Shift function on x-axis into positive direction by ``offset``.
"""
return lambda t: self.f(t - offset)
@modifier
def repeat(self, n=2):
"""
Repeat the function a total of n times in the interval [0;1].
"""
return lambda t: self.f(t % (1 / n) * n)
@modifier
def multiply(self, factor):
"""
Scale function by ``factor``
"""
if isinstance(factor, Easing):
return lambda t: self(t) * factor(t)
else:
return lambda t: factor * self.f(t)
@modifier
def add(self, offset):
"""
Add ``offset`` to function
"""
if isinstance(offset, Easing):
return lambda t: self(t) + offset(t)
else:
return lambda t: self.f(t) + offset
def __add__(self, offset):
return self.add(offset)
def __radd__(self, offset):
return self.add(offset)
def __sub__(self, offset):
return self.add(-offset)
def __rsub__(self, offset):
return self.add(-offset)
def __mul__(self, factor):
return self.multiply(factor)
def __rmul__(self, factor):
return self.multiply(factor)
def __neg__(self):
return self.multiply(-1)
def __truediv__(self, factor):
return self.multiply(1 / factor)
def __or__(self, other):
return self.transition(other)
def __rshift__(self, offset):
return self.shift(offset)
def __lshift__(self, offset):
return self.shift(-offset)
def __getitem__(self, index):
if isinstance(index, Easing):
return self.chain(index)
if isinstance(index, slice):
return self.zoom(
0 if index.start is None else index.start,
1 if index.stop is None else index.stop,
)
else:
raise ValueError(
f"{index = } has to be slice of floats or an easing function"
)
@modifier
def chain(self, f=None):
"""
Feed parameter through the given function before evaluating this function.
"""
if f is None:
f = self.f
return lambda t: self.f(f(t))
@modifier
def zoom(self, left, right=None):
"""
Arrange so that the interval [left;right] is moved into [0;1]
If only one argument is given, zoom in/out by moving edges that far.
"""
if left is not None and right is None:
if left >= 0.5:
raise ValueError(
f"{left = } is > 0.5 which doesn't make sense (bounds would cross)"
)
left = left
right = 1 - left
if left >= right:
raise ValueError(f"{right = } bound must be greater than {left = }")
return self.chain(linear.between(left, right)).f
@modifier
def between(self, left=0, right=1, e=None):
"""
Arrange so ``f(0)==a`` and ``f(1)==b``.
"""
f0, f1 = self.f(np.array([0, 1]))
la = f0 - left
lb = f1 - right
if e is None: # linear is defined later
e = (
self # use ourself as transition when we're ascending within [0;1]
if (self.is_ascending and np.allclose(self.f(np.array([0, 1])), [0, 1]))
else linear
)
def f(t):
t_ = e(t)
return self.f(t_) - (la * (1 - t_)) - lb * t_
return f
@modifier
def transition(self, other, e=None):
"""
Transiton from one easing to another
"""
if e is None:
e = linear
def f(t):
t_ = e(t)
return self.f(t) * (1 - t_) + other(t) * t_
return f
@classmethod
def function(cls, decorated_fun):
return cls(f=decorated_fun, name=decorated_fun.__name__)
def plot(self, *others, xlim=(0, 1), ax=None):
import matplotlib.pyplot as plt # lazy import for speed
from cycler import cycler
if ax is None:
fig, ax_ = plt.subplots()
else:
ax_ = ax
try:
ax_.set_prop_cycle(
cycler(linestyle=["solid", "dashed", "dotted"], linewidth=[1, 1, 2])
* plt.rcParams["axes.prop_cycle"]
)
except ValueError as e:
pass
t = np.linspace(*xlim, 1000)
funs = list(others or [])
if isinstance(self, Easing):
funs.insert(0, self)
for f in funs:
ax_.plot(t, f(t), label=getattr(f, "name", getattr(f, "__name__", str(f))))
ax_.legend(ncol=int(np.ceil(len(ax_.get_lines()) / 10)))
if ax is None:
plt.show()
return ax_
@functools.cached_property
def min(self):
v = self.f(t := np.linspace(0, 1, 1000))
approxmin = Extremum(pos=t[i := np.argmin(v)], value=v[i])
opt = scipy.optimize.minimize(self, x0=[approxmin.pos], bounds=[(0, 1)])
optmin = Extremum(pos=opt.x[0], value=opt.fun)
return min(approxmin, optmin)
@functools.cached_property
def max(self):
"""
Determine the maximum value
"""
v = self.f(t := np.linspace(0, 1, 1000))
approxmax = Extremum(pos=t[i := np.argmax(v)], value=v[i])
opt = scipy.optimize.minimize(-self, x0=[approxmax.pos], bounds=[(0, 1)])
optmax = Extremum(pos=opt.x[0], value=-opt.fun)
return max(approxmax, optmax)
@functools.cached_property
def mean(self):
return np.mean(self.f(np.linspace(0, 1, 1000)))
def __lt__(self, e):
return np.all(self.f(t := np.linspace(0, 1, 50)) < e.f(t))
def __eq__(self, e):
return np.allclose(self.f(t := np.linspace(0, 1, 50)), e.f(t))
def __call__(self, t):
return self.f(t)
@Easing.function
def linear(t):
return t
@Easing.function
def in_quad(t):
return t * t
@Easing.function
def out_quad(t):
return -t * (t - 2)
@Easing.function
def in_out_quad(t):
u = 2 * t - 1
a = 2 * t * t
b = -0.5 * (u * (u - 2) - 1)
return np.where(t < 0.5, a, b)
@Easing.function
def in_cubic(t):
return t * t * t
@Easing.function
def out_cubic(t):
u = t - 1
return u * u * u + 1
@Easing.function
def in_out_cubic(t):
u = t * 2
v = u - 2
a = 0.5 * u * u * u
b = 0.5 * (v * v * v + 2)
return np.where(u < 1, a, b)
@Easing.function
def in_quart(t):
return t * t * t * t
@Easing.function
def out_quart(t):
u = t - 1
return -(u * u * u * u - 1)
@Easing.function
def in_out_quart(t):
u = t * 2
v = u - 2
a = 0.5 * u * u * u * u
b = -0.5 * (v * v * v * v - 2)
return np.where(u < 1, a, b)
@Easing.function
def in_quint(t):
return t * t * t * t * t
@Easing.function
def out_quint(t):
u = t - 1
return u * u * u * u * u + 1
@Easing.function
def in_out_quint(t):
u = t * 2
v = u - 2
a = 0.5 * u * u * u * u * u
b = 0.5 * (v * v * v * v * v + 2)
return np.where(u < 1, a, b)
@Easing.function
def in_sine(t):
return -np.cos(t * np.pi / 2) + 1
@Easing.function
def out_sine(t):
return np.sin(t * np.pi / 2)
@Easing.function
def in_out_sine(t):
return -0.5 * (np.cos(np.pi * t) - 1)
@Easing.function
def in_expo(t):
a = np.zeros(len(t))
b = 2 ** (10 * (t - 1))
return np.where(t == 0, a, b)
@Easing.function
def out_expo(t):
a = np.zeros(len(t)) + 1
b = 1 - 2 ** (-10 * t)
return np.where(t == 1, a, b)
@Easing.function
def in_out_expo(t):
zero = np.zeros(len(t))
one = zero + 1
a = 0.5 * 2 ** (20 * t - 10)
b = 1 - 0.5 * 2 ** (-20 * t + 10)
return np.where(t == 0, zero, np.where(t == 1, one, np.where(t < 0.5, a, b)))
@Easing.function
def in_circ(t):
return -1 * (np.sqrt(1 - t * t) - 1)
@Easing.function
def out_circ(t):
u = t - 1
return np.sqrt(1 - u * u)
@Easing.function
def in_out_circ(t):
u = t * 2
v = u - 2
a = -0.5 * (np.sqrt(1 - u * u) - 1)
b = 0.5 * (np.sqrt(1 - v * v) + 1)
return np.where(u < 1, a, b)
@Easing.function
def in_elastic(t, k=0.5):
u = t - 1
return -1 * (2 ** (10.0 * u) * np.sin((u - k / 4) * (2 * np.pi) / k))
@Easing.function
def out_elastic(t, k=0.5):
return 2 ** (-10.0 * t) * np.sin((t - k / 4) * (2 * np.pi / k)) + 1
@Easing.function
def in_out_elastic(t, k=0.5):
u = t * 2
v = u - 1
a = -0.5 * (2 ** (10 * v) * np.sin((v - k / 4) * 2 * np.pi / k))
b = 2 ** (-10 * v) * np.sin((v - k / 4) * 2 * np.pi / k) * 0.5 + 1
return np.where(u < 1, a, b)
@Easing.function
def in_back(t):
k = 1.70158
return t * t * ((k + 1) * t - k)
@Easing.function
def out_back(t):
k = 1.70158
u = t - 1
return u * u * ((k + 1) * u + k) + 1
@Easing.function
def in_out_back(t):
k = 1.70158 * 1.525
u = t * 2
v = u - 2
a = 0.5 * (u * u * ((k + 1) * u - k))
b = 0.5 * (v * v * ((k + 1) * v + k) + 2)
return np.where(u < 1, a, b)
@Easing.function
def in_bounce(t):
return 1 - out_bounce(1 - t)
@Easing.function
def out_bounce(t):
a = (121 * t * t) / 16
b = (363 / 40 * t * t) - (99 / 10 * t) + 17 / 5
c = (4356 / 361 * t * t) - (35442 / 1805 * t) + 16061 / 1805
d = (54 / 5 * t * t) - (513 / 25 * t) + 268 / 25
return np.where(t < 4 / 11, a, np.where(t < 8 / 11, b, np.where(t < 9 / 10, c, d)))
@Easing.function
def in_out_bounce(t):
a = in_bounce(2 * t) * 0.5
b = out_bounce(2 * t - 1) * 0.5 + 0.5
return np.where(t < 0.5, a, b)
@Easing.function
def in_square(t):
return np.heaviside(t - 1, 0)
@Easing.function
def out_square(t):
return np.heaviside(t + 1, 0)
@Easing.function
def in_out_square(t):
return np.heaviside(t - 0.5, 0)
def constant(x):
return Easing(f=lambda t: np.full_like(t, x), name=f"constant({x})")
zero = constant(0)
one = constant(1)
@Easing.function
def smoothstep(t):
t = np.clip(t, 0, 1)
return 3 * t * t - 2 * t * t * t
def _main():
import matplotlib.pyplot as plt
from cycler import cycler
plt.rcParams["axes.prop_cycle"] *= cycler(
linestyle=["solid", "dashed", "dotted"], linewidth=[1, 2, 3]
)
plt.rcParams["figure.autolayout"] = True
plt.rcParams["axes.grid"] = True
plt.rcParams["axes.axisbelow"] = True
plt.rcParams["legend.fontsize"] = "small"
LOCALS = globals()
print(f"{LOCALS = }")
fig, axes = plt.subplots(nrows=2)
Easing.plot(
*sorted((obj for n, obj in LOCALS.items() if isinstance(obj, Easing)), key=str),
ax=axes[0],
)
Easing.plot(
in_sine.symmetric,
in_out_sine.symmetric.multiply(-0.6),
linear.symmetric.multiply(-0.7),
in_out_sine.multiply(-0.6).symmetric,
out_sine.multiply(-0.6).reverse.symmetric.multiply(2),
out_bounce.add(-0.5),
ax=axes[1],
)
axes[0].set_title("Standard")
axes[1].set_title("Derived")
plt.show()
if __name__ == "__main__":
_main()

42
sdf/errors.py Normal file
View File

@@ -0,0 +1,42 @@
import warnings
import functools
class SDFCADError(Exception):
pass
class SDFCADInfiniteObjectError(Exception):
"""
Error raised when an infinite object is encountered where not suitable.
"""
pass
class SDFCADWarning(Warning):
pass
class SDFCADAlphaQualityWarning(SDFCADWarning):
show = True
def alpha_quality(decorated_fun):
@functools.wraps(decorated_fun)
def wrapper(*args, **kwargs):
if SDFCADAlphaQualityWarning.show:
warnings.warn(
f"{decorated_fun.__name__}() is alpha quality "
f"and might give wrong results. Use with care. "
f"Hide this warning by setting sdf.errors.SDFCADAlphaQualityWarning.show=False.",
SDFCADAlphaQualityWarning,
)
with warnings.catch_warnings():
# Don't reissue nested alpha quality warnings
warnings.simplefilter("ignore", SDFCADAlphaQualityWarning)
return decorated_fun(*args, **kwargs)
else:
return decorated_fun(*args, **kwargs)
return wrapper

282
sdf/mesh.py Normal file
View File

@@ -0,0 +1,282 @@
from functools import partial
from multiprocessing.pool import ThreadPool
from skimage import measure
import multiprocessing
import itertools
import numpy as np
import time
from . import progress, stl
WORKERS = multiprocessing.cpu_count()
SAMPLES = 2**18
BATCH_SIZE = 32
def _marching_cubes(volume, level=0):
verts, faces, _, _ = measure.marching_cubes(volume, level)
return verts[faces].reshape((-1, 3))
def _cartesian_product(*arrays):
la = len(arrays)
dtype = np.result_type(*arrays)
arr = np.empty([len(a) for a in arrays] + [la], dtype=dtype)
for i, a in enumerate(np.ix_(*arrays)):
arr[..., i] = a
return arr.reshape(-1, la)
def _skip(sdf, job):
X, Y, Z = job
x0, x1 = X[0], X[-1]
y0, y1 = Y[0], Y[-1]
z0, z1 = Z[0], Z[-1]
x = (x0 + x1) / 2
y = (y0 + y1) / 2
z = (z0 + z1) / 2
r = abs(sdf(np.array([(x, y, z)])).reshape(-1)[0])
d = np.linalg.norm(np.array((x - x0, y - y0, z - z0)))
if r <= d:
return False
corners = np.array(list(itertools.product((x0, x1), (y0, y1), (z0, z1))))
values = sdf(corners).reshape(-1)
same = np.all(values > 0) if values[0] > 0 else np.all(values < 0)
return same
def _worker(sdf, job, sparse):
X, Y, Z = job
if sparse and _skip(sdf, job):
return None
# return _debug_triangles(X, Y, Z)
P = _cartesian_product(X, Y, Z)
volume = sdf(P).reshape((len(X), len(Y), len(Z)))
try:
points = _marching_cubes(volume)
except Exception:
return []
# return _debug_triangles(X, Y, Z)
scale = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
offset = np.array([X[0], Y[0], Z[0]])
return points * scale + offset
def _estimate_bounds(sdf):
# TODO: raise exception if bound estimation fails
s = 16
x0 = y0 = z0 = -1e9
x1 = y1 = z1 = 1e9
prev = None
for i in range(32):
X = np.linspace(x0, x1, s)
Y = np.linspace(y0, y1, s)
Z = np.linspace(z0, z1, s)
d = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
threshold = np.linalg.norm(d) / 2
if threshold == prev:
break
prev = threshold
P = _cartesian_product(X, Y, Z)
volume = sdf(P).reshape((len(X), len(Y), len(Z)))
where = np.argwhere(np.abs(volume) <= threshold)
x1, y1, z1 = (x0, y0, z0) + where.max(axis=0) * d + d / 2
x0, y0, z0 = (x0, y0, z0) + where.min(axis=0) * d - d / 2
return ((x0, y0, z0), (x1, y1, z1))
def generate(
sdf,
step=None,
bounds=None,
samples=SAMPLES,
workers=WORKERS,
batch_size=BATCH_SIZE,
verbose=True,
sparse=True,
):
start = time.time()
if bounds is None:
bounds = _estimate_bounds(sdf)
(x0, y0, z0), (x1, y1, z1) = bounds
if step is None and samples is not None:
volume = (x1 - x0) * (y1 - y0) * (z1 - z0)
step = (volume / samples) ** (1 / 3)
try:
dx, dy, dz = step
except TypeError:
dx = dy = dz = step
if verbose:
print("min %g, %g, %g" % (x0, y0, z0))
print("max %g, %g, %g" % (x1, y1, z1))
print("step %g, %g, %g" % (dx, dy, dz))
X = np.arange(x0, x1, dx)
Y = np.arange(y0, y1, dy)
Z = np.arange(z0, z1, dz)
s = batch_size
Xs = [X[i : i + s + 1] for i in range(0, len(X), s)]
Ys = [Y[i : i + s + 1] for i in range(0, len(Y), s)]
Zs = [Z[i : i + s + 1] for i in range(0, len(Z), s)]
batches = list(itertools.product(Xs, Ys, Zs))
num_batches = len(batches)
num_samples = sum(len(xs) * len(ys) * len(zs) for xs, ys, zs in batches)
if verbose:
print(
"%d samples in %d batches with %d workers"
% (num_samples, num_batches, workers)
)
points = []
skipped = empty = nonempty = 0
bar = progress.Bar(num_batches, enabled=verbose)
f = partial(_worker, sdf, sparse=sparse)
with ThreadPool(workers) as pool:
for result in pool.imap(f, batches):
bar.increment(1)
if result is None:
skipped += 1
elif len(result) == 0:
empty += 1
else:
nonempty += 1
points.extend(result)
bar.done()
if verbose:
print("%d skipped, %d empty, %d nonempty" % (skipped, empty, nonempty))
triangles = len(points) // 3
seconds = time.time() - start
print("%d triangles in %g seconds" % (triangles, seconds))
return points
def save(path, *args, **kwargs):
points = generate(*args, **kwargs)
if str(path).lower().endswith(".stl"):
stl.write_binary_stl(path, points)
else:
mesh = _mesh(points)
mesh.write(path)
def _mesh(points):
import meshio
points, cells = np.unique(points, axis=0, return_inverse=True)
cells = [("triangle", cells.reshape((-1, 3)))]
return meshio.Mesh(points, cells)
def _debug_triangles(X, Y, Z):
x0, x1 = X[0], X[-1]
y0, y1 = Y[0], Y[-1]
z0, z1 = Z[0], Z[-1]
p = 0.25
x0, x1 = x0 + (x1 - x0) * p, x1 - (x1 - x0) * p
y0, y1 = y0 + (y1 - y0) * p, y1 - (y1 - y0) * p
z0, z1 = z0 + (z1 - z0) * p, z1 - (z1 - z0) * p
v = [
(x0, y0, z0),
(x0, y0, z1),
(x0, y1, z0),
(x0, y1, z1),
(x1, y0, z0),
(x1, y0, z1),
(x1, y1, z0),
(x1, y1, z1),
]
return [
v[3],
v[5],
v[7],
v[5],
v[3],
v[1],
v[0],
v[6],
v[4],
v[6],
v[0],
v[2],
v[0],
v[5],
v[1],
v[5],
v[0],
v[4],
v[5],
v[6],
v[7],
v[6],
v[5],
v[4],
v[6],
v[3],
v[7],
v[3],
v[6],
v[2],
v[0],
v[3],
v[2],
v[3],
v[0],
v[1],
]
def sample_slice(sdf, w=1024, h=1024, x=None, y=None, z=None, bounds=None):
if bounds is None:
bounds = _estimate_bounds(sdf)
(x0, y0, z0), (x1, y1, z1) = bounds
if x is not None:
X = np.array([x])
Y = np.linspace(y0, y1, w)
Z = np.linspace(z0, z1, h)
extent = (Z[0], Z[-1], Y[0], Y[-1])
axes = "ZY"
elif y is not None:
Y = np.array([y])
X = np.linspace(x0, x1, w)
Z = np.linspace(z0, z1, h)
extent = (Z[0], Z[-1], X[0], X[-1])
axes = "ZX"
elif z is not None:
Z = np.array([z])
X = np.linspace(x0, x1, w)
Y = np.linspace(y0, y1, h)
extent = (Y[0], Y[-1], X[0], X[-1])
axes = "YX"
else:
raise Exception("x, y, or z position must be specified")
P = _cartesian_product(X, Y, Z)
return sdf(P).reshape((w, h)), extent, axes
def show_slice(*args, **kwargs):
import matplotlib.pyplot as plt
show_abs = kwargs.pop("abs", False)
a, extent, axes = sample_slice(*args, **kwargs)
if show_abs:
a = np.abs(a)
im = plt.imshow(a, extent=extent, origin="lower")
plt.xlabel(axes[0])
plt.ylabel(axes[1])
plt.colorbar(im)
plt.show()

83
sdf/progress.py Normal file
View File

@@ -0,0 +1,83 @@
import sys
import time
def pretty_time(seconds):
seconds = int(round(seconds))
s = seconds % 60
m = (seconds // 60) % 60
h = seconds // 3600
return "%d:%02d:%02d" % (h, m, s)
class Bar(object):
def __init__(self, max_value=100, min_value=0, enabled=True):
self.min_value = min_value
self.max_value = max_value
self.value = min_value
self.start_time = time.time()
self.enabled = enabled
@property
def percent_complete(self):
t = (self.value - self.min_value) / (self.max_value - self.min_value)
return t * 100
@property
def elapsed_time(self):
return time.time() - self.start_time
@property
def eta(self):
t = self.percent_complete / 100
if t == 0:
return 0
return (1 - t) * self.elapsed_time / t
def increment(self, delta):
self.update(self.value + delta)
def update(self, value):
self.value = value
if self.enabled:
sys.stdout.write(" %s \r" % self.render())
sys.stdout.flush()
def done(self):
self.update(self.max_value)
self.stop()
def stop(self):
if self.enabled:
sys.stdout.write("\n")
sys.stdout.flush()
def render(self):
items = [
self.render_percent_complete(),
self.render_value(),
self.render_bar(),
self.render_elapsed_time(),
self.render_eta(),
]
return " ".join(items)
def render_percent_complete(self):
return "%3.0f%%" % self.percent_complete
def render_value(self):
if self.min_value == 0:
return "(%g of %g)" % (self.value, self.max_value)
else:
return "(%g)" % (self.value)
def render_bar(self, size=30):
a = int(round(self.percent_complete / 100.0 * size))
b = size - a
return "[" + "#" * a + "-" * b + "]"
def render_elapsed_time(self):
return pretty_time(self.elapsed_time)
def render_eta(self):
return pretty_time(self.eta)

27
sdf/stl.py Normal file
View File

@@ -0,0 +1,27 @@
import numpy as np
import struct
def write_binary_stl(path, points):
n = len(points) // 3
points = np.array(points, dtype="float32").reshape((-1, 3, 3))
normals = np.cross(points[:, 1] - points[:, 0], points[:, 2] - points[:, 0])
normals /= np.linalg.norm(normals, axis=1).reshape((-1, 1))
dtype = np.dtype(
[
("normal", ("<f", 3)),
("points", ("<f", (3, 3))),
("attr", "<H"),
]
)
a = np.zeros(n, dtype=dtype)
a["points"] = points
a["normal"] = normals
with open(path, "wb") as fp:
fp.write(b"\x00" * 80)
fp.write(struct.pack("<I", n))
fp.write(a.tobytes())

160
sdf/text.py Normal file
View File

@@ -0,0 +1,160 @@
from PIL import Image, ImageFont, ImageDraw
import scipy.ndimage as nd
import numpy as np
from . import d2
# TODO: add support for newlines?
PIXELS = 2**22
def _load_image(thing):
if isinstance(thing, str):
return Image.open(thing)
elif isinstance(thing, (np.ndarray, np.generic)):
return Image.fromarray(thing)
return Image.fromarray(np.array(thing))
def measure_text(name, text, width=None, height=None):
font = ImageFont.truetype(name, 96)
x0, y0, x1, y1 = font.getbbox(text)
aspect = (x1 - x0) / (y1 - y0)
if width is None and height is None:
height = 1
if width is None:
width = height * aspect
if height is None:
height = width / aspect
return (width, height)
def measure_image(thing, width=None, height=None):
im = _load_image(thing)
w, h = im.size
aspect = w / h
if width is None and height is None:
height = 1
if width is None:
width = height * aspect
if height is None:
height = width / aspect
return (width, height)
@d2.sdf2
def text(font_name, text, width=None, height=None, pixels=PIXELS, points=512):
# load font file
font = ImageFont.truetype(font_name, points)
# compute texture bounds
p = 0.2
x0, y0, x1, y1 = font.getbbox(text)
px = int((x1 - x0) * p)
py = int((y1 - y0) * p)
tw = x1 - x0 + 1 + px * 2
th = y1 - y0 + 1 + py * 2
# render text to image
im = Image.new("L", (tw, th))
draw = ImageDraw.Draw(im)
draw.text((px - x0, py - y0), text, font=font, fill=255)
return _sdf(width, height, pixels, px, py, im)
@d2.sdf2
def image(thing, width=None, height=None, pixels=PIXELS):
im = _load_image(thing).convert("L")
return _sdf(width, height, pixels, 0, 0, im)
def _sdf(width, height, pixels, px, py, im):
tw, th = im.size
# downscale image if necessary
factor = (pixels / (tw * th)) ** 0.5
if factor < 1:
tw, th = int(round(tw * factor)), int(round(th * factor))
px, py = int(round(px * factor)), int(round(py * factor))
im = im.resize((tw, th))
# convert to numpy array and apply distance transform
im = im.convert("1")
a = np.array(im)
inside = -nd.distance_transform_edt(a)
outside = nd.distance_transform_edt(~a)
texture = np.zeros(a.shape)
texture[a] = inside[a]
texture[~a] = outside[~a]
# save debug image
# a = np.abs(texture)
# lo, hi = a.min(), a.max()
# a = (a - lo) / (hi - lo) * 255
# im = Image.fromarray(a.astype('uint8'))
# im.save('debug.png')
# compute world bounds
pw = tw - px * 2
ph = th - py * 2
aspect = pw / ph
if width is None and height is None:
height = 1
if width is None:
width = height * aspect
if height is None:
height = width / aspect
x0 = -width / 2
y0 = -height / 2
x1 = width / 2
y1 = height / 2
# scale texture distances
scale = width / tw
texture *= scale
# prepare fallback rectangle
# TODO: reduce size based on mesh resolution instead of dividing by 2
rectangle = d2.rectangle((width / 2, height / 2))
def f(p):
x = p[:, 0]
y = p[:, 1]
u = (x - x0) / (x1 - x0)
v = (y - y0) / (y1 - y0)
v = 1 - v
i = u * pw + px
j = v * ph + py
d = _bilinear_interpolate(texture, i, j)
q = rectangle(p).reshape(-1)
outside = (i < 0) | (i >= tw - 1) | (j < 0) | (j >= th - 1)
d[outside] = q[outside]
return d
return f
def _bilinear_interpolate(a, x, y):
x0 = np.floor(x).astype(int)
x1 = x0 + 1
y0 = np.floor(y).astype(int)
y1 = y0 + 1
x0 = np.clip(x0, 0, a.shape[1] - 1)
x1 = np.clip(x1, 0, a.shape[1] - 1)
y0 = np.clip(y0, 0, a.shape[0] - 1)
y1 = np.clip(y1, 0, a.shape[0] - 1)
pa = a[y0, x0]
pb = a[y1, x0]
pc = a[y0, x1]
pd = a[y1, x1]
wa = (x1 - x) * (y1 - y)
wb = (x1 - x) * (y - y0)
wc = (x - x0) * (y1 - y)
wd = (x - x0) * (y - y0)
return wa * pa + wb * pb + wc * pc + wd * pd

3
sdf/units.py Normal file
View File

@@ -0,0 +1,3 @@
import pint
units = pint.UnitRegistry()

32
sdf/util.py Normal file
View File

@@ -0,0 +1,32 @@
import math
import functools
import inspect
import numpy as np
pi = math.pi
degrees = math.degrees
radians = math.radians
def n_trailing_ascending_positive(d):
"""
Determine how many elements in a given sequence are positive and ascending.
Args:
d (sequence of numbers): the sequence to check
Returns:
int : the amount of trailing ascending positive elements
"""
d = np.array(d).flatten()
# is the next element larger than previous and positive?
order = (d[1:] > d[:-1]) & (d[:-1] > 0)
# TODO: Not happy at all with this if/else mess. Is there no easier way to find the
# index in a numpy array after which the values are only ascending? 🤔
if np.all(order): # all ascending
return d.size
elif np.all(~order): # none ascending
return 0
else: # count from end how many are ascending
return np.argmin(order[::-1]) + 1