424 lines
14 KiB
Python
424 lines
14 KiB
Python
|
|
#!/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}")
|