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