920 lines
35 KiB
Markdown
920 lines
35 KiB
Markdown
# 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.
|