Bring in plaza props/prefabs, ZhaoYun source GLB, design originals, and Hunyuan/Blender tooling used to produce them. Co-authored-by: Cursor <cursoragent@cursor.com>
194 lines
6.4 KiB
Python
194 lines
6.4 KiB
Python
"""TownSquare 分件:图生3D → 减面烘焙 → 安装到 Bundles/Scenes/Town/TownSquare/{name}/"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT / "Tools") not in sys.path:
|
|
sys.path.insert(0, str(ROOT / "Tools"))
|
|
PARTS = [
|
|
("ground", 800),
|
|
("shop", 1800),
|
|
("tree", 1500),
|
|
("lamp", 500),
|
|
("board", 800),
|
|
("fountain", 1800),
|
|
("bench", 700),
|
|
("bush", 500),
|
|
]
|
|
SCENE_TYPE = "Town"
|
|
SCENE_ID = "TownSquare"
|
|
TEX_SIZE = 1024
|
|
|
|
|
|
def _decode(raw: bytes | None) -> str:
|
|
if not raw:
|
|
return ""
|
|
return raw.decode("utf-8", errors="replace")
|
|
|
|
|
|
def run(cmd: list[str]) -> dict:
|
|
print(f"RUN {' '.join(cmd)}", flush=True)
|
|
env = dict(os.environ)
|
|
env["PYTHONIOENCODING"] = "utf-8"
|
|
proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, env=env)
|
|
out = _decode(proc.stdout).strip()
|
|
err = _decode(proc.stderr).strip()
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(
|
|
f"exit {proc.returncode}\nstdout:\n{out[-4000:]}\nstderr:\n{err[-4000:]}"
|
|
)
|
|
last = None
|
|
for line in out.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("{") and line.endswith("}"):
|
|
try:
|
|
last = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if last is None and out.startswith("{"):
|
|
try:
|
|
last = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
last = None
|
|
return last or {"ok": True, "raw": out[-2000:]}
|
|
|
|
|
|
def is_valid_glb(path: Path) -> bool:
|
|
from hunyuan_3d.glb_check import glb_textures_ok
|
|
|
|
ok, _ = glb_textures_ok(path)
|
|
return ok
|
|
|
|
|
|
def pick(files: list[Path], pred) -> Path | None:
|
|
for path in files:
|
|
if path.is_file() and pred(path.name.lower()):
|
|
return path
|
|
return None
|
|
|
|
|
|
def install_part(name: str, xpbr_dir: Path, concept: Path) -> Path:
|
|
dest = ROOT / "AIClient/Assets/Bundles/Scenes" / SCENE_TYPE / SCENE_ID / name
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
files = list(xpbr_dir.iterdir())
|
|
fbx = pick(files, lambda n: n.endswith(".fbx"))
|
|
base = pick(files, lambda n: n.endswith("_base.png") or n.endswith("_basemap.png"))
|
|
mix = pick(files, lambda n: n.endswith("_mix.png") or n.endswith("_mixmap.png"))
|
|
mat = pick(files, lambda n: n.endswith(".mat"))
|
|
if not fbx or not base:
|
|
raise FileNotFoundError(f"XPbr 不完整: {xpbr_dir} files={[p.name for p in files]}")
|
|
mapping = {
|
|
fbx: dest / f"{name}.fbx",
|
|
base: dest / f"{name}_base.png",
|
|
mix: dest / f"{name}_mix.png" if mix else None,
|
|
mat: dest / f"{name}.mat" if mat else None,
|
|
}
|
|
copied = []
|
|
for src, target in mapping.items():
|
|
if src is None or target is None:
|
|
continue
|
|
shutil.copy2(src, target)
|
|
meta = Path(str(src) + ".meta")
|
|
if meta.is_file():
|
|
shutil.copy2(meta, Path(str(target) + ".meta"))
|
|
copied.append(target.name)
|
|
print(json.dumps({"installed": str(dest), "files": copied, "design": str(concept)}, ensure_ascii=False), flush=True)
|
|
return dest
|
|
|
|
|
|
def main() -> int:
|
|
summary = []
|
|
for name, tris in PARTS:
|
|
img = ROOT / "Doc/Design/Scene/TownSquare" / f"{name}.png"
|
|
glb = ROOT / "Tools/output/townsquare_3d" / name / f"{name}.glb"
|
|
bake_out = ROOT / "Tools/output/townsquare_game" / name
|
|
glb.parent.mkdir(parents=True, exist_ok=True)
|
|
print(json.dumps({"event": "part_start", "name": name, "tris": tris}, ensure_ascii=False), flush=True)
|
|
|
|
if not is_valid_glb(glb):
|
|
if glb.is_file():
|
|
print(
|
|
json.dumps({"event": "hunyuan_regen", "name": name, "reason": "invalid_glb"}, ensure_ascii=False),
|
|
flush=True,
|
|
)
|
|
glb.unlink()
|
|
hunyuan = run(
|
|
[
|
|
sys.executable,
|
|
str(ROOT / "Tools/hunyuan_3d_generate.py"),
|
|
"-i",
|
|
str(img),
|
|
"-o",
|
|
str(glb),
|
|
"--faces",
|
|
"50k",
|
|
"--result-only",
|
|
"--quiet",
|
|
]
|
|
)
|
|
if hunyuan.get("ok") is False or hunyuan.get("status") not in (None, "succeeded"):
|
|
raise RuntimeError(f"hunyuan failed {name}: {hunyuan}")
|
|
if not is_valid_glb(glb):
|
|
raise RuntimeError(f"hunyuan produced invalid glb: {glb}")
|
|
else:
|
|
print(
|
|
json.dumps({"event": "hunyuan_skip", "name": name}, ensure_ascii=False),
|
|
flush=True,
|
|
)
|
|
|
|
xpbr_dir = bake_out / f"{name}_xpbr"
|
|
bake: dict = {}
|
|
has_xpbr = xpbr_dir.is_dir() and any(xpbr_dir.glob("*.fbx"))
|
|
if has_xpbr:
|
|
print(
|
|
json.dumps({"event": "bake_skip", "name": name}, ensure_ascii=False),
|
|
flush=True,
|
|
)
|
|
else:
|
|
bake = run(
|
|
[
|
|
sys.executable,
|
|
str(ROOT / "Tools/model_tools/model_lowpoly_bake.py"),
|
|
str(glb),
|
|
"-o",
|
|
str(bake_out),
|
|
"--tris",
|
|
str(tris),
|
|
"--size",
|
|
str(TEX_SIZE),
|
|
"--result-only",
|
|
"--quiet",
|
|
]
|
|
)
|
|
if bake.get("xpbr_dir"):
|
|
xpbr_dir = Path(bake["xpbr_dir"])
|
|
dest = install_part(name, xpbr_dir, img)
|
|
rec = {
|
|
"name": name,
|
|
"tris_target": tris,
|
|
"tris_after": bake.get("tris_after"),
|
|
"glb": str(glb.relative_to(ROOT)).replace("\\", "/"),
|
|
"dest": str(dest.relative_to(ROOT)).replace("\\", "/"),
|
|
}
|
|
summary.append(rec)
|
|
print(json.dumps({"event": "part_done", **rec}, ensure_ascii=False), flush=True)
|
|
|
|
out = ROOT / "Tools/output/townsquare_pipeline_summary.json"
|
|
out.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print(json.dumps({"ok": True, "summary": summary}, ensure_ascii=False), flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except Exception as exc:
|
|
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), flush=True)
|
|
raise
|