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

920 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.