# ModelTranslator Bake Quality Optimization Implementation Plan > **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:** Make decimation and selected-to-active baking automatically repair unsuitable low-poly normals and UVs, and derive projection distance from measured high/low surface separation. **Architecture:** Add a Blender-independent `mt_quality.py` policy module for deterministic thresholds, percentile calculations, and CLI validation. Keep Blender operations in `bl_decimate.py` and `bl_bake.py`: decimation rebuilds normals and adaptively unwraps UVs, while baking validates arbitrary low models, repairs only in memory, samples a high-poly BVH, and reports quality metrics through the existing `MT_SUMMARY` protocol. **Tech Stack:** Python 3 standard library, Blender 5.0 `bpy`/`mathutils`, `unittest`, existing headless Blender runner. ## Global Constraints - Do not add external dependencies. - Do not overwrite either input FBX; repaired data is exported only in generated low/bake output FBX files. - Keep existing output directories, output stems, texture names, and existing CLI invocations compatible. - Explicit `--ray-distance` overrides adaptive ray calculation. - Defaults are texture size 2048, padding 8px, smooth angle 66 degrees, UV overlap limit 2%, and UV flip limit 0.5%. - Preserve unrelated user changes and never stage `Tools/ModelTranslator/src`, bake products, Unity assets, or other dirty-worktree files. - Follow TDD for every pure policy behavior and run the real `well1500.fbx + well_uv.fbx` smoke test before completion. --- ## File Map - Create `Tools/ModelTranslator/mt_quality.py`: pure quality thresholds and decisions; no `bpy` import. - Create `Tools/ModelTranslator/tests/test_mt_quality.py`: unit tests for every policy function. - Modify `Tools/ModelTranslator/bl_decimate.py`: normal rebuild, adaptive seam attempts, dynamic UV margin, metrics. - Modify `Tools/ModelTranslator/model_decimate.py`: new compatible CLI options and metric output. - Modify `Tools/ModelTranslator/bl_bake.py`: UV/normal diagnosis, in-memory repair, BVH sampling, adaptive ray, dynamic bake margin. - Modify `Tools/ModelTranslator/model_bake.py`: new compatible CLI options and metric output. - Modify `Tools/ModelTranslator/README.md`: defaults, overrides, and quality diagnostics. --- ### Task 1: Pure Quality Policy Module **Files:** - Create: `Tools/ModelTranslator/mt_quality.py` - Create: `Tools/ModelTranslator/tests/test_mt_quality.py` **Interfaces:** - Produces: `percentile(values, q) -> float` - Produces: `sample_indices(count, limit=10000) -> list[int]` - Produces: `adaptive_ray_distance(distances, bbox_dims) -> dict` - Produces: `normal_deviation_is_bad(median_deg, p90_deg) -> bool` - Produces: `should_repair_normals(median_deg, p90_deg, keep) -> bool` - Produces: `padding_to_margin(padding, texture_size) -> float` - Produces: `uv_metrics_acceptable(metrics) -> bool` - Produces: `should_repair_uv(metrics, keep) -> bool` - Produces: `select_uv_candidate(candidates) -> dict | None` - [ ] **Step 1: Write failing unit tests** Create `Tools/ModelTranslator/tests/test_mt_quality.py` with: ```python import os import sys import unittest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import mt_quality as mq class TestPercentile(unittest.TestCase): def test_interpolates_percentile(self): self.assertAlmostEqual(mq.percentile([0.0, 10.0], 0.25), 2.5) def test_rejects_empty_values(self): with self.assertRaises(ValueError): mq.percentile([], 0.99) def test_rejects_invalid_quantile(self): with self.assertRaises(ValueError): mq.percentile([1.0], 1.1) class TestSampleIndices(unittest.TestCase): def test_returns_all_when_under_limit(self): self.assertEqual(mq.sample_indices(4, 10), [0, 1, 2, 3]) def test_evenly_spreads_and_includes_endpoints(self): self.assertEqual(mq.sample_indices(100, 4), [0, 33, 66, 99]) def test_empty_count(self): self.assertEqual(mq.sample_indices(0), []) class TestAdaptiveRayDistance(unittest.TestCase): def test_uses_p99_with_safety_factor(self): result = mq.adaptive_ray_distance([0.004] * 20, (1.0, 0.0, 0.0)) self.assertAlmostEqual(result["distance_p99"], 0.004) self.assertAlmostEqual(result["value"], 0.006) self.assertFalse(result["capped"]) def test_applies_bbox_floor(self): result = mq.adaptive_ray_distance([0.0], (1.0, 0.0, 0.0)) self.assertAlmostEqual(result["value"], 0.0005) def test_applies_bbox_cap(self): result = mq.adaptive_ray_distance([0.02], (1.0, 0.0, 0.0)) self.assertAlmostEqual(result["value"], 0.01) self.assertTrue(result["capped"]) def test_rejects_zero_bbox(self): with self.assertRaises(ValueError): mq.adaptive_ray_distance([0.01], (0.0, 0.0, 0.0)) class TestNormalPolicy(unittest.TestCase): def test_flags_median_threshold(self): self.assertTrue(mq.normal_deviation_is_bad(25.1, 20.0)) def test_flags_p90_threshold(self): self.assertTrue(mq.normal_deviation_is_bad(10.0, 50.1)) def test_accepts_threshold_boundaries(self): self.assertFalse(mq.normal_deviation_is_bad(25.0, 50.0)) def test_repairs_bad_normals_unless_kept(self): self.assertTrue(mq.should_repair_normals(30.0, 55.0, keep=False)) self.assertFalse(mq.should_repair_normals(30.0, 55.0, keep=True)) class TestUvPolicy(unittest.TestCase): def test_padding_to_margin(self): self.assertAlmostEqual(mq.padding_to_margin(8, 2048), 8 / 2048.0) def test_padding_rejects_invalid_values(self): with self.assertRaises(ValueError): mq.padding_to_margin(-1, 2048) with self.assertRaises(ValueError): mq.padding_to_margin(8, 0) def test_accepts_clean_uv(self): self.assertTrue(mq.uv_metrics_acceptable( {"overlap": 0.02, "flipped": 0.005, "fill": 0.4})) def test_rejects_unknown_or_dirty_uv(self): self.assertFalse(mq.uv_metrics_acceptable( {"overlap": None, "flipped": 0.0, "fill": 0.4})) self.assertFalse(mq.uv_metrics_acceptable( {"overlap": 0.021, "flipped": 0.0, "fill": 0.4})) self.assertFalse(mq.uv_metrics_acceptable( {"overlap": 0.0, "flipped": 0.006, "fill": 0.4})) self.assertFalse(mq.uv_metrics_acceptable( {"overlap": 0.0, "flipped": 0.0, "fill": 0.0})) def test_selects_first_acceptable_candidate(self): candidates = [ {"seam_angle": 60, "overlap": 0.3, "flipped": 0.0, "fill": 0.5}, {"seam_angle": 45, "overlap": 0.012, "flipped": 0.0, "fill": 0.45}, {"seam_angle": 40, "overlap": 0.01, "flipped": 0.0, "fill": 0.4}, ] self.assertIs(mq.select_uv_candidate(candidates), candidates[1]) def test_returns_none_when_all_candidates_fail(self): self.assertIsNone(mq.select_uv_candidate([ {"overlap": 0.1, "flipped": 0.0, "fill": 0.5}])) def test_repairs_bad_uv_unless_kept(self): dirty = {"overlap": 0.1, "flipped": 0.0, "fill": 0.5} self.assertTrue(mq.should_repair_uv(dirty, keep=False)) self.assertFalse(mq.should_repair_uv(dirty, keep=True)) if __name__ == "__main__": unittest.main() ``` - [ ] **Step 2: Run the test and verify RED** Run from `Tools/ModelTranslator`: ```powershell python -m unittest tests.test_mt_quality -v ``` Expected: `ERROR` with `ModuleNotFoundError: No module named 'mt_quality'`. - [ ] **Step 3: Implement the pure policy module** Create `Tools/ModelTranslator/mt_quality.py` with: ```python """Pure quality policies shared by ModelTranslator Blender tools.""" import math DEFAULT_TEXTURE_SIZE = 2048 DEFAULT_PADDING = 8 DEFAULT_SMOOTH_ANGLE = 66.0 UV_SEAM_ANGLES = (60.0, 55.0, 50.0, 45.0, 40.0, 30.0) UV_OVERLAP_LIMIT = 0.02 UV_FLIP_LIMIT = 0.005 NORMAL_MEDIAN_LIMIT = 25.0 NORMAL_P90_LIMIT = 50.0 RAY_SAFETY = 1.5 RAY_LOWER_PCT = 0.0005 RAY_UPPER_PCT = 0.01 def percentile(values, q): if not values: raise ValueError("percentile requires at least one value") if not 0.0 <= q <= 1.0: raise ValueError("quantile must be between 0 and 1") ordered = sorted(float(v) for v in values) pos = (len(ordered) - 1) * q lo = int(math.floor(pos)) hi = int(math.ceil(pos)) if lo == hi: return ordered[lo] return ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo) def sample_indices(count, limit=10000): if count <= 0: return [] if limit <= 0: raise ValueError("sample limit must be positive") if count <= limit: return list(range(count)) return [round(i * (count - 1) / float(limit - 1)) for i in range(limit)] def adaptive_ray_distance(distances, bbox_dims): diagonal = math.sqrt(sum(float(d) * float(d) for d in bbox_dims)) if diagonal <= 0.0: raise ValueError("low-poly bounding box diagonal must be positive") p99 = percentile(distances, 0.99) lower = diagonal * RAY_LOWER_PCT upper = diagonal * RAY_UPPER_PCT raw = max(p99 * RAY_SAFETY, lower) return {"distance_p99": p99, "value": min(raw, upper), "capped": raw > upper} def normal_deviation_is_bad(median_deg, p90_deg): return median_deg > NORMAL_MEDIAN_LIMIT or p90_deg > NORMAL_P90_LIMIT def should_repair_normals(median_deg, p90_deg, keep=False): return not keep and normal_deviation_is_bad(median_deg, p90_deg) def padding_to_margin(padding, texture_size): if padding < 0: raise ValueError("padding must not be negative") if texture_size <= 0: raise ValueError("texture size must be positive") return padding / float(texture_size) def uv_metrics_acceptable(metrics): overlap = metrics.get("overlap") return (overlap is not None and overlap <= UV_OVERLAP_LIMIT and metrics.get("flipped", 1.0) <= UV_FLIP_LIMIT and metrics.get("fill", 0.0) > 0.0) def should_repair_uv(metrics, keep=False): return not keep and not uv_metrics_acceptable(metrics) def select_uv_candidate(candidates): return next((m for m in candidates if uv_metrics_acceptable(m)), None) ``` - [ ] **Step 4: Run focused and full unit tests and verify GREEN** Run: ```powershell python -m unittest tests.test_mt_quality -v python -m unittest discover -s tests -v ``` Expected: all new tests pass; all existing ModelTranslator tests pass with zero failures. - [ ] **Step 5: Commit the policy module** ```powershell git add -- Tools/ModelTranslator/mt_quality.py Tools/ModelTranslator/tests/test_mt_quality.py git commit -m "ModelTranslator: add bake quality policies" ``` --- ### Task 2: Decimation Normal and UV Repair **Files:** - Modify: `Tools/ModelTranslator/bl_decimate.py` - Modify: `Tools/ModelTranslator/model_decimate.py` - Test: `Tools/ModelTranslator/tests/test_mt_quality.py` **Interfaces:** - Consumes: all UV policy constants/functions from `mt_quality.py`. - Produces: `repair_normals(obj, smooth_angle) -> bool` for reuse by `bl_bake.py`. - Produces: `_normal_geometry_metrics(obj) -> dict` with `median` and `p90`. - Produces: `_do_unwrap(obj, mode, warnings, margin, angles) -> dict` with `mode`, `seam_angle`, and UV metrics. - Extends the Blender script argv with `texture_size`, `padding`, and `smooth_angle`, each retaining a default when absent. - [ ] **Step 1: Verify the new CLI behavior is RED before editing production code** Run from `Tools/ModelTranslator`: ```powershell python model_decimate.py src/well1500.fbx --tris 5000 --texture-size 2048 --padding 8 --smooth-angle 66 ``` Expected: exit code 2 and `unrecognized arguments` for the new options. - [ ] **Step 2: Parameterize UV unwrap and add adaptive candidate selection** In `bl_decimate.py`, import the policy values and replace fixed UV constants: ```python from mt_quality import (DEFAULT_PADDING, DEFAULT_SMOOTH_ANGLE, DEFAULT_TEXTURE_SIZE, UV_SEAM_ANGLES, padding_to_margin, percentile, select_uv_candidate, uv_metrics_acceptable) ``` Replace `_unwrap_smart` with the parameterized version: ```python def _unwrap_smart(obj, margin): import bpy import math _clear_uv_layers(obj.data) 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=margin) bpy.ops.object.mode_set(mode='OBJECT') ``` Replace `_unwrap_seam` with the parameterized version below. At the beginning of each attempt, clear old seam flags before selecting the current angle: ```python def _unwrap_seam(obj, warnings, seam_angle, margin): import bpy import math _clear_uv_layers(obj.data) obj.data.uv_layers.new() bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_mode(type='EDGE') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.mark_seam(clear=True) bpy.ops.mesh.select_all(action='DESELECT') bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(seam_angle)) bpy.ops.mesh.mark_seam(clear=False) bpy.ops.mesh.select_all(action='SELECT') try: bpy.ops.uv.unwrap(method='MINIMUM_STRETCH', margin=margin) except TypeError: warnings.append("MINIMUM_STRETCH 不可用,展开改用 ANGLE_BASED") bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=margin) try: bpy.ops.uv.pack_islands(rotate=True, margin=margin) except RuntimeError: warnings.append("pack_islands 不可用,沿用 unwrap 自带布局") bpy.ops.object.mode_set(mode='OBJECT') ``` Replace `_do_unwrap` with an adaptive loop. Only final fallback information is appended to `warnings`; per-angle metrics remain in the summary-ready result: ```python def _do_unwrap(obj, mode, warnings, margin, angles=UV_SEAM_ANGLES): if mode == "seam": attempts = [] for angle in angles: _unwrap_seam(obj, warnings, angle, margin) metrics = _collect_uv_metrics(obj) metrics["seam_angle"] = angle attempts.append(metrics) chosen = select_uv_candidate(attempts) if chosen is not None: chosen["mode"] = "seam" return chosen warnings.append("seam 展开候选均未通过质量门,回退 smart_project") mode = "smart_fallback" _unwrap_smart(obj, margin) metrics = _collect_uv_metrics(obj) metrics["mode"] = mode if mode == "smart_fallback" else "smart" metrics["seam_angle"] = None if not uv_metrics_acceptable(metrics): warnings.append("smart_project 结果仍未通过 UV 质量门") return metrics ``` - [ ] **Step 3: Add reusable normal metrics and repair** Add these Blender-only helpers after `join_meshes`: ```python def _normal_geometry_metrics(obj): import math values = [] mesh = obj.data for poly in mesh.polygons: face_normal = poly.normal.normalized() for loop_index in poly.loop_indices: corner = mesh.corner_normals[loop_index].vector.normalized() dot = max(-1.0, min(1.0, corner.dot(face_normal))) values.append(math.degrees(math.acos(dot))) return {"median": round(percentile(values, 0.5), 3), "p90": round(percentile(values, 0.9), 3)} def repair_normals(obj, smooth_angle): import bpy import math had_custom = bool(getattr(obj.data, "has_custom_normals", False)) bpy.ops.object.select_all(action='DESELECT') obj.select_set(True) bpy.context.view_layer.objects.active = obj if had_custom: bpy.ops.mesh.customdata_custom_splitnormals_clear() bpy.ops.object.shade_smooth_by_angle( angle=math.radians(smooth_angle), keep_sharp_edges=True) obj.data.update() return had_custom ``` In `main`, capture geometry-normal metrics before and after repair, then unwrap with the dynamic margin: ```python normal_before = _normal_geometry_metrics(obj) had_custom = repair_normals(obj, smooth_angle) normal_after = _normal_geometry_metrics(obj) margin = padding_to_margin(padding, texture_size) uv_info = _do_unwrap(obj, unwrap_mode, warnings, margin) uv_info.update({"repaired": True, "padding": padding, "texture_size": texture_size}) normal_info = {"repaired": True, "had_custom": had_custom, "basis": "corner_vs_face", "before_median": normal_before["median"], "before_p90": normal_before["p90"], "after_median": normal_after["median"], "after_p90": normal_after["p90"], "smooth_angle": smooth_angle} ``` Parse optional Blender argv compatibly: ```python texture_size = int(argv[4]) if len(argv) > 4 else DEFAULT_TEXTURE_SIZE padding = int(argv[5]) if len(argv) > 5 else DEFAULT_PADDING smooth_angle = float(argv[6]) if len(argv) > 6 else DEFAULT_SMOOTH_ANGLE ``` Add `normal_info` to `MT_SUMMARY` as `normal`. - [ ] **Step 4: Extend and validate `model_decimate.py` CLI** Add arguments and validation: ```python from mt_quality import DEFAULT_PADDING, DEFAULT_SMOOTH_ANGLE, DEFAULT_TEXTURE_SIZE ap.add_argument("--texture-size", type=int, default=DEFAULT_TEXTURE_SIZE, help="目标烘焙贴图边长,用于换算 UV padding") ap.add_argument("--padding", type=int, default=DEFAULT_PADDING, help="UV 岛像素间距") ap.add_argument("--smooth-angle", type=float, default=DEFAULT_SMOOTH_ANGLE, help="低模平滑锐边角度(度)") ``` After parsing: ```python if args.texture_size <= 0: ap.error("--texture-size 必须为正整数") if args.padding < 0: ap.error("--padding 不能为负数") if not 0.0 < args.smooth_angle < 180.0: ap.error("--smooth-angle 必须在 0 到 180 度之间") ``` Append `texture_size`, `padding`, and `smooth_angle` to `run_blender_script` arguments. Print one normal line and replace the existing UV line with the expanded form: ```python n = s["normal"] print(" 法线: 修复=%s 中位 %.1f° -> %.1f° P90 %.1f° -> %.1f°" % ("是" if n["repaired"] else "否", n["before_median"], n["after_median"], n["before_p90"], n["after_p90"])) angle = "n/a" if u["seam_angle"] is None else "%.0f°" % u["seam_angle"] print(" UV: 模式=%s seam=%s 岛数=%d 利用率=%.1f%% 翻转=%.1f%% 重叠=%s padding=%dpx" % (u["mode"], angle, u["islands"], u["fill"] * 100, u["flipped"] * 100, overlap, u["padding"])) ``` - [ ] **Step 5: Run unit and real decimation verification** Run: ```powershell python -m py_compile mt_quality.py bl_decimate.py model_decimate.py python -m unittest discover -s tests -v python model_decimate.py src/well1500.fbx --tris 5000 --texture-size 2048 --padding 8 --smooth-angle 66 -o out_quality ``` Expected: - compile and unit tests exit 0; - `out_quality/well1500_low.fbx` exists; - CLI reports 5000 faces within existing tolerance; - normal median and P90 decrease; - UV overlap is at most 2%, flipped is at most 0.5%, and the chosen seam angle is reported. - [ ] **Step 6: Commit decimation repair** ```powershell git add -- Tools/ModelTranslator/bl_decimate.py Tools/ModelTranslator/model_decimate.py git commit -m "ModelTranslator: repair decimated normals and UVs" ``` --- ### Task 3: Bake Diagnosis, In-Memory Repair, and Adaptive Ray **Files:** - Modify: `Tools/ModelTranslator/bl_bake.py` - Modify: `Tools/ModelTranslator/model_bake.py` - Consume: `Tools/ModelTranslator/bl_decimate.py` - Consume: `Tools/ModelTranslator/mt_quality.py` **Interfaces:** - Consumes: `repair_normals`, `_collect_uv_metrics`, `_do_unwrap`, and `_triangulate` from `bl_decimate.py`. - Consumes: adaptive ray, normal policy, sampling, padding, and UV policy from `mt_quality.py`. - Produces: `MT_SUMMARY.normal`, `MT_SUMMARY.uv`, and `MT_SUMMARY.ray`. - Extends Blender argv with `padding`, `smooth_angle`, `keep_low_normals`, and `keep_low_uv`. - [ ] **Step 1: Verify the new bake CLI behavior is RED** Run from `Tools/ModelTranslator`: ```powershell python model_bake.py src/well1500.fbx src/well_uv.fbx --padding 8 --smooth-angle 66 --keep-low-normals ``` Expected: exit code 2 and `unrecognized arguments`. - [ ] **Step 2: Add projection sample and normal metric helpers** Import policies and shared Blender operations: ```python from mt_quality import (DEFAULT_PADDING, DEFAULT_SMOOTH_ANGLE, adaptive_ray_distance, padding_to_margin, percentile, sample_indices, should_repair_normals, should_repair_uv, uv_metrics_acceptable) ``` Add helpers before `_setup_bake_target`: ```python def _build_projection_samples(high, low, limit=10000): from mathutils.bvhtree import BVHTree high_vertices = [high.matrix_world @ v.co for v in high.data.vertices] high_polygons = [tuple(p.vertices) for p in high.data.polygons] bvh = BVHTree.FromPolygons(high_vertices, high_polygons, all_triangles=False) samples = [] for low_index in sample_indices(len(low.data.polygons), limit): poly = low.data.polygons[low_index] hit = bvh.find_nearest(low.matrix_world @ poly.center) if hit[0] is not None: samples.append({"low_index": low_index, "location": hit[0], "geometric_normal": hit[1], "high_index": hit[2], "distance": hit[3]}) if not samples: raise ValueError("高低模 BVH 采样无命中") return samples def _normal_projection_metrics(high, low, samples): import math from mathutils.geometry import barycentric_transform high_mesh, low_mesh = high.data, low.data high_normal_matrix = high.matrix_world.to_3x3().inverted().transposed() low_normal_matrix = low.matrix_world.to_3x3().inverted().transposed() angles = [] for sample in samples: high_poly = high_mesh.polygons[sample["high_index"]] if len(high_poly.vertices) != 3: high_normal = sample["geometric_normal"].normalized() else: vertex_ids = list(high_poly.vertices) loop_ids = list(high_poly.loop_indices) coords = [high.matrix_world @ high_mesh.vertices[i].co for i in vertex_ids] normals = [(high_normal_matrix @ high_mesh.corner_normals[i].vector).normalized() for i in loop_ids] high_normal = barycentric_transform( sample["location"], coords[0], coords[1], coords[2], normals[0], normals[1], normals[2]).normalized() low_poly = low_mesh.polygons[sample["low_index"]] low_normals = [low_mesh.corner_normals[i].vector for i in low_poly.loop_indices] low_normal = (low_normal_matrix @ sum(low_normals[1:], low_normals[0])).normalized() dot = max(-1.0, min(1.0, low_normal.dot(high_normal))) angles.append(math.degrees(math.acos(dot))) return {"median": round(percentile(angles, 0.5), 3), "p90": round(percentile(angles, 0.9), 3)} ``` - [ ] **Step 3: Add UV and normal auto-repair to bake setup** After low UV existence validation and before creating the bake material: ```python margin = padding_to_margin(padding, size) uv_before = _collect_uv_metrics(low) uv_info = dict(uv_before) uv_info.update({"repaired": False, "seam_angle": None, "padding": padding, "texture_size": size}) if should_repair_uv(uv_before, keep=keep_low_uv): uv_info = _do_unwrap(low, "seam", warnings, margin) uv_info.update({"repaired": True, "padding": padding, "texture_size": size}) elif not uv_metrics_acceptable(uv_before): warnings.append("低模 UV 未通过质量门,按 --keep-low-uv 保留") bpy.ops.object.select_all(action='DESELECT') high.select_set(True) bpy.context.view_layer.objects.active = high _triangulate(high) try: samples = _build_projection_samples(high, low) except ValueError as exc: print("MT_SUMMARY " + json.dumps({"error": str(exc)}, ensure_ascii=False)) return normal_before = _normal_projection_metrics(high, low, samples) normal_repaired = False if should_repair_normals(normal_before["median"], normal_before["p90"], keep=keep_low_normals): repair_normals(low, smooth_angle) normal_repaired = True elif normal_deviation_is_bad(normal_before["median"], normal_before["p90"]): warnings.append("低模法线偏差超标,按 --keep-low-normals 保留") normal_after = _normal_projection_metrics(high, low, samples) normal_info = {"repaired": normal_repaired, "basis": "low_vs_high", "before_median": normal_before["median"], "before_p90": normal_before["p90"], "after_median": normal_after["median"], "after_p90": normal_after["p90"], "smooth_angle": smooth_angle} ``` Import the four shared Blender helpers inside `main` to avoid system-Python `bpy` imports at module import time: ```python from bl_decimate import (_collect_uv_metrics, _do_unwrap, _triangulate, repair_normals) ``` - [ ] **Step 4: Replace auto ray and bake margin behavior** Replace the current auto ray selection: ```python if ray_arg == "auto": try: ray_info = adaptive_ray_distance( [sample["distance"] for sample in samples], tuple(low.dimensions)) except ValueError as exc: print("MT_SUMMARY " + json.dumps({"error": str(exc)}, ensure_ascii=False)) return ray = ray_info["value"] ray_info.update({"source": "adaptive"}) if ray_info["capped"]: warnings.append("自适应射线达到包围盒对角线 1%% 上限,请检查高低模对应关系") else: ray = float(ray_arg) ray_info = {"source": "explicit", "distance_p99": None, "value": ray, "capped": False} ``` Replace `_bake_pass` with the same existing body plus an explicit `padding` argument and dynamic margin: ```python def _bake_pass(low, high, target_node, name, bake_type, size, colorspace, ray, samples, padding, outdir, stem): 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=padding, use_clear=True) if bake_type == 'NORMAL': kwargs["normal_space"] = 'TANGENT' 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) ``` Pass `padding` at every `_bake_pass` call. Preserve the existing top-level `ray_distance` field and add the structured summaries: ```python ray_info = dict(ray_info) ray_info["distance_p99"] = (None if ray_info["distance_p99"] is None else round(ray_info["distance_p99"], 6)) ray_info["value"] = round(ray_info["value"], 6) print("MT_SUMMARY " + json.dumps( {"stem": stem, "fbx": os.path.basename(out_fbx), "outputs": outputs, "size": size, "ray_distance": round(ray, 6), "ray": ray_info, "normal": normal_info, "uv": uv_info, "high_tris": len(high.data.polygons), "low_tris": len(low.data.polygons), "warnings": warnings}, ensure_ascii=False)) ``` - [ ] **Step 5: Extend and validate `model_bake.py` CLI** Add: ```python from mt_quality import DEFAULT_PADDING, DEFAULT_SMOOTH_ANGLE ap.add_argument("--padding", type=int, default=DEFAULT_PADDING, help="烘焙贴图 UV 扩边像素") ap.add_argument("--smooth-angle", type=float, default=DEFAULT_SMOOTH_ANGLE, help="异常低模法线的修复角度(度)") ap.add_argument("--keep-low-normals", action="store_true", help="即使检测异常也保留输入低模法线") ap.add_argument("--keep-low-uv", action="store_true", help="即使检测异常也保留输入低模 UV") ``` Validate padding and angle with the same conditions as Task 2. Append these values to Blender argv: ```python str(args.padding), str(args.smooth_angle), "1" if args.keep_low_normals else "0", "1" if args.keep_low_uv else "0" ``` Parse them compatibly in `bl_bake.py`: ```python padding = int(argv[6]) if len(argv) > 6 else DEFAULT_PADDING smooth_angle = float(argv[7]) if len(argv) > 7 else DEFAULT_SMOOTH_ANGLE keep_low_normals = len(argv) > 8 and argv[8] == "1" keep_low_uv = len(argv) > 9 and argv[9] == "1" ``` Print concise `normal`, `uv`, and `ray` lines from the returned summary: ```python n = s["normal"] print(" 法线: 修复=%s 中位 %.1f° -> %.1f° P90 %.1f° -> %.1f°" % ("是" if n["repaired"] else "否", n["before_median"], n["after_median"], n["before_p90"], n["after_p90"])) u = s["uv"] overlap = "n/a" if u["overlap"] is None else "%.1f%%" % (u["overlap"] * 100) angle = "n/a" if u["seam_angle"] is None else "%.0f°" % u["seam_angle"] print(" UV: 修复=%s 模式=%s seam=%s 翻转=%.1f%% 重叠=%s padding=%dpx" % ("是" if u["repaired"] else "否", u.get("mode", "input"), angle, u["flipped"] * 100, overlap, u["padding"])) r = s["ray"] p99 = "n/a" if r["distance_p99"] is None else "%.4f" % r["distance_p99"] print(" 射线: 来源=%s P99=%s 距离=%.4f%s" % (r["source"], p99, r["value"], "(已截断)" if r["capped"] else "")) ``` - [ ] **Step 6: Run focused verification with the diagnosed model** Run: ```powershell python -m py_compile bl_bake.py model_bake.py python -m unittest discover -s tests -v python model_bake.py src/well1500.fbx src/well_uv.fbx -o out_bake_optimized --size 2048 --padding 8 --samples 64 ``` Expected: - compile and all unit tests exit 0; - normal repair is `True`, after median and P90 are lower than before, and after median is at most 20 degrees; - UV repair is `True`, overlap is at most 2%, flipped is at most 0.5%; - ray source is `adaptive`, with value between 0.004 and 0.010 for this model; - `out_bake_optimized/well_uv` contains one FBX and five non-empty 2048x2048 PNG files. - [ ] **Step 7: Verify explicit preservation and explicit ray branches** Run a lower-cost 256px branch check: ```powershell python model_bake.py src/well1500.fbx src/well_uv.fbx -o out_bake_optimized_keep --size 256 --padding 2 --samples 4 --ray-distance 0.006 --keep-low-normals --keep-low-uv ``` Expected: exit 0; ray source is `explicit`; warnings state that abnormal low normals and UVs were preserved; outputs exist at 256x256. - [ ] **Step 8: Commit bake adaptation** ```powershell git add -- Tools/ModelTranslator/bl_bake.py Tools/ModelTranslator/model_bake.py git commit -m "ModelTranslator: adapt bake projection to model quality" ``` --- ### Task 4: Documentation and End-to-End Verification **Files:** - Modify: `Tools/ModelTranslator/README.md` - Verify: `Tools/ModelTranslator/out_bake_optimized/well_uv/*` (untracked product) **Interfaces:** - Documents the CLI and summary fields produced by Tasks 2 and 3. - Does not change converter output naming or Unity import behavior. - [ ] **Step 1: Update README with exact defaults and overrides** Replace the decimate/bake bullets in the “减面与高低模烘焙” section with text covering: ```markdown - **model_decimate.py**:减面后清除旧 custom normals,按 `--smooth-angle`(默认 66°)重建锐边平滑;UV 默认尝试多档 seam 角度并以重叠 ≤2%、翻转 ≤0.5% 为质量门,失败才回退 Smart UV。`--texture-size` 默认 2048,`--padding` 默认 8px。 - **model_bake.py**:烘焙前检查外部低模法线和 UV;异常时只在内存及输出 FBX 中自动修复,源文件不变,`--keep-low-normals` / `--keep-low-uv` 可保留输入数据。自动射线取高低模 BVH 距离 P99×1.5,并限制在包围盒对角线 0.05%–1%;`--ray-distance` 显式值优先。贴图 `--padding` 默认 8px。 ``` Add one short troubleshooting paragraph explaining the printed normal/UV/ray metrics and that extreme `--tris` reduction may still need a higher low-poly face budget or explicit cage. - [ ] **Step 2: Run final automated verification** Run from `Tools/ModelTranslator`: ```powershell python -m py_compile mt_quality.py bl_decimate.py model_decimate.py bl_bake.py model_bake.py python -m unittest discover -s tests -v ``` Expected: compile exits 0 and all tests pass with zero failures/errors. - [ ] **Step 3: Validate optimized artifact dimensions and presence** Run: ```powershell Get-ChildItem out_bake_optimized\well_uv | Select-Object Name,Length ``` Then load each PNG in Blender and print dimensions: ```powershell $code = @' import bpy, glob, json, os, sys directory = os.path.abspath(sys.argv[sys.argv.index('--') + 1]) result = {} for path in sorted(glob.glob(os.path.join(directory, '*.png'))): image = bpy.data.images.load(path) result[os.path.basename(path)] = list(image.size) print('IMAGE_JSON ' + json.dumps(result)) '@ & 'D:\tools\blender-5.0.0-windows-x64\blender.exe' -b --factory-startup --python-expr $code -- 'out_bake_optimized\well_uv' | Select-String 'IMAGE_JSON' ``` Expected: exactly five PNG entries, each `[2048, 2048]`, plus a non-empty `well_uv.fbx`. - [ ] **Step 4: Visually inspect optimized maps** Open with the local image viewer: - `Tools/ModelTranslator/out_bake_optimized/well_uv/well_uv_normal.png` - `Tools/ModelTranslator/out_bake_optimized/well_uv/well_uv_ao.png` - `Tools/ModelTranslator/out_bake_optimized/well_uv/well_uv_color.png` Acceptance: no large cross-part projections, no unexpected unbaked holes inside occupied UV regions, and visibly fewer extreme normal colors than the current `out_bake/well_uv` result. - [ ] **Step 5: Inspect the final diff and staged paths** Run: ```powershell git diff --check git status --short git diff -- Tools/ModelTranslator docs/superpowers ``` Expected: no whitespace errors; only planned code/docs are modified or staged by this work; existing user Unity/source/output changes remain untouched and unstaged. - [ ] **Step 6: Commit README and verification record** ```powershell git add -- Tools/ModelTranslator/README.md git commit -m "ModelTranslator: document adaptive bake quality" ``` - [ ] **Step 7: Fresh completion gate** Re-run immediately before reporting completion: ```powershell python -m unittest discover -s tests -v git status --short ``` Expected: all ModelTranslator tests pass; remaining dirty paths are pre-existing user assets/products, not uncommitted planned source changes.