@forgent3d/cad-runtime 0.1.2 → 0.1.4

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.
package/README.md CHANGED
@@ -9,7 +9,7 @@ This package is intentionally runtime-light:
9
9
  - no React
10
10
  - no database clients
11
11
 
12
- `cad-agent` and `cf-sandbox` can both depend on this package without coupling their deploy targets together.
12
+ `@forgent3d/cloud` and `cf-sandbox` can both depend on this package without coupling their deploy targets together.
13
13
 
14
14
  ## Python Runtime Assets
15
15
 
package/dist/index.cjs CHANGED
@@ -31,8 +31,7 @@ module.exports = __toCommonJS(index_exports);
31
31
  var SANDBOX_AUTH_SCHEME = "Bearer";
32
32
  var SANDBOX_API_PATHS = {
33
33
  resolve: "/v1/sandboxes/resolve",
34
- runTool: "/v1/tools/run",
35
- export: "/v1/export"
34
+ runTool: "/v1/tools/run"
36
35
  };
37
36
  function normalizeSandboxSecret(secret) {
38
37
  const normalized = secret?.trim();
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/sandbox-protocol.ts"],"sourcesContent":["export {\n SANDBOX_API_PATHS,\n SANDBOX_AUTH_SCHEME,\n normalizeSandboxSecret,\n sandboxAuthorizationHeader,\n type SandboxApiPath,\n} from \"./sandbox-protocol.js\";\n","export const SANDBOX_AUTH_SCHEME = \"Bearer\";\n\nexport const SANDBOX_API_PATHS = {\n resolve: \"/v1/sandboxes/resolve\",\n runTool: \"/v1/tools/run\",\n export: \"/v1/export\",\n} as const;\n\nexport type SandboxApiPath = (typeof SANDBOX_API_PATHS)[keyof typeof SANDBOX_API_PATHS];\n\nexport function normalizeSandboxSecret(secret: string | undefined): string | undefined {\n const normalized = secret?.trim();\n return normalized || undefined;\n}\n\nexport function sandboxAuthorizationHeader(secret: string | undefined): string | undefined {\n const normalized = normalizeSandboxSecret(secret);\n return normalized ? `${SANDBOX_AUTH_SCHEME} ${normalized}` : undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAO,IAAM,sBAAsB;AAE5B,IAAM,oBAAoB;AAAA,EAC/B,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AACV;AAIO,SAAS,uBAAuB,QAAgD;AACrF,QAAM,aAAa,QAAQ,KAAK;AAChC,SAAO,cAAc;AACvB;AAEO,SAAS,2BAA2B,QAAgD;AACzF,QAAM,aAAa,uBAAuB,MAAM;AAChD,SAAO,aAAa,GAAG,mBAAmB,IAAI,UAAU,KAAK;AAC/D;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/sandbox-protocol.ts"],"sourcesContent":["export {\n SANDBOX_API_PATHS,\n SANDBOX_AUTH_SCHEME,\n normalizeSandboxSecret,\n sandboxAuthorizationHeader,\n type SandboxApiPath,\n} from \"./sandbox-protocol.js\";\n","export const SANDBOX_AUTH_SCHEME = \"Bearer\";\n\nexport const SANDBOX_API_PATHS = {\n resolve: \"/v1/sandboxes/resolve\",\n runTool: \"/v1/tools/run\",\n} as const;\n\nexport type SandboxApiPath = (typeof SANDBOX_API_PATHS)[keyof typeof SANDBOX_API_PATHS];\n\nexport function normalizeSandboxSecret(secret: string | undefined): string | undefined {\n const normalized = secret?.trim();\n return normalized || undefined;\n}\n\nexport function sandboxAuthorizationHeader(secret: string | undefined): string | undefined {\n const normalized = normalizeSandboxSecret(secret);\n return normalized ? `${SANDBOX_AUTH_SCHEME} ${normalized}` : undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAO,IAAM,sBAAsB;AAE5B,IAAM,oBAAoB;AAAA,EAC/B,SAAS;AAAA,EACT,SAAS;AACX;AAIO,SAAS,uBAAuB,QAAgD;AACrF,QAAM,aAAa,QAAQ,KAAK;AAChC,SAAO,cAAc;AACvB;AAEO,SAAS,2BAA2B,QAAgD;AACzF,QAAM,aAAa,uBAAuB,MAAM;AAChD,SAAO,aAAa,GAAG,mBAAmB,IAAI,UAAU,KAAK;AAC/D;","names":[]}
package/dist/index.d.cts CHANGED
@@ -2,7 +2,6 @@ declare const SANDBOX_AUTH_SCHEME = "Bearer";
2
2
  declare const SANDBOX_API_PATHS: {
3
3
  readonly resolve: "/v1/sandboxes/resolve";
4
4
  readonly runTool: "/v1/tools/run";
5
- readonly export: "/v1/export";
6
5
  };
