@forgent3d/cad-runtime 0.1.1 → 0.1.3

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/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.1",
3
+ "version": "0.1.3",
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
@@ -94,18 +97,321 @@ def _write_stl(shape, path_out):
94
97
  raise RuntimeError(f"Unable to export STL: {exc}")
95
98
 
96
99
 
97
- def _write_glb(shape, path_out):
100
+ def _xml_escape_text(value):
101
+ return (
102
+ str(value if value is not None else "")
103
+ .replace("&", "&amp;")
104
+ .replace("<", "&lt;")
105
+ .replace(">", "&gt;")
106
+ )
107
+
108
+
109
+ def _xml_escape_attr(value):
110
+ return _xml_escape_text(value).replace('"', "&quot;").replace("'", "&apos;")
111
+
112
+
113
+ def _format_3mf_number(value):
114
+ num = float(value)
115
+ if abs(num) < 1e-9:
116
+ num = 0.0
117
+ if num.is_integer():
118
+ return str(int(num))
119
+ return f"{num:.6f}".rstrip("0").rstrip(".") or "0"
120
+
121
+
122
+ def _rgba_to_3mf_color(rgba):
123
+ if not rgba:
124
+ rgba = (0xb0 / 255, 0xb0 / 255, 0xb0 / 255, 1.0)
125
+ values = []
126
+ for i, fallback in enumerate((0.69, 0.69, 0.69, 1.0)):
127
+ try:
128
+ v = float(rgba[i])
129
+ except Exception:
130
+ v = fallback
131
+ values.append(max(0, min(255, round(max(0.0, min(1.0, v)) * 255))))
132
+ return "#" + "".join(f"{v:02X}" for v in values)
133
+
134
+
135
+ def _read_stl_triangles(path_in):
136
+ with open(path_in, "rb") as f:
137
+ data = f.read()
138
+ if len(data) >= 84:
139
+ tri_count = struct.unpack("<I", data[80:84])[0]
140
+ expected = 84 + tri_count * 50
141
+ if expected == len(data):
142
+ triangles = []
143
+ offset = 84
144
+ for _ in range(tri_count):
145
+ values = struct.unpack("<12fH", data[offset:offset + 50])
146
+ triangles.append((
147
+ (values[3], values[4], values[5]),
148
+ (values[6], values[7], values[8]),
149
+ (values[9], values[10], values[11]),
150
+ ))
151
+ offset += 50
152
+ return triangles
153
+
154
+ text = data.decode("utf-8", errors="ignore")
155
+ vertices = []
156
+ for line in text.splitlines():
157
+ parts = line.strip().split()
158
+ if len(parts) >= 4 and parts[0].lower() == "vertex":
159
+ try:
160
+ vertices.append((float(parts[1]), float(parts[2]), float(parts[3])))
161
+ except ValueError:
162
+ pass
163
+ if len(vertices) < 3 or len(vertices) % 3 != 0:
164
+ raise RuntimeError("Unable to parse STL triangles for mesh export")
165
+ return [(vertices[i], vertices[i + 1], vertices[i + 2]) for i in range(0, len(vertices), 3)]
166
+
167
+
168
+ def _write_obj_mesh(triangles, path_out, title="Forgent3D model"):
169
+ if not triangles:
170
+ raise RuntimeError("OBJ export found no triangles")
171
+ with open(path_out, "w", encoding="utf-8", newline="\n") as f:
172
+ f.write("# Forgent3D OBJ export\n")
173
+ f.write(f"o {_xml_escape_text(title or 'model')}\n")
174
+ for tri in triangles:
175
+ for vertex in tri:
176
+ f.write(
177
+ f"v {_format_3mf_number(vertex[0])} "
178
+ f"{_format_3mf_number(vertex[1])} "
179
+ f"{_format_3mf_number(vertex[2])}\n"
180
+ )
181
+ for i in range(0, len(triangles) * 3, 3):
182
+ f.write(f"f {i + 1} {i + 2} {i + 3}\n")
183
+
184
+
185
+ def _write_3mf_archive(triangles, path_out, title="Forgent3D model", color=None):
186
+ if not triangles:
187
+ raise RuntimeError("3MF export found no triangles")
188
+ display_color = _rgba_to_3mf_color(color)
189
+ vertices_xml = []
190
+ triangles_xml = []
191
+ vertex_index = 0
192
+ for tri in triangles:
193
+ indices = []
194
+ for vertex in tri:
195
+ indices.append(vertex_index)
196
+ vertices_xml.append(
197
+ f' <vertex x="{_format_3mf_number(vertex[0])}" '
198
+ f'y="{_format_3mf_number(vertex[1])}" '
199
+ f'z="{_format_3mf_number(vertex[2])}" />'
200
+ )
201
+ vertex_index += 1
202
+ triangles_xml.append(
203
+ f' <triangle v1="{indices[0]}" v2="{indices[1]}" v3="{indices[2]}" />'
204
+ )
205
+
206
+ safe_title = _xml_escape_text(str(title or "Forgent3D model")[:128])
207
+ model_xml = "\n".join([
208
+ '<?xml version="1.0" encoding="UTF-8"?>',
209
+ '<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">',
210
+ f' <metadata name="Title">{safe_title}</metadata>',
211
+ ' <resources>',
212
+ ' <basematerials id="1">',
213
+ f' <base name="Default" displaycolor="{display_color}" />',
214
+ ' </basematerials>',
215
+ f' <object id="2" type="model" name="{_xml_escape_attr(title or "model")}" pid="1" pindex="0">',
216
+ ' <mesh>',
217
+ ' <vertices>',
218
+ *vertices_xml,
219
+ ' </vertices>',
220
+ ' <triangles>',
221
+ *triangles_xml,
222
+ ' </triangles>',
223
+ ' </mesh>',
224
+ ' </object>',
225
+ ' </resources>',
226
+ ' <build>',
227
+ ' <item objectid="2" />',
228
+ ' </build>',
229
+ '</model>',
230
+ '',
231
+ ])
232
+ content_types_xml = "\n".join([
233
+ '<?xml version="1.0" encoding="UTF-8"?>',
234
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
235
+ ' <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />',
236
+ ' <Override PartName="/3D/3dmodel.model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml" />',
237
+ '</Types>',
238
+ '',
239
+ ])
240
+ rels_xml = "\n".join([
241
+ '<?xml version="1.0" encoding="UTF-8"?>',
242
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
243
+ ' <Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel" />',
244
+ '</Relationships>',
245
+ '',
246
+ ])
247
+ with zipfile.ZipFile(path_out, "w", compression=zipfile.ZIP_DEFLATED) as z:
248
+ z.writestr("[Content_Types].xml", content_types_xml)
249
+ z.writestr("_rels/.rels", rels_xml)
250
+ z.writestr("3D/3dmodel.model", model_xml)
251
+
252
+
253
+ # Keep in sync with packages/electron/src/viewer-materials.ts MATERIAL_PRESETS.
254
+ _VIEWER_PRESET_COLORS = {
255
+ "cad_clay": (0xc8 / 255, 0xd0 / 255, 0xdc / 255, 1.0),
256
+ "matte_plastic": (0xb9 / 255, 0xc2 / 255, 0xd0 / 255, 1.0),
257
+ "gloss_plastic": (0xb9 / 255, 0xc2 / 255, 0xd0 / 255, 1.0),
258
+ "painted_metal": (0x8f / 255, 0xa3 / 255, 0xb8 / 255, 1.0),
259
+ "anodized_aluminum":(0x4f / 255, 0x8f / 255, 0xd8 / 255, 1.0),
260
+ "brushed_steel": (0xa7 / 255, 0xb0 / 255, 0xba / 255, 1.0),
261
+ "dark_steel": (0x4b / 255, 0x55 / 255, 0x63 / 255, 1.0),
262
+ "polished_metal": (0xd0 / 255, 0xd6 / 255, 0xdc / 255, 1.0),
263
+ "rubber": (0x24 / 255, 0x28 / 255, 0x32 / 255, 1.0),
264
+ "glass_clear": (0xd8 / 255, 0xec / 255, 0xff / 255, 0.34),
265
+ }
266
+
267
+
268
+ def _parse_hex_color(text):
269
+ s = str(text or "").strip().lstrip("#")
270
+ if len(s) == 3:
271
+ s = "".join(ch * 2 for ch in s)
272
+ if len(s) not in (6, 8):
273
+ return None
274
+ try:
275
+ r = int(s[0:2], 16) / 255
276
+ g = int(s[2:4], 16) / 255
277
+ b = int(s[4:6], 16) / 255
278
+ a = (int(s[6:8], 16) / 255) if len(s) == 8 else 1.0
279
+ return (r, g, b, a)
280
+ except ValueError:
281
+ return None
282
+
283
+
284
+ def _resolve_material_color(spec):
285
+ """Resolve a __viewer.materials entry to an (r, g, b, a) tuple of floats."""
286
+ if spec is None:
287
+ return None
288
+ if isinstance(spec, bool):
289
+ return None
290
+ if isinstance(spec, int):
291
+ v = spec & 0xffffff
292
+ return (((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255, 1.0)
293
+ if isinstance(spec, str):
294
+ text = spec.strip()
295
+ if text in _VIEWER_PRESET_COLORS:
296
+ return _VIEWER_PRESET_COLORS[text]
297
+ return _parse_hex_color(text)
298
+ if isinstance(spec, dict):
299
+ explicit = spec.get("color")
300
+ if explicit is not None:
301
+ resolved = _resolve_material_color(explicit)
302
+ if resolved is not None:
303
+ return resolved
304
+ preset_name = str(spec.get("preset") or spec.get("material") or "").strip()
305
+ if preset_name in _VIEWER_PRESET_COLORS:
306
+ return _VIEWER_PRESET_COLORS[preset_name]
307
+ return None
308
+
309
+
310
+ def _load_model_params(model_name: str):
311
+ path = os.path.join(MODELS_DIR, model_name, "params.json")
312
+ if not os.path.isfile(path):
313
+ return None
314
+ try:
315
+ with open(path, "r", encoding="utf-8") as f:
316
+ return json.load(f)
317
+ except Exception:
318
+ return None
319
+
320
+
321
+ def _apply_viewer_colors(shape, params):
322
+ """Stamp build123d .color from params.json __viewer.materials so export_gltf preserves it."""
323
+ if not isinstance(params, dict):
324
+ return
325
+ viewer = params.get("__viewer") or params.get("viewer") or {}
326
+ materials = viewer.get("materials") if isinstance(viewer, dict) else None
327
+ if not isinstance(materials, dict):
328
+ return
329
+ parts_spec = materials.get("parts") if isinstance(materials.get("parts"), dict) else {}
330
+ default_rgba = _resolve_material_color(materials.get("default"))
331
+
332
+ try:
333
+ from build123d import Color # type: ignore
334
+ except Exception:
335
+ return
336
+
337
+ def _set(node, rgba):
338
+ if rgba is None:
339
+ return
340
+ try:
341
+ node.color = Color(float(rgba[0]), float(rgba[1]), float(rgba[2]), float(rgba[3]))
342
+ except Exception:
343
+ pass
344
+
345
+ try:
346
+ children = list(getattr(shape, "children", []) or [])
347
+ except TypeError:
348
+ children = []
349
+
350
+ if not children:
351
+ # Single-shape model: apply default color if configured.
352
+ _set(shape, default_rgba)
353
+ return
354
+
355
+ for child in children:
356
+ label = str(getattr(child, "label", "") or "").strip()
357
+ spec = parts_spec.get(label) if label else None
358
+ rgba = _resolve_material_color(spec) if spec is not None else None
359
+ if rgba is None:
360
+ rgba = default_rgba
361
+ _set(child, rgba)
362
+
363
+
364
+ def _write_glb(shape, path_out, params=None):
98
365
  try:
99
366
  from build123d import export_gltf, Unit # type: ignore
100
367
  except Exception as exc:
101
368
  raise RuntimeError(f"build123d.export_gltf unavailable: {exc}")
102
369
  try:
370
+ _apply_viewer_colors(shape, params)
103
371
  export_gltf(shape, path_out, unit=Unit.MM, binary=True)
104
372
  return "build123d.export_gltf"
105
373
  except Exception as exc:
106
374
  raise RuntimeError(f"Unable to export GLB: {exc}")
107
375
 
108
376
 
377
+ def _write_3mf(shape, path_out, params=None, title="Forgent3D model"):
378
+ fd, tmp_stl = tempfile.mkstemp(suffix=".stl")
379
+ os.close(fd)
380
+ try:
381
+ _write_stl(shape, tmp_stl)
382
+ triangles = _read_stl_triangles(tmp_stl)
383
+ finally:
384
+ try:
385
+ os.unlink(tmp_stl)
386
+ except OSError:
387
+ pass
388
+
389
+ color = None
390
+ if isinstance(params, dict):
391
+ viewer = params.get("__viewer") or params.get("viewer") or {}
392
+ materials = viewer.get("materials") if isinstance(viewer, dict) else None
393
+ if isinstance(materials, dict):
394
+ color = _resolve_material_color(materials.get("default"))
395
+ _write_3mf_archive(triangles, path_out, title=title, color=color)
396
+ return "export_runner.3mf_from_stl_mesh"
397
+
398
+
399
+ def _write_obj(shape, path_out, title="Forgent3D model"):
400
+ fd, tmp_stl = tempfile.mkstemp(suffix=".stl")
401
+ os.close(fd)
402
+ try:
403
+ _write_stl(shape, tmp_stl)
404
+ triangles = _read_stl_triangles(tmp_stl)
405
+ finally:
406
+ try:
407
+ os.unlink(tmp_stl)
408
+ except OSError:
409
+ pass
410
+
411
+ _write_obj_mesh(triangles, path_out, title=title)
412
+ return "export_runner.obj_from_stl_mesh"
413
+
414
+
109
415
  def _resolve_model_source(model_name: str, part_name: str, source_override: str = None):
110
416
  if source_override:
111
417
  candidate = source_override
@@ -271,7 +577,7 @@ def build_one(model_name: str, part_name: str = None, export_format: str = "brep
271
577
  return 8
272
578
 
273
579
  fmt = (export_format or "brep").strip().lower()
274
- if fmt not in ("brep", "step", "stl", "glb"):
580
+ if fmt not in ("brep", "step", "stl", "obj", "glb", "3mf"):
275
581
  print(f"[export_runner] Unsupported export format: {fmt}", file=sys.stderr)
276
582
  return 7
277
583
 
@@ -289,7 +595,11 @@ def build_one(model_name: str, part_name: str = None, export_format: str = "brep
289
595
  elif fmt == "step":
290
596
  method = _write_step(result, out)
291
597
  elif fmt == "glb":
292
- method = _write_glb(result, out)
598
+ method = _write_glb(result, out, _load_model_params(model_name))
599
+ elif fmt == "obj":
600
+ method = _write_obj(result, out, model_name)
601
+ elif fmt == "3mf":
602
+ method = _write_3mf(result, out, _load_model_params(model_name), model_name)
293
603
  else:
294
604
  method = _write_stl(result, out)
295
605
  export_elapsed = time.perf_counter() - export_started
@@ -313,7 +623,7 @@ def main() -> int:
313
623
  parser.add_argument("--part", default=None, help="Legacy alias for --model")
314
624
  parser.add_argument("--part-name", default=None, help="Part directory name inside models/<model>/parts/")
315
625
  parser.add_argument("--source", default=None, help="Optional project-relative or absolute source file path (overrides default lookup)")
316
- parser.add_argument("--export-format", default="brep", choices=["brep", "step", "stl", "glb"])
626
+ parser.add_argument("--export-format", default="brep", choices=["brep", "step", "stl", "obj", "glb", "3mf"])
317
627
  parser.add_argument("--output", default=None, help="Optional absolute path for exported file")
318
628
  args = parser.parse_args()
319
629
  global PROJECT_ROOT, MODELS_DIR, CACHE_DIR