#!/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 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) 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}") return 0 if __name__ == "__main__": raise SystemExit(main())