7
6
  type SandboxApiPath = (typeof SANDBOX_API_PATHS)[keyof typeof SANDBOX_API_PATHS];
8
7
  declare function normalizeSandboxSecret(secret: string | undefined): string | undefined;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,6 @@ declare const SANDBOX_AUTH_SCHEME = "Bearer";
2
2
  declare const SANDBOX_API_PATHS: {
3
3
  readonly resolve: "/v1/sandboxes/resolve";
4
4
  readonly runTool: "/v1/tools/run";
5
- readonly export: "/v1/export";
6
5
  };
7
6
  type SandboxApiPath = (typeof SANDBOX_API_PATHS)[keyof typeof SANDBOX_API_PATHS];
8
7
  declare function normalizeSandboxSecret(secret: string | undefined): string | undefined;
package/dist/index.js CHANGED
@@ -2,8 +2,7 @@
2
2
  var SANDBOX_AUTH_SCHEME = "Bearer";
3
3
  var SANDBOX_API_PATHS = {
4
4
  resolve: "/v1/sandboxes/resolve",
5
- runTool: "/v1/tools/run",
6
- export: "/v1/export"
5
+ runTool: "/v1/tools/run"
7
6
  };
8
7
  function normalizeSandboxSecret(secret) {
9
8
  const normalized = secret?.trim();
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/sandbox-protocol.ts"],"sourcesContent":["export const SANDBOX_AUTH_SCHEME = \"Bearer\";\n\nexport const SANDBOX_API_PATHS = {\n resolve: \"/v1/sandboxes/resolve\",\n runTool: \"/v1/tools/run\",\n export: \"/v1/export\",\n} as const;\n\nexport type SandboxApiPath = (typeof SANDBOX_API_PATHS)[keyof typeof SANDBOX_API_PATHS];\n\nexport function normalizeSandboxSecret(secret: string | undefined): string | undefined {\n const normalized = secret?.trim();\n return normalized || undefined;\n}\n\nexport function sandboxAuthorizationHeader(secret: string | undefined): string | undefined {\n const normalized = normalizeSandboxSecret(secret);\n return normalized ? `${SANDBOX_AUTH_SCHEME} ${normalized}` : undefined;\n}\n"],"mappings":";AAAO,IAAM,sBAAsB;AAE5B,IAAM,oBAAoB;AAAA,EAC/B,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AACV;AAIO,SAAS,uBAAuB,QAAgD;AACrF,QAAM,aAAa,QAAQ,KAAK;AAChC,SAAO,cAAc;AACvB;AAEO,SAAS,2BAA2B,QAAgD;AACzF,QAAM,aAAa,uBAAuB,MAAM;AAChD,SAAO,aAAa,GAAG,mBAAmB,IAAI,UAAU,KAAK;AAC/D;","names":[]}
1
+ {"version":3,"sources":["../src/sandbox-protocol.ts"],"sourcesContent":["export const SANDBOX_AUTH_SCHEME = \"Bearer\";\n\nexport const SANDBOX_API_PATHS = {\n resolve: \"/v1/sandboxes/resolve\",\n runTool: \"/v1/tools/run\",\n} as const;\n\nexport type SandboxApiPath = (typeof SANDBOX_API_PATHS)[keyof typeof SANDBOX_API_PATHS];\n\nexport function normalizeSandboxSecret(secret: string | undefined): string | undefined {\n const normalized = secret?.trim();\n return normalized || undefined;\n}\n\nexport function sandboxAuthorizationHeader(secret: string | undefined): string | undefined {\n const normalized = normalizeSandboxSecret(secret);\n return normalized ? `${SANDBOX_AUTH_SCHEME} ${normalized}` : undefined;\n}\n"],"mappings":";AAAO,IAAM,sBAAsB;AAE5B,IAAM,oBAAoB;AAAA,EAC/B,SAAS;AAAA,EACT,SAAS;AACX;AAIO,SAAS,uBAAuB,QAAgD;AACrF,QAAM,aAAa,QAAQ,KAAK;AAChC,SAAO,cAAc;AACvB;AAEO,SAAS,2BAA2B,QAAgD;AACzF,QAAM,aAAa,uBAAuB,MAAM;AAChD,SAAO,aAAa,GAAG,mBAAmB,IAAI,UAAU,KAAK;AAC/D;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgent3d/cad-runtime",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Shared CAD runtime protocol, types, and pure utilities for Forgent3D cloud services.",
5
5
  "license": "MIT",
6
6
  "author": "Forgent3D",
@@ -3,9 +3,9 @@ export_runner.py - Auto-managed by AI CAD Companion Viewer (do not edit manually
3
3
  ----------------------------------------------------------------------------
4
4
  Responsibilities:
5
5
  * Accept --model <name> (and legacy --part <name>);
6
- * Optionally support on-demand exports via --export-format/--output (step/stl/brep);
7
- * Load models/<model>/parts/<part-name>/part.py and read a geometry object named result;
8
- * Export build123d geometry to .cache/<name>.brep (and STEP/STL) via OCCT APIs;
6
+ * Optionally support on-demand exports via --export-format/--output (step/stl/obj/brep/glb/3mf);
7
+ * Load models/<model>/assembly.py, part.py, or parts/<part-name>/part.py and read result;
8
+ * Export build123d geometry to .cache/<name>.brep and requested exchange/mesh formats;
9
9
  * Let the frontend parse BREP via occt-import-js for geometry inspection.
10
10
  """
11
11
  import os
@@ -18,8 +18,11 @@ if sys.platform == "win32":
18
18
 
19
19
  import argparse
20
20
  import json
21
+ import struct
22
+ import tempfile
21
23
  import time
22
24
  import traceback
25
+ import zipfile
23
26
 
24
27
  HERE = os.path.dirname(os.path.abspath(__file__))
25
28
  PROJECT_ROOT = HERE
@@ -27,6 +30,58 @@ MODELS_DIR = os.path.join(PROJECT_ROOT, "models")
27
30
  CACHE_DIR = os.path.join(PROJECT_ROOT, ".cache")
28
31
  MODEL_KINDS = ("part",)
29
32
 
33
+ # Sentinel-prefixed line emitted on stdout when --emit-build-json is passed, so a
34
+ # caller can read the build summary (bbox/anchors/hasResult) from this single run
35
+ # instead of a separate aicad-script build pass. Keep in sync with cf-sandbox rebuild.ts.
36
+ BUILD_JSON_SENTINEL = "@@AICAD_BUILD_JSON@@"
37
+
38
+
39
+ def _emit_build_json(payload) -> None:
40
+ print(BUILD_JSON_SENTINEL + json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
41
+
42
+
43
+ def _bbox_summary(shape):
44
+ if shape is None or not hasattr(shape, "bounding_box"):
45
+ return None
46
+ try:
47
+ bb = shape.bounding_box()
48
+ return {
49
+ "min": [float(bb.min.X), float(bb.min.Y), float(bb.min.Z)],
50
+ "max": [float(bb.max.X), float(bb.max.Y), float(bb.max.Z)],
51
+ "size": [
52
+ float(bb.max.X - bb.min.X),
53
+ float(bb.max.Y - bb.min.Y),
54
+ float(bb.max.Z - bb.min.Z),
55
+ ],
56
+ }
57
+ except Exception:
58
+ return None
59
+
60
+
61
+ def _build_summary_payload(model_name, part_name, source_path, ns, result):
62
+ metadata = ns.get("metadata") if isinstance(ns, dict) else None
63
+ anchors = None
64
+ metadata_keys = []
65
+ if isinstance(metadata, dict):
66
+ metadata_keys = sorted(str(k) for k in metadata.keys())
67
+ if isinstance(metadata.get("anchors"), dict):
68
+ try:
69
+ anchors = _json_safe(metadata.get("anchors"))
70
+ except Exception:
71
+ anchors = None
72
+ return {
73
+ "ok": result is not None,
74
+ "script": "build",
75
+ "model": model_name,
76
+ "part": part_name,
77
+ "source": os.path.relpath(source_path, PROJECT_ROOT).replace(os.sep, "/") if source_path else None,
78
+ "resultType": type(result).__name__ if result is not None else None,
79
+ "hasResult": result is not None,
80
+ "bbox": _bbox_summary(result),
81
+ "metadataKeys": metadata_keys,
82
+ "metadataAnchors": anchors,
83
+ }
84
+
30
85
 
31
86
  def _looks_like_build123d(obj) -> bool:
32
87
  return any(c.__module__.startswith("build123d") for c in type(obj).__mro__)
@@ -94,6 +149,159 @@ def _write_stl(shape, path_out):
94
149
  raise RuntimeError(f"Unable to export STL: {exc}")
95
150
 
96
151
 
152
+ def _xml_escape_text(value):
153
+ return (
154
+ str(value if value is not None else "")
155
+ .replace("&", "&amp;")
156
+ .replace("<", "&lt;")
157
+ .replace(">", "&gt;")
158
+ )
159
+
160
+
161
+ def _xml_escape_attr(value):
162
+ return _xml_escape_text(value).replace('"', "&quot;").replace("'", "&apos;")
163
+
164
+
165
+ def _format_3mf_number(value):
166
+ num = float(value)
167
+ if abs(num) < 1e-9:
168
+ num = 0.0
169
+ if num.is_integer():
170
+ return str(int(num))
171
+ return f"{num:.6f}".rstrip("0").rstrip(".") or "0"
172
+
173
+
174
+ def _rgba_to_3mf_color(rgba):
175
+ if not rgba:
176
+ rgba = (0xb0 / 255, 0xb0 / 255, 0xb0 / 255, 1.0)
177
+ values = []
178
+ for i, fallback in enumerate((0.69, 0.69, 0.69, 1.0)):
179
+ try:
180
+ v = float(rgba[i])
181
+ except Exception:
182
+ v = fallback
183
+ values.append(max(0, min(255, round(max(0.0, min(1.0, v)) * 255))))
184
+ return "#" + "".join(f"{v:02X}" for v in values)
185
+
186
+
187
+ def _read_stl_triangles(path_in):
188
+ with open(path_in, "rb") as f:
189
+ data = f.read()
190
+ if len(data) >= 84:
191
+ tri_count = struct.unpack("<I", data[80:84])[0]
192
+ expected = 84 + tri_count * 50
193
+ if expected == len(data):
194
+ triangles = []
195
+ offset = 84
196
+ for _ in range(tri_count):
197
+ values = struct.unpack("<12fH", data[offset:offset + 50])
198
+ triangles.append((
199
+ (values[3], values[4], values[5]),
200
+ (values[6], values[7], values[8]),
201
+ (values[9], values[10], values[11]),
202
+ ))
203
+ offset += 50
204
+ return triangles
205
+
206
+ text = data.decode("utf-8", errors="ignore")
207
+ vertices = []
208
+ for line in text.splitlines():
209
+ parts = line.strip().split()
210
+ if len(parts) >= 4 and parts[0].lower() == "vertex":
211
+ try:
212
+ vertices.append((float(parts[1]), float(parts[2]), float(parts[3])))
213
+ except ValueError:
214
+ pass
215
+ if len(vertices) < 3 or len(vertices) % 3 != 0:
216
+ raise RuntimeError("Unable to parse STL triangles for mesh export")
217
+ return [(vertices[i], vertices[i + 1], vertices[i + 2]) for i in range(0, len(vertices), 3)]
218
+
219
+
220
+ def _write_obj_mesh(triangles, path_out, title="Forgent3D model"):
221
+ if not triangles:
222
+ raise RuntimeError("OBJ export found no triangles")
223
+ with open(path_out, "w", encoding="utf-8", newline="\n") as f:
224
+ f.write("# Forgent3D OBJ export\n")
225
+ f.write(f"o {_xml_escape_text(title or 'model')}\n")
226
+ for tri in triangles:
227
+ for vertex in tri:
228
+ f.write(
229
+ f"v {_format_3mf_number(vertex[0])} "
230
+ f"{_format_3mf_number(vertex[1])} "
231
+ f"{_format_3mf_number(vertex[2])}\n"
232
+ )
233
+ for i in range(0, len(triangles) * 3, 3):
234
+ f.write(f"f {i + 1} {i + 2} {i + 3}\n")
235
+
236
+
237
+ def _write_3mf_archive(triangles, path_out, title="Forgent3D model", color=None):
238
+ if not triangles:
239
+ raise RuntimeError("3MF export found no triangles")
240
+ display_color = _rgba_to_3mf_color(color)
241
+ vertices_xml = []
242
+ triangles_xml = []
243
+ vertex_index = 0
244
+ for tri in triangles:
245
+ indices = []
246
+ for vertex in tri:
247
+ indices.append(vertex_index)
248
+ vertices_xml.append(
249
+ f' <vertex x="{_format_3mf_number(vertex[0])}" '
250
+ f'y="{_format_3mf_number(vertex[1])}" '
251
+ f'z="{_format_3mf_number(vertex[2])}" />'
252
+ )
253
+ vertex_index += 1
254
+ triangles_xml.append(
255
+ f' <triangle v1="{indices[0]}" v2="{indices[1]}" v3="{indices[2]}" />'
256
+ )
257
+
258
+ safe_title = _xml_escape_text(str(title or "Forgent3D model")[:128])
259
+ model_xml = "\n".join([
260
+ '<?xml version="1.0" encoding="UTF-8"?>',
261
+ '<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">',
262
+ f' <metadata name="Title">{safe_title}</metadata>',
263
+ ' <resources>',
264
+ ' <basematerials id="1">',
265
+ f' <base name="Default" displaycolor="{display_color}" />',
266
+ ' </basematerials>',
267
+ f' <object id="2" type="model" name="{_xml_escape_attr(title or "model")}" pid="1" pindex="0">',
268
+ ' <mesh>',
269
+ ' <vertices>',
270
+ *vertices_xml,
271
+ ' </vertices>',
272
+ ' <triangles>',
273
+ *triangles_xml,
274
+ ' </triangles>',
275
+ ' </mesh>',
276
+ ' </object>',
277
+ ' </resources>',
278
+ ' <build>',
279
+ ' <item objectid="2" />',
280
+ ' </build>',
281
+ '</model>',
282
+ '',
283
+ ])
284
+ content_types_xml = "\n".join([
285
+ '<?xml version="1.0" encoding="UTF-8"?>',
286
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
287
+ ' <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />',
288
+ ' <Override PartName="/3D/3dmodel.model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml" />',
289
+ '</Types>',
290
+ '',
291
+ ])
292
+ rels_xml = "\n".join([
293
+ '<?xml version="1.0" encoding="UTF-8"?>',
294
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
295
+ ' <Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel" />',
296
+ '</Relationships>',
297
+ '',
298
+ ])
299
+ with zipfile.ZipFile(path_out, "w", compression=zipfile.ZIP_DEFLATED) as z:
300
+ z.writestr("[Content_Types].xml", content_types_xml)
301
+ z.writestr("_rels/.rels", rels_xml)
302
+ z.writestr("3D/3dmodel.model", model_xml)
303
+
304
+
97
305
  # Keep in sync with packages/electron/src/viewer-materials.ts MATERIAL_PRESETS.
98
306
  _VIEWER_PRESET_COLORS = {
99
307
  "cad_clay": (0xc8 / 255, 0xd0 / 255, 0xdc / 255, 1.0),
@@ -218,6 +426,44 @@ def _write_glb(shape, path_out, params=None):
218
426
  raise RuntimeError(f"Unable to export GLB: {exc}")
219
427
 
220
428
 
429
+ def _write_3mf(shape, path_out, params=None, title="Forgent3D model"):
430
+ fd, tmp_stl = tempfile.mkstemp(suffix=".stl")
431
+ os.close(fd)
432
+ try:
433
+ _write_stl(shape, tmp_stl)
434
+ triangles = _read_stl_triangles(tmp_stl)
435
+ finally:
436
+ try:
437
+ os.unlink(tmp_stl)
438
+ except OSError:
439
+ pass
440
+
441
+ color = None
442
+ if isinstance(params, dict):
443
+ viewer = params.get("__viewer") or params.get("viewer") or {}
444
+ materials = viewer.get("materials") if isinstance(viewer, dict) else None
445
+ if isinstance(materials, dict):
446
+ color = _resolve_material_color(materials.get("default"))
447
+ _write_3mf_archive(triangles, path_out, title=title, color=color)
448
+ return "export_runner.3mf_from_stl_mesh"
449
+
450
+
451
+ def _write_obj(shape, path_out, title="Forgent3D model"):
452
+ fd, tmp_stl = tempfile.mkstemp(suffix=".stl")
453
+ os.close(fd)
454
+ try:
455
+ _write_stl(shape, tmp_stl)
456
+ triangles = _read_stl_triangles(tmp_stl)
457
+ finally:
458
+ try:
459
+ os.unlink(tmp_stl)
460
+ except OSError:
461
+ pass
462
+
463
+ _write_obj_mesh(triangles, path_out, title=title)
464
+ return "export_runner.obj_from_stl_mesh"
465
+
466
+
221
467
  def _resolve_model_source(model_name: str, part_name: str, source_override: str = None):
222
468
  if source_override:
223
469
  candidate = source_override
@@ -358,7 +604,7 @@ def _build_namespace(model_name: str, part_name: str, source_override: str = Non
358
604
  return ns, source_path, 0
359
605
 
360
606
 
361
- def build_one(model_name: str, part_name: str = None, export_format: str = "brep", output: str = None, source_override: str = None) -> int:
607
+ def build_one(model_name: str, part_name: str = None, export_format: str = "brep", output: str = None, source_override: str = None, emit_build_json: bool = False) -> int:
362
608
  part_name = part_name or model_name
363
609
  build_started = time.perf_counter()
364
610
  ns, source_path, err = _build_namespace(model_name, part_name, source_override)
@@ -372,6 +618,8 @@ def build_one(model_name: str, part_name: str = None, export_format: str = "brep
372
618
  if candidate is not None:
373
619
  result = candidate
374
620
  if result is None:
621
+ if emit_build_json:
622
+ _emit_build_json(_build_summary_payload(model_name, part_name, source_path, ns, None))
375
623
  print(f"[export_runner] {source_path} must define a global result (or assembly) object (build123d).",
376
624
  file=sys.stderr)
377
625
  return 4
@@ -381,9 +629,11 @@ def build_one(model_name: str, part_name: str = None, export_format: str = "brep
381
629
  except Exception as exc:
382
630
  print(f"[export_runner] Failed to write metadata.json: {exc}", file=sys.stderr)
383
631
  return 8
632
+ if emit_build_json:
633
+ _emit_build_json(_build_summary_payload(model_name, part_name, source_path, ns, result))
384
634
 
385
635
  fmt = (export_format or "brep").strip().lower()
386
- if fmt not in ("brep", "step", "stl", "glb"):
636
+ if fmt not in ("brep", "step", "stl", "obj", "glb", "3mf"):
387
637
  print(f"[export_runner] Unsupported export format: {fmt}", file=sys.stderr)
388
638
  return 7
389
639
 
@@ -402,6 +652,10 @@ def build_one(model_name: str, part_name: str = None, export_format: str = "brep
402
652
  method = _write_step(result, out)
403
653
  elif fmt == "glb":
404
654
  method = _write_glb(result, out, _load_model_params(model_name))
655
+ elif fmt == "obj":
656
+ method = _write_obj(result, out, model_name)
657
+ elif fmt == "3mf":
658
+ method = _write_3mf(result, out, _load_model_params(model_name), model_name)
405
659
  else:
406
660
  method = _write_stl(result, out)
407
661
  export_elapsed = time.perf_counter() - export_started
@@ -425,8 +679,10 @@ def main() -> int:
425
679
  parser.add_argument("--part", default=None, help="Legacy alias for --model")
426
680
  parser.add_argument("--part-name", default=None, help="Part directory name inside models/<model>/parts/")
427
681
  parser.add_argument("--source", default=None, help="Optional project-relative or absolute source file path (overrides default lookup)")
428
- parser.add_argument("--export-format", default="brep", choices=["brep", "step", "stl", "glb"])
682
+ parser.add_argument("--export-format", default="brep", choices=["brep", "step", "stl", "obj", "glb", "3mf"])
429
683
  parser.add_argument("--output", default=None, help="Optional absolute path for exported file")
684
+ parser.add_argument("--emit-build-json", action="store_true",
685
+ help="Emit a sentinel-prefixed build-summary JSON line on stdout (cloud rebuild fast path)")
430
686
  args = parser.parse_args()
431
687
  global PROJECT_ROOT, MODELS_DIR, CACHE_DIR
432
688
  PROJECT_ROOT = os.path.abspath(args.project) if args.project else HERE
@@ -435,7 +691,7 @@ def main() -> int:
435
691
  model_name = args.model or args.part
436
692
  if not model_name:
437
693
  parser.error("one of --model / --part is required")
438
- return build_one(model_name, args.part_name or model_name, args.export_format, args.output, args.source)
694
+ return build_one(model_name, args.part_name or model_name, args.export_format, args.output, args.source, args.emit_build_json)
439
695
 
440
696
 
441
697
  if __name__ == "__main__":