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/spec.py ADDED
@@ -0,0 +1,1072 @@
1
+ """
2
+ The study: everything a topology optimization needs, as one JSON document.
3
+
4
+ Units are fixed so nobody has to guess: millimetres, newtons, megapascals
5
+ (N/mm^2), grams per cubic centimetre. A study an agent writes looks like:
6
+
7
+ {
8
+ "name": "arm-link",
9
+ "designSpace": [{"type": "box", "min": [0, -20, -12], "max": [160, 20, 12]}],
10
+ "preserve": [
11
+ {"id": "shoulder", "shape": {"type": "cylinder", "center": [0, 0, 0],
12
+ "axis": "z", "length": 24, "r": 16}}
13
+ ],
14
+ "keepOut": [
15
+ {"id": "shoulder-bore", "shape": {"type": "cylinder", "center": [0, 0, 0],
16
+ "axis": "z", "length": 30, "r": 11}}
17
+ ],
18
+ "supports": [{"region": "shoulder-bore"}],
19
+ "loadCases": [
20
+ {"name": "payload", "loads": [{"region": "wrist-bore", "force": [0, 0, -40]}]}
21
+ ],
22
+ "material": "pa12",
23
+ "goal": {"volumeFraction": 0.25},
24
+ "minMember": 3.0,
25
+ "symmetry": ["z"],
26
+ "resolution": {"maxElements": 80000}
27
+ }
28
+
29
+ A load or support names a region: a preserve or keep-out id, or an inline
30
+ shape. Loading a keep-out (a bore) means "through whatever material lines it",
31
+ which is how a bolt or a shaft actually pushes on a part.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import dataclass, field
36
+ from typing import Any
37
+
38
+ import numpy as np
39
+
40
+ from .am import parse_build_direction
41
+ from .materials import Material, material_from_spec
42
+ from .shapes import Shape, shape_from_spec
43
+
44
+ __all__ = ["Region", "Load", "LoadCase", "Support", "Goal", "Study", "study_from_dict"]
45
+
46
+ DEFAULT_MAX_ELEMENTS = 60_000
47
+ # mm: the member size the filter holds a study to when it names neither a
48
+ # minMember nor a process: about three FDM perimeters, and under FDM's own
49
+ # floor (2 x its 0.8 mm minimum feature) on purpose. At 1.6 the filter grew
50
+ # on grids coarser than a millimetre too (the mbb-beam eval, 1.29 mm cells,
51
+ # went from passing at SF 1.52 to a search that stalled at 1.49); at 1.4 it
52
+ # only acts below ~1.2 mm cells, where the sub-millimetre skins come from.
53
+ DEFAULT_MIN_MEMBER = 1.4
54
+ # Hard ceiling regardless of what a study asks for. From 100k cells the
55
+ # optimizer is matrix-free multigrid (mg.py), ~1.5 KB per cell: an 845k-cell
56
+ # study ran end to end in 20 min and 1.6 GB on a 4-core box. Past a few
57
+ # million the meshing and verification steps, not the solver, set the limit.
58
+ MAX_ELEMENTS_CEILING = 4_000_000
59
+
60
+
61
+ @dataclass
62
+ class Region:
63
+ id: str
64
+ shape: Shape
65
+ # keepOut only: false for a clearance volume (a drive, a spring's
66
+ # swept space) that may end against material, unlike a bore a screw or
67
+ # pin has to pass through (verify's closed-bore check skips it)
68
+ bore: bool = True
69
+ # keepOut only: mm of material kept round it, pinned solid (a duct's or
70
+ # a plenum's wall; see pressure.py). Set for every pressurized keep-out.
71
+ wall: float | None = None
72
+
73
+
74
+ @dataclass
75
+ class Load:
76
+ region: str | None
77
+ shape: Shape | None
78
+ force: np.ndarray # N, total over the region
79
+ # N·mm about the region's centroid (a motor's torque on a hub).
80
+ moment: np.ndarray = field(default_factory=lambda: np.zeros(3))
81
+ # "uniform", or "bearing": a shaft pushing on one side of a bore, with
82
+ # the force spread as a cosine over the half facing it.
83
+ distribution: str = "uniform"
84
+ # MPa gauge in the keep-out `region` names, on the material lining it
85
+ # (pressure.py); 0 = no pressure. Negative is a vacuum.
86
+ pressure: float = 0.0
87
+ # The force's direction is not known: None (as written), "any" (its size
88
+ # in every direction) or "cone" (within `cone` degrees of it). The
89
+ # optimizer and verification design for the worst one (directions.py).
90
+ direction: str | None = None
91
+ cone: float | None = None
92
+
93
+
94
+ @dataclass
95
+ class LoadCase:
96
+ name: str
97
+ loads: list[Load]
98
+ weight: float = 1.0
99
+ # durability (durability.py): times applied over the part's life
100
+ # (fatigue), hours held (creep), and the temperature then (C)
101
+ cycles: float | None = None
102
+ hours: float | None = None
103
+ temperature: float | None = None
104
+
105
+
106
+ @dataclass
107
+ class Support:
108
+ region: str | None
109
+ shape: Shape | None
110
+ fix: tuple[bool, bool, bool] = (True, True, True)
111
+ # A pin through a bore: held radially and axially, free to rotate about
112
+ # the bore's axis (a bolt or a shaft in a plain hole).
113
+ pin: bool = False
114
+
115
+
116
+ @dataclass
117
+ class Goal:
118
+ # Exactly one of these drives the optimizer.
119
+ volume_fraction: float | None = None
120
+ # "As light as possible while every load case keeps this safety factor"
121
+ # and/or "deflects no more than this (mm) at its loads": the study
122
+ # searches over volume fractions, verifying each outcome. A robot arm is
123
+ # usually stiffness-limited long before it is strength-limited.
124
+ safety_factor: float | None = None
125
+ max_deflection: float | None = None
126
+ # "search": compliance designs at a sequence of volume fractions until
127
+ # one passes; "constrained": minimize mass with the limits as MMA
128
+ # constraints, corrected against the verified mesh (constrained.py);
129
+ # "auto": the search, then the constrained method too when the search's
130
+ # part idles (stress utilization under 0.15, not deflection-bound),
131
+ # keeping the lighter (the default; study._run_auto).
132
+ method: str = "auto"
133
+ # How the search's pick is confirmed before delivery: "voxel" re-verifies
134
+ # it on a finer voxel grid; "tet" on a body-fitted tetrahedral mesh
135
+ # (crosscheck.py), whose reading also calibrates the search's voxel
136
+ # stress so it can step down to the real target; "auto" is "tet" when
137
+ # the cross-check is installed and covers the study, else "voxel".
138
+ confirm: str = "auto"
139
+ # Extra fixed fractions to return alongside, for a trade-off gallery.
140
+ outcomes: list[float] = field(default_factory=list)
141
+ # Which passing outcome is delivered: the lightest ("mass"), or for FDM
142
+ # the one a slicer prints fastest ("printTime") or with the least
143
+ # filament, support included ("filament"); see slicer.py.
144
+ minimize: str = "mass"
145
+ # Search this many times from different starting fractions (the goal's
146
+ # volumeFraction times RESTART_STARTS) and deliver the lightest confirmed
147
+ # part. One search settles in the local optimum its start leads to: the
148
+ # same study started at 0.8x and 1.25x delivered 68.7-80.6 g (wall
149
+ # bracket), 140.7-150.1 g (GRIP turret). For a final production part;
150
+ # N times the time. See docs/study-format.md.
151
+ restarts: int = 1
152
+ # A safety factor holds against buckling too: the part's linear
153
+ # buckling load factor (verify._buckling) must reach it, as the stress
154
+ # safety factor must. False reports the factor without failing on it.
155
+ buckling: bool = True
156
+
157
+
158
+ # How the part will be made. Each process sets what the design must respect:
159
+ # whether layers make it weaker across them (FDM bonds layer to layer at a
160
+ # fraction of the in-plane strength), whether overhangs need control, and
161
+ # the smallest feature it holds, and the smallest hole that stays open (unfused
162
+ # powder sinters shut in a narrow SLS/MJF hole, resin cures shut in a narrow
163
+ # SLA one, a nozzle bridges over a narrow FDM one). Values are typical
164
+ # published ranges, chosen conservative; override any of them in the study.
165
+ PROCESSES = {
166
+ "fdm": {"interlayer": 0.6, "minFeature": 0.8, "minHole": 2.0, "overhangs": True,
167
+ "note": "filament: layer bonds ~50-70% of in-plane strength; self-supporting at 45 degrees"},
168
+ "sls": {"interlayer": 0.9, "minFeature": 0.8, "minHole": 2.0, "overhangs": False,
169
+ "note": "powder bed (PA12, PA11): nearly isotropic, no supports; leave powder escape holes in closed cavities"},
170
+ "mjf": {"interlayer": 0.95, "minFeature": 0.8, "minHole": 2.0, "overhangs": False,
171
+ "note": "HP Multi Jet Fusion: as SLS, slightly more isotropic"},
172
+ "sla": {"interlayer": 1.0, "minFeature": 0.5, "minHole": 1.0, "overhangs": False,
173
+ "note": "resin vat: isotropic once cured; supports are added by the slicer"},
174
+ "dlp": {"interlayer": 1.0, "minFeature": 0.5, "minHole": 1.0, "overhangs": False,
175
+ "note": "resin vat, whole-layer exposure (DLP/LCD): as SLA; large cross-sections peel hard"},
176
+ "volumetric": {"interlayer": 1.0, "minFeature": 0.3, "minHole": 0.6, "overhangs": False,
177
+ "note": "volumetric / tomographic resin printing: no layers, no supports"},
178
+ "jetting": {"interlayer": 0.95, "minFeature": 0.4, "minHole": 1.0, "overhangs": False,
179
+ "note": "material jetting (PolyJet): photopolymer droplets cured layer by layer, several materials "
180
+ "mixed voxel by voxel; gel support everywhere, so no overhang limit; nearly isotropic"},
181
+ "lpbf": {"interlayer": 0.85, "minFeature": 0.4, "minHole": 1.0, "overhangs": True,
182
+ "note": "laser powder-bed fusion metal (AlSi10Mg, Ti64, 316L): strength across layers ~85-90% "
183
+ "as built; overhangs past 45 degrees need supports, which cannot come out of closed "
184
+ "channels; bridges only ~2 mm; enclosed powder must drain"},
185
+ }
186
+
187
+
188
+ @dataclass
189
+ class Process:
190
+ kind: str
191
+ # (axis, sign) or a unit vector the layers stack along, if layered;
192
+ # "auto" until orient.choose_build_direction picks one
193
+ build: Any
194
+ interlayer: float # strength across layers / in-plane strength
195
+ min_feature: float # mm
196
+ overhangs: bool # design self-supporting along `build`
197
+ # stiffness across layers / in-plane (modulus, shear); None = the
198
+ # process default in am._LAYER_STIFFNESS
199
+ ez: float | None = None
200
+ gz: float | None = None
201
+ # what the optimizer designs with: False = isotropic (verification
202
+ # always uses the layered stiffness), True = the physical layered
203
+ # stiffness, "bond" = across-layer terms cut to the bond's strength
204
+ # ratio (am.layered_elasticity). Neither layered option beat isotropic
205
+ # reliably on the motor mount and hook (2026-10-29), so off by default.
206
+ anisotropic: Any = False
207
+ # mm: the smallest powder escape or resin drain hole the part's cavities
208
+ # may rely on (processcheck.RULES default)
209
+ escape_hole: float | None = None
210
+ min_hole: float | None = None # mm
211
+ # mm: longest unsupported bridge the printer manages (printcheck.BRIDGE_SPAN default)
212
+ bridge_span: float | None = None
213
+ # lattice stiffness as printed over designed: None = the published-test
214
+ # fit (lattice_print.print_factor), a number, or "designed" (1)
215
+ lattice_stiffness: Any = None
216
+
217
+
218
+ def _process(spec) -> Process | None:
219
+ if spec is None:
220
+ return None
221
+ if isinstance(spec, str):
222
+ spec = {"type": spec}
223
+ kind = str(spec.get("type", "")).lower()
224
+ if kind not in PROCESSES:
225
+ raise ValueError(f"process.type must be one of {sorted(PROCESSES)}, got {kind!r}")
226
+ base = PROCESSES[kind]
227
+ from .am import parse_build_direction
228
+ raw_build = spec.get("buildDirection", "+z" if kind in ("fdm", "lpbf") else None)
229
+ build = "auto" if isinstance(raw_build, str) and raw_build.strip().lower() == "auto" \
230
+ else parse_build_direction(raw_build)
231
+ inter = float(spec.get("interlayer", base["interlayer"]))
232
+ if not 0.0 < inter <= 1.0:
233
+ raise ValueError("process.interlayer is a fraction of the in-plane strength, in (0, 1]")
234
+ stiff = {}
235
+ for key, name in (("ez", "stiffnessAcrossLayers"), ("gz", "shearAcrossLayers")):
236
+ if spec.get(name) is not None:
237
+ v = float(spec[name])
238
+ if not 0.0 < v <= 1.0:
239
+ raise ValueError(f"process.{name} is a fraction of the in-plane value, in (0, 1]")
240
+ stiff[key] = v
241
+ esc = spec.get("escapeHole")
242
+ if esc is not None and float(esc) <= 0:
243
+ raise ValueError("process.escapeHole is a hole diameter in mm, > 0")
244
+ span = spec.get("bridgeSpan")
245
+ if span is not None and float(span) <= 0:
246
+ raise ValueError("process.bridgeSpan is the longest bridge the printer prints cleanly, in mm, > 0")
247
+ lat_k = spec.get("latticeStiffness")
248
+ if lat_k is not None:
249
+ if isinstance(lat_k, str):
250
+ if lat_k.strip().lower() not in ("designed", "as-printed"):
251
+ raise ValueError('process.latticeStiffness is a factor in (0, 1.5], "as-printed" or "designed"')
252
+ lat_k = None if lat_k.strip().lower() == "as-printed" else "designed"
253
+ elif not 0.0 < float(lat_k) <= 1.5:
254
+ raise ValueError("process.latticeStiffness is the printed lattice's stiffness over the designed "
255
+ "geometry's, in (0, 1.5]")
256
+ else:
257
+ lat_k = float(lat_k)
258
+ return Process(kind, build, inter, float(spec.get("minFeature", base["minFeature"])),
259
+ bool(spec.get("overhangs", base["overhangs"])),
260
+ anisotropic=_anisotropic(spec.get("anisotropic", False)),
261
+ escape_hole=float(esc) if esc is not None else None,
262
+ min_hole=float(spec.get("minHole", base["minHole"])),
263
+ bridge_span=float(span) if span is not None else None, lattice_stiffness=lat_k, **stiff)
264
+
265
+
266
+ def _drains(spec, process):
267
+ """`drains`: "auto" (default; powder and resin processes and LPBF), true,
268
+ false, or an object of escape.EscapeOptions keys. Resolved to an
269
+ EscapeOptions, or None when off."""
270
+ from .drains import drains_wanted
271
+ from .escape import EscapeOptions
272
+
273
+ if spec in ("auto", None):
274
+ return EscapeOptions.for_process(process) if drains_wanted("auto", process) else None
275
+ if not drains_wanted(spec, process):
276
+ return None
277
+ return EscapeOptions.from_spec(spec if isinstance(spec, dict) else {}, process)
278
+
279
+
280
+ def _card_process(process, mat, spec):
281
+ """A library card that measured across-layer strength and stiffness on
282
+ the study's process (material_library.py) sets the process's factors,
283
+ unless the study sets its own; a calibrated card then overrides both."""
284
+ if process is None or not mat.measured or mat.measured != process.kind:
285
+ return process
286
+ spec = spec if isinstance(spec, dict) else {}
287
+ inter, ez = mat.across_layers()
288
+ if inter is not None and "interlayer" not in spec:
289
+ process.interlayer = inter
290
+ if ez is not None and "stiffnessAcrossLayers" not in spec:
291
+ process.ez = ez
292
+ return process
293
+
294
+
295
+ def _calibrated_process(process, mat_in, spec):
296
+ """A calibrated card (calibration.py) measured its printer's across-layer
297
+ strength and stiffness: use them unless the study sets its own."""
298
+ if process is None or not isinstance(mat_in, str):
299
+ return process
300
+ from .calibration import load_card
301
+ card = load_card(mat_in)
302
+ cp = (card or {}).get("process") or {}
303
+ if cp.get("type") != process.kind:
304
+ return process
305
+ spec = spec or {}
306
+ if "interlayer" not in spec and cp.get("interlayer"):
307
+ process.interlayer = float(cp["interlayer"])
308
+ if "stiffnessAcrossLayers" not in spec and cp.get("stiffnessAcrossLayers"):
309
+ process.ez = float(cp["stiffnessAcrossLayers"])
310
+ # the gyroid coupon measured this printer's lattice against the model
311
+ lat = (card or {}).get("lattice") or {}
312
+ if "latticeStiffness" not in spec and lat.get("stiffnessFactor"):
313
+ process.lattice_stiffness = float(lat["stiffnessFactor"])
314
+ return process
315
+
316
+
317
+ _BUILD_SIM_KEYS = {"layer": "layer", "plate": "plate", "scale": "scale", "release": "release",
318
+ "bedTemperature": "bed", "chamberTemperature": "chamber", "bedStiffness": "bed_stiffness",
319
+ "relax": "relax", "recoaterGap": "recoater_gap", "plateCells": "plate_cells",
320
+ "bedAdhesion": "bed_adhesion", "printLayer": "print_layer", "brim": "brim"}
321
+
322
+
323
+ def _build_sim(spec, process, material):
324
+ """`buildSim`: true or options -> {"setup": buildsim.BuildSetup,
325
+ "compensate": bool, "cells": int}; None when absent or false."""
326
+ if spec is None or spec is False:
327
+ return None
328
+ if spec is True:
329
+ spec = {}
330
+ kind = process.kind if process is not None else None
331
+ if kind not in ("lpbf", "fdm", "sls", "mjf"):
332
+ raise ValueError("buildSim simulates lpbf, fdm, sls or mjf builds: give process.type")
333
+ from .buildsim import BuildSetup
334
+ kw = {v: spec[k] for k, v in _BUILD_SIM_KEYS.items() if k in spec}
335
+ if "strain" in spec:
336
+ kw["strain"] = tuple(float(x) for x in spec["strain"])
337
+ if kw.get("release", "full") not in ("full", "none"):
338
+ raise ValueError("buildSim.release is 'full' (off the plate) or 'none' (as built, on it)")
339
+ mat = material if isinstance(material, str) else "pla"
340
+ setup = BuildSetup(process="sls" if kind == "mjf" else kind, material=mat, **kw)
341
+ warp = float(spec.get("warpWeight", 0.0))
342
+ if warp < 0:
343
+ raise ValueError("buildSim.warpWeight is a weight >= 0 (0: verify only)")
344
+ lim_d = float(spec["maxDistortion"]) if spec.get("maxDistortion") is not None else None
345
+ lim_l = float(spec["maxRecoaterLift"]) if spec.get("maxRecoaterLift") is not None else None
346
+ for name, v in (("maxDistortion", lim_d), ("maxRecoaterLift", lim_l)):
347
+ if v is not None and v <= 0:
348
+ raise ValueError(f"buildSim.{name} is a limit in mm > 0")
349
+ if warp > 0 or lim_d is not None or lim_l is not None:
350
+ what = "warpWeight" if warp > 0 else "maxDistortion / maxRecoaterLift"
351
+ if kind not in ("lpbf", "fdm"):
352
+ raise ValueError(f"buildSim.{what} designs against a layer-by-layer build: process lpbf or fdm")
353
+ if process.build == "auto" or process.build is None or len(process.build) != 2:
354
+ raise ValueError(f"buildSim.{what} needs process.buildDirection along a grid axis "
355
+ "('+z', '-y', ...), not 'auto' or a tilted vector")
356
+ coarsen = spec.get("warpCoarsen")
357
+ ps = spec.get("printSetup")
358
+ if ps is True:
359
+ ps = {}
360
+ if ps:
361
+ if kind != "fdm":
362
+ raise ValueError("buildSim.printSetup searches FDM print setups: process fdm")
363
+ ps = {"materials": ps.get("materials"), "builds": ps.get("orientations"), "beds": ps.get("bedTemperatures"),
364
+ "brims": tuple(float(b) for b in ps.get("brims", (0.0, 5.0))), "max_bed": float(ps.get("maxBed", 110.0)),
365
+ "reoptimize": bool(ps.get("reoptimize", True))}
366
+ return {"printSetup": ps or None, "setup": setup, "compensate": bool(spec.get("compensate", False)),
367
+ "cells": int(spec.get("cells", 60_000)), "iterations": int(spec.get("iterations", 2)),
368
+ "warpWeight": warp, "warpLayer": float(spec["warpLayer"]) if spec.get("warpLayer") else None,
369
+ "maxDistortion": lim_d, "maxRecoaterLift": lim_l,
370
+ "warpCoarsen": int(coarsen) if coarsen is not None else None,
371
+ "warpInOptimizer": bool(spec.get("warpInOptimizer", kind != "fdm"))}
372
+
373
+
374
+ def _slicer(spec, process, base_dir, goal):
375
+ """slicer.SlicerOptions for FDM studies (None otherwise)."""
376
+ from .slicer import SlicerOptions
377
+
378
+ if spec is not None and (process is None or process.kind != "fdm"):
379
+ raise ValueError("slicer is for FDM studies: give process {\"type\": \"fdm\"}")
380
+ if goal.minimize != "mass" and (process is None or process.kind != "fdm"):
381
+ raise ValueError(f"goal.minimize {goal.minimize!r} slices the part: it needs process fdm")
382
+ if process is None or process.kind != "fdm":
383
+ return None
384
+ opts = SlicerOptions.from_spec(spec, base_dir)
385
+ if goal.minimize != "mass" and opts.enabled is False:
386
+ raise ValueError(f"goal.minimize {goal.minimize!r} needs the slicer: drop slicer.enabled false")
387
+ if opts.grade is not None:
388
+ from .infill import GradeOptions
389
+
390
+ GradeOptions.from_spec(opts.grade, goal.safety_factor) # validate now, not after the solve
391
+ return opts
392
+
393
+
394
+ def _anisotropic(v):
395
+ if v in (True, False, "bond"):
396
+ return v
397
+ if v in ("physical", "true"):
398
+ return True
399
+ raise ValueError("process.anisotropic is false, true (physical layered stiffness) or \"bond\"")
400
+
401
+
402
+ @dataclass
403
+ class Thermal:
404
+ """A study's `thermal` block (see thermal.study_heat): temperatures in C,
405
+ heat in W, convection in W/(m^2 K); each target is (region id, shape)."""
406
+ ambient: float = 25.0
407
+ stress_free: float | None = None # None: ambient
408
+ temperatures: list = field(default_factory=list) # (region, shape, C)
409
+ heat: list = field(default_factory=list) # (region, shape, W, "surface" | "volume")
410
+ convection: list = field(default_factory=list) # (region, shape, h, fluid C | None)
411
+ conductivity: float | None = None # W/(m K); None: the material card's
412
+ expansion: Any = True # True (card), 1/K, or False
413
+ soften: bool = True
414
+ specific_heat: float | None = None # J/(kg K); None: the material card's
415
+ cycle: Any = None # thermal.Cycle: heat switched on and off, or None (steady)
416
+
417
+ @property
418
+ def t_ref(self):
419
+ return self.ambient if self.stress_free is None else self.stress_free
420
+
421
+
422
+ STILL_AIR = 8.0 # W/(m^2 K): the convection a thermal block gets when it names none
423
+
424
+
425
+ def _thermal(t, base_dir) -> Thermal | None:
426
+ if t is None:
427
+ return None
428
+ if not isinstance(t, dict):
429
+ raise ValueError("thermal must be an object: {ambient, temperatures, heat, convection, ...}")
430
+
431
+ def num(v, label, positive=False):
432
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or not np.isfinite(v) or (positive and v <= 0):
433
+ raise ValueError(f"{label} must be a {'positive ' if positive else ''}number")
434
+ return float(v)
435
+
436
+ out = Thermal(ambient=num(t.get("ambient", 25.0), "thermal.ambient"))
437
+ if t.get("stressFree") is not None:
438
+ out.stress_free = num(t["stressFree"], "thermal.stressFree")
439
+ for i, it in enumerate(t.get("temperatures") or []):
440
+ label = f"thermal.temperatures[{i}]"
441
+ region, shape = _target(it, label, base_dir)
442
+ out.temperatures.append((region, shape, num(it.get("t"), f"{label}.t (C)")))
443
+ for i, it in enumerate(t.get("heat") or []):
444
+ label = f"thermal.heat[{i}]"
445
+ region, shape = _target(it, label, base_dir)
446
+ spread = it.get("spread", "surface")
447
+ if spread not in ("surface", "volume"):
448
+ raise ValueError(f"{label}.spread must be 'surface' or 'volume'")
449
+ out.heat.append((region, shape, num(it.get("watts"), f"{label}.watts", True), spread))
450
+ conv = t.get("convection", STILL_AIR)
451
+ if isinstance(conv, (int, float)) and not isinstance(conv, bool):
452
+ conv = [{"h": conv}]
453
+ if not isinstance(conv, list):
454
+ raise ValueError("thermal.convection is a coefficient in W/(m² K) or a list of {h, region|shape, fluid}")
455
+ for i, it in enumerate(conv):
456
+ label = f"thermal.convection[{i}]"
457
+ h = num(it.get("h"), f"{label}.h (W/(m² K))")
458
+ if h < 0:
459
+ raise ValueError(f"{label}.h must be >= 0")
460
+ region, shape = _target(it, label, base_dir) if ("region" in it or "shape" in it) else (None, None)
461
+ fluid = num(it["fluid"], f"{label}.fluid (C)") if it.get("fluid") is not None else None
462
+ out.convection.append((region, shape, h, fluid))
463
+ if t.get("conductivity") is not None:
464
+ out.conductivity = num(t["conductivity"], "thermal.conductivity", True)
465
+ e = t.get("expansion", True)
466
+ if not isinstance(e, bool):
467
+ e = num(e, "thermal.expansion (1/K, or true/false)")
468
+ if e < 0:
469
+ raise ValueError("thermal.expansion must be >= 0")
470
+ out.expansion = e
471
+ out.soften = bool(t.get("soften", True))
472
+ if t.get("specificHeat") is not None:
473
+ out.specific_heat = num(t["specificHeat"], "thermal.specificHeat (J/(kg K))", True)
474
+ cy = t.get("cycle")
475
+ if cy is not None:
476
+ from .thermal import Cycle
477
+
478
+ if not isinstance(cy, dict):
479
+ raise ValueError("thermal.cycle is {period, on, cycles}: seconds per cycle, seconds the heat is on")
480
+ period = num(cy.get("period"), "thermal.cycle.period (s)", True)
481
+ on = num(cy.get("on"), "thermal.cycle.on (s the heat is on each period)", True)
482
+ if on >= period:
483
+ raise ValueError("thermal.cycle.on must be shorter than the period (for heat on all the time, "
484
+ "leave cycle out)")
485
+ if not out.heat:
486
+ raise ValueError("thermal.cycle switches the heat inputs on and off: give thermal.heat")
487
+ n = cy.get("cycles")
488
+ if n is not None:
489
+ n = num(n, "thermal.cycle.cycles (over the part's life)", True)
490
+ steps = cy.get("steps", 48)
491
+ if isinstance(steps, bool) or not isinstance(steps, int) or steps < 8:
492
+ raise ValueError("thermal.cycle.steps is a whole number of time steps per period, at least 8")
493
+ fw = cy.get("fatigueWeight", 0.0)
494
+ fw = num(fw, "thermal.cycle.fatigueWeight")
495
+ if fw < 0:
496
+ raise ValueError("thermal.cycle.fatigueWeight must be >= 0")
497
+ if fw > 0 and out.expansion is False:
498
+ raise ValueError("thermal.cycle.fatigueWeight needs expansion: without it the swing carries no stress")
499
+ fsf = cy.get("fatigueSafetyFactor")
500
+ if fsf is not None:
501
+ fsf = num(fsf, "thermal.cycle.fatigueSafetyFactor", True)
502
+ if n is None:
503
+ raise ValueError("thermal.cycle.fatigueSafetyFactor needs cycles: the fatigue strength it holds "
504
+ "the swing under is the one at that many cycles")
505
+ if out.expansion is False:
506
+ raise ValueError("thermal.cycle.fatigueSafetyFactor needs expansion: without it the swing "
507
+ "carries no stress")
508
+ if fw > 0:
509
+ raise ValueError("thermal.cycle: give fatigueWeight (objective) or fatigueSafetyFactor "
510
+ "(constraint), not both")
511
+ out.cycle = Cycle(period, on, n, steps, fw, fsf)
512
+ if not out.temperatures and not any(h > 0 for *_, h, _ in out.convection):
513
+ raise ValueError("thermal needs a way for heat out: a fixed temperature (temperatures) or convection")
514
+ # a part that just runs warm (inside a 55 C enclosure): ambient alone,
515
+ # every cell at it, softened for it; with no heat and no fixed
516
+ # temperature it is that or nothing
517
+ if not out.temperatures and not out.heat and out.t_ref == out.ambient \
518
+ and (not out.soften or abs(out.ambient - 25.0) < 1.0):
519
+ raise ValueError("thermal has nothing to do: no heat input, no fixed temperature, the stress-free "
520
+ "temperature is the ambient, and the ambient is room temperature (for a part that "
521
+ "runs warm, set ambient to its service temperature)")
522
+ return out
523
+
524
+
525
+ @dataclass
526
+ class Study:
527
+ name: str
528
+ design_space: list[Shape]
529
+ preserve: list[Region]
530
+ keep_out: list[Region]
531
+ supports: list[Support]
532
+ load_cases: list[LoadCase]
533
+ material: Material
534
+ goal: Goal
535
+ min_member: float | None
536
+ symmetry: list[str]
537
+ pitch: float | None
538
+ max_elements: int
539
+ max_iterations: int = 120
540
+ # m/s², applied to the part's own mass in every load case.
541
+ gravity: np.ndarray | None = None
542
+ # Build direction for overhang control, as (axis, sign); see am.py.
543
+ print_direction: Any = None
544
+ # Heat in the part (spec.Thermal), applied in every load case, or None.
545
+ thermal: Any = None
546
+ # 2.5D manufacturing: the part is a profile extruded along this axis
547
+ # (0, 1, 2), i.e. cut from plate by waterjet, laser or a 2.5-axis mill.
548
+ extrusion: int | None = None
549
+ # Milling or casting access (manufacture.Access), a circular pattern
550
+ # (manufacture.Pattern), and the thickest member allowed (mm).
551
+ access: Any = None
552
+ pattern: Any = None
553
+ max_member: float | None = None
554
+ # Every material to try (a list in the study); `material` is the first.
555
+ materials: list = field(default_factory=list)
556
+ # How it is made (spec.PROCESSES): layer weakness, overhangs, min feature.
557
+ process: Process | None = None
558
+ # (region id, thickness mm) of every wall and skin pinned solid
559
+ shells: list = field(default_factory=list)
560
+ # fatigue / creep settings (durability.Durability), and each fastener's
561
+ # preload and head bearing area for the relaxation check
562
+ durability: Any = None
563
+ joints: list = field(default_factory=list)
564
+ # FDM: slicer.SlicerOptions (print time, filament, support per outcome;
565
+ # graded infill); None for other processes
566
+ slicer: Any = None
567
+ # Smallest hole (mm) the optimizer may leave, or None: see simp.SimpOptions.dilate.
568
+ min_hole: float | None = None
569
+ # Escape holes into sealed voids: "auto" (powder and resin processes),
570
+ # True, False, or an escape.EscapeOptions; see escape.py.
571
+ drains: Any = "auto"
572
+ # multimat.MultiMaterial: the optimizer places several materials
573
+ multi_material: Any = None
574
+ # Build-process simulation of the delivered part (buildsim.py): a
575
+ # buildsim.BuildSetup plus {"compensate": bool, "cells": int}, or None
576
+ build_sim: Any = None
577
+ raw: dict[str, Any] = field(default_factory=dict)
578
+
579
+ @property
580
+ def length_scale(self) -> float:
581
+ """The member size the density filter enforces (mm): minMember, else
582
+ DEFAULT_MIN_MEMBER. A grid fine enough to resolve a sub-millimetre
583
+ sheet would otherwise build one (the 845k-cell arm link with no
584
+ minMember came out a 1 mm skin sealed round its cable channel)."""
585
+ return float(self.min_member) if self.min_member else DEFAULT_MIN_MEMBER
586
+
587
+ @property
588
+ def self_equilibrated(self) -> bool:
589
+ """No supports: every load is a pressure, held by pressure.stabilize."""
590
+ return not self.supports
591
+
592
+ def elastic(self, material: Material | None = None, design: bool = False):
593
+ """What the solver takes for `nu`: the material's Poisson's ratio, or
594
+ a fem.Layered when the process builds in layers (am.layered_elasticity).
595
+ design=True is the optimizer's surrogate stiffness."""
596
+ from .am import layered_elasticity
597
+
598
+ mat = material or self.material
599
+ if self.process is None or (design and not self.process.anisotropic):
600
+ return mat.nu
601
+ return layered_elasticity(mat.nu, self.process, design=design and self.process.anisotropic == "bond")
602
+
603
+ def region_shape(self, region_id: str) -> tuple[Shape, str]:
604
+ for r in self.preserve:
605
+ if r.id == region_id:
606
+ return r.shape, "preserve"
607
+ for r in self.keep_out:
608
+ if r.id == region_id:
609
+ return r.shape, "keepOut"
610
+ known = [r.id for r in self.preserve] + [r.id for r in self.keep_out]
611
+ raise ValueError(f"unknown region {region_id!r}; known regions: {known}")
612
+
613
+ def bounds(self) -> tuple[np.ndarray, np.ndarray]:
614
+ los, his = [], []
615
+ for s in self.design_space + [r.shape for r in self.preserve]:
616
+ lo, hi = s.bounds()
617
+ los.append(lo)
618
+ his.append(hi)
619
+ return np.min(los, axis=0), np.max(his, axis=0)
620
+
621
+
622
+ def _regions(items, label, base_dir):
623
+ out, seen = [], set()
624
+ for i, item in enumerate(items or []):
625
+ if not isinstance(item, dict) or "shape" not in item:
626
+ raise ValueError(f"{label}[{i}] must be {{'id', 'shape'}}")
627
+ rid = str(item.get("id") or f"{label}-{i}")
628
+ if rid in seen:
629
+ raise ValueError(f"duplicate {label} id {rid!r}")
630
+ seen.add(rid)
631
+ wall = item.get("wall")
632
+ if wall is not None and not (isinstance(wall, (int, float)) and wall > 0):
633
+ raise ValueError(f"{label}[{i}].wall is a thickness in mm (> 0)")
634
+ out.append(Region(rid, shape_from_spec(item["shape"], base_dir), bool(item.get("bore", True)),
635
+ float(wall) if wall is not None else None))
636
+ return out
637
+
638
+
639
+ _FACES = {f"{s}{a}": (i, 1 if s == "+" else -1) for i, a in enumerate("xyz") for s in "+-"}
640
+
641
+
642
+ def _skin(v, design):
643
+ """Top-level `skin`: a thickness (mm) or {thickness, open: ['-z', ..]}.
644
+ Returns (thickness, the skin shape)."""
645
+ from .shapes import Shell, Union, shrink
646
+
647
+ if isinstance(v, (int, float)):
648
+ v = {"thickness": v}
649
+ if not isinstance(v, dict):
650
+ raise ValueError("skin is a thickness in mm, or {thickness, open: ['-z', ...]}")
651
+ t = v.get("thickness")
652
+ if not isinstance(t, (int, float)) or t <= 0:
653
+ raise ValueError("skin.thickness is the wall in mm (> 0)")
654
+ faces = []
655
+ for f in v.get("open") or []:
656
+ key = str(f).strip().lower()
657
+ key = key if key[:1] in "+-" else "+" + key
658
+ if key not in _FACES:
659
+ raise ValueError(f"skin.open entries are design-space faces like '-z' or '+x', got {f!r}")
660
+ faces.append(_FACES[key])
661
+ ds = design[0] if len(design) == 1 else Union(design)
662
+ return float(t), Shell(ds, shrink(ds, float(t), faces))
663
+
664
+
665
+ def _target(item, label, base_dir):
666
+ if "region" in item:
667
+ return str(item["region"]), None
668
+ if "shape" in item:
669
+ return None, shape_from_spec(item["shape"], base_dir)
670
+ raise ValueError(f"{label} needs a 'region' id or an inline 'shape'")
671
+
672
+
673
+ def _extrusion_axis(v):
674
+ if v is None:
675
+ return None
676
+ a = str(v).strip().lower().lstrip("+-")
677
+ if a not in ("x", "y", "z"):
678
+ raise ValueError("extrusion must be 'x', 'y' or 'z' (the axis the profile is extruded along)")
679
+ return "xyz".index(a)
680
+
681
+
682
+ def _mass_load(ld, f, m, region, shape, label, preserve, keep_out):
683
+ """A carried mass as the force and moment it puts on its region: weight
684
+ x dynamic factor along gravity, and the moment of that force about the
685
+ region's centre when the mass sits elsewhere ('at')."""
686
+ from .am import build_vector
687
+ from .shapes import _vec
688
+
689
+ mass, dyn = ld["mass"], ld.get("dynamic", 1.0)
690
+ if not isinstance(mass, (int, float)) or mass <= 0:
691
+ raise ValueError(f"{label}.mass is the carried mass in kg (> 0)")
692
+ if not isinstance(dyn, (int, float)) or dyn < 1:
693
+ raise ValueError(f"{label}.dynamic multiplies the weight: 1 static, 2 a moving joint, 3 a crash")
694
+ try:
695
+ g = build_vector(parse_build_direction(ld.get("gravity", "-z")))
696
+ except (ValueError, TypeError, KeyError):
697
+ raise ValueError(f"{label}.gravity is the way the mass pulls: '-z', '+y', ... or [x, y, z]") from None
698
+ w = 9.81 * float(mass) * float(dyn) * np.asarray(g, float) / np.linalg.norm(g)
699
+ return f + w, m
700
+
701
+
702
+ def _at_moment(ld, f, m, region, shape, label, preserve, keep_out):
703
+ """A force (or carried mass) applied away from its region ('at'): the
704
+ moment of the whole force about the region's centre. A belt pulling on
705
+ a pulley in front of a motor face, a gripper's mass 80 mm out."""
706
+ from .shapes import _vec
707
+
708
+ if ld.get("direction") is not None or ld.get("cone") is not None:
709
+ raise ValueError(f"{label}: a force 'at' an offset makes a moment that would have to turn with an "
710
+ "unknown direction; give each direction you need as a load case of its own (or use an "
711
+ "in-plane direction such as \"xz\", which turns the force round 'at'; it takes a plain force, so write a mass as kg x 9.81 x the dynamic factor in N)")
712
+ if shape is None:
713
+ regs = {r.id: r.shape for r in list(preserve) + list(keep_out)}
714
+ shape = regs.get(region)
715
+ if shape is None:
716
+ raise ValueError(f"{label}: 'at' needs the region's shape to measure the lever from")
717
+ lo, hi = shape.bounds()
718
+ lever = _vec(ld["at"], name=f"{label}.at") - 0.5 * (np.asarray(lo) + np.asarray(hi))
719
+ return m + np.cross(lever, f)
720
+
721
+
722
+ _PLANES = {"xy": (0, 1), "xz": (0, 2), "yz": (1, 2)}
723
+ _PLANAR_STEPS = 8 # 22.5 degrees apart: the worst direction is within cos(11.25) = 98%
724
+
725
+
726
+ def _expand_planar(d: dict) -> dict:
727
+ """A load whose direction is any in a plane ("direction": "xz") becomes
728
+ eight plain cases round that plane. Von Mises stress does not care about
729
+ the force's sign, so half a turn covers it. Brief-eval agents wrote these
730
+ cases by hand (a pillow block's radial load, a hook's swinging strap): a
731
+ 3D 'any' or a cone also pushes out of plane, which a thin part cannot
732
+ carry and need not."""
733
+ cases = d.get("loadCases")
734
+ if not isinstance(cases, list) or not any(
735
+ isinstance(ld, dict) and ld.get("direction") in _PLANES
736
+ for c in cases if isinstance(c, dict) for ld in (c.get("loads") or [])):
737
+ return d
738
+ out = []
739
+ for ci, c in enumerate(cases):
740
+ loads = c.get("loads") or [] if isinstance(c, dict) else []
741
+ planar = [i for i, ld in enumerate(loads) if isinstance(ld, dict) and ld.get("direction") in _PLANES]
742
+ if not planar:
743
+ out.append(c)
744
+ continue
745
+ if len(planar) > 1:
746
+ raise ValueError(f"loadCases[{ci}]: at most one load per case may have an in-plane direction; "
747
+ "put another in a load case of its own")
748
+ li = planar[0]
749
+ ld = loads[li]
750
+ if ld.get("cone") is not None or ld.get("mass") is not None or ld.get("pressure") is not None:
751
+ raise ValueError(f"loadCases[{ci}].loads[{li}]: an in-plane direction goes with a force, not a cone, "
752
+ "mass or pressure. For a swinging payload, write the force out: mass (kg) x 9.81 x the dynamic "
753
+ "factor, in N, e.g. 0.6 kg at 2 g is \"force\": [11.8, 0, 0]")
754
+ a, b = _PLANES[ld["direction"]]
755
+ f = np.asarray(ld.get("force") or [0, 0, 0], float)
756
+ size = float(np.linalg.norm(f))
757
+ if size <= 0:
758
+ raise ValueError(f"loadCases[{ci}].loads[{li}]: direction {ld['direction']!r} needs a force "
759
+ "(its size, and the first direction to try)")
760
+ start = float(np.arctan2(f[b], f[a])) if abs(f[a]) + abs(f[b]) > 1e-9 * size else 0.0
761
+ normal = np.zeros(3)
762
+ normal[3 - a - b] = 1.0 if (a, b) != (0, 2) else -1.0 # right-handed: a x b
763
+ m0 = np.asarray(ld.get("moment") or [0, 0, 0], float)
764
+ name = c.get("name") or f"case{ci}"
765
+ # Half a circle covers it only when the stress under -F is the stress
766
+ # under F: the case's one load, spread evenly, with no moment and an
767
+ # isotropic, tension-blind check. A bearing load presses on one side
768
+ # of the bore, other loads add, a moment does not turn round, and a
769
+ # layered process fails on interlayer tension: the full circle then.
770
+ full = (len(loads) > 1 or ld.get("distribution") == "bearing" or ld.get("moment") is not None
771
+ or d.get("process") is not None or d.get("printDirection") is not None)
772
+ steps, span = (2 * _PLANAR_STEPS, 2 * np.pi) if full else (_PLANAR_STEPS, np.pi)
773
+ for k in range(steps):
774
+ t = span * k / steps
775
+ fk = np.zeros(3)
776
+ fk[a], fk[b] = size * np.cos(start + t), size * np.sin(start + t)
777
+ # the moment turns with the force about the plane's normal
778
+ cth, sth = np.cos(t), np.sin(t)
779
+ mk = m0 * cth + np.cross(normal, m0) * sth + normal * (normal @ m0) * (1 - cth)
780
+ new = {k2: v for k2, v in ld.items() if k2 != "direction"}
781
+ new["force"] = fk.round(9).tolist()
782
+ if ld.get("moment") is not None:
783
+ new["moment"] = mk.round(9).tolist()
784
+ out.append({**c, "name": f"{name}@{180.0 * k / _PLANAR_STEPS:g}",
785
+ "loads": [new if i == li else x for i, x in enumerate(loads)]})
786
+ return {**d, "loadCases": out}
787
+
788
+
789
+ def study_from_dict(d: dict, base_dir: str | None = None) -> Study:
790
+ """Validate and parse a study. Every error names the field to fix."""
791
+ if not isinstance(d, dict):
792
+ raise ValueError("study must be a JSON object")
793
+ from . import quick
794
+ from .fasteners import expand_fasteners
795
+ d = quick.study(d)
796
+ d = expand_fasteners(d)
797
+ d = _expand_planar(d)
798
+ joints = d.pop("_joints", []) if isinstance(d.get("_joints"), list) else []
799
+ ds = d.get("designSpace")
800
+ if isinstance(ds, dict):
801
+ ds = [ds]
802
+ if not ds:
803
+ raise ValueError("designSpace is required: one shape or a list of shapes")
804
+ design_space = [shape_from_spec(s, base_dir) for s in ds]
805
+ preserve = _regions(d.get("preserve"), "preserve", base_dir)
806
+ keep_out = _regions(d.get("keepOut"), "keepOut", base_dir)
807
+ clash = {r.id for r in preserve} & {r.id for r in keep_out}
808
+ if clash:
809
+ raise ValueError(f"region ids must be unique across preserve and keepOut: {sorted(clash)}")
810
+
811
+ supports = []
812
+ for i, s in enumerate(d.get("supports") or []):
813
+ region, shape = _target(s, f"supports[{i}]", base_dir)
814
+ fix = s.get("fix", "all")
815
+ pin = fix == "pin"
816
+ if pin:
817
+ if region is None and not hasattr(shape, "axis"):
818
+ raise ValueError(f"supports[{i}]: a pin needs a cylinder (a bore keep-out or a cylinder shape)")
819
+ fix_t = (False, False, False)
820
+ elif fix == "all":
821
+ fix_t = (True, True, True)
822
+ else:
823
+ fix_t = tuple(a in fix for a in "xyz") if isinstance(fix, str) else tuple(bool(v) for v in fix)
824
+ if len(fix_t) != 3 or not any(fix_t):
825
+ raise ValueError(f"supports[{i}].fix must be 'all', 'pin', axes like 'xz', or 3 booleans")
826
+ supports.append(Support(region, shape, fix_t, pin))
827
+
828
+ cases_in = d.get("loadCases")
829
+ if cases_in is None and "loads" in d:
830
+ cases_in = [{"name": "default", "loads": d["loads"]}]
831
+ cases = []
832
+ for ci, c in enumerate(cases_in or []):
833
+ loads = []
834
+ for li, ld in enumerate(c.get("loads") or []):
835
+ region, shape = _target(ld, f"loadCases[{ci}].loads[{li}]", base_dir)
836
+ label = f"loadCases[{ci}].loads[{li}]"
837
+ p = ld.get("pressure", 0.0)
838
+ if not isinstance(p, (int, float)) or not np.isfinite(p):
839
+ raise ValueError(f"{label}.pressure is a gauge pressure in MPa (1 bar = 0.1 MPa)")
840
+ if p:
841
+ if region is None:
842
+ raise ValueError(f"{label}: a pressure acts in a keepOut (the fluid's volume); "
843
+ "name it with 'region'")
844
+ if any(k in ld for k in ("force", "moment", "torque", "distribution")):
845
+ raise ValueError(f"{label}: a pressure load takes no force, moment or distribution; "
846
+ "put those in a load of their own")
847
+ loads.append(Load(region, None, np.zeros(3), pressure=float(p)))
848
+ continue
849
+ f = np.asarray(ld.get("force", [0, 0, 0]), float).reshape(-1)
850
+ m = np.asarray(ld.get("moment", ld.get("torque", [0, 0, 0])), float).reshape(-1)
851
+ if f.shape != (3,) or m.shape != (3,) or not np.all(np.isfinite(f)) or not np.all(np.isfinite(m)):
852
+ raise ValueError(f"{label}: force [fx, fy, fz] (N) and moment [mx, my, mz] (N·mm) must be 3 numbers")
853
+ if ld.get("mass") is not None:
854
+ f, m = _mass_load(ld, f, m, region, shape, label, preserve, keep_out)
855
+ if ld.get("at") is not None:
856
+ m = _at_moment(ld, f, m, region, shape, label, preserve, keep_out)
857
+ if not np.any(f) and not np.any(m):
858
+ raise ValueError(f"{label} needs a nonzero force (N), moment (N·mm), mass (kg) or pressure (MPa)")
859
+ dist = str(ld.get("distribution", "uniform"))
860
+ if dist not in ("uniform", "bearing"):
861
+ raise ValueError(f"{label}.distribution must be 'uniform' or 'bearing'")
862
+ if dist == "bearing" and not np.any(f):
863
+ raise ValueError(f"{label}: a bearing load needs a force")
864
+ spread, cone = None, None
865
+ if ld.get("direction") is not None:
866
+ if ld["direction"] != "any":
867
+ raise ValueError(f"{label}.direction must be 'any' (the force's size, in every direction) "
868
+ "or a plane, 'xy', 'xz' or 'yz' (any direction in it)")
869
+ spread = "any"
870
+ if ld.get("cone") is not None:
871
+ cone = ld["cone"]
872
+ if spread or not isinstance(cone, (int, float)) or not 0 < cone <= 180:
873
+ raise ValueError(f"{label}.cone is a half-angle in degrees (0-180] round the force's "
874
+ "direction, and cannot go with direction 'any'")
875
+ spread, cone = ("any", None) if cone >= 180 else ("cone", float(cone))
876
+ if spread and not np.any(f):
877
+ raise ValueError(f"{label}: direction 'any' or a cone needs a force (its size and, for a cone, "
878
+ "its axis)")
879
+ loads.append(Load(region, shape, f, m, dist, direction=spread, cone=cone))
880
+ if not loads:
881
+ raise ValueError(f"loadCases[{ci}] has no loads")
882
+ if sum(ld.direction is not None for ld in loads) > 1:
883
+ raise ValueError(f"loadCases[{ci}]: at most one load per case may have an unknown direction "
884
+ "(direction 'any' or a cone); put another in a load case of its own")
885
+ life = {}
886
+ for key in ("cycles", "hours"):
887
+ if c.get(key) is not None:
888
+ v = c[key]
889
+ if not isinstance(v, (int, float)) or not np.isfinite(v) or v <= 0:
890
+ raise ValueError(f"loadCases[{ci}].{key} must be a positive number "
891
+ + ("(load cycles over the part's life)" if key == "cycles" else
892
+ "(hours the load is held over the part's life)"))
893
+ life[key] = float(v)
894
+ if c.get("temperature") is not None:
895
+ life["temperature"] = float(c["temperature"])
896
+ cases.append(LoadCase(str(c.get("name") or f"case-{ci}"), loads, float(c.get("weight", 1.0)), **life))
897
+ if not cases:
898
+ raise ValueError("at least one load case is required (loadCases: [{name, loads}])")
899
+ pressurized = {ld.region for c in cases for ld in c.loads if ld.pressure}
900
+ ko_ids = {r.id for r in keep_out}
901
+ for rid in sorted(pressurized - ko_ids):
902
+ raise ValueError(f"pressure on {rid!r}: a pressure acts in a keepOut (the fluid's volume); "
903
+ f"keepOut ids: {sorted(ko_ids)}")
904
+ if not supports and not all(ld.pressure for c in cases for ld in c.loads):
905
+ raise ValueError("at least one support is required (where the part is held); only a study whose "
906
+ "every load is a pressure may leave supports out")
907
+ gravity = d.get("gravity")
908
+ if gravity is not None and not supports:
909
+ raise ValueError("gravity needs supports: a part's own weight has to be held somewhere")
910
+ if gravity is not None:
911
+ gravity = np.asarray(gravity, float).reshape(-1)
912
+ if gravity.shape != (3,) or not np.all(np.isfinite(gravity)):
913
+ raise ValueError("gravity must be an acceleration vector in m/s², e.g. [0, 0, -9.81]")
914
+ thermal = _thermal(d.get("thermal"), base_dir)
915
+
916
+ g = d.get("goal") or {"volumeFraction": 0.3}
917
+ goal = Goal(
918
+ volume_fraction=g.get("volumeFraction"),
919
+ safety_factor=g.get("safetyFactor"),
920
+ max_deflection=g.get("maxDeflection"),
921
+ method=str(g.get("method", "auto")),
922
+ confirm=str(g.get("confirm", "auto")),
923
+ outcomes=[float(v) for v in g.get("outcomes") or []],
924
+ minimize=str(g.get("minimize", "mass")),
925
+ restarts=int(g.get("restarts", 1)),
926
+ buckling=bool(g.get("buckling", True)),
927
+ )
928
+ if goal.restarts < 1:
929
+ raise ValueError("goal.restarts must be 1 or more")
930
+ if goal.minimize not in ("mass", "printTime", "filament"):
931
+ raise ValueError("goal.minimize must be 'mass', 'printTime' or 'filament'")
932
+ if goal.volume_fraction is None and goal.safety_factor is None and goal.max_deflection is None:
933
+ raise ValueError("goal needs volumeFraction (0..1), safetyFactor (>1) and/or maxDeflection (mm)")
934
+ if goal.method not in ("search", "constrained", "auto"):
935
+ raise ValueError("goal.method must be 'search', 'constrained' or 'auto'")
936
+ if goal.confirm not in ("auto", "voxel", "tet"):
937
+ raise ValueError("goal.confirm must be 'auto', 'voxel' or 'tet'")
938
+ if goal.max_deflection is not None and goal.max_deflection <= 0:
939
+ raise ValueError("goal.maxDeflection must be a positive distance in mm")
940
+ if goal.volume_fraction is not None and not (0.02 <= goal.volume_fraction <= 0.95):
941
+ raise ValueError("goal.volumeFraction must be between 0.02 and 0.95")
942
+ if goal.safety_factor is not None and goal.safety_factor <= 0:
943
+ raise ValueError("goal.safetyFactor must be positive")
944
+ for v in goal.outcomes:
945
+ if not (0.02 <= v <= 0.95):
946
+ raise ValueError("goal.outcomes values must be between 0.02 and 0.95")
947
+
948
+ mat_in = d.get("material", "pla")
949
+ materials = [material_from_spec(m) for m in mat_in] if isinstance(mat_in, list) else [material_from_spec(mat_in)]
950
+ if not materials:
951
+ raise ValueError("material list is empty")
952
+
953
+ sym = [str(a).lower() for a in d.get("symmetry") or []]
954
+ if any(a not in ("x", "y", "z") for a in sym):
955
+ raise ValueError("symmetry entries must be 'x', 'y' or 'z' (planes through the design space center)")
956
+
957
+ res = d.get("resolution") or {}
958
+ pitch = res.get("pitch")
959
+ max_el = int(res.get("maxElements") or DEFAULT_MAX_ELEMENTS)
960
+ if max_el > MAX_ELEMENTS_CEILING:
961
+ raise ValueError(f"resolution.maxElements is capped at {MAX_ELEMENTS_CEILING}")
962
+ if pitch is not None and float(pitch) <= 0:
963
+ raise ValueError("resolution.pitch must be positive (mm)")
964
+ ps = res.get("pitchStarts", "auto")
965
+ if ps != "auto" and (isinstance(ps, bool) or not isinstance(ps, int) or not 1 <= ps <= 5):
966
+ raise ValueError("resolution.pitchStarts must be 'auto' or an integer 1-5")
967
+
968
+ min_member = d.get("minMember")
969
+ process = _process(d.get("process"))
970
+ process = _card_process(process, materials[0], d.get("process"))
971
+ process = _calibrated_process(process, mat_in, d.get("process"))
972
+ print_direction = parse_build_direction(d.get("printDirection"))
973
+ if process is not None:
974
+ # the process sets what a study left open: overhang control along the
975
+ # build direction, and a minimum member it can actually make
976
+ if print_direction is None and process.overhangs and process.build not in (None, "auto"):
977
+ print_direction = process.build
978
+ floor = 2.0 * process.min_feature
979
+ min_member = max(float(min_member), floor) if min_member is not None else floor
980
+ min_hole = d.get("minHole")
981
+ if min_hole is not None and not (isinstance(min_hole, (int, float)) and min_hole > 0):
982
+ raise ValueError("minHole is the narrowest hole or gap allowed, in mm (> 0)")
983
+ if min_hole is None and process is not None:
984
+ min_hole = process.min_hole
985
+ # Walls round keep-outs and the design space's skin are preserved
986
+ # material: fixed, pinned solid, with only what is behind them designed
987
+ # (skin-and-rib). Every pressurized keep-out has one: the pressure needs
988
+ # a surface to push on that the optimizer cannot open.
989
+ from .shapes import Shell, Union, grow
990
+
991
+ shells = []
992
+ clip = design_space[0] if len(design_space) == 1 else Union(design_space)
993
+ taken = {r.id for r in preserve} | {r.id for r in keep_out}
994
+ for r in keep_out:
995
+ t = r.wall
996
+ if t is None and r.id in pressurized:
997
+ if min_member is None:
998
+ raise ValueError(f"keepOut {r.id!r} is pressurized: give it a 'wall' (mm), the thickness of "
999
+ "the skin the pressure acts on, or set minMember")
1000
+ t = r.wall = float(min_member)
1001
+ if t is None:
1002
+ continue
1003
+ rid = f"{r.id}-wall"
1004
+ if rid in taken:
1005
+ raise ValueError(f"region id {rid!r} is taken; it names keepOut {r.id!r}'s wall")
1006
+ preserve.append(Region(rid, Shell(grow(r.shape, t), r.shape, clip)))
1007
+ shells.append((rid, t))
1008
+ if "bore" not in next((k for k in d.get("keepOut") or [] if k.get("id") == r.id), {}):
1009
+ # a pressure vessel's cavity is closed on purpose
1010
+ r.bore = r.bore and r.id not in pressurized
1011
+ if d.get("skin") is not None:
1012
+ t, shape = _skin(d["skin"], design_space)
1013
+ if "skin" in taken:
1014
+ raise ValueError("region id 'skin' is taken; it names the design space's skin")
1015
+ preserve.append(Region("skin", shape))
1016
+ shells.append(("skin", t))
1017
+ from .durability import durability_from_spec
1018
+ durability = durability_from_spec(d.get("durability"))
1019
+ from .manufacture import parse_casting, parse_milling, parse_pattern
1020
+
1021
+ if d.get("milling") is not None and d.get("casting") is not None:
1022
+ raise ValueError("a study is milled or cast, not both: give milling or casting")
1023
+ access = parse_milling(d.get("milling")) or parse_casting(d.get("casting"))
1024
+ if access is not None and print_direction is not None:
1025
+ raise ValueError(f"{access.kind} and overhang control (printDirection, or a process that prints with "
1026
+ "overhang limits) do not go together: drop one")
1027
+ max_member = d.get("maxMember")
1028
+ if max_member is not None:
1029
+ if not (isinstance(max_member, (int, float)) and max_member > 0):
1030
+ raise ValueError("maxMember is the thickest member allowed, in mm (> 0)")
1031
+ if min_member is not None and max_member < 2.0 * float(min_member):
1032
+ raise ValueError(f"maxMember ({max_member:g}mm) must be at least twice minMember "
1033
+ f"({float(min_member):g}mm) to leave the optimizer room")
1034
+ st = Study(
1035
+ name=str(d.get("name") or "study"),
1036
+ design_space=design_space,
1037
+ preserve=preserve,
1038
+ keep_out=keep_out,
1039
+ supports=supports,
1040
+ load_cases=cases,
1041
+ material=materials[0],
1042
+ materials=materials,
1043
+ goal=goal,
1044
+ min_member=float(min_member) if min_member is not None else None,
1045
+ symmetry=sym,
1046
+ pitch=float(pitch) if pitch is not None else None,
1047
+ max_elements=max_el,
1048
+ max_iterations=int(d.get("maxIterations", 120)),
1049
+ gravity=gravity,
1050
+ thermal=thermal,
1051
+ print_direction=print_direction,
1052
+ extrusion=_extrusion_axis(d.get("extrusion")),
1053
+ access=access,
1054
+ pattern=parse_pattern(d.get("pattern")),
1055
+ max_member=float(max_member) if max_member is not None else None,
1056
+ process=process,
1057
+ shells=shells,
1058
+ durability=durability,
1059
+ joints=joints,
1060
+ slicer=_slicer(d.get("slicer"), process, base_dir, goal),
1061
+ min_hole=float(min_hole) if min_hole is not None else None,
1062
+ drains=_drains(d.get("drains", "auto"), process),
1063
+ build_sim=_build_sim(d.get("buildSim"), process, d.get("material")),
1064
+ raw=d,
1065
+ )
1066
+ if d.get("multiMaterial") is not None:
1067
+ from .multimat import parse_multimaterial
1068
+
1069
+ st.multi_material = parse_multimaterial(d["multiMaterial"], st, base_dir)
1070
+ if "material" not in d:
1071
+ st.material = st.materials[0] = st.multi_material.materials[0]
1072
+ return st