Files
AIC-Project/docs/superpowers/plans/2026-07-16-model-bake-quality-optimization.md
T

35 KiB
Raw Blame History

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:

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:

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:

"""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:

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
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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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
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:

- **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:

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:

Get-ChildItem out_bake_optimized\well_uv | Select-Object Name,Length

Then load each PNG in Blender and print dimensions:

$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:

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
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:

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.