feat(vehicle): model the sedan with collision hulls
First 0.3.0 asset. Tools/vehicle_build.py builds a 4.72 x 1.80 x 1.44 m sedan - 2.78 m wheelbase, 1.62 m track, 0.33 m wheels - as a three-tier silhouette: sill slab, bonnet and boot shoulders, cabin greenhouse, plus glazing, lamps, bumpers and mirrors. 252 triangles. Collision is four UCX_-prefixed convex hulls (body, cabin, two bumpers) per the collision-proxy convention, hidden from render so the preview reads as the car rather than the colliders. Two corrections from the preview, both visible in the saved image: the first build was one tall slab plus a floating cabin and read as a box truck, and the wheels were buried inside a full-width sill - the sill is now 1.56 m so the wheels stand proud. Reports every dimension to JSON so the Chaos vehicle config can read the wheelbase, track and radius instead of restating them.
This commit is contained in:
1 parent
73b5bd6cd1
commit
a72bf85b2e
5 files changed
+251
-2
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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_<mesh>_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())
|
||||
Reference in new issue
Block a user