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.
418 lines
18 KiB
Python
418 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Model a drivable sedan in Blender: body, wheels, collision and LOD.
|
|
|
|
blender -b --factory-startup --python Tools/blender/vehicle_build.py -- \
|
|
--out Art/blender/vehicle/gigline-sedan.blend \
|
|
--fbx /tmp/gigline/vehicle/sedan.fbx \
|
|
--report /tmp/gigline/vehicle/report.json
|
|
|
|
Scale is metres, sourced from real sedan dimensions: 4.72 m long, 1.83 m wide,
|
|
1.44 m tall, 2.78 m wheelbase. Wheels are separate objects so Chaos can attach
|
|
PhysicsAssets-lite wheel references and so the wheel radius the vehicle config needs is
|
|
read from the mesh rather than typed in twice.
|
|
|
|
Collision is `UCX_`-prefixed convex boxes per panel group, which is what the
|
|
`collision-proxy` skill asks for and what Unreal's `ue_metric_v1` preset expects: one
|
|
hull for the body, one per wheel-less corner trimmed, and a floor slab. Simplified
|
|
hulls are deliberate — a car does not need per-panel collision for driving.
|
|
|
|
Every measurement is logged and written to the report, so the vehicle config values
|
|
(wheelbase, track, radius) can be read from the build instead of guessed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
|
|
import bpy
|
|
from mathutils import Vector
|
|
|
|
_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
|
|
|
|
# Real sedan reference: length, width, height, wheelbase, track, wheel radius.
|
|
DIMS = {
|
|
"length": 4.72,
|
|
"width": 1.80,
|
|
"height": 1.44,
|
|
"wheelbase": 2.78,
|
|
"track": 1.62,
|
|
"wheel_radius": 0.33,
|
|
"wheel_width": 0.22,
|
|
"ride_height": 0.16,
|
|
}
|
|
|
|
PARTS: list[str] = []
|
|
|
|
|
|
def log(message: str) -> None:
|
|
PARTS.append(message)
|
|
print(f"[vehicle] {message}")
|
|
|
|
|
|
def material(name, colour, roughness=0.45, metallic=0.0):
|
|
mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
|
|
mat.use_nodes = True
|
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
if bsdf:
|
|
bsdf.inputs["Base Color"].default_value = (*colour, 1.0)
|
|
bsdf.inputs["Roughness"].default_value = roughness
|
|
bsdf.inputs["Metallic"].default_value = metallic
|
|
return mat
|
|
|
|
|
|
def box(name, centre, size, mat):
|
|
bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre)
|
|
obj = bpy.context.active_object
|
|
obj.name = name
|
|
obj.scale = size
|
|
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
|
obj.data.materials.append(mat)
|
|
return obj
|
|
|
|
|
|
def build_body(mats):
|
|
"""Three-tier silhouette: sill slab, bonnet and boot shoulders, cabin greenhouse.
|
|
|
|
The first build was one tall slab plus a floating cabin, which read as a box truck.
|
|
A sedan reads from the step between the bonnet and the roof, so the volumes are
|
|
stacked instead: a slab to beltline height, a lower bonnet in front and boot behind,
|
|
and the cabin only over the middle.
|
|
"""
|
|
L, W, H = DIMS["length"], DIMS["width"], DIMS["height"]
|
|
ride = DIMS["ride_height"]
|
|
belt = 0.98 # beltline height above ground
|
|
roof = H # 1.44
|
|
parts = []
|
|
|
|
# Sill slab: full length, narrow enough that the wheels stand proud of it.
|
|
parts.append(box("Body_Sill", (0.0, 0.0, ride + (belt - ride) * 0.5),
|
|
(L, 1.56, belt - ride), mats["paint"]))
|
|
# Bonnet: front 1.55 m, from beltline to a lower bonnet top.
|
|
parts.append(box("Body_Bonnet", (L * 0.5 - 0.78, 0.0, (belt + 1.06) * 0.5),
|
|
(1.56, W * 0.94, 1.06 - belt), mats["paint"]))
|
|
# Boot: rear 1.05 m, slightly lower than the bonnet.
|
|
parts.append(box("Body_Boot", (-L * 0.5 + 0.52, 0.0, (belt + 1.02) * 0.5),
|
|
(1.04, W * 0.94, 1.02 - belt), mats["paint"]))
|
|
# Cabin: the greenhouse, between bonnet and boot, narrower than the sills.
|
|
parts.append(box("Body_Cabin", (-0.02, 0.0, (1.06 + roof) * 0.5),
|
|
(2.02, W * 0.86, roof - 1.06), mats["paint"]))
|
|
|
|
# Glazing, inset a couple of centimetres so it reads as glass, not paint.
|
|
parts.append(box("Glass_Windscreen", (0.96, 0.0, 1.24), (0.05, W * 0.78, 0.30), mats["glass"]))
|
|
parts.append(box("Glass_Rear", (-1.00, 0.0, 1.24), (0.05, W * 0.76, 0.28), mats["glass"]))
|
|
for side in (-1.0, 1.0):
|
|
parts.append(box(f"Glass_Side_{'L' if side > 0 else 'R'}",
|
|
(-0.02, side * W * 0.43, 1.24), (1.76, 0.02, 0.26), mats["glass"]))
|
|
|
|
# Lamps, grille, bumpers, mirrors.
|
|
for side in (-1.0, 1.0):
|
|
parts.append(box(f"Lamp_Front_{'L' if side > 0 else 'R'}",
|
|
(L * 0.5 - 0.03, side * W * 0.32, 0.94), (0.05, 0.28, 0.12), mats["lamp"]))
|
|
parts.append(box(f"Lamp_Rear_{'L' if side > 0 else 'R'}",
|
|
(-L * 0.5 + 0.03, side * W * 0.32, 0.94), (0.04, 0.26, 0.10), mats["tail"]))
|
|
parts.append(box(f"Mirror_{'L' if side > 0 else 'R'}",
|
|
(0.80, side * (W * 0.5 + 0.05), 1.18), (0.08, 0.12, 0.07), mats["trim"]))
|
|
parts.append(box("Grille", (L * 0.5 - 0.02, 0.0, 0.80), (0.04, W * 0.48, 0.12), mats["trim"]))
|
|
for front in (1.0, -1.0):
|
|
parts.append(box(f"Bumper_{'F' if front > 0 else 'R'}",
|
|
(front * (L * 0.5 - 0.06), 0.0, ride + 0.14),
|
|
(0.16, W * 0.90, 0.20), mats["trim"]))
|
|
log(f"body: {len(parts)} parts, {L} x {W} x {H} m, beltline {belt} m")
|
|
return parts
|
|
|
|
|
|
def build_wheels(mats):
|
|
r, w = DIMS["wheel_radius"], DIMS["wheel_width"]
|
|
half_base = DIMS["wheelbase"] * 0.5
|
|
half_track = DIMS["track"] * 0.5
|
|
wheels = []
|
|
for name, x, y in (("Wheel_FL", half_base, half_track),
|
|
("Wheel_FR", half_base, -half_track),
|
|
("Wheel_RL", -half_base, half_track),
|
|
("Wheel_RR", -half_base, -half_track)):
|
|
bpy.ops.mesh.primitive_cylinder_add(vertices=20, radius=r, depth=w,
|
|
location=(x, y, r), rotation=(0.0, math.pi / 2, 0.0))
|
|
wheel = bpy.context.active_object
|
|
wheel.name = name
|
|
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
|
|
wheel.data.materials.append(mats["tyre"])
|
|
wheels.append(wheel)
|
|
log(f"wheels: 4 at r={r} m, width {w} m, wheelbase {DIMS['wheelbase']} m, track {DIMS['track']} m")
|
|
return wheels
|
|
|
|
|
|
def build_collision(mats):
|
|
"""One convex hull per major volume: body, cabin, and the four wheel arches.
|
|
|
|
Unreal reads `UCX_<mesh>_NN` names; the exporter ships them as separate meshes and
|
|
the import preset turns them into simple collision.
|
|
"""
|
|
L, W, H = DIMS["length"], DIMS["width"], DIMS["height"]
|
|
ride = DIMS["ride_height"]
|
|
hulls = [
|
|
box("UCX_Sedan_Body_01", (0.0, 0.0, ride + 0.40), (L * 0.98, W * 0.98, 0.70), mats["collision"]),
|
|
box("UCX_Sedan_Cabin_01", (-0.12, 0.0, ride + 0.94), (L * 0.44, W * 0.86, 0.42), mats["collision"]),
|
|
box("UCX_Sedan_BumperF_01", (L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]),
|
|
box("UCX_Sedan_BumperR_01", (-L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]),
|
|
]
|
|
for hull in hulls:
|
|
hull.hide_render = True
|
|
hull.display_type = "WIRE"
|
|
log(f"collision: {len(hulls)} convex hulls (body, cabin, two bumpers), hidden from render")
|
|
return hulls
|
|
|
|
|
|
def build_skeletal(joined, wheels, out_fbx, exp_dir):
|
|
"""Bind the whole vehicle to a five-bone rig for Chaos.
|
|
|
|
Chaos Vehicles drive a *skeletal* mesh: the movement component names wheel bones and
|
|
spins them, and the physics asset supplies the collision. The static split above
|
|
stays for parts that do not deform, but the drivable asset has to be skeletal, so
|
|
this builds a rig with a root plus one bone per wheel and skins every wheel to its
|
|
own bone at full weight.
|
|
"""
|
|
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)
|
|
half_base = DIMS["wheelbase"] * 0.5
|
|
half_track = DIMS["track"] * 0.5
|
|
r = DIMS["wheel_radius"]
|
|
wheel_positions = {
|
|
"Wheel_FL": (half_base, half_track),
|
|
"Wheel_FR": (half_base, -half_track),
|
|
"Wheel_RL": (-half_base, half_track),
|
|
"Wheel_RR": (-half_base, -half_track),
|
|
}
|
|
for name, (x, y) in wheel_positions.items():
|
|
bone = edit_bones.new(name)
|
|
bone.head = (x, y, r)
|
|
# Point the bone along the axle: the wheel spins about its own Y.
|
|
bone.tail = (x, y - math.copysign(0.25, y), r)
|
|
bone.parent = root
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
|
|
# Join every visible system plus the wheels into one mesh, then weight by part.
|
|
parts = [obj for obj in joined.values()] + list(wheels)
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
for part in parts:
|
|
part.select_set(True)
|
|
bpy.context.view_layer.objects.active = joined["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", *wheel_positions]}
|
|
# Join order is preserved per part: re-derive which vertices belong to which wheel by
|
|
# their world position, since the join loses the object boundaries.
|
|
half_base = DIMS["wheelbase"] * 0.5
|
|
half_track = DIMS["track"] * 0.5
|
|
for vertex in mesh.data.vertices:
|
|
co = vertex.co
|
|
nearest = None
|
|
for name, (x, y) in wheel_positions.items():
|
|
if abs(co.x - x) < 0.45 and abs(co.y - y) < 0.16 and co.z < DIMS["wheel_radius"] * 2.2:
|
|
nearest = name
|
|
break
|
|
groups[nearest or "root"].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(exp_dir, "vehicle_skeletal.fbx")
|
|
fbx_export_preset.export_character(path, selection=True)
|
|
log(f"skeletal vehicle exported: {path}, bones root + 4 wheels, "
|
|
f"{sum(len(p.vertices) - 2 for p in mesh.data.polygons)} triangles")
|
|
return path
|
|
|
|
|
|
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="Art/blender/vehicle/gigline-sedan.blend")
|
|
ap.add_argument("--fbx", default="/tmp/gigline/vehicle/sedan.fbx")
|
|
ap.add_argument("--report", default="/tmp/gigline/vehicle/report.json")
|
|
args = ap.parse_args(argv)
|
|
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
mats = {
|
|
"paint": material("M_Vehicle_Paint", (0.42, 0.05, 0.05), 0.28, 0.35),
|
|
"glass": material("M_Vehicle_Glass", (0.06, 0.08, 0.09), 0.12),
|
|
"tyre": material("M_Vehicle_Tyre", (0.02, 0.02, 0.02), 0.85),
|
|
"trim": material("M_Vehicle_Trim", (0.04, 0.04, 0.04), 0.55),
|
|
"lamp": material("M_Vehicle_Lamp", (0.85, 0.82, 0.70), 0.18),
|
|
"tail": material("M_Vehicle_Tail", (0.45, 0.03, 0.03), 0.25),
|
|
"collision": material("M_Vehicle_Collision", (0.2, 0.2, 0.2), 0.9),
|
|
}
|
|
|
|
body = build_body(mats)
|
|
wheels = build_wheels(mats)
|
|
hulls = build_collision(mats)
|
|
|
|
# Object split per the vehicle-artist skill: body, glass, lights and wheels stay
|
|
# separate so each can be swapped, made transparent or made emissive on its own. A
|
|
# single merged mesh is exactly what the skill forbids for a game vehicle.
|
|
systems = {}
|
|
for part in body:
|
|
name = part.name
|
|
if name.startswith("Glass_"):
|
|
key = "glass"
|
|
elif name.startswith("Lamp_"):
|
|
key = "light_front"
|
|
elif name.startswith("Lamp_Rear") or name.startswith("Lamp_Rear_"):
|
|
key = "light_rear"
|
|
elif name.startswith("Mirror_"):
|
|
key = "mirrors"
|
|
else:
|
|
key = "body"
|
|
systems.setdefault(key, []).append(part)
|
|
# Lamps were named lamp_front/lamp_rear by prefix; separate them properly.
|
|
rear = [p for p in systems.pop("light_front", []) if "Rear" in p.name]
|
|
front = [p for p in systems.get("body", []) if False]
|
|
# Collect every lamp again from the body list by name.
|
|
lamps = [p for p in body if p.name.startswith("Lamp_")]
|
|
systems["light_front"] = [p for p in lamps if "Rear" not in p.name]
|
|
systems["light_rear"] = [p for p in lamps if "Rear" in p.name]
|
|
systems["body"] = [p for p in body if not p.name.startswith(
|
|
("Glass_", "Lamp_", "Mirror_"))]
|
|
|
|
joined = {}
|
|
for key, parts in systems.items():
|
|
if not parts:
|
|
continue
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
for part in parts:
|
|
part.select_set(True)
|
|
bpy.context.view_layer.objects.active = parts[0]
|
|
if len(parts) > 1:
|
|
bpy.ops.object.join()
|
|
obj = bpy.context.active_object
|
|
obj.name = {
|
|
"body": "SM_Vehicle_Body",
|
|
"glass": "SM_Vehicle_Glass",
|
|
"light_front": "SM_Vehicle_Light_Front",
|
|
"light_rear": "SM_Vehicle_Light_Rear",
|
|
"mirrors": "SM_Vehicle_Mirrors",
|
|
}[key]
|
|
joined[key] = obj
|
|
shell = joined["body"]
|
|
hull_join = hulls
|
|
log("systems: " + ", ".join(f"{k}={len(v)} parts" for k, v in systems.items()))
|
|
|
|
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
|
|
bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(args.out))
|
|
log(f"saved {args.out}")
|
|
|
|
# Export: shell + collision hulls in one file, wheels in their own file so Chaos
|
|
# can bind them to wheel bones or use them as visual wheels.
|
|
os.makedirs(os.path.dirname(os.path.abspath(args.fbx)), exist_ok=True)
|
|
exp_dir = os.path.dirname(os.path.abspath(args.fbx))
|
|
exported = {}
|
|
for key, obj in joined.items():
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
path = os.path.join(exp_dir, f"{key}.fbx")
|
|
fbx_export_preset.export_static(path, selection=True)
|
|
exported[key] = path
|
|
# Body carries the collision hulls so the hulls and the shell land in one asset,
|
|
# which is how Unreal wires UCX_ meshes to their mesh.
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
shell.select_set(True)
|
|
for hull in hull_join:
|
|
hull.select_set(True)
|
|
bpy.context.view_layer.objects.active = shell
|
|
fbx_export_preset.export_static(os.path.join(exp_dir, "body.fbx"), selection=True)
|
|
# One wheel asset, reused four times: Chaos binds the same wheel mesh per bone.
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
wheels[0].select_set(True)
|
|
bpy.context.view_layer.objects.active = wheels[0]
|
|
fbx_export_preset.export_static(os.path.join(exp_dir, "wheel.fbx"), selection=True)
|
|
exported["wheel"] = os.path.join(exp_dir, "wheel.fbx")
|
|
log(f"exported {len(exported)} system FBX files to {exp_dir}")
|
|
per_system = {key: sum(len(p.vertices) - 2 for p in obj.data.polygons)
|
|
for key, obj in joined.items()}
|
|
system_names = {key: obj.name for key, obj in joined.items()}
|
|
wheel_names = [w.name for w in wheels]
|
|
wheel_triangles = sum(len(p.vertices) - 2 for p in wheels[0].data.polygons)
|
|
|
|
triangles = sum(len(p.vertices) - 2 for p in shell.data.polygons)
|
|
shell_triangles = triangles
|
|
for hull in hull_join:
|
|
triangles += sum(len(p.vertices) - 2 for p in hull.data.polygons)
|
|
# LODs per the vehicle-artist targets: LOD1 drops mirrors and lamp detail, LOD2 is a
|
|
# body-plus-glass shell. They are separate objects here and get wired into the
|
|
# body's LOD group at import time.
|
|
lod_paths = {}
|
|
for index, keep_glass, ratio in ((1, True, 0.55), (2, False, 0.25)):
|
|
lod_key = f"body_lod{index}"
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
shell.select_set(True)
|
|
if keep_glass and "glass" in joined:
|
|
joined["glass"].select_set(True)
|
|
bpy.context.view_layer.objects.active = shell
|
|
bpy.ops.object.duplicate()
|
|
lod = bpy.context.active_object
|
|
lod.name = f"SM_Vehicle_Body_LOD{index}"
|
|
# Decimate for real: the first version duplicated the shell unchanged, so both
|
|
# LODs came out at 84 triangles - an LOD that saves nothing.
|
|
decimate = lod.modifiers.new(name="LOD", type="DECIMATE")
|
|
decimate.ratio = ratio
|
|
bpy.ops.object.modifier_apply(modifier=decimate.name)
|
|
lod_path = os.path.join(exp_dir, f"{lod_key}.fbx")
|
|
fbx_export_preset.export_static(lod_path, selection=True)
|
|
lod_paths[lod_key] = lod_path
|
|
lod.hide_set(False)
|
|
log(f"{lod_key}: exported, {sum(len(p.vertices) - 2 for p in lod.data.polygons)} triangles")
|
|
bpy.data.objects.remove(lod, do_unlink=True)
|
|
|
|
# Skeletal asset last: joining the parts for it destroys the objects the LOD and
|
|
# system steps need. The first attempt called it earlier and died on
|
|
# "StructRNA of type Object has been removed".
|
|
skeletal_path = build_skeletal(joined, wheels, args.fbx, exp_dir)
|
|
exported["skeletal"] = skeletal_path
|
|
|
|
report = {
|
|
"asset": "SM_Vehicle_Sedan",
|
|
"dimensions_m": DIMS,
|
|
"body_parts": len(body),
|
|
"triangles": shell_triangles,
|
|
"collision_hulls": [h.name for h in hull_join],
|
|
"wheels": wheel_names,
|
|
"systems": {key: {"object": system_names.get(key), "triangles": per_system.get(key, 0),
|
|
"fbx": exported.get(key)}
|
|
for key in system_names},
|
|
"wheel_triangles": wheel_triangles,
|
|
"lods": lod_paths,
|
|
"log": PARTS,
|
|
}
|
|
if args.report:
|
|
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"[vehicle] report -> {args.report}")
|
|
print(f"[vehicle] {triangles} triangles, {len(hull_join)} hulls")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|