#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Fast OBJ/GLB/GLTF -> STEP converter for FreeCAD (headless-friendly) 参数说明: tol : 网格→形状拟合公差,越大越快(默认 0.5) sew_tol : Sewing 缝合公差(环境缺少 Sewing 会自动跳过),默认 0.05 solid : 1 尝试固化为 Solid(耗时大),0 仅导出壳(默认 0) split : 1 使用 trimesh 分块清理(若环境有 trimesh),0 关闭(默认 1) max_comp : 分块后最多保留的组件数(默认 64) 注意: - 若 FreeCAD 发行版缺少 MeshPart.meshToShape 或 Part.Sewing,脚本会自动回退/跳过。 - 若导出 STEP 仍失败,会兜底导出 .brep,方便后续用 pythonocc/OCP 再转 STEP。 """ import secrets import sys, os, time, tempfile from datetime import datetime import FreeCAD as App import Part, Mesh # 尝试加载 MeshPart(某些构建可能没有) try: import MeshPart HAS_MESHPART = True except Exception: MeshPart = None HAS_MESHPART = False # 可选:trimesh 用于更稳的分块/清理与 GLB/GLTF 转临时 OBJ try: import trimesh HAS_TRIMESH = True except Exception: HAS_TRIMESH = False def log(msg): try: App.Console.PrintMessage(str(msg) + "\n") except Exception: print(msg, flush=True) # ---------------------- 网格清理/转换核心 ---------------------- def _freecad_mesh_quick_clean(fc_mesh): """FreeCAD 轻量清理:不大改拓扑,提升稳健性。""" for fn in ( "removeDuplicatedFacets", "removeDuplicatedPoints", "removeDegeneratedFacets", "removeNonManifoldEdges", ): try: getattr(fc_mesh, fn)() except Exception: pass return fc_mesh def _mesh_to_shape_fast(fc_mesh, tol=0.5, sew_tol=0.05, to_solid=False): """ Mesh -> B-Rep(更稳健版本): - 若有 MeshPart.meshToShape 优先使用(共面聚合,通常更快) - 否则退回 Part.Shape.makeShapeFromMesh - Sewing 若不可用则跳过(部分构建缺少 Part.Sewing) - 仅在 to_solid=True 且闭壳时尝试 Solidify(耗时较大) """ # 1) mesh -> shape shape = None if HAS_MESHPART and hasattr(MeshPart, "meshToShape"): try: shape = MeshPart.meshToShape(fc_mesh, tol) if isinstance(shape, tuple): # 某些版本返回 (shape, mapping) shape = shape[0] except Exception as e: log(f"meshToShape 失败,退回 makeShapeFromMesh: {e}") if shape is None: shape = Part.Shape() shape.makeShapeFromMesh(fc_mesh.Topology, tol) # 2) sewing(若 Part.Sewing 不存在则跳过) try: SewingCls = getattr(Part, "Sewing", None) if SewingCls is not None: sew = SewingCls() ok = False for setter in ("tolerance", "SetTolerance"): try: if setter == "tolerance": sew.tolerance = sew_tol else: sew.SetTolerance(sew_tol) ok = True break except Exception: continue sew.add(shape) sew.perform() new_shape = None if hasattr(sew, "SewedShape"): new_shape = sew.SewedShape try: if hasattr(new_shape, "isNull") and new_shape.isNull(): new_shape = None except Exception: pass if new_shape is None and hasattr(sew, "sewedShape"): try: new_shape = sew.sewedShape() except Exception: pass if new_shape is None and hasattr(sew, "sewShape"): try: new_shape = sew.sewShape() except Exception: pass if new_shape is not None: shape = new_shape else: log("跳过 Sewing:当前 FreeCAD Part 模块缺少 Sewing 绑定") except Exception as e: log(f"Sewing 失败,继续原 shape: {e}") # 3) 可选固化 if to_solid: try: if not getattr(shape, "Solids", []): shell = Part.Shell(shape.Faces) if hasattr(shell, "isClosed") and shell.isClosed(): shape = Part.makeSolid(shell) except Exception as e: log(f"Solidify 失败,保留壳: {e}") # 4) 轻量拓扑精简 try: shape = shape.removeSplitter() except Exception: pass try: if hasattr(shape, "fixTolerance"): shape.fixTolerance(sew_tol) except Exception: pass return shape def _export_shapes_to_step(shapes, step_file_path): """ 稳健导出: - 确保输出目录存在 - 过滤无效 shape - 尝试合并 Compound(有时更稳定) - Part.export 失败则 Import.export;再不行兜底写 .brep """ out_dir = os.path.dirname(step_file_path) or "." os.makedirs(out_dir, exist_ok=True) valid = [] for s in shapes: try: if hasattr(s, "isNull") and s.isNull(): continue if hasattr(s, "Faces") and len(s.Faces) == 0: continue valid.append(s) except Exception: continue if not valid: raise RuntimeError("没有可导出的有效 shape(可能网格太脏或全部构造失败)") try: compound = Part.makeCompound(valid) except Exception: compound = valid[0] doc = App.newDocument("Obj2StepFast") try: if hasattr(doc, "suppressRecompute"): doc.suppressRecompute() obj = doc.addObject("Part::Feature", "Compound") obj.Shape = compound if hasattr(doc, "recompute"): doc.recompute() try: Part.export([obj], step_file_path) return except Exception as e1: log(f"Part.export 失败:{e1}") try: import Import Import.export([obj], step_file_path) return except Exception as e2: log(f"Import.export 也失败:{e2}") brep_path = os.path.splitext(step_file_path)[0] + ".brep" try: obj.Shape.exportBrep(brep_path) raise RuntimeError( f"STEP 导出失败,已兜底写入 BREP:{brep_path}\n" f"可用 pythonocc/OCP 将 BREP 转为 STEP。" ) except Exception as e3: raise RuntimeError(f"STEP 与 BREP 均导出失败:{e3}") finally: App.closeDocument(doc.Name) # ---------------------- 顶层转换逻辑 ---------------------- def _glb_gltf_to_tmp_obj_if_needed(in_path): ext = os.path.splitext(in_path)[1].lower() if ext in (".glb", ".gltf"): if not HAS_TRIMESH: raise RuntimeError("输入为 GLB/GLTF,但 FreeCAD Python 环境未安装 trimesh。请安装后再试。") mesh = trimesh.load(in_path, force="mesh") if mesh.is_empty: raise RuntimeError(f"GLB/GLTF 读取失败或为空:{in_path}") tmp = tempfile.mktemp(suffix=".obj") mesh.export(tmp) return tmp, True return in_path, False # 新增:生成带时间戳+随机串的唯一文件名(避免覆盖) def generate_unique_step_filename(base_name: str, output_dir: str) -> str: """ 生成格式示例: model_20260316_131245_8f4a2c1d.step """ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") random_part = secrets.token_hex(4) # 8 位随机十六进制 filename = f"{base_name}_{timestamp}_{random_part}.step" return os.path.join(output_dir, filename) def obj_to_step_fast(input_path, step_file_path=None, tol=0.5, sew_tol=0.05, to_solid=False, split_components=True, max_components=64): t0 = time.perf_counter() if not os.path.exists(input_path): raise FileNotFoundError(input_path) base = os.path.splitext(os.path.basename(input_path))[0] # ── 决定最终输出路径 ── if step_file_path is None: # 没有指定路径 → 用 output_dir 或 input 同目录 + 唯一文件名 out_dir = os.path.dirname(input_path) step_file_path = generate_unique_step_filename(base, out_dir) elif os.path.isdir(step_file_path): # 用户传的是目录 → 在该目录生成唯一文件名 step_file_path = generate_unique_step_filename(base, step_file_path) else: # 用户明确指定了 .step / .stp 文件路径 → 直接使用(不加随机后缀) if not step_file_path.lower().endswith((".step", ".stp")): step_file_path += ".step" # 这里不做额外唯一性处理,尊重用户意图(可能想覆盖) log(f"目标 STEP 文件:{step_file_path}") # 下面部分保持原样 ........................................ shapes = [] tmp_created_paths = [] src_for_freecad, is_tmp = _glb_gltf_to_tmp_obj_if_needed(input_path) if is_tmp: tmp_created_paths.append(src_for_freecad) if HAS_TRIMESH and split_components: m = None try: m = trimesh.load(src_for_freecad, force="mesh") except Exception as e: log(f"trimesh 读取失败(转纯 FreeCAD 路径):{e}") m = None if m is not None and not m.is_empty: try: m.remove_duplicate_faces() m.remove_degenerate_faces() m.remove_unreferenced_vertices() m.fix_normals() except Exception: pass parts = m.split(only_watertight=False) if len(parts) == 0: parts = [m] if len(parts) > max_components: parts = sorted(parts, key=lambda x: x.faces.shape[0], reverse=True)[:max_components] log(f"Trimesh分块:{len(parts)} 个组件") for i, pm in enumerate(parts): tmp_obj = tempfile.mktemp(suffix=f"_{i}.obj") pm.export(tmp_obj) tmp_created_paths.append(tmp_obj) fc_mesh = Mesh.Mesh(tmp_obj) _freecad_mesh_quick_clean(fc_mesh) shp = _mesh_to_shape_fast(fc_mesh, tol=tol, sew_tol=sew_tol, to_solid=to_solid) shapes.append(shp) else: fc_mesh = Mesh.Mesh(src_for_freecad) log(f"载入Mesh: {src_for_freecad}, 三角数={len(fc_mesh.Facets)}") _freecad_mesh_quick_clean(fc_mesh) shp = _mesh_to_shape_fast(fc_mesh, tol=tol, sew_tol=sew_tol, to_solid=to_solid) shapes.append(shp) else: fc_mesh = Mesh.Mesh(src_for_freecad) log(f"载入Mesh: {src_for_freecad}, 三角数={len(fc_mesh.Facets)}") _freecad_mesh_quick_clean(fc_mesh) shp = _mesh_to_shape_fast(fc_mesh, tol=tol, sew_tol=sew_tol, to_solid=to_solid) shapes.append(shp) _export_shapes_to_step(shapes, step_file_path) for p in tmp_created_paths: try: os.remove(p) except Exception: pass t1 = time.perf_counter() log(f"STEP导出: {step_file_path} (耗时 {t1 - t0:.2f}s)") return step_file_path def main(obj_file_path, output_dir=None, tol=0.5, sew_tol=0.05, to_solid=False, split_components=True, max_components=64): if output_dir is None: output_dir = os.path.dirname(obj_file_path) os.makedirs(output_dir, exist_ok=True) base = os.path.splitext(os.path.basename(obj_file_path))[0] # 使用唯一文件名生成 step_path step_path = generate_unique_step_filename(base, output_dir) return obj_to_step_fast( obj_file_path, step_file_path=step_path, # 这里传唯一路径 tol=tol, sew_tol=sew_tol, to_solid=to_solid, split_components=split_components, max_components=max_components, ) # ── CLI 部分也做相应调整 ── def _parse_cli(argv): in_path = os.path.abspath(argv[1]) out_arg = os.path.abspath(argv[2]) if len(argv) > 2 else None tol = float(argv[3]) if len(argv) > 3 else 0.5 sew_tol = float(argv[4]) if len(argv) > 4 else 0.05 solid = bool(int(argv[5])) if len(argv) > 5 else False split = bool(int(argv[6])) if len(argv) > 6 else True max_comp = int(argv[7]) if len(argv) > 7 else 64 return in_path, out_arg, tol, sew_tol, solid, split, max_comp # ---------------------- CLI entry ---------------------- if __name__ == "__main__": in_path, out_arg, tol, sew_tol, solid, split, max_comp = _parse_cli(sys.argv) # 判断第二个参数是目录还是 step 文件 out_is_step = out_arg.lower().endswith((".step", ".stp")) if (not out_is_step) or os.path.isdir(out_arg): out_dir = out_arg os.makedirs(out_dir, exist_ok=True) result = main( in_path, output_dir=out_dir, tol=tol, sew_tol=sew_tol, to_solid=solid, split_components=split, max_components=max_comp, ) else: os.makedirs(os.path.dirname(out_arg), exist_ok=True) result = obj_to_step_fast( in_path, step_file_path=out_arg, tol=tol, sew_tol=sew_tol, to_solid=solid, split_components=split, max_components=max_comp, ) log("完成!") log(f"STEP: {result}")