toolwake 0.2.0__tar.gz → 0.2.2__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.
Files changed (26) hide show
  1. {toolwake-0.2.0 → toolwake-0.2.2}/CHANGELOG.md +56 -6
  2. {toolwake-0.2.0/src/toolwake.egg-info → toolwake-0.2.2}/PKG-INFO +1 -1
  3. {toolwake-0.2.0 → toolwake-0.2.2}/pyproject.toml +1 -1
  4. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/__init__.py +1 -1
  5. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/simulate.py +44 -3
  6. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/viewer.py +21 -3
  7. {toolwake-0.2.0 → toolwake-0.2.2/src/toolwake.egg-info}/PKG-INFO +1 -1
  8. {toolwake-0.2.0 → toolwake-0.2.2}/tests/test_toolwake.py +108 -0
  9. {toolwake-0.2.0 → toolwake-0.2.2}/LICENSE +0 -0
  10. {toolwake-0.2.0 → toolwake-0.2.2}/MANIFEST.in +0 -0
  11. {toolwake-0.2.0 → toolwake-0.2.2}/README.md +0 -0
  12. {toolwake-0.2.0 → toolwake-0.2.2}/examples/demo.py +0 -0
  13. {toolwake-0.2.0 → toolwake-0.2.2}/setup.cfg +0 -0
  14. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/cli.py +0 -0
  15. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/deposit.py +0 -0
  16. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/geometry.py +0 -0
  17. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/profile.py +0 -0
  18. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/render.py +0 -0
  19. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/serve.py +0 -0
  20. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/tool.py +0 -0
  21. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake/toolpath.py +0 -0
  22. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake.egg-info/SOURCES.txt +0 -0
  23. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake.egg-info/dependency_links.txt +0 -0
  24. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake.egg-info/entry_points.txt +0 -0
  25. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake.egg-info/requires.txt +0 -0
  26. {toolwake-0.2.0 → toolwake-0.2.2}/src/toolwake.egg-info/top_level.txt +0 -0
@@ -4,10 +4,63 @@ All notable changes to this project are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.2.2] — 2026-09-18
8
+
9
+ Bounds the size of the viewer page. No reported number changes.
10
+
11
+ ### Fixed
12
+ - **The viewer inlined every deposited bead, so the page grew without limit.**
13
+ Frames were capped at 400; beads were not capped at all. A 2 674-row path
14
+ already produces a 1.2 MB page, and a 100 000-row one would produce tens of
15
+ megabytes — which is a problem the moment anyone raises their row budget and
16
+ tries to embed the result in an iframe.
17
+
18
+ `to_html_str` now takes `max_beads` (default 20 000) and decimates the drawn
19
+ wake on the same rule as frames: sample evenly, and never drop a bead
20
+ something collided with. Purely cosmetic — the report is computed from the
21
+ full deposit, and nothing on the page claims a bead count.
22
+
23
+ ## [0.2.1] — 2026-09-18
24
+
25
+ Finishes 0.2.0. That release fixed the tool's geometry; this one fixes the
26
+ material's. Reported clearances change again.
27
+
28
+ ### Fixed
29
+ - **The bead was round at the bore diameter, so it was taller than its own
30
+ layer.** A bead laid at layer height h is squashed to h and spreads
31
+ sideways; kept round it pokes up through where the nozzle will sit on the
32
+ next pass. On a 0.036 mm-layer slab with a 0.09 mm bore that was 498 rows
33
+ reported, 391 of them blamed on material exactly one layer below.
34
+ - **The bead was centred on the toolpath, i.e. in the nozzle TIP's own plane.**
35
+ Material leaving the bore fills the gap between the previous layer's top and
36
+ the nozzle face, occupying [z - h, z] — its centre is h/2 down. Straddling
37
+ the face's plane, any same-layer neighbour passing under the wall reported a
38
+ collision of exactly one bead radius: tangent contacts dressed up as
39
+ penetrations. 41 rows on the same slab, every one at exactly -bead_radius,
40
+ which is what gave it away.
41
+
42
+ Rows reported as penetrating, across both releases:
43
+
44
+ | toolpath | 0.1.1 | 0.2.0 | 0.2.1 |
45
+ |---|---|---|---|
46
+ | LightPipeRobotTest | 7 | 0 | **0** |
47
+ | test_infill | 2 193 | 0 | **0** |
48
+ | small_slab | 1 224 | 498 | **0** |
49
+
50
+ Every G-code file in the reference set now reports clear.
51
+
52
+ ### Added
53
+ - `simulate(bead_radius=..., bead_drop=...)` — the laid bead's size, and where
54
+ it sits relative to the nozzle face. `bead_drop` is applied along the TOOL
55
+ axis, not -Z, so it stays correct on a non-planar move. Explicit arguments
56
+ rather than inferred from the path: only the caller knows whether a Z step
57
+ is a layer or a ramp.
58
+
7
59
  ## [0.2.0] — 2026-09-18
