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 作为示例输入图

Files changed (2) hide show
  1. agent_loop.py +391 -20
  2. 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
- DEFAULT_OUTPUT_PATH = "outputs/model.step"
 
23
  STEP_SUFFIXES = (".step", ".stp")
 
 
 
 
 
 
24
 
25
  SYSTEM = f"""
26
- You are a CAD generation agent at {WORKDIR}.
27
 
28
- Your only tool is execute. For CAD tasks:
 
 
 
 
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 execute with the code and optional output_path.
33
- - Each successful execute writes into a unique run directory to avoid overwriting previous models.
34
- - If execute returns ok=false, inspect the structured error, fix the code, and call execute again.
35
- - When execute returns ok=true, report the output_path to the user.
 
 
 
 
 
 
 
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
- for _ in range(20):
77
- run_id = new_run_id()
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
- raise RuntimeError("Failed to allocate a unique output run directory.")
 
 
 
 
 
 
 
 
 
 
 
 
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 run_execute(code: str, output_path: str = DEFAULT_OUTPUT_PATH, prompt: str | None = None) -> str:
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": "execute requires non-empty CadQuery code.",
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
- "execute": lambda **kw: run_execute(kw["code"], kw.get("output_path", DEFAULT_OUTPUT_PATH)),
 
 
 
 
 
 
 
362
  }
363
 
364
  TOOLS = [
365
  {
366
- "name": "execute",
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 == "execute":
416
- output = run_execute(
417
  code=block.input["code"],
418
- output_path=block.input.get("output_path", DEFAULT_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&timestamp=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&timestamp=YOUR_TIMESTAMP&sign=YOUR_SIGN&busid=123456789"