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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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()
|