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>
312 lines
8.9 KiB
Python
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())
|