8
60
 
9
- Reported clearances change. The tool was modelled with two pieces of geometry
10
- it does not have, and both made it collide with the print it was making.
61
+ Reported clearances change. The TOOL was modelled with two pieces of geometry
62
+ it does not have, and both made it collide with the print it was making. The
63
+ BEAD had two of its own; those are 0.2.1.
11
64
 
12
65
  ### Fixed
13
66
  - **Tool sections were capsules, so the tool reached below its own tip.** A
@@ -33,10 +86,7 @@ On the three reference toolpaths, rows reported as penetrating:
33
86
  | test_infill | 2 193 | 16 | **0** |
34
87
  | small_slab | 1 224 | 1 218 | 498 |
35
88
 
36
- `small_slab` is not a geometry artefact: its layer height is 0.036 mm while a
37
- bead is modelled as a cylinder of the full 0.09 mm bore. A bead 2.5x thicker
38
- than the layer it sits in will intersect the nozzle no matter how the tool is
39
- shaped. Squashing the bead to the layer height is a separate change.
89
+ `small_slab`'s remaining 498 are the bead model, not the tool; fixed in 0.2.1.
40
90
 
41
91
  ### Added
42
92
  - `Cylinder` — flat-ended, optionally hollow, exact signed distance.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toolwake
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Simulate a depositing tool travelling a toolpath, accumulate the material it leaves in its wake, and check the tool against it.
5
5
  Author-email: Zane Bates <zanetbates1@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "toolwake"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Simulate a depositing tool travelling a toolpath, accumulate the material it leaves in its wake, and check the tool against it."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -19,7 +19,7 @@ from .profile import Section, ToolProfile, blunt_cannula, luer_taper_tip
19
19
  from .tool import Needle, ToolPose
20
20
  from .toolpath import PRINT, TRAVEL, Toolpath
21
21
 
22
- __version__ = "0.2.0"
22
+ __version__ = "0.2.2"
23
23
 
