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 - 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 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. 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 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): def add_collision(body):
"""One convex box per major volume, `UCX_`-prefixed for Unreal.""" """One convex box per major volume, `UCX_`-prefixed for Unreal."""
hulls = [] hulls = []
@@ -282,6 +366,38 @@ def main(argv=None) -> int:
normalise_scale(meshes) 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 = {} systems = {}
for obj in meshes: for obj in meshes:
systems.setdefault(classify(obj), []).append(obj) systems.setdefault(classify(obj), []).append(obj)
@@ -363,6 +479,12 @@ def main(argv=None) -> int:
with open(args.report, "w") as handle: with open(args.report, "w") as handle:
json.dump(report, handle, indent=2) json.dump(report, handle, indent=2)
print(f"[hatch] report -> {args.report}") 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 return 0