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.
@@ -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]
@@ -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
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)