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
@@ -0,0 +1,446 @@
1
+ """
2
+ Manufacturing constraints for parts that are not printed, and the size and
3
+ pattern controls that go with them: the rest of what Fusion 360's
4
+ generative design offers beside additive.
5
+
6
+ - **Milling** (`"milling"`): every void must be reachable by a cutter coming
7
+ straight in along one of the access directions. 3-axis mills from one
8
+ side (+z by default), 2.5-axis does the same with flat pocket floors at a
9
+ fixed step-down, and 5-axis is approximated as 3+2 positional machining
10
+ from the six axis directions. `toolDiameter` keeps every slot and inside
11
+ corner at least as wide as the cutter.
12
+ - **Casting / molding** (`"casting"`): no undercuts along the draw
13
+ direction. A two-sided mold (the default) pulls halves both ways along the
14
+ draw, so every line parallel to it crosses the part once; a one-sided mold
15
+ pulls one way off a flat parting plane.
16
+ - **Circular pattern** (`"pattern"`): N identical sectors round an axis (a
17
+ turret, a hub, a wheel), as Fusion's circular symmetry.
18
+ - **Maximum member** (`"maxMember"`): no member thicker than this, the
19
+ counterpart of minMember.
20
+
21
+ Access is one operator for milling and casting. A cell is blocked from
22
+ direction d when anything solid lies between it and the outside along d;
23
+ it must then be solid itself, because neither a cutter nor a mold half can
24
+ get there. With several access directions a cell has to be blocked from
25
+ all of them:
26
+
27
+ accessible_i = min over d of max(x_j : j on the ray from i along d)
28
+
29
+ which is a cumulative max per direction (exact, with the gradient routed to
30
+ the cell that set the max) and a min across directions. It runs on the
31
+ projected densities like the AM filter (am.py), so material that would
32
+ leave an undercut is credited as filled, and the optimizer stops putting it
33
+ there. Casting two-sided along z is access from +z and -z; 3-axis milling
34
+ from the top is access from +z, the same as a one-sided mold.
35
+
36
+ The cutter's radius and the 2.5-axis floors are applied exactly to the
37
+ delivered design (`machinable`): the void a flat end mill of that radius can
38
+ sweep from each side, with each pocket's depth rounded up to the step-down,
39
+ and everything else in the design space filled. It only adds material, so
40
+ it can only lower stress; the final analysis runs on the filled part.
41
+
42
+ The maximum member is the local volume constraint of Wu, Aage, Westermann &
43
+ Sigmund (2018, "Infill optimization for additive manufacturing", IEEE TVCG
44
+ 24): the solid share of every ball round a design cell stays under ALPHA. A
45
+ slab thicker than the limit fills the ball past it. The ball is sized so a
46
+ flat slab exactly maxMember thick reads ALPHA (a ball of radius R cut by a
47
+ centred slab of half-thickness h holds 1.5 u - 0.5 u^3 of solid, u = h/R);
48
+ a round strut of the same diameter reads less, so for struts the limit is
49
+ looser. Instead of one aggregated constraint each violating ball adds a
50
+ penalty to the optimality-criteria update, as a cost on top of volume,
51
+ whose weight grows while any ball is over: thick members hollow out or split
52
+ in two, and the volume they free goes where the stiffness is.
53
+ """
54
+ from __future__ import annotations
55
+
56
+ from dataclasses import dataclass, field
57
+
58
+ import numpy as np
59
+ from scipy import ndimage
60
+
61
+ __all__ = ["Access", "Pattern", "AccessFilter", "MaxMember", "parse_milling", "parse_casting",
62
+ "parse_pattern", "tie_groups", "machinable", "member_thickness", "report", "AXIS_DIRECTIONS"]
63
+
64
+ AXIS_DIRECTIONS = [(a, s) for a in range(3) for s in (1, -1)]
65
+ # Solid share of a ball allowed by maxMember.
66
+ ALPHA = 0.85
67
+ # Ball radius / maxMember for which a slab maxMember thick reads ALPHA.
68
+ _BALL = 0.754
69
+
70
+
71
+ def _label(d):
72
+ return ("+" if d[1] > 0 else "-") + "xyz"[d[0]]
73
+
74
+
75
+ def _axis_direction(v, field_name):
76
+ s = str(v).strip().lower()
77
+ if len(s) == 1:
78
+ s = "+" + s
79
+ if len(s) != 2 or s[0] not in "+-" or s[1] not in "xyz":
80
+ raise ValueError(f"{field_name} entries are axis directions like '+z' or '-x' "
81
+ f"(tilted directions are not supported yet), got {v!r}")
82
+ return "xyz".index(s[1]), (1 if s[0] == "+" else -1)
83
+
84
+
85
+ @dataclass
86
+ class Access:
87
+ """Which way a cutter or mold half reaches the part."""
88
+ kind: str # "milling" | "casting"
89
+ directions: list # (axis, sign): the side the cutter comes from / a mold half pulls toward
90
+ axes: float | None = None # milling: 2.5, 3 or 5
91
+ tool_radius: float | None = None # mm
92
+ step: float | None = None # mm between 2.5-axis pocket floors
93
+ draw: tuple | None = None # casting
94
+
95
+ def summary(self) -> dict:
96
+ out = {"kind": self.kind, "directions": [_label(d) for d in self.directions]}
97
+ if self.kind == "milling":
98
+ out["axes"] = self.axes
99
+ if self.tool_radius:
100
+ out["toolDiameter"] = 2.0 * self.tool_radius
101
+ if self.step:
102
+ out["stepDown"] = self.step
103
+ else:
104
+ out["draw"] = _label(self.draw)
105
+ out["sides"] = len(self.directions)
106
+ return out
107
+
108
+
109
+ @dataclass
110
+ class Pattern:
111
+ """N identical sectors round an axis through `center`."""
112
+ axis: int
113
+ count: int
114
+ center: np.ndarray | None = None
115
+
116
+ def summary(self) -> dict:
117
+ out = {"type": "circular", "axis": "xyz"[self.axis], "count": self.count}
118
+ if self.center is not None:
119
+ out["center"] = [float(c) for c in self.center]
120
+ return out
121
+
122
+
123
+ def parse_milling(v) -> Access | None:
124
+ """`"milling": {"axes": 3, "directions": ["+z"], "toolDiameter": 6, "stepDown": 2}`."""
125
+ if v is None:
126
+ return None
127
+ if not isinstance(v, dict):
128
+ raise ValueError("milling is an object: {axes: 2.5 | 3 | 5, directions: ['+z', ...], toolDiameter: mm}")
129
+ axes = float(v.get("axes", 3))
130
+ if axes not in (2.5, 3.0, 5.0):
131
+ raise ValueError("milling.axes is 2.5, 3 or 5")
132
+ dirs = v.get("directions")
133
+ if dirs is None:
134
+ dirs = [_label(d) for d in AXIS_DIRECTIONS] if axes == 5 else ["+z"]
135
+ if isinstance(dirs, str):
136
+ dirs = [dirs]
137
+ if not dirs:
138
+ raise ValueError("milling.directions needs at least one direction the cutter comes from")
139
+ parsed = []
140
+ for d in dirs:
141
+ p = _axis_direction(d, "milling.directions")
142
+ if p not in parsed:
143
+ parsed.append(p)
144
+ tool = v.get("toolDiameter")
145
+ if tool is not None and not (isinstance(tool, (int, float)) and tool > 0):
146
+ raise ValueError("milling.toolDiameter is the smallest cutter's diameter in mm (> 0)")
147
+ step = v.get("stepDown")
148
+ if step is not None and not (isinstance(step, (int, float)) and step > 0):
149
+ raise ValueError("milling.stepDown is the spacing of 2.5-axis pocket floors in mm (> 0)")
150
+ if step is not None and axes != 2.5:
151
+ raise ValueError("milling.stepDown is for 2.5-axis milling (axes: 2.5)")
152
+ if axes == 2.5 and step is None:
153
+ step = 2.0
154
+ return Access("milling", parsed, axes=axes, tool_radius=0.5 * float(tool) if tool else None,
155
+ step=float(step) if step else None)
156
+
157
+
158
+ def parse_casting(v) -> Access | None:
159
+ """`"casting": {"draw": "+z", "sides": 2}`; a string is the draw direction."""
160
+ if v is None:
161
+ return None
162
+ if isinstance(v, str):
163
+ v = {"draw": v}
164
+ if not isinstance(v, dict) or "draw" not in v:
165
+ raise ValueError("casting is {draw: '+z', sides: 1 | 2}: the direction the mold opens")
166
+ draw = _axis_direction(v["draw"], "casting.draw")
167
+ sides = v.get("sides", 2)
168
+ if sides not in (1, 2):
169
+ raise ValueError("casting.sides is 2 (mold halves pull both ways along the draw) or 1 (one half pulls "
170
+ "toward draw off a flat parting plane)")
171
+ dirs = [draw] if sides == 1 else [draw, (draw[0], -draw[1])]
172
+ return Access("casting", dirs, draw=draw)
173
+
174
+
175
+ def parse_pattern(v) -> Pattern | None:
176
+ """`"pattern": {"type": "circular", "axis": "z", "count": 6, "center": [x, y, z]}`."""
177
+ if v is None:
178
+ return None
179
+ if not isinstance(v, dict):
180
+ raise ValueError("pattern is {type: 'circular', axis: 'z', count: N, center: [x, y, z]}")
181
+ if v.get("type", "circular") != "circular":
182
+ raise ValueError("pattern.type must be 'circular'")
183
+ a = str(v.get("axis", "z")).strip().lower().lstrip("+-")
184
+ if a not in ("x", "y", "z"):
185
+ raise ValueError("pattern.axis is 'x', 'y' or 'z'")
186
+ n = v.get("count")
187
+ if not isinstance(n, int) or isinstance(n, bool) or n < 2:
188
+ raise ValueError("pattern.count is the number of identical sectors (an integer, at least 2)")
189
+ c = v.get("center")
190
+ if c is not None:
191
+ c = np.asarray(c, float)
192
+ if c.shape != (3,) or not np.all(np.isfinite(c)):
193
+ raise ValueError("pattern.center is a point [x, y, z] on the axis")
194
+ return Pattern("xyz".index(a), n, c)
195
+
196
+
197
+ # --- access ---------------------------------------------------------------
198
+
199
+ def _cummax(f, axis, sign, track):
200
+ """Max over each cell and everything beyond it toward the `sign` end of
201
+ `axis` (where the cutter comes from), and the axis coordinate of the
202
+ cell that set it."""
203
+ g = np.moveaxis(f, axis, -1)
204
+ if sign > 0:
205
+ g = g[..., ::-1]
206
+ m = np.maximum.accumulate(g, axis=-1)
207
+ idx = None
208
+ if track:
209
+ n = g.shape[-1]
210
+ pos = np.where(g >= m, np.arange(n), 0)
211
+ idx = np.maximum.accumulate(pos, axis=-1)
212
+ if sign > 0:
213
+ idx = (n - 1 - idx)[..., ::-1]
214
+ idx = np.moveaxis(idx, -1, axis)
215
+ if sign > 0:
216
+ m = m[..., ::-1]
217
+ return np.moveaxis(m, -1, axis), idx
218
+
219
+
220
+ class AccessFilter:
221
+ """Same interface as am.AMFilter: forward/backward on full grids."""
222
+
223
+ def __init__(self, dims, directions, track: bool = True):
224
+ self.dims = tuple(int(d) for d in dims)
225
+ self.directions = list(directions)
226
+ self.track = track
227
+ self._state = None
228
+
229
+ def forward(self, field: np.ndarray) -> np.ndarray:
230
+ vals, idxs = [], []
231
+ for a, s in self.directions:
232
+ m, idx = _cummax(field, a, s, self.track)
233
+ vals.append(m)
234
+ idxs.append(idx)
235
+ if len(vals) == 1:
236
+ out, choice = vals[0], None
237
+ else:
238
+ stack = np.stack(vals)
239
+ choice = np.argmin(stack, axis=0)
240
+ out = np.take_along_axis(stack, choice[None], axis=0)[0]
241
+ self._state = (idxs, choice) if self.track else None
242
+ return out
243
+
244
+ def backward(self, grad: np.ndarray) -> np.ndarray:
245
+ if self._state is None:
246
+ raise RuntimeError("AccessFilter.backward needs a tracked forward first")
247
+ idxs, choice = self._state
248
+ flat = np.arange(int(np.prod(self.dims))).reshape(self.dims)
249
+ strides = [self.dims[1] * self.dims[2], self.dims[2], 1]
250
+ coords = np.indices(self.dims)
251
+ out = np.zeros(flat.size)
252
+ for k, (a, _) in enumerate(self.directions):
253
+ g = grad if choice is None else np.where(choice == k, grad, 0.0)
254
+ target = flat + (idxs[k] - coords[a]) * strides[a]
255
+ out += np.bincount(target.ravel(), weights=g.ravel(), minlength=flat.size)
256
+ return out.reshape(self.dims)
257
+
258
+
259
+ def _disk(r_cells: float, axis: int) -> np.ndarray:
260
+ k = int(np.floor(r_cells + 1e-9))
261
+ i, j = np.mgrid[-k:k + 1, -k:k + 1]
262
+ d = (i * i + j * j) <= r_cells * r_cells + 1e-9
263
+ return np.expand_dims(d, axis)
264
+
265
+
266
+ def machinable(solid: np.ndarray, access: Access, pitch: float) -> np.ndarray:
267
+ """The void a cutter (or mold half) can reach: exact, on a bool grid.
268
+ Returns the bool grid of cells it cannot reach; with a tool radius the
269
+ cutter is a flat end mill of that radius entering along each direction,
270
+ and with a step-down each pocket's depth is rounded up to it (floors on
271
+ a grid of levels from the stock face)."""
272
+ r = (access.tool_radius or 0.0) / pitch
273
+ pad = int(np.ceil(r)) + 1
274
+ s = np.pad(solid, pad, constant_values=False)
275
+ reach_any = np.zeros_like(s)
276
+ step = (access.step / pitch) if access.step else None
277
+ for a, sign in access.directions:
278
+ void = ~s
279
+ disk = _disk(r, a) if r >= 1.0 else None
280
+ centre_ok = ndimage.binary_erosion(void, structure=disk, border_value=1) if disk is not None else void
281
+ g = np.moveaxis(centre_ok, a, -1)
282
+ if sign > 0:
283
+ g = g[..., ::-1]
284
+ reach = np.logical_and.accumulate(g, axis=-1)
285
+ if step:
286
+ # depth reached inside the stock, rounded up to a floor level
287
+ # (so less is cut); a through-cut stays through
288
+ n = reach.shape[-1]
289
+ depth = reach.sum(axis=-1)
290
+ level = np.floor(np.floor(np.maximum(depth - pad, 0) / step + 1e-9) * step + 1e-9).astype(int) + pad
291
+ depth = np.where((depth >= n) | (depth < pad), depth, level)
292
+ reach = np.arange(n) < depth[..., None]
293
+ if sign > 0:
294
+ reach = reach[..., ::-1]
295
+ reach = np.moveaxis(reach, -1, a)
296
+ cut = ndimage.binary_dilation(reach, structure=disk) if disk is not None else reach
297
+ reach_any |= cut
298
+ blocked = ~reach_any
299
+ return blocked[pad:-pad, pad:-pad, pad:-pad]
300
+
301
+
302
+ # --- pattern and ties -----------------------------------------------------
303
+
304
+ def tie_groups(domain, extrusion: int | None = None, pattern: Pattern | None = None):
305
+ """Label per element: cells with one label share one design value (the
306
+ columns of an extruded part, the sectors of a circular pattern, or
307
+ both). None when nothing is tied."""
308
+ mesh = domain.mesh
309
+ if extrusion is None and pattern is None:
310
+ return None
311
+ if pattern is None:
312
+ a = extrusion
313
+ others = [i for i in range(3) if i != a]
314
+ ij = mesh.elem_ijk[:, others]
315
+ _, columns = np.unique(ij[:, 0] * (domain.dims[others[1]] + 1) + ij[:, 1], return_inverse=True)
316
+ return columns
317
+ from scipy.sparse import coo_matrix
318
+ from scipy.sparse.csgraph import connected_components
319
+
320
+ n = mesh.n_elem
321
+ rows, cols = [], []
322
+ if extrusion is not None:
323
+ cols_ = tie_groups(domain, extrusion, None)
324
+ _, first = np.unique(cols_, return_index=True)
325
+ rows.append(np.arange(n))
326
+ cols.append(first[cols_])
327
+ lut = np.full(domain.dims, -1, np.int64)
328
+ lut[tuple(mesh.elem_ijk.T)] = np.arange(n)
329
+ pts = domain.origin + (mesh.elem_ijk + 0.5) * domain.pitch
330
+ c = pattern.center if pattern.center is not None else domain.origin + 0.5 * np.asarray(domain.dims) * domain.pitch
331
+ a = pattern.axis
332
+ u_ax, v_ax = (a + 1) % 3, (a + 2) % 3
333
+ q = pts - c
334
+ theta = np.mod(np.arctan2(q[:, v_ax], q[:, u_ax]), 2 * np.pi)
335
+ sector = 2 * np.pi / pattern.count
336
+ k = np.floor(theta / sector + 1e-9)
337
+ back = -k * sector
338
+ cu, sv = np.cos(back), np.sin(back)
339
+ p0 = pts.copy()
340
+ p0[:, u_ax] = c[u_ax] + cu * q[:, u_ax] - sv * q[:, v_ax]
341
+ p0[:, v_ax] = c[v_ax] + sv * q[:, u_ax] + cu * q[:, v_ax]
342
+ ijk0 = np.floor((p0 - domain.origin) / domain.pitch).astype(int)
343
+ ok = np.all((ijk0 >= 0) & (ijk0 < np.asarray(domain.dims)), axis=1)
344
+ rep = np.full(n, -1)
345
+ rep[ok] = lut[tuple(ijk0[ok].T)]
346
+ has = rep >= 0
347
+ rows.append(np.arange(n)[has])
348
+ cols.append(rep[has])
349
+ r = np.concatenate(rows)
350
+ cc = np.concatenate(cols)
351
+ g = coo_matrix((np.ones(len(r)), (r, cc)), shape=(n, n))
352
+ _, labels = connected_components(g, directed=False)
353
+ return labels
354
+
355
+
356
+ # --- maximum member -------------------------------------------------------
357
+
358
+ class MaxMember:
359
+ """Local volume penalty (module docstring). Call with the projected
360
+ densities; returns the excess over ALPHA (as a share of 1 - ALPHA) at
361
+ the worst ball and the 95th percentile over the solid, and the penalty
362
+ gradient per element."""
363
+
364
+ def __init__(self, domain, max_member: float, design: np.ndarray):
365
+ from scipy.signal import fftconvolve
366
+
367
+ self._conv = lambda f: fftconvolve(f, self.kernel, mode="same")
368
+ self.domain = domain
369
+ r = _BALL * float(max_member) / domain.pitch
370
+ k = int(np.ceil(r))
371
+ i, j, l = np.mgrid[-k:k + 1, -k:k + 1, -k:k + 1]
372
+ self.kernel = ((i * i + j * j + l * l) <= r * r).astype(float)
373
+ self.ijk = tuple(domain.mesh.elem_ijk.T)
374
+ self.design = design
375
+ # normalized by the whole ball: the outside and keep-outs count as
376
+ # void, so a flange along a face reads its own thickness (normalized
377
+ # over the cells that exist, it read as if mirrored: twice as thick)
378
+ self.norm = float(self.kernel.sum())
379
+
380
+ def local(self, xp: np.ndarray) -> np.ndarray:
381
+ f = np.zeros(self.domain.dims)
382
+ f[self.ijk] = np.where(self.design, xp, 0.0) # preserved bosses are not members
383
+ return np.clip(self._conv(f)[self.ijk], 0.0, None) / self.norm
384
+
385
+ def __call__(self, xp: np.ndarray):
386
+ """(worst excess, 95th percentile excess over the solid, gradient)."""
387
+ rho = self.local(xp)
388
+ v = np.where(self.design, np.maximum(rho - ALPHA, 0.0) / (1.0 - ALPHA), 0.0)
389
+ solid = self.design & (xp > 0.5)
390
+ over = float(np.percentile(v[solid], 95)) if solid.any() else 0.0
391
+ g = np.zeros(self.domain.dims)
392
+ g[self.ijk] = 2.0 * v / (1.0 - ALPHA) / self.norm
393
+ grad = np.clip(self._conv(g)[self.ijk], 0.0, None) * self.design
394
+ return float(v.max()), over, grad
395
+
396
+
397
+ def member_thickness(solid: np.ndarray, preserved: np.ndarray, pitch: float) -> float:
398
+ """Thickest member (mm) of a bool grid, from the distance transform:
399
+ twice the largest inscribed ball in the solid that keeps clear of
400
+ preserved geometry (a boss is the user's, not a member)."""
401
+ if not solid.any():
402
+ return 0.0
403
+ d = ndimage.distance_transform_edt(np.pad(solid, 1)) [1:-1, 1:-1, 1:-1]
404
+ dp = ndimage.distance_transform_edt(~preserved) if preserved.any() else np.full(solid.shape, np.inf)
405
+ ok = solid & ~preserved & (d <= dp)
406
+ if not ok.any():
407
+ return 0.0
408
+ return float(max(2.0 * d[ok].max() - 1.0, 1.0) * pitch)
409
+
410
+
411
+ # --- report ---------------------------------------------------------------
412
+
413
+ def report(study, domain, mesh) -> dict:
414
+ """What the delivered mesh does against the study's manufacturing
415
+ constraints, measured on its voxelization at the design grid."""
416
+ from .verify import voxelize
417
+
418
+ out = {}
419
+ access, pattern, mx = study.access, study.pattern, study.max_member
420
+ if access is None and pattern is None and mx is None:
421
+ return out
422
+ lo = domain.origin
423
+ hi = lo + np.asarray(domain.dims) * domain.pitch
424
+ solid, _ = voxelize(mesh, domain.pitch, box=(lo, hi))
425
+ v = domain.pitch ** 3
426
+ if access is not None:
427
+ rough = Access(access.kind, access.directions) # exact access, no cutter radius or floors
428
+ blocked = machinable(solid, rough, domain.pitch)
429
+ hidden = blocked & ~solid
430
+ keep = np.zeros(domain.dims, bool)
431
+ if study.keep_out:
432
+ ctr = (domain.origin + (np.indices(domain.dims).reshape(3, -1).T + 0.5) * domain.pitch)
433
+ for r in study.keep_out:
434
+ keep |= r.shape.contains(ctr).reshape(domain.dims)
435
+ # the cells its surface cuts through are its shadow too
436
+ keep = ndimage.binary_dilation(keep, iterations=1)
437
+ out["access"] = access.summary() | {
438
+ "undercutVolume": float(hidden.sum() * v),
439
+ "undercutInKeepOuts": float((hidden & keep).sum() * v),
440
+ }
441
+ if pattern is not None:
442
+ out["pattern"] = pattern.summary()
443
+ if mx is not None:
444
+ pres = domain.full_grid(domain.preserve.astype(float)) > 0.5
445
+ out["maxMember"] = {"limit": mx, "measured": member_thickness(solid, pres, domain.pitch)}
446
+ return out