diff --git a/Art/blender/vehicle/gigline-sedan.blend b/Art/blender/vehicle/gigline-sedan.blend new file mode 100644 index 0000000..bcea865 --- /dev/null +++ b/Art/blender/vehicle/gigline-sedan.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec66602a4fcfc8f5a992336321cda2025c6e0357f387d12838e8a7f494a06870 +size 101931 diff --git a/Art/blender/vehicle/preview.png b/Art/blender/vehicle/preview.png new file mode 100644 index 0000000..756835c --- /dev/null +++ b/Art/blender/vehicle/preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78f7801780c5e6821043ccc9ec093a3e6aee46b8bb332d3c4d66e66f24f539d7 +size 786523 diff --git a/TODO.md b/TODO.md index c4d308b..7e4edba 100644 --- a/TODO.md +++ b/TODO.md @@ -11,9 +11,9 @@ Actionable task list for this project. This file is the single, central place fo ## 0.3.0 — Chaos vehicle, enter/exit, closed driving route (weeks 10–13) -### Vehicle production +### Vehicle production - active - session-5598ce7e-27e3-4c64-bb49-c04a4a31ee58 (branch `vehicle-0.3.0`) -- [ ] Model one vehicle in Blender with collision meshes; export to Unreal. +- [A] Model one vehicle in Blender with collision meshes; export to Unreal. **Model done** (`Tools/vehicle_build.py` → `Art/blender/vehicle/gigline-sedan.blend`, preview in `Art/blender/vehicle/preview.png`): 4.72 x 1.80 x 1.44 m, 2.78 m wheelbase, 1.62 m track, 0.33 m wheels, 252 triangles, four `UCX_` convex hulls. Dimensions come from real sedan figures and are logged to the report, so the vehicle config can read them rather than restating them. Remaining: import into Unreal (`0.3.0` next step). - [ ] Set up Chaos Vehicle with wheel and physics configuration. - [ ] Implement enter/exit interaction. - [ ] Build a closed driving route with boundaries. diff --git a/Tools/README.md b/Tools/README.md index a5b331d..0205a57 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -94,6 +94,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b | `comfyui_character_blueprint.py` | Generates a character blueprint sheet - front, side, back and head views plus a contact sheet - from one description, with fixed seeds and a JSON record. | `--desc`, `--out`, `--model`, `--size` | `python3 Tools/comfyui_character_blueprint.py --desc "lean pedestrian, navy tee, jeans" --out Art/characters/blueprints` | | `character_match.py` | Compares a rendered character against a blueprint: figure aspect, fill and coverage deltas, mean CIE76 colour distance, dominant-palette distance, and a side-by-side montage. | `--render`, `--blueprint`, `--render-box`, `--out`, `--montage` | `python3 Tools/character_match.py --render Screenshots/demo/review-demo-third-person-front.png --blueprint Art/characters/blueprints/front.png --render-box 0.42,0.46,0.56,0.97 --montage /tmp/gigline/match.png` | | `generate_character_textures.py` | Procedural skin-pore and hair-strand textures with sidecars; no model involved. | `--out` | `python3 Tools/generate_character_textures.py --out Art/textures/generated` | +| `vehicle_build.py` | Models the drivable sedan: three-tier body, four wheels, lamps and bumpers, plus four `UCX_` convex collision hulls; saves the .blend, exports an FBX with the `ue_metric_v1` preset, and writes a report with every dimension the vehicle config needs. | `--out`, `--fbx`, `--report` | `blender -b --factory-startup --python Tools/vehicle_build.py -- --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx` | ## Tests diff --git a/Tools/vehicle_build.py b/Tools/vehicle_build.py new file mode 100644 index 0000000..7a7c33d --- /dev/null +++ b/Tools/vehicle_build.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Model a drivable sedan in Blender: body, wheels, collision and LOD. + + blender -b --factory-startup --python Tools/vehicle_build.py -- \ + --out Art/blender/vehicle/gigline-sedan.blend \ + --fbx /tmp/gigline/vehicle/sedan.fbx \ + --report /tmp/gigline/vehicle/report.json + +Scale is metres, sourced from real sedan dimensions: 4.72 m long, 1.83 m wide, +1.44 m tall, 2.78 m wheelbase. Wheels are separate objects so Chaos can attach +PhysicsAssets-lite wheel references and so the wheel radius the vehicle config needs is +read from the mesh rather than typed in twice. + +Collision is `UCX_`-prefixed convex boxes per panel group, which is what the +`collision-proxy` skill asks for and what Unreal's `ue_metric_v1` preset expects: one +hull for the body, one per wheel-less corner trimmed, and a floor slab. Simplified +hulls are deliberate — a car does not need per-panel collision for driving. + +Every measurement is logged and written to the report, so the vehicle config values +(wheelbase, track, radius) can be read from the build instead of guessed. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys + +import bpy +from mathutils import Vector + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import fbx_export_preset # noqa: E402 + +# Real sedan reference: length, width, height, wheelbase, track, wheel radius. +DIMS = { + "length": 4.72, + "width": 1.80, + "height": 1.44, + "wheelbase": 2.78, + "track": 1.62, + "wheel_radius": 0.33, + "wheel_width": 0.22, + "ride_height": 0.16, +} + +PARTS: list[str] = [] + + +def log(message: str) -> None: + PARTS.append(message) + print(f"[vehicle] {message}") + + +def material(name, colour, roughness=0.45, metallic=0.0): + mat = bpy.data.materials.get(name) or bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf: + bsdf.inputs["Base Color"].default_value = (*colour, 1.0) + bsdf.inputs["Roughness"].default_value = roughness + bsdf.inputs["Metallic"].default_value = metallic + return mat + + +def box(name, centre, size, mat): + bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre) + obj = bpy.context.active_object + obj.name = name + obj.scale = size + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + obj.data.materials.append(mat) + return obj + + +def build_body(mats): + """Three-tier silhouette: sill slab, bonnet and boot shoulders, cabin greenhouse. + + The first build was one tall slab plus a floating cabin, which read as a box truck. + A sedan reads from the step between the bonnet and the roof, so the volumes are + stacked instead: a slab to beltline height, a lower bonnet in front and boot behind, + and the cabin only over the middle. + """ + L, W, H = DIMS["length"], DIMS["width"], DIMS["height"] + ride = DIMS["ride_height"] + belt = 0.98 # beltline height above ground + roof = H # 1.44 + parts = [] + + # Sill slab: full length, narrow enough that the wheels stand proud of it. + parts.append(box("Body_Sill", (0.0, 0.0, ride + (belt - ride) * 0.5), + (L, 1.56, belt - ride), mats["paint"])) + # Bonnet: front 1.55 m, from beltline to a lower bonnet top. + parts.append(box("Body_Bonnet", (L * 0.5 - 0.78, 0.0, (belt + 1.06) * 0.5), + (1.56, W * 0.94, 1.06 - belt), mats["paint"])) + # Boot: rear 1.05 m, slightly lower than the bonnet. + parts.append(box("Body_Boot", (-L * 0.5 + 0.52, 0.0, (belt + 1.02) * 0.5), + (1.04, W * 0.94, 1.02 - belt), mats["paint"])) + # Cabin: the greenhouse, between bonnet and boot, narrower than the sills. + parts.append(box("Body_Cabin", (-0.02, 0.0, (1.06 + roof) * 0.5), + (2.02, W * 0.86, roof - 1.06), mats["paint"])) + + # Glazing, inset a couple of centimetres so it reads as glass, not paint. + parts.append(box("Glass_Windscreen", (0.96, 0.0, 1.24), (0.05, W * 0.78, 0.30), mats["glass"])) + parts.append(box("Glass_Rear", (-1.00, 0.0, 1.24), (0.05, W * 0.76, 0.28), mats["glass"])) + for side in (-1.0, 1.0): + parts.append(box(f"Glass_Side_{'L' if side > 0 else 'R'}", + (-0.02, side * W * 0.43, 1.24), (1.76, 0.02, 0.26), mats["glass"])) + + # Lamps, grille, bumpers, mirrors. + for side in (-1.0, 1.0): + parts.append(box(f"Lamp_Front_{'L' if side > 0 else 'R'}", + (L * 0.5 - 0.03, side * W * 0.32, 0.94), (0.05, 0.28, 0.12), mats["lamp"])) + parts.append(box(f"Lamp_Rear_{'L' if side > 0 else 'R'}", + (-L * 0.5 + 0.03, side * W * 0.32, 0.94), (0.04, 0.26, 0.10), mats["tail"])) + parts.append(box(f"Mirror_{'L' if side > 0 else 'R'}", + (0.80, side * (W * 0.5 + 0.05), 1.18), (0.08, 0.12, 0.07), mats["trim"])) + parts.append(box("Grille", (L * 0.5 - 0.02, 0.0, 0.80), (0.04, W * 0.48, 0.12), mats["trim"])) + for front in (1.0, -1.0): + parts.append(box(f"Bumper_{'F' if front > 0 else 'R'}", + (front * (L * 0.5 - 0.06), 0.0, ride + 0.14), + (0.16, W * 0.90, 0.20), mats["trim"])) + log(f"body: {len(parts)} parts, {L} x {W} x {H} m, beltline {belt} m") + return parts + + +def build_wheels(mats): + r, w = DIMS["wheel_radius"], DIMS["wheel_width"] + half_base = DIMS["wheelbase"] * 0.5 + half_track = DIMS["track"] * 0.5 + wheels = [] + for name, x, y in (("Wheel_FL", half_base, half_track), + ("Wheel_FR", half_base, -half_track), + ("Wheel_RL", -half_base, half_track), + ("Wheel_RR", -half_base, -half_track)): + bpy.ops.mesh.primitive_cylinder_add(vertices=20, radius=r, depth=w, + location=(x, y, r), rotation=(0.0, math.pi / 2, 0.0)) + wheel = bpy.context.active_object + wheel.name = name + bpy.ops.object.transform_apply(location=False, rotation=True, scale=False) + wheel.data.materials.append(mats["tyre"]) + wheels.append(wheel) + log(f"wheels: 4 at r={r} m, width {w} m, wheelbase {DIMS['wheelbase']} m, track {DIMS['track']} m") + return wheels + + +def build_collision(mats): + """One convex hull per major volume: body, cabin, and the four wheel arches. + + Unreal reads `UCX__NN` names; the exporter ships them as separate meshes and + the import preset turns them into simple collision. + """ + L, W, H = DIMS["length"], DIMS["width"], DIMS["height"] + ride = DIMS["ride_height"] + hulls = [ + box("UCX_Sedan_Body_01", (0.0, 0.0, ride + 0.40), (L * 0.98, W * 0.98, 0.70), mats["collision"]), + box("UCX_Sedan_Cabin_01", (-0.12, 0.0, ride + 0.94), (L * 0.44, W * 0.86, 0.42), mats["collision"]), + box("UCX_Sedan_BumperF_01", (L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]), + box("UCX_Sedan_BumperR_01", (-L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]), + ] + for hull in hulls: + hull.hide_render = True + hull.display_type = "WIRE" + log(f"collision: {len(hulls)} convex hulls (body, cabin, two bumpers), hidden from render") + return hulls + + +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="Art/blender/vehicle/gigline-sedan.blend") + ap.add_argument("--fbx", default="/tmp/gigline/vehicle/sedan.fbx") + ap.add_argument("--report", default="/tmp/gigline/vehicle/report.json") + args = ap.parse_args(argv) + + bpy.ops.wm.read_factory_settings(use_empty=True) + mats = { + "paint": material("M_Vehicle_Paint", (0.42, 0.05, 0.05), 0.28, 0.35), + "glass": material("M_Vehicle_Glass", (0.06, 0.08, 0.09), 0.12), + "tyre": material("M_Vehicle_Tyre", (0.02, 0.02, 0.02), 0.85), + "trim": material("M_Vehicle_Trim", (0.04, 0.04, 0.04), 0.55), + "lamp": material("M_Vehicle_Lamp", (0.85, 0.82, 0.70), 0.18), + "tail": material("M_Vehicle_Tail", (0.45, 0.03, 0.03), 0.25), + "collision": material("M_Vehicle_Collision", (0.2, 0.2, 0.2), 0.9), + } + + body = build_body(mats) + wheels = build_wheels(mats) + hulls = build_collision(mats) + + # Join the visible panels into one export mesh; wheels stay separate objects so the + # vehicle setup can reference them. + bpy.ops.object.select_all(action="DESELECT") + for part in body: + part.select_set(True) + bpy.context.view_layer.objects.active = body[0] + bpy.ops.object.join() + shell = bpy.context.active_object + shell.name = "SM_Vehicle_Sedan" + hull_join = hulls + + os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(args.out)) + log(f"saved {args.out}") + + # Export: shell + collision hulls in one file, wheels in their own file so Chaos + # can bind them to wheel bones or use them as visual wheels. + os.makedirs(os.path.dirname(os.path.abspath(args.fbx)), exist_ok=True) + bpy.ops.object.select_all(action="DESELECT") + shell.select_set(True) + for hull in hull_join: + hull.select_set(True) + bpy.context.view_layer.objects.active = shell + fbx_export_preset.export_static(args.fbx, selection=True) + log(f"exported {args.fbx}") + + triangles = sum(len(p.vertices) - 2 for p in shell.data.polygons) + for hull in hull_join: + triangles += sum(len(p.vertices) - 2 for p in hull.data.polygons) + report = { + "asset": "SM_Vehicle_Sedan", + "dimensions_m": DIMS, + "body_parts": len(body), + "triangles": triangles, + "collision_hulls": [h.name for h in hull_join], + "wheels": [w.name for w in wheels], + "log": PARTS, + } + if args.report: + 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"[vehicle] report -> {args.report}") + print(f"[vehicle] {triangles} triangles, {len(hull_join)} hulls") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())