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