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/mcp_server.py ADDED
@@ -0,0 +1,786 @@
1
+ """
2
+ A local MCP server, so any agent (Claude Code, Cursor, Claude Desktop…) can
3
+ run generative design studies on the machine it is working on, with no
4
+ hosted service in between:
5
+
6
+ {"mcpServers": {"loadpath": {"command": "loadpath", "args": ["mcp"]}}}
7
+
8
+ Tools:
9
+
10
+ - loadpath_guide: how to write a study (SKILL.md and the field reference)
11
+ - loadpath_schema: the JSON Schema for a study, heat exchanger or lattice
12
+ - loadpath_examples: worked example documents to start from
13
+ - loadpath_check: validate a study (every problem at once) and show the
14
+ voxel problem, without solving
15
+ - loadpath_start: run a study as a background job (optionally with a
16
+ lattice infill of the best outcome)
17
+ - loadpath_lattice: fill a finished outcome with a stress-graded lattice
18
+ - loadpath_heat_exchanger: design a two-fluid TPMS heat exchanger (a job)
19
+ - loadpath_size_heat_exchanger: pick cell and wall for a duty (seconds)
20
+ - loadpath_check_printability / loadpath_check_isolation / loadpath_check_fit:
21
+ checks on any STL an agent already holds (wall thickness, overhangs,
22
+ trapped powder; two-fluid isolation; does a component fit its cavity)
23
+ - loadpath_status / loadpath_jobs / loadpath_cancel: follow jobs
24
+
25
+ Jobs run in a child process, the same `python -m loadpath ...` the CLI
26
+ uses, under LOADPATH_HOME (default ~/.loadpath/jobs). Each job folder holds
27
+ its input, progress.jsonl (one JSON event per line), out/report.json and
28
+ the STLs and renders, and survives a restart of the server.
29
+
30
+ Needs the `mcp` extra: pip install "loadpath[mcp]".
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import subprocess
37
+ import sys
38
+ import time
39
+ import uuid
40
+
41
+ _HERE = os.path.dirname(os.path.abspath(__file__))
42
+ _procs: dict[str, subprocess.Popen] = {}
43
+ _POLL_HINT = "Poll loadpath_status every 30-60 seconds; a study takes minutes."
44
+ _QUICK_NOTE = ("LOADPATH_QUICK is set in the server's environment: a coarse grid and fewer iterations (a first "
45
+ "look; masses and safety factors move by a few to tens of percent). Restart the server without it "
46
+ "for the part to print.")
47
+ _VIEWER_NOTE = ("An interactive 3D page for the user: orbit the part, colour it by stress, safety factor or "
48
+ "displacement, animate the deformation, cut it open, replay the optimization. Give them the path "
49
+ "(or open it in a browser); it is one self-contained HTML file.")
50
+
51
+
52
+ def _home() -> str:
53
+ root = os.environ.get("LOADPATH_HOME") or os.path.join(os.path.expanduser("~"), ".loadpath", "jobs")
54
+ os.makedirs(root, exist_ok=True)
55
+ return root
56
+
57
+
58
+ def _job_dir(job_id: str) -> str:
59
+ if not job_id or any(c not in "0123456789abcdef" for c in job_id):
60
+ raise ValueError(f"unknown job id {job_id!r}; loadpath_jobs lists them")
61
+ d = os.path.join(_home(), job_id)
62
+ if not os.path.isdir(d):
63
+ raise ValueError(f"no job {job_id} under {_home()}; loadpath_jobs lists them")
64
+ return d
65
+
66
+
67
+ def _read(path):
68
+ try:
69
+ with open(path) as fh:
70
+ return fh.read()
71
+ except OSError:
72
+ return ""
73
+
74
+
75
+ def _meta(d) -> dict:
76
+ try:
77
+ return json.loads(_read(os.path.join(d, "job.json")) or "{}")
78
+ except json.JSONDecodeError:
79
+ return {}
80
+
81
+
82
+ def _alive(job_id, meta) -> bool:
83
+ proc = _procs.get(job_id)
84
+ if proc is not None:
85
+ return proc.poll() is None
86
+ pid = meta.get("pid")
87
+ if not pid:
88
+ return False
89
+ try:
90
+ os.kill(pid, 0)
91
+ except OSError:
92
+ return False
93
+ # a pid the OS has since reused for something else
94
+ cmd = _read(f"/proc/{pid}/cmdline")
95
+ return not cmd or job_id in cmd
96
+
97
+
98
+ def _failure(tool: str, exc: BaseException) -> dict:
99
+ """What a tool returns instead of raising: the error an agent can act on,
100
+ never a bare stack trace or a dropped connection."""
101
+ msg = str(exc).strip() or type(exc).__name__
102
+ out = {"ok": False, "tool": tool, "error": msg}
103
+ if isinstance(exc, (FileNotFoundError, IsADirectoryError)):
104
+ out["hint"] = "paths resolve against base_dir (default: the server's working directory); pass an absolute path"
105
+ elif not isinstance(exc, (ValueError, KeyError, TypeError)):
106
+ out["hint"] = ("an internal error, not a problem with the input as far as loadpath can tell; "
107
+ "loadpath_jobs and the job's log have more")
108
+ out["exception"] = type(exc).__name__
109
+ return out
110
+
111
+
112
+ def guarded(fn):
113
+ """Wrap a tool body so any exception comes back as {"ok": false, ...}
114
+ (or a one-item list of it, for tools that return content lists)."""
115
+ import functools
116
+
117
+ @functools.wraps(fn)
118
+ def wrapper(*a, **k):
119
+ try:
120
+ return fn(*a, **k)
121
+ except Exception as exc: # noqa: BLE001 - the agent gets the error, the server stays up
122
+ f = _failure(fn.__name__, exc)
123
+ return [json.dumps(f)] if fn.__annotations__.get("return") in (list, "list") else f
124
+ return wrapper
125
+
126
+
127
+ def guide() -> str:
128
+ return _read(os.path.join(_HERE, "SKILL.md")) + "\n\n---\n\n" + _read(os.path.join(_HERE, "README.md"))
129
+
130
+
131
+ def check(study, base_dir: str | None = None) -> dict:
132
+ from . import quick
133
+ from .tasks import check_study
134
+ out = check_study(study, base_dir)
135
+ if quick.on():
136
+ out["quick"] = _QUICK_NOTE
137
+ return out
138
+
139
+
140
+ def _spawn(kind: str, name: str, inputs: dict, argv: list, cells=None, warnings=None, parent=None,
141
+ base_dir=None) -> dict:
142
+ job_id = uuid.uuid4().hex
143
+ d = os.path.join(_home(), job_id)
144
+ os.makedirs(os.path.join(d, "out"))
145
+ for fname, doc in inputs.items():
146
+ with open(os.path.join(d, fname), "w") as fh:
147
+ json.dump(doc, fh, indent=1)
148
+ env = dict(os.environ, PYTHONUNBUFFERED="1", LOADPATH_NO_HINT="1")
149
+ env["PYTHONPATH"] = os.path.dirname(_HERE) + os.pathsep + env.get("PYTHONPATH", "")
150
+ argv = [a.replace("{dir}", d) for a in argv]
151
+ with open(os.path.join(d, "progress.jsonl"), "wb") as err, open(os.path.join(d, "stdout.json"), "wb") as out:
152
+ proc = subprocess.Popen([sys.executable, "-m", "loadpath", *argv], stdout=out, stderr=err,
153
+ stdin=subprocess.DEVNULL, env=env, cwd=d)
154
+ _procs[job_id] = proc
155
+ from . import quick
156
+ meta = {"kind": kind, "name": name, "pid": proc.pid, "started": time.time(), "parent": parent,
157
+ "baseDir": base_dir, "quick": quick.on()}
158
+ with open(os.path.join(d, "job.json"), "w") as fh:
159
+ json.dump(meta, fh)
160
+ out = {"ok": True, "jobId": job_id, "kind": kind, "dir": d, "next": _POLL_HINT}
161
+ if meta["quick"]:
162
+ out["quick"] = _QUICK_NOTE
163
+ if cells is not None:
164
+ out["cells"] = cells
165
+ if warnings:
166
+ out["warnings"] = warnings
167
+ return out
168
+
169
+
170
+ def start(study, base_dir: str | None = None, lattice: dict | None = None) -> dict:
171
+ """Validate and run a study in the background. `lattice` (options, or
172
+ {} for the defaults) also fills the best outcome with a lattice."""
173
+ if isinstance(study, str):
174
+ study = json.loads(study)
175
+ if lattice is not None:
176
+ study = {**study, "lattice": lattice}
177
+ v = check(study, base_dir)
178
+ if not v["ok"]:
179
+ return v
180
+ return _spawn("study", v.get("name") or "study", {"study.json": study},
181
+ ["run", "{dir}/study.json", "--out", "{dir}/out", "--base-dir", base_dir or os.getcwd()],
182
+ cells=v["cells"], warnings=v["warnings"], base_dir=base_dir or os.getcwd())
183
+
184
+
185
+ def lattice(job_id: str, outcome: int | None = None, options: dict | None = None) -> dict:
186
+ """Fill one outcome of a finished study job with a stress-graded TPMS
187
+ lattice, as a new job."""
188
+ d = _job_dir(job_id)
189
+ if _meta(d).get("kind", "study") != "study":
190
+ raise ValueError("loadpath_lattice takes a finished study job")
191
+ report = os.path.join(d, "out", "report.json")
192
+ if not os.path.exists(report):
193
+ raise ValueError(f"job {job_id} has no outcomes yet; wait until loadpath_status says done")
194
+ with open(report) as fh:
195
+ r = json.load(fh)
196
+ i = r.get("best") if outcome is None else int(outcome)
197
+ if i is None or not 0 <= i < len(r["outcomes"]):
198
+ raise ValueError(f"outcome must be 0..{len(r['outcomes']) - 1}")
199
+ stl = os.path.join(d, "out", os.path.basename(r["outcomes"][i]["stl"]))
200
+ study = json.loads(_read(os.path.join(d, "study.json")))
201
+ from .schema import format_issues, lint
202
+ errors = [x for x in lint(options or {}, "lattice") if x["level"] == "error"]
203
+ if errors:
204
+ return {"ok": False, "error": format_issues(errors)}
205
+ base = _meta(d).get("baseDir") or os.getcwd()
206
+ return _spawn("lattice", f"{study.get('name', 'study')}-lattice",
207
+ {"study.json": study, "lattice.json": options or {}},
208
+ ["lattice", "{dir}/study.json", stl, "--options", "{dir}/lattice.json", "--out", "{dir}/out",
209
+ "--base-dir", base], parent=job_id)
210
+
211
+
212
+ def heat_exchanger(spec, base_dir: str | None = None) -> dict:
213
+ if isinstance(spec, str):
214
+ spec = json.loads(spec)
215
+ from .schema import lint
216
+ issues = lint(spec, "heatExchanger")
217
+ errors = [x for x in issues if x["level"] == "error"]
218
+ if errors:
219
+ return {"ok": False, "error": "; ".join(f"{x['path']}: {x['message']}" for x in errors), "issues": issues}
220
+ try:
221
+ from .tasks import _hx_spec
222
+ _hx_spec(spec, base_dir or os.getcwd())
223
+ except (ValueError, KeyError, TypeError) as exc:
224
+ return {"ok": False, "error": str(exc)}
225
+ out = _spawn("heatExchanger", str(spec.get("name") or "heat-exchanger"), {"exchanger.json": spec},
226
+ ["heatx", "{dir}/exchanger.json", "--out", "{dir}/out", "--base-dir", base_dir or os.getcwd()])
227
+ out["warnings"] = [f"{x['path']}: {x['message']}" for x in issues]
228
+ return out
229
+
230
+
231
+ def field(graph, base_dir: str | None = None) -> dict:
232
+ """Validate a field graph and mesh it in the background."""
233
+ if isinstance(graph, str):
234
+ graph = json.loads(graph)
235
+ from .schema import format_issues, lint
236
+ issues = lint(graph, "field")
237
+ errors = [x for x in issues if x["level"] == "error"]
238
+ if errors:
239
+ return {"ok": False, "error": format_issues(errors), "issues": issues}
240
+ out = _spawn("field", str(graph.get("name") or "field"), {"graph.json": graph},
241
+ ["field", "{dir}/graph.json", "--out", "{dir}/out", "--base-dir", base_dir or os.getcwd()],
242
+ base_dir=base_dir or os.getcwd())
243
+ out["warnings"] = [f"{x['path']}: {x['message']}" for x in issues]
244
+ return out
245
+
246
+
247
+ def size_heat_exchanger(spec, base_dir: str | None = None, max_pressure_drop: dict | None = None,
248
+ min_wall: float | None = None, objective: str | None = None,
249
+ min_effectiveness: float | None = None, splits: list | None = None,
250
+ axes: str | None = None) -> dict:
251
+ from .tasks import size_heat_exchanger as size
252
+ if isinstance(spec, str):
253
+ spec = json.loads(spec)
254
+ try:
255
+ return size(spec, base_dir or os.getcwd(), max_pressure_drop, min_wall, objective, min_effectiveness, splits,
256
+ axes)
257
+ except (ValueError, KeyError, TypeError) as exc:
258
+ return {"ok": False, "error": str(exc)}
259
+
260
+
261
+ def _load_mesh(path: str, base_dir: str | None = None):
262
+ import trimesh
263
+
264
+ full = path if os.path.isabs(path) else os.path.join(base_dir or os.getcwd(), path)
265
+ if not os.path.exists(full):
266
+ raise ValueError(f"no mesh at {full}")
267
+ if full.lower().endswith((".step", ".stp")):
268
+ from .cadimport import load_part
269
+ return load_part(full).mesh
270
+ mesh = trimesh.load(full, force="mesh")
271
+ if not len(getattr(mesh, "faces", [])):
272
+ raise ValueError(f"{full} has no triangles")
273
+ return mesh
274
+
275
+
276
+ def import_cad(path: str, out_dir: str | None = None, base_dir: str | None = None, space: str = "part",
277
+ material: str = "petg", force: float = 100.0, grow: float = 0.0, process: str | None = None,
278
+ support: str | None = None, loads=None, ring: float | None = None) -> dict:
279
+ """A STEP/STL part's bores, bolt patterns, mounting faces and
280
+ interfaces, and with out_dir a draft study written there (cadimport.py)."""
281
+ from .cadimport import import_part
282
+
283
+ full = path if os.path.isabs(path) else os.path.join(base_dir or os.getcwd(), path)
284
+ if not os.path.exists(full):
285
+ return {"ok": False, "error": f"no part at {full}"}
286
+ try:
287
+ if out_dir is None:
288
+ return {"ok": True, **import_part(full)}
289
+ out = os.path.abspath(out_dir if os.path.isabs(out_dir) else os.path.join(base_dir or os.getcwd(), out_dir))
290
+ r = import_part(full, out, space=space, material=material, force=force, grow=grow, process=process,
291
+ support=support, loads=loads, ring=ring)
292
+ except (ValueError, RuntimeError) as exc:
293
+ return {"ok": False, "error": str(exc)}
294
+ return {"ok": True, "baseDir": out, **r}
295
+
296
+
297
+ _AXES = {"+x": (1, 0, 0), "-x": (-1, 0, 0), "+y": (0, 1, 0), "-y": (0, -1, 0), "+z": (0, 0, 1), "-z": (0, 0, -1)}
298
+
299
+
300
+ def _direction(build):
301
+ if build is None:
302
+ return (0.0, 0.0, 1.0)
303
+ if isinstance(build, str):
304
+ if build.lower() not in _AXES:
305
+ raise ValueError(f"build must be one of {sorted(_AXES)} or a vector [x, y, z], not {build!r}")
306
+ return _AXES[build.lower()]
307
+ v = [float(c) for c in build]
308
+ if len(v) != 3 or not any(v):
309
+ raise ValueError("build must be a non-zero vector [x, y, z]")
310
+ return tuple(v)
311
+
312
+
313
+ def check_printability(path: str, min_wall: float = 1.5, overhang_deg: float = 45.0, build=None,
314
+ pitch: float | None = None, slicer: bool = False, process: str = "fdm",
315
+ base_dir: str | None = None, material: str | None = None, slice: bool = False) -> dict:
316
+ """Printability of an STL/3MF/OBJ: wall thickness (mm, +/- the grid
317
+ pitch, with the thinnest spots), overhang area, trapped voids. slicer=True
318
+ adds the layer-by-layer read (bridges, islands, support volume, bed).
319
+ process sls/mjf/sla/dlp adds report["process"] (processcheck). slice=True
320
+ (FDM) runs PrusaSlicer headless: report["sliced"] has print time,
321
+ filament and support volume (slicer.py)."""
322
+ try:
323
+ mesh = _load_mesh(path, base_dir)
324
+ direction = _direction(build)
325
+ except ValueError as exc:
326
+ return {"ok": False, "error": str(exc)}
327
+ from .checks.dfm import check_dfm
328
+
329
+ spec = {"minWall": float(min_wall), "overhangDeg": float(overhang_deg), "build": direction}
330
+ if pitch:
331
+ spec["pitch"] = float(pitch)
332
+ report = check_dfm(mesh, spec)
333
+ if slicer:
334
+ try:
335
+ from .printcheck import check_print
336
+ report["slicer"] = check_print(mesh, direction, process=process)
337
+ except Exception as exc: # noqa: BLE001 - the rest of the report stands
338
+ report["slicerError"] = str(exc)
339
+ if slice and str(process).lower() == "fdm":
340
+ try:
341
+ from .materials import material_from_spec
342
+ from .slicer import slice_mesh
343
+
344
+ report["sliced"] = slice_mesh(mesh, direction, material_from_spec(material) if material else None)
345
+ except Exception as exc: # noqa: BLE001 - the rest of the report stands
346
+ report["slicedError"] = f"{type(exc).__name__}: {exc}"
347
+ from .processcheck import RULES
348
+
349
+ if str(process).lower() in RULES:
350
+ try:
351
+ from .materials import material_from_spec
352
+ from .processcheck import check_process
353
+
354
+ mat = material_from_spec(material) if material else None
355
+ report["process"] = check_process(mesh, str(process).lower(), build=direction, material=mat,
356
+ slicer=slicer)
357
+ except Exception as exc: # noqa: BLE001 - the rest of the report stands
358
+ report["processError"] = str(exc)
359
+ return report
360
+
361
+
362
+ def check_isolation(path: str, ports: list, plugs: list | None = None, min_feature: float | None = None,
363
+ pitch: float | None = None, base_dir: str | None = None) -> dict:
364
+ """Two-fluid isolation of an STL: each circuit's ports in one void, no
365
+ void shared by two circuits, and the thinnest wall between them in mm."""
366
+ try:
367
+ mesh = _load_mesh(path, base_dir)
368
+ except ValueError as exc:
369
+ return {"ok": False, "error": str(exc)}
370
+ from .checks.networks import check_networks
371
+
372
+ bad = [p for p in ports or [] if not {"name", "point", "r"} <= set(p)]
373
+ if not ports or bad:
374
+ return {"ok": False, "error": "ports must be a list of {name: '<circuit>_<role>', point: [x, y, z], r: mm}"}
375
+ return check_networks(mesh, ports, pitch=pitch, plugs=plugs, min_feature=min_feature)
376
+
377
+
378
+ def check_component_fit(path: str, components: list, base_dir: str | None = None) -> dict:
379
+ """Does each component (board, clearance, mounting holes, ports) fit the
380
+ part's cavity: placement in air, walls around it, bosses, port cutouts."""
381
+ try:
382
+ mesh = _load_mesh(path, base_dir)
383
+ except ValueError as exc:
384
+ return {"ok": False, "error": str(exc)}
385
+ from .checks.fit import check_fit
386
+
387
+ try:
388
+ return check_fit(mesh, {"components": components})
389
+ except (ValueError, KeyError, TypeError) as exc:
390
+ return {"ok": False, "error": str(exc)}
391
+
392
+
393
+ def _attention(o: dict, study: dict) -> list[str]:
394
+ from .delivery import attention
395
+ return attention(o, study)
396
+
397
+
398
+ def _png(d, path):
399
+ if not path:
400
+ return None
401
+ try:
402
+ with open(os.path.join(d, "out", os.path.basename(path)), "rb") as fh:
403
+ return fh.read()
404
+ except OSError:
405
+ return None
406
+
407
+
408
+ def _study_status(d, r):
409
+ study = json.loads(_read(os.path.join(d, "study.json")) or "{}")
410
+ outcomes = []
411
+ for i, o in enumerate(r["outcomes"]):
412
+ cases = [{"name": c["name"], "peakStressMPa": c["peakStress"], "safetyFactor": c["safetyFactor"],
413
+ "deflectionMm": c["deflection"],
414
+ **({"interlayerStressMPa": c["interlayerStress"]} if c.get("interlayerStress") else {})}
415
+ for c in o.get("cases") or []]
416
+ row = {
417
+ "outcome": i, "material": o.get("material"), "volumeFraction": o["volumeFraction"],
418
+ "massGrams": o["mass"], "lighterThanSolid": o.get("massReduction"),
419
+ "safetyFactor": o.get("safetyFactor"), "passes": o.get("passes"),
420
+ "minMemberMm": o.get("minMember"), "p5MemberMm": o.get("p5Member"), "bodies": o.get("bodies"),
421
+ "keepOutIntrusionMm3": o.get("keepOutIntrusion"), "preserveMissingMm3": o.get("preserveMissing"),
422
+ "designOverhangFraction": o.get("designOverhangFraction"), "cases": cases,
423
+ "start": o.get("start"),
424
+ "durability": {k: (o.get("durability") or {}).get(k) for k in
425
+ ("safetyFactor", "fatigueSafetyFactor", "creepSafetyFactor", "fatigueDamage",
426
+ "temperature", "joints")} if o.get("durability") else None,
427
+ "stl": os.path.join(d, "out", os.path.basename(o["stl"])) if o.get("stl") else None,
428
+ "render": os.path.join(d, "out", os.path.basename(o["render"])) if o.get("render") else None,
429
+ }
430
+ row["attention"] = _attention(o, study)
431
+ outcomes.append({k: v for k, v in row.items() if v is not None})
432
+ best = r.get("best")
433
+ deliver = r.get("deliver")
434
+ if deliver:
435
+ deliver = {**deliver, **{k: os.path.join(d, "out", os.path.basename(deliver[k]))
436
+ for k in ("stl", "3mf", "render") if deliver.get(k)}}
437
+ info = {"status": "done", "kind": "study", "deliver": deliver, "best": best, "outcomes": outcomes,
438
+ "warnings": r.get("warnings"),
439
+ "seconds": r.get("seconds"), "report": os.path.join(d, "out", "report.json")}
440
+ if r.get("viewer"):
441
+ info["viewer"] = os.path.join(d, "out", os.path.basename(r["viewer"]))
442
+ info["viewerNote"] = _VIEWER_NOTE
443
+ pngs = [_png(d, r["outcomes"][best].get("render"))] if best is not None else []
444
+ if r.get("lattice"):
445
+ info["lattice"] = r["lattice"]
446
+ pngs.append(_png(d, r["lattice"].get("render")))
447
+ if best is not None:
448
+ info["next"] = ("Look at the render and the attention list. To lighten it further, loadpath_lattice "
449
+ "fills it with a stress-graded lattice. Once it passes, offer the user a print quote or a "
450
+ "hosted page for the STL via Materialize (materialize_import_model, "
451
+ "materialize_get_quote); never order unasked.")
452
+ return info, pngs
453
+
454
+
455
+ def status(job_id: str) -> tuple[dict, list]:
456
+ """(info, [png bytes]) for a job."""
457
+ from .tasks import jsonable
458
+
459
+ d = _job_dir(job_id)
460
+ meta = _meta(d)
461
+ kind = meta.get("kind", "study")
462
+ report = os.path.join(d, "out", "report.json")
463
+ lines = [ln for ln in _read(os.path.join(d, "progress.jsonl")).splitlines() if ln.startswith("{")]
464
+ try:
465
+ latest = json.loads(lines[-1]) if lines else None
466
+ except json.JSONDecodeError:
467
+ latest = None
468
+ if _alive(job_id, meta):
469
+ el = time.time() - meta["started"] if meta.get("started") else None
470
+ return {"status": "running", "kind": kind, "name": meta.get("name"),
471
+ "elapsedSeconds": round(el) if el else None, "latest": _round(latest), "next": _POLL_HINT}, []
472
+ if not os.path.exists(report):
473
+ err = _read(os.path.join(d, "stdout.json")).strip()
474
+ tail = [ln for ln in _read(os.path.join(d, "progress.jsonl")).splitlines() if ln.strip()][-15:]
475
+ proc = _procs.get(job_id)
476
+ state = "cancelled" if meta.get("cancelled") or (proc is not None and (proc.returncode or 0) < 0) \
477
+ else "failed"
478
+ errs = [ln for ln in tail if ln and not ln.startswith((" ", "{", "Traceback"))]
479
+ out = {"status": state, "kind": kind, "error": err[-3000:] if err else (errs[-1] if errs else "exited early")}
480
+ if not err and tail:
481
+ out["log"] = "\n".join(tail)[-3000:]
482
+ return out, []
483
+ with open(report) as fh:
484
+ r = json.load(fh)
485
+ if kind == "study":
486
+ info, pngs = _study_status(d, r)
487
+ elif kind == "lattice":
488
+ info = {"status": "done", "kind": kind, "parent": meta.get("parent"), **r}
489
+ pngs = [_png(d, r.get("render"))]
490
+ elif kind == "field":
491
+ info = {"status": "done", "kind": kind, **r, "report": report}
492
+ att = []
493
+ if r.get("enclosedVoids"):
494
+ att.append(f"{r['enclosedVoids']} void(s) sealed inside the part ({r['enclosedVoidVolume']:.0f} mm^3): "
495
+ "powder or resin stays trapped there; " +
496
+ ("see drains.undrained" if r.get("drains") else
497
+ "give the graph a 'process' (sls, mjf, sla, dlp, lpbf) and it drills drain holes"))
498
+ esc = (r.get("drains") or {}).get("meshEscape") or {}
499
+ if esc.get("narrowExitCount"):
500
+ att.append(f"{esc['narrowExitCount']} chamber(s) open only through gaps under the escape hole: "
501
+ "raise drains.diameter")
502
+ if att:
503
+ info["attention"] = att
504
+ pngs = [_png(d, r.get("render"))]
505
+ else:
506
+ rep = r.get("report", {})
507
+ keep = ("isolated", "connected", "minWallBetweenFluids", "minOuterWall", "overhangArea", "watertight",
508
+ "solidVolume", "bounds", "warnings", "zones")
509
+ info = {"status": "done", "kind": kind, "passes": r.get("passes"), "stl": r.get("stl"),
510
+ "viewer": r.get("viewer"),
511
+ "fluidSpaces": r.get("fluidSpaces"), "rating": r.get("rating"), "ratingError": r.get("ratingError"),
512
+ "check": {k: rep.get(k) for k in keep if k in rep},
513
+ "report": report}
514
+ for n in ("A", "B"):
515
+ if n in rep and isinstance(rep[n], dict):
516
+ info.setdefault("fluidReport", {})[n] = rep[n]
517
+ rt = r.get("rating")
518
+ if rt:
519
+ info["summary"] = {
520
+ "effectiveness": rt.get("effectiveness"), "dutyW": rt.get("duty"), "tOutC": rt.get("tOut"),
521
+ "pressureDropPa": {n: f.get("pressureDrop") for n, f in rt.get("fluids", {}).items()},
522
+ "flags": sorted({x for f in rt.get("fluids", {}).values() for x in f.get("flags", [])})}
523
+ info = {k: v for k, v in info.items() if v is not None}
524
+ pngs = [_png(d, r.get("render"))]
525
+ if meta.get("quick"):
526
+ info["quick"] = _QUICK_NOTE
527
+ return jsonable(info), [p for p in pngs if p]
528
+
529
+
530
+ def _round(o, nd=4):
531
+ if isinstance(o, float):
532
+ return float(f"{o:.{nd}g}")
533
+ if isinstance(o, dict):
534
+ return {k: _round(v, nd) for k, v in o.items()}
535
+ if isinstance(o, list):
536
+ return [_round(v, nd) for v in o]
537
+ return o
538
+
539
+
540
+ def jobs(limit: int = 20) -> list[dict]:
541
+ """Recent jobs, newest first."""
542
+ rows = []
543
+ root = _home()
544
+ for job_id in os.listdir(root):
545
+ d = os.path.join(root, job_id)
546
+ meta = _meta(d)
547
+ if not meta:
548
+ continue
549
+ done = os.path.exists(os.path.join(d, "out", "report.json"))
550
+ st = "running" if _alive(job_id, meta) else "done" if done else \
551
+ "cancelled" if meta.get("cancelled") else "failed"
552
+ rows.append({"jobId": job_id, "kind": meta.get("kind"), "name": meta.get("name"), "status": st,
553
+ "started": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(meta.get("started", 0))),
554
+ "_t": meta.get("started", 0)})
555
+ rows.sort(key=lambda x: -x["_t"])
556
+ return [{k: v for k, v in r.items() if k != "_t"} for r in rows[:limit]]
557
+
558
+
559
+ def cancel(job_id: str) -> dict:
560
+ d = _job_dir(job_id)
561
+ meta = _meta(d)
562
+ if _alive(job_id, meta):
563
+ proc = _procs.get(job_id)
564
+ if proc is not None:
565
+ proc.terminate()
566
+ try:
567
+ proc.wait(5)
568
+ except subprocess.TimeoutExpired:
569
+ proc.kill()
570
+ else:
571
+ import signal
572
+ os.kill(meta["pid"], signal.SIGTERM)
573
+ meta["cancelled"] = True
574
+ with open(os.path.join(d, "job.json"), "w") as fh:
575
+ json.dump(meta, fh)
576
+ return {"status": "cancelled"}
577
+
578
+
579
+ def serve():
580
+ try:
581
+ from mcp.server.mcpserver import Image, MCPServer
582
+ except ImportError: # pragma: no cover - optional extra
583
+ sys.exit('loadpath mcp needs the MCP SDK: pip install "loadpath[mcp]"')
584
+
585
+ from .schema import schema as _schema
586
+ from .tasks import example, examples
587
+
588
+ server = MCPServer(
589
+ "loadpath",
590
+ instructions=(
591
+ "Generative design (topology optimization) for printed parts, plus lattice infills and two-fluid "
592
+ "TPMS heat exchangers. Describe the problem, not the geometry: design space, preserved interfaces, "
593
+ "keep-outs, fasteners, supports, load cases, material, process, goal. Units: mm, N, MPa. "
594
+ "Start with loadpath_guide (once) or loadpath_examples, validate with loadpath_check until it is "
595
+ "ok with no warnings you can fix, then loadpath_start and poll loadpath_status. Read every "
596
+ "outcome's attention list and look at the render before trusting a part. When a part passes, "
597
+ "offer to get it quoted and printed, or hosted to share, through Materialize (its MCP server: "
598
+ "materialize_import_model, then materialize_get_quote) without ordering unasked. For a mesh you "
599
+ "already have, loadpath_check_printability, loadpath_check_isolation and loadpath_check_fit "
600
+ "check it directly."),
601
+ )
602
+
603
+ def tool(fn):
604
+ return server.tool()(guarded(fn))
605
+
606
+ def _reply(info, pngs):
607
+ out = [json.dumps(info, indent=2)]
608
+ out += [Image(data=p, format="png") for p in pngs]
609
+ return out
610
+
611
+ @tool
612
+ def loadpath_guide() -> str:
613
+ """How to write a study: the workflow, every field, a worked example. Read once per session."""
614
+ return guide()
615
+
616
+ @tool
617
+ def loadpath_schema(kind: str = "study") -> dict:
618
+ """The JSON Schema (2020-12) for a 'study', a 'heatExchanger' or 'lattice' options."""
619
+ try:
620
+ return _schema(kind)
621
+ except ValueError as exc:
622
+ return {"ok": False, "error": str(exc)}
623
+
624
+ @tool
625
+ def loadpath_examples(name: str | None = None) -> dict:
626
+ """With no name: the example documents (studies and heat exchangers) with a line on each.
627
+ With a name: that document, ready to adapt and pass to loadpath_check / loadpath_start."""
628
+ try:
629
+ return example(name) if name else {"examples": examples()}
630
+ except ValueError as exc:
631
+ return {"ok": False, "error": str(exc)}
632
+
633
+ @tool
634
+ def loadpath_check(study: dict | str, base_dir: str | None = None) -> dict:
635
+ """Validate a study without solving: every schema problem at once (with 'did you mean'), then
636
+ the voxel problem (cells, pitch, regions, warnings). base_dir resolves relative mesh paths
637
+ (default: the server's working directory)."""
638
+ return check(study, base_dir)
639
+
640
+ @tool
641
+ def loadpath_start(study: dict | str, base_dir: str | None = None, lattice: dict | None = None) -> dict:
642
+ """Validate and start a study in the background; returns a jobId for loadpath_status.
643
+ lattice: optional lattice options ({} for defaults) to also fill the best outcome with a
644
+ stress-graded TPMS lattice."""
645
+ return start(study, base_dir, lattice)
646
+
647
+ @tool
648
+ def loadpath_status(job_id: str) -> list:
649
+ """A job's progress, or when done: its results and renders. For a study: every outcome (mass,
650
+ safety factor and deflection per load case, pass/fail, keep-out and preserve checks, an
651
+ 'attention' list of problems, STL path) and a render of the best one."""
652
+ try:
653
+ return _reply(*status(job_id))
654
+ except ValueError as exc:
655
+ return [json.dumps({"ok": False, "error": str(exc)})]
656
+
657
+ @tool
658
+ def loadpath_lattice(job_id: str, outcome: int | None = None, options: dict | None = None) -> dict:
659
+ """Fill an outcome of a finished study job (default: the best) with a TPMS sheet lattice whose
660
+ wall follows the part's stress, keeping a solid skin and solid material where it works hard.
661
+ options: {type: gyroid|diamond|schwarz-p, cell, skin (0 = open, for powder and resin), minWall,
662
+ maxWall, solidAbove, safetyFactor}. Starts a job; poll loadpath_status."""
663
+ try:
664
+ return lattice(job_id, outcome, options)
665
+ except ValueError as exc:
666
+ return {"ok": False, "error": str(exc)}
667
+
668
+ @tool
669
+ def loadpath_heat_exchanger(spec: dict | str, base_dir: str | None = None) -> dict:
670
+ """Design a two-fluid heat exchanger from a sheet TPMS in any core shape, with self-supporting
671
+ headers and ports, check the fluids are isolated and connected, and rate it if spec.duty is given.
672
+ See loadpath_schema('heatExchanger') and loadpath_examples. Starts a job; poll loadpath_status."""
673
+ return heat_exchanger(spec, base_dir)
674
+
675
+ @tool
676
+ def loadpath_field(graph: dict | str, base_dir: str | None = None) -> dict:
677
+ """Mesh an implicit part described as a graph of fields: shapes and meshes as signed
678
+ distances, simulation results and imported scalar fields, remap/expr math, and field-driven
679
+ operations (variable shell walls, variable fillets, TPMS lattices with graded cell size and wall).
680
+ Any numeric parameter can be another node. With a 'process' (sls, mjf, sla, dlp, volumetric, lpbf)
681
+ sealed voids (a skin over a lattice) get drain holes at the calmest flat sites, on bosses, verified
682
+ clear (report 'drains'). See loadpath_schema('field') and examples/fields/arm-link-graded.json.
683
+ Starts a job; poll loadpath_status."""
684
+ return field(graph, base_dir)
685
+
686
+ @tool
687
+ def loadpath_size_heat_exchanger(spec: dict, max_pressure_drop: dict | None = None,
688
+ min_wall: float | None = None, base_dir: str | None = None,
689
+ objective: str | None = None, min_effectiveness: float | None = None,
690
+ splits: list | None = None, axes: str | None = None) -> dict:
691
+ """Before designing: sweep TPMS cell and wall (and, with splits, the first fluid's share of the
692
+ void, e.g. [0.5, 0.6, 0.66] for air against a liquid) for the spec's core and duty. Returns the
693
+ candidates that meet max_pressure_drop ({fluid name: Pa}) and min_effectiveness, best
694
+ effectiveness first, or with objective "mass" the lightest core ("pressure": the least pressure
695
+ drop). min_wall defaults to the process minimum; axes "xy"/"yx" pins which fluid runs along x.
696
+ Only candidates the design step can mesh are returned (each with its grid). Each carries
697
+ solidVolume (mm^3) and coreMass (g, the core only: shell, headers and ports add more). Seconds."""
698
+ return size_heat_exchanger(spec, base_dir, max_pressure_drop, min_wall, objective, min_effectiveness, splits,
699
+ axes)
700
+
701
+ @tool
702
+ def loadpath_check_printability(path: str, min_wall: float = 1.5, overhang_deg: float = 45.0,
703
+ build: str | list | None = None, pitch: float | None = None,
704
+ slicer: bool = False, process: str = "fdm",
705
+ base_dir: str | None = None, material: str | None = None,
706
+ slice: bool = False) -> dict:
707
+ """Printability of any mesh file (STL, 3MF, OBJ), not only loadpath's own parts: wall thickness
708
+ in mm with its +/- (the voxel pitch) and the thinnest spots, overhang area for the build
709
+ direction ('+z'... or [x, y, z]), and trapped voids that cannot drain powder or resin.
710
+ minWallOk None means too coarse to judge, not a pass. slicer=True adds a layer-by-layer read
711
+ (bridges, islands, support volume, bed contact). process 'sls'/'mjf' (powder nylon, TPU) or
712
+ 'sla'/'dlp' (resin) adds the process checks: walls against its minimum, powder or resin trapped
713
+ in cavities or behind exits under the escape-hole size, warp-prone thin flat panels (powder),
714
+ peel area, suction cups and supports (resin); material (e.g. 'pa12', 'tpu', 'resin-tough')
715
+ flags a card that is not made by that process. slice=True (FDM) slices it headless with
716
+ PrusaSlicer: 'sliced' gives print time (s), filament (cm^3, g with material) and support volume.
717
+ Seconds to a minute on multi-million-triangle parts."""
718
+ return check_printability(path, min_wall, overhang_deg, build, pitch, slicer, process, base_dir, material,
719
+ slice)
720
+
721
+ @tool
722
+ def loadpath_check_isolation(path: str, ports: list, plugs: list | None = None,
723
+ min_feature: float | None = None, pitch: float | None = None,
724
+ base_dir: str | None = None) -> dict:
725
+ """Check that a two-fluid part (heat exchanger, manifold) keeps its fluids apart. ports: probe
726
+ spheres [{name: 'a_in', point: [x, y, z], r}] inside each fluid; ports sharing the prefix before
727
+ '_' are one circuit. plugs: port openings to virtually close, capsules {a, b, r} or boxes {c, h},
728
+ or every circuit meets in the outside air. min_feature: the thinnest wall or channel (mm), so the
729
+ grid resolves it (else the verdict is 'inconclusive'). Returns isolated, the void each port
730
+ found, and minWallMm +/- minWallToleranceMm between circuits."""
731
+ return check_isolation(path, ports, plugs, min_feature, pitch, base_dir)
732
+
733
+ @tool
734
+ def loadpath_check_fit(path: str, components: list, base_dir: str | None = None) -> dict:
735
+ """Check that components fit an enclosure mesh. components: [{id, boardMm: [L, W, T],
736
+ clearanceMm, aboveMm, belowMm, holes: {positions: [[x, y]], diaMm} | null, ports: [{id, edge:
737
+ '-x', offsetMm, heightMm, wMm, hMm} | {id, face: '+z', xMm, yMm, wMm, hMm}]}], in the
738
+ component's frame (origin at board centre, z up). Solves the placement in the cavity and
739
+ reports cavity, bosses and each port cutout as ok / not / null (could not check)."""
740
+ return check_component_fit(path, components, base_dir)
741
+
742
+ @tool
743
+ def loadpath_import_cad(path: str, out_dir: str | None = None, base_dir: str | None = None,
744
+ space: str = "part", material: str = "petg", force: float = 100.0,
745
+ grow: float = 0.0, process: str | None = None, support: str | None = None,
746
+ loads: list | None = None, ring: float | None = None) -> dict:
747
+ """Bring a CAD part in: a STEP (.step/.stp) or STL/3MF/OBJ file in mm. Finds its bores (axis,
748
+ radius, counterbore steps, through or blind; 'bolt' up to M6 clearance, else 'bore'), the flat
749
+ exposed faces it mounts by with the bolts opening onto each, and its interfaces (a bolted face,
750
+ a bolt pattern, a bore), and heals open shells. With out_dir it also writes a draft study there:
751
+ the part (space 'part'), its convex hull ('hull') or box ('box'), grown by grow mm, as design
752
+ space; each interface preserved (rings round bores, a pad under bolted faces, cut to the part);
753
+ bores as keep-outs; mating faces cleared when the space grows; and a suggested support (the
754
+ main bolted interface) and load (force N, a placeholder, across the lever to the farthest
755
+ large interface). Call it first without out_dir: `summary.interfaces` lists each interface id
756
+ with what it is. Then pass material (and process, e.g. 'sls'), support (an interface id) and
757
+ loads ([{interface, force: [fx, fy, fz] N}], one load case each) so the draft carries the real
758
+ setup instead of the placeholder; bolts get washer seats, not bosses. ring (mm) keeps an exact
759
+ tube of that wall round every bore. space 'part' only removes material from the part as drawn:
760
+ to lighten within its envelope use 'box' or 'hull'. Then loadpath_check and loadpath_start with
761
+ base_dir = the returned baseDir. Seconds."""
762
+ return import_cad(path, out_dir, base_dir, space, material, force, grow, process, support, loads, ring)
763
+
764
+ @tool
765
+ def loadpath_verify(study: dict | str, path: str, base_dir: str | None = None, max_cells: int = 60000) -> dict:
766
+ """Check any mesh (an earlier outcome, an edited STL, a part from elsewhere) against a study:
767
+ mass, safety factor, peak stress and deflection per load case, keep-out intrusion, preserve
768
+ loss, bodies, and passes against the study's goal. A minute or less; no job needed."""
769
+ from .tasks import verify_mesh
770
+ return verify_mesh(study, path, base_dir, max_cells)
771
+
772
+ @tool
773
+ def loadpath_jobs(limit: int = 20) -> dict:
774
+ """Recent jobs (studies, lattices, heat exchangers), newest first, with their status."""
775
+ return {"jobs": jobs(limit)}
776
+
777
+ @tool
778
+ def loadpath_cancel(job_id: str) -> dict:
779
+ """Stop a running job."""
780
+ return cancel(job_id)
781
+
782
+ server.run("stdio")
783
+
784
+
785
+ if __name__ == "__main__": # pragma: no cover
786
+ serve()