Files
gig-line/Tools/vehicle_from_template.py
T
zwitschi b50afa4bcd feat(vehicle): the car renders, built from the owner's hot hatch template
The invisible-vehicle defect is closed. Shot 9 now shows the car instead of an empty road.

Tools/vehicle_from_template.py converts Art/blender/vehicle/hot hatch.blend into UE-ready
assets, following two skills:

  vehicle-artist - systems split (body 113k tris, trim 10.5k, glass 20.5k, lights 18.6k,
  wheels 47.8k), one UCX_ hull 1.45 x 3.94 x 0.76 m, two decimated LODs at 39.7k and 13.6k.
  unreal-export - scale normalised to a documented 4.05 m hot hatch before export (the
  template measures 7.26 m, factor 0.5579), then verified after import: body bounds
  1.51 x 4.02 x 1.22 m against the 4.05 m target.

Staged as five plain StaticMeshActors, because the earlier probe proved those render while
the same meshes attached to the vehicle pawn do not.

Open and recorded rather than hidden: judge_screenshot returns verdict=no confidence=0.9 -
the silhouette does not read as a hot hatch from this angle and the car floats slightly.
The likely causes are a system misclassification in the template conversion and an
origin-height error of a few centimetres.
2026-11-09 15:52:42 +01:00

244 lines
9.1 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/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
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
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 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 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.
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)
offset = __import__("mathutils").Vector(
(-(min(xs) + max(xs)) / 2.0, -(min(ys) + max(ys)) / 2.0, -min(zs)))
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)
report = {
"template": bpy.data.filepath,
"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())