Files
gig-line/Tools/blender/vehicle_from_template.py
zwitschi dbb42322ff fix(tools): make the e2e chain actually run after the move, and record what it found
The e2e asset check now completes: PASS on all six steps - empty maps 9 s, demo build 10 s, review build 9 s,
captures 80 s, judge 388 s, review file. Four breakages from the folder move had to be fixed first:

- ue_placement and ue_common are siblings of the scripts that import them, but the rewrite put only the tools
  root on sys.path, so every Unreal commandlet died with ModuleNotFoundError. Both paths are inserted now.
- render_screenshots.sh resolved the repository root one level up, so commandlet paths became Tools/Tools/...
  and the capture step failed in 0 s.
- the driver treated a result file left by an earlier run as success, which is how a ten-second 'demo build'
  passed without building. Result files are unlinked before each step and child output streams to a per-step
  log file rather than a pipe.
- two editors on one project hang instead of failing, so the driver refuses to start while one is running.

Also corrects ue_recreate_maps.py: LevelEditorSubsystem.new_level() refuses a path whose package is still in
memory and delete_asset() leaves the file on disk, measured create->delete->create returning False. The step
now loads each map, destroys every actor, saves, and reports the count it left (0), which is the property the
builders actually need.

Open question recorded in TODO.md: the rebuilt map holds 409 actors and 613 KB against 600 and 886 KB before,
and the ~190 missing ones look like the facade-kit and theatre-trim placements that run as separate steps.
2026-11-10 13:37:21 +01:00

371 lines
15 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 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())