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:
1 parent
d8cbb0c95b
commit
63ba9d91ce
3 files changed
+140
No files matched your search
Binary file not shown.
@@ -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`.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user