Merge: ModelTranslator UV 展开质量优化 + 线框观察图
松弛(minimize_stretch)+纹素均衡(average_islands_scale)、seam 角度扫描 分级抢救(66/55/45/35 取过门且岛最少者)、UV 线框观察图(Blender 抽几何→ 系统 Python Pillow 绘 PNG)。gargoyle 由 smart 兜底(324 岛)改善为 seam@45(205 岛)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
|
||||
- Blender 5.0(默认路径 `D:\tools\blender-5.0.0-windows-x64\blender.exe`;也可用 `--blender` 参数或环境变量 `BLENDER_EXE` 指定)
|
||||
- UVPackmaster 4(可选,UV 排布质量更好:Blender 扩展 `uvpackmaster4` + 独立引擎,引擎路径经注册表自动发现;已装则 Blender 展开路径(seam/smart)的排布自动启用,未装自动回退内置 pack_islands)
|
||||
- 系统 Python 3.x(仅标准库)
|
||||
- 系统 Python 3.x(标准库 + Pillow——仅 `model_decimate.py` 绘 UV 观察图用;`pip install Pillow`)
|
||||
|
||||
## 用法
|
||||
|
||||
@@ -46,7 +46,7 @@ python model_bake.py src/well1500.fbx src/well1500_low.fbx # -> out_bake/well1
|
||||
python model_translator.py out_bake/well1500/well1500.fbx # -> out/well1500/(Unity 资源)
|
||||
```
|
||||
|
||||
- **model_decimate.py**:多 mesh 自动 join;三角化后 Decimate(collapse) 减到 `--tris`(±3%,最多 2 轮修正);旧 UV 全删后重展——默认锐边 seam 整岛展开,质量门不达标(翻转 >2% 或重叠 >8%)自动回退 Smart UV Project,`--unwrap smart` 可直接选投影式展开;排布默认用 UVPackmaster 4 重排(利用率显著提升,日志模式带 `+uvpm` 后缀,4px@2048 像素边距与烘焙 padding 同口径),UVPM4 不可用自动沿用内置 pack_islands 布局;输出 `<名>_low.fbx`(`-o` 改目录),日志报 UV 岛数/利用率
|
||||
- **model_decimate.py**:多 mesh 自动 join;三角化后 Decimate(collapse) 减到 `--tris`(±3%,最多 2 轮修正);旧 UV 全删后重展——默认锐边 seam 整岛展开(SLIM 展开后 `minimize_stretch` 松弛 + `average_islands_scale` 纹素均衡),质量门不达标(翻转 >2% 或重叠 >8%)先按更低 seam 角度扫描(55/45/35°)取过门且岛数最少者抢救,全失败才回退 Smart UV Project,`--unwrap smart` 可直接选投影式展开;排布默认用 UVPackmaster 4 重排(只在最终选定 UV 上跑一次),UVPM4 不可用自动沿用内置 pack_islands 布局;输出 `<名>_low.fbx`(`-o` 改目录)与 `<名>_low_uv.png`(UV 线框观察图,便于人工查阅切分/排布/碎岛——Blender 抽 UV 几何,系统 Python 用 Pillow 绘制,因 headless 无 GPU 无法用 Blender 直接出 PNG),日志报 UV 岛数/利用率与实际生效的 seam 角度
|
||||
- **model_bake.py**:Cycles selected-to-active 烘焙。normal(切线空间 OpenGL +Y)/ao 直接烘;color/metallic/roughness 把高模 Principled 对应输入接 Emission 烘 EMIT。`--size` 默认 2048,`--samples` AO 采样默认 64,射线距离默认低模包围盒对角线 2%(`--ray-distance` 覆盖);低模无 UV 会报错,高低模包围盒差超 10% 打警告;导入后自动应用物体变换——未应用缩放的 FBX(如 cm 单位导出的 scale=0.01)会把射线距离缩到近零导致大面积烘空(脏色);固定 `-t 1` 单线程跑 Blender——5.0 的 selected-to-active 射线求交多线程有竞争,会随机 EXCEPTION_ACCESS_VIOLATION 崩溃
|
||||
- 输出命名 `<名>_color/normal/metallic/roughness/ao.png`,正好命中转换器的纯网格贴图命名约定
|
||||
|
||||
|
||||
@@ -38,6 +38,16 @@ def uvpm_mode_label(base, applied):
|
||||
return base + "+uvpm" if applied else base
|
||||
|
||||
|
||||
def pick_best_candidate(candidates):
|
||||
"""从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。
|
||||
每个 candidate 至少含 flipped/overlap/islands。"""
|
||||
passing = [c for c in candidates
|
||||
if uv_gate_ok(c["flipped"], c["overlap"])]
|
||||
if not passing:
|
||||
return None
|
||||
return min(passing, key=lambda c: c["islands"])
|
||||
|
||||
|
||||
def count_islands(n_faces, adjacent_pairs):
|
||||
"""union-find 数 UV 岛:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。"""
|
||||
parent = list(range(n_faces))
|
||||
@@ -106,6 +116,8 @@ PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px
|
||||
UVPM_EXT = "bl_ext.user_default.uvpackmaster4" # UVPackmaster 4 扩展模块名
|
||||
UVPM_PIXEL_MARGIN = 4 # UVPM 岛间距(像素),与 PACK_MARGIN * UVPM_TEX_SIZE 同口径
|
||||
UVPM_TEX_SIZE = 2048
|
||||
SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0) # 首选 SEAM_ANGLE_DEG 不过门时依次下探的 seam 角度
|
||||
STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数
|
||||
|
||||
|
||||
def _clear_uv_layers(mesh):
|
||||
@@ -125,18 +137,22 @@ def _unwrap_smart(obj):
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
def _unwrap_seam(obj, warnings):
|
||||
"""锐边标 seam -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> pack。碎岛远少于投影式。"""
|
||||
def _unwrap_seam(obj, warnings, seam_angle_deg=SEAM_ANGLE_DEG):
|
||||
"""锐边标 seam -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> 松弛 -> 纹素均衡 -> pack。
|
||||
seam_angle_deg 可变以支持角度扫描;重跑前清掉上一档残留 seam。"""
|
||||
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')
|
||||
# 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛
|
||||
bpy.ops.mesh.select_mode(type='EDGE')
|
||||
# 清掉上一档角度残留 seam(同一对象多角度扫描时必须;同时丢弃源资产自带 seam,保证确定性)
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.mark_seam(clear=True)
|
||||
# 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛
|
||||
bpy.ops.mesh.select_all(action='DESELECT')
|
||||
bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(SEAM_ANGLE_DEG))
|
||||
bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(seam_angle_deg))
|
||||
bpy.ops.mesh.mark_seam(clear=False)
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
try:
|
||||
@@ -144,6 +160,14 @@ def _unwrap_seam(obj, warnings):
|
||||
except TypeError: # 老版本无 SLIM 枚举
|
||||
warnings.append("MINIMUM_STRETCH 不可用,展开改用 ANGLE_BASED")
|
||||
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=PACK_MARGIN)
|
||||
try: # 角度松弛:压翻转/拉伸
|
||||
bpy.ops.uv.minimize_stretch(iterations=STRETCH_ITERS)
|
||||
except (RuntimeError, TypeError) as e:
|
||||
warnings.append("minimize_stretch 不可用(%s),跳过松弛" % e)
|
||||
try: # 统一纹素密度,避免大岛霸占分辨率
|
||||
bpy.ops.uv.average_islands_scale()
|
||||
except (RuntimeError, TypeError) as e:
|
||||
warnings.append("average_islands_scale 不可用(%s),跳过纹素均衡" % e)
|
||||
try:
|
||||
bpy.ops.uv.pack_islands(rotate=True, margin=PACK_MARGIN)
|
||||
except RuntimeError: # headless 上下文不满足时靠 unwrap 自带打包
|
||||
@@ -265,18 +289,35 @@ def _uvpm_repack(obj, warnings):
|
||||
|
||||
|
||||
def _do_unwrap(obj, mode, warnings):
|
||||
"""按模式展开(排布默认 UVPM4 增强,失败保底内置 pack);
|
||||
seam 质量不达标自动回退 smart。返回 uv 指标 dict(含 mode)。"""
|
||||
"""按模式展开:seam 从 SEAM_ANGLE_DEG 起降序扫 SEAM_ANGLE_SWEEP,首个过门者即选并停扫
|
||||
——岛数随角度降单调增,故首个过门者已是过门候选里岛数最少的(pick_best_candidate 据此在
|
||||
候选集取岛数最少者,与早停一致;重跑分支为防御:早停下 best 恒为最后一档,通常不触发);
|
||||
全失败退 smart。排布 UVPM4 增强,只在最终 UV 上跑一次,失败保底内置 pack。返回 uv 指标 dict(含 mode)。"""
|
||||
if mode == "seam":
|
||||
_unwrap_seam(obj, warnings)
|
||||
uvpm = _uvpm_repack(obj, warnings)
|
||||
m = _collect_uv_metrics(obj)
|
||||
if uv_gate_ok(m["flipped"], m["overlap"]):
|
||||
m["mode"] = uvpm_mode_label("seam", uvpm)
|
||||
angles = [SEAM_ANGLE_DEG] + list(SEAM_ANGLE_SWEEP)
|
||||
candidates = []
|
||||
for ang in angles:
|
||||
_unwrap_seam(obj, warnings, seam_angle_deg=ang)
|
||||
m = _collect_uv_metrics(obj)
|
||||
m["angle"] = ang
|
||||
candidates.append(m)
|
||||
if uv_gate_ok(m["flipped"], m["overlap"]):
|
||||
break # 该档已过门;更低角度只会更碎,无需再试
|
||||
best = pick_best_candidate(candidates)
|
||||
if best is not None:
|
||||
if best["angle"] != candidates[-1]["angle"]:
|
||||
# 选中档不是最后跑的那档,重跑恢复其 UV(_unwrap_seam 会覆盖)
|
||||
_unwrap_seam(obj, warnings, seam_angle_deg=best["angle"])
|
||||
uvpm = _uvpm_repack(obj, warnings)
|
||||
m = _collect_uv_metrics(obj)
|
||||
m["mode"] = uvpm_mode_label("seam@%d" % int(best["angle"]), uvpm)
|
||||
return m
|
||||
warnings.append("seam 展开质量不达标(翻转 %.1f%% 重叠 %s),回退 smart_project"
|
||||
% (m["flipped"] * 100,
|
||||
"%.1f%%" % (m["overlap"] * 100) if m["overlap"] is not None else "未知"))
|
||||
detail = "、".join(
|
||||
"%d°(翻转%.1f%% 重叠%s)" % (
|
||||
int(c["angle"]), c["flipped"] * 100,
|
||||
"%.1f%%" % (c["overlap"] * 100) if c["overlap"] is not None else "未知")
|
||||
for c in candidates)
|
||||
warnings.append("所有 seam 角度均未过质量门(%s),回退 smart_project" % detail)
|
||||
mode = "smart_fallback"
|
||||
_unwrap_smart(obj)
|
||||
uvpm = _uvpm_repack(obj, warnings)
|
||||
@@ -285,6 +326,21 @@ def _do_unwrap(obj, mode, warnings):
|
||||
return m
|
||||
|
||||
|
||||
def _collect_uv_polygons(obj):
|
||||
"""提取每个面的 UV 多边形(归一化坐标):[[[u,v], ...], ...],供外部 PIL 绘图。
|
||||
无 UV 层返回空列表。"""
|
||||
import bmesh
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
uv = bm.loops.layers.uv.active
|
||||
polys = []
|
||||
if uv is not None:
|
||||
for f in bm.faces:
|
||||
polys.append([[l[uv].uv.x, l[uv].uv.y] for l in f.loops])
|
||||
bm.free()
|
||||
return polys
|
||||
|
||||
|
||||
def main():
|
||||
import bpy
|
||||
import json
|
||||
@@ -323,6 +379,17 @@ def main():
|
||||
warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%)" % (MAX_RETRY, cur, target))
|
||||
|
||||
uv_info = _do_unwrap(obj, unwrap_mode, warnings)
|
||||
uv_png = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.png"
|
||||
uv_polys_json = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.polys.json"
|
||||
uv_preview = None
|
||||
try:
|
||||
polys = _collect_uv_polygons(obj)
|
||||
os.makedirs(os.path.dirname(uv_polys_json), exist_ok=True)
|
||||
with open(uv_polys_json, "w", encoding="utf-8") as fp:
|
||||
json.dump(polys, fp)
|
||||
uv_preview = uv_png # 目标 PNG;实际绘制由 model_decimate(系统 Python + PIL)完成
|
||||
except Exception as e:
|
||||
warnings.append("UV 几何导出失败(%s),跳过观察图" % e)
|
||||
out_dir = os.path.dirname(os.path.abspath(out_fbx))
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
# 仅导出合并后的对象;整场景导出会把 armature/empty 等非 mesh 带进 _low.fbx
|
||||
@@ -332,7 +399,11 @@ def main():
|
||||
print("MT_SUMMARY " + json.dumps(
|
||||
{"src": os.path.basename(src), "fbx": os.path.basename(out_fbx),
|
||||
"tris_before": orig, "tris_after": cur, "target": target,
|
||||
"uv": uv_info, "warnings": warnings}, ensure_ascii=False))
|
||||
"uv": uv_info,
|
||||
"uv_preview": uv_preview,
|
||||
"uv_polys": uv_polys_json if uv_preview else None,
|
||||
"warnings": warnings},
|
||||
ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__" and "--" in sys.argv:
|
||||
|
||||
@@ -33,6 +33,19 @@ def main():
|
||||
overlap = "%.1f%%" % (u["overlap"] * 100) if u["overlap"] is not None else "n/a"
|
||||
print(" UV: 模式=%s 岛数=%d 利用率=%.1f%% 翻转=%.1f%% 重叠=%s" %
|
||||
(u["mode"], u["islands"], u["fill"] * 100, u["flipped"] * 100, overlap))
|
||||
if s.get("uv_polys"):
|
||||
try:
|
||||
import json as _json
|
||||
from uv_preview import render_uv_png
|
||||
with open(s["uv_polys"], encoding="utf-8") as fp:
|
||||
polys = _json.load(fp)
|
||||
render_uv_png(polys, s["uv_preview"])
|
||||
print(" UV 观察图: %s" % s["uv_preview"])
|
||||
except Exception as e:
|
||||
print(" [警告] UV 观察图渲染失败:%s" % e)
|
||||
finally: # sidecar 是内部临时文件,无论渲染成败都清掉
|
||||
if os.path.exists(s["uv_polys"]):
|
||||
os.remove(s["uv_polys"])
|
||||
for w in s["warnings"]:
|
||||
print(" [警告] %s" % w)
|
||||
|
||||
|
||||
@@ -103,5 +103,34 @@ class TestUvpmModeLabel(unittest.TestCase):
|
||||
self.assertEqual(bd.uvpm_mode_label("smart_fallback", False), "smart_fallback")
|
||||
|
||||
|
||||
class TestPickBestCandidate(unittest.TestCase):
|
||||
def _c(self, flipped, overlap, islands, angle):
|
||||
return {"flipped": flipped, "overlap": overlap,
|
||||
"islands": islands, "angle": angle}
|
||||
|
||||
def test_none_when_empty(self):
|
||||
self.assertIsNone(bd.pick_best_candidate([]))
|
||||
|
||||
def test_none_when_no_candidate_passes_gate(self):
|
||||
cands = [self._c(0.30, 0.50, 100, 66), self._c(0.20, 0.40, 200, 45)]
|
||||
self.assertIsNone(bd.pick_best_candidate(cands))
|
||||
|
||||
def test_picks_only_passing(self):
|
||||
cands = [self._c(0.30, 0.50, 50, 66), self._c(0.00, 0.00, 300, 45)]
|
||||
best = bd.pick_best_candidate(cands)
|
||||
self.assertEqual(best["angle"], 45)
|
||||
|
||||
def test_picks_fewest_islands_among_passing(self):
|
||||
cands = [self._c(0.00, 0.00, 120, 55), self._c(0.01, 0.02, 90, 45),
|
||||
self._c(0.00, 0.00, 300, 35)]
|
||||
best = bd.pick_best_candidate(cands)
|
||||
self.assertEqual(best["islands"], 90)
|
||||
|
||||
def test_overlap_none_treated_as_pass(self):
|
||||
cands = [self._c(0.01, None, 42, 66)]
|
||||
best = bd.pick_best_candidate(cands)
|
||||
self.assertEqual(best["islands"], 42)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import uv_preview as up
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class TestToPx(unittest.TestCase):
|
||||
def test_origin_bottom_left_maps_to_bottom_left_pixel(self):
|
||||
# UV(0,0) 原点在左下 -> 图像左下角(x=pad, y=size-pad)
|
||||
x, y = up._to_px(0.0, 0.0, size=100, pad=10)
|
||||
self.assertAlmostEqual(x, 10.0)
|
||||
self.assertAlmostEqual(y, 90.0)
|
||||
|
||||
def test_top_right(self):
|
||||
x, y = up._to_px(1.0, 1.0, size=100, pad=10)
|
||||
self.assertAlmostEqual(x, 90.0)
|
||||
self.assertAlmostEqual(y, 10.0)
|
||||
|
||||
|
||||
class TestRenderUvPng(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
|
||||
def _out(self, name="uv.png"):
|
||||
return os.path.join(self.tmp, name)
|
||||
|
||||
def test_writes_png_of_requested_size(self):
|
||||
polys = [[[0.1, 0.1], [0.4, 0.1], [0.25, 0.4]],
|
||||
[[0.6, 0.6], [0.9, 0.6], [0.9, 0.9], [0.6, 0.9]]]
|
||||
out = up.render_uv_png(polys, self._out(), size=128)
|
||||
self.assertTrue(os.path.isfile(out))
|
||||
with Image.open(out) as im:
|
||||
self.assertEqual(im.size, (128, 128))
|
||||
self.assertEqual(im.format, "PNG")
|
||||
|
||||
def test_empty_polygons_still_writes_blank(self):
|
||||
out = up.render_uv_png([], self._out("blank.png"), size=64)
|
||||
self.assertTrue(os.path.isfile(out))
|
||||
with Image.open(out) as im:
|
||||
self.assertEqual(im.size, (64, 64))
|
||||
|
||||
def test_degenerate_polygon_skipped_no_crash(self):
|
||||
out = up.render_uv_png([[[0.5, 0.5]]], self._out("deg.png"), size=64)
|
||||
self.assertTrue(os.path.isfile(out))
|
||||
@@ -0,0 +1,31 @@
|
||||
"""UV 线框观察图渲染:把 UV 多边形(归一化 0-1 坐标)画成 PNG 供人工查阅。
|
||||
在系统 Python(有 Pillow)中运行——Blender 自带 Python 无 PIL,故 UV 几何由
|
||||
bl_decimate 导出为 JSON,本模块负责绘制。"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
def _to_px(u, v, size, pad):
|
||||
"""UV(0-1, 原点左下) -> 像素(原点左上),含边距。"""
|
||||
span = size - 2 * pad
|
||||
x = pad + u * span
|
||||
y = pad + (1.0 - v) * span # V 轴翻转:图像 y 向下
|
||||
return x, y
|
||||
|
||||
|
||||
def render_uv_png(polygons, out_png, size=1024, pad=8,
|
||||
line=(30, 30, 30, 255), fill=(80, 140, 220, 64)):
|
||||
"""polygons: [[[u,v], ...], ...] 每个多边形一组 UV 顶点。
|
||||
白底 + 半透明填充 + 深色线框;写 PNG 到 out_png,返回 out_png。"""
|
||||
base = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||
overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay, "RGBA")
|
||||
for poly in polygons:
|
||||
pts = [_to_px(u, v, size, pad) for (u, v) in poly]
|
||||
if len(pts) >= 3:
|
||||
draw.polygon(pts, fill=fill)
|
||||
for poly in polygons:
|
||||
pts = [_to_px(u, v, size, pad) for (u, v) in poly]
|
||||
if len(pts) >= 2:
|
||||
draw.line(pts + pts[:1], fill=line, width=1)
|
||||
Image.alpha_composite(base, overlay).convert("RGB").save(out_png, "PNG")
|
||||
return out_png
|
||||
@@ -0,0 +1,687 @@
|
||||
# UV 展开质量优化 + 线框观察图 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:** 增强 ModelTranslator 减面工具的 UV 展开质量(松弛 + 纹素均衡 + seam 角度扫描抢救),并每次导出一张 UV 线框观察图供人工查阅。
|
||||
|
||||
**Architecture:** 全部改动内联进 `Tools/ModelTranslator/bl_decimate.py`(Blender 内脚本)与 `model_decimate.py`(CLI)。纯函数(候选择优)用 `unittest` 做 TDD;Blender 算子集成沿用本仓库既有约定——由端到端跑真实 FBX 验证(无法在无 Blender 环境单测)。所有新增算子/导出均为增强项,任一失败降级记警告、不中断,FBX 必产出。
|
||||
|
||||
**Tech Stack:** Python 3(标准库 + unittest)、Blender 5.0 headless(`bpy`)、UVPackmaster 4(既有,可选)。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `Tools/ModelTranslator/bl_decimate.py`(Modify):新增常量、`pick_best_candidate` 纯函数、`_unwrap_seam` 增强并参数化角度、`_do_unwrap` 改分级抢救、新增 `_export_uv_layout`/`_ensure_uv_layout_addon`、`main()` 接线。
|
||||
- `Tools/ModelTranslator/model_decimate.py`(Modify):CLI 打印 UV 观察图路径。
|
||||
- `Tools/ModelTranslator/tests/test_bl_decimate.py`(Modify):新增 `pick_best_candidate` 单测。
|
||||
- `Tools/ModelTranslator/README.md`(Modify):补充展开增强与观察图说明。
|
||||
|
||||
参照文件(无需改):`mt_run.py`(Blender 运行器 + MT_SUMMARY 协议)、`tests/test_bl_decimate.py`(现有纯函数测试模式)。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Headless 算子可行性验证(spike)
|
||||
|
||||
先确认三个新算子在 Blender 5.0 `--factory-startup` 下能跑通,锁定 `export_layout` 的 add-on 模块名。结果决定后续任务是否需调整降级路径。
|
||||
|
||||
**Files:**
|
||||
- Create: `Tools/ModelTranslator/tests/bl_probe_ops.py`(临时探针脚本,验证后删除)
|
||||
|
||||
- [ ] **Step 1: 写探针脚本**
|
||||
|
||||
Create `Tools/ModelTranslator/tests/bl_probe_ops.py`:
|
||||
|
||||
```python
|
||||
"""探针:验证 minimize_stretch / average_islands_scale / export_layout 在
|
||||
headless factory-startup 下可用性,并找出 UV Layout add-on 模块名。
|
||||
运行:blender -b --factory-startup --python tests/bl_probe_ops.py -- <out.png>
|
||||
"""
|
||||
import sys
|
||||
import bpy
|
||||
import addon_utils
|
||||
|
||||
out_png = sys.argv[sys.argv.index("--") + 1:][0]
|
||||
r = {}
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.mesh.primitive_uv_sphere_add()
|
||||
obj = bpy.context.view_layer.objects.active
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.uv.smart_project()
|
||||
|
||||
try:
|
||||
bpy.ops.uv.minimize_stretch(iterations=5)
|
||||
r["minimize_stretch"] = "ok"
|
||||
except Exception as e:
|
||||
r["minimize_stretch"] = "FAIL: %r" % e
|
||||
|
||||
try:
|
||||
bpy.ops.uv.average_islands_scale()
|
||||
r["average_islands_scale"] = "ok"
|
||||
except Exception as e:
|
||||
r["average_islands_scale"] = "FAIL: %r" % e
|
||||
|
||||
enabled = None
|
||||
for name in ("io_mesh_uv_layout", "bl_ext.blender_org.io_mesh_uv_layout"):
|
||||
try:
|
||||
if addon_utils.enable(name, default_set=True) is not None:
|
||||
enabled = name
|
||||
break
|
||||
except Exception as e:
|
||||
r.setdefault("addon_errors", []).append("%s: %r" % (name, e))
|
||||
r["uv_layout_addon"] = enabled
|
||||
|
||||
try:
|
||||
bpy.ops.uv.select_all(action='SELECT')
|
||||
bpy.ops.uv.export_layout(filepath=out_png, mode='PNG', size=(256, 256), opacity=0.25)
|
||||
r["export_layout"] = "ok"
|
||||
except Exception as e:
|
||||
r["export_layout"] = "FAIL: %r" % e
|
||||
|
||||
print("PROBE " + repr(r))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行探针**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && "D:\tools\blender-5.0.0-windows-x64\blender.exe" -b --factory-startup --python tests/bl_probe_ops.py -- /tmp/probe_uv.png
|
||||
```
|
||||
Expected: 输出一行 `PROBE {...}`,理想全为 `ok` 且 `uv_layout_addon` 为某个非 None 名字。
|
||||
|
||||
- [ ] **Step 3: 记录结果,按需调整后续任务**
|
||||
|
||||
- 若某算子 `FAIL`:保留后续任务里对应的 try/except 降级(本就有),并在 commit message 注明该算子不可用。
|
||||
- 若 `uv_layout_addon` 为 `None`(两个名字都不行):把探针输出里 `addon_errors` 贴到 Task 5 的 commit note,并把 `UV_LAYOUT_ADDONS` 换成探针发现的可用名(若有);仍为 None 则观察图功能整体降级为「记警告跳过」,需告知用户。
|
||||
- 若 `export_layout` 的 `size`/`opacity` 参数签名报错:记录真实签名,Task 5 相应调整。
|
||||
|
||||
- [ ] **Step 4: 删除探针脚本并提交记录(无代码改动则跳过提交)**
|
||||
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && rm tests/bl_probe_ops.py
|
||||
```
|
||||
|
||||
(探针为一次性,不入库。将结论写入下一个 commit 的正文即可。)
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `pick_best_candidate` 纯函数(TDD)
|
||||
|
||||
从候选 UV 指标中选「过质量门且 UV 岛数最少」者;无过门候选返回 None。这是分级抢救的选择规则,纯函数、可单测。
|
||||
|
||||
**Files:**
|
||||
- Modify: `Tools/ModelTranslator/bl_decimate.py`(在 `uvpm_mode_label` 之后、`count_islands` 之前的纯函数区新增)
|
||||
- Test: `Tools/ModelTranslator/tests/test_bl_decimate.py`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
在 `tests/test_bl_decimate.py` 的 `TestUvpmModeLabel` 类之后新增:
|
||||
|
||||
```python
|
||||
class TestPickBestCandidate(unittest.TestCase):
|
||||
def _c(self, flipped, overlap, islands, angle):
|
||||
return {"flipped": flipped, "overlap": overlap,
|
||||
"islands": islands, "angle": angle}
|
||||
|
||||
def test_none_when_empty(self):
|
||||
self.assertIsNone(bd.pick_best_candidate([]))
|
||||
|
||||
def test_none_when_no_candidate_passes_gate(self):
|
||||
cands = [self._c(0.30, 0.50, 100, 66), self._c(0.20, 0.40, 200, 45)]
|
||||
self.assertIsNone(bd.pick_best_candidate(cands))
|
||||
|
||||
def test_picks_only_passing(self):
|
||||
cands = [self._c(0.30, 0.50, 50, 66), self._c(0.00, 0.00, 300, 45)]
|
||||
best = bd.pick_best_candidate(cands)
|
||||
self.assertEqual(best["angle"], 45)
|
||||
|
||||
def test_picks_fewest_islands_among_passing(self):
|
||||
cands = [self._c(0.00, 0.00, 120, 55), self._c(0.01, 0.02, 90, 45),
|
||||
self._c(0.00, 0.00, 300, 35)]
|
||||
best = bd.pick_best_candidate(cands)
|
||||
self.assertEqual(best["islands"], 90)
|
||||
|
||||
def test_overlap_none_treated_as_pass(self):
|
||||
cands = [self._c(0.01, None, 42, 66)]
|
||||
best = bd.pick_best_candidate(cands)
|
||||
self.assertEqual(best["islands"], 42)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate.TestPickBestCandidate -v
|
||||
```
|
||||
Expected: FAIL — `AttributeError: module 'bl_decimate' has no attribute 'pick_best_candidate'`
|
||||
|
||||
- [ ] **Step 3: 写最小实现**
|
||||
|
||||
在 `bl_decimate.py` 的 `uvpm_mode_label` 函数之后新增:
|
||||
|
||||
```python
|
||||
def pick_best_candidate(candidates):
|
||||
"""从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。
|
||||
每个 candidate 至少含 flipped/overlap/islands。"""
|
||||
passing = [c for c in candidates
|
||||
if uv_gate_ok(c["flipped"], c["overlap"])]
|
||||
if not passing:
|
||||
return None
|
||||
return min(passing, key=lambda c: c["islands"])
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试确认通过**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate.TestPickBestCandidate -v
|
||||
```
|
||||
Expected: PASS(5 tests OK)
|
||||
|
||||
- [ ] **Step 5: 全量单测不回归**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate tests.test_unity_assets -v
|
||||
```
|
||||
Expected: 全部 PASS
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py Tools/ModelTranslator/tests/test_bl_decimate.py && git commit -m "ModelTranslator: 新增 pick_best_candidate——过门且岛数最少者择优
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `_unwrap_seam` 增强(松弛 + 纹素均衡 + 角度参数)
|
||||
|
||||
unwrap 后加 `minimize_stretch` 松弛、pack 前加 `average_islands_scale`;seam 角度参数化以支持扫描;扫描重跑前清掉上一档残留 seam。
|
||||
|
||||
**Files:**
|
||||
- Modify: `Tools/ModelTranslator/bl_decimate.py:104-107`(常量区)、`:128-151`(`_unwrap_seam`)
|
||||
|
||||
- [ ] **Step 1: 新增常量**
|
||||
|
||||
在 `bl_decimate.py` 现有 `SEAM_ANGLE_DEG = 66.0` 一段(`:104-108`)之后追加:
|
||||
|
||||
```python
|
||||
SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0) # 首选 66° 不过门时依次下探的 seam 角度
|
||||
STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数
|
||||
```
|
||||
|
||||
(UV 观察图相关常量不放这里——headless 无法用 Blender 出 PNG,绘图移到系统 Python 侧的 `uv_preview.py`,见 Task 5。)
|
||||
|
||||
- [ ] **Step 2: 替换 `_unwrap_seam`**
|
||||
|
||||
将 `bl_decimate.py:128-151` 的整个 `_unwrap_seam` 替换为:
|
||||
|
||||
```python
|
||||
def _unwrap_seam(obj, warnings, seam_angle_deg=SEAM_ANGLE_DEG):
|
||||
"""锐边标 seam -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> 松弛 -> 纹素均衡 -> pack。
|
||||
seam_angle_deg 可变以支持角度扫描;重跑前清掉上一档残留 seam。"""
|
||||
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')
|
||||
# 清掉上一档角度残留 seam(同一对象多角度扫描时必须)
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.mark_seam(clear=True)
|
||||
# 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛
|
||||
bpy.ops.mesh.select_all(action='DESELECT')
|
||||
bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(seam_angle_deg))
|
||||
bpy.ops.mesh.mark_seam(clear=False)
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
try:
|
||||
bpy.ops.uv.unwrap(method='MINIMUM_STRETCH', margin=PACK_MARGIN)
|
||||
except TypeError: # 老版本无 SLIM 枚举
|
||||
warnings.append("MINIMUM_STRETCH 不可用,展开改用 ANGLE_BASED")
|
||||
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=PACK_MARGIN)
|
||||
try: # 角度松弛:压翻转/拉伸
|
||||
bpy.ops.uv.minimize_stretch(iterations=STRETCH_ITERS)
|
||||
except (RuntimeError, TypeError) as e:
|
||||
warnings.append("minimize_stretch 不可用(%s),跳过松弛" % e)
|
||||
try: # 统一纹素密度,避免大岛霸占分辨率
|
||||
bpy.ops.uv.average_islands_scale()
|
||||
except RuntimeError as e:
|
||||
warnings.append("average_islands_scale 不可用(%s),跳过纹素均衡" % e)
|
||||
try:
|
||||
bpy.ops.uv.pack_islands(rotate=True, margin=PACK_MARGIN)
|
||||
except RuntimeError: # headless 上下文不满足时靠 unwrap 自带打包
|
||||
warnings.append("pack_islands 不可用,沿用 unwrap 自带布局")
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 语法自检**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -c "import ast; ast.parse(open('bl_decimate.py', encoding='utf-8').read()); print('OK')"
|
||||
```
|
||||
Expected: `OK`
|
||||
|
||||
- [ ] **Step 4: 单测不回归**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v
|
||||
```
|
||||
Expected: 全部 PASS(纯函数区未受影响)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py && git commit -m "ModelTranslator: _unwrap_seam 加松弛+纹素均衡,seam 角度参数化
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `_do_unwrap` 分级抢救(角度扫描)
|
||||
|
||||
首选 seam@66 过门则直接用;不过则依次扫 55/45/35,收集候选,用 `pick_best_candidate` 取过门且岛最少者并重跑恢复其 UV;全失败退 smart。UVPM 只在最终选定 UV 上跑一次。
|
||||
|
||||
**Files:**
|
||||
- Modify: `Tools/ModelTranslator/bl_decimate.py:267-285`(`_do_unwrap`)
|
||||
|
||||
- [ ] **Step 1: 替换 `_do_unwrap`**
|
||||
|
||||
将 `bl_decimate.py:267-285` 的整个 `_do_unwrap` 替换为:
|
||||
|
||||
```python
|
||||
def _do_unwrap(obj, mode, warnings):
|
||||
"""按模式展开:seam 首选 66° 过门即用;否则扫 SEAM_ANGLE_SWEEP,
|
||||
取过门且岛数最少者(重跑恢复其 UV);全失败退 smart。排布 UVPM4 增强,
|
||||
只在最终 UV 上跑一次,失败保底内置 pack。返回 uv 指标 dict(含 mode)。"""
|
||||
if mode == "seam":
|
||||
angles = [SEAM_ANGLE_DEG] + list(SEAM_ANGLE_SWEEP)
|
||||
candidates = []
|
||||
for ang in angles:
|
||||
_unwrap_seam(obj, warnings, seam_angle_deg=ang)
|
||||
m = _collect_uv_metrics(obj)
|
||||
m["angle"] = ang
|
||||
candidates.append(m)
|
||||
if uv_gate_ok(m["flipped"], m["overlap"]):
|
||||
break # 该档已过门;更低角度只会更碎,无需再试
|
||||
best = pick_best_candidate(candidates)
|
||||
if best is not None:
|
||||
if best["angle"] != candidates[-1]["angle"]:
|
||||
# 选中档不是最后跑的那档,重跑恢复其 UV(_unwrap_seam 会覆盖)
|
||||
_unwrap_seam(obj, warnings, seam_angle_deg=best["angle"])
|
||||
uvpm = _uvpm_repack(obj, warnings)
|
||||
m = _collect_uv_metrics(obj)
|
||||
m["mode"] = uvpm_mode_label("seam@%d" % int(best["angle"]), uvpm)
|
||||
return m
|
||||
warnings.append("所有 seam 角度(%s)均未过质量门,回退 smart_project"
|
||||
% "、".join("%d°" % int(a) for a in angles))
|
||||
mode = "smart_fallback"
|
||||
_unwrap_smart(obj)
|
||||
uvpm = _uvpm_repack(obj, warnings)
|
||||
m = _collect_uv_metrics(obj)
|
||||
m["mode"] = uvpm_mode_label(mode if mode == "smart_fallback" else "smart", uvpm)
|
||||
return m
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 语法自检**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -c "import ast; ast.parse(open('bl_decimate.py', encoding='utf-8').read()); print('OK')"
|
||||
```
|
||||
Expected: `OK`
|
||||
|
||||
- [ ] **Step 3: 端到端跑 gargoyle(验证扫描逻辑)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/gargoyle.fbx --tris 1000
|
||||
```
|
||||
Expected: 成功输出面数与 UV 行;`模式` 为 `seam@NN+uvpm`(某档走通)**或** `smart_fallback+uvpm`(全失败退回,且日志有「所有 seam 角度…均未过质量门」警告)。对比改动前基线(smart_fallback,324 岛,翻转 0/重叠 2.3%)——记录新岛数/翻转/重叠。
|
||||
|
||||
- [ ] **Step 4: 回归第二个模型(确认首选路径未坏)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/well1500.fbx --tris 5000
|
||||
```
|
||||
Expected: 成功;`模式` 合理(seam@66 或某档 / smart_fallback),无异常退出。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py && git commit -m "ModelTranslator: _do_unwrap 改角度扫描分级抢救,取过门且岛最少者
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `uv_preview.py` — PIL 绘 UV 线框 PNG(TDD)
|
||||
|
||||
新模块,纯函数 `render_uv_png`:拿 UV 多边形(归一化坐标)用 Pillow 画白底半透明填充+线框 PNG。在系统 Python(有 PIL 12.2.0)单测。
|
||||
|
||||
**Files:**
|
||||
- Create: `Tools/ModelTranslator/uv_preview.py`
|
||||
- Test: `Tools/ModelTranslator/tests/test_uv_preview.py`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
Create `Tools/ModelTranslator/tests/test_uv_preview.py`:
|
||||
|
||||
```python
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import uv_preview as up
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class TestToPx(unittest.TestCase):
|
||||
def test_origin_bottom_left_maps_to_bottom_left_pixel(self):
|
||||
# UV(0,0) 原点在左下 -> 图像左下角(x=pad, y=size-pad)
|
||||
x, y = up._to_px(0.0, 0.0, size=100, pad=10)
|
||||
self.assertAlmostEqual(x, 10.0)
|
||||
self.assertAlmostEqual(y, 90.0)
|
||||
|
||||
def test_top_right(self):
|
||||
x, y = up._to_px(1.0, 1.0, size=100, pad=10)
|
||||
self.assertAlmostEqual(x, 90.0)
|
||||
self.assertAlmostEqual(y, 10.0)
|
||||
|
||||
|
||||
class TestRenderUvPng(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
|
||||
def _out(self, name="uv.png"):
|
||||
return os.path.join(self.tmp, name)
|
||||
|
||||
def test_writes_png_of_requested_size(self):
|
||||
polys = [[[0.1, 0.1], [0.4, 0.1], [0.25, 0.4]],
|
||||
[[0.6, 0.6], [0.9, 0.6], [0.9, 0.9], [0.6, 0.9]]]
|
||||
out = up.render_uv_png(polys, self._out(), size=128)
|
||||
self.assertTrue(os.path.isfile(out))
|
||||
with Image.open(out) as im:
|
||||
self.assertEqual(im.size, (128, 128))
|
||||
self.assertEqual(im.format, "PNG")
|
||||
|
||||
def test_empty_polygons_still_writes_blank(self):
|
||||
out = up.render_uv_png([], self._out("blank.png"), size=64)
|
||||
self.assertTrue(os.path.isfile(out))
|
||||
with Image.open(out) as im:
|
||||
self.assertEqual(im.size, (64, 64))
|
||||
|
||||
def test_degenerate_polygon_skipped_no_crash(self):
|
||||
out = up.render_uv_png([[[0.5, 0.5]]], self._out("deg.png"), size=64)
|
||||
self.assertTrue(os.path.isfile(out))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_uv_preview -v
|
||||
```
|
||||
Expected: FAIL — `ModuleNotFoundError: No module named 'uv_preview'`
|
||||
|
||||
- [ ] **Step 3: 写实现**
|
||||
|
||||
Create `Tools/ModelTranslator/uv_preview.py`:
|
||||
|
||||
```python
|
||||
"""UV 线框观察图渲染:把 UV 多边形(归一化 0-1 坐标)画成 PNG 供人工查阅。
|
||||
在系统 Python(有 Pillow)中运行——Blender 自带 Python 无 PIL,故 UV 几何由
|
||||
bl_decimate 导出为 JSON,本模块负责绘制。"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
def _to_px(u, v, size, pad):
|
||||
"""UV(0-1, 原点左下) -> 像素(原点左上),含边距。"""
|
||||
span = size - 2 * pad
|
||||
x = pad + u * span
|
||||
y = pad + (1.0 - v) * span # V 轴翻转:图像 y 向下
|
||||
return x, y
|
||||
|
||||
|
||||
def render_uv_png(polygons, out_png, size=1024, pad=8,
|
||||
line=(30, 30, 30, 255), fill=(80, 140, 220, 64)):
|
||||
"""polygons: [[[u,v], ...], ...] 每个多边形一组 UV 顶点。
|
||||
白底 + 半透明填充 + 深色线框;写 PNG 到 out_png,返回 out_png。"""
|
||||
base = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||
overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay, "RGBA")
|
||||
for poly in polygons:
|
||||
pts = [_to_px(u, v, size, pad) for (u, v) in poly]
|
||||
if len(pts) >= 3:
|
||||
draw.polygon(pts, fill=fill)
|
||||
for poly in polygons:
|
||||
pts = [_to_px(u, v, size, pad) for (u, v) in poly]
|
||||
if len(pts) >= 2:
|
||||
draw.line(pts + pts[:1], fill=line, width=1)
|
||||
Image.alpha_composite(base, overlay).convert("RGB").save(out_png, "PNG")
|
||||
return out_png
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试确认通过**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_uv_preview -v
|
||||
```
|
||||
Expected: PASS(5 tests OK)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/uv_preview.py Tools/ModelTranslator/tests/test_uv_preview.py && git commit -m "ModelTranslator: 新增 uv_preview.render_uv_png——PIL 绘 UV 线框 PNG
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `bl_decimate.py` 抽取 UV 几何到 sidecar JSON
|
||||
|
||||
Blender 端提取每面 UV 多边形写入 `<名>_low_uv.polys.json`,summary 报告 `uv_preview`(目标 PNG 路径)与 `uv_polys`(sidecar 路径)。绘图由系统 Python 侧(Task 7)完成。
|
||||
|
||||
**Files:**
|
||||
- Modify: `Tools/ModelTranslator/bl_decimate.py`(新增 `_collect_uv_polygons`;`main()` 接线 + summary 字段)
|
||||
|
||||
- [ ] **Step 1: 新增几何抽取函数**
|
||||
|
||||
在 `bl_decimate.py` 的 `_do_unwrap` 之后、`main` 之前新增:
|
||||
|
||||
```python
|
||||
def _collect_uv_polygons(obj):
|
||||
"""提取每个面的 UV 多边形(归一化坐标):[[[u,v], ...], ...],供外部 PIL 绘图。
|
||||
无 UV 层返回空列表。"""
|
||||
import bmesh
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
uv = bm.loops.layers.uv.active
|
||||
polys = []
|
||||
if uv is not None:
|
||||
for f in bm.faces:
|
||||
polys.append([[l[uv].uv.x, l[uv].uv.y] for l in f.loops])
|
||||
bm.free()
|
||||
return polys
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `main()` 接线(抽取 + 写 sidecar)**
|
||||
|
||||
在 `main()` 中 `uv_info = _do_unwrap(obj, unwrap_mode, warnings)` 之后、`out_dir = ...` 之前插入:
|
||||
|
||||
```python
|
||||
uv_png = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.png"
|
||||
uv_polys_json = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.polys.json"
|
||||
uv_preview = None
|
||||
try:
|
||||
polys = _collect_uv_polygons(obj)
|
||||
os.makedirs(os.path.dirname(uv_polys_json), exist_ok=True)
|
||||
with open(uv_polys_json, "w", encoding="utf-8") as fp:
|
||||
json.dump(polys, fp)
|
||||
uv_preview = uv_png # 目标 PNG;实际绘制由 model_decimate(系统 Python + PIL)完成
|
||||
except Exception as e:
|
||||
warnings.append("UV 几何导出失败(%s),跳过观察图" % e)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: summary 增加字段**
|
||||
|
||||
将 `main()` 末尾 `print("MT_SUMMARY " + json.dumps({...}))` 的 dict 改为包含 `uv_preview` 与 `uv_polys`:
|
||||
|
||||
```python
|
||||
print("MT_SUMMARY " + json.dumps(
|
||||
{"src": os.path.basename(src), "fbx": os.path.basename(out_fbx),
|
||||
"tris_before": orig, "tris_after": cur, "target": target,
|
||||
"uv": uv_info,
|
||||
"uv_preview": uv_preview,
|
||||
"uv_polys": uv_polys_json if uv_preview else None,
|
||||
"warnings": warnings},
|
||||
ensure_ascii=False))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 语法自检**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -c "import ast; ast.parse(open('bl_decimate.py', encoding='utf-8').read()); print('OK')"
|
||||
```
|
||||
Expected: `OK`
|
||||
|
||||
- [ ] **Step 5: 端到端确认 sidecar 生成**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/gargoyle.fbx --tris 1000 && ls -la src/gargoyle_low_uv.polys.json && head -c 120 src/gargoyle_low_uv.polys.json
|
||||
```
|
||||
Expected: 命令成功;`src/gargoyle_low_uv.polys.json` 存在且是形如 `[[[...]]]` 的 JSON(此时 Task 7 尚未接线,sidecar 不会被删除,属正常)。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py && git commit -m "ModelTranslator: bl_decimate 抽取 UV 几何到 sidecar JSON
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: `model_decimate.py` 渲染 PNG + 清理 + 打印
|
||||
|
||||
系统 Python 侧读 sidecar JSON、用 `uv_preview.render_uv_png` 出 PNG、删 sidecar、打印路径。
|
||||
|
||||
**Files:**
|
||||
- Modify: `Tools/ModelTranslator/model_decimate.py`(UV 指标打印后、warnings 循环前)
|
||||
|
||||
- [ ] **Step 1: 接线渲染**
|
||||
|
||||
在 `model_decimate.py` 的 UV 指标打印(现有 `print(" UV: ...")` 两行)之后、`for w in s["warnings"]:` 之前插入:
|
||||
|
||||
```python
|
||||
if s.get("uv_polys"):
|
||||
try:
|
||||
import json as _json
|
||||
from uv_preview import render_uv_png
|
||||
with open(s["uv_polys"], encoding="utf-8") as fp:
|
||||
polys = _json.load(fp)
|
||||
render_uv_png(polys, s["uv_preview"])
|
||||
os.remove(s["uv_polys"])
|
||||
print(" UV 观察图: %s" % s["uv_preview"])
|
||||
except Exception as e:
|
||||
print(" [警告] UV 观察图渲染失败:%s" % e)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 端到端确认 PNG 生成、sidecar 清理、日志打印**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/gargoyle.fbx --tris 1000 && ls -la src/gargoyle_low_uv.png && ( test -f src/gargoyle_low_uv.polys.json && echo "SIDECAR REMAINS (BAD)" || echo "sidecar cleaned OK" )
|
||||
```
|
||||
Expected: 日志含 `UV 观察图: ...gargoyle_low_uv.png`;`gargoyle_low_uv.png` 存在且非空;打印 `sidecar cleaned OK`。用 Read 工具打开 PNG 肉眼确认是 UV 线框(岛分布可读)。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/model_decimate.py && git commit -m "ModelTranslator: model_decimate 渲染 UV 观察图 PNG 并清理 sidecar
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: README 更新
|
||||
|
||||
补充展开增强与观察图说明,保持文档与行为一致。
|
||||
|
||||
**Files:**
|
||||
- Modify: `Tools/ModelTranslator/README.md`(依赖段 + model_decimate.py 说明段)
|
||||
|
||||
- [ ] **Step 1: 更新 model_decimate.py 说明**
|
||||
|
||||
将 `README.md` 那条 `**model_decimate.py**:…` 说明整体替换为反映新行为:
|
||||
|
||||
```markdown
|
||||
- **model_decimate.py**:多 mesh 自动 join;三角化后 Decimate(collapse) 减到 `--tris`(±3%,最多 2 轮修正);旧 UV 全删后重展——默认锐边 seam 整岛展开(SLIM 展开后 `minimize_stretch` 松弛 + `average_islands_scale` 纹素均衡),质量门不达标(翻转 >2% 或重叠 >8%)先按更低 seam 角度扫描(55/45/35°)取过门且岛数最少者抢救,全失败才回退 Smart UV Project,`--unwrap smart` 可直接选投影式展开;排布默认用 UVPackmaster 4 重排(只在最终选定 UV 上跑一次),UVPM4 不可用自动沿用内置 pack_islands 布局;输出 `<名>_low.fbx`(`-o` 改目录)与 `<名>_low_uv.png`(UV 线框观察图,便于人工查阅切分/排布/碎岛——Blender 抽 UV 几何,系统 Python 用 Pillow 绘制,因 headless 无 GPU 无法用 Blender 直接出 PNG),日志报 UV 岛数/利用率与实际生效的 seam 角度
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 依赖段补 Pillow**
|
||||
|
||||
在 `README.md` 的「## 依赖」列表中追加一行(放在「系统 Python 3.x(仅标准库)」之后或替换其括注):
|
||||
|
||||
```markdown
|
||||
- 系统 Python 3.x(标准库 + Pillow——仅 `model_decimate.py` 绘 UV 观察图用;`pip install Pillow`)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/README.md && git commit -m "ModelTranslator: README 补充展开增强、UV 观察图与 Pillow 依赖说明
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 最终验证
|
||||
|
||||
- [ ] **Step 1: 全量单测**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate tests.test_unity_assets -v
|
||||
```
|
||||
Expected: 全部 PASS
|
||||
|
||||
- [ ] **Step 2: 端到端 + 观察图肉眼确认**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/gargoyle.fbx --tris 1000 && ls -la src/gargoyle_low_uv.png
|
||||
```
|
||||
Expected:命令成功;日志有 `模式`、UV 指标、`UV 观察图:` 行;`gargoyle_low_uv.png` 存在。用 Read 打开图片确认线框可读。
|
||||
|
||||
- [ ] **Step 3: 记录前后对比**
|
||||
|
||||
在最终汇报里给出改动前(基线 smart_fallback / 324 岛 / 翻转 0% / 重叠 2.3% / 利用率 77.6%)vs 改动后(实际 mode / 岛数 / 翻转 / 重叠 / 利用率)的对比表。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录(2026-07-21 spike 后修订)
|
||||
|
||||
- **Spec 覆盖**:①松弛+纹素均衡→Task 3;②角度扫描分级抢救→Task 2(纯函数)+Task 4;③UV 线框观察图→Task 5(PIL 绘图 TDD)+Task 6(Blender 抽几何)+Task 7(渲染/清理/打印);错误处理→各任务 try/except;实现首步验证→Task 1 spike(已完成,结论见下);README+依赖→Task 8;测试/验收→Task 2/4/5/7/9。全部有对应任务。
|
||||
- **Task 1 spike 结论**:`minimize_stretch` / `average_islands_scale` headless 可用;`export_layout(mode='PNG')` headless **不可用**(GPUOffScreen 后台无 GPU);`io_mesh_uv_layout` 是正确 add-on 名但仍受 GPU 限制。故观察图改为 Blender 抽 UV 几何→系统 Python(PIL) 绘 PNG 的两端架构(Task 5–7),不再用 `export_layout`。
|
||||
- **占位符**:无 TBD/TODO;每个代码步给出完整代码与确切命令、预期输出。
|
||||
- **类型/命名一致**:`pick_best_candidate`(Task 2 定义,Task 4 调用一致);`_unwrap_seam(obj, warnings, seam_angle_deg=...)`(Task 3 定义,Task 4 调用一致);`render_uv_png`/`_to_px`(Task 5 定义,Task 7 调用一致);`_collect_uv_polygons`(Task 6 定义);summary 字段 `uv_preview`(PNG 路径)与 `uv_polys`(sidecar 路径)(Task 6 产出,Task 7 消费,键名一致);常量仅 `SEAM_ANGLE_SWEEP`/`STRETCH_ITERS`(Task 3 定义,Task 4 使用)——UV 预览尺寸/透明度作为 `render_uv_png` 默认参数,不再是 bl_decimate 常量。
|
||||
- **已知风险**:Blender 抽几何在无 UV 层时返回空列表(Task 6 已处理);渲染在系统 Python 侧,PIL 已确认可用(12.2.0);sidecar 清理失败仅影响残留文件,不影响 FBX/PNG 产出。
|
||||
@@ -0,0 +1,131 @@
|
||||
# UV 展开质量优化 + 线框观察图导出
|
||||
|
||||
日期:2026-07-21
|
||||
范围:`Tools/ModelTranslator/bl_decimate.py`、`Tools/ModelTranslator/model_decimate.py`
|
||||
|
||||
## 背景与动机
|
||||
|
||||
减面工具当前 UV 流程(`bl_decimate.py`):
|
||||
- `_unwrap_seam`:锐边(66°)标 seam → `MINIMUM_STRETCH`(SLIM) 展开 → 内置 `pack_islands`。
|
||||
- `_do_unwrap`:seam 结果过质量门(翻转 ≤2%、重叠 ≤8%)则用;**一次不过就整个回退 `_unwrap_smart`(Smart UV Project)**。
|
||||
- 排布:`_uvpm_repack` 用 UVPackmaster 4 在已有岛上重排,失败保底内置布局。
|
||||
|
||||
实测 gargoyle(499952→998 面):seam 展开翻转 18.6%、重叠 41.4%,直接退到 smart,最终 **324 个碎岛**、利用率 77.6%。碎岛过多导致烘焙缝多、padding 浪费、纹素利用低。
|
||||
|
||||
结论:瓶颈在**展开质量**,不在排布。本设计增强展开、并新增 UV 线框观察图便于人工查阅。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 展开阶段引入拉伸松弛与纹素密度均衡,降低翻转/拉伸。
|
||||
2. seam 失败不再一步退化为碎岛的 smart,而是先松弛、再按更低角度扫描抢救,尽量保住低岛数解析式展开。
|
||||
3. 每次减面产出一张 UV 线框观察图,供人工检查切分/排布/碎岛。
|
||||
|
||||
## 非目标(YAGNI)
|
||||
|
||||
- 不抽独立「多候选择优」模块;改动内联进现有函数。
|
||||
- 观察图仅纯 UV 线框,不叠贴图/棋盘格(减面阶段尚无烘焙贴图)。
|
||||
- 不改 `model_bake.py` / `model_translator.py` / UVPM 排布逻辑。
|
||||
- ~~不引入 matplotlib/PIL 等新依赖。~~ **(2026-07-21 修订)** headless Blender 的 `export_layout(mode='PNG')` 走 GPUOffScreen,后台模式不可用(`SystemError: GPU functions ... not available in background mode`);`mode='SVG'` 可用但非位图。经用户确认,观察图改为**用系统 Python 的 Pillow 自绘 PNG 线框**——引入 Pillow 依赖(系统 Python 已装 12.2.0;Blender 自带 Python 无 PIL)。
|
||||
|
||||
## 设计
|
||||
|
||||
### 1. 展开质量增强(minimize_stretch + average_islands_scale)
|
||||
|
||||
`_unwrap_seam`(`bl_decimate.py:128`)在 unwrap 之后、pack 之前插两步:
|
||||
|
||||
```python
|
||||
bpy.ops.uv.unwrap(method='MINIMUM_STRETCH', margin=PACK_MARGIN) # 已有
|
||||
bpy.ops.uv.minimize_stretch(iterations=STRETCH_ITERS) # 新增:角度松弛压翻转/拉伸
|
||||
...
|
||||
bpy.ops.uv.average_islands_scale() # 新增:统一纹素密度
|
||||
bpy.ops.uv.pack_islands(rotate=True, margin=PACK_MARGIN) # 已有
|
||||
```
|
||||
|
||||
- 新增常量 `STRETCH_ITERS = 30`。
|
||||
- `minimize_stretch` 与现有 unwrap 同为 edit-mode 算子、同上下文,风险低。
|
||||
- `average_islands_scale` headless 上下文若不可用:`try/except RuntimeError` 记警告跳过、不中断(与现有 `pack_islands`/UVPM 兜底同风格)。
|
||||
- `ANGLE_BASED` 老版本回退分支同样插入这两步(保持一致)。
|
||||
|
||||
### 2. seam 角度扫描 + 分级抢救
|
||||
|
||||
**参数化 seam 角度**:`_unwrap_seam` 增加 `seam_angle_deg` 入参(默认沿用 `SEAM_ANGLE_DEG=66.0`),`edges_select_sharp(sharpness=radians(seam_angle_deg))`。
|
||||
|
||||
**`_do_unwrap`(`bl_decimate.py:267`)改为分级**:
|
||||
|
||||
1. seam@66° → 松弛 → 采集指标 → 测门,过则采用;
|
||||
2. 不过 → 依次试 `SEAM_ANGLE_SWEEP = (55, 45, 35)`,每档:clear UV → 标 seam@角度 → 展开+松弛 → 采集指标;**在所有过门候选里取 UV 岛数最少者**采用;
|
||||
3. 全不过门 → `_unwrap_smart` 保底(同现状)。
|
||||
|
||||
细节:
|
||||
- **UVPM 重排只在最终选定的 UV 上跑一次**(`_uvpm_repack`),不在每个候选上跑,省时。因此候选比较用的是内置 `pack_islands` 布局下的指标;翻转/重叠是展开固有属性,不随排布器变,用于比较有效。
|
||||
- `mode` 统一标注实际生效角度:base 字符串固定为 `seam@<角度>`(首选走通即 `seam@66`),再经 `uvpm_mode_label` 加 `+uvpm` 后缀 → 如 `seam@45+uvpm`。smart 保底路径保持 `smart_fallback`(同现状)。
|
||||
- 候选指标采集复用 `_collect_uv_metrics`;每个候选都要跑一次 `_collect_uv_metrics`(含 `_overlap_fraction` 的 edit-mode 算子),已知开销可接受(展开远快于减面)。
|
||||
- 代价:一次减面最多 4 轮展开(66/55/45/35)。gargoyle 类正是受益场景。
|
||||
|
||||
**质量门与常量**:`UV_FLIP_TOL`/`UV_OVERLAP_TOL`/`uv_gate_ok` 不变。新增 `SEAM_ANGLE_SWEEP`。
|
||||
|
||||
### 3. UV 线框观察图(Blender 抽几何 → 系统 Python 用 PIL 绘 PNG)
|
||||
|
||||
因 headless 无法用 Blender 直接出 PNG(见非目标修订),拆成两端:
|
||||
|
||||
**Blender 端(`bl_decimate.py`)**:最终 UV 定下后,`_collect_uv_polygons(obj)` 用 bmesh 提取每个面的 UV 多边形(归一化 0-1 坐标)`[[[u,v],...], ...]`,`main()` 写入 sidecar JSON `<名>_low_uv.polys.json`;`MT_SUMMARY` 增加字段 `uv_preview`(目标 PNG 路径 `<名>_low_uv.png`)与 `uv_polys`(sidecar JSON 路径);抽取失败记警告、两字段置 null、不中断。
|
||||
|
||||
**系统 Python 端(`uv_preview.py` + `model_decimate.py`)**:
|
||||
- 新模块 `uv_preview.py` 提供纯函数 `render_uv_png(polygons, out_png, size=1024, pad=8, ...)`:白底、半透明填充 + 深色线框(V 轴翻转到图像坐标),用 Pillow 绘制并存 PNG。可在系统 Python 单测。
|
||||
- `model_decimate.py` 拿到 summary 后:读 `uv_polys` JSON → 调 `render_uv_png` 输出到 `uv_preview` → 删除 sidecar JSON → 打印 PNG 路径;任一步失败打印警告、不中断(FBX 已产出)。
|
||||
- 输出:紧邻 `_low.fbx`,命名 `<名>_low_uv.png`。
|
||||
|
||||
### 4. CLI 输出(model_decimate.py)
|
||||
|
||||
`model_decimate.py` 打印处(`model_decimate.py:30-35`)追加一行:
|
||||
|
||||
```python
|
||||
if s.get("uv_preview"):
|
||||
print(" UV 观察图: %s" % s["uv_preview"])
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
FBX → join → triangulate → decimate(±3%, ≤2 retry)
|
||||
→ _do_unwrap:
|
||||
seam@66 → +stretch → metrics → gate?
|
||||
├ pass → 选定
|
||||
└ fail → sweep 55/45/35(各 +stretch → metrics)→ 取过门且岛最少
|
||||
└ 全失败 → smart_project
|
||||
→ average_islands_scale + pack_islands(在 _unwrap_* 内)
|
||||
→ _uvpm_repack(最终 UV 上一次)
|
||||
→ _export_uv_layout → <名>_low_uv.png
|
||||
→ export_scene.fbx → <名>_low.fbx
|
||||
→ MT_SUMMARY(json){..., uv, uv_preview, warnings}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 失败点 | 处理 |
|
||||
|---|---|
|
||||
| `minimize_stretch` 不可用 | try/except 记警告,跳过松弛继续 |
|
||||
| `average_islands_scale` 上下文不可用 | try/except RuntimeError 记警告,跳过 |
|
||||
| 所有 seam 角度不过门 | 回退 `_unwrap_smart`(保底,同现状) |
|
||||
| `export_layout` 失败 | try/except 记警告,`uv_preview=null`,不中断 FBX 产出 |
|
||||
| UVPM 不可用 | 现有逻辑不变(记警告,保留内置布局) |
|
||||
|
||||
原则:**观察图与质量增强均为增强项,任何单点失败都降级而非中断**,FBX 必产出。
|
||||
|
||||
## 测试与验证
|
||||
|
||||
1. 现有单测不回归:`python -m unittest tests.test_unity_assets`。
|
||||
2. 纯函数可单测:`SEAM_ANGLE_SWEEP` 遍历顺序、候选择优(取过门且岛最少)逻辑抽成纯函数 `pick_best_candidate(candidates)` 便于单测(输入指标 dict 列表,输出选中项)。
|
||||
3. 端到端:`python model_decimate.py src/gargoyle.fbx --tris 1000`,对比改动前后指标(岛数/利用率/翻转/重叠)与 `mode` 标注;确认 `src/gargoyle_low_uv.png` 生成且线框可读。
|
||||
4. 回归另一模型(如 well1500)确认首选 seam@66 仍走通、未被扫描逻辑破坏。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- gargoyle 减面后:走通某档 seam(岛数显著低于 324)**或**明确记录全失败退 smart 的原因;无论哪条路径都产出可读的 `_low_uv.png`。
|
||||
- 任一增强算子在 headless 不可用时,流程降级不崩,FBX 与观察图(若 export 本身可用)照常产出。
|
||||
- CLI 日志新增 UV 观察图路径行;`mode` 标注实际生效角度。
|
||||
- 现有单测全绿。
|
||||
|
||||
## 未决/实现时验证项
|
||||
|
||||
- `minimize_stretch` / `average_islands_scale` / `export_layout` 在当前 Blender 5.0 headless 的算子上下文可行性——实现首步用最小脚本各验证一次,不可行则按错误处理表降级并在 spec/README 注明。
|
||||
Reference in New Issue
Block a user