Files
FiDA-3D-Trellis/1_obj_to_step.py
2026-04-13 11:20:56 +08:00

424 lines
14 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}")