798 lines
31 KiB
Python
798 lines
31 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
convert_models.py —— 可复用·批量模型转换器(Blender 后端)
|
||
==========================================================
|
||
|
||
支持把一整个目录的模型批量转成 GLB(也可输出 .gltf / 分离式 glTF)。
|
||
输入格式:fbx / obj / gltf / glb / stl / ply / usd / usda / usdc / dae / blend
|
||
输出格式:glb(默认,单文件内嵌贴图)/ gltf(.gltf + .bin + 贴图分离)
|
||
|
||
--------------------------------------------------------------------------
|
||
★ GLB 重新导出(re-export)
|
||
--------------------------------------------------------------------------
|
||
把已经存在的 .glb 重新走一遍 Blender 的导入 → 导出流程,用于:
|
||
|
||
* 换导出参数重出(--draco 压缩、--tex-format jpeg 换贴图编码、
|
||
去掉相机/灯光/自定义属性、改 Y-up/Z-up …)
|
||
* 修好模型后用同一套参数刷新一批旧 GLB
|
||
* 统一把某个目录的 GLB 规格拉齐
|
||
|
||
用 --re-export 开启;它会自动把源格式设成 glb、输出格式设成 glb 并允许覆盖。
|
||
默认**原地替换**,替换前会把原文件备份到 `_glb_backup/`(可用 --backup-dir 改位置)。
|
||
|
||
# 原地重导出(自动备份原始 GLB)
|
||
python convert_models.py --re-export --input "E:\\Godot\\agame\\assets\\GLB"
|
||
|
||
# 把一批老 GLB 的贴图统一压成 JPEG(体积减小的稳妥做法,Godot 完全支持)
|
||
python convert_models.py --re-export --input "E:\\Godot\\agame\\assets\\GLB" \
|
||
--tex-format jpeg --tex-quality 85
|
||
|
||
注意:这是**往返(round-trip)**过程,模型会经 Blender 重新解析一次。
|
||
网格数、三角面数、材质数、贴图、动画、自定义属性都会保留;差异仅在:
|
||
* 顶点数可能因 glTF 规范在 UV/法线接缝处重新拆分而小幅增加(正常)
|
||
* 因此不压缩时体积通常持平,个别模型会 ±几个百分点
|
||
* 若开启 --draco,坐标会被量化,尺寸会有 1e-4 级别的微小变化
|
||
|
||
--------------------------------------------------------------------------
|
||
两种运行方式(脚本会自动判断)
|
||
--------------------------------------------------------------------------
|
||
1) 普通终端里直接跑(脚本会自己找到 Blender 并接管):
|
||
|
||
python convert_models.py --input "E:\\某个模型目录" --output "E:\\输出目录"
|
||
|
||
* 可用 --blender "D:\\...\\blender.exe" 手动指定 Blender
|
||
* Windows 下也可以直接双击同目录的 convert.bat(支持拖文件夹进去)
|
||
|
||
2) 在 Blender 里跑(把本文件丢进 Text Editor,改完 CONFIG 后 Run Script)
|
||
|
||
--------------------------------------------------------------------------
|
||
常用参数(全部可选,命令行会覆盖 CONFIG)
|
||
--------------------------------------------------------------------------
|
||
-i, --input DIR 输入目录
|
||
-o, --output DIR 输出目录;默认 __same__(输出到源文件旁边)
|
||
-e, --ext LIST 要处理的扩展名,逗号分隔,默认 fbx
|
||
-r, --recursive 递归扫描子目录(输出保持相对目录结构)
|
||
--overwrite 覆盖已存在的输出(默认跳过 → 可断点续跑)
|
||
--dry-run 只列出将要转换的文件,不实际执行
|
||
--suffix S 输出文件名加后缀,如 --suffix _conv
|
||
--filter GLOB 只处理文件名匹配该通配符的文件,如 --filter "Door_*"
|
||
--exclude GLOB 排除文件名匹配该通配符的文件,如 --exclude "*_LOD*"
|
||
--limit N 只处理前 N 个(调试用)
|
||
|
||
--re-export GLB 重新导出模式(见上方说明)
|
||
--in-place 输出直接替换源文件;替换前自动备份
|
||
--backup-dir DIR 指定备份目录(默认 <源目录>/_glb_backup)
|
||
--no-backup 原地替换时不备份(危险,不建议)
|
||
|
||
--scale FLOAT 导入时的全局缩放,默认 1.0(源文件按厘米建模时用 0.01)
|
||
--apply 导出前应用修改器
|
||
--no-anim 不导出动画
|
||
--no-texture 不导出贴图(仅保留材质基础色)
|
||
--tex-format auto|jpeg|webp 贴图编码;默认 auto 保持原编码,jpeg/webp 可显著减小体积
|
||
--tex-quality N 贴图质量 0-100(配合 --tex-format 使用,默认 75)
|
||
--leaf-bones 保留自动生成的 leaf bone(默认剔除,游戏资产更干净)
|
||
--tangents 导出切线
|
||
--no-extras 不导出自定义属性
|
||
--draco 启用 Draco 网格压缩(体积可降 80%+,但需引擎支持解码)
|
||
⚠ 本项目的 Godot(4.7)官方 glTF 导入器不支持 Draco,
|
||
开启后 GLB 会导入失败。除非另装 GDDraco 之类的
|
||
GDExtension 解码扩展,否则请保持默认关闭。
|
||
--cameras / --lights 顺带导出相机 / 灯光(默认都不导出)
|
||
--z-up 输出 Z-up(默认按 glTF 标准输出 Y-up)
|
||
--out-format glb|gltf 输出格式,默认 glb
|
||
|
||
-q, --quiet 只输出汇总
|
||
-v, --verbose 失败时打印完整堆栈
|
||
--csv 额外生成 _convert_report.csv
|
||
|
||
--------------------------------------------------------------------------
|
||
产物
|
||
--------------------------------------------------------------------------
|
||
<输出目录>/<同名模型>.glb 每个源文件一个
|
||
<输出目录>/_convert_report.txt 转换报告(成功/跳过/失败、耗时、体积)
|
||
<输出目录>/_convert_report.csv 加 --csv 时才有
|
||
原地重导出时:原文件被替换,原始文件备份在 <源目录>/_glb_backup/
|
||
|
||
退出码:0 全部成功;1 有失败;2 参数或环境错误。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import fnmatch
|
||
import glob
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import traceback
|
||
|
||
# ===========================================================================
|
||
# CONFIG —— 在 Blender 里 Run Script 时生效;命令行参数会覆盖这里的值
|
||
# ===========================================================================
|
||
CONFIG = {
|
||
"input": r"E:\Godot\agame\assets\FBX",
|
||
"output": "__same__", # 或写成 r"E:\Godot\agame\assets\GLB"
|
||
"ext": "fbx",
|
||
"recursive": False,
|
||
"overwrite": False, # True = 覆盖已有文件;False = 跳过(续跑)
|
||
"apply_modifiers": False,
|
||
"animations": True,
|
||
"textures": True,
|
||
"keep_leaf_bones": False,
|
||
"scale": 1.0,
|
||
"limit": 0,
|
||
"yup": True,
|
||
"cameras": False,
|
||
"lights": False,
|
||
"extras": True,
|
||
"tangents": False,
|
||
"draco": False,
|
||
"out_format": "glb",
|
||
"suffix": "",
|
||
"filter": "",
|
||
"exclude": "",
|
||
"dry_run": False,
|
||
"quiet": False,
|
||
"verbose": False,
|
||
"csv": False,
|
||
# ---- GLB 重新导出 ----
|
||
"re_export": False, # 重新导出模式:源=glb,输出=glb,允许覆盖
|
||
"in_place": False, # 原地替换源文件(自动备份)
|
||
"backup_dir": "", # 空 = <源目录>/_glb_backup
|
||
"no_backup": False, # 原地替换时不备份(危险)
|
||
"tex_format": "auto", # auto / jpeg / webp
|
||
"tex_quality": 0, # 0 = 用 Blender 默认
|
||
}
|
||
|
||
BOOL_FLAGS = {
|
||
"--recursive": ("recursive", True),
|
||
"--overwrite": ("overwrite", True),
|
||
"--apply": ("apply_modifiers", True),
|
||
"--no-anim": ("animations", False),
|
||
"--no-texture": ("textures", False),
|
||
"--leaf-bones": ("keep_leaf_bones", True),
|
||
"--tangents": ("tangents", True),
|
||
"--no-extras": ("extras", False),
|
||
"--draco": ("draco", True),
|
||
"--cameras": ("cameras", True),
|
||
"--lights": ("lights", True),
|
||
"--z-up": ("yup", False),
|
||
"--dry-run": ("dry_run", True),
|
||
"-q": ("quiet", True),
|
||
"--quiet": ("quiet", True),
|
||
"-v": ("verbose", True),
|
||
"--verbose": ("verbose", True),
|
||
"--csv": ("csv", True),
|
||
# ---- GLB 重新导出 ----
|
||
"--re-export": ("re_export", True),
|
||
"--reexport": ("re_export", True),
|
||
"--in-place": ("in_place", True),
|
||
"--no-backup": ("no_backup", True),
|
||
}
|
||
|
||
|
||
VALUE_FLAGS = {
|
||
"-i": "input", "--input": "input",
|
||
"-o": "output", "--output": "output", "--out": "output",
|
||
"-e": "ext", "--ext": "ext",
|
||
"--scale": "scale",
|
||
"--limit": "limit",
|
||
"--suffix": "suffix",
|
||
"--filter": "filter",
|
||
"--exclude": "exclude",
|
||
"--out-format": "out_format",
|
||
"--blender": "blender", # 只在外部启动模式下使用
|
||
"--backup-dir": "backup_dir",
|
||
"--tex-format": "tex_format",
|
||
"--tex-quality": "tex_quality",
|
||
}
|
||
|
||
HELP = __doc__
|
||
|
||
|
||
# ===========================================================================
|
||
# 命令行解析
|
||
# ===========================================================================
|
||
def parse_opts(argv):
|
||
"""解析纯参数列表(不含 Blender 自身参数)。"""
|
||
opts = dict(CONFIG)
|
||
opts.setdefault("blender", "")
|
||
seen = set()
|
||
|
||
i = 0
|
||
while i < len(argv):
|
||
a = argv[i]
|
||
key, eq, inline = a.partition("=")
|
||
|
||
if key in ("-h", "--help"):
|
||
print(HELP)
|
||
raise SystemExit(0)
|
||
|
||
if key in VALUE_FLAGS:
|
||
val = inline if eq else (argv[i + 1] if i + 1 < len(argv) else None)
|
||
if not eq:
|
||
i += 1
|
||
if val is None:
|
||
raise SystemExit("[ERROR] 参数 %s 缺少取值" % a)
|
||
name = VALUE_FLAGS[key]
|
||
if name == "scale":
|
||
opts[name] = float(val)
|
||
elif name in ("limit", "tex_quality"):
|
||
opts[name] = int(val)
|
||
elif name in ("out_format", "tex_format"):
|
||
opts[name] = val.lower()
|
||
else:
|
||
opts[name] = val
|
||
seen.add(name)
|
||
i += 1
|
||
continue
|
||
|
||
if key in BOOL_FLAGS:
|
||
name, value = BOOL_FLAGS[key]
|
||
opts[name] = value
|
||
seen.add(name)
|
||
i += 1
|
||
continue
|
||
|
||
i += 1
|
||
|
||
opts["_seen"] = seen
|
||
return apply_mode(opts)
|
||
|
||
|
||
def apply_mode(opts):
|
||
"""把 --re-export / --in-place 归一化成具体行为,并做约束校验。"""
|
||
seen = opts.pop("_seen", set())
|
||
|
||
if opts["out_format"] not in ("glb", "gltf"):
|
||
raise SystemExit("[ERROR] --out-format 只能是 glb 或 gltf")
|
||
if opts["tex_format"] not in ("auto", "jpeg", "webp"):
|
||
raise SystemExit("[ERROR] --tex-format 只能是 auto / jpeg / webp")
|
||
if not 0 <= int(opts["tex_quality"]) <= 100:
|
||
raise SystemExit("[ERROR] --tex-quality 需要在 0-100 之间")
|
||
|
||
if opts["re_export"]:
|
||
# 重新导出:源 glb -> 输出 glb,允许覆盖
|
||
if "ext" not in seen:
|
||
opts["ext"] = "glb"
|
||
opts["out_format"] = "glb"
|
||
opts["overwrite"] = True
|
||
# 没指定输出目录时,默认原地替换(并自动备份)
|
||
if opts["output"] == "__same__" and "in_place" not in seen:
|
||
opts["in_place"] = True
|
||
|
||
if opts["in_place"]:
|
||
if opts["out_format"] != "glb":
|
||
raise SystemExit("[ERROR] --in-place 只支持 glb 输出(多文件格式无法原地替换)")
|
||
exts = {e.strip().lower().lstrip(".") for e in str(opts["ext"]).split(",") if e.strip()}
|
||
if exts != {"glb"}:
|
||
raise SystemExit("[ERROR] --in-place 要求源文件为 .glb,当前 --ext=%s" % opts["ext"])
|
||
opts["overwrite"] = True
|
||
|
||
return opts
|
||
|
||
|
||
def blender_user_args(raw_argv):
|
||
"""Blender 运行模式下,用户参数一律放在 '--' 之后。
|
||
|
||
在 Blender 的 Text Editor 里直接 Run Script 时通常没有 '--',
|
||
此时返回空列表 —— 即完全使用脚本顶部的 CONFIG。
|
||
"""
|
||
if "--" in raw_argv:
|
||
return raw_argv[raw_argv.index("--") + 1:]
|
||
return []
|
||
|
||
|
||
|
||
# ===========================================================================
|
||
# 日志
|
||
# ===========================================================================
|
||
def log(msg, quiet=False):
|
||
if quiet:
|
||
return
|
||
try:
|
||
print(msg, flush=True)
|
||
except UnicodeEncodeError:
|
||
print(msg.encode("ascii", "replace").decode("ascii"), flush=True)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ===========================================================================
|
||
# Blender 定位 + 外部启动(在普通 Python 里运行时走这里)
|
||
# ===========================================================================
|
||
def find_blender():
|
||
"""返回 blender 可执行文件路径,找不到返回 None。"""
|
||
env = os.environ.get("BLENDER_EXE") or os.environ.get("BLENDER")
|
||
if env and os.path.isfile(env):
|
||
return env
|
||
|
||
exe = "blender.exe" if os.name == "nt" else "blender"
|
||
from shutil import which
|
||
w = which(exe) or which("blender")
|
||
if w:
|
||
return w
|
||
|
||
patterns = [
|
||
r"C:\Program Files\Blender Foundation\Blender*\blender.exe",
|
||
r"C:\Program Files (x86)\Blender Foundation\Blender*\blender.exe",
|
||
r"D:\Program Files\Blender Foundation\Blender*\blender.exe",
|
||
r"E:\Program Files\Blender Foundation\Blender*\blender.exe",
|
||
r"C:\Program Files\Steam\steamapps\common\Blender\blender.exe",
|
||
r"D:\Steam\steamapps\common\Blender\blender.exe",
|
||
"/Applications/Blender.app/Contents/MacOS/Blender",
|
||
"/usr/local/bin/blender",
|
||
"/usr/bin/blender",
|
||
"/snap/bin/blender",
|
||
"/opt/blender*/blender",
|
||
]
|
||
found = []
|
||
for p in patterns:
|
||
found.extend(glob.glob(p))
|
||
|
||
def ver_key(path):
|
||
digits = []
|
||
cur = ""
|
||
for ch in os.path.basename(os.path.dirname(path)) + os.path.basename(path):
|
||
if ch.isdigit():
|
||
cur += ch
|
||
elif cur:
|
||
digits.append(int(cur)); cur = ""
|
||
if cur:
|
||
digits.append(int(cur))
|
||
return digits
|
||
|
||
found = [f for f in found if os.path.isfile(f)]
|
||
if not found:
|
||
return None
|
||
try:
|
||
found.sort(key=ver_key)
|
||
except Exception:
|
||
pass
|
||
return found[-1]
|
||
|
||
|
||
def relaunch(opts, raw_argv):
|
||
blender = opts.get("blender") or find_blender()
|
||
if not blender:
|
||
print("[ERROR] 找不到 Blender。请用 --blender 指定,例如:")
|
||
print(' python convert_models.py --blender "D:\\Program Files\\Blender '
|
||
'Foundation\\Blender 5.1\\blender.exe" --input ... --output ...')
|
||
return 2
|
||
|
||
child_args = raw_argv[1:]
|
||
|
||
cmd = [blender, "-b", "--factory-startup", "--python", os.path.abspath(__file__), "--"] + child_args
|
||
print("[INFO] Blender: %s" % blender, flush=True)
|
||
print("[INFO] 输出格式: %s -> %s" % (opts.get("ext", "?"), opts.get("out_format", "glb")), flush=True)
|
||
print(flush=True)
|
||
try:
|
||
return subprocess.call(cmd)
|
||
except OSError as exc:
|
||
print("[ERROR] 启动 Blender 失败: %s" % exc)
|
||
return 2
|
||
|
||
|
||
# ===========================================================================
|
||
# 以下函数都在 Blender 内运行
|
||
# ===========================================================================
|
||
def scene_reset(bpy):
|
||
"""彻底清空场景,避免上一个模型的数据污染下一个。"""
|
||
for obj in list(bpy.data.objects):
|
||
bpy.data.objects.remove(obj, do_unlink=True)
|
||
for coll in list(bpy.data.collections):
|
||
bpy.data.collections.remove(coll)
|
||
for _ in range(3):
|
||
bpy.data.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||
|
||
|
||
def _try_op(op, **kwargs):
|
||
"""尝试调用 Blender 算子;成功返回 True。"""
|
||
try:
|
||
op(**kwargs)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def import_model(bpy, path, opts):
|
||
"""按扩展名导入模型,返回实际使用的导入器名称。"""
|
||
ext = os.path.splitext(path)[1].lower().lstrip(".")
|
||
scale = opts["scale"]
|
||
leaf = not opts["keep_leaf_bones"]
|
||
|
||
if ext == "fbx":
|
||
# Blender 5.x 新版原生导入器,失败再退回 4.x 的旧算子
|
||
if hasattr(bpy.ops.wm, "fbx_import") and _try_op(
|
||
bpy.ops.wm.fbx_import, filepath=path, global_scale=scale,
|
||
ignore_leaf_bones=leaf, use_anim=True, use_custom_normals=True):
|
||
return "wm.fbx_import"
|
||
scene_reset(bpy)
|
||
bpy.ops.import_scene.fbx(
|
||
filepath=path, global_scale=scale, ignore_leaf_bones=leaf,
|
||
automatic_bone_orientation=False, use_anim=True, use_custom_normals=True)
|
||
return "import_scene.fbx"
|
||
|
||
if ext == "obj":
|
||
if _try_op(bpy.ops.wm.obj_import, filepath=path, global_scale=scale):
|
||
return "wm.obj_import"
|
||
bpy.ops.import_scene.obj(filepath=path, global_scale=scale)
|
||
return "import_scene.obj"
|
||
|
||
if ext in ("gltf", "glb"):
|
||
bpy.ops.import_scene.gltf(filepath=path)
|
||
return "import_scene.gltf"
|
||
|
||
if ext == "stl":
|
||
if _try_op(bpy.ops.wm.stl_import, filepath=path, global_scale=scale):
|
||
return "wm.stl_import"
|
||
bpy.ops.import_mesh.stl(filepath=path, global_scale=scale)
|
||
return "import_mesh.stl"
|
||
|
||
if ext == "ply":
|
||
if _try_op(bpy.ops.wm.ply_import, filepath=path):
|
||
return "wm.ply_import"
|
||
bpy.ops.import_mesh.ply(filepath=path)
|
||
return "import_mesh.ply"
|
||
|
||
if ext in ("usd", "usda", "usdc", "usdz"):
|
||
bpy.ops.wm.usd_import(filepath=path, global_scale=scale)
|
||
return "wm.usd_import"
|
||
|
||
if ext in ("dae", "dae.gz"):
|
||
bpy.ops.wm.collada_import(filepath=path)
|
||
return "wm.collada_import"
|
||
|
||
if ext == "blend":
|
||
bpy.ops.wm.open_mainfile(filepath=path)
|
||
return "wm.open_mainfile"
|
||
|
||
raise RuntimeError("不支持导入的格式: .%s" % ext)
|
||
|
||
|
||
def export_model(bpy, dst, opts):
|
||
# 贴图编码:auto 保持原编码;jpeg/webp 会重编码,体积通常显著下降
|
||
if not opts["textures"]:
|
||
img_fmt = "NONE"
|
||
else:
|
||
img_fmt = {"auto": "AUTO", "jpeg": "JPEG", "webp": "WEBP"}[opts["tex_format"]]
|
||
|
||
kwargs = dict(
|
||
filepath=dst,
|
||
export_format="GLB" if opts["out_format"] == "glb" else "GLTF_SEPARATE",
|
||
use_selection=False,
|
||
export_apply=opts["apply_modifiers"],
|
||
export_yup=opts["yup"],
|
||
export_materials="EXPORT",
|
||
export_image_format=img_fmt,
|
||
export_animations=opts["animations"],
|
||
export_cameras=opts["cameras"],
|
||
export_lights=opts["lights"],
|
||
export_extras=opts["extras"],
|
||
export_texcoords=True,
|
||
export_normals=True,
|
||
export_tangents=opts["tangents"],
|
||
)
|
||
if opts["textures"] and int(opts["tex_quality"]) > 0:
|
||
kwargs["export_image_quality"] = int(opts["tex_quality"])
|
||
if opts["draco"]:
|
||
kwargs.update(
|
||
export_draco_mesh_compression_enable=True,
|
||
export_draco_mesh_compression_level=6,
|
||
)
|
||
try:
|
||
bpy.ops.export_scene.gltf(**kwargs)
|
||
except TypeError:
|
||
# 老版本 Blender 参数名不兼容时,退化到最小参数集
|
||
bpy.ops.export_scene.gltf(
|
||
filepath=dst,
|
||
export_format=kwargs["export_format"],
|
||
use_selection=False,
|
||
export_apply=opts["apply_modifiers"],
|
||
export_yup=opts["yup"],
|
||
export_animations=opts["animations"],
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 文件收集
|
||
# ---------------------------------------------------------------------------
|
||
def collect_files(root, exts, recursive, include, exclude):
|
||
"""按扩展名/通配符筛选源文件,返回排序后的绝对路径列表。"""
|
||
if not os.path.isdir(root):
|
||
return []
|
||
if recursive:
|
||
walker = ((d, fs) for d, _sub, fs in os.walk(root))
|
||
else:
|
||
walker = [(root, os.listdir(root))]
|
||
|
||
hits = []
|
||
for dirpath, filenames in walker:
|
||
for fn in filenames:
|
||
if fn.startswith("."):
|
||
continue
|
||
if not os.path.isfile(os.path.join(dirpath, fn)):
|
||
continue
|
||
if os.path.splitext(fn)[1].lower().lstrip(".") not in exts:
|
||
continue
|
||
if include and not fnmatch.fnmatch(fn.lower(), include.lower()):
|
||
continue
|
||
if exclude and fnmatch.fnmatch(fn.lower(), exclude.lower()):
|
||
continue
|
||
hits.append(os.path.join(dirpath, fn))
|
||
return sorted(hits)
|
||
|
||
|
||
def _fmt_size(num_bytes):
|
||
for unit in ("B", "KB", "MB", "GB"):
|
||
if num_bytes < 1024 or unit == "GB":
|
||
return "%.2f%s" % (num_bytes, unit)
|
||
num_bytes /= 1024.0
|
||
|
||
|
||
def _pct(old, new):
|
||
"""体积变化百分比,例如 -74%。"""
|
||
if not old:
|
||
return ""
|
||
return "%+.0f%%" % ((new - old) * 100.0 / old)
|
||
|
||
|
||
def backup_file(src, backup_root, src_root):
|
||
"""把源文件复制进备份目录(保持相对目录结构)。
|
||
|
||
已存在同名备份时不覆盖 —— 保证备份里始终是**最早那一版**原始文件,
|
||
重复跑 --re-export 不会把中间结果覆盖掉原始文件。
|
||
"""
|
||
dst = os.path.join(backup_root, os.path.relpath(src, src_root))
|
||
if os.path.exists(dst):
|
||
return dst
|
||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
# 让 Godot 跳过备份目录
|
||
gdignore = os.path.join(backup_root, ".gdignore")
|
||
if not os.path.exists(gdignore):
|
||
try:
|
||
open(gdignore, "w").close()
|
||
except OSError:
|
||
pass
|
||
return dst
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 主流程(Blender 内)
|
||
# ---------------------------------------------------------------------------
|
||
def run_inside_blender(raw_argv):
|
||
import bpy
|
||
|
||
try:
|
||
opts = parse_opts(blender_user_args(raw_argv))
|
||
except SystemExit as exc:
|
||
if exc.code:
|
||
print(exc.code)
|
||
return 0 if exc.code == 0 else 2
|
||
|
||
src_root = os.path.abspath(opts["input"]) if opts["input"] else ""
|
||
if not src_root or not os.path.isdir(src_root):
|
||
print("[ERROR] 输入目录不存在: %s" % (src_root or "(空)"))
|
||
print(" 用 --input \"目录\" 指定,或修改脚本顶部的 CONFIG。")
|
||
return 2
|
||
|
||
out_root = src_root if opts["output"] == "__same__" else os.path.abspath(opts["output"])
|
||
|
||
exts = {e.strip().lower().lstrip(".") for e in str(opts["ext"]).split(",") if e.strip()}
|
||
files = collect_files(src_root, exts, opts["recursive"], opts["filter"], opts["exclude"])
|
||
if opts["limit"] > 0:
|
||
files = files[:int(opts["limit"])]
|
||
|
||
# ---- 原地替换模式准备 ----
|
||
in_place = bool(opts["in_place"])
|
||
hint_same_dir = (not in_place and os.path.normcase(out_root) == os.path.normcase(src_root)
|
||
and opts["out_format"] == "glb" and exts == {"glb"})
|
||
if hint_same_dir:
|
||
in_place = True
|
||
|
||
backup_root = ""
|
||
if in_place:
|
||
if opts["no_backup"]:
|
||
backup_root = ""
|
||
else:
|
||
backup_root = os.path.abspath(opts["backup_dir"]) if opts["backup_dir"] \
|
||
else os.path.join(src_root, "_glb_backup")
|
||
out_root = backup_root or src_root
|
||
|
||
quiet = opts["quiet"]
|
||
log("=" * 72)
|
||
log("批量模型转换 -> %s%s"
|
||
% (opts["out_format"].upper(), " [原地重新导出]" if in_place else ""))
|
||
log("输入目录 : %s" % src_root)
|
||
if in_place:
|
||
log("输出 : 原地替换源文件")
|
||
log("备份 : %s" % (backup_root if backup_root else "(未启用备份)"))
|
||
else:
|
||
log("输出目录 : %s" % out_root)
|
||
log("格式 : %s 找到文件: %d" % (",".join(sorted(exts)), len(files)))
|
||
log("递归 : %-5s 覆盖: %-5s 缩放: %s%s"
|
||
% (opts["recursive"], opts["overwrite"], opts["scale"],
|
||
(" Draco: %s" % opts["draco"]) if opts["draco"] else ""))
|
||
if in_place and hint_same_dir and not opts["in_place"]:
|
||
log("提示 : 输出目录与源目录相同,已自动切换为原地替换(原文件会先备份)")
|
||
log("=" * 72)
|
||
|
||
if not files:
|
||
log("[WARN] 没有匹配到任何源文件。")
|
||
return 0
|
||
|
||
if opts["dry_run"]:
|
||
for idx, src in enumerate(files, 1):
|
||
log("[%3d/%3d] 待转换 %s" % (idx, len(files), os.path.relpath(src, src_root)))
|
||
log("-" * 72)
|
||
log("[DRY-RUN] 共 %d 个文件,未实际执行。" % len(files))
|
||
return 0
|
||
|
||
ok, skipped, failed = 0, 0, []
|
||
rows = []
|
||
t_all = time.time()
|
||
out_bytes = 0
|
||
|
||
for idx, src in enumerate(files, 1):
|
||
stem = os.path.splitext(os.path.basename(src))[0]
|
||
if in_place:
|
||
dst_dir = os.path.dirname(src)
|
||
dst = src
|
||
else:
|
||
if opts["recursive"]:
|
||
rel = os.path.relpath(os.path.dirname(src), src_root)
|
||
dst_dir = out_root if rel == "." else os.path.join(out_root, rel)
|
||
else:
|
||
dst_dir = out_root
|
||
dst = os.path.join(dst_dir, stem + opts["suffix"] + "." + opts["out_format"])
|
||
|
||
tag = "[%3d/%3d]" % (idx, len(files))
|
||
if not in_place:
|
||
if os.path.abspath(dst) == os.path.abspath(src):
|
||
log("%s 跳过(输入输出同名) %s" % (tag, stem), quiet)
|
||
skipped += 1
|
||
continue
|
||
if os.path.exists(dst) and not opts["overwrite"]:
|
||
log("%s 跳过(已存在) %s" % (tag, stem), quiet)
|
||
skipped += 1
|
||
rows.append((src, dst, "skipped", "", "0", ""))
|
||
continue
|
||
|
||
os.makedirs(dst_dir, exist_ok=True)
|
||
before = os.path.getsize(src) if os.path.isfile(src) else 0
|
||
# 原地模式先写临时文件,成功后再原子替换,失败不会破坏原文件
|
||
tmp = os.path.join(dst_dir, stem + "__reexport_tmp__." + opts["out_format"]) if in_place else dst
|
||
t0 = time.time()
|
||
try:
|
||
scene_reset(bpy)
|
||
importer = import_model(bpy, src, opts)
|
||
if not bpy.data.objects:
|
||
raise RuntimeError("导入后场景为空")
|
||
export_model(bpy, tmp, opts)
|
||
|
||
if in_place:
|
||
if backup_root:
|
||
backup_file(src, backup_root, src_root)
|
||
os.replace(tmp, dst)
|
||
|
||
size = os.path.getsize(dst)
|
||
out_bytes += size
|
||
dt = time.time() - t0
|
||
note = ("%s -> %s (%s)" % (_fmt_size(before), _fmt_size(size), _pct(before, size))
|
||
if in_place else _fmt_size(size))
|
||
log("%s OK %-38s %-24s %5.1fs (%s)"
|
||
% (tag, stem, note, dt, importer), quiet)
|
||
rows.append((src, dst, "ok", importer, "%.2f" % dt, note))
|
||
ok += 1
|
||
except Exception as exc:
|
||
dt = time.time() - t0
|
||
log("%s FAIL %-38s %s" % (tag, stem, exc), quiet)
|
||
if opts["verbose"]:
|
||
log(traceback.format_exc())
|
||
rows.append((src, dst, "failed", "", "%.2f" % dt, str(exc)))
|
||
failed.append((src, str(exc), traceback.format_exc()))
|
||
try:
|
||
if os.path.exists(tmp):
|
||
os.remove(tmp)
|
||
if not in_place and os.path.exists(dst):
|
||
os.remove(dst)
|
||
except OSError:
|
||
pass
|
||
|
||
total = time.time() - t_all
|
||
src_bytes = sum(os.path.getsize(f[0]) for f in rows if os.path.isfile(f[0])) if rows else 0
|
||
|
||
# ---- 报告 ----
|
||
report = os.path.join(out_root, "_convert_report.txt")
|
||
csv_path = os.path.join(out_root, "_convert_report.csv")
|
||
header = "批量模型重新导出报告" if in_place else "批量模型转换报告"
|
||
try:
|
||
os.makedirs(out_root, exist_ok=True)
|
||
with open(report, "w", encoding="utf-8") as f:
|
||
f.write("%s\n" % header)
|
||
f.write("时间 : %s\n" % time.strftime("%Y-%m-%d %H:%M:%S"))
|
||
f.write("输入目录 : %s\n" % src_root)
|
||
if in_place:
|
||
f.write("输出 : 原地替换源文件\n")
|
||
f.write("备份目录 : %s\n" % (backup_root if backup_root else "(未启用备份)"))
|
||
else:
|
||
f.write("输出目录 : %s\n" % out_root)
|
||
f.write("输入格式 : %s\n输出格式 : %s\n" % (",".join(sorted(exts)), opts["out_format"]))
|
||
f.write("导出选项 : draco=%s apply=%s anim=%s texture=%s/%s extras=%s yup=%s\n"
|
||
% (opts["draco"], opts["apply_modifiers"], opts["animations"],
|
||
opts["textures"], opts["tex_format"], opts["extras"], opts["yup"]))
|
||
f.write("总计 %d 成功 %d 跳过 %d 失败 %d\n" % (len(files), ok, skipped, len(failed)))
|
||
f.write("耗时 : %.1fs\n" % total)
|
||
f.write("产物体积 : %s\n" % _fmt_size(out_bytes))
|
||
f.write("\n%-10s %-46s %-8s %s\n" % ("状态", "源文件", "耗时s", "说明"))
|
||
f.write("-" * 100 + "\n")
|
||
for src, dst, status, imp, dt, note in rows:
|
||
f.write("%-10s %-46s %-8s %s\n" % (status, os.path.relpath(src, src_root), dt, note))
|
||
if failed:
|
||
f.write("\n--- 失败清单 ---\n")
|
||
for src, err, _tb in failed:
|
||
f.write("\n%s\n %s\n" % (src, err))
|
||
except OSError as exc:
|
||
report = "(写入失败: %s)" % exc
|
||
|
||
if opts["csv"]:
|
||
try:
|
||
import csv as _csv
|
||
with open(csv_path, "w", encoding="utf-8-sig", newline="") as f:
|
||
w = _csv.writer(f)
|
||
w.writerow(["status", "source", "output", "importer", "seconds", "note"])
|
||
for row in rows:
|
||
w.writerow([row[2], row[0], row[1], row[3], row[4], row[5]])
|
||
except Exception as exc:
|
||
csv_path = "(写入失败: %s)" % exc
|
||
|
||
log("-" * 72)
|
||
log("完成: 成功 %d / 跳过 %d / 失败 %d 耗时 %.1fs 产物 %s"
|
||
% (ok, skipped, len(failed), total, _fmt_size(out_bytes)))
|
||
if in_place:
|
||
log("原文件已原地替换;备份位置: %s" % (backup_root if backup_root else "(未备份)"))
|
||
if failed:
|
||
log("失败清单:")
|
||
for src, err, _tb in failed:
|
||
log(" - %s (%s)" % (os.path.basename(src), err))
|
||
if in_place:
|
||
log(" (失败的文件没有被改动,原文件保持原样)")
|
||
log("报告: %s" % report)
|
||
if opts["csv"]:
|
||
log("CSV : %s" % csv_path)
|
||
|
||
return 1 if failed else 0
|
||
|
||
|
||
# ===========================================================================
|
||
# 入口:自动判断是在 Blender 内还是在普通 Python 里
|
||
# ===========================================================================
|
||
def main():
|
||
raw_argv = list(sys.argv)
|
||
inside = "bpy" in sys.modules
|
||
if not inside:
|
||
try:
|
||
import bpy # noqa: F401
|
||
inside = True
|
||
except ImportError:
|
||
inside = False
|
||
|
||
if inside:
|
||
return run_inside_blender(raw_argv)
|
||
|
||
# 普通 Python:解析参数后转交给 Blender
|
||
try:
|
||
opts = parse_opts(raw_argv[1:])
|
||
except SystemExit as exc:
|
||
if exc.code:
|
||
print(exc.code)
|
||
return 0 if exc.code == 0 else 2
|
||
return relaunch(opts, raw_argv)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|