35 KiB
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-distanceoverrides 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.fbxsmoke test before completion.
File Map
- Create
Tools/ModelTranslator/mt_quality.py: pure quality thresholds and decisions; nobpyimport. - 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) -> boolfor reuse bybl_bake.py. -
Produces:
_normal_geometry_metrics(obj) -> dictwithmedianandp90. -
Produces:
_do_unwrap(obj, mode, warnings, margin, angles) -> dictwithmode,seam_angle, and UV metrics. -
Extends the Blender script argv with
texture_size,padding, andsmooth_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.pyCLI
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.fbxexists; -
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_triangulatefrombl_decimate.py. -
Consumes: adaptive ray, normal policy, sampling, padding, and UV policy from
mt_quality.py. -
Produces:
MT_SUMMARY.normal,MT_SUMMARY.uv, andMT_SUMMARY.ray. -
Extends Blender argv with
padding,smooth_angle,keep_low_normals, andkeep_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.pyCLI
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_uvcontains 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.pngTools/ModelTranslator/out_bake_optimized/well_uv/well_uv_ao.pngTools/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.