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/infill.py ADDED
@@ -0,0 +1,376 @@
1
+ """
2
+ Stress-graded FDM infill: the slicer grades the part, loadpath says where.
3
+
4
+ A slicer already knows how to print a part sparse inside and solid at the
5
+ skin. What it does not know is where the part can afford to be sparse, so
6
+ people print everything at 15-20% and hope, or at 100% and wait. This sizes
7
+ a ladder of (infill density, perimeter count) levels region by region from
8
+ the part's stress, then hands the regions to the slicer as modifier volumes
9
+ in a 3MF (threemf.write_3mf): no lattice mesh, no million triangles, and the
10
+ slicer's own toolpaths.
11
+
12
+ The sizing is the homogenized fully-stressed design lattice.py uses for TPMS
13
+ cores, with the slicer's construction in place of a sheet lattice:
14
+
15
+ - each analysis cell is part skin, part infill. The skin is the perimeters
16
+ (count x line width) and is solid; the rest is gyroid infill at the
17
+ level's density rho, a sheet of modulus E C rho^n whose walls carry
18
+ A rho^-m times the macro stress (lattice.SHEETS: PrusaSlicer's gyroid is
19
+ the same surface drawn one extrusion wide), at a knockdown for walls that
20
+ are a single line with a seam every layer;
21
+ - a voxel FEA of the graded part gives each cell's strain; each cell takes
22
+ the lowest level whose skin and infill both stay under strength / (SF x
23
+ verify's 1.15 allowance), holding the cell's macro stress (fully stressed
24
+ design), and the analysis repeats as the load moves onto what got stiffer;
25
+ - material round preserved shapes, keep-outs, loads and supports (a
26
+ `collar`) stays solid: a bolt boss is not where to save filament.
27
+
28
+ The verified safety factor of the solid part is scaled by how much the
29
+ graded part reads below the solid one on the same grid and loads, so the
30
+ grading carries the delivered part's refined verification (submodels,
31
+ interlayer check) instead of the coarser analysis grid's absolute numbers.
32
+ Deflection is scaled the same way and the whole ladder floor steps up while
33
+ it breaks `goal.maxDeflection`.
34
+
35
+ The infill constants are estimates: sheet-gyroid unit-cell FEA, and a 0.8
36
+ knockdown for single-line walls. Printed coupons (the calibration loop) are
37
+ what will tighten them.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ from dataclasses import dataclass
42
+
43
+ import numpy as np
44
+ from scipy import ndimage
45
+
46
+ from .lattice import SHEETS, _block_mean, _collar_distance, _signed_distance
47
+ from .spec import Study
48
+
49
+ __all__ = ["GradeOptions", "grade_infill", "DEFAULT_LEVELS"]
50
+
51
+ # (infill %, perimeters) from sparsest to densest; solid (100%) is always
52
+ # the level above the last. One perimeter count throughout by default: a
53
+ # modifier that changes the perimeters makes PrusaSlicer split the layer
54
+ # there and run perimeters round the cut, inside the part (the shelf
55
+ # bracket's external perimeters went 6 -> 16 cm^3 and the print took 45 min
56
+ # longer than with the same infill at 3 walls throughout). Changing only the
57
+ # density merges the regions' perimeters.
58
+ DEFAULT_LEVELS = ((10, 3), (15, 3), (25, 3), (40, 3), (60, 3))
59
+
60
+
61
+ @dataclass
62
+ class GradeOptions:
63
+ levels: tuple = DEFAULT_LEVELS
64
+ line_width: float = 0.45 # mm per perimeter (the profile's perimeter width)
65
+ safety_factor: float | None = None # default: the study goal's, else 2
66
+ collar: float = 2.0 # mm of solid round preserves, keep-outs, loads, supports
67
+ knockdown: float = 0.8 # infill wall strength / solid strength
68
+ analysis_cells: int = 25_000
69
+ iterations: int = 12
70
+ modifier_faces: int = 20_000 # per modifier mesh, after decimation
71
+
72
+ @classmethod
73
+ def from_spec(cls, spec, safety_factor: float | None = None) -> "GradeOptions":
74
+ spec = dict(spec or {})
75
+ o = cls()
76
+ if "levels" in spec:
77
+ lv = []
78
+ for item in spec["levels"]:
79
+ if isinstance(item, dict):
80
+ d, p = item.get("infill"), item.get("perimeters", 3)
81
+ else:
82
+ d, p = item
83
+ d, p = float(d), int(p)
84
+ if not 0.0 < d < 100.0 or p < 1:
85
+ raise ValueError("slicer.grade.levels are [infill % (0..100, exclusive), perimeters >= 1]")
86
+ lv.append((d, p))
87
+ if not lv:
88
+ raise ValueError("slicer.grade.levels is empty")
89
+ lv.sort()
90
+ o.levels = tuple(lv)
91
+ for key, attr in (("lineWidth", "line_width"), ("collar", "collar"), ("knockdown", "knockdown"),
92
+ ("analysisCells", "analysis_cells")):
93
+ if key in spec:
94
+ setattr(o, attr, type(getattr(o, attr))(spec[key]))
95
+ if not 0.0 < o.knockdown <= 1.0:
96
+ raise ValueError("slicer.grade.knockdown is a fraction of the solid strength, in (0, 1]")
97
+ o.safety_factor = float(spec.get("safetyFactor") or safety_factor or 2.0)
98
+ return o
99
+
100
+
101
+ def _level_props(o: GradeOptions, kind: str = "gyroid"):
102
+ """Per level (the solid one last): relative density, skin thickness mm,
103
+ sheet modulus factor and infill wall-stress factor."""
104
+ s = SHEETS[kind]
105
+ rho = np.array([d / 100.0 for d, _ in o.levels] + [1.0])
106
+ skin = np.array([p * o.line_width for _, p in o.levels] + [o.levels[-1][1] * o.line_width])
107
+ emod = np.where(rho >= 1.0, 1.0, s["C"] * rho ** s["n"])
108
+ wall = np.where(rho >= 1.0, 1.0, s["A"] * rho ** -s["m"])
109
+ return rho, skin, emod, wall
110
+
111
+
112
+ def grade_infill(study: Study, mesh, verified: dict | None = None, opts: GradeOptions | None = None,
113
+ on_progress=None) -> dict:
114
+ """Size per-region infill for `mesh` (the delivered part). `verified` is
115
+ its verification report (safety factor and per-case deflection to
116
+ scale). Returns {"objectSettings", "modifiers" (meshes and settings for
117
+ threemf.write_3mf), "levels", "safetyFactor", "predicted", ...}."""
118
+ from scipy.spatial import cKDTree
119
+
120
+ from .domain import boundary_conditions, gravity_force
121
+ from .fem import HexMesh, Solver, Stiffness, von_mises
122
+ from .verify import STRESS_ALLOWANCE, voxelize
123
+
124
+ o = opts or GradeOptions(safety_factor=study.goal.safety_factor or 2.0)
125
+ mat = study.material
126
+ rho_l, skin_l, emod_l, wall_l = _level_props(o)
127
+ n_lv = len(rho_l)
128
+ solid_lv = n_lv - 1
129
+
130
+ vol = float(mesh.volume)
131
+ pitch = (vol / o.analysis_cells) ** (1.0 / 3.0)
132
+ r = 3
133
+ step = pitch / r
134
+ lo = np.asarray(mesh.bounds[0], float) - pitch
135
+ hi = np.asarray(mesh.bounds[1], float) + pitch
136
+ dims = tuple(int(np.ceil((hi[i] - lo[i]) / pitch)) * r for i in range(3))
137
+ solid, origin = voxelize(mesh, step, box=(lo, lo + np.asarray(dims) * step))
138
+ if solid.shape != dims:
139
+ out = np.zeros(dims, bool)
140
+ sl = tuple(slice(0, min(a, b)) for a, b in zip(solid.shape, dims))
141
+ out[sl] = solid[sl]
142
+ solid = out
143
+ depth = -_signed_distance(solid, step) # mm inside the surface, > 0 inside
144
+ f_in = _block_mean(solid.astype(np.float32), r)
145
+ # share of each analysis cell's material that is skin at each level
146
+ fs_lv = []
147
+ for t in skin_l:
148
+ fs_lv.append(_block_mean((solid & (depth < t)).astype(np.float32), r) / np.maximum(f_in, 1e-6))
149
+ active = f_in >= 0.5
150
+ lbl, _ = ndimage.label(active)
151
+ ijk = np.argwhere(active)
152
+ hm = HexMesh(ijk, pitch, lo)
153
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
154
+ sup = np.unique(lbl[tuple(ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
155
+ keep = np.isin(lbl[tuple(ijk.T)], sup)
156
+ if not keep.all():
157
+ ijk = ijk[keep]
158
+ hm = HexMesh(ijk, pitch, lo)
159
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
160
+ fs = np.stack([np.clip(f[tuple(ijk.T)], 0.0, 1.0) for f in fs_lv], axis=1) # (ne, levels)
161
+ fs[:, solid_lv] = 1.0
162
+ centers = hm.elem_centers()
163
+ collar = np.zeros(hm.n_elem, bool)
164
+ if o.collar > 0:
165
+ collar = _collar_distance(study, centers, o.collar) < 0
166
+ # the stiffness of each cell at each level, as a share of solid
167
+ e_lv = fs + (1.0 - fs) * emod_l[None, :]
168
+
169
+ stiff = Stiffness(hm, mat.nu, bc.fixed)
170
+ solver = Solver(hm, rtol=1e-6, maxiter=3000)
171
+ contact = bc.support_nodes.copy()
172
+ for ln in bc.case_load_nodes:
173
+ contact |= ln
174
+ band = 1.5 * pitch
175
+ near = cKDTree(hm.node_xyz[contact]).query(centers, k=1, distance_upper_bound=band)[0] < np.inf
176
+ fg = gravity_force(hm, np.ones(hm.n_elem), bc.gravity, bc.fixed) if bc.gravity is not None else 0.0
177
+ loads = [bc.loads[ci] + fg for ci in range(len(study.load_cases))]
178
+ allow0 = mat.strength / (o.safety_factor * STRESS_ALLOWANCE)
179
+ rows = np.arange(hm.n_elem)
180
+
181
+ last = [None]
182
+
183
+ def analyze(lev):
184
+ e = e_lv[rows, lev]
185
+ k = stiff.assemble(mat.E * e)
186
+ if bc.springs is not None:
187
+ k = (k + mat.E * bc.springs).tocsr()
188
+ solver.invalidate() # a new stiffness contrast wants a new hierarchy
189
+ us = solver.solve_many(k, loads, last[0])
190
+ last[0] = us
191
+ se = np.max([von_mises(stiff.stress(u, mat.E)) for u in us], axis=0)
192
+ defl = []
193
+ for ci, u in enumerate(us):
194
+ ln = bc.case_load_nodes[ci]
195
+ d = u.reshape(-1, 3)
196
+ defl.append(float(np.linalg.norm(d[ln].mean(axis=0))) if ln.any() else
197
+ float(np.linalg.norm(d, axis=1).max()))
198
+ # the skin carries the strain at full modulus; the infill's walls
199
+ # carry A rho^-m times its macro stress (E* / E of the strain stress)
200
+ skin_s = np.where(fs[rows, lev] > 0, se, 0.0)
201
+ inf_s = np.where(fs[rows, lev] < 1, wall_l[lev] * emod_l[lev] * se / o.knockdown, 0.0)
202
+ peak = float(np.max(np.where(near, 0.0, np.maximum(skin_s, inf_s))))
203
+ return se, defl, peak
204
+
205
+ def report_sf(peak):
206
+ return mat.strength / (STRESS_ALLOWANCE * peak) if peak > 0 else float("inf")
207
+
208
+ # the solid part on the same grid: what the graded one is compared with
209
+ se_solid, defl_solid, peak_solid = analyze(np.full(hm.n_elem, solid_lv))
210
+ sf_solid = report_sf(peak_solid)
211
+ ver_sf = (verified or {}).get("safetyFactor")
212
+ ver_defl = [c.get("deflection") for c in (verified or {}).get("cases", [])]
213
+ target_defl = study.goal.max_deflection
214
+
215
+ def on_verified_scale(sf_pred):
216
+ # the graded part's safety factor on the verified part's scale
217
+ return ver_sf * sf_pred / sf_solid if ver_sf is not None else sf_pred
218
+
219
+ # Start from the solid part's strain and let each cell find its level;
220
+ # the allowable then moves (geometrically damped) until the graded part
221
+ # reads the target, as the load shifts onto what stayed stiff. Each
222
+ # cell's macro stress is held while it picks a level (fully stressed
223
+ # design, as lattice.py sizes TPMS): at a fixed strain a denser gyroid's
224
+ # walls carry more, never less, so a strain-held pick only ever chose
225
+ # the sparsest level or solid.
226
+ lev = np.full(hm.n_elem, solid_lv)
227
+ se = se_solid
228
+ floor = 0
229
+ history = []
230
+ allow = allow0 * (sf_solid / ver_sf) if ver_sf is not None else allow0
231
+ grid = np.zeros(active.shape)
232
+ best = None # (fill, levels, se, defl, peak) of the sparsest passing pass
233
+ short = 1.0
234
+ prev = lev
235
+ for it in range(o.iterations):
236
+ sig = (se * e_lv[rows, lev])[:, None] # macro stress, MPa
237
+ skin_s = sig / e_lv # strain-equivalent at each level
238
+ inf_s = wall_l[None, :] * emod_l[None, :] * skin_s / o.knockdown
239
+ ok = (np.where(fs < 1, inf_s, 0.0) <= allow) & (np.where(fs > 0, skin_s, 0.0) <= allow)
240
+ ok[:, solid_lv] = True
241
+ need = np.argmax(ok, axis=1)
242
+ # a concentration spreads over a cell before the slicer's regions can
243
+ # follow it
244
+ grid[:] = 0
245
+ grid[tuple(ijk.T)] = need
246
+ need = ndimage.maximum_filter(grid, size=3)[tuple(ijk.T)].astype(int)
247
+ # hysteresis, or levels flicker: after a pass under the target only
248
+ # raise levels; after one that held it, drop at most one
249
+ if it == 0:
250
+ new = need
251
+ elif short < 1.0:
252
+ new = np.maximum(need, lev)
253
+ else:
254
+ new = np.maximum(need, lev - 1)
255
+ new = np.maximum(new, floor)
256
+ new[collar] = solid_lv
257
+ se, defl, peak = analyze(new)
258
+ lev = new
259
+ sf_pred = report_sf(peak)
260
+ short = on_verified_scale(sf_pred) / o.safety_factor
261
+ ratio_d = [d / max(ds, 1e-12) for d, ds in zip(defl, defl_solid)]
262
+ stiff_ok = not (target_defl and ver_defl) or \
263
+ max(v * rr for v, rr in zip(ver_defl, ratio_d) if v is not None) <= target_defl
264
+ fill = float(np.mean(fs[rows, lev] + (1 - fs[rows, lev]) * rho_l[lev]))
265
+ history.append({"iteration": it, "predictedSF": on_verified_scale(sf_pred), "floor": floor,
266
+ "fill": fill, "solidCells": float(np.mean(lev == solid_lv))})
267
+ if on_progress is not None:
268
+ on_progress({"stage": "grade", **history[-1]}, None)
269
+ if short >= 1.0 and stiff_ok and (best is None or fill < best[0]):
270
+ best = (fill, lev.copy(), se, defl, peak)
271
+ if not stiff_ok:
272
+ if floor >= solid_lv:
273
+ break
274
+ floor += 1
275
+ continue
276
+ if 1.0 <= short < 1.06:
277
+ break
278
+ allow *= float(np.clip(short, 0.8, 0.99)) if short < 1.0 else float(np.clip(short, 1.0, 2.0)) ** 0.75
279
+ prev = lev
280
+ if best is None:
281
+ # nothing graded held the target: solid, as verified
282
+ lev = np.full(hm.n_elem, solid_lv)
283
+ se, defl, peak = se_solid, defl_solid, peak_solid
284
+ else:
285
+ _, lev, se, defl, peak = best
286
+ # reported on the verified part's scale
287
+ knock = sf_solid / report_sf(peak) if peak > 0 else 1.0
288
+ sf = ver_sf / knock if ver_sf is not None else report_sf(peak)
289
+ ratio_d = [d / max(ds, 1e-12) for d, ds in zip(defl, defl_solid)]
290
+
291
+ cell_vol = pitch ** 3 * f_in[tuple(ijk.T)]
292
+ fill = fs[rows, lev] + (1.0 - fs[rows, lev]) * rho_l[lev]
293
+ est_volume = float((cell_vol * fill).sum())
294
+ lv_grid = np.full(active.shape, -1, int)
295
+ lv_grid[tuple(ijk.T)] = lev
296
+ base = int(lev[~collar].min()) if (~collar).any() else solid_lv
297
+ modifiers = _modifier_meshes(lv_grid, lo, pitch, base, n_lv, o)
298
+ usage = {(_label(o, i)): float(cell_vol[lev == i].sum() / cell_vol.sum()) for i in range(n_lv)
299
+ if (lev == i).any()}
300
+ obj = _settings(o, base)
301
+ skin_t = skin_l[base]
302
+ obj.update(top_solid_min_thickness=f"{skin_t:.2f}", bottom_solid_min_thickness=f"{skin_t:.2f}")
303
+ return {
304
+ "levels": [{"infill": float(rho_l[i] * 100), "perimeters": (o.levels[i][1] if i < solid_lv else None),
305
+ "label": _label(o, i)} for i in range(n_lv)],
306
+ "base": _label(o, base),
307
+ "volumeShare": usage,
308
+ "objectSettings": obj,
309
+ "modifiers": modifiers,
310
+ "safetyFactor": sf,
311
+ "targetSafetyFactor": o.safety_factor,
312
+ "solidSafetyFactor": ver_sf,
313
+ "knockdown": knock,
314
+ "deflectionRatio": ratio_d,
315
+ "deflection": [v * rr for v, rr in zip(ver_defl, ratio_d)] if ver_defl else None,
316
+ "estimatedVolume": est_volume,
317
+ "estimatedMass": est_volume * mat.density / 1000.0,
318
+ "analysisPitch": pitch,
319
+ "analysisCells": int(hm.n_elem),
320
+ "predicted": {"solid": sf_solid, "graded": report_sf(peak)},
321
+ "history": history,
322
+ "assumptions": {"pattern": "gyroid", "sheet": SHEETS["gyroid"], "knockdown": o.knockdown,
323
+ "lineWidth": o.line_width, "collar": o.collar},
324
+ }
325
+
326
+
327
+ def _label(o: GradeOptions, i: int) -> str:
328
+ if i >= len(o.levels):
329
+ return "solid"
330
+ d, p = o.levels[i]
331
+ return f"{d:g}% x{p}"
332
+
333
+
334
+ def _settings(o: GradeOptions, i: int, walls: bool = True) -> dict:
335
+ """PrusaSlicer keys for level i; `walls` False leaves the perimeters to
336
+ the object (a modifier that keeps the object's count)."""
337
+ if i >= len(o.levels):
338
+ out = {"fill_density": "100%", "fill_pattern": "rectilinear", "perimeters": str(o.levels[-1][1])}
339
+ else:
340
+ d, p = o.levels[i]
341
+ out = {"fill_density": f"{d:g}%", "fill_pattern": "gyroid", "perimeters": str(p)}
342
+ if not walls:
343
+ out.pop("perimeters")
344
+ return out
345
+
346
+
347
+ def _modifier_meshes(lv_grid, lo, pitch, base, n_lv, o: GradeOptions) -> list:
348
+ """One closed mesh per level above the base, around every cell at that
349
+ level or above (nested: later, denser modifiers win in the slicer), a
350
+ cell larger all round so a region is never short of what it needs."""
351
+ import trimesh
352
+ from skimage.measure import marching_cubes
353
+
354
+ out = []
355
+ for i in range(base + 1, n_lv):
356
+ m = lv_grid >= i
357
+ if not (lv_grid == i).any():
358
+ continue # the same region as the next level up
359
+ m = ndimage.binary_dilation(m, iterations=1)
360
+ f = ndimage.gaussian_filter(np.pad(m, 2).astype(np.float32), 0.7)
361
+ verts, faces, _, _ = marching_cubes(f, 0.5, spacing=(pitch,) * 3)
362
+ verts = verts + lo - 2.0 * pitch + 0.5 * pitch
363
+ tm = trimesh.Trimesh(verts, faces[:, ::-1], process=True)
364
+ if len(tm.faces) > o.modifier_faces:
365
+ try:
366
+ import fast_simplification
367
+
368
+ v2, f2 = fast_simplification.simplify(tm.vertices, tm.faces, 1.0 - o.modifier_faces / len(tm.faces))
369
+ tm = trimesh.Trimesh(v2, f2, process=True)
370
+ except ImportError:
371
+ pass
372
+ if tm.volume < 0:
373
+ tm.invert()
374
+ walls = o.levels[min(i, len(o.levels) - 1)][1] != o.levels[min(base, len(o.levels) - 1)][1]
375
+ out.append({"name": f"loadpath {_label(o, i)}", "mesh": tm, "settings": _settings(o, i, walls)})
376
+ return out