Files
FiDA-3D-Trellis/1_obj_to_step.py

424 lines
14 KiB
Python
Raw Normal View History

2026-03-17 11:28:52 +08:00
#!/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 分块清理若环境有 trimesh0 关闭默认 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}")