Spaces:
Running
Running
KaiWu commited on
Commit ·
2ff83a9
1
Parent(s): b5baed0
feat: 新增图生3D能力,集成 Lux3D API
Browse files- 新增 generate_3d_model 工具:API Key 解析、签名生成、任务创建/轮询、结果下载、manifest 记录
- 新增 lux3d_api.md 作为 API 参考文档
- 新增 inputs/little-bird.png 作为示例输入图
- agent_loop.py +391 -20
- lux3d_api.md +153 -0
agent_loop.py
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
|
|
|
|
|
| 1 |
import json
|
|
|
|
| 2 |
import os
|
| 3 |
import subprocess
|
| 4 |
import sys
|
| 5 |
import textwrap
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from datetime import datetime
|
| 7 |
from pathlib import Path
|
| 8 |
from uuid import uuid4
|
|
@@ -19,20 +26,38 @@ if os.getenv("ANTHROPIC_BASE_URL"):
|
|
| 19 |
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
| 20 |
|
| 21 |
WORKDIR = Path.cwd()
|
| 22 |
-
|
|
|
|
| 23 |
STEP_SUFFIXES = (".step", ".stp")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
SYSTEM = f"""
|
| 26 |
-
You are a CAD generation agent at {WORKDIR}.
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
- Write CadQuery Python code.
|
| 30 |
- The code must assign the final model to a variable named result.
|
| 31 |
- You may use cadquery as cq; it is pre-imported by the tool.
|
| 32 |
-
- Call
|
| 33 |
-
- Each successful
|
| 34 |
-
- If
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
Do not ask the user to run commands or create files manually.
|
| 38 |
""".strip()
|
|
@@ -60,6 +85,22 @@ def new_run_id() -> str:
|
|
| 60 |
return f"{timestamp}_{uuid4().hex[:6]}"
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
def resolve_run_output(requested_output_path: str) -> tuple[Path, Path, str]:
|
| 64 |
requested_path = safe_path(requested_output_path)
|
| 65 |
suffix = requested_path.suffix.lower()
|
|
@@ -73,13 +114,22 @@ def resolve_run_output(requested_output_path: str) -> tuple[Path, Path, str]:
|
|
| 73 |
output_root = requested_path
|
| 74 |
output_name = "model.step"
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
run_dir = output_root / "runs" / run_id
|
| 79 |
-
if not run_dir.exists():
|
| 80 |
-
return run_dir, run_dir / output_name, run_id
|
| 81 |
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
def update_latest_link(output_root: Path, run_dir: Path) -> str | None:
|
|
@@ -107,6 +157,7 @@ def write_manifest(
|
|
| 107 |
payload: dict,
|
| 108 |
) -> Path:
|
| 109 |
manifest_path = run_dir / "manifest.json"
|
|
|
|
| 110 |
manifest = {
|
| 111 |
"run_id": run_id,
|
| 112 |
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
@@ -122,6 +173,164 @@ def write_manifest(
|
|
| 122 |
return manifest_path
|
| 123 |
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
CADQUERY_RUNNER = r"""
|
| 126 |
import contextlib
|
| 127 |
import io
|
|
@@ -235,14 +444,14 @@ def _json_response(payload: dict) -> str:
|
|
| 235 |
return json.dumps(payload, ensure_ascii=False, indent=2)
|
| 236 |
|
| 237 |
|
| 238 |
-
def
|
| 239 |
try:
|
| 240 |
if not code or not code.strip():
|
| 241 |
return _json_response({
|
| 242 |
"ok": False,
|
| 243 |
"stage": "input",
|
| 244 |
"error_type": "EmptyCode",
|
| 245 |
-
"error": "
|
| 246 |
"traceback_tail": "",
|
| 247 |
"stdout": "",
|
| 248 |
"stderr": "",
|
|
@@ -356,14 +565,148 @@ def run_execute(code: str, output_path: str = DEFAULT_OUTPUT_PATH, prompt: str |
|
|
| 356 |
return _json_response(payload)
|
| 357 |
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
# -- The dispatch map: {tool_name: handler} --
|
| 360 |
TOOL_HANDLERS = {
|
| 361 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
}
|
| 363 |
|
| 364 |
TOOLS = [
|
| 365 |
{
|
| 366 |
-
"name": "
|
| 367 |
"description": textwrap.dedent("""
|
| 368 |
Execute CadQuery Python code and export the result as a STEP file.
|
| 369 |
The code must assign the final CadQuery model to a variable named result.
|
|
@@ -386,6 +729,28 @@ TOOLS = [
|
|
| 386 |
"required": ["code"],
|
| 387 |
},
|
| 388 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
]
|
| 390 |
|
| 391 |
|
|
@@ -412,10 +777,16 @@ def agent_loop(messages: list):
|
|
| 412 |
prompt = latest_user_prompt(messages)
|
| 413 |
for block in response.content:
|
| 414 |
if block.type == "tool_use":
|
| 415 |
-
if block.name == "
|
| 416 |
-
output =
|
| 417 |
code=block.input["code"],
|
| 418 |
-
output_path=block.input.get("output_path",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
prompt=prompt,
|
| 420 |
)
|
| 421 |
else:
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import hashlib
|
| 3 |
import json
|
| 4 |
+
import mimetypes
|
| 5 |
import os
|
| 6 |
import subprocess
|
| 7 |
import sys
|
| 8 |
import textwrap
|
| 9 |
+
import time
|
| 10 |
+
import urllib.error
|
| 11 |
+
import urllib.parse
|
| 12 |
+
import urllib.request
|
| 13 |
from datetime import datetime
|
| 14 |
from pathlib import Path
|
| 15 |
from uuid import uuid4
|
|
|
|
| 26 |
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
| 27 |
|
| 28 |
WORKDIR = Path.cwd()
|
| 29 |
+
DEFAULT_CADQUERY_OUTPUT_PATH = "outputs/model.step"
|
| 30 |
+
DEFAULT_LUX3D_OUTPUT_PATH = "outputs"
|
| 31 |
STEP_SUFFIXES = (".step", ".stp")
|
| 32 |
+
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp")
|
| 33 |
+
LUX3D_API_KEY_ENV = "LUX3D_API_KEY"
|
| 34 |
+
LUX3D_CREATE_URL = "https://api.luxreal.ai/global/lux3d/generate/task/create"
|
| 35 |
+
LUX3D_GET_URL = "https://api.luxreal.ai/global/lux3d/generate/task/get"
|
| 36 |
+
LUX3D_POLL_INTERVAL_SECONDS = 12
|
| 37 |
+
LUX3D_TIMEOUT_SECONDS = 600
|
| 38 |
|
| 39 |
SYSTEM = f"""
|
| 40 |
+
You are a CAD and 3D model generation agent at {WORKDIR}.
|
| 41 |
|
| 42 |
+
You have two tools:
|
| 43 |
+
- execute_cadquery: use it for precise CAD, dimensioned parts, parametric geometry, engineering models, and STEP/CAD output.
|
| 44 |
+
- generate_3d_model: use it when the user provides an image path and asks for a 3D model, mesh, GLB, OBJ, or other non-precise 3D asset.
|
| 45 |
+
|
| 46 |
+
For CadQuery tasks:
|
| 47 |
- Write CadQuery Python code.
|
| 48 |
- The code must assign the final model to a variable named result.
|
| 49 |
- You may use cadquery as cq; it is pre-imported by the tool.
|
| 50 |
+
- Call execute_cadquery with the code and optional output_path.
|
| 51 |
+
- Each successful tool call writes into a unique run directory to avoid overwriting previous models.
|
| 52 |
+
- If execute_cadquery returns ok=false, inspect the structured error, fix the code, and call execute_cadquery again.
|
| 53 |
+
|
| 54 |
+
For image-to-3D tasks:
|
| 55 |
+
- The user must provide a workspace-relative image path.
|
| 56 |
+
- If the user asks for image-to-3D generation but does not provide an image path, ask for the image path. Do not guess.
|
| 57 |
+
- Call generate_3d_model with the image_path and optional output_path.
|
| 58 |
+
- Do not ask the user to read files, convert images to base64, call APIs, or download results manually.
|
| 59 |
+
|
| 60 |
+
When a tool returns ok=true, report output_path, run_dir, and manifest_path to the user.
|
| 61 |
|
| 62 |
Do not ask the user to run commands or create files manually.
|
| 63 |
""".strip()
|
|
|
|
| 85 |
return f"{timestamp}_{uuid4().hex[:6]}"
|
| 86 |
|
| 87 |
|
| 88 |
+
def tail_text(text: str, limit: int = 4000) -> str:
|
| 89 |
+
if not text:
|
| 90 |
+
return ""
|
| 91 |
+
return text[-limit:]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def allocate_run_dir(output_root: Path) -> tuple[Path, str]:
|
| 95 |
+
for _ in range(20):
|
| 96 |
+
run_id = new_run_id()
|
| 97 |
+
run_dir = output_root / "runs" / run_id
|
| 98 |
+
if not run_dir.exists():
|
| 99 |
+
return run_dir, run_id
|
| 100 |
+
|
| 101 |
+
raise RuntimeError("Failed to allocate a unique output run directory.")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
def resolve_run_output(requested_output_path: str) -> tuple[Path, Path, str]:
|
| 105 |
requested_path = safe_path(requested_output_path)
|
| 106 |
suffix = requested_path.suffix.lower()
|
|
|
|
| 114 |
output_root = requested_path
|
| 115 |
output_name = "model.step"
|
| 116 |
|
| 117 |
+
run_dir, run_id = allocate_run_dir(output_root)
|
| 118 |
+
return run_dir, run_dir / output_name, run_id
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
+
|
| 121 |
+
def resolve_lux3d_run(output_path: str | None) -> tuple[Path, Path, str, str | None, str]:
|
| 122 |
+
requested_output_path = output_path or DEFAULT_LUX3D_OUTPUT_PATH
|
| 123 |
+
requested_path = safe_path(requested_output_path)
|
| 124 |
+
if requested_path.suffix:
|
| 125 |
+
output_root = requested_path.parent
|
| 126 |
+
output_name = requested_path.name
|
| 127 |
+
else:
|
| 128 |
+
output_root = requested_path
|
| 129 |
+
output_name = None
|
| 130 |
+
|
| 131 |
+
run_dir, run_id = allocate_run_dir(output_root)
|
| 132 |
+
return output_root, run_dir, run_id, output_name, requested_output_path
|
| 133 |
|
| 134 |
|
| 135 |
def update_latest_link(output_root: Path, run_dir: Path) -> str | None:
|
|
|
|
| 157 |
payload: dict,
|
| 158 |
) -> Path:
|
| 159 |
manifest_path = run_dir / "manifest.json"
|
| 160 |
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 161 |
manifest = {
|
| 162 |
"run_id": run_id,
|
| 163 |
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
|
|
| 173 |
return manifest_path
|
| 174 |
|
| 175 |
|
| 176 |
+
def write_lux3d_manifest(
|
| 177 |
+
run_dir: Path,
|
| 178 |
+
run_id: str,
|
| 179 |
+
prompt: str | None,
|
| 180 |
+
image_path: Path,
|
| 181 |
+
requested_output_path: str,
|
| 182 |
+
output_path: Path | None,
|
| 183 |
+
busid: str | int | None,
|
| 184 |
+
status: int | None,
|
| 185 |
+
result_url: str | None,
|
| 186 |
+
poll_count: int,
|
| 187 |
+
error: str | None = None,
|
| 188 |
+
) -> Path:
|
| 189 |
+
manifest_path = run_dir / "manifest.json"
|
| 190 |
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 191 |
+
manifest = {
|
| 192 |
+
"run_id": run_id,
|
| 193 |
+
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
| 194 |
+
"generator": "lux3d",
|
| 195 |
+
"prompt": prompt,
|
| 196 |
+
"image_path": workspace_relative(image_path),
|
| 197 |
+
"requested_output_path": requested_output_path,
|
| 198 |
+
"output_path": workspace_relative(output_path) if output_path else None,
|
| 199 |
+
"busid": busid,
|
| 200 |
+
"status": status,
|
| 201 |
+
"result_url": result_url,
|
| 202 |
+
"poll_count": poll_count,
|
| 203 |
+
"error": error,
|
| 204 |
+
}
|
| 205 |
+
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 206 |
+
return manifest_path
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def validate_image_path(image_path: str) -> Path:
|
| 210 |
+
if not image_path or not image_path.strip():
|
| 211 |
+
raise ValueError("image_path is required for image-to-3D generation.")
|
| 212 |
+
|
| 213 |
+
path = safe_path(image_path)
|
| 214 |
+
if not path.exists():
|
| 215 |
+
raise FileNotFoundError(f"Image file does not exist: {image_path}")
|
| 216 |
+
if not path.is_file():
|
| 217 |
+
raise ValueError(f"image_path must be a file: {image_path}")
|
| 218 |
+
if path.suffix.lower() not in IMAGE_SUFFIXES:
|
| 219 |
+
raise ValueError(f"Unsupported image format: {path.suffix}. Use one of: {', '.join(IMAGE_SUFFIXES)}")
|
| 220 |
+
return path
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def image_to_data_url(image_path: Path) -> str:
|
| 224 |
+
mime_type = mimetypes.guess_type(str(image_path))[0]
|
| 225 |
+
if not mime_type:
|
| 226 |
+
mime_type = "image/jpeg" if image_path.suffix.lower() in (".jpg", ".jpeg") else f"image/{image_path.suffix.lower().lstrip('.')}"
|
| 227 |
+
encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
|
| 228 |
+
return f"data:{mime_type};base64,{encoded}"
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def parse_lux3d_api_key() -> dict:
|
| 232 |
+
api_key = os.getenv(LUX3D_API_KEY_ENV, "").strip()
|
| 233 |
+
if not api_key:
|
| 234 |
+
raise ValueError(f"Missing {LUX3D_API_KEY_ENV}. Set it in your environment or .env file.")
|
| 235 |
+
|
| 236 |
+
try:
|
| 237 |
+
decoded = base64.b64decode(api_key, validate=True).decode("utf-8")
|
| 238 |
+
except Exception as exc:
|
| 239 |
+
raise ValueError(f"Invalid {LUX3D_API_KEY_ENV}: expected a base64-encoded API key.") from exc
|
| 240 |
+
|
| 241 |
+
parts = decoded.split(":")
|
| 242 |
+
if len(parts) != 4 or not all(parts):
|
| 243 |
+
raise ValueError(f"Invalid {LUX3D_API_KEY_ENV}: expected decoded format version:appkey:appsecret:appuid.")
|
| 244 |
+
|
| 245 |
+
version, appkey, appsecret, appuid = parts
|
| 246 |
+
return {
|
| 247 |
+
"version": version,
|
| 248 |
+
"appkey": appkey,
|
| 249 |
+
"appsecret": appsecret,
|
| 250 |
+
"appuid": appuid,
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def lux3d_query(credentials: dict, extra: dict | None = None) -> str:
|
| 255 |
+
timestamp = str(int(time.time() * 1000))
|
| 256 |
+
sign = hashlib.md5(
|
| 257 |
+
(credentials["appsecret"] + credentials["appkey"] + credentials["appuid"] + timestamp).encode("utf-8")
|
| 258 |
+
).hexdigest()
|
| 259 |
+
params = {
|
| 260 |
+
"appuid": credentials["appuid"],
|
| 261 |
+
"appkey": credentials["appkey"],
|
| 262 |
+
"timestamp": timestamp,
|
| 263 |
+
"sign": sign,
|
| 264 |
+
}
|
| 265 |
+
if extra:
|
| 266 |
+
params.update(extra)
|
| 267 |
+
return urllib.parse.urlencode(params)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def lux3d_json_request(method: str, url: str, payload: dict | None = None, timeout: int = 30) -> dict:
|
| 271 |
+
data = None
|
| 272 |
+
headers = {}
|
| 273 |
+
if payload is not None:
|
| 274 |
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
| 275 |
+
headers["Content-Type"] = "application/json"
|
| 276 |
+
|
| 277 |
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
| 278 |
+
try:
|
| 279 |
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 280 |
+
raw = response.read().decode("utf-8")
|
| 281 |
+
except urllib.error.HTTPError as exc:
|
| 282 |
+
body = exc.read().decode("utf-8", errors="replace")
|
| 283 |
+
raise RuntimeError(f"Lux3D API HTTP {exc.code}: {tail_text(body)}") from exc
|
| 284 |
+
except urllib.error.URLError as exc:
|
| 285 |
+
raise RuntimeError(f"Lux3D API request failed: {exc.reason}") from exc
|
| 286 |
+
|
| 287 |
+
try:
|
| 288 |
+
return json.loads(raw)
|
| 289 |
+
except Exception as exc:
|
| 290 |
+
raise RuntimeError(f"Lux3D API returned invalid JSON: {tail_text(raw)}") from exc
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def lux3d_create_task(credentials: dict, image_data_url: str) -> str | int:
|
| 294 |
+
query = lux3d_query(credentials)
|
| 295 |
+
payload = lux3d_json_request("POST", f"{LUX3D_CREATE_URL}?{query}", {"img": image_data_url})
|
| 296 |
+
busid = payload.get("d")
|
| 297 |
+
if not busid:
|
| 298 |
+
raise RuntimeError(f"Lux3D create task response did not include busid: {payload}")
|
| 299 |
+
return busid
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def lux3d_get_task(credentials: dict, busid: str | int) -> dict:
|
| 303 |
+
query = lux3d_query(credentials, {"busid": busid})
|
| 304 |
+
payload = lux3d_json_request("GET", f"{LUX3D_GET_URL}?{query}")
|
| 305 |
+
data = payload.get("d")
|
| 306 |
+
if not isinstance(data, dict):
|
| 307 |
+
raise RuntimeError(f"Lux3D get task response did not include task data: {payload}")
|
| 308 |
+
return data
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def infer_lux3d_output_name(result_url: str) -> str:
|
| 312 |
+
parsed = urllib.parse.urlparse(result_url)
|
| 313 |
+
name = Path(urllib.parse.unquote(parsed.path)).name
|
| 314 |
+
if name and "." in name:
|
| 315 |
+
return name
|
| 316 |
+
return "model.glb"
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def download_lux3d_result(result_url: str, output_path: Path) -> None:
|
| 320 |
+
request = urllib.request.Request(result_url, headers={"User-Agent": "aigc-3dcad/0.1"})
|
| 321 |
+
try:
|
| 322 |
+
with urllib.request.urlopen(request, timeout=120) as response:
|
| 323 |
+
data = response.read()
|
| 324 |
+
except urllib.error.HTTPError as exc:
|
| 325 |
+
body = exc.read().decode("utf-8", errors="replace")
|
| 326 |
+
raise RuntimeError(f"Lux3D result download HTTP {exc.code}: {tail_text(body)}") from exc
|
| 327 |
+
except urllib.error.URLError as exc:
|
| 328 |
+
raise RuntimeError(f"Lux3D result download failed: {exc.reason}") from exc
|
| 329 |
+
|
| 330 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 331 |
+
output_path.write_bytes(data)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
CADQUERY_RUNNER = r"""
|
| 335 |
import contextlib
|
| 336 |
import io
|
|
|
|
| 444 |
return json.dumps(payload, ensure_ascii=False, indent=2)
|
| 445 |
|
| 446 |
|
| 447 |
+
def run_execute_cadquery(code: str, output_path: str = DEFAULT_CADQUERY_OUTPUT_PATH, prompt: str | None = None) -> str:
|
| 448 |
try:
|
| 449 |
if not code or not code.strip():
|
| 450 |
return _json_response({
|
| 451 |
"ok": False,
|
| 452 |
"stage": "input",
|
| 453 |
"error_type": "EmptyCode",
|
| 454 |
+
"error": "execute_cadquery requires non-empty CadQuery code.",
|
| 455 |
"traceback_tail": "",
|
| 456 |
"stdout": "",
|
| 457 |
"stderr": "",
|
|
|
|
| 565 |
return _json_response(payload)
|
| 566 |
|
| 567 |
|
| 568 |
+
def run_generate_3d_model(
|
| 569 |
+
image_path: str,
|
| 570 |
+
output_path: str | None = None,
|
| 571 |
+
prompt: str | None = None,
|
| 572 |
+
poll_interval_seconds: int = LUX3D_POLL_INTERVAL_SECONDS,
|
| 573 |
+
timeout_seconds: int = LUX3D_TIMEOUT_SECONDS,
|
| 574 |
+
) -> str:
|
| 575 |
+
busid = None
|
| 576 |
+
status = None
|
| 577 |
+
result_url = None
|
| 578 |
+
output = None
|
| 579 |
+
poll_count = 0
|
| 580 |
+
run_dir = None
|
| 581 |
+
run_id = None
|
| 582 |
+
requested_output_path = output_path or DEFAULT_LUX3D_OUTPUT_PATH
|
| 583 |
+
|
| 584 |
+
try:
|
| 585 |
+
image = validate_image_path(image_path)
|
| 586 |
+
credentials = parse_lux3d_api_key()
|
| 587 |
+
output_root, run_dir, run_id, requested_output_name, requested_output_path = resolve_lux3d_run(output_path)
|
| 588 |
+
image_data_url = image_to_data_url(image)
|
| 589 |
+
except Exception as exc:
|
| 590 |
+
return _json_response({
|
| 591 |
+
"ok": False,
|
| 592 |
+
"stage": "input",
|
| 593 |
+
"error_type": type(exc).__name__,
|
| 594 |
+
"error": str(exc),
|
| 595 |
+
"busid": busid,
|
| 596 |
+
})
|
| 597 |
+
|
| 598 |
+
try:
|
| 599 |
+
busid = lux3d_create_task(credentials, image_data_url)
|
| 600 |
+
deadline = time.time() + timeout_seconds
|
| 601 |
+
|
| 602 |
+
while True:
|
| 603 |
+
if time.time() >= deadline:
|
| 604 |
+
raise TimeoutError(f"Lux3D task timed out after {timeout_seconds} seconds.")
|
| 605 |
+
|
| 606 |
+
time.sleep(poll_interval_seconds)
|
| 607 |
+
poll_count += 1
|
| 608 |
+
task = lux3d_get_task(credentials, busid)
|
| 609 |
+
raw_status = task.get("status")
|
| 610 |
+
status = int(raw_status) if raw_status is not None else None
|
| 611 |
+
|
| 612 |
+
if status in (0, 1):
|
| 613 |
+
continue
|
| 614 |
+
|
| 615 |
+
if status == 3:
|
| 616 |
+
outputs = task.get("outputs") or []
|
| 617 |
+
result_url = next(
|
| 618 |
+
(item.get("content") for item in outputs if isinstance(item, dict) and item.get("content")),
|
| 619 |
+
None,
|
| 620 |
+
)
|
| 621 |
+
if not result_url:
|
| 622 |
+
raise RuntimeError(f"Lux3D task succeeded but did not include a result URL: {task}")
|
| 623 |
+
|
| 624 |
+
output_name = requested_output_name or infer_lux3d_output_name(result_url)
|
| 625 |
+
output = run_dir / output_name
|
| 626 |
+
download_lux3d_result(result_url, output)
|
| 627 |
+
manifest_path = write_lux3d_manifest(
|
| 628 |
+
run_dir=run_dir,
|
| 629 |
+
run_id=run_id,
|
| 630 |
+
prompt=prompt,
|
| 631 |
+
image_path=image,
|
| 632 |
+
requested_output_path=requested_output_path,
|
| 633 |
+
output_path=output,
|
| 634 |
+
busid=busid,
|
| 635 |
+
status=status,
|
| 636 |
+
result_url=result_url,
|
| 637 |
+
poll_count=poll_count,
|
| 638 |
+
)
|
| 639 |
+
latest_warning = update_latest_link(output_root, run_dir)
|
| 640 |
+
payload = {
|
| 641 |
+
"ok": True,
|
| 642 |
+
"stage": "done",
|
| 643 |
+
"run_id": run_id,
|
| 644 |
+
"run_dir": workspace_relative(run_dir),
|
| 645 |
+
"output_path": workspace_relative(output),
|
| 646 |
+
"manifest_path": workspace_relative(manifest_path),
|
| 647 |
+
"latest_path": workspace_relative(output_root / "latest"),
|
| 648 |
+
"busid": busid,
|
| 649 |
+
"status": status,
|
| 650 |
+
"result_url": result_url,
|
| 651 |
+
"poll_count": poll_count,
|
| 652 |
+
}
|
| 653 |
+
if latest_warning:
|
| 654 |
+
payload["warning"] = latest_warning
|
| 655 |
+
return _json_response(payload)
|
| 656 |
+
|
| 657 |
+
if status == 4:
|
| 658 |
+
raise RuntimeError(f"Lux3D task failed: {task}")
|
| 659 |
+
|
| 660 |
+
raise RuntimeError(f"Lux3D task returned unknown status {raw_status}: {task}")
|
| 661 |
+
except Exception as exc:
|
| 662 |
+
manifest_path = None
|
| 663 |
+
if run_dir and run_id:
|
| 664 |
+
manifest_path = write_lux3d_manifest(
|
| 665 |
+
run_dir=run_dir,
|
| 666 |
+
run_id=run_id,
|
| 667 |
+
prompt=prompt,
|
| 668 |
+
image_path=image,
|
| 669 |
+
requested_output_path=requested_output_path,
|
| 670 |
+
output_path=output,
|
| 671 |
+
busid=busid,
|
| 672 |
+
status=status,
|
| 673 |
+
result_url=result_url,
|
| 674 |
+
poll_count=poll_count,
|
| 675 |
+
error=str(exc),
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
payload = {
|
| 679 |
+
"ok": False,
|
| 680 |
+
"stage": "lux3d",
|
| 681 |
+
"error_type": type(exc).__name__,
|
| 682 |
+
"error": str(exc),
|
| 683 |
+
"busid": busid,
|
| 684 |
+
"status": status,
|
| 685 |
+
"result_url": result_url,
|
| 686 |
+
"poll_count": poll_count,
|
| 687 |
+
}
|
| 688 |
+
if manifest_path:
|
| 689 |
+
payload["run_id"] = run_id
|
| 690 |
+
payload["run_dir"] = workspace_relative(run_dir)
|
| 691 |
+
payload["manifest_path"] = workspace_relative(manifest_path)
|
| 692 |
+
return _json_response(payload)
|
| 693 |
+
|
| 694 |
+
|
| 695 |
# -- The dispatch map: {tool_name: handler} --
|
| 696 |
TOOL_HANDLERS = {
|
| 697 |
+
"execute_cadquery": lambda **kw: run_execute_cadquery(
|
| 698 |
+
kw["code"],
|
| 699 |
+
kw.get("output_path", DEFAULT_CADQUERY_OUTPUT_PATH),
|
| 700 |
+
),
|
| 701 |
+
"generate_3d_model": lambda **kw: run_generate_3d_model(
|
| 702 |
+
kw["image_path"],
|
| 703 |
+
kw.get("output_path"),
|
| 704 |
+
),
|
| 705 |
}
|
| 706 |
|
| 707 |
TOOLS = [
|
| 708 |
{
|
| 709 |
+
"name": "execute_cadquery",
|
| 710 |
"description": textwrap.dedent("""
|
| 711 |
Execute CadQuery Python code and export the result as a STEP file.
|
| 712 |
The code must assign the final CadQuery model to a variable named result.
|
|
|
|
| 729 |
"required": ["code"],
|
| 730 |
},
|
| 731 |
},
|
| 732 |
+
{
|
| 733 |
+
"name": "generate_3d_model",
|
| 734 |
+
"description": textwrap.dedent("""
|
| 735 |
+
Generate a non-precise 3D model from a local image using the Lux3D image-to-3D API.
|
| 736 |
+
The input is a workspace-relative image path, not image bytes or base64 text.
|
| 737 |
+
The tool reads the image, creates a Lux3D task, polls for completion, downloads the result, and writes a manifest.
|
| 738 |
+
""").strip(),
|
| 739 |
+
"input_schema": {
|
| 740 |
+
"type": "object",
|
| 741 |
+
"properties": {
|
| 742 |
+
"image_path": {
|
| 743 |
+
"type": "string",
|
| 744 |
+
"description": "Workspace-relative path to a source image file. Supported formats: .png, .jpg, .jpeg, .webp.",
|
| 745 |
+
},
|
| 746 |
+
"output_path": {
|
| 747 |
+
"type": "string",
|
| 748 |
+
"description": "Optional workspace-relative model file path or output directory. Defaults to outputs.",
|
| 749 |
+
},
|
| 750 |
+
},
|
| 751 |
+
"required": ["image_path"],
|
| 752 |
+
},
|
| 753 |
+
},
|
| 754 |
]
|
| 755 |
|
| 756 |
|
|
|
|
| 777 |
prompt = latest_user_prompt(messages)
|
| 778 |
for block in response.content:
|
| 779 |
if block.type == "tool_use":
|
| 780 |
+
if block.name == "execute_cadquery":
|
| 781 |
+
output = run_execute_cadquery(
|
| 782 |
code=block.input["code"],
|
| 783 |
+
output_path=block.input.get("output_path", DEFAULT_CADQUERY_OUTPUT_PATH),
|
| 784 |
+
prompt=prompt,
|
| 785 |
+
)
|
| 786 |
+
elif block.name == "generate_3d_model":
|
| 787 |
+
output = run_generate_3d_model(
|
| 788 |
+
image_path=block.input["image_path"],
|
| 789 |
+
output_path=block.input.get("output_path"),
|
| 790 |
prompt=prompt,
|
| 791 |
)
|
| 792 |
else:
|
lux3d_api.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
API 调用
|
| 2 |
+
概述
|
| 3 |
+
Lux3D 当前提供 2 个接口,用于完成图生 3D 的异步生成流程:
|
| 4 |
+
|
| 5 |
+
创建任务
|
| 6 |
+
查询任务
|
| 7 |
+
调用流程如下:
|
| 8 |
+
|
| 9 |
+
API Key -> 解析 appuid/appkey/appsecret -> 生成 sign -> 创建任务 -> 查询任务 -> 获取 lux3d_url
|
| 10 |
+
建议每 10-15 秒轮询查询任务状态。
|
| 11 |
+
|
| 12 |
+
鉴权说明
|
| 13 |
+
API 调用使用签名方式鉴权。
|
| 14 |
+
|
| 15 |
+
开发者申请到的 API Key 并不是直接作为请求参数透传给接口,而是需要先解析出以下信息:
|
| 16 |
+
|
| 17 |
+
appuid
|
| 18 |
+
appkey
|
| 19 |
+
appsecret
|
| 20 |
+
随后按以下规则生成签名:
|
| 21 |
+
|
| 22 |
+
sign = MD5(appsecret + appkey + appuid + timestamp)
|
| 23 |
+
其中:
|
| 24 |
+
|
| 25 |
+
timestamp 为毫秒级时间戳
|
| 26 |
+
sign 作为 query 参数参与接口调用
|
| 27 |
+
目前 Lux3D 不提供独立的服务端接口帮助开发者解析 API Key。 如果使用 API 方式接入,开发者需要在本地或自己的服务中先完成 API Key 解析,再使用解析结果调用 Lux3D API。
|
| 28 |
+
|
| 29 |
+
API Key 解析结果
|
| 30 |
+
根据当前实现,API Key 可解析出以下结构:
|
| 31 |
+
|
| 32 |
+
version:appkey:appsecret:appuid
|
| 33 |
+
编码方式为 Base64。
|
| 34 |
+
|
| 35 |
+
API Key 解析示例
|
| 36 |
+
以下示例展示如何从 API Key 中解析出 appkey、appsecret 和 appuid。
|
| 37 |
+
|
| 38 |
+
JavaScript
|
| 39 |
+
const apiKey = "YOUR_API_KEY";
|
| 40 |
+
const decoded =
|
| 41 |
+
typeof atob === "function"
|
| 42 |
+
? atob(apiKey)
|
| 43 |
+
: Buffer.from(apiKey, "base64").toString("utf-8");
|
| 44 |
+
const [version, appkey, appsecret, appuid] = decoded.split(":");
|
| 45 |
+
|
| 46 |
+
if (!version || !appkey || !appsecret || !appuid) {
|
| 47 |
+
throw new Error("Invalid API Key format");
|
| 48 |
+
}
|
| 49 |
+
Python
|
| 50 |
+
import base64
|
| 51 |
+
|
| 52 |
+
api_key = "YOUR_API_KEY"
|
| 53 |
+
decoded = base64.b64decode(api_key).decode("utf-8")
|
| 54 |
+
version, appkey, appsecret, appuid = decoded.split(":")
|
| 55 |
+
签名生成示例
|
| 56 |
+
以下示例展示如何基于解析结果生成 sign。
|
| 57 |
+
|
| 58 |
+
JavaScript
|
| 59 |
+
const appkey = "YOUR_APPKEY";
|
| 60 |
+
const appsecret = "YOUR_APPSECRET";
|
| 61 |
+
const appuid = "YOUR_APPUID";
|
| 62 |
+
const timestamp = Date.now().toString();
|
| 63 |
+
const sign = CryptoJS.MD5(appsecret + appkey + appuid + timestamp).toString();
|
| 64 |
+
Python
|
| 65 |
+
import hashlib
|
| 66 |
+
import time
|
| 67 |
+
|
| 68 |
+
appkey = "YOUR_APPKEY"
|
| 69 |
+
appsecret = "YOUR_APPSECRET"
|
| 70 |
+
appuid = "YOUR_APPUID"
|
| 71 |
+
timestamp = str(int(time.time() * 1000))
|
| 72 |
+
sign = hashlib.md5((appsecret + appkey + appuid + timestamp).encode("utf-8")).hexdigest()
|
| 73 |
+
接口列表
|
| 74 |
+
接口 方法 说明
|
| 75 |
+
https://api.luxreal.ai/global/lux3d/generate/task/create POST 创建图生 3D 任务
|
| 76 |
+
https://api.luxreal.ai/global/lux3d/generate/task/get GET 查询图生 3D 任务状态与结果
|
| 77 |
+
创建图生 3D 任务
|
| 78 |
+
描述
|
| 79 |
+
创建图生 3D 任务。
|
| 80 |
+
|
| 81 |
+
请求成功后返回任务 ID busid,后续可通过查询接口获取任务状态和结果。
|
| 82 |
+
|
| 83 |
+
API
|
| 84 |
+
POST https://api.luxreal.ai/global/lux3d/generate/task/create
|
| 85 |
+
Query 参数
|
| 86 |
+
参数 是否必须 类型 说明
|
| 87 |
+
appuid 是 string 第三方用户 ID
|
| 88 |
+
appkey 是 string 由 API Key 解析得到
|
| 89 |
+
timestamp 是 long 毫秒级时间戳
|
| 90 |
+
sign 是 string 签名结果
|
| 91 |
+
Request Body
|
| 92 |
+
{
|
| 93 |
+
"img": ""
|
| 94 |
+
}
|
| 95 |
+
Body 参数说明
|
| 96 |
+
参数 是否必须 类型 说明
|
| 97 |
+
img 是 string 图片 Base64,建议使用 Data URL 格式,例如 data:image/png;base64,...
|
| 98 |
+
响应
|
| 99 |
+
{
|
| 100 |
+
"d": "",
|
| 101 |
+
"m": null,
|
| 102 |
+
"c": null
|
| 103 |
+
}
|
| 104 |
+
响应字段说明
|
| 105 |
+
参数 是否必须 类型 说明
|
| 106 |
+
d 是 long 返回 busid
|
| 107 |
+
m 是 string null
|
| 108 |
+
c 是 string null
|
| 109 |
+
示例
|
| 110 |
+
curl -X POST "https://api.luxreal.ai/global/lux3d/generate/task/create?appuid=YOUR_APPUID&appkey=YOUR_APPKEY×tamp=YOUR_TIMESTAMP&sign=YOUR_SIGN" \
|
| 111 |
+
-H "Content-Type: application/json" \
|
| 112 |
+
-d '{
|
| 113 |
+
"img": "data:image/png;base64,BASE64_IMAGE_STRING"
|
| 114 |
+
}'
|
| 115 |
+
查询图生 3D 任务
|
| 116 |
+
描述
|
| 117 |
+
根据 busid 查询任务状态和结果。
|
| 118 |
+
|
| 119 |
+
查询结果中的输出内容有效期为 2 小时,建议在任务成功后尽快获取并保存结果。
|
| 120 |
+
|
| 121 |
+
API
|
| 122 |
+
GET https://api.luxreal.ai/global/lux3d/generate/task/get
|
| 123 |
+
Query 参数
|
| 124 |
+
参数 是否必须 类型 说明
|
| 125 |
+
appuid 是 string 第三方用户 ID
|
| 126 |
+
appkey 是 string 由 API Key 解析得到
|
| 127 |
+
timestamp 是 long 毫秒级时间戳
|
| 128 |
+
sign 是 string 签名结果
|
| 129 |
+
busid 是 long 任务 ID
|
| 130 |
+
响应
|
| 131 |
+
{
|
| 132 |
+
"d": {
|
| 133 |
+
"busId": "",
|
| 134 |
+
"outputs": [
|
| 135 |
+
{
|
| 136 |
+
"content": null
|
| 137 |
+
}
|
| 138 |
+
],
|
| 139 |
+
"status": ""
|
| 140 |
+
},
|
| 141 |
+
"m": null,
|
| 142 |
+
"c": null
|
| 143 |
+
}
|
| 144 |
+
响应字段说明
|
| 145 |
+
参数 是否必须 类型 说明
|
| 146 |
+
d.busId 是 long 主线 ID
|
| 147 |
+
d.status 是 int 0 初始化,1 进行中,3 成功,4 失败
|
| 148 |
+
d.outputs 否 list<object> 输出列表
|
| 149 |
+
d.outputs[].content 否 string 结果内容
|
| 150 |
+
m 是 string null
|
| 151 |
+
c 是 string null
|
| 152 |
+
示例
|
| 153 |
+
curl -X GET "https://api.luxreal.ai/global/lux3d/generate/task/get?appuid=YOUR_APPUID&appkey=YOUR_APPKEY×tamp=YOUR_TIMESTAMP&sign=YOUR_SIGN&busid=123456789"
|