toolwake 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolwake-0.1.0/CHANGELOG.md +28 -0
- toolwake-0.1.0/LICENSE +21 -0
- toolwake-0.1.0/MANIFEST.in +10 -0
- toolwake-0.1.0/PKG-INFO +223 -0
- toolwake-0.1.0/README.md +197 -0
- toolwake-0.1.0/examples/demo.py +82 -0
- toolwake-0.1.0/pyproject.toml +39 -0
- toolwake-0.1.0/setup.cfg +4 -0
- toolwake-0.1.0/src/toolwake/__init__.py +51 -0
- toolwake-0.1.0/src/toolwake/cli.py +75 -0
- toolwake-0.1.0/src/toolwake/deposit.py +150 -0
- toolwake-0.1.0/src/toolwake/geometry.py +179 -0
- toolwake-0.1.0/src/toolwake/profile.py +181 -0
- toolwake-0.1.0/src/toolwake/render.py +173 -0
- toolwake-0.1.0/src/toolwake/serve.py +232 -0
- toolwake-0.1.0/src/toolwake/simulate.py +142 -0
- toolwake-0.1.0/src/toolwake/tool.py +124 -0
- toolwake-0.1.0/src/toolwake/toolpath.py +228 -0
- toolwake-0.1.0/src/toolwake/viewer.py +430 -0
- toolwake-0.1.0/src/toolwake.egg-info/PKG-INFO +223 -0
- toolwake-0.1.0/src/toolwake.egg-info/SOURCES.txt +24 -0
- toolwake-0.1.0/src/toolwake.egg-info/dependency_links.txt +1 -0
- toolwake-0.1.0/src/toolwake.egg-info/entry_points.txt +2 -0
- toolwake-0.1.0/src/toolwake.egg-info/requires.txt +8 -0
- toolwake-0.1.0/src/toolwake.egg-info/top_level.txt +1 -0
- toolwake-0.1.0/tests/test_toolwake.py +661 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. Format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] — 2026-09-14
|
|
8
|
+
|
|
9
|
+
First release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `Toolpath` — load from G-code (`M82`/`M83` aware, so print and travel moves
|
|
13
|
+
are told apart correctly) or from `(N, 6)` pose files; `helix` and
|
|
14
|
+
`conical_helix` fixtures.
|
|
15
|
+
- `Needle` / `ToolProfile` — tools as a coaxial stack of sections, the way CAM
|
|
16
|
+
describes one. `Needle.luer()` models a luer-lock dispensing tip whose hub is
|
|
17
|
+
roughly 50x the cannula diameter.
|
|
18
|
+
- `Deposit` — accumulating wake with a spatial hash for broad phase and exact
|
|
19
|
+
capsule/box distance for narrow phase, plus a trailing-window rule so the
|
|
20
|
+
tool never collides with the bead it is currently extruding.
|
|
21
|
+
- `simulate` — per-row clearance against everything laid earlier, naming which
|
|
22
|
+
section of the tool was closest.
|
|
23
|
+
- `animate` — mp4/gif of the tool and its wake (needs matplotlib).
|
|
24
|
+
- `to_html` / `to_html_str` — a self-contained interactive viewer with no
|
|
25
|
+
external requests; step between collisions, deep-link any frame.
|
|
26
|
+
- `ToolwakeSession` / `asgi_routes` — serve the viewer for embedding in another
|
|
27
|
+
GUI, with a version poll so an iframe follows new results.
|
|
28
|
+
- `toolwake` CLI; exits 2 on a collision so it drops into a pre-flight gate.
|
toolwake-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zane Bates
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
include LICENSE
|
|
2
|
+
include README.md
|
|
3
|
+
include CHANGELOG.md
|
|
4
|
+
recursive-include tests *.py
|
|
5
|
+
recursive-include examples *.py
|
|
6
|
+
# Rendered output is regenerated by examples/demo.py; shipping it would bloat
|
|
7
|
+
# the sdist by several MB for no benefit.
|
|
8
|
+
prune examples/*.mp4
|
|
9
|
+
prune examples/*.html
|
|
10
|
+
global-exclude __pycache__ *.py[cod]
|
toolwake-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toolwake
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simulate a depositing tool travelling a toolpath, accumulate the material it leaves in its wake, and check the tool against it.
|
|
5
|
+
Author-email: Zane Bates <zanetbates1@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/zbates1/toolwake
|
|
8
|
+
Project-URL: Issues, https://github.com/zbates1/toolwake/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/zbates1/toolwake/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: 3d-printing,non-planar,bioprinting,collision,toolpath,robotics
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy>=1.21
|
|
20
|
+
Provides-Extra: render
|
|
21
|
+
Requires-Dist: matplotlib>=3.5; extra == "render"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Requires-Dist: matplotlib>=3.5; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# toolwake
|
|
28
|
+
|
|
29
|
+
Simulate a depositing tool travelling a toolpath, accumulate the material it
|
|
30
|
+
leaves in its wake, and check the tool against that wake.
|
|
31
|
+
|
|
32
|
+
Built for non-planar bioprinting, where a tall part and a wide end effector can
|
|
33
|
+
collide with material the printer laid down minutes earlier — a failure that
|
|
34
|
+
ordinary slicer previews and robot self-collision checks both miss.
|
|
35
|
+
|
|
36
|
+
The video and the numbers come from the same model, so the picture cannot
|
|
37
|
+
disagree with the report.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from toolwake import Toolpath, Needle, simulate, animate
|
|
41
|
+
|
|
42
|
+
path = Toolpath.helix(radius=0.018, pitch=0.0035, turns=7)
|
|
43
|
+
needle = Needle(inner_d=90e-6, outer_d=0.4e-3, length=12.7e-3,
|
|
44
|
+
housing=(0.130, 0.075, 0.072))
|
|
45
|
+
|
|
46
|
+
res = simulate(path, needle, lag=10)
|
|
47
|
+
print(res.report())
|
|
48
|
+
# {'status': 'ok', 'rows': 770, 'min_clearance_mm': 3.2626, ...}
|
|
49
|
+
|
|
50
|
+
animate(res, "wake.mp4") # video
|
|
51
|
+
to_html(res, "wake.html") # interactive, self-contained
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The HTML viewer is one file with no external requests — drag to rotate, scrub
|
|
55
|
+
the timeline, and step between collisions. `wake.html#worst` opens on the
|
|
56
|
+
deepest penetration, and every frame is linkable as `#f=<row>`, so "look at the
|
|
57
|
+
collision around row 506" becomes something you can send.
|
|
58
|
+
|
|
59
|
+
Or from the shell:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
toolwake helix -o wake.mp4
|
|
63
|
+
toolwake part.gcode --housing 0.130 0.075 0.072 --report clearance.json
|
|
64
|
+
toolwake part.gcode --html check.html --no-video
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The CLI exits `2` on a collision, so it drops straight into a pre-flight gate.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install toolwake # core: numpy only
|
|
73
|
+
pip install toolwake[render] # + matplotlib for video
|
|
74
|
+
# the HTML viewer needs neither
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`.mp4` output needs ffmpeg on PATH. `.gif` does not.
|
|
78
|
+
|
|
79
|
+
## Why it is built this way
|
|
80
|
+
|
|
81
|
+
**The tool is a profile, the way CAM describes one.** A dispensing needle is
|
|
82
|
+
not a single cylinder: a luer-lock tip is a thin cannula stepping up to a hub
|
|
83
|
+
roughly 50x its diameter, then a locking collar. Sections stack from the tip
|
|
84
|
+
upward, each a cylinder or a taper, so the model has the same holder / shank /
|
|
85
|
+
flute structure a CAM simulation draws.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
Needle.luer(inner_d=90e-6)
|
|
89
|
+
# ToolProfile(4 sections: cannula, taper, hub, luer collar;
|
|
90
|
+
# len=32.7 mm, r_max=5.50 mm)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Reports name the section that was closest, because "hub" and "cannula" call for
|
|
94
|
+
different fixes.
|
|
95
|
+
|
|
96
|
+
**Primitives, not meshes.** Each section is a capsule and the end effector is an
|
|
97
|
+
oriented box, so the distance functions are exact and there is no mesh
|
|
98
|
+
dependency. Tapers become capsules at their LARGEST radius — deliberately
|
|
99
|
+
conservative, since a false stop costs a reprint and a missed collision costs
|
|
100
|
+
the part. Pass `slices=` to follow a taper more tightly.
|
|
101
|
+
|
|
102
|
+
**Model the housing, not just the tip.** The nozzle crossing an earlier bead is
|
|
103
|
+
the failure people picture and the rarer one. On a tall part the housing is down
|
|
104
|
+
inside the structure while the tip is somewhere harmless. `test_housing_catches_
|
|
105
|
+
what_the_needle_misses` is that case in one test: a thin needle reports clear,
|
|
106
|
+
a 130 mm body reports a collision, on the same path.
|
|
107
|
+
|
|
108
|
+
**Non-planar is orientation, not just Z.** A 6-DOF path carries a rotation
|
|
109
|
+
vector per row, read the way a UR reports pose, so rows move between the robot
|
|
110
|
+
and here unchanged. `conical_helix` leans the tool to the wall's own slope;
|
|
111
|
+
`with_tool_axis` turns any set of surface normals into orientations.
|
|
112
|
+
|
|
113
|
+
Orientation only changes the answer when the closest approach is somewhere
|
|
114
|
+
other than the tip — lean the tool and the tip has not moved, but the hub has
|
|
115
|
+
swung several millimetres. That is the whole reason the body is modelled.
|
|
116
|
+
|
|
117
|
+
**Deposit after measuring.** Each row is checked against the wake *before* its
|
|
118
|
+
own material is added, and `lag` extends that immunity a few rows back —
|
|
119
|
+
otherwise the tool always collides with the bead it is extruding right now.
|
|
120
|
+
|
|
121
|
+
**Only print moves leave a wake.** Travel moves deposit nothing. Treating them
|
|
122
|
+
as material fills the part with phantom obstacles and every later move reports a
|
|
123
|
+
false collision. `Toolpath.from_gcode` handles both `M82` and `M83`, because
|
|
124
|
+
whether a row extrudes depends on it and slicers disagree — Cura emits `M82`,
|
|
125
|
+
PrusaSlicer `M83`.
|
|
126
|
+
|
|
127
|
+
**Two-phase queries.** A uniform spatial hash narrows the candidate beads, then
|
|
128
|
+
exact capsule/box distance measures against those only. A plain occupancy grid
|
|
129
|
+
would give a binary answer quantised to the voxel size; this reports a real
|
|
130
|
+
distance. The grid is sized to the *query* radius rather than the bead — getting
|
|
131
|
+
that wrong made an early version 16x slower for identical results.
|
|
132
|
+
|
|
133
|
+
## Embedding in another GUI
|
|
134
|
+
|
|
135
|
+
`ToolwakeSession` holds the latest result and hands it out as HTML, so a host
|
|
136
|
+
application can iframe it and let it follow new checks on its own.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from toolwake import ToolwakeSession, Needle
|
|
140
|
+
|
|
141
|
+
session = ToolwakeSession(needle=Needle.luer(inner_d=90e-6), lag=10)
|
|
142
|
+
url = session.serve(port=7100) # -> http://127.0.0.1:7100
|
|
143
|
+
...
|
|
144
|
+
session.run(toolpath) # the iframe reloads itself
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The page polls a version endpoint, so re-checking a toolpath updates the view
|
|
148
|
+
without the host telling it anything.
|
|
149
|
+
|
|
150
|
+
If the host already runs a web framework, skip the extra port and mount the
|
|
151
|
+
routes — same bytes, no second socket, no cross-origin handling:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from fastapi import Response
|
|
155
|
+
from toolwake.serve import asgi_routes
|
|
156
|
+
|
|
157
|
+
for path, fn in asgi_routes(session):
|
|
158
|
+
def make(fn=fn):
|
|
159
|
+
def endpoint():
|
|
160
|
+
body, ctype = fn()
|
|
161
|
+
return Response(body, media_type=ctype)
|
|
162
|
+
return endpoint
|
|
163
|
+
app.get("/api/toolwake" + path)(make())
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Then point an iframe at `/api/toolwake/view`. Routes are `/view`, `/version`
|
|
167
|
+
and `/report.json`.
|
|
168
|
+
|
|
169
|
+
> On some Windows machines a local security product stalls large responses over
|
|
170
|
+
> loopback — measured here as 1 KB instant and 83 KB failing under a bare
|
|
171
|
+
> stdlib handler with no toolwake code in the path. If `serve()` hangs, mount
|
|
172
|
+
> the routes instead.
|
|
173
|
+
|
|
174
|
+
## API
|
|
175
|
+
|
|
176
|
+
| | |
|
|
177
|
+
|---|---|
|
|
178
|
+
| `Toolpath.from_gcode(path)` | parse G0/G1, infer print vs travel from E |
|
|
179
|
+
| `Toolpath.from_arrays(xyz, kinds, rotvec)` | bring your own, 3-DOF or 6-DOF |
|
|
180
|
+
| `Toolpath.from_poses(path)` | (N, 6) `x y z rx ry rz` pose file |
|
|
181
|
+
| `Toolpath.with_tool_axis(axes)` | set orientation from per-row tool directions |
|
|
182
|
+
| `Toolpath.helix(...)` | vertical-tool helix fixture |
|
|
183
|
+
| `Toolpath.conical_helix(...)` | flaring wall; tool leans to stay normal |
|
|
184
|
+
| `Needle(inner_d, outer_d, length, housing)` | plain cannula |
|
|
185
|
+
| `Needle.luer(inner_d, ...)` | full luer tip: cannula, taper, hub, collar |
|
|
186
|
+
| `ToolProfile([Section(...), ...])` | build any coaxial tool |
|
|
187
|
+
| `simulate(path, needle, lag, threshold)` | → `Result` |
|
|
188
|
+
| `Result.report()` | JSON-ready clearance summary |
|
|
189
|
+
| `animate(result, "out.mp4")` | video of the tool and its wake |
|
|
190
|
+
| `to_html(result, "out.html")` | interactive viewer, jumps between collisions |
|
|
191
|
+
| `ToolwakeSession(...)` | re-checkable session, serves HTML for embedding |
|
|
192
|
+
| `asgi_routes(session)` | route table to mount into an existing app |
|
|
193
|
+
| `Deposit`, `Capsule`, `Box` | the pieces, usable directly |
|
|
194
|
+
|
|
195
|
+
Distances are **metres** throughout; the report converts to mm.
|
|
196
|
+
|
|
197
|
+
`Result.status` is `ok` / `warn` / `collision`, matching the usual clearance
|
|
198
|
+
banding: penetration is a collision, anything inside `threshold` is a warning.
|
|
199
|
+
|
|
200
|
+
## Limits
|
|
201
|
+
|
|
202
|
+
- **Per-row sampling, not swept volumes.** A thin tool moving fast past a thin
|
|
203
|
+
wall can pass between sampled rows. Densify the path or add continuous
|
|
204
|
+
collision detection if that matters for your geometry.
|
|
205
|
+
- **Beads are straight segments** between consecutive rows, with a constant
|
|
206
|
+
radius. Die swell, sag and variable extrusion width are not modelled.
|
|
207
|
+
- **No process physics** — nothing here knows about cure, flow or adhesion.
|
|
208
|
+
- **Preset hub dimensions are nominal.** `luer_taper_tip` defaults approximate a
|
|
209
|
+
34G half-inch tip; hubs vary between manufacturers. Measure the tip you run
|
|
210
|
+
and pass the real numbers.
|
|
211
|
+
- `search` bounds how far a query looks. Beads beyond it are not measured and
|
|
212
|
+
the clearance returns `inf`. Raise it for a large housing.
|
|
213
|
+
|
|
214
|
+
## Development
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
pip install -e .[dev]
|
|
218
|
+
pytest
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Licence
|
|
222
|
+
|
|
223
|
+
MIT
|
toolwake-0.1.0/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# toolwake
|
|
2
|
+
|
|
3
|
+
Simulate a depositing tool travelling a toolpath, accumulate the material it
|
|
4
|
+
leaves in its wake, and check the tool against that wake.
|
|
5
|
+
|
|
6
|
+
Built for non-planar bioprinting, where a tall part and a wide end effector can
|
|
7
|
+
collide with material the printer laid down minutes earlier — a failure that
|
|
8
|
+
ordinary slicer previews and robot self-collision checks both miss.
|
|
9
|
+
|
|
10
|
+
The video and the numbers come from the same model, so the picture cannot
|
|
11
|
+
disagree with the report.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from toolwake import Toolpath, Needle, simulate, animate
|
|
15
|
+
|
|
16
|
+
path = Toolpath.helix(radius=0.018, pitch=0.0035, turns=7)
|
|
17
|
+
needle = Needle(inner_d=90e-6, outer_d=0.4e-3, length=12.7e-3,
|
|
18
|
+
housing=(0.130, 0.075, 0.072))
|
|
19
|
+
|
|
20
|
+
res = simulate(path, needle, lag=10)
|
|
21
|
+
print(res.report())
|
|
22
|
+
# {'status': 'ok', 'rows': 770, 'min_clearance_mm': 3.2626, ...}
|
|
23
|
+
|
|
24
|
+
animate(res, "wake.mp4") # video
|
|
25
|
+
to_html(res, "wake.html") # interactive, self-contained
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The HTML viewer is one file with no external requests — drag to rotate, scrub
|
|
29
|
+
the timeline, and step between collisions. `wake.html#worst` opens on the
|
|
30
|
+
deepest penetration, and every frame is linkable as `#f=<row>`, so "look at the
|
|
31
|
+
collision around row 506" becomes something you can send.
|
|
32
|
+
|
|
33
|
+
Or from the shell:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
toolwake helix -o wake.mp4
|
|
37
|
+
toolwake part.gcode --housing 0.130 0.075 0.072 --report clearance.json
|
|
38
|
+
toolwake part.gcode --html check.html --no-video
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The CLI exits `2` on a collision, so it drops straight into a pre-flight gate.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install toolwake # core: numpy only
|
|
47
|
+
pip install toolwake[render] # + matplotlib for video
|
|
48
|
+
# the HTML viewer needs neither
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`.mp4` output needs ffmpeg on PATH. `.gif` does not.
|
|
52
|
+
|
|
53
|
+
## Why it is built this way
|
|
54
|
+
|
|
55
|
+
**The tool is a profile, the way CAM describes one.** A dispensing needle is
|
|
56
|
+
not a single cylinder: a luer-lock tip is a thin cannula stepping up to a hub
|
|
57
|
+
roughly 50x its diameter, then a locking collar. Sections stack from the tip
|
|
58
|
+
upward, each a cylinder or a taper, so the model has the same holder / shank /
|
|
59
|
+
flute structure a CAM simulation draws.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
Needle.luer(inner_d=90e-6)
|
|
63
|
+
# ToolProfile(4 sections: cannula, taper, hub, luer collar;
|
|
64
|
+
# len=32.7 mm, r_max=5.50 mm)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Reports name the section that was closest, because "hub" and "cannula" call for
|
|
68
|
+
different fixes.
|
|
69
|
+
|
|
70
|
+
**Primitives, not meshes.** Each section is a capsule and the end effector is an
|
|
71
|
+
oriented box, so the distance functions are exact and there is no mesh
|
|
72
|
+
dependency. Tapers become capsules at their LARGEST radius — deliberately
|
|
73
|
+
conservative, since a false stop costs a reprint and a missed collision costs
|
|
74
|
+
the part. Pass `slices=` to follow a taper more tightly.
|
|
75
|
+
|
|
76
|
+
**Model the housing, not just the tip.** The nozzle crossing an earlier bead is
|
|
77
|
+
the failure people picture and the rarer one. On a tall part the housing is down
|
|
78
|
+
inside the structure while the tip is somewhere harmless. `test_housing_catches_
|
|
79
|
+
what_the_needle_misses` is that case in one test: a thin needle reports clear,
|
|
80
|
+
a 130 mm body reports a collision, on the same path.
|
|
81
|
+
|
|
82
|
+
**Non-planar is orientation, not just Z.** A 6-DOF path carries a rotation
|
|
83
|
+
vector per row, read the way a UR reports pose, so rows move between the robot
|
|
84
|
+
and here unchanged. `conical_helix` leans the tool to the wall's own slope;
|
|
85
|
+
`with_tool_axis` turns any set of surface normals into orientations.
|
|
86
|
+
|
|
87
|
+
Orientation only changes the answer when the closest approach is somewhere
|
|
88
|
+
other than the tip — lean the tool and the tip has not moved, but the hub has
|
|
89
|
+
swung several millimetres. That is the whole reason the body is modelled.
|
|
90
|
+
|
|
91
|
+
**Deposit after measuring.** Each row is checked against the wake *before* its
|
|
92
|
+
own material is added, and `lag` extends that immunity a few rows back —
|
|
93
|
+
otherwise the tool always collides with the bead it is extruding right now.
|
|
94
|
+
|
|
95
|
+
**Only print moves leave a wake.** Travel moves deposit nothing. Treating them
|
|
96
|
+
as material fills the part with phantom obstacles and every later move reports a
|
|
97
|
+
false collision. `Toolpath.from_gcode` handles both `M82` and `M83`, because
|
|
98
|
+
whether a row extrudes depends on it and slicers disagree — Cura emits `M82`,
|
|
99
|
+
PrusaSlicer `M83`.
|
|
100
|
+
|
|
101
|
+
**Two-phase queries.** A uniform spatial hash narrows the candidate beads, then
|
|
102
|
+
exact capsule/box distance measures against those only. A plain occupancy grid
|
|
103
|
+
would give a binary answer quantised to the voxel size; this reports a real
|
|
104
|
+
distance. The grid is sized to the *query* radius rather than the bead — getting
|
|
105
|
+
that wrong made an early version 16x slower for identical results.
|
|
106
|
+
|
|
107
|
+
## Embedding in another GUI
|
|
108
|
+
|
|
109
|
+
`ToolwakeSession` holds the latest result and hands it out as HTML, so a host
|
|
110
|
+
application can iframe it and let it follow new checks on its own.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from toolwake import ToolwakeSession, Needle
|
|
114
|
+
|
|
115
|
+
session = ToolwakeSession(needle=Needle.luer(inner_d=90e-6), lag=10)
|
|
116
|
+
url = session.serve(port=7100) # -> http://127.0.0.1:7100
|
|
117
|
+
...
|
|
118
|
+
session.run(toolpath) # the iframe reloads itself
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The page polls a version endpoint, so re-checking a toolpath updates the view
|
|
122
|
+
without the host telling it anything.
|
|
123
|
+
|
|
124
|
+
If the host already runs a web framework, skip the extra port and mount the
|
|
125
|
+
routes — same bytes, no second socket, no cross-origin handling:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from fastapi import Response
|
|
129
|
+
from toolwake.serve import asgi_routes
|
|
130
|
+
|
|
131
|
+
for path, fn in asgi_routes(session):
|
|
132
|
+
def make(fn=fn):
|
|
133
|
+
def endpoint():
|
|
134
|
+
body, ctype = fn()
|
|
135
|
+
return Response(body, media_type=ctype)
|
|
136
|
+
return endpoint
|
|
137
|
+
app.get("/api/toolwake" + path)(make())
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Then point an iframe at `/api/toolwake/view`. Routes are `/view`, `/version`
|
|
141
|
+
and `/report.json`.
|
|
142
|
+
|
|
143
|
+
> On some Windows machines a local security product stalls large responses over
|
|
144
|
+
> loopback — measured here as 1 KB instant and 83 KB failing under a bare
|
|
145
|
+
> stdlib handler with no toolwake code in the path. If `serve()` hangs, mount
|
|
146
|
+
> the routes instead.
|
|
147
|
+
|
|
148
|
+
## API
|
|
149
|
+
|
|
150
|
+
| | |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `Toolpath.from_gcode(path)` | parse G0/G1, infer print vs travel from E |
|
|
153
|
+
| `Toolpath.from_arrays(xyz, kinds, rotvec)` | bring your own, 3-DOF or 6-DOF |
|
|
154
|
+
| `Toolpath.from_poses(path)` | (N, 6) `x y z rx ry rz` pose file |
|
|
155
|
+
| `Toolpath.with_tool_axis(axes)` | set orientation from per-row tool directions |
|
|
156
|
+
| `Toolpath.helix(...)` | vertical-tool helix fixture |
|
|
157
|
+
| `Toolpath.conical_helix(...)` | flaring wall; tool leans to stay normal |
|
|
158
|
+
| `Needle(inner_d, outer_d, length, housing)` | plain cannula |
|
|
159
|
+
| `Needle.luer(inner_d, ...)` | full luer tip: cannula, taper, hub, collar |
|
|
160
|
+
| `ToolProfile([Section(...), ...])` | build any coaxial tool |
|
|
161
|
+
| `simulate(path, needle, lag, threshold)` | → `Result` |
|
|
162
|
+
| `Result.report()` | JSON-ready clearance summary |
|
|
163
|
+
| `animate(result, "out.mp4")` | video of the tool and its wake |
|
|
164
|
+
| `to_html(result, "out.html")` | interactive viewer, jumps between collisions |
|
|
165
|
+
| `ToolwakeSession(...)` | re-checkable session, serves HTML for embedding |
|
|
166
|
+
| `asgi_routes(session)` | route table to mount into an existing app |
|
|
167
|
+
| `Deposit`, `Capsule`, `Box` | the pieces, usable directly |
|
|
168
|
+
|
|
169
|
+
Distances are **metres** throughout; the report converts to mm.
|
|
170
|
+
|
|
171
|
+
`Result.status` is `ok` / `warn` / `collision`, matching the usual clearance
|
|
172
|
+
banding: penetration is a collision, anything inside `threshold` is a warning.
|
|
173
|
+
|
|
174
|
+
## Limits
|
|
175
|
+
|
|
176
|
+
- **Per-row sampling, not swept volumes.** A thin tool moving fast past a thin
|
|
177
|
+
wall can pass between sampled rows. Densify the path or add continuous
|
|
178
|
+
collision detection if that matters for your geometry.
|
|
179
|
+
- **Beads are straight segments** between consecutive rows, with a constant
|
|
180
|
+
radius. Die swell, sag and variable extrusion width are not modelled.
|
|
181
|
+
- **No process physics** — nothing here knows about cure, flow or adhesion.
|
|
182
|
+
- **Preset hub dimensions are nominal.** `luer_taper_tip` defaults approximate a
|
|
183
|
+
34G half-inch tip; hubs vary between manufacturers. Measure the tip you run
|
|
184
|
+
and pass the real numbers.
|
|
185
|
+
- `search` bounds how far a query looks. Beads beyond it are not measured and
|
|
186
|
+
the clearance returns `inf`. Raise it for a large housing.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
pip install -e .[dev]
|
|
192
|
+
pytest
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Licence
|
|
196
|
+
|
|
197
|
+
MIT
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Two runs: one that clears, one that does not.
|
|
2
|
+
|
|
3
|
+
The second is the interesting one — it is the case a needle-tip-only check
|
|
4
|
+
passes and a housing-aware check catches.
|
|
5
|
+
"""
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from toolwake import Needle, Toolpath, animate, simulate
|
|
9
|
+
from toolwake.toolpath import PRINT, TRAVEL
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def clean():
|
|
13
|
+
"""A helix with enough pitch that the tool never revisits earlier material."""
|
|
14
|
+
path = Toolpath.helix(radius=0.018, pitch=0.0035, turns=7, per_turn=110)
|
|
15
|
+
needle = Needle(inner_d=90e-6, outer_d=0.4e-3, length=12.7e-3)
|
|
16
|
+
return path, needle
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def housing_collision():
|
|
20
|
+
"""Print a tall wall, then travel back down beside it.
|
|
21
|
+
|
|
22
|
+
25 mm away: a 0.4 mm needle is comfortably clear, a 130 mm housing is not.
|
|
23
|
+
"""
|
|
24
|
+
n = 150
|
|
25
|
+
wall = np.column_stack([np.zeros(n), np.zeros(n), np.linspace(0, 0.06, n)])
|
|
26
|
+
m = 60
|
|
27
|
+
aside = np.column_stack([np.full(m, 0.025), np.zeros(m),
|
|
28
|
+
np.linspace(0.06, 0.0, m)])
|
|
29
|
+
path = Toolpath(np.vstack([wall, aside]),
|
|
30
|
+
kinds=[PRINT] * n + [TRAVEL] * m)
|
|
31
|
+
needle = Needle(inner_d=90e-6, outer_d=0.4e-3, length=12.7e-3,
|
|
32
|
+
housing=(0.130, 0.075, 0.072))
|
|
33
|
+
return path, needle
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
for name, build in (("helix_wake", clean),
|
|
38
|
+
("housing_collision", housing_collision)):
|
|
39
|
+
path, needle = build()
|
|
40
|
+
res = simulate(path, needle, lag=10)
|
|
41
|
+
print(f"{name}: {res!r}")
|
|
42
|
+
print(f" {res.report()}")
|
|
43
|
+
animate(res, f"examples/{name}.mp4", fps=30, max_frames=300)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def nonplanar_two_walls():
|
|
47
|
+
"""Two concentric walls; the second is printed with the tool tilted outward.
|
|
48
|
+
|
|
49
|
+
This is the realistic non-planar failure. A single vase wall rarely traps
|
|
50
|
+
its own tool — the body leans over empty space. Put a second feature beside
|
|
51
|
+
it and the leaning body sweeps straight through the one printed first.
|
|
52
|
+
|
|
53
|
+
Outer wall at r = 20 mm goes down first. The inner wall at r = 10 mm is
|
|
54
|
+
then printed with a 30 deg outward lean, which throws the hub about
|
|
55
|
+
20*sin(30) = 10 mm outward — exactly onto the outer wall.
|
|
56
|
+
"""
|
|
57
|
+
import numpy as np
|
|
58
|
+
from toolwake import Needle, Toolpath
|
|
59
|
+
from toolwake.geometry import rotvec_from_axis
|
|
60
|
+
from toolwake.toolpath import PRINT, TRAVEL
|
|
61
|
+
|
|
62
|
+
def ring(radius, z0, z1, turns, per_turn, lean):
|
|
63
|
+
n = int(turns * per_turn)
|
|
64
|
+
t = np.linspace(0.0, turns * 2 * np.pi, n)
|
|
65
|
+
xyz = np.column_stack([radius * np.cos(t), radius * np.sin(t),
|
|
66
|
+
np.linspace(z0, z1, n)])
|
|
67
|
+
axes = np.column_stack([np.sin(lean) * np.cos(t),
|
|
68
|
+
np.sin(lean) * np.sin(t),
|
|
69
|
+
np.full(n, np.cos(lean))])
|
|
70
|
+
return xyz, np.array([rotvec_from_axis(u) for u in axes])
|
|
71
|
+
|
|
72
|
+
outer_xyz, outer_rv = ring(0.020, 0.0, 0.030, 5, 80, 0.0)
|
|
73
|
+
inner_xyz, inner_rv = ring(0.010, 0.0, 0.030, 5, 80, np.radians(30.0))
|
|
74
|
+
|
|
75
|
+
# One travel hop between the two walls, which deposits nothing.
|
|
76
|
+
hop_xyz = np.array([inner_xyz[0]])
|
|
77
|
+
hop_rv = np.array([inner_rv[0]])
|
|
78
|
+
|
|
79
|
+
path = Toolpath(np.vstack([outer_xyz, hop_xyz, inner_xyz]),
|
|
80
|
+
kinds=[PRINT] * len(outer_xyz) + [TRAVEL] + [PRINT] * len(inner_xyz),
|
|
81
|
+
rotvec=np.vstack([outer_rv, hop_rv, inner_rv]))
|
|
82
|
+
return path, Needle.luer(inner_d=90e-6)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "toolwake"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Simulate a depositing tool travelling a toolpath, accumulate the material it leaves in its wake, and check the tool against it."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Zane Bates", email = "zanetbates1@gmail.com" }]
|
|
13
|
+
keywords = ["3d-printing", "non-planar", "bioprinting", "collision", "toolpath", "robotics"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Visualization",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["numpy>=1.21"]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
render = ["matplotlib>=3.5"]
|
|
25
|
+
dev = ["pytest>=7", "matplotlib>=3.5"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
toolwake = "toolwake.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/zbates1/toolwake"
|
|
32
|
+
Issues = "https://github.com/zbates1/toolwake/issues"
|
|
33
|
+
Changelog = "https://github.com/zbates1/toolwake/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
toolwake-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""toolwake — simulate a depositing tool, accumulate its wake, check against it.
|
|
2
|
+
|
|
3
|
+
from toolwake import Toolpath, Needle, simulate, animate
|
|
4
|
+
|
|
5
|
+
path = Toolpath.helix(radius=0.02, pitch=0.004, turns=6)
|
|
6
|
+
res = simulate(path, Needle(inner_d=90e-6, housing=(0.130, 0.075, 0.072)))
|
|
7
|
+
print(res.report())
|
|
8
|
+
animate(res, "wake.mp4")
|
|
9
|
+
|
|
10
|
+
The wake the video draws and the wake the clearance numbers are measured
|
|
11
|
+
against are the same object, so the picture cannot disagree with the report.
|
|
12
|
+
"""
|
|
13
|
+
from .deposit import Deposit
|
|
14
|
+
from .geometry import (Box, Capsule, rotation_from_rotvec,
|
|
15
|
+
rotvec_from_axis)
|
|
16
|
+
from .simulate import Result, simulate
|
|
17
|
+
from .viewer import to_html_str
|
|
18
|
+
from .profile import Section, ToolProfile, blunt_cannula, luer_taper_tip
|
|
19
|
+
from .tool import Needle, ToolPose
|
|
20
|
+
from .toolpath import PRINT, TRAVEL, Toolpath
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Toolpath", "PRINT", "TRAVEL",
|
|
26
|
+
"Needle", "ToolPose",
|
|
27
|
+
"Section", "ToolProfile", "blunt_cannula", "luer_taper_tip",
|
|
28
|
+
"Deposit",
|
|
29
|
+
"Capsule", "Box", "rotation_from_rotvec", "rotvec_from_axis",
|
|
30
|
+
"simulate", "Result",
|
|
31
|
+
"animate", "to_html", "to_html_str", "ToolwakeSession",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def animate(*args, **kwargs):
|
|
37
|
+
"""Render a Result to video. Imported lazily so matplotlib stays optional."""
|
|
38
|
+
from .render import animate as _animate
|
|
39
|
+
return _animate(*args, **kwargs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ToolwakeSession(*args, **kwargs):
|
|
43
|
+
"""A re-checkable simulation served as HTML, for embedding in another GUI."""
|
|
44
|
+
from .serve import ToolwakeSession as _S
|
|
45
|
+
return _S(*args, **kwargs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def to_html(*args, **kwargs):
|
|
49
|
+
"""Write a standalone interactive viewer. No dependencies beyond numpy."""
|
|
50
|
+
from .viewer import to_html as _to_html
|
|
51
|
+
return _to_html(*args, **kwargs)
|