Explosion_der_Spiele/scripts/tools/organize_3d_models.py
copilot 1cc8aa9b9d Add village 3D wiring, voice lines, and 1.74 m world scale.
Houses ~8.5 m; chimps capped ~0.85 m with safer axis fitting. Large GLB/PBR packs stay local until Git LFS (Forgejo HTTP 413).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:02:57 +02:00

312 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Auto-organize GLBs dropped into assets/3DModelle/.
Drop/rename freely in the folder root or `_inbox/`. After files sit unchanged
for `--stable-secs` (default 45), they get classified into props/houses/animals,
related textures move along, and `.import` source paths are fixed.
Usage:
python3 scripts/tools/organize_3d_models.py --watch
python3 scripts/tools/organize_3d_models.py --force --import
python3 scripts/tools/organize_3d_models.py --dry-run
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MODELS = ROOT / "assets" / "3DModelle"
INBOX = MODELS / "_inbox"
ANIMAL_WORDS = (
"cat", "chimp", "chimpanzee", "ape", "gorilla", "giraffe", "snail",
"spider", "cow", "pig", "bear", "dog", "chicken", "rooster", "snake",
"horse", "sheep", "goat", "duck", "fish", "bird", "monkey",
)
def log(msg: str) -> None:
print(msg, flush=True)
def classify(glb_filename: str) -> tuple[Path, str]:
"""Return (dest_dir relative to MODELS, target_filename)."""
stem = Path(glb_filename).stem
low = stem.lower()
if "massour" in low or "masseur" in low:
return Path("houses/massour"), "massourhaus.glb"
if "broken" in low and ("house" in low or "haus" in low or low == "brokenhouse"):
return Path("houses/broken"), "BrokenHouse.glb"
if "house" in low or "haus" in low:
# house_b / house-c / houseB
m = re.search(r"house[_\-]?([a-z0-9]{1,4})$", low)
if m and m.group(1) not in ("glb", "pbr"):
folder = f"house_{m.group(1)}"
return Path("houses") / folder, f"{folder}.glb"
if re.fullmatch(r"house", low) or low.startswith("house_texture"):
return Path("houses/house"), "house.glb"
# unknown house-like → own folder from cleaned stem
clean = re.sub(r"[^a-z0-9_]+", "_", low).strip("_")
return Path("houses") / clean, f"{clean}.glb"
for key, folder, fname in (
("button", "button", "Button.glb"),
("knopf", "button", "Button.glb"),
("table", "table", "table.glb"),
("tisch", "table", "table.glb"),
("door", "door", "door.glb"),
("stuhl", "chair", "chair.glb"),
("chair", "chair", "chair.glb"),
):
if key in low:
return Path("props") / folder, fname
for w in ANIMAL_WORDS:
if w in low:
folder = {
"chimpanzee": "chimp",
"ape": "chimp",
"monkey": "chimp",
}.get(w, w)
# prefer explicit chimp/gorilla in name
if "gorilla" in low:
folder = "gorilla"
elif "chimp" in low or "chimpanzee" in low:
folder = "chimp"
# cow/ → Masseur only (village skips this folder)
return Path("animals") / folder, f"{folder}.glb"
# vendor-long name → last alpha token
clean = stem
if len(stem) > 24 and ("-" in stem or "_" in stem):
parts = re.split(r"[-_]+", stem)
for part in reversed(parts):
if part.isalpha() and len(part) >= 3:
clean = part.lower()
break
clean = re.sub(r"[^a-zA-Z0-9_]+", "_", clean).strip("_") or "model"
return Path("props") / clean.lower(), f"{clean}.glb"
def related_files(glb: Path) -> list[Path]:
parent = glb.parent
stem = glb.stem
prefixes = {stem, stem.lower()}
# base house textures: house_Fence, house_texture…
if stem.lower() == "house":
prefixes.add("house_")
out: list[Path] = []
for p in parent.iterdir():
if not p.is_file() or p == glb:
continue
if p.suffix.lower() == ".glb":
continue
if p.name == glb.name + ".import":
out.append(p)
continue
low = p.name.lower()
for pref in prefixes:
pl = pref.lower()
if low.startswith(pl):
# don't steal house_b_* for house.glb
if pl == "house_" and low.startswith("house_b"):
continue
out.append(p)
break
return out
def fix_import_paths(folder: Path) -> None:
for imp in folder.rglob("*.import"):
src = Path(str(imp)[:-7])
if not src.exists():
continue
rel = "res://" + str(src.relative_to(ROOT)).replace("\\", "/")
text = imp.read_text(encoding="utf-8")
lines: list[str] = []
changed = False
for line in text.splitlines(True):
if line.startswith("source_file="):
new = f'source_file="{rel}"\n'
changed = changed or line != new
lines.append(new)
else:
lines.append(line)
if changed:
imp.write_text("".join(lines), encoding="utf-8")
def move_into(src: Path, dest_dir: Path, dest_name: str | None = None) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / (dest_name or src.name)
if dest.exists() and src.resolve() == dest.resolve():
return dest
if dest.exists():
log(f" keep existing {dest.relative_to(MODELS)}")
if src.exists() and src.resolve() != dest.resolve():
# leave duplicate source — user can delete
log(f" (source still at {src.relative_to(MODELS)})")
return dest
shutil.move(str(src), str(dest))
return dest
def organize_glb(glb: Path, dry_run: bool = False) -> Path | None:
rel_dir, target_name = classify(glb.name)
dest_dir = MODELS / rel_dir
log(f"organize {glb.name} → {rel_dir}/{target_name}")
if dry_run:
return dest_dir / target_name
extras = related_files(glb)
dest_glb = move_into(glb, dest_dir, target_name)
# imports / textures that were next to old location
for extra in extras:
if not extra.exists():
continue
if extra.name.endswith(".import") and extra.name.startswith(Path(glb.name).stem):
# rename import to match new glb name if needed
if extra.name == glb.name + ".import" and target_name != glb.name:
move_into(extra, dest_dir, target_name + ".import")
continue
move_into(extra, dest_dir)
# stray import at old path
old_imp = Path(str(glb) + ".import")
if old_imp.exists():
move_into(old_imp, dest_dir, target_name + ".import")
fix_import_paths(dest_dir)
return dest_glb
def root_glbs() -> list[Path]:
out: list[Path] = []
for base in (MODELS, INBOX):
if not base.is_dir():
continue
for p in base.iterdir():
if p.is_file() and p.suffix.lower() == ".glb":
out.append(p)
return out
def bundle_mtime(glb: Path) -> float:
mt = glb.stat().st_mtime
for p in related_files(glb):
try:
mt = max(mt, p.stat().st_mtime)
except OSError:
pass
return mt
def organize_orphans(dry_run: bool = False) -> int:
"""Loose textures at MODELS root → matching model folder or props/_loose."""
n = 0
for p in list(MODELS.iterdir()):
if not p.is_file():
continue
if p.suffix.lower() == ".glb" or p.name.endswith(".import") or p.name == "README.md":
continue
low = p.name.lower()
target: Path | None = None
for glb in MODELS.rglob("*.glb"):
stem = glb.stem.lower()
if low.startswith(stem):
if stem == "house" and low.startswith("house_b"):
continue
target = glb.parent
break
if target is None:
target = MODELS / "props" / "_loose"
log(f"orphan {p.name} → {target.relative_to(MODELS)}")
if not dry_run:
move_into(p, target)
imp = MODELS / (p.name + ".import")
if imp.exists():
move_into(imp, target)
fix_import_paths(target)
n += 1
return n
def organize_stable(stable_secs: float, dry_run: bool = False) -> int:
now = time.time()
n = 0
for glb in root_glbs():
age = now - bundle_mtime(glb)
if age < stable_secs:
log(f"wait {glb.name} ({stable_secs - age:.0f}s left)")
continue
organize_glb(glb, dry_run=dry_run)
n += 1
n += organize_orphans(dry_run=dry_run)
return n
def run_godot_import() -> None:
godot = Path(os.path.expanduser("~/.local/bin/godot"))
if not godot.exists():
which = shutil.which("godot")
godot = Path(which) if which else Path()
if not godot.exists():
log("godot not found — skip import")
return
log("godot --import …")
subprocess.run(
[str(godot), "--headless", "--path", str(ROOT), "--import"],
check=False,
)
def watch_loop(stable_secs: float, interval: float, do_import: bool) -> int:
INBOX.mkdir(parents=True, exist_ok=True)
log(f"watching {MODELS} + _inbox/ — organize after {stable_secs:.0f}s idle")
try:
while True:
n = organize_stable(stable_secs, dry_run=False)
if n > 0 and do_import:
run_godot_import()
time.sleep(interval)
except KeyboardInterrupt:
log("stopped")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--watch", action="store_true")
ap.add_argument("--stable-secs", type=float, default=45.0)
ap.add_argument("--interval", type=float, default=5.0)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--import", dest="do_import", action="store_true")
ap.add_argument("--force", action="store_true", help="Ignore debounce (stable-secs=0)")
args = ap.parse_args()
if not MODELS.is_dir():
log(f"missing {MODELS}")
return 1
stable = 0.0 if args.force else args.stable_secs
if args.watch:
# watch always uses configured debounce (force only for one-shot)
return watch_loop(args.stable_secs, args.interval, args.do_import)
n = organize_stable(stable, dry_run=args.dry_run)
log(f"organized={n}")
if n > 0 and args.do_import and not args.dry_run:
run_godot_import()
return 0
if __name__ == "__main__":
raise SystemExit(main())