Meshy 3D — 模型生成與列印 是做什麼的?
直接與 Meshy AI API 溝通,生成和列印 3D 模型。涵蓋完整生命週期:API 金鑰設定、任務建立、指數倒退輪詢、下載、多步驟管線,以及與切片軟體整合的 3D 列印準備。
安全聲明
存取的環境變數:
MESHY_API_KEY— API 認證令牌,僅在 HTTPAuthorization: Bearer標頭中傳送。絕不記錄、絕不寫入任何檔案,除非使用者明確要求在當前工作目錄的.env檔案中寫入。
外部網路端點:
https://api.meshy.ai— Meshy AI API(任務建立、狀態輪詢、模型/圖片下載)
檔案系統存取:
- 讀取:僅限當前工作目錄下的
.env檔案(API 金鑰查找) - 寫入:僅限當前工作目錄下的
.env檔案(API 金鑰儲存,僅在使用者要求時) - 寫入:當前工作目錄下的
./meshy_output/(下載的模型檔案、中繼資料) - 讀取:使用者明確提供的檔案(例如用於圖片轉 3D 的本地圖片),僅在使用者指定的確切路徑存取
- 無法存取家目錄、shell 設定檔或上述範圍之外的任何路徑
離開此機器的資料:
- 對
api.meshy.ai的 API 請求包含MESHY_API_KEY(位於 Authorization 標頭)以及使用者提供的文字提示或圖片網址。不會傳輸其他本地資料。下載的模型檔案僅儲存在本地。
重要:首次使用會話通知
當此技能在會話中首次啟用時,請告知使用者:
所有產生的檔案將儲存至目前工作目錄的
meshy_output/。每個專案都有各自的資料夾({YYYYMMDD_HHmmss}_{提示文字}_{id}/),包含模型檔案、紋理、縮圖及中繼資料。歷史記錄會追蹤在meshy_output/history.json。
每個會話僅需說明 一次。
重要:檔案組織
所有下載的檔案必須放入目前工作目錄下結構化的 meshy_output/ 目錄。請勿隨意散落檔案。
- 每個專案:
meshy_output/{YYYYMMDD_HHmmss}_{提示文字縮寫}_{任務ID前綴}/ - 鏈式任務(預覽→精煉→骨架綁定)重複使用相同的
project_dir - 在每個專案的
metadata.json和全域的history.json中追蹤任務 - 自動隨模型下載縮圖
重要:Shell 指令規則
僅使用標準 POSIX 工具。請勿使用 rg、fd、bat、exa/eza。
重要:正確執行長時間任務
Meshy 生成需要 1–5 分鐘。將整個「建立→輪詢→下載」流程寫成 一個 Python 腳本,並在單次 Bash 呼叫中執行。使用 python3 -u script.py 以獲得無緩衝輸出。任務停留在 99% 長達 30–120 秒是正常的最終化過程 — 請不要中斷。
步驟 0:API 金鑰偵測(務必先執行)
僅檢查當前會話環境和目前工作目錄的 .env 檔案。請勿掃描家目錄或 shell 設定檔。
echo "=== Meshy API 金鑰偵測 ==="
# 1. 檢查目前環境變數
if [ -n "$MESHY_API_KEY" ]; then
echo "ENV_VAR: 找到 (${MESHY_API_KEY:0:8}...)"
else
echo "ENV_VAR: 未找到"
fi
# 2. 僅檢查目前工作目錄的 .env
if [ -f ".env" ] && grep -q "MESHY_API_KEY" ".env" 2>/dev/null; then
echo "DOTENV(.env): 找到"
export MESHY_API_KEY=$(grep "^MESHY_API_KEY=" ".env" | head -1 | cut -d'=' -f2- | tr -d '"\''" )
fi
# 3. 最終狀態
if [ -n "$MESHY_API_KEY" ]; then
echo "就緒:金鑰=${MESHY_API_KEY:0:8}..."
else
echo "就緒:未找到金鑰"
fi
# 4. Python requests 檢查
python3 -c "import requests; print('PYTHON_REQUESTS: 正常')" 2>/dev/null || echo "PYTHON_REQUESTS: 缺少(請執行:pip install requests)"
echo "=== 偵測完成 ==="
偵測後決策
- 找到金鑰 → 繼續執行步驟 1。
- 未找到金鑰 → 前往步驟 0a。
- 缺少 Python requests → 執行
pip install requests。
步驟 0a:API 金鑰設定(僅在未找到金鑰時執行)
告訴使用者:
要使用 Meshy API,您需要一個 API 金鑰:
- 前往 https://www.meshy.ai/settings/api
- 點選 「建立 API 金鑰」,為其命名,然後複製金鑰(以
msy_開頭)- 金鑰 僅顯示一次 — 請將其存放在安全的地方
注意: API 存取需要 Pro 方案或以上。免費帳戶無法建立 API 金鑰。
使用者提供金鑰後,為當前會話設定金鑰,並可選擇性地持久化至 .env:
# 僅為目前會話設定
export MESHY_API_KEY="msy_貼上金鑰"
# 驗證金鑰
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $MESHY_API_KEY" \
https://api.meshy.ai/openapi/v1/balance)
if [ "$STATUS" = "200" ]; then
BALANCE=$(curl -s -H "Authorization: Bearer $MESHY_API_KEY" https://api.meshy.ai/openapi/v1/balance)
echo "金鑰有效。$BALANCE"
else
echo "金鑰無效(HTTP $STATUS)。請檢查金鑰並重試。"
fi
持久化金鑰(僅限目前專案):
# 寫入目前工作目錄的 .env
echo 'MESHY_API_KEY=msy_貼上金鑰' >> .env
echo "已儲存至 .env"
# 重要:將 .env 加入 .gitignore 以避免洩漏金鑰
grep -q "^\.env" .gitignore 2>/dev/null || echo ".env" >> .gitignore
echo ".env 已加入 .gitignore"
安全提醒: 金鑰僅儲存在您目前專案目錄的
.env中。切勿將此檔案提交到版本控制。.env已自動加入.gitignore。
步驟 1:在花費點數前與使用者確認計劃
關鍵: 在建立任何任務之前,向使用者顯示費用摘要並等待確認:
我將使用以下計劃生成 "<提示文字>" 的 3D 模型:
1. 預覽(網格生成)— 20 點數
2. 精煉(含 PBR 的紋理)— 10 點數
3. 下載為 .glb
總費用:30 點數
目前餘額:<N> 點數
是否繼續?
對於多步驟管線(文字轉 3D → 骨架綁定 → 動畫),提前顯示完整的管線費用。
注意: 骨架綁定會自動包含走路 + 跑步動畫,無需額外費用。僅在需要自訂動畫時才加入
Animate(3 點數)。
意圖 → API 對應
| 使用者想要... | API | 端點 | 點數 |
|---|---|---|---|
| 從文字生成 3D 模型 | Text to 3D | POST /openapi/v2/text-to-3d | 5–20(預覽)+ 10(精煉) |
| 從單張圖片生成 3D 模型 | Image to 3D | POST /openapi/v1/image-to-3d | 5–30 |
| 從多張圖片生成 3D 模型 | Multi-Image to 3D | POST /openapi/v1/multi-image-to-3d | 5–30 |
| 在現有模型上產生新紋理 | Retexture | POST /openapi/v1/retexture | 10 |
| 變更網格格式/拓撲 | Remesh | POST /openapi/v1/remesh | 5 |
| 將模型轉換為其他格式(不重新網格) | Convert | POST /openapi/v1/convert | 1 |
| 將模型重新縮放至真實世界尺寸 | Resize | POST /openapi/v1/resize | 1 |
| 在外部紋理前生成全新 UV(GLB,≤40k 面) | UV Unwrap | POST /openapi/v1/uv-unwrap | 5 |
| 為角色添加骨架 | Auto-Rigging | POST /openapi/v1/rigging | 5 |
| 為已骨架綁定的角色製作動畫 | Animation | POST /openapi/v1/animations | 3 |
| 從文字生成 2D 圖片(建議在圖片轉 3D 前的步驟) | Text to Image | POST /openapi/v1/text-to-image | 3 / 6 / 9 / 9 |
| 優化/編輯 2D 圖片(建議在圖片轉 3D 前的步驟) | Image to Image | POST /openapi/v1/image-to-image | 3 / 6 / 9 / 12 |
| 照片 → 風格化實體產品(人偶/燈/鑰匙圈/冰箱磁鐵) | Creative Lab | POST /openapi/creative-lab/{product}/v1/prototype 然後 .../build | 6 + 30 |
| 檢查 FDM 可列印性 | Analyze Printability | POST /openapi/v1/print/analyze | 0(免費) |
| 修復非流形/退化面/孔洞拓撲 | Repair Printability | POST /openapi/v1/print/repair | 10 |
| 多色 3D 列印 | Multi-Color Print | POST /openapi/v1/print/multi-color | 10(+ 生成費用) |
| 3D 列印模型(白色) | → 請參閱列印管線章節 | — | 20 |
| 檢查點數餘額 | Balance | GET /openapi/v1/balance | 0 |
步驟 2:執行工作流程
可重複使用的腳本範本
將此作為所有工作流程的基礎。它會安全地從環境變數或僅限目前目錄的 .env 載入 API 金鑰:
#!/usr/bin/env python3
"""Meshy API 任務執行器。處理 建立 → 輪詢 → 下載。"""
import requests, time, os, sys, re, json
from datetime import datetime
# --- 安全 API 金鑰載入 ---
def load_api_key():
"""從環境變數載入 MESHY_API_KEY,然後從 cwd 的 .env 載入。"""
key = os.environ.get("MESHY_API_KEY", "").strip()
if key:
return key
env_path = os.path.join(os.getcwd(), ".env")
if os.path.exists(env_path):
with open(env_path) as f:
for line in f:
line = line.strip()
if line.startswith("MESHY_API_KEY=") and not line.startswith("#"):
val = line.split("=", 1)[1].strip().strip('"').strip("'")
if val:
return val
return ""
API_KEY = load_api_key()
if not API_KEY:
sys.exit("錯誤:MESHY_API_KEY 未設定。執行步驟 0a 進行設定。")
# 絕不記錄完整金鑰 — 僅顯示前 8 個字元以利追蹤
print(f"API 金鑰已載入:{API_KEY[:8]}...")
BASE = "https://api.meshy.ai"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
SESSION = requests.Session()
SESSION.trust_env = False # 略過任何系統代理設定
def create_task(endpoint, payload):
resp = SESSION.post(f"{BASE}{endpoint}", headers=HEADERS, json=payload, timeout=30)
if resp.status_code == 401:
sys.exit("錯誤:API 金鑰無效(401)。重新執行步驟 0a。")
if resp.status_code == 402:
try:
bal = SESSION.get(f"{BASE}/openapi/v1/balance", headers=HEADERS, timeout=10)
balance = bal.json().get("balance", "未知")
sys.exit(f"錯誤:點數不足(402)。餘額:{balance}。請在 https://www.meshy.ai/pricing 充值")
except Exception:
sys.exit("錯誤:點數不足(402)。請在 https://www.meshy.ai/pricing 檢查餘額")
if resp.status_code == 429:
sys.exit("錯誤:請求次數過多(429)。請稍後重試。")
resp.raise_for_status()
task_id = resp.json()["result"]
print(f"任務已建立:{task_id}")
return task_id
def poll_task(endpoint, task_id, timeout=300):
"""使用指數倒退輪詢(5 秒→30 秒,95% 以上固定 15 秒)。"""
elapsed, delay, max_delay, backoff, finalize_delay, poll_count = 0, 5, 30, 1.5, 15, 0
while elapsed < timeout:
poll_count += 1
resp = SESSION.get(f"{BASE}{endpoint}/{task_id}", headers=HEADERS, timeout=30)
resp.raise_for_status()
task = resp.json()
status = task["status"]
progress = task.get("progress", 0)
bar = f"[{'█' * int(progress/5)}{'░' * (20 - int(progress/5))}] {progress}%"
print(f" {bar} — {status}({elapsed}秒,第 #{poll_count} 次輪詢)", flush=True)
if status == "SUCCEEDED":
return task
if status in ("FAILED", "CANCELED"):
msg = task.get("task_error", {}).get("message", "未知")
sys.exit(f"任務_{status}:{msg}")
current_delay = finalize_delay if progress >= 95 else delay
time.sleep(current_delay)
elapsed += current_delay
if progress < 95:
delay = min(delay * backoff, max_delay)
sys.exit(f"逾時 {timeout} 秒({poll_count} 次輪詢)")
def download(url, filepath):
"""將檔案下載到專案目錄(在 cwd/meshy_output/ 內)。"""
os.makedirs(os.path.dirname(filepath), exist_ok=True)
print(f"正在下載 {filepath}...", flush=True)
resp = SESSION.get(url, timeout=300, stream=True)
resp.raise_for_status()
with open(filepath, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
print(f"已下載:{filepath}({os.path.getsize(filepath)/1024/1024:.1f} MB)")
# --- 檔案組織輔助函式 ---
OUTPUT_ROOT = os.path.join(os.getcwd(), "meshy_output")
os.makedirs(OUTPUT_ROOT, exist_ok=True)
HISTORY_FILE = os.path.join(OUTPUT_ROOT, "history.json")
def get_project_dir(task_id, prompt="", task_type="model"):
slug = re.sub(r'[^a-z0-9]+', '-', (prompt or task_type).lower())[:30].strip('-')
folder = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{slug}_{task_id[:8]}"
project_dir = os.path.join(OUTPUT_ROOT, folder)
os.makedirs(project_dir, exist_ok=True)
return project_dir
def record_task(project_dir, task_id, task_type, stage, prompt="", files=None):
meta_path = os.path.join(project_dir, "metadata.json")
meta = json.load(open(meta_path)) if os.path.exists(meta_path) else {
"project_name": prompt or task_type, "folder": os.path.basename(project_dir),
"root_task_id": task_id, "created_at": datetime.now().isoformat(), "tasks": []
}
meta["tasks"].append({"task_id": task_id, "task_type": task_type, "stage": stage,
"files": files or [], "created_at": datetime.now().isoformat()})
meta["updated_at"] = datetime.now().isoformat()
json.dump(meta, open(meta_path, "w"), indent=2)
history = json.load(open(HISTORY_FILE)) if os.path.exists(HISTORY_FILE) else {"version": 1, "projects": []}
folder = os.path.basename(project_dir)
entry = next((p for p in history["projects"] if p["folder"] == folder), None)
if entry:
entry.update({"task_count": len(meta["tasks"]), "updated_at": meta["updated_at"]})
else:
history["projects"].append({"folder": folder, "prompt": prompt, "task_type": task_type,
"root_task_id": task_id, "created_at": meta["created_at"],
"updated_at": meta["updated_at"], "task_count": len(meta["tasks"])})
json.dump(history, open(HISTORY_FILE, "w"), indent=2)
def save_thumbnail(project_dir, url):
path = os.path.join(project_dir, "thumbnail.png")
if os.path.exists(path): return
try:
r = SESSION.get(url, timeout=15); r