24
24
  __all__ = [
25
25
  "Toolpath", "PRINT", "TRAVEL",
@@ -89,6 +89,7 @@ class Result:
89
89
 
90
90
  def simulate(path: Toolpath, needle: Needle | None = None, *, lag: int = 8,
91
91
  threshold: float = 5e-4, search: float = 0.02,
92
+ bead_radius: float | None = None, bead_drop: float = 0.0,
92
93
  progress=None) -> Result:
93
94
  """Run the wake simulation over `path`.
94
95
 
@@ -100,16 +101,49 @@ def simulate(path: Toolpath, needle: Needle | None = None, *, lag: int = 8,
100
101
  threshold: clearance below which a row counts as "close", metres.
101
102
  search: broad-phase radius, metres. Beads beyond this are not measured;
102
103
  raise it if the housing is large.
104
+ bead_radius: radius of the laid bead, metres. Defaults to the needle's
105
+ bore radius, which assumes the bead keeps the round cross-section
106
+ it had inside the needle.
107
+
108
+ It does not. A bead laid at layer height h is squashed to h and
109
+ spreads sideways, and modelling it round makes it TALLER than the
110
+ layer it lives in — so it pokes up through where the nozzle will
111
+ sit on the next pass and reads as a collision on rows that printed
112
+ perfectly. On a 0.036 mm-layer slab with a 0.09 mm bore that was
113
+ 498 rows reported, 391 of them blamed on material one layer below.
114
+ Passing `min(bore_radius, h / 2)` leaves 41, and those are genuine
115
+ same-layer retraces.
116
+
117
+ A bead cannot be taller than the layer it was laid in. Left as an
118
+ explicit argument rather than inferred, because only the caller
119
+ knows the process.
120
+ bead_drop: how far BELOW the nozzle face the bead sits, metres,
121
+ measured along the tool axis. Zero puts the bead's centre on the
122
+ toolpath, which is where the nozzle tip is — so every bead ends up
123
+ half-embedded in the plane the nozzle face travels in, and any
124
+ same-layer neighbour passing under the wall reports a collision of
125
+ exactly one bead radius. Those are tangent contacts dressed up as
126
+ penetrations: on the reference slab, 41 rows, every one of them at
127
+ exactly -bead_radius.
128
+
129
+ Material leaving the bore fills the gap between the previous
130
+ layer's top and the nozzle face, so it occupies [z - h, z] and its
131
+ centre is h/2 down. Passing `h / 2` alongside `bead_radius=h / 2`
132
+ makes the bead exactly fill its layer, tangent to the face that
133
+ laid it. That takes the same slab to zero.
103
134
  progress: optional callable(i, n) for a progress bar.
104
135
 
105
136
  Returns:
106
137
  A `Result` carrying per-row clearance and the accumulated wake.
107
138
  """
108
139
  needle = needle or Needle()
140
+ r_bead = needle.bead_radius if bead_radius is None else float(bead_radius)
141
+ if r_bead < 0:
142
+ raise ValueError("bead_radius must be >= 0")
109
143
  # The grid only exists to shrink the candidate set, so scale it to the
110
144
  # query, not the bead — see Deposit.__init__.
111
- dep = Deposit(bead_radius=needle.bead_radius,
112
- cell=max(search / 4.0, 8.0 * needle.bead_radius))
145
+ dep = Deposit(bead_radius=r_bead,
146
+ cell=max(search / 4.0, 8.0 * r_bead))
113
147
  n = len(path)
114
148
  clearance = np.full(n, np.inf)
115
149
  culprit = np.full(n, -1, dtype=int)
@@ -133,7 +167,14 @@ def simulate(path: Toolpath, needle: Needle | None = None, *, lag: int = 8,
133
167
 
134
168
  # Deposit AFTER measuring — see the module docstring.
135
169
  if i > 0 and path.kinds[i] == PRINT:
136
- dep.add(path.xyz[i - 1], path.xyz[i], frame=i)
170
+ a, b = path.xyz[i - 1], path.xyz[i]
171
+ if bead_drop:
172
+ # Along the TOOL axis, not -Z: on a non-planar move the
173
+ # material still lands under the nozzle face, wherever that
174
+ # face is pointing.
175
+ shift = pose.axis * float(bead_drop)
176
+ a, b = a - shift, b - shift
177
+ dep.add(a, b, frame=i)
137
178
 
138
179
  if progress is not None and (i % 200 == 0 or i == n - 1):
139
180
  progress(i + 1, n)
@@ -310,7 +310,7 @@ def to_html(result, out="wake.html", **kw):
310
310
  return out
311
311
 
312
312
 
313
- def to_html_str(result, *, max_frames=400, fps=20, title=None,
313
+ def to_html_str(result, *, max_frames=400, max_beads=20000, fps=20, title=None,
314
314
  show_housing=True, live_poll=None):
315
315
  """Write a standalone interactive viewer for `result`.
316
316
 
@@ -318,6 +318,10 @@ def to_html_str(result, *, max_frames=400, fps=20, title=None,
318
318
  "jump to worst" goes straight to the deepest penetration — which is usually
319
319
  the only frame anyone actually wants to see.
320
320
 
321
+ `max_frames` and `max_beads` bound the size of the page. Both sample evenly
322
+ and both keep every collision regardless: the page exists to show what went
323
+ wrong, so that is the one thing decimation must never discard.
324
+
321
325
  The current frame is mirrored into the URL hash, so `file.html#f=506` or
322
326
  `file.html#worst` opens directly on a given row. That turns "look at the
323
327
  collision around row 506" into something sendable.
@@ -353,8 +357,22 @@ def to_html_str(result, *, max_frames=400, fps=20, title=None,
353
357
  if c >= 0 and d < result.threshold}
354
358
  segs = dep.segments()
355
359
  t_of = np.asarray(dep._t) if len(dep) else np.zeros(0, dtype=int)
356
- beads = [[*map(float, s[0]), *map(float, s[1]), int(t_of[j]), 1 if j in hot else 0]
357
- for j, s in enumerate(segs)]
360
+
361
+ # Decimate the drawn wake, on the same rule as frames: sample evenly, and
362
+ # never drop a bead something collided with. Every segment is inlined into
363
+ # the page, so an unbounded wake is an unbounded page — a 2 674-row path
364
+ # gives 1.2 MB, and a 100 000-row one would give tens of megabytes that no
365
+ # iframe is going to enjoy. Purely cosmetic: the report is computed from
366
+ # the full deposit, and nothing on the page claims a bead count.
367
+ n_segs = len(segs)
368
+ if max_beads and n_segs > max_beads:
369
+ stride = int(np.ceil(n_segs / max_beads))
370
+ shown = sorted(set(range(0, n_segs, stride)) | hot)
371
+ else:
372
+ shown = range(n_segs)
373
+ beads = [[*map(float, segs[j][0]), *map(float, segs[j][1]),
374
+ int(t_of[j]), 1 if j in hot else 0]
375
+ for j in shown]
358
376
 
359
377
  frames = []
360
378
  for r in rows:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toolwake
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Simulate a depositing tool travelling a toolpath, accumulate the material it leaves in its wake, and check the tool against it.
5
5
  Author-email: Zane Bates <zanetbates1@gmail.com>
6
6
  License: MIT
@@ -812,3 +812,111 @@ def test_printing_above_a_previous_layer_is_not_a_collision():
812
812
  assert res.n_hits == 0, (
813
813
  f"{res.n_hits} rows called collisions on a clean two-layer raster; "
814
814
  f"worst {res.worst * 1e3:.4f} mm")
815
+
816
+
817
+ def test_bead_cannot_be_taller_than_its_layer():
818
+ """A round bead of the bore diameter pokes up through the next layer.
819
+
820
+ The bead defaults to the needle's bore radius, which assumes it keeps the
821
+ cross-section it had inside the needle. Laid at a layer height smaller than
822
+ the bore, a round bead is taller than its own layer — so its top sits above
823
+ where the nozzle will be on the next pass, and rows that printed perfectly
824
+ report as collisions. On a 0.036 mm-layer slab with a 0.09 mm bore that was
825
+ 498 rows, 391 of them blamed on material exactly one layer below.
826
+ """
827
+ layer, bore_r = 36e-6, 45e-6
828
+ assert layer / 2 < bore_r, "fixture must have a layer thinner than the bore"
829
+
830
+ # The second layer is offset sideways by a raster pitch, so the previous
831
+ # layer's bead lands under the needle's WALL rather than under its bore.
832
+ # Directly under the bore it is inside the hole and correctly ignored —
833
+ # which is why this fixture has to be offset to reproduce anything.
834
+ pitch = 85e-6
835
+ assert bore_r < pitch < 95e-6, "pitch must put the bead under the wall"
836
+ pts, kinds = [], []
837
+ for k, z in enumerate((layer, 2 * layer)):
838
+ for x in range(14):
839
+ pts.append((x * 1e-3, k * pitch, z))
840
+ kinds.append(PRINT)
841
+ path = Toolpath.from_arrays(np.array(pts), kinds=np.array(kinds))
842
+ needle = Needle(inner_d=2 * bore_r)
843
+
844
+ round_bead = simulate(path, needle, lag=2, threshold=0.0)
845
+ squashed = simulate(path, needle, lag=2, threshold=0.0,
846
+ bead_radius=layer / 2)
847
+
848
+ assert round_bead.n_hits > 0, (
849
+ "fixture no longer reproduces the artefact it exists to document")
850
+ assert squashed.n_hits == 0, (
851
+ f"a bead squashed to its layer still collides: {squashed.n_hits} rows, "
852
+ f"worst {squashed.worst * 1e3:.4f} mm")
853
+ # The clearance left is the half-layer of headroom under the nozzle.
854
+ assert squashed.worst == pytest.approx(layer / 2, rel=1e-6)
855
+
856
+
857
+ def test_bead_sits_below_the_nozzle_face_not_on_the_path():
858
+ """A bead centred on the toolpath is half-embedded in the nozzle's plane.
859
+
860
+ Material leaving the bore fills the gap between the previous layer's top
861
+ and the nozzle face: it occupies [z - h, z], centred h/2 down. Centred on
862
+ the path instead, every bead straddles the plane the face travels in, so a
863
+ same-layer neighbour passing under the wall reports a collision of exactly
864
+ one bead radius — a tangent contact dressed up as a penetration. On the
865
+ reference slab that was 41 rows, every one at exactly -bead_radius.
866
+ """
867
+ layer = 36e-6
868
+ pitch = 85e-6 # neighbour lands under the wall, not the bore
869
+ pts, kinds = [], []
870
+ for lane in range(2): # two adjacent lines in the SAME layer
871
+ for x in range(14):
872
+ pts.append((x * 1e-3, lane * pitch, layer))
873
+ kinds.append(PRINT)
874
+ path = Toolpath.from_arrays(np.array(pts), kinds=np.array(kinds))
875
+ needle = Needle(inner_d=90e-6)
876
+
877
+ on_path = simulate(path, needle, lag=2, threshold=0.0,
878
+ bead_radius=layer / 2)
879
+ dropped = simulate(path, needle, lag=2, threshold=0.0,
880
+ bead_radius=layer / 2, bead_drop=layer / 2)
881
+
882
+ assert on_path.n_hits > 0, "fixture no longer reproduces the artefact"
883
+ assert on_path.worst == pytest.approx(-layer / 2, rel=1e-6), (
884
+ "the artefact should be exactly one bead radius — a tangent contact")
885
+ assert dropped.n_hits == 0, (
886
+ f"bead dropped to its layer still collides: {dropped.n_hits} rows, "
887
+ f"worst {dropped.worst * 1e3:.4f} mm")
888
+
889
+
890
+ def test_viewer_bounds_the_wake_but_keeps_every_collision():
891
+ """The page inlines every bead, so an unbounded wake is an unbounded page.
892
+
893
+ A 2 674-row path already produces 1.2 MB; at the 100 000-row budget the
894
+ backend now allows, an uncapped wake would be tens of megabytes and no
895
+ iframe would enjoy it. Decimation samples evenly and keeps every bead
896
+ something collided with — the page exists to show what went wrong, so that
897
+ is the one thing it must never drop.
898
+ """
899
+ import json
900
+ import re
901
+
902
+ from toolwake.viewer import to_html_str
903
+
904
+ # A tight spiral so plenty of beads land near the tool and some collide.
905
+ path = Toolpath.helix(radius=0.004, pitch=2e-4, turns=12.0, per_turn=120)
906
+ res = simulate(path, Needle(inner_d=90e-6), lag=4, threshold=0.0)
907
+
908
+ def beads_of(html):
909
+ m = re.search(r'"beads":\s*(\[.*?\])\s*,\s*"', html, re.S)
910
+ assert m, "bead array not found in the page"
911
+ return json.loads(m.group(1))
912
+
913
+ full = beads_of(to_html_str(res, max_beads=0))
914
+ small = beads_of(to_html_str(res, max_beads=60))
915
+ assert len(full) > 200, "fixture is too small to exercise decimation"
916
+
917
+ n_hot = sum(1 for b in full if b[7])
918
+ assert len(small) <= 60 + n_hot, (
919
+ f"{len(small)} beads kept against a cap of 60 (+{n_hot} collisions)")
920
+ assert len(small) < len(full)
921
+ assert sum(1 for b in small if b[7]) == n_hot, (
922
+ "decimation dropped a bead that something collided with")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes