#!/usr/bin/env python3 """Turn the provided hot-hatch .blend into UE-ready assets. blender -b "Art/blender/vehicle/hot hatch.blend" \ --python Tools/blender/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 _HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) 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 _colour_rank(colour): """Sort key for picking a paint colour: saturation first, then brightness.""" return (max(colour) - min(colour), sum(colour)) def read_material(material): """Base colour, roughness and metallic for a material, Principled or legacy. The template's car paint is a **legacy node graph** - mix shaders over glossy and diffuse BSDFs - not a Principled shader. Reading only Principled skipped it, which is exactly why the car arrived in Unreal near-black: the paint colour never left Blender. For those graphs the colour is taken from the mix nodes, which is where the red lives. """ if not material.use_nodes: return None nodes = material.node_tree.nodes bsdf = nodes.get("Principled BSDF") if bsdf: colour = bsdf.inputs["Base Color"].default_value return { "base_color": [round(colour[0], 4), round(colour[1], 4), round(colour[2], 4)], "roughness": round(bsdf.inputs["Roughness"].default_value, 4), "metallic": round(bsdf.inputs["Metallic"].default_value, 4), "source": "principled", } # Legacy graph: the largest colour found on a mix or diffuse node is the paint. best = None roughness = 0.5 metallic = 0.0 for node in nodes: if node.type in ("MIX", "MIX_RGB"): for socket in node.inputs: try: value = socket.default_value except Exception: # noqa: BLE001 - not every socket has a default continue if not hasattr(value, "__len__") or len(value) < 3: continue candidate = (value[0], value[1], value[2]) if max(candidate) < 0.03: continue # Prefer the most saturated colour, not the brightest: the paint's red # (0.49, 0, 0) is darker than the 0.8 grey diffuse beside it, and a # "brightest wins" rule picked the grey - which is how a red car reaches # Unreal as a grey one. if best is None or _colour_rank(candidate) > _colour_rank(best): best = candidate elif node.type == "BSDF_DIFFUSE": colour = node.inputs["Color"].default_value candidate = (colour[0], colour[1], colour[2]) if best is None or _colour_rank(candidate) > _colour_rank(best): best = candidate elif node.type == "BSDF_GLOSSY": try: roughness = round(node.inputs["Roughness"].default_value, 4) except Exception: # noqa: BLE001 pass if best is None: return None return { "base_color": [round(best[0], 4), round(best[1], 4), round(best[2], 4)], "roughness": roughness, "metallic": metallic, "source": "legacy graph", } def ensure_principled(material): """Rewire a legacy graph to a Principled BSDF so the FBX export carries a colour. The template's car paint is a mix-shader graph, and the FBX exporter cannot map that - which is why SM_Vehicle_Body arrived in Unreal holding `WorldGridMaterial` (the default fallback) and rendered black while Blender showed red. Converting in place keeps the colour and makes the export uniform. """ if not material.use_nodes: return False nodes = material.node_tree.nodes if nodes.get("Principled BSDF"): return False spec = read_material(material) if not spec: return False nodes.clear() output = nodes.new("ShaderNodeOutputMaterial") bsdf = nodes.new("ShaderNodeBsdfPrincipled") colour = spec["base_color"] bsdf.inputs["Base Color"].default_value = (colour[0], colour[1], colour[2], 1.0) bsdf.inputs["Roughness"].default_value = max(0.05, spec.get("roughness", 0.5)) bsdf.inputs["Metallic"].default_value = spec.get("metallic", 0.0) material.node_tree.links.new(bsdf.outputs["BSDF"], output.inputs["Surface"]) log(f"converted {material.name} to Principled, base " f"{tuple(round(c, 3) for c in colour)}") return True 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 min_z(objects): from mathutils import Vector lowest = None for obj in objects: for corner in obj.bound_box: z = (obj.matrix_world @ Vector(corner)).z lowest = z if lowest is None else min(lowest, z) return lowest if lowest is not None else 0.0 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. Ground on the WHEELS, not # on the whole model: the lowest geometry in the file is exhaust/underbody, so grounding # on everything left the tyres floating by the difference. 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) wheels = [o for o in objects if "rubber" in [m.name.lower() for m in o.data.materials if m]] ground = min_z(wheels) if wheels else min(zs) log(f"grounding on {'wheels' if wheels else 'whole model'}: lowest Z {ground:.3f} m") offset = __import__("mathutils").Vector( (-(min(xs) + max(xs)) / 2.0, -(min(ys) + max(ys)) / 2.0, -ground)) 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 build_skeletal(joined, args): """Bind the body + wheels to a root + 4 wheel bones for the Chaos vehicle. The hot hatch's nose is along +Y, so wheel front/rear is the Y sign and left/right is the X sign (the wheels are mirrored in the template, so the joined mesh has all four). Each wheel is weighted to its own bone at full weight; the body goes to the root. """ import math body = joined["body"] wheels = joined["wheels"] # Derive the four wheel centres from the wheel geometry: group vertices by quadrant. # After the Unreal-frame rotation, front/rear is the X sign and left/right is the Z sign. quadrants = {"FL": [], "FR": [], "RL": [], "RR": []} for vertex in wheels.data.vertices: world = wheels.matrix_world @ vertex.co key = ("F" if world.x > 0 else "R") + ("L" if world.z > 0 else "R") quadrants[key].append(world) centres = {} for key, verts in quadrants.items(): if not verts: continue centres[f"Wheel_{key}"] = ( sum(v.x for v in verts) / len(verts), sum(v.y for v in verts) / len(verts), sum(v.z for v in verts) / len(verts), ) if len(centres) != 4: raise SystemExit(f"expected 4 wheel quadrants, got {len(centres)}: {list(centres)}") log(f"wheel centres: " + ", ".join(f"{k}=({v[0]:.2f},{v[1]:.2f},{v[2]:.2f})" for k, v in sorted(centres.items()))) bpy.ops.object.armature_add(location=(0.0, 0.0, 0.0)) rig = bpy.context.active_object rig.name = "VehicleRig" bpy.ops.object.mode_set(mode="EDIT") edit_bones = rig.data.edit_bones root = edit_bones[0] root.name = "root" root.head = (0.0, 0.0, 0.0) root.tail = (0.0, 0.0, 0.30) for name, (x, y, z) in sorted(centres.items()): bone = edit_bones.new(name) bone.head = (x, y, z) # Axle direction is irrelevant to the Chaos solver (it drives the wheel about the # vehicle's Y), so point the tail straight down for a stable reference frame. bone.tail = (x, y, z - 0.25) bone.parent = root bpy.ops.object.mode_set(mode="OBJECT") # Join the body and wheels into one skinned mesh. bpy.ops.object.select_all(action="DESELECT") body.select_set(True) wheels.select_set(True) bpy.context.view_layer.objects.active = body bpy.ops.object.join() mesh = bpy.context.active_object mesh.name = "SM_Vehicle_Skeletal" groups = {name: mesh.vertex_groups.new(name=name) for name in ["root", *centres]} for vertex in mesh.data.vertices: world = mesh.matrix_world @ vertex.co nearest, best = "root", 0.6 ** 2 for name, (x, y, z) in centres.items(): d = (world.x - x) ** 2 + (world.y - y) ** 2 + (world.z - z) ** 2 if d < best: best, nearest = d, name groups[nearest].add([vertex.index], 1.0, "REPLACE") armature = mesh.modifiers.new(name="Armature", type="ARMATURE") armature.object = rig mesh.parent = rig bpy.ops.object.select_all(action="DESELECT") rig.select_set(True) mesh.select_set(True) bpy.context.view_layer.objects.active = rig path = os.path.join(args.out, "vehicle_skeletal.fbx") fbx_export_preset.export_character(path, selection=True) log(f"skeletal vehicle exported: {path}, bones root + 4 wheels") return path 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) # Apply the MIRROR modifier so the left wheels exist in the mesh data BEFORE the nose # rotation; the mirror is in the object's local space, so rotating first would mirror # about a rotated axis. for obj in meshes: bpy.context.view_layer.objects.active = obj obj.select_set(True) for modifier in list(obj.modifiers): try: bpy.ops.object.modifier_apply(modifier=modifier.name) except RuntimeError: pass obj.select_set(False) # Rotate so the FBX axis conversion (Blender Y->Unreal -Z, Z->-Y) lands the car unrolled: # length along +X (forward), width along Y, height along Z. The mapping is X->-Z, Y->X, # Z->-Y, i.e. the rotation matrix [[0,1,0],[0,0,-1],[-1,0,0]]. from mathutils import Matrix rot = Matrix(( (0.0, 1.0, 0.0), (0.0, 0.0, -1.0), (-1.0, 0.0, 0.0), )) for obj in meshes: obj.rotation_euler = rot.to_euler() bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.object.transform_apply(location=False, rotation=True, scale=False) obj.select_set(False) bpy.context.view_layer.update() log("rotated to the Unreal frame (length +X, width +Y, height +Z)") 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) converted = sum(1 for m in bpy.data.materials if ensure_principled(m)) log(f"legacy materials converted for export: {converted}") materials = {} for material in bpy.data.materials: found = read_material(material) if found: materials[material.name] = found principled = sum(1 for m in bpy.data.materials if m.use_nodes and m.node_tree.nodes.get("Principled BSDF")) log(f"materials: {len(materials)} read, {principled} of them Principled, " f"{len(materials) - principled} from legacy graphs") report = { "template": bpy.data.filepath, "materials": materials, "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}") # Build the skeletal (physics) mesh last, after the report has recorded the separate # systems: build_skeletal joins the body and wheels, so it must not run before the # static exports and report are written. build_skeletal(joined, args) return 0 if __name__ == "__main__": raise SystemExit(main())