Files
gig-line/Tools/blender/vehicle_from_template.py
T
zwitschi 63ba9d91ce feat(vehicle): build the skeletal mesh from the hot hatch
vehicle_from_template.py now authors the physics mesh from the real hot hatch: apply
the mirror/nodes modifiers, derive the 4 wheel centres, add a root + 4 wheel bones,
weight and export vehicle_skeletal.fbx. A rotation orients the car so it imports
unrolled (length +X, width +Y, height +Z); wheel bones land at front +X, rear -X,
left/right +/-Y. The vehicle still floats (rises to 33 m) - recorded in MEMORY.
2026-11-10 20:47:54 +01:00

493 lines
20 KiB
Python

#!/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())