Files
gig-line/Tools/vehicle_build.py
T
zwitschi 02cadd4964 feat(vehicle): split into systems, add real LODs, import to Unreal
Follows the vehicle-artist object split, which the first build violated: body, glass,
front and rear lights, mirrors and a single wheel mesh are now separate objects and
separate assets in /Game/Hollywood/Vehicle. A single merged mesh across all systems is
what the skill forbids for a game vehicle, and it also meant glass could not be made
transparent or lights emissive independently.

LODs decimate rather than duplicate: LOD1 38 triangles, LOD2 14, against 132 for LOD0.
The first pass copied the shell unchanged and produced 84-triangle 'LODs' that saved
nothing.

Imports verified with bounds: body 4.76 x 1.76 x 1.28 m, wheel 0.22 x 0.66 x 0.66 m,
which match the modelled dimensions.

Open, recorded: LOD group wiring needs StaticMeshEditorSubsystem.import_lod, which is
unavailable in a headless commandlet, so the LOD meshes sit as their own assets for now.
2026-11-09 14:46:02 +01:00

332 lines
14 KiB
Python

#!/usr/bin/env python3
"""Model a drivable sedan in Blender: body, wheels, collision and LOD.
blender -b --factory-startup --python Tools/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
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
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 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}")
triangles = sum(len(p.vertices) - 2 for p in shell.data.polygons)
per_system = {key: sum(len(p.vertices) - 2 for p in obj.data.polygons)
for key, obj in joined.items()}
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)
report = {
"asset": "SM_Vehicle_Sedan",
"dimensions_m": DIMS,
"body_parts": len(body),
"triangles": triangles,
"collision_hulls": [h.name for h in hull_join],
"wheels": [w.name for w in wheels],
"systems": {key: {"object": obj.name, "triangles": per_system.get(key, 0),
"fbx": exported.get(key)}
for key, obj in joined.items()},
"wheel_triangles": sum(len(p.vertices) - 2 for p in wheels[0].data.polygons),
"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())