"""Convert a downloaded model (glb/gltf/fbx/obj/blend) into a game-ready car GLB.
Run: /Applications/Blender.app/Contents/MacOS/Blender -b -P tools/import_shitbox.py -- \
"" [yaw_deg]
Normalizes: uniform scale so the longest horizontal extent = target length,
optional yaw (degrees) so the nose faces +Y (Godot -Z), centered, ground at
-0.75 (the controller's ride height). Exports cars//car.glb.
ponytail: imported models have no det_* panels, so they don't shed parts --
cutting doors out of downloaded meshes is per-model surgery for later.
"""
import math
import os
import sys
import bpy
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
src, car_id, target_len = argv[0], argv[1], float(argv[2])
yaw = math.radians(float(argv[3])) if len(argv) > 3 else 0.0
pitch = math.radians(float(argv[4])) if len(argv) > 4 else 0.0
ext = os.path.splitext(src)[1].lower()
if ext == ".blend":
bpy.ops.wm.open_mainfile(filepath=src)
else:
bpy.ops.wm.read_factory_settings(use_empty=True)
if ext in (".glb", ".gltf"):
bpy.ops.import_scene.gltf(filepath=src)
elif ext == ".fbx":
bpy.ops.import_scene.fbx(filepath=src)
elif ext == ".obj":
bpy.ops.wm.obj_import(filepath=src)
else:
raise SystemExit("unsupported: " + ext)
for obj in list(bpy.data.objects):
if obj.type not in ("MESH", "EMPTY"):
bpy.data.objects.remove(obj, do_unlink=True)
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
assert meshes, "no meshes found"
root = bpy.data.objects.new("body", None)
bpy.context.collection.objects.link(root)
for obj in bpy.data.objects:
if obj is not root and obj.parent is None:
mw = obj.matrix_world.copy()
obj.parent = root
obj.matrix_world = mw
def bbox():
lo = Vector((1e9, 1e9, 1e9))
hi = Vector((-1e9, -1e9, -1e9))
dg = bpy.context.evaluated_depsgraph_get()
for o in meshes:
for c in o.evaluated_get(dg).bound_box:
w = o.matrix_world @ Vector(c)
lo = Vector(map(min, lo, w))
hi = Vector(map(max, hi, w))
return lo, hi
root.rotation_euler = (pitch, 0, yaw) # XYZ order: pitch applies before yaw
bpy.context.view_layer.update()
lo, hi = bbox()
size = hi - lo
scale = target_len / max(size.x, size.y)
root.scale = (scale,) * 3
bpy.context.view_layer.update()
lo, hi = bbox()
center = (lo + hi) / 2
root.location = Vector((-center.x, -center.y, -lo.z - 0.75))
bpy.context.view_layer.update()
# cap textures at 1K -- these packs ship 4K maps, 16x the pixels a shitbox needs
for img in bpy.data.images:
if img.size[0] > 1024 or img.size[1] > 1024:
img.scale(min(img.size[0], 1024), min(img.size[1], 1024))
# cap geometry -- some packs are near-scan density; a shitbox needs 150k tris tops
BUDGET = 150_000
total = sum(len(o.data.polygons) for o in meshes)
if total > BUDGET:
for o in meshes:
mod = o.modifiers.new("dec", "DECIMATE")
mod.ratio = BUDGET / total
print("decimating %d -> %d tris" % (total, BUDGET))
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cars", car_id)
os.makedirs(out, exist_ok=True)
for obj in bpy.data.objects:
obj.select_set(True)
path = os.path.join(out, "car.glb")
bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=True, export_apply=True)
lo, hi = bbox()
print("imported %s -> %s | size %.2f x %.2f x %.2f" % (src, path, hi.x - lo.x, hi.y - lo.y, hi.z - lo.z))