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.
493 lines
20 KiB
Python
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())
|