diff --git a/docs/superpowers/plans/2026-07-16-model-decimate-bake.md b/docs/superpowers/plans/2026-07-16-model-decimate-bake.md new file mode 100644 index 00000000..cfc61066 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-model-decimate-bake.md @@ -0,0 +1,833 @@ +# ModelTranslator 减面与烘焙工具实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 `Tools/ModelTranslator` 新增两个 headless Blender 工具——FBX 减面到指定三角面数(含 Smart UV 重展),以及高低模烘焙分离出 normal/ao/color/metallic/roughness 五张贴图。 + +**Architecture:** 沿用现有"系统 Python CLI 包装器 + `blender -b --factory-startup --python bl_*.py` + stdout `MT_SUMMARY ` JSON 行协议"模式。纯逻辑函数放模块顶部(不 import bpy,可单测),Blender 专属代码全部延迟导入 bpy。烘焙用 Cycles selected-to-active:NORMAL/AO pass 直接烘,color/metallic/roughness 用 EMIT trick(把 Principled 对应输入改接 Emission)。 + +**Tech Stack:** Python 3 标准库、Blender 5.0(bpy)、unittest。 + +**设计文档:** `docs/superpowers/specs/2026-07-16-model-decimate-bake-design.md` + +**重要提醒:** +- 仓库路径含 `#`(`D:\UD\AI\AIC#Project`),**bash 里所有路径必须加引号** +- Blender 位于 `D:\tools\blender-5.0.0-windows-x64\blender.exe`(`find_blender` 的默认值) +- 测试素材:`Tools/ModelTranslator/src/well1500.fbx`(高模),目标 5000 面 +- `Tools/ModelTranslator/src/` 与产物目录均**不入 git**(现状即未跟踪),只提交代码/文档 + +## 文件结构 + +``` +Tools/ModelTranslator/ + mt_run.py # 新增:find_blender + run_blender_script(两个新 CLI 共享) + model_decimate.py # 新增:工具1 CLI + bl_decimate.py # 新增:工具1 Blender 内脚本(含可单测纯逻辑) + model_bake.py # 新增:工具2 CLI + bl_bake.py # 新增:工具2 Blender 内脚本(含可单测纯逻辑) + tests/ + test_bl_decimate.py # 新增 + test_bl_bake.py # 新增 + README.md # 修改:补两个新工具的用法与管线说明 +``` + +(`model_translator.py` 不动,避免破坏已验证的工具3。) + +--- + +### Task 1: mt_run.py 共享 Blender 运行器 + +**Files:** +- Create: `Tools/ModelTranslator/mt_run.py` + +纯 subprocess 薄封装,无业务逻辑,不写单测(与现有 `model_translator.py` 的 `find_blender`/`run_blender` 同模式,仅泛化脚本名与参数)。 + +- [ ] **Step 1: 写 mt_run.py** + +```python +"""find_blender / run_blender_script:model_decimate 与 model_bake 共享的 +headless Blender 运行器(与 model_translator.py 同模式,脚本名与参数泛化)。""" +import json +import os +import shutil +import subprocess +import sys + +DEFAULT_BLENDER = r"D:\tools\blender-5.0.0-windows-x64\blender.exe" +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def find_blender(cli_arg): + for cand in (cli_arg, os.environ.get("BLENDER_EXE"), DEFAULT_BLENDER, + shutil.which("blender")): + if cand and os.path.isfile(cand): + return cand + sys.exit("找不到 blender.exe,请用 --blender 指定或设置环境变量 BLENDER_EXE") + + +def run_blender_script(blender, script, script_args): + """跑 HERE 下的 bl_*.py,返回 MT_SUMMARY JSON;失败或 summary 带 error 则退出。""" + cmd = [blender, "-b", "--factory-startup", + "--python", os.path.join(HERE, script), "--"] + list(script_args) + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace") + summary = None + for line in (proc.stdout or "").splitlines(): + if line.startswith("MT_SUMMARY "): + summary = json.loads(line[len("MT_SUMMARY "):]) + if proc.returncode != 0 or summary is None or "error" in summary: + sys.stderr.write((proc.stdout or "")[-2000:] + "\n" + + (proc.stderr or "")[-2000:] + "\n") + err = summary["error"] if summary and "error" in summary else script + sys.exit("Blender 执行失败:%s" % err) + return summary +``` + +- [ ] **Step 2: 语法检查** + +Run: `python -m py_compile "D:/UD/AI/AIC#Project/Tools/ModelTranslator/mt_run.py"` +Expected: 无输出(编译通过) + +- [ ] **Step 3: Commit** + +```bash +cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/mt_run.py && git commit -m "ModelTranslator: 共享 Blender 运行器 mt_run.py" +``` + +--- + +### Task 2: bl_decimate.py 纯逻辑(TDD) + +**Files:** +- Create: `Tools/ModelTranslator/tests/test_bl_decimate.py` +- Create: `Tools/ModelTranslator/bl_decimate.py`(本任务只写纯逻辑部分) + +- [ ] **Step 1: 写失败测试** + +```python +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import bl_decimate as bd + + +class TestDecimateRatio(unittest.TestCase): + def test_normal_reduction(self): + self.assertAlmostEqual(bd.decimate_ratio(5000, 1500000), 5000 / 1500000.0) + + def test_current_below_target_returns_one(self): + self.assertEqual(bd.decimate_ratio(5000, 3000), 1.0) + + def test_current_equal_target_returns_one(self): + self.assertEqual(bd.decimate_ratio(5000, 5000), 1.0) + + def test_zero_current_returns_one(self): + self.assertEqual(bd.decimate_ratio(5000, 0), 1.0) + + +class TestWithinTolerance(unittest.TestCase): + def test_exact_hit(self): + self.assertTrue(bd.within_tolerance(5000, 5000)) + + def test_within_3_percent(self): + self.assertTrue(bd.within_tolerance(5000, 5150)) # +3% + self.assertTrue(bd.within_tolerance(5000, 4850)) # -3% + + def test_outside_3_percent(self): + self.assertFalse(bd.within_tolerance(5000, 5200)) + self.assertFalse(bd.within_tolerance(5000, 4700)) + + def test_zero_target_is_false(self): + self.assertFalse(bd.within_tolerance(0, 0)) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v` +Expected: FAIL/ERROR(`No module named 'bl_decimate'`) + +- [ ] **Step 3: 写 bl_decimate.py 纯逻辑部分** + +```python +"""Blender 内运行:FBX 减面到指定三角面数 + Smart UV 重展。 +调用:blender -b --factory-startup --python bl_decimate.py -- +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +TOLERANCE = 0.03 # 面数相对误差容忍 +MAX_RETRY = 2 # ratio 修正轮数上限 + + +def decimate_ratio(target, cur): + """Decimate collapse ratio;cur <= target 或非法时返回 1.0(不减面)。""" + if cur <= 0 or cur <= target: + return 1.0 + return target / float(cur) + + +def within_tolerance(target, actual, tol=TOLERANCE): + """减面结果是否落在目标 ±tol 内。""" + if target <= 0: + return False + return abs(actual - target) <= target * tol +``` + +- [ ] **Step 4: 运行确认通过** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v` +Expected: 全部 PASS(8 个测试) + +- [ ] **Step 5: Commit** + +```bash +cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py Tools/ModelTranslator/tests/test_bl_decimate.py && git commit -m "ModelTranslator: 减面 ratio/容差纯逻辑(TDD)" +``` + +--- + +### Task 3: bl_decimate.py Blender 主流程 + model_decimate.py CLI + +**Files:** +- Modify: `Tools/ModelTranslator/bl_decimate.py`(追加 Blender 专属部分) +- Create: `Tools/ModelTranslator/model_decimate.py` + +Blender ops 无法脱离 Blender 单测,正确性由 Task 4 冒烟测试验证。 + +- [ ] **Step 1: bl_decimate.py 追加 Blender 主流程** + +在纯逻辑之后追加(`join_meshes` 为公开函数,Task 6 的 bl_bake 会复用): + +```python +# ---------------- 以下仅在 Blender 内执行 ---------------- + + +def join_meshes(objs): + """多 mesh join 成单对象,返回结果对象(bl_bake 复用)。""" + import bpy + bpy.ops.object.select_all(action='DESELECT') + for o in objs: + o.select_set(True) + bpy.context.view_layer.objects.active = objs[0] + if len(objs) > 1: + bpy.ops.object.join() + return bpy.context.view_layer.objects.active + + +def _apply_modifier(obj, mod): + import bpy + bpy.context.view_layer.objects.active = obj + bpy.ops.object.modifier_apply(modifier=mod.name) + + +def _triangulate(obj): + mod = obj.modifiers.new("mt_tri", 'TRIANGULATE') + _apply_modifier(obj, mod) + + +def _decimate(obj, ratio): + mod = obj.modifiers.new("mt_dec", 'DECIMATE') + mod.decimate_type = 'COLLAPSE' + mod.ratio = ratio + mod.use_collapse_triangulate = True + _apply_modifier(obj, mod) + + +def _smart_unwrap(obj): + """删旧 UV 层后 Smart UV Project 重展。island_margin 0.002 ≈ 2048 图 4px。""" + import bpy + import math + mesh = obj.data + while mesh.uv_layers: + mesh.uv_layers.remove(mesh.uv_layers[0]) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=0.002) + bpy.ops.object.mode_set(mode='OBJECT') + + +def main(): + import bpy + import json + argv = sys.argv[sys.argv.index("--") + 1:] + src, out_fbx, target = argv[0], argv[1], int(argv[2]) + warnings = [] + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.fbx(filepath=src) + + meshes = [o for o in bpy.data.objects if o.type == 'MESH'] + skipped = [o.name for o in bpy.data.objects if o.type != 'MESH'] + if skipped: + warnings.append("忽略非 mesh 对象:%s(本工具只处理静态网格)" % ", ".join(skipped)) + if not meshes: + print("MT_SUMMARY " + json.dumps({"error": "FBX 中没有 mesh"}, ensure_ascii=False)) + return + obj = join_meshes(meshes) + + _triangulate(obj) + orig = len(obj.data.polygons) + 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 后仍可能残留非三角面 + cur = len(obj.data.polygons) + if within_tolerance(target, cur) or cur <= target: + break + + _smart_unwrap(obj) + out_dir = os.path.dirname(os.path.abspath(out_fbx)) + os.makedirs(out_dir, exist_ok=True) + bpy.ops.export_scene.fbx(filepath=out_fbx, path_mode='STRIP', embed_textures=False) + + print("MT_SUMMARY " + json.dumps( + {"src": os.path.basename(src), "fbx": os.path.basename(out_fbx), + "tris_before": orig, "tris_after": cur, "target": target, + "warnings": warnings}, ensure_ascii=False)) + + +if __name__ == "__main__" and "--" in sys.argv: + main() +``` + +- [ ] **Step 2: 写 model_decimate.py** + +```python +"""FBX 减面 CLI:减到指定三角面数并 Smart UV 重展,输出 <名>_low.fbx。 +用法:python model_decimate.py src/well1500.fbx --tris 5000 [-o dir] [--blender exe]""" +import argparse +import os + +from mt_run import find_blender, run_blender_script + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("input", help="FBX 文件") + ap.add_argument("--tris", type=int, required=True, help="目标三角面数") + ap.add_argument("-o", "--out", default=None, help="输出目录(默认源文件同目录)") + ap.add_argument("--blender", default=None) + args = ap.parse_args() + + blender = find_blender(args.blender) + name = os.path.splitext(os.path.basename(args.input))[0] + outdir = args.out or os.path.dirname(os.path.abspath(args.input)) + out_fbx = os.path.join(outdir, name + "_low.fbx") + s = run_blender_script(blender, "bl_decimate.py", + [args.input, out_fbx, str(args.tris)]) + print("== %s: %d -> %d 面(目标 %d)-> %s" % + (s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx)) + for w in s["warnings"]: + print(" [警告] %s" % w) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 3: 语法检查 + 既有测试不回归** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m py_compile bl_decimate.py model_decimate.py && python -m unittest -v` +Expected: 编译通过,全部单测 PASS + +- [ ] **Step 4: Commit** + +```bash +cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py Tools/ModelTranslator/model_decimate.py && git commit -m "ModelTranslator: FBX 减面工具 model_decimate(Decimate collapse + Smart UV 重展)" +``` + +--- + +### Task 4: 工具1 冒烟测试(well1500 → 5000 面) + +**Files:** 无新文件;产出 `Tools/ModelTranslator/src/well1500_low.fbx`(不入 git) + +- [ ] **Step 1: 跑减面** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/well1500.fbx --tris 5000` +Expected: 输出形如 `== well1500.fbx: NNNNNN -> ~5000 面(目标 5000)-> ...well1500_low.fbx`,`tris_after` 在 4850~5150 之间(±3%)或 ≤5000 + +- [ ] **Step 2: 确认产物存在** + +Run: `ls -la "D:/UD/AI/AIC#Project/Tools/ModelTranslator/src/well1500_low.fbx"` +Expected: 文件存在且非 0 字节 + +- [ ] **Step 3: 若面数超差或报错** + +按 superpowers:systematic-debugging 排查(常见点:Blender 5.0 `smart_project` 参数名、`modifier_apply` 上下文),修复后重跑 Step 1。**不达标不得进入 Task 5。** + +--- + +### Task 5: bl_bake.py 纯逻辑(TDD) + +**Files:** +- Create: `Tools/ModelTranslator/tests/test_bl_bake.py` +- Create: `Tools/ModelTranslator/bl_bake.py`(本任务只写纯逻辑部分) + +- [ ] **Step 1: 写失败测试** + +```python +import math +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import bl_bake as bb + + +class TestEstimateRayDistance(unittest.TestCase): + def test_two_percent_of_bbox_diagonal(self): + # 3-4-12 直角箱对角线 = 13 + self.assertAlmostEqual(bb.estimate_ray_distance((3.0, 4.0, 12.0)), 0.26) + + def test_custom_pct(self): + self.assertAlmostEqual( + bb.estimate_ray_distance((1.0, 0.0, 0.0), pct=0.5), 0.5) + + +class TestOutputStem(unittest.TestCase): + def test_strips_low_suffix(self): + self.assertEqual(bb.output_stem("well1500_low"), "well1500") + + def test_keeps_name_without_suffix(self): + self.assertEqual(bb.output_stem("well1500"), "well1500") + + def test_only_strips_trailing_suffix(self): + self.assertEqual(bb.output_stem("low_poly_low"), "low_poly") + + +class TestBboxMismatch(unittest.TestCase): + def test_identical_ok(self): + self.assertFalse(bb.bbox_mismatch((1.0, 2.0, 3.0), (1.0, 2.0, 3.0))) + + def test_within_10_percent_ok(self): + self.assertFalse(bb.bbox_mismatch((1.0, 2.0, 3.0), (1.05, 1.9, 3.2))) + + def test_one_axis_exceeds(self): + self.assertTrue(bb.bbox_mismatch((1.0, 2.0, 3.0), (1.0, 2.0, 3.5))) + + def test_zero_axis_ignored(self): + # 平面模型某轴为 0,不应误报 + self.assertFalse(bb.bbox_mismatch((1.0, 0.0, 3.0), (1.0, 0.0, 3.0))) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_bake -v` +Expected: FAIL/ERROR(`No module named 'bl_bake'`) + +- [ ] **Step 3: 写 bl_bake.py 纯逻辑部分** + +```python +"""Blender 内运行:高低模 Cycles 烘焙,低模 UV 上出 normal/ao/color/metallic/roughness。 +调用:blender -b --factory-startup --python bl_bake.py -- \ + +""" +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +DEFAULT_RAY_PCT = 0.02 # 自动射线距离 = 低模包围盒对角线 * 2% +BBOX_TOL = 0.10 # 高低模包围盒尺寸相对差警告阈值 + +# (输出名, bake type, EMIT 来源的 Principled 输入, 目标图颜色空间) +BAKE_PASSES = [ + ("normal", 'NORMAL', None, 'Non-Color'), + ("ao", 'AO', None, 'Non-Color'), + ("color", 'EMIT', 'Base Color', 'sRGB'), + ("metallic", 'EMIT', 'Metallic', 'Non-Color'), + ("roughness", 'EMIT', 'Roughness', 'Non-Color'), +] + + +def estimate_ray_distance(bbox_dims, pct=DEFAULT_RAY_PCT): + """低模包围盒尺寸 (dx,dy,dz) -> 射线距离 = 对角线长 * pct。""" + return math.sqrt(sum(d * d for d in bbox_dims)) * pct + + +def output_stem(low_name): + """低模名去掉 _low 后缀作为输出前缀(对齐工具3纯网格贴图命名约定)。""" + return low_name[:-4] if low_name.endswith("_low") else low_name + + +def bbox_mismatch(high_dims, low_dims, tol=BBOX_TOL): + """任一轴尺寸相对差超 tol 返回 True;接近 0 的轴忽略(平面模型)。""" + for h, l in zip(high_dims, low_dims): + m = max(abs(h), abs(l)) + if m > 1e-9 and abs(h - l) / m > tol: + return True + return False +``` + +- [ ] **Step 4: 运行确认通过** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_bake -v` +Expected: 全部 PASS(9 个测试) + +- [ ] **Step 5: Commit** + +```bash +cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_bake.py Tools/ModelTranslator/tests/test_bl_bake.py && git commit -m "ModelTranslator: 烘焙射线距离/命名/包围盒校验纯逻辑(TDD)" +``` + +--- + +### Task 6: bl_bake.py Blender 主流程 + +**Files:** +- Modify: `Tools/ModelTranslator/bl_bake.py`(追加 Blender 专属部分) + +关键点:① NORMAL/AO 先烘(依赖高模原始材质),EMIT 三张后烘(会改写节点图,headless 一次性会话无需还原);② 烘焙目标图的颜色空间决定 PNG 编码——Cycles 向字节图写入时按图颜色空间转换,color 图建为 sRGB 得到 sRGB 编码 PNG,其余 Non-Color 保持线性原值(对应 well10k 双重 sRGB 教训,Unity 端 sRGB 开关由工具3 `.meta` 控制)。 + +- [ ] **Step 1: 追加 Blender 主流程** + +在纯逻辑之后追加: + +```python +# ---------------- 以下仅在 Blender 内执行 ---------------- + + +def _import_fbx_joined(path, warnings): + """导入 FBX 并把其中所有 mesh join 成单对象;无 mesh 返回 None。""" + import bpy + from bl_decimate import join_meshes + before = set(bpy.data.objects) + bpy.ops.import_scene.fbx(filepath=path) + new = [o for o in bpy.data.objects if o not in before] + meshes = [o for o in new if o.type == 'MESH'] + skipped = [o.name for o in new if o.type != 'MESH'] + if skipped: + warnings.append("%s:忽略非 mesh 对象 %s" + % (os.path.basename(path), ", ".join(skipped))) + if not meshes: + return None + return join_meshes(meshes) + + +def _setup_bake_target(low, stem): + """低模换成单一烘焙材质,返回接收烘焙结果的 image 节点。""" + import bpy + mat = bpy.data.materials.new(stem) + mat.use_nodes = True + node = mat.node_tree.nodes.new('ShaderNodeTexImage') + mat.node_tree.nodes.active = node + low.data.materials.clear() + low.data.materials.append(mat) + return mat, node + + +def _high_materials(high): + return [s.material for s in high.material_slots if s.material] + + +def _rewire_emit(mat, input_name, warnings): + """把 Principled 指定输入(连线或常量)改接 Emission 直连输出,供 EMIT 烘焙。""" + import bpy + if not mat.use_nodes: + mat.use_nodes = True + nt = mat.node_tree + bsdf = next((n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED'), None) + out = next((n for n in nt.nodes if n.type == 'OUTPUT_MATERIAL'), None) + if out is None: + out = nt.nodes.new('ShaderNodeOutputMaterial') + emit = nt.nodes.get("mt_emit") + if emit is None: + emit = nt.nodes.new('ShaderNodeEmission') + emit.name = "mt_emit" + for l in list(emit.inputs['Color'].links): + nt.links.remove(l) + if bsdf is None: + warnings.append("材质 %s 无 Principled BSDF,%s 用 0.5 灰常量" + % (mat.name, input_name)) + emit.inputs['Color'].default_value = (0.5, 0.5, 0.5, 1.0) + else: + sock = bsdf.inputs[input_name] + if sock.is_linked: + nt.links.new(sock.links[0].from_socket, emit.inputs['Color']) + else: + v = sock.default_value + if isinstance(v, float): + emit.inputs['Color'].default_value = (v, v, v, 1.0) + else: # Base Color 是 4 分量 + emit.inputs['Color'].default_value = (v[0], v[1], v[2], 1.0) + for l in list(out.inputs['Surface'].links): + nt.links.remove(l) + nt.links.new(emit.outputs['Emission'], out.inputs['Surface']) + + +def _bake_pass(low, high, target_node, name, bake_type, size, colorspace, + ray, samples, outdir, stem): + """执行一个烘焙 pass 并保存 PNG,返回文件名。""" + import bpy + img = bpy.data.images.new("mt_bake_" + name, size, size, alpha=False) + img.colorspace_settings.name = colorspace + target_node.image = img + + bpy.ops.object.select_all(action='DESELECT') + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + bpy.context.scene.cycles.samples = samples + + kwargs = dict(type=bake_type, use_selected_to_active=True, + cage_extrusion=ray, max_ray_distance=ray * 2.0, + margin=16, use_clear=True) + if bake_type == 'NORMAL': + kwargs["normal_space"] = 'TANGENT' # OpenGL +Y + bpy.ops.object.bake(**kwargs) + + path = os.path.abspath(os.path.join(outdir, "%s_%s.png" % (stem, name))) + img.filepath_raw = path + img.file_format = 'PNG' + img.save() + bpy.data.images.remove(img) + return os.path.basename(path) + + +def main(): + import bpy + import json + argv = sys.argv[sys.argv.index("--") + 1:] + high_fbx, low_fbx, outdir = argv[0], argv[1], argv[2] + size, ray_arg, ao_samples = int(argv[3]), argv[4], int(argv[5]) + warnings = [] + + bpy.ops.wm.read_factory_settings(use_empty=True) + high = _import_fbx_joined(high_fbx, warnings) + low = _import_fbx_joined(low_fbx, warnings) + if high is None or low is None: + print("MT_SUMMARY " + json.dumps( + {"error": "高模或低模 FBX 中没有 mesh"}, ensure_ascii=False)) + return + if not low.data.uv_layers: + print("MT_SUMMARY " + json.dumps( + {"error": "低模没有 UV 层,请先用 model_decimate.py 生成"}, + ensure_ascii=False)) + return + + high_dims, low_dims = tuple(high.dimensions), tuple(low.dimensions) + if bbox_mismatch(high_dims, low_dims): + warnings.append("高低模包围盒尺寸差异超 %d%%:高 %s 低 %s,确认是同一模型且坐标已对齐" + % (BBOX_TOL * 100, + [round(d, 3) for d in high_dims], + [round(d, 3) for d in low_dims])) + ray = estimate_ray_distance(low_dims) if ray_arg == "auto" else float(ray_arg) + + stem = output_stem(os.path.splitext(os.path.basename(low_fbx))[0]) + os.makedirs(outdir, exist_ok=True) + + scene = bpy.context.scene + scene.render.engine = 'CYCLES' + scene.cycles.device = 'CPU' + bake_mat, target_node = _setup_bake_target(low, stem) + + mats = _high_materials(high) + outputs = {} + for name, bake_type, emit_input, colorspace in BAKE_PASSES: + if emit_input is not None: # EMIT trick:先改写全部高模材质 + for m in mats: + _rewire_emit(m, emit_input, warnings) + samples = ao_samples if bake_type == 'AO' else 1 + outputs[name] = _bake_pass(low, high, target_node, name, bake_type, + size, colorspace, ray, samples, outdir, stem) + + # 导出低模:剥掉烘焙 image 节点,材质名保留 stem(工具3 兜底识别用) + bake_mat.node_tree.nodes.remove(target_node) + out_fbx = os.path.join(outdir, stem + ".fbx") + bpy.ops.object.select_all(action='DESELECT') + low.select_set(True) + bpy.ops.export_scene.fbx(filepath=out_fbx, use_selection=True, + path_mode='STRIP', embed_textures=False) + + print("MT_SUMMARY " + json.dumps( + {"stem": stem, "fbx": os.path.basename(out_fbx), "outputs": outputs, + "size": size, "ray_distance": round(ray, 6), + "high_tris": len(high.data.polygons), "low_tris": len(low.data.polygons), + "warnings": warnings}, ensure_ascii=False)) + + +if __name__ == "__main__" and "--" in sys.argv: + main() +``` + +- [ ] **Step 2: 语法检查 + 纯逻辑测试不回归** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m py_compile bl_bake.py && python -m unittest tests.test_bl_bake -v` +Expected: 编译通过,9 个测试 PASS + +- [ ] **Step 3: Commit** + +```bash +cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_bake.py && git commit -m "ModelTranslator: 高低模烘焙 Blender 主流程(NORMAL/AO + EMIT trick)" +``` + +--- + +### Task 7: model_bake.py CLI + +**Files:** +- Create: `Tools/ModelTranslator/model_bake.py` + +- [ ] **Step 1: 写 model_bake.py** + +```python +"""高低模烘焙 CLI:在低模 UV 上烘出 normal/ao/color/metallic/roughness 五张贴图。 +产物命名对齐 model_translator.py 纯网格约定,可直接作为其输入。 +用法:python model_bake.py <高模.fbx> <低模.fbx> [-o out_bake] [--size 2048] + [--ray-distance N] [--samples 64] [--blender exe]""" +import argparse +import os + +from bl_bake import output_stem +from mt_run import HERE, find_blender, run_blender_script + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("high", help="高模 FBX(带材质/贴图)") + ap.add_argument("low", help="低模 FBX(须有 UV,见 model_decimate.py)") + ap.add_argument("-o", "--out", default=os.path.join(HERE, "out_bake")) + ap.add_argument("--size", type=int, default=2048, help="烘焙贴图边长") + ap.add_argument("--ray-distance", type=float, default=None, + help="射线距离(场景单位),默认低模包围盒对角线 2%%") + ap.add_argument("--samples", type=int, default=64, help="AO 采样数") + ap.add_argument("--blender", default=None) + args = ap.parse_args() + + blender = find_blender(args.blender) + stem = output_stem(os.path.splitext(os.path.basename(args.low))[0]) + outdir = os.path.join(args.out, stem) + ray = "auto" if args.ray_distance is None else str(args.ray_distance) + s = run_blender_script(blender, "bl_bake.py", + [args.high, args.low, outdir, str(args.size), + ray, str(args.samples)]) + print("== %s(高 %d 面 / 低 %d 面,射线距离 %.4f)-> %s" % + (s["stem"], s["high_tris"], s["low_tris"], s["ray_distance"], outdir)) + for name, fname in s["outputs"].items(): + print(" %-10s %s" % (name, fname)) + for w in s["warnings"]: + print(" [警告] %s" % w) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: 语法检查 + 全部单测** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m py_compile model_bake.py && python -m unittest -v` +Expected: 编译通过,全部单测 PASS + +- [ ] **Step 3: Commit** + +```bash +cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/model_bake.py && git commit -m "ModelTranslator: 烘焙工具 CLI model_bake.py" +``` + +--- + +### Task 8: 工具2 冒烟测试(well1500 高模 + well1500_low 低模) + +**Files:** 无新文件;产出 `Tools/ModelTranslator/out_bake/well1500/`(不入 git) + +- [ ] **Step 1: 跑烘焙** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_bake.py src/well1500.fbx src/well1500_low.fbx` +Expected: 打印 5 行输出贴图名,无 error 退出(高面数模型 CPU 烘焙可能要几分钟,Bash timeout 给到 600000) + +- [ ] **Step 2: 校验产物:fbx + 5 张 2048 PNG** + +Run: +```bash +cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator/out_bake/well1500" && ls && python -c " +import struct, glob +for p in sorted(glob.glob('*.png')): + w, h = struct.unpack('>II', open(p, 'rb').read(24)[16:24]) + print(p, w, h)" +``` +Expected: `well1500.fbx` 存在;`well1500_color/normal/metallic/roughness/ao.png` 五张均为 `2048 2048` + +- [ ] **Step 3: 抽查烘焙内容非空** + +Run: +```bash +cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && "D:/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python-expr " +import bpy +for n in ('color', 'normal'): + img = bpy.data.images.load(r'D:\UD\AI\AIC#Project\Tools\ModelTranslator\out_bake\well1500\well1500_%s.png' % n) + px = list(img.pixels[:4000]) + uniq = len(set(round(v, 2) for v in px)) + print('CHECK', n, 'unique=', uniq) +" +``` +Expected: 两张图 `unique=` 都明显 >1(纯黑/纯灰说明烘焙落空——射线距离或 selected-to-active 出了问题,按 systematic-debugging 排查后重跑) + +--- + +### Task 9: 全管线验证 + README 更新 + +**Files:** +- Modify: `Tools/ModelTranslator/README.md` + +- [ ] **Step 1: 工具3 吃工具2 产物** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_translator.py out_bake/well1500/well1500.fbx` +Expected: 输出 `out/well1500/`,日志显示 color/normal/metallic/roughness/ao 五个来源均识别到(无"(常量)"),生成 base/mix PNG + .mat + .meta + +- [ ] **Step 2: README 补两个新工具** + +在 README.md「用法」小节后新增一节(保持现有行文风格): + +```markdown +## 减面与高低模烘焙(可选前置工具) + +高面数模型先减面、再把细节烘到低模贴图,产物直接作为上面转换器的输入: + +​```bash +cd Tools/ModelTranslator +python model_decimate.py src/well1500.fbx --tris 5000 # -> src/well1500_low.fbx +python model_bake.py src/well1500.fbx src/well1500_low.fbx # -> out_bake/well1500/ +python model_translator.py out_bake/well1500/well1500.fbx # -> out/well1500/(Unity 资源) +​``` + +- **model_decimate.py**:多 mesh 自动 join;三角化后 Decimate(collapse) 减到 `--tris`(±3%,最多 2 轮修正);旧 UV 全删,Smart UV Project 重展;输出 `<名>_low.fbx`(`-o` 改目录) +- **model_bake.py**:Cycles selected-to-active 烘焙。normal(切线空间 OpenGL +Y)/ao 直接烘;color/metallic/roughness 把高模 Principled 对应输入接 Emission 烘 EMIT。`--size` 默认 2048,`--samples` AO 采样默认 64,射线距离默认低模包围盒对角线 2%(`--ray-distance` 覆盖);低模无 UV 会报错,高低模包围盒差超 10% 打警告 +- 输出命名 `<名>_color/normal/metallic/roughness/ao.png`,正好命中转换器的纯网格贴图命名约定 +``` + +(注意:示例中的 ``` 围栏按 README 实际格式写,上面的 ​``` 仅为本计划文档转义。) + +- [ ] **Step 3: 全部单测最后过一遍** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest -v` +Expected: 全部 PASS + +- [ ] **Step 4: Commit** + +```bash +cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/README.md && git commit -m "ModelTranslator: README 补减面/烘焙工具用法(well1500 全管线冒烟通过)" +``` + +- [ ] **Step 5: Unity 手动验证(用户操作)** + +把 `out/well1500/` 拷入 `Client/Assets/`,拖进场景确认外观正常(法线/颜色/粗糙度无明显异常、无双重 gamma 变亮)。此步由用户在 Unity 编辑器完成。