First 0.3.0 asset. Tools/vehicle_build.py builds a 4.72 x 1.80 x 1.44 m sedan - 2.78 m wheelbase, 1.62 m track, 0.33 m wheels - as a three-tier silhouette: sill slab, bonnet and boot shoulders, cabin greenhouse, plus glazing, lamps, bumpers and mirrors. 252 triangles. Collision is four UCX_-prefixed convex hulls (body, cabin, two bumpers) per the collision-proxy convention, hidden from render so the preview reads as the car rather than the colliders. Two corrections from the preview, both visible in the saved image: the first build was one tall slab plus a floating cabin and read as a box truck, and the wheels were buried inside a full-width sill - the sill is now 1.56 m so the wheels stand proud. Reports every dimension to JSON so the Chaos vehicle config can read the wheelbase, track and radius instead of restating them.
243 lines
10 KiB
Python
243 lines
10 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)
|
|
|
|
# Join the visible panels into one export mesh; wheels stay separate objects so the
|
|
# vehicle setup can reference them.
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
for part in body:
|
|
part.select_set(True)
|
|
bpy.context.view_layer.objects.active = body[0]
|
|
bpy.ops.object.join()
|
|
shell = bpy.context.active_object
|
|
shell.name = "SM_Vehicle_Sedan"
|
|
hull_join = hulls
|
|
|
|
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)
|
|
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(args.fbx, selection=True)
|
|
log(f"exported {args.fbx}")
|
|
|
|
triangles = sum(len(p.vertices) - 2 for p in shell.data.polygons)
|
|
for hull in hull_join:
|
|
triangles += sum(len(p.vertices) - 2 for p in hull.data.polygons)
|
|
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],
|
|
"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())
|