feat(vehicle): build the skeletal mesh from the hot hatch

vehicle_from_template.py now authors the physics mesh from the real hot hatch: apply
the mirror/nodes modifiers, derive the 4 wheel centres, add a root + 4 wheel bones,
weight and export vehicle_skeletal.fbx. A rotation orients the car so it imports
unrolled (length +X, width +Y, height +Z); wheel bones land at front +X, rear -X,
left/right +/-Y. The vehicle still floats (rises to 33 m) - recorded in MEMORY.
This commit is contained in:
zwitschi committed 2026-11-10 20:47:54 +01:00
1 parent d8cbb0c95b
commit 63ba9d91ce
3 files changed
+140

No files matched your search

Binary file not shown.
+15
View File
@@ -1132,3 +1132,18 @@ Historical task context from 2026-11-09/10; later vehicle findings above may sup
- Next step is not more solver tracing: reconcile the two meshes — drive the **hot hatch** (make
it skeletal, or rebuild `SK_Vehicle_Sedan` to match the hot hatch's axis and dimensions) so the
visible and physics cars are one asset with one forward axis.
## 2026-11-10 — Hot hatch is now the skeletal (physics) mesh; vehicle still floats
- `vehicle_from_template.py` now builds a skeletal mesh from the hot hatch (the real car):
it applies the MIRROR/NODES modifiers, derives the four wheel centres by quadrant, adds a
root + `Wheel_FL/FR/RL/RR` armature, weights wheels-to-bones, and exports
`vehicle_skeletal.fbx`. A rotation matrix [[0,1,0],[0,0,-1],[-1,0,0]] orients the car so the
FBX conversion lands it unrolled (length +X, width +Y, height +Z). Wheel bones import
correctly: FL (1.43,-0.71,0.34) FR (1.43,0.62,0.34) RL (-1.22,-0.75,0.36) RR (-1.22,0.67,0.36).
- **Still floats** (rises 300 -> 3315 cm, `simulating=1`, 4 wheels): the same suspension push
as before, so the reverse/float is NOT the visual/physics axis mismatch after all. Remaining
suspect is the chassis box centre (`-Extent.Z*0.25` = ~-20) vs the wheel-bone height (0.34):
the suspension rest length is measured against the root BONE, not the box, so the box offset
may be the push. Next: try `Box.Center = (0,0,+Extent.Z*0.3)` or drive the box from the
wheel-bone height, then re-run `-drivetest`.
+122
View File
@@ -230,6 +230,90 @@ def join_system(objects, name):
return joined
def build_skeletal(joined, args):
"""Bind the body + wheels to a root + 4 wheel bones for the Chaos vehicle.
The hot hatch's nose is along +Y, so wheel front/rear is the Y sign and left/right is the
X sign (the wheels are mirrored in the template, so the joined mesh has all four). Each
wheel is weighted to its own bone at full weight; the body goes to the root.
"""
import math
body = joined["body"]
wheels = joined["wheels"]
# Derive the four wheel centres from the wheel geometry: group vertices by quadrant.
# After the Unreal-frame rotation, front/rear is the X sign and left/right is the Z sign.
quadrants = {"FL": [], "FR": [], "RL": [], "RR": []}
for vertex in wheels.data.vertices:
world = wheels.matrix_world @ vertex.co
key = ("F" if world.x > 0 else "R") + ("L" if world.z > 0 else "R")
quadrants[key].append(world)
centres = {}
for key, verts in quadrants.items():
if not verts:
continue
centres[f"Wheel_{key}"] = (
sum(v.x for v in verts) / len(verts),
sum(v.y for v in verts) / len(verts),
sum(v.z for v in verts) / len(verts),
)
if len(centres) != 4:
raise SystemExit(f"expected 4 wheel quadrants, got {len(centres)}: {list(centres)}")
log(f"wheel centres: " + ", ".join(f"{k}=({v[0]:.2f},{v[1]:.2f},{v[2]:.2f})"
for k, v in sorted(centres.items())))
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)
for name, (x, y, z) in sorted(centres.items()):
bone = edit_bones.new(name)
bone.head = (x, y, z)
# Axle direction is irrelevant to the Chaos solver (it drives the wheel about the
# vehicle's Y), so point the tail straight down for a stable reference frame.
bone.tail = (x, y, z - 0.25)
bone.parent = root
bpy.ops.object.mode_set(mode="OBJECT")
# Join the body and wheels into one skinned mesh.
bpy.ops.object.select_all(action="DESELECT")
body.select_set(True)
wheels.select_set(True)
bpy.context.view_layer.objects.active = 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", *centres]}
for vertex in mesh.data.vertices:
world = mesh.matrix_world @ vertex.co
nearest, best = "root", 0.6 ** 2
for name, (x, y, z) in centres.items():
d = (world.x - x) ** 2 + (world.y - y) ** 2 + (world.z - z) ** 2
if d < best:
best, nearest = d, name
groups[nearest].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(args.out, "vehicle_skeletal.fbx")
fbx_export_preset.export_character(path, selection=True)
log(f"skeletal vehicle exported: {path}, bones root + 4 wheels")
return path
def add_collision(body):
"""One convex box per major volume, `UCX_`-prefixed for Unreal."""
hulls = []
@@ -282,6 +366,38 @@ def main(argv=None) -> int:
normalise_scale(meshes)
# Apply the MIRROR modifier so the left wheels exist in the mesh data BEFORE the nose
# rotation; the mirror is in the object's local space, so rotating first would mirror
# about a rotated axis.
for obj in meshes:
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
for modifier in list(obj.modifiers):
try:
bpy.ops.object.modifier_apply(modifier=modifier.name)
except RuntimeError:
pass
obj.select_set(False)
# Rotate so the FBX axis conversion (Blender Y->Unreal -Z, Z->-Y) lands the car unrolled:
# length along +X (forward), width along Y, height along Z. The mapping is X->-Z, Y->X,
# Z->-Y, i.e. the rotation matrix [[0,1,0],[0,0,-1],[-1,0,0]].
from mathutils import Matrix
rot = Matrix((
(0.0, 1.0, 0.0),
(0.0, 0.0, -1.0),
(-1.0, 0.0, 0.0),
))
for obj in meshes:
obj.rotation_euler = rot.to_euler()
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
obj.select_set(False)
bpy.context.view_layer.update()
log("rotated to the Unreal frame (length +X, width +Y, height +Z)")
systems = {}
for obj in meshes:
systems.setdefault(classify(obj), []).append(obj)
@@ -363,6 +479,12 @@ def main(argv=None) -> int:
with open(args.report, "w") as handle:
json.dump(report, handle, indent=2)
print(f"[hatch] report -> {args.report}")
# Build the skeletal (physics) mesh last, after the report has recorded the separate
# systems: build_skeletal joins the body and wheels, so it must not run before the
# static exports and report are written.
build_skeletal(joined, args)
return 0