From 63ba9d91ce4f8c4927337a3d2e5a8e3896931e7b Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Tue, 10 Nov 2026 20:47:54 +0100 Subject: [PATCH] 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. --- .../Hollywood/Vehicle/car_paint.uasset | 3 + MEMORY.md | 15 +++ Tools/blender/vehicle_from_template.py | 122 ++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 Game/GigLine/Content/Hollywood/Vehicle/car_paint.uasset diff --git a/Game/GigLine/Content/Hollywood/Vehicle/car_paint.uasset b/Game/GigLine/Content/Hollywood/Vehicle/car_paint.uasset new file mode 100644 index 0000000..53c57fe --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/car_paint.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:958f1da7e8765b2f0274583cd29cd763417c46be8de0900226c0ec5065d7d73c +size 56597 diff --git a/MEMORY.md b/MEMORY.md index fcaa211..5a87905 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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`. diff --git a/Tools/blender/vehicle_from_template.py b/Tools/blender/vehicle_from_template.py index ef161b7..36f91f6 100644 --- a/Tools/blender/vehicle_from_template.py +++ b/Tools/blender/vehicle_from_template.py @@ -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