diff --git a/Tools/ModelTranslator/bl_decimate.py b/Tools/ModelTranslator/bl_decimate.py index 592f6666..e6846be3 100644 --- a/Tools/ModelTranslator/bl_decimate.py +++ b/Tools/ModelTranslator/bl_decimate.py @@ -6,6 +6,10 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import qr_bridge + +QR_POLL_INTERVAL = 0.3 # progress.txt 轮询间隔(秒) + TOLERANCE = 0.03 # 面数相对误差容忍 MAX_RETRY = 2 # ratio 修正轮数上限 @@ -112,6 +116,92 @@ def _decimate(obj, ratio): _apply_modifier(obj, mod) +def _remesh_quad(obj, target_tris, warnings): + """Quad Remesher 重拓扑 obj(headless 直调 xremesh.exe,绕开 modal 操作符)。 + 成功返回 True,并把导回的 quad 网格设为新 active object(原 obj 已删); + 任何失败记 warning 返回 False,交上层回退 collapse。""" + import bpy + import subprocess + import tempfile + import time + engine = qr_bridge.find_qr_engine() + if not engine: + warnings.append("未找到 QuadRemesher 引擎(xremesh.exe),回退 collapse") + return False + work = os.path.join(tempfile.gettempdir(), "mt_qr") + os.makedirs(work, exist_ok=True) + in_fbx = os.path.join(work, "inputMesh.fbx").replace("\\", "/") + out_fbx = os.path.join(work, "retopo.fbx").replace("\\", "/") + prog = os.path.join(work, "progress.txt").replace("\\", "/") + settings = os.path.join(work, "RetopoSettings.txt") + for f in (out_fbx, prog): + if os.path.isfile(f): + os.remove(f) + try: + bpy.ops.object.select_all(action='DESELECT') + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.export_scene.fbx(filepath=in_fbx, use_selection=True) + target_quads = qr_bridge.tris_to_target_quads(target_tris) + with open(settings, "w") as fp: + fp.write(qr_bridge.build_settings(in_fbx, out_fbx, prog, target_quads)) + proc = subprocess.Popen([engine, "-s", settings]) + except Exception as e: + warnings.append("QR 启动失败(%s),回退 collapse" % e) + return False + + t0 = time.time() + state = "running" + while True: + try: + with open(prog) as fp: + state, msg = qr_bridge.parse_progress(fp.read()) + except IOError: + state, msg = "running", "" + if state == "success": + break + if state == "error": + warnings.append("QR 重拓扑失败(%s),回退 collapse" % msg) + return False + if proc.poll() is not None: + # 进程已退出:给 progress/输出一点落地时间后再判一次 + time.sleep(QR_POLL_INTERVAL) + try: + with open(prog) as fp: + state, _ = qr_bridge.parse_progress(fp.read()) + except IOError: + state = "running" + break + if time.time() - t0 > qr_bridge.QR_TIMEOUT: + proc.kill() + warnings.append("QR 超时(%ds),回退 collapse" % int(qr_bridge.QR_TIMEOUT)) + return False + time.sleep(QR_POLL_INTERVAL) + + if state != "success" or not ( + os.path.isfile(out_fbx) and os.path.getsize(out_fbx) > 0): + warnings.append("QR 无有效输出,回退 collapse") + return False + + try: + before = set(bpy.data.objects) + bpy.ops.import_scene.fbx(filepath=out_fbx) + new = [o for o in bpy.data.objects + if o not in before and o.type == 'MESH'] + if not new: + warnings.append("QR 导回无 mesh,回退 collapse") + return False + retopo = new[0] + bpy.data.objects.remove(obj, do_unlink=True) + bpy.ops.object.select_all(action='DESELECT') + retopo.select_set(True) + bpy.context.view_layer.objects.active = retopo + return True + except Exception as e: + warnings.append("QR 导回失败(%s),回退 collapse" % e) + return False + + def _clean_mesh(obj, warnings): """减面前清理:按局部包围盒对角线相对焊接重合点 + 去退化面,给 collapse 更干净的流形, 减少被迫加的 seam。各步失败降级不中断。""" @@ -392,6 +482,7 @@ def main(): src, out_fbx, target = argv[0], argv[1], int(argv[2]) unwrap_mode = argv[3] if len(argv) > 3 else "seam" overlap_tol = float(argv[4]) if len(argv) > 4 and argv[4] else UV_OVERLAP_TOL + reducer_arg = argv[5] if len(argv) > 5 else "collapse" warnings = [] bpy.ops.wm.read_factory_settings(use_empty=True) @@ -408,21 +499,33 @@ def main(): _clean_mesh(obj, warnings) _triangulate(obj) - orig = len(obj.data.polygons) + orig = len(obj.data.polygons) # 高模三角面数(tris_before) cur = orig - if cur <= target: - warnings.append("当前 %d 面 <= 目标 %d,跳过减面" % (cur, target)) - else: - # collapse 结果是近似值;未达容差时按新面数修正 ratio 再来,最多 MAX_RETRY 轮 - for _ in range(1 + MAX_RETRY): - _decimate(obj, decimate_ratio(target, cur)) - _triangulate(obj) # collapse triangulate 后仍可能残留非三角面 + used_reducer = "collapse" + did_quad = False + if reducer_arg == "quad": + if _remesh_quad(obj, target, warnings): + obj = bpy.context.view_layer.objects.active # QR 导回的 quad 对象 + _triangulate(obj) # 三角化供导出/计数 cur = len(obj.data.polygons) - if within_tolerance(target, cur) or cur <= target: - break - # 重试用尽仍超差且偏多:记录警告,交由上层决定是否可接受 - if not within_tolerance(target, cur) and cur > target: - warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%)" % (MAX_RETRY, cur, target)) + used_reducer = "quad" + did_quad = True + else: + used_reducer = "quad->collapse" + if not did_quad: + if cur <= target: + warnings.append("当前 %d 面 <= 目标 %d,跳过减面" % (cur, target)) + else: + # collapse 结果是近似值;未达容差时按新面数修正 ratio 再来,最多 MAX_RETRY 轮 + for _ in range(1 + MAX_RETRY): + _decimate(obj, decimate_ratio(target, cur)) + _triangulate(obj) # collapse triangulate 后仍可能残留非三角面 + cur = len(obj.data.polygons) + if within_tolerance(target, cur) or cur <= target: + break + # 重试用尽仍超差且偏多:记录警告,交由上层决定是否可接受 + if not within_tolerance(target, cur) and cur > target: + warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%)" % (MAX_RETRY, cur, target)) uv_info = _do_unwrap(obj, unwrap_mode, warnings, overlap_tol) uv_png = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.png" @@ -445,6 +548,7 @@ def main(): print("MT_SUMMARY " + json.dumps( {"src": os.path.basename(src), "fbx": os.path.basename(out_fbx), "tris_before": orig, "tris_after": cur, "target": target, + "reducer": used_reducer, "uv": uv_info, "uv_preview": uv_preview, "uv_polys": uv_polys_json if uv_preview else None,