loadpath 0.1.1__py3-none-any.whl

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 (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/voxelprint.py ADDED
@@ -0,0 +1,582 @@
1
+ """
2
+ Voxel print export: a multi-material fraction field as a GrabCAD Voxel Print
3
+ PNG slice stack for PolyJet material jetting (Stratasys J750 / J850 / J8,
4
+ J5) and other voxel-addressable printers.
5
+
6
+ A multi-material design here is a continuous field: per cell, the volume
7
+ fraction of each of k materials (0 = rigid Vero, 1 = rubbery Agilus30, in
8
+ between a graded mix), the rest void. A material-jetting printer drops
9
+ exactly one material per printer voxel, so a mix is made by dithering: the
10
+ field is resampled (trilinear) to the printer's native voxel grid and every
11
+ printer voxel is assigned one material (or left empty) so that the local
12
+ share of each material matches the requested fraction. The result is one
13
+ PNG per layer whose pixel colours are the materials, which GrabCAD Print's
14
+ Voxel Print Utility turns into a .GCVF print job (Apps > Voxel Print
15
+ Utility: give the folder and the slice prefix, then map each colour to a
16
+ loaded material).
17
+
18
+ Format facts, sourced from Stratasys "Guide to Voxel Printing"
19
+ (https://support.stratasys.com/en/Software/GrabCAD-Print/Tips-Guides-and-FAQs/Guide-to-Voxel-Printing,
20
+ formerly https://help.grabcad.com/article/230-guide-to-voxel-printing):
21
+
22
+ - PNG is the supported input for new work (one PNG per layer); the per-
23
+ material 1-bit BMP route is legacy.
24
+ - J750 slice resolution is 600 DPI in X and 300 DPI in Y (a 1 inch cube is
25
+ 600 x 300 pixel slices, so image width is X and height is Y).
26
+ - Layer thickness by build mode: High Quality 0.014 mm, High Mix 0.027 mm,
27
+ High Speed 0.027 mm. Slices should be cut at the print layer thickness;
28
+ otherwise the printer repeats or skips slices.
29
+ - At least 30 slices.
30
+ - Each image may use at most 6 distinct colours (3 in High Speed) plus the
31
+ background colour; the utility matches colours to loaded materials.
32
+ Anti-aliasing / dithering *between colours* (blended RGB values at
33
+ boundaries) causes a "Too many colors" error. The dithering done here
34
+ never blends colours: every pixel is exactly one palette colour, so it is
35
+ the safe kind.
36
+ - The guide's example makes background pixels transparent (alpha 0), and
37
+ the utility lets the background colour be checked/set. The slice file
38
+ prefix (e.g. "slice_") is entered in the utility.
39
+ - Base-material reference RGB colours (the table in the guide) are in
40
+ STRATASYS_COLORS below, used as default colours by material name.
41
+ - The J5 uses 300 x 300 DPI and 0.01875 mm slices (Stratasys J5 GrabCAD
42
+ Print manual, https://manuals.plus/stratasys/j5-grabcad-print-manual).
43
+
44
+ Assumed (not stated in a public spec; all configurable):
45
+
46
+ - The J850 / J8-series profile is taken equal to the J750 (600 x 300 DPI,
47
+ 0.027 mm High Mix); the guide documents the J750 only. "j850" defaults to
48
+ High Mix, the mode for graded digital materials; "j850-hq" is 0.014 mm.
49
+ - Images are 8-bit RGBA PNGs, void = fully transparent (0, 0, 0, 0)
50
+ (`image_mode="RGB"` writes an opaque background colour instead, white by
51
+ default). Bit depth and palette-vs-truecolour aren't specified.
52
+ - Files are named prefix + zero-padded layer index + ".png", layer 0 at the
53
+ bottom (lowest z). Image row 0 is the +Y edge (the slice seen from above,
54
+ +X right, +Y up); pass `flip_y=False` for row 0 = -Y. Other voxel
55
+ pipelines (e.g. OpenVCAD's material-inkjet compiler,
56
+ https://matterassembly.org/OpenVCAD-Docs/v3/guides/compilers/material-inkjet.html)
57
+ note the handedness may need checking against the tool: print a test
58
+ coupon with an asymmetric feature first.
59
+ - The image covers exactly the field's bounding box (origin to origin +
60
+ shape * pitch), each axis rounded to whole printer voxels.
61
+
62
+ Dithering. "floyd-steinberg" is multi-channel error diffusion within each
63
+ layer: the palette is the simplex vertices (one-hot material or void), so
64
+ the nearest palette entry is the channel with the largest value, and the
65
+ quantization error goes 7/16, 3/16, 5/16, 1/16 to the unvisited neighbours.
66
+ The scan is serial in principle; it is vectorized here as a wavefront
67
+ (pixel (y, x) only depends on pixels with x + 2y smaller, so all pixels on
68
+ one line x + 2y = t are done at once) and across a batch of layers. Layers
69
+ alternate scan orientation, and a small random perturbation of each choice
70
+ (`jitter`; the error is still diffused against the true value) breaks up
71
+ FS's regular textures, so patterns don't stack into identical columns.
72
+ "ordered" (8x8 Bayer) and "blue-noise" (a high-pass noise tile) are
73
+ threshold dithers: a threshold u per pixel picks the material whose
74
+ cumulative-fraction interval contains u, fully vectorized; the tile shifts
75
+ from layer to layer. All three preserve each material's mean share.
76
+
77
+ Geometry. By default (`crisp=True`) the part's outline is not dithered: a
78
+ printer voxel is solid where the resampled total occupancy is >= 0.5, and
79
+ there the material shares are renormalized to sum to 1 and dithered. With
80
+ `crisp=False` void is one more dithered channel (porous grading).
81
+
82
+ Memory: the field is resampled and dithered a few layers at a time, so the
83
+ printer-resolution volume is never held whole.
84
+ """
85
+ from __future__ import annotations
86
+
87
+ import json
88
+ import os
89
+ import struct
90
+ import time
91
+ import zlib
92
+
93
+ import numpy as np
94
+
95
+ __all__ = ["export_voxel_slices", "PRINTERS", "STRATASYS_COLORS"]
96
+
97
+ _MM_PER_IN = 25.4
98
+
99
+ # dpi_x, dpi_y, layer_mm, max materials per job (guide: 6, High Speed 3)
100
+ PRINTERS = {
101
+ "j750": dict(dpi_x=600, dpi_y=300, layer_mm=0.027, max_materials=6,
102
+ mode="High Mix"),
103
+ "j750-hq": dict(dpi_x=600, dpi_y=300, layer_mm=0.014, max_materials=6,
104
+ mode="High Quality"),
105
+ "j750-hs": dict(dpi_x=600, dpi_y=300, layer_mm=0.027, max_materials=3,
106
+ mode="High Speed"),
107
+ "j850": dict(dpi_x=600, dpi_y=300, layer_mm=0.027, max_materials=6,
108
+ mode="High Mix"),
109
+ "j850-hq": dict(dpi_x=600, dpi_y=300, layer_mm=0.014, max_materials=6,
110
+ mode="High Quality"),
111
+ "j850-hs": dict(dpi_x=600, dpi_y=300, layer_mm=0.027, max_materials=3,
112
+ mode="High Speed"),
113
+ # J5: resolution from the J5 manual; 5 materials is the J5 cabinet
114
+ # (assumed as the per-job colour limit)
115
+ "j5": dict(dpi_x=300, dpi_y=300, layer_mm=0.01875, max_materials=5,
116
+ mode="J5"),
117
+ }
118
+ PRINTERS["j8"] = PRINTERS["j850"]
119
+
120
+ # Stratasys base-material RGB values from the Guide to Voxel Printing.
121
+ STRATASYS_COLORS = {
122
+ "fullcure720": (255, 255, 170),
123
+ "tango+": (255, 255, 100),
124
+ "tangoblack+": (50, 50, 50),
125
+ "agilus30clr": (255, 255, 100),
126
+ "agilus30blk": (40, 77, 108),
127
+ "agilus30wht": (255, 255, 255),
128
+ "veroflexwt": (255, 255, 255),
129
+ "veropurewht": (240, 240, 240),
130
+ "veroblack": (26, 26, 29),
131
+ "veroflexbk": (26, 26, 29),
132
+ "veroclear": (227, 233, 253),
133
+ "veroflexclr": (227, 233, 253),
134
+ "verocyan": (0, 90, 158),
135
+ "veroflexcy": (0, 90, 158),
136
+ "veromgnt": (166, 33, 98),
137
+ "veroflexmgt": (166, 33, 98),
138
+ "veroyellow": (200, 189, 3),
139
+ "veroflexyl": (200, 189, 3),
140
+ "veroultraclr": (247, 247, 247),
141
+ "rgd821": (247, 247, 247),
142
+ "veromgt-v": (198, 0, 88),
143
+ "veroyl-v": (240, 197, 0),
144
+ "verocy-v": (0, 137, 166),
145
+ "veroblue": (156, 208, 237),
146
+ "verogrey": (185, 185, 185),
147
+ }
148
+ _ALIASES = {"agilus30": "agilus30blk", "vero": "veroblue",
149
+ "verogray": "verogrey", "veromagenta": "veromgnt",
150
+ "agilus30black": "agilus30blk", "agilus30white": "agilus30wht",
151
+ "agilus30clear": "agilus30clr", "tangoplus": "tango+",
152
+ "tangoblackplus": "tangoblack+", "verowhite": "veropurewht"}
153
+
154
+
155
+ # ---------------------------------------------------------------- inputs
156
+
157
+ def _printer_profile(printer):
158
+ if isinstance(printer, dict):
159
+ p = dict(printer)
160
+ if "voxel_mm" in p:
161
+ dx, dy, dz = p.pop("voxel_mm")
162
+ p.setdefault("dpi_x", _MM_PER_IN / dx)
163
+ p.setdefault("dpi_y", _MM_PER_IN / dy)
164
+ p.setdefault("layer_mm", dz)
165
+ for key in ("dpi_x", "dpi_y", "layer_mm"):
166
+ if key not in p:
167
+ raise ValueError(f"printer dict needs {key!r} (or voxel_mm)")
168
+ p.setdefault("max_materials", 6)
169
+ p.setdefault("mode", "custom")
170
+ p.setdefault("name", "custom")
171
+ return p
172
+ key = str(printer).lower().replace(" ", "")
173
+ if key not in PRINTERS:
174
+ raise ValueError(f"unknown printer {printer!r}; known: "
175
+ f"{sorted(PRINTERS)} or a dict with dpi_x, dpi_y, "
176
+ "layer_mm")
177
+ p = dict(PRINTERS[key])
178
+ p["name"] = key
179
+ return p
180
+
181
+
182
+ def _parse_color(c):
183
+ if isinstance(c, str):
184
+ s = c.lstrip("#")
185
+ if len(s) != 6:
186
+ raise ValueError(f"colour {c!r}: want '#rrggbb'")
187
+ return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))
188
+ c = tuple(int(round(v)) for v in c)
189
+ if len(c) != 3 or not all(0 <= v <= 255 for v in c):
190
+ raise ValueError(f"colour {c!r}: want 3 ints 0..255")
191
+ return c
192
+
193
+
194
+ def _parse_materials(materials, k):
195
+ out = []
196
+ for m in materials:
197
+ if isinstance(m, dict):
198
+ name, color = m["name"], m.get("color")
199
+ elif isinstance(m, str):
200
+ name, color = m, None
201
+ else:
202
+ name, color = m
203
+ if color is None:
204
+ key = name.lower().replace(" ", "")
205
+ key = _ALIASES.get(key, key)
206
+ if key not in STRATASYS_COLORS:
207
+ raise ValueError(f"no default colour for material {name!r}; "
208
+ "give (name, color)")
209
+ color = STRATASYS_COLORS[key]
210
+ out.append({"name": str(name), "color": _parse_color(color)})
211
+ if len(out) != k:
212
+ raise ValueError(f"{len(out)} materials given for {k} fraction "
213
+ "channels")
214
+ cols = [m["color"] for m in out]
215
+ if len(set(cols)) != len(cols):
216
+ raise ValueError(f"material colours must be distinct, got {cols}; "
217
+ "the utility maps colours to materials")
218
+ return out
219
+
220
+
221
+ # ------------------------------------------------------------ resampling
222
+
223
+ def _axis_weights(n_out, step_out, pitch, n_in):
224
+ """Linear interpolation of printer-voxel centres onto cell centres."""
225
+ u = ((np.arange(n_out) + 0.5) * step_out) / pitch - 0.5
226
+ u = np.clip(u, 0.0, n_in - 1)
227
+ i0 = np.minimum(np.floor(u).astype(np.int64), max(n_in - 2, 0))
228
+ i1 = np.minimum(i0 + 1, n_in - 1)
229
+ w = (u - i0).astype(np.float32)
230
+ return i0, i1, w
231
+
232
+
233
+ # -------------------------------------------------------------- dithering
234
+
235
+ def _bayer(n=8):
236
+ m = np.zeros((1, 1), dtype=np.int64)
237
+ while m.shape[0] < n:
238
+ m = np.block([[4 * m, 4 * m + 2], [4 * m + 3, 4 * m + 1]])
239
+ return ((m + 0.5) / m.size).astype(np.float32)
240
+
241
+
242
+ def _blue_noise(n=64, seed=12345):
243
+ """A blue-ish noise threshold tile: white noise, high-passed in Fourier
244
+ space (periodic, so tiles seamlessly), then rank-mapped to uniform."""
245
+ rng = np.random.default_rng(seed)
246
+ f = np.fft.fft2(rng.standard_normal((n, n)))
247
+ kx = np.fft.fftfreq(n)[:, None]
248
+ ky = np.fft.fftfreq(n)[None, :]
249
+ r = np.sqrt(kx ** 2 + ky ** 2)
250
+ f *= 1.0 - np.exp(-(r / 0.18) ** 2)
251
+ g = np.real(np.fft.ifft2(f)).ravel()
252
+ t = np.empty(n * n, dtype=np.float32)
253
+ t[np.argsort(g, kind="stable")] = (np.arange(n * n) + 0.5) / (n * n)
254
+ return t.reshape(n, n)
255
+
256
+
257
+ def _threshold_dither(vals, tile, layer_ids):
258
+ """vals (L, H, W, C) with channel 0 = void. Picks the channel whose
259
+ cumulative interval holds the per-pixel threshold."""
260
+ L, H, W, C = vals.shape
261
+ ty, tx = tile.shape
262
+ c = np.cumsum(vals, axis=-1)
263
+ out = np.empty((L, H, W), dtype=np.uint8)
264
+ for li, iz in enumerate(layer_ids):
265
+ # step the tile offset through all ty*tx shifts in a scrambled order
266
+ # (odd multiplier, coprime to the power-of-two tile size)
267
+ m = (int(iz) * 2654435761) % (ty * tx)
268
+ oy, ox = divmod(m, tx)
269
+ tt = np.roll(tile, (oy, ox), axis=(0, 1))
270
+ thr = np.tile(tt, (H // ty + 1, W // tx + 1))[:H, :W]
271
+ idx = (thr[..., None] >= c[li]).sum(axis=-1)
272
+ out[li] = np.minimum(idx, C - 1)
273
+ return out
274
+
275
+
276
+ def _fs_dither(vals, void_mask, jitter=0.0, seed=0):
277
+ """Multi-channel Floyd-Steinberg on L layers at once, wavefront order.
278
+ vals (L, H, W, C) float32; void_mask (L, H, W) bool or None (pixels
279
+ forced to channel 0, whose error is dropped). `jitter` perturbs only the
280
+ choice (the error is still taken against the true value, so means are
281
+ kept); it breaks up FS's regular textures so they don't repeat layer
282
+ after layer."""
283
+ L, H, W, C = vals.shape
284
+ noise = None
285
+ if jitter > 0:
286
+ rng = np.random.default_rng(seed)
287
+ noise = (rng.random((L, H, W, C), dtype=np.float32) - 0.5) * jitter
288
+ buf = np.zeros((L, H + 1, W + 2, C), dtype=np.float32)
289
+ buf[:, :H, 1:W + 1] = vals
290
+ out = np.empty((L, H, W), dtype=np.uint8)
291
+ ys_all = np.arange(H)
292
+ li = np.arange(L)[:, None]
293
+ for t in range(W + 2 * (H - 1)):
294
+ y_lo = max(0, -(-(t - (W - 1)) // 2))
295
+ y_hi = min(H - 1, t // 2)
296
+ ys = ys_all[y_lo:y_hi + 1]
297
+ xs = t - 2 * ys
298
+ v = buf[:, ys, xs + 1] # (L, n, C)
299
+ if noise is None:
300
+ q = v.argmax(axis=-1) # (L, n)
301
+ else:
302
+ q = (v + noise[:, ys, xs]).argmax(axis=-1)
303
+ if void_mask is not None:
304
+ m = void_mask[:, ys, xs]
305
+ q[m] = 0
306
+ out[:, ys, xs] = q
307
+ v[li, np.arange(len(ys))[None, :], q] -= 1.0 # v is now the error
308
+ if void_mask is not None:
309
+ v[m] = 0.0
310
+ buf[:, ys, xs + 2] += v * (7.0 / 16.0)
311
+ buf[:, ys + 1, xs] += v * (3.0 / 16.0)
312
+ buf[:, ys + 1, xs + 1] += v * (5.0 / 16.0)
313
+ buf[:, ys + 1, xs + 2] += v * (1.0 / 16.0)
314
+ return out
315
+
316
+
317
+ # ---------------------------------------------------------------- PNG out
318
+
319
+ def _png_bytes(img):
320
+ """Minimal 8-bit RGB/RGBA PNG writer (no Pillow)."""
321
+ h, w, ch = img.shape
322
+ ctype = {3: 2, 4: 6}[ch]
323
+ raw = np.empty((h, 1 + w * ch), dtype=np.uint8)
324
+ raw[:, 0] = 0
325
+ raw[:, 1:] = img.reshape(h, w * ch)
326
+
327
+ def chunk(tag, data):
328
+ return (struct.pack(">I", len(data)) + tag + data
329
+ + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
330
+
331
+ return (b"\x89PNG\r\n\x1a\n"
332
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, ctype, 0, 0, 0))
333
+ + chunk(b"IDAT", zlib.compress(raw.tobytes(), 3))
334
+ + chunk(b"IEND", b""))
335
+
336
+
337
+ def _png_writer(dpi):
338
+ try:
339
+ from PIL import Image
340
+ except ImportError:
341
+ Image = None
342
+
343
+ def write(path, img):
344
+ if Image is not None:
345
+ mode = "RGBA" if img.shape[2] == 4 else "RGB"
346
+ Image.fromarray(img, mode).save(path, compress_level=3,
347
+ dpi=dpi)
348
+ else:
349
+ with open(path, "wb") as f:
350
+ f.write(_png_bytes(img))
351
+ return write, ("pillow" if Image is not None else "zlib")
352
+
353
+
354
+ # ------------------------------------------------------------------ main
355
+
356
+ def export_voxel_slices(out_dir, fraction, origin, pitch, materials,
357
+ printer="j850", dither="floyd-steinberg", solid=None,
358
+ max_layers=None, *, prefix="slice_", crisp=True,
359
+ image_mode="RGBA", background=(255, 255, 255),
360
+ flip_y=True, jitter=0.25, batch_mb=96, verbose=False) -> dict:
361
+ """Write a GrabCAD Voxel Print PNG slice stack for a material field.
362
+
363
+ out_dir: folder for the PNGs and manifest.json (created).
364
+ fraction: (nx, ny, nz, k) per-cell volume fractions of k materials
365
+ (sum over k <= 1; the rest is void), or (nx, ny, nz) for a two-
366
+ material mix (0 = materials[0], 1 = materials[1]) with `solid` the
367
+ occupancy. Cell (0, 0, 0)'s min corner is at `origin` (mm); cells
368
+ are `pitch` mm cubes (a scalar or (px, py, pz)).
369
+ materials: k entries, each a name (colour from STRATASYS_COLORS, e.g.
370
+ "VeroCyan", "Agilus30Blk"), (name, color) or {"name", "color"};
371
+ colours are RGB tuples or "#rrggbb" and must be distinct.
372
+ printer: a PRINTERS key ("j850" = 600 x 300 DPI, 0.027 mm High Mix;
373
+ "j850-hq" 0.014 mm; "j750*"; "j5") or a dict with dpi_x, dpi_y,
374
+ layer_mm (or voxel_mm=(dx, dy, dz)) and optional max_materials.
375
+ dither: "floyd-steinberg", "ordered" (Bayer 8x8) or "blue-noise".
376
+ solid: (nx, ny, nz) bool/float occupancy, multiplies the fractions;
377
+ required meaning for the 2-material form (default: all solid).
378
+ max_layers: write only the first this-many layers (previews, tests).
379
+ crisp: threshold the outline at occupancy 0.5 instead of dithering void.
380
+ image_mode: "RGBA" (void transparent) or "RGB" (void = `background`).
381
+ flip_y: image row 0 is the +Y edge (seen from above).
382
+ jitter: Floyd-Steinberg decision noise amplitude (0 = plain FS).
383
+
384
+ Returns a summary dict (also written as manifest.json) with the printer
385
+ grid, files, and per-material requested vs achieved share/volume.
386
+ """
387
+ t_start = time.time()
388
+ prof = _printer_profile(printer)
389
+ f = np.asarray(fraction, dtype=np.float32)
390
+ if f.ndim == 3:
391
+ f = np.stack([1.0 - f, f], axis=-1)
392
+ if solid is None:
393
+ solid = np.ones(f.shape[:3], dtype=np.float32)
394
+ elif f.ndim != 4:
395
+ raise ValueError("fraction must be (nx, ny, nz, k) or (nx, ny, nz)")
396
+ f = np.clip(f, 0.0, 1.0)
397
+ if solid is not None:
398
+ s = np.asarray(solid, dtype=np.float32)
399
+ if s.shape != f.shape[:3]:
400
+ raise ValueError(f"solid shape {s.shape} != grid {f.shape[:3]}")
401
+ f = f * np.clip(s, 0.0, 1.0)[..., None]
402
+ tot = f.sum(axis=-1, keepdims=True)
403
+ f = np.where(tot > 1.0, f / np.maximum(tot, 1e-12), f)
404
+ nx, ny, nz, k = f.shape
405
+ if k > prof["max_materials"]:
406
+ raise ValueError(f"{k} materials; {prof['name']} takes at most "
407
+ f"{prof['max_materials']} per job")
408
+ mats = _parse_materials(materials, k)
409
+ if dither not in ("floyd-steinberg", "ordered", "blue-noise"):
410
+ raise ValueError(f"unknown dither {dither!r}")
411
+ if image_mode not in ("RGBA", "RGB"):
412
+ raise ValueError("image_mode must be 'RGBA' or 'RGB'")
413
+ bg = _parse_color(background)
414
+ if image_mode == "RGB" and bg in [m["color"] for m in mats]:
415
+ raise ValueError("background colour equals a material colour")
416
+
417
+ pitch3 = np.broadcast_to(np.asarray(pitch, dtype=float), (3,))
418
+ origin = np.asarray(origin, dtype=float).reshape(3)
419
+ size = np.array([nx, ny, nz]) * pitch3
420
+ dx = _MM_PER_IN / prof["dpi_x"]
421
+ dy = _MM_PER_IN / prof["dpi_y"]
422
+ dz = float(prof["layer_mm"])
423
+ W = max(1, int(round(size[0] / dx)))
424
+ H = max(1, int(round(size[1] / dy)))
425
+ NZ = max(1, int(round(size[2] / dz)))
426
+ n_write = NZ if max_layers is None else min(NZ, int(max_layers))
427
+
428
+ # channels: 0 = void, 1..k = materials
429
+ full = np.concatenate([1.0 - f.sum(axis=-1, keepdims=True), f], axis=-1)
430
+ full = np.ascontiguousarray(full.astype(np.float32))
431
+ C = k + 1
432
+ xi0, xi1, xw = _axis_weights(W, dx, pitch3[0], nx)
433
+ yi0, yi1, yw = _axis_weights(H, dy, pitch3[1], ny)
434
+ zi0, zi1, zw = _axis_weights(NZ, dz, pitch3[2], nz)
435
+ if flip_y: # row 0 = +Y
436
+ yi0, yi1, yw = yi0[::-1], yi1[::-1], yw[::-1]
437
+
438
+ lut = np.zeros((C, 4), dtype=np.uint8)
439
+ lut[0] = (0, 0, 0, 0) if image_mode == "RGBA" else (*bg, 255)
440
+ for j, m in enumerate(mats):
441
+ lut[j + 1] = (*m["color"], 255)
442
+ nch = 4 if image_mode == "RGBA" else 3
443
+ write, png_backend = _png_writer((prof["dpi_x"], prof["dpi_y"]))
444
+
445
+ os.makedirs(out_dir, exist_ok=True)
446
+ digits = max(4, len(str(NZ - 1)))
447
+ names = [f"{prefix}{i:0{digits}d}.png" for i in range(n_write)]
448
+
449
+ tile = (_bayer(8) if dither == "ordered"
450
+ else _blue_noise() if dither == "blue-noise" else None)
451
+ per_layer_bytes = H * W * C * 4 * (4 if tile is None else 4)
452
+ batch = int(max(1, min(32, batch_mb * 2 ** 20 // max(per_layer_bytes, 1))))
453
+
454
+ counts = np.zeros(C, dtype=np.int64)
455
+ req_raw = np.zeros(C, dtype=np.float64) # resampled fractions
456
+ req_target = np.zeros(C, dtype=np.float64) # what the dither aimed at
457
+ t_dither = 0.0
458
+ for z0 in range(0, n_write, batch):
459
+ ids = np.arange(z0, min(n_write, z0 + batch))
460
+ # z, then x, then y interpolation (separable trilinear)
461
+ a = full[:, :, zi0[ids]] * (1 - zw[ids])[None, None, :, None] \
462
+ + full[:, :, zi1[ids]] * zw[ids][None, None, :, None]
463
+ a = np.moveaxis(a, 2, 0) # (L,nx,ny,C)
464
+ a = a[:, xi0] * (1 - xw)[None, :, None, None] \
465
+ + a[:, xi1] * xw[None, :, None, None] # (L,W,ny,C)
466
+ a = a[:, :, yi0] * (1 - yw)[None, None, :, None] \
467
+ + a[:, :, yi1] * yw[None, None, :, None] # (L,W,H,C)
468
+ vals = np.ascontiguousarray(np.swapaxes(a, 1, 2)) # (L,H,W,C)
469
+ del a
470
+ req_raw += vals.sum(axis=2).sum(axis=(0, 1), dtype=np.float64)
471
+ if crisp:
472
+ occ = 1.0 - vals[..., 0]
473
+ void_mask = occ < 0.5
474
+ vals[..., 1:] /= np.maximum(occ, 1e-6)[..., None]
475
+ vals[..., 0] = 0.0
476
+ vals[void_mask] = 0.0
477
+ vals[void_mask, 0] = 1.0
478
+ else:
479
+ void_mask = None
480
+ req_target += vals.sum(axis=2).sum(axis=(0, 1), dtype=np.float64)
481
+
482
+ t0 = time.time()
483
+ if tile is None:
484
+ # alternate scan orientation layer to layer (flip x, flip y)
485
+ fl = [(iz % 2 == 1, (iz // 2) % 2 == 1) for iz in ids]
486
+ for li, (fx, fy) in enumerate(fl):
487
+ if fx:
488
+ vals[li] = vals[li, :, ::-1].copy()
489
+ if fy:
490
+ vals[li] = vals[li, ::-1].copy()
491
+ vm = None if void_mask is None else void_mask.copy()
492
+ if vm is not None:
493
+ for li, (fx, fy) in enumerate(fl):
494
+ if fx:
495
+ vm[li] = vm[li, :, ::-1].copy()
496
+ if fy:
497
+ vm[li] = vm[li, ::-1].copy()
498
+ idx = _fs_dither(vals, vm, jitter, seed=int(ids[0]))
499
+ for li, (fx, fy) in enumerate(fl):
500
+ if fx:
501
+ idx[li] = idx[li, :, ::-1].copy()
502
+ if fy:
503
+ idx[li] = idx[li, ::-1].copy()
504
+ else:
505
+ idx = _threshold_dither(vals, tile, ids)
506
+ if void_mask is not None:
507
+ idx[void_mask] = 0
508
+ t_dither += time.time() - t0
509
+ del vals
510
+ counts += np.bincount(idx.ravel(), minlength=C)
511
+ for li, iz in enumerate(ids):
512
+ write(os.path.join(out_dir, names[iz]), lut[idx[li]][..., :nch])
513
+ if verbose:
514
+ print(f"voxelprint: layers {ids[0]}..{ids[-1]} of {n_write}")
515
+
516
+ vox_mm3 = dx * dy * dz
517
+ n_solid = counts[1:].sum()
518
+ tgt_solid = req_target[1:].sum()
519
+ per_mat = []
520
+ for j, m in enumerate(mats):
521
+ req_share = float(req_target[j + 1] / tgt_solid) if tgt_solid else 0.0
522
+ ach_share = float(counts[j + 1] / n_solid) if n_solid else 0.0
523
+ per_mat.append({
524
+ "name": m["name"],
525
+ "color": "#%02x%02x%02x" % m["color"],
526
+ "rgb": list(m["color"]),
527
+ "png_index": j + 1,
528
+ "requested_share": req_share,
529
+ "achieved_share": ach_share,
530
+ "share_error": ach_share - req_share,
531
+ "requested_volume_mm3": float(req_raw[j + 1] * vox_mm3),
532
+ "achieved_volume_mm3": float(counts[j + 1] * vox_mm3),
533
+ "input_volume_mm3": float(f[..., j].sum() * np.prod(pitch3)),
534
+ })
535
+ elapsed = time.time() - t_start
536
+ summary = {
537
+ "format": "GrabCAD Voxel Print PNG slice stack",
538
+ "printer": prof["name"],
539
+ "build_mode": prof.get("mode"),
540
+ "dpi": [prof["dpi_x"], prof["dpi_y"]],
541
+ "voxel_mm": [dx, dy, dz],
542
+ "layer_thickness_mm": dz,
543
+ "width_px": W,
544
+ "height_px": H,
545
+ "layers_total": NZ,
546
+ "layers_written": n_write,
547
+ "truncated": n_write < NZ,
548
+ "size_mm": [W * dx, H * dy, NZ * dz],
549
+ "requested_size_mm": size.tolist(),
550
+ "origin_mm": origin.tolist(),
551
+ "file_prefix": prefix,
552
+ "files": names,
553
+ "image_mode": image_mode,
554
+ "void_color": ("transparent (0,0,0,0)" if image_mode == "RGBA"
555
+ else "#%02x%02x%02x" % bg),
556
+ "orientation": ("columns +X, row 0 = " + ("+Y" if flip_y else "-Y")
557
+ + " edge, layer 0 = bottom (min z)"),
558
+ "dither": dither,
559
+ "crisp_outline": bool(crisp),
560
+ "materials": per_mat,
561
+ "void_voxels": int(counts[0]),
562
+ "solid_voxels": int(n_solid),
563
+ "max_share_error": max((abs(p["share_error"]) for p in per_mat),
564
+ default=0.0),
565
+ "warnings": ([] if NZ >= 30 else
566
+ [f"{NZ} slices; the Voxel Print Utility wants >= 30"]),
567
+ "png_backend": png_backend,
568
+ "seconds": elapsed,
569
+ "seconds_dither": t_dither,
570
+ "seconds_per_layer": elapsed / max(n_write, 1),
571
+ "utility_steps": ("GrabCAD Print > Apps > Voxel Print Utility: slice "
572
+ f"prefix '{prefix}', select this folder, check "
573
+ f"slice range 0..{n_write - 1}, layer thickness "
574
+ f"{dz} mm and background colour, then map each "
575
+ "colour to its material."),
576
+ }
577
+ manifest = {kk: v for kk, v in summary.items() if kk != "files"}
578
+ manifest["file_pattern"] = f"{prefix}{'#' * digits}.png"
579
+ with open(os.path.join(out_dir, "manifest.json"), "w") as fh:
580
+ json.dump(manifest, fh, indent=2)
581
+ summary["out_dir"] = os.path.abspath(out_dir)
582
+ return summary