#!/usr/bin/env python3 """Turn the provided hot-hatch .blend into UE-ready assets. blender -b "Art/blender/vehicle/hot hatch.blend" \ --python Tools/vehicle_from_template.py -- \ --out /tmp/gigline/hatch --report /tmp/gigline/hatch/report.json Follows two skills: * **vehicle-artist** — one object per system (body, wheels, glass, lights, trim, mirrors), collision planned with the body, LODs planned before export. * **unreal-export** — scale confirmed with a known dimension, `SM_`/`UCX_` naming, LOD chain, and every asset measured after import. The template is not to scale: its longest axis is 7.21 m, which no hot hatch is. The tool therefore normalises to a documented real length (default 4.05 m) and records the factor, rather than exporting whatever units the file happened to use. That is the "confirm scale with known-dimension test" step, done before the export instead of after the import. """ from __future__ import annotations import argparse import json import os import sys import bpy sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import fbx_export_preset # noqa: E402 # A three-door hot hatch: 4.05 m long, 1.75 m wide, 1.42 m tall. TARGET_LENGTH_M = 4.05 TARGET_WIDTH_M = 1.75 TARGET_HEIGHT_M = 1.42 PARTS: list[str] = [] def log(message: str) -> None: PARTS.append(message) print(f"[hatch] {message}") SYSTEM_RULES = ( # (system, material substrings that identify it, object-name substrings) ("glass", ("windows",), ("Cube.003",)), ("lights", ("light", "reflector"), ("lights and grill", "Cylinder.003", "Cylinder.004")), ("mirrors", ("mirror",), ("Cube.005",)), ("wheels", ("rubber", "brake caliper", "black shiny metal"), ("Cylinder", "Cylinder.001")), ("trim", ("plastic black", "pure black", "chrome", "black metal", "rough chrome"), ("Cube.001", "Cube.002", "Cube.004", "Cube.006", "Cube.007", "Cylinder.002")), ) def classify(obj): """Assign an object to a system by its materials first, then by its name.""" materials = [m.name.lower() for m in obj.data.materials if m] name = obj.name.lower() for system, materials_hint, names_hint in SYSTEM_RULES: if any(hint in name for hint in (n.lower() for n in names_hint)): return system if any(any(h in material for h in materials_hint) for material in materials): return system return "body" def normalise_scale(objects): """Scale and centre the whole car so it matches the documented dimensions.""" xs, ys, zs = [], [], [] for obj in objects: for corner in obj.bound_box: world = obj.matrix_world @ __import__("mathutils").Vector(corner) xs.append(world.x) ys.append(world.y) zs.append(world.z) length = max(ys) - min(ys) width = max(xs) - min(xs) height = max(zs) - min(zs) log(f"template bounds: {length:.2f} x {width:.2f} x {height:.2f} m") factor = TARGET_LENGTH_M / length if length > 0.01 else 1.0 log(f"scale factor {factor:.4f} (target length {TARGET_LENGTH_M} m, " f"was {length:.2f} m)") for obj in objects: obj.scale = tuple(s * factor for s in obj.scale) obj.location = tuple(l * factor for l in obj.location) bpy.context.view_layer.update() # Drop the car onto Z 0 and centre it on the origin in X/Y. xs, ys, zs = [], [], [] for obj in objects: for corner in obj.bound_box: world = obj.matrix_world @ __import__("mathutils").Vector(corner) xs.append(world.x) ys.append(world.y) zs.append(world.z) offset = __import__("mathutils").Vector( (-(min(xs) + max(xs)) / 2.0, -(min(ys) + max(ys)) / 2.0, -min(zs))) for obj in objects: obj.location = tuple(l + o for l, o in zip(obj.location, offset)) bpy.context.view_layer.update() log(f"centred and grounded (offset {offset.x:.2f}, {offset.y:.2f}, {offset.z:.2f} m)") def join_system(objects, name): bpy.ops.object.select_all(action="DESELECT") for obj in objects: obj.select_set(True) bpy.context.view_layer.objects.active = objects[0] if len(objects) > 1: bpy.ops.object.join() joined = bpy.context.active_object joined.name = name return joined def add_collision(body): """One convex box per major volume, `UCX_`-prefixed for Unreal.""" hulls = [] xs, ys, zs = [], [], [] for corner in body.bound_box: world = body.matrix_world @ __import__("mathutils").Vector(corner) xs.append(world.x) ys.append(world.y) zs.append(world.z) length, width, height = max(ys) - min(ys), max(xs) - min(xs), max(zs) - min(zs) centre = ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0, min(zs) + height * 0.5) bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre) hull = bpy.context.active_object hull.name = "UCX_SM_Vehicle_Body_01" hull.scale = (width * 0.96, length * 0.98, height * 0.62) bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) hull.hide_render = True hulls.append(hull) log(f"collision: 1 hull {width*0.96:.2f} x {length*0.98:.2f} x {height*0.62:.2f} m") return hulls def decimate_copy(source, name, ratio): bpy.ops.object.select_all(action="DESELECT") source.select_set(True) bpy.context.view_layer.objects.active = source bpy.ops.object.duplicate() copy = bpy.context.active_object copy.name = name modifier = copy.modifiers.new(name="LOD", type="DECIMATE") modifier.ratio = ratio bpy.ops.object.modifier_apply(modifier=modifier.name) return copy def main(argv=None) -> int: argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--out", default="/tmp/gigline/hatch") ap.add_argument("--report", default="/tmp/gigline/hatch/report.json") args = ap.parse_args(argv) meshes = [o for o in bpy.data.objects if o.type == "MESH"] if not meshes: raise SystemExit("no meshes in the template") log(f"template: {len(meshes)} mesh objects, " f"{sum(len(o.data.vertices) for o in meshes)} vertices") normalise_scale(meshes) systems = {} for obj in meshes: systems.setdefault(classify(obj), []).append(obj) log("systems: " + ", ".join(f"{k}={len(v)} objects" for k, v in systems.items())) os.makedirs(args.out, exist_ok=True) exported = {} joined = {} for system, objects in systems.items(): name = { "body": "SM_Vehicle_Body", "glass": "SM_Vehicle_Glass", "lights": "SM_Vehicle_Lights", "mirrors": "SM_Vehicle_Mirrors", "wheels": "SM_Vehicle_Wheels", "trim": "SM_Vehicle_Trim", }[system] obj = join_system(objects, name) joined[system] = obj path = os.path.join(args.out, f"{system}.fbx") bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) bpy.context.view_layer.objects.active = obj fbx_export_preset.export_static(path, selection=True) exported[system] = path tris = sum(len(p.vertices) - 2 for p in obj.data.polygons) log(f"{name}: {tris} triangles -> {path}") hulls = add_collision(joined["body"]) bpy.ops.object.select_all(action="DESELECT") joined["body"].select_set(True) for hull in hulls: hull.select_set(True) bpy.context.view_layer.objects.active = joined["body"] fbx_export_preset.export_static(os.path.join(args.out, "body.fbx"), selection=True) log("body re-exported with its UCX hull") lods = {} for index, ratio in ((1, 0.35), (2, 0.12)): lod = decimate_copy(joined["body"], f"SM_Vehicle_Body_LOD{index}", ratio) path = os.path.join(args.out, f"body_lod{index}.fbx") bpy.ops.object.select_all(action="DESELECT") lod.select_set(True) bpy.context.view_layer.objects.active = lod fbx_export_preset.export_static(path, selection=True) tris = sum(len(p.vertices) - 2 for p in lod.data.polygons) lods[f"body_lod{index}"] = {"path": path, "triangles": tris} log(f"SM_Vehicle_Body_LOD{index}: {tris} triangles") bpy.data.objects.remove(lod, do_unlink=True) report = { "template": bpy.data.filepath, "target_dimensions_m": {"length": TARGET_LENGTH_M, "width": TARGET_WIDTH_M, "height": TARGET_HEIGHT_M}, "systems": {system: {"object": joined[system].name, "triangles": sum(len(p.vertices) - 2 for p in joined[system].data.polygons), "fbx": exported.get(system)} for system in joined}, "lods": lods, "collision": [h.name for h in hulls], "log": PARTS, } os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True) with open(args.report, "w") as handle: json.dump(report, handle, indent=2) print(f"[hatch] report -> {args.report}") return 0 if __name__ == "__main__": raise SystemExit(main())