Builder learns 2-door coupes, two-tone accent bonnets, vinyl roof caps and boot spoilers. Datto repainted orange with vinyl roof per reference. Screenshot harness gains cam=front for front three-quarter checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
242 lines
11 KiB
Python
242 lines
11 KiB
Python
"""Procedural Aussie shitbox factory.
|
|
|
|
Run: /Applications/Blender.app/Contents/MacOS/Blender -b -P tools/build_cars.py
|
|
|
|
Each car: side-silhouette profile extruded across the width (body + greenhouse),
|
|
cylinder wheels, and separate det_* panel objects (doors, mirrors, bumpers,
|
|
bonnet, boot) that the game rips off on impact. Exports cars/<id>/car.glb.
|
|
|
|
Convention: +Y is the nose in Blender (imports facing -Z, Godot-forward).
|
|
Ground is z=0 while building; the body root is dropped -0.75 at the end to
|
|
match the car controller's ride height.
|
|
|
|
ponytail: silhouettes are hand-tuned point lists, not reference-traced. Feed me
|
|
side-on photos per car when a shape needs to get closer.
|
|
"""
|
|
import os
|
|
import math
|
|
import bpy
|
|
import bmesh
|
|
from mathutils import Matrix
|
|
|
|
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cars")
|
|
|
|
GLASS = (0.12, 0.16, 0.20, 1)
|
|
TYRE = (0.08, 0.08, 0.08, 1)
|
|
CHROME = (0.75, 0.76, 0.78, 1)
|
|
DARK = (0.15, 0.15, 0.15, 1)
|
|
LIGHT_F = (0.95, 0.92, 0.75, 1)
|
|
LIGHT_R = (0.7, 0.1, 0.08, 1)
|
|
|
|
# L, W, heights: nose/belt/tail, lengths: bonnet/boot, roof height + rear roof end,
|
|
# axle overhangs, wheel radius, paint, chrome bumpers, hatch rear.
|
|
# Optional: doors=2, bonnet_color (two-tone accent), roof_cap (vinyl roof), spoiler.
|
|
CARS = {
|
|
"datto_120y": dict(L=3.92, W=1.50, nose=0.62, belt=0.78, tail=0.72,
|
|
bonnet=1.00, boot=0.85, roof=1.32, roof_rear=0.75,
|
|
ohang_f=0.72, ohang_r=0.80, wheel=0.27,
|
|
paint=(0.90, 0.45, 0.08, 1), chrome_bumpers=True, hatch=False,
|
|
roof_cap=(0.24, 0.23, 0.22, 1)),
|
|
"turdrunner_slur": dict(L=4.51, W=1.71, nose=0.64, belt=0.82, tail=0.76,
|
|
bonnet=1.35, boot=1.15, roof=1.34, roof_rear=0.85,
|
|
ohang_f=0.85, ohang_r=0.95, wheel=0.30,
|
|
paint=(0.95, 0.75, 0.05, 1), chrome_bumpers=False, hatch=False,
|
|
bonnet_color=(0.04, 0.04, 0.04, 1), spoiler=True),
|
|
"floored_xd": dict(L=4.85, W=1.91, nose=0.62, belt=0.86, tail=0.80,
|
|
bonnet=1.60, boot=1.00, roof=1.31, roof_rear=0.70,
|
|
ohang_f=0.95, ohang_r=1.00, wheel=0.31,
|
|
paint=(0.04, 0.04, 0.045, 1), chrome_bumpers=False, hatch=False,
|
|
doors=2),
|
|
"gemmy_tx": dict(L=4.13, W=1.57, nose=0.60, belt=0.76, tail=0.70,
|
|
bonnet=1.15, boot=0.95, roof=1.28, roof_rear=0.80,
|
|
ohang_f=0.80, ohang_r=0.85, wheel=0.28,
|
|
paint=(0.72, 0.30, 0.10, 1), chrome_bumpers=True, hatch=False),
|
|
"kingswood_hq": dict(L=4.81, W=1.88, nose=0.68, belt=0.85, tail=0.78,
|
|
bonnet=1.50, boot=1.30, roof=1.36, roof_rear=1.05,
|
|
ohang_f=0.90, ohang_r=1.05, wheel=0.31,
|
|
paint=(0.66, 0.74, 0.55, 1), chrome_bumpers=True, hatch=False),
|
|
"vl_terbo": dict(L=4.72, W=1.72, nose=0.56, belt=0.80, tail=0.82,
|
|
bonnet=1.30, boot=1.05, roof=1.33, roof_rear=0.90,
|
|
ohang_f=0.85, ohang_r=0.95, wheel=0.30,
|
|
paint=(0.92, 0.92, 0.90, 1), chrome_bumpers=False, hatch=False),
|
|
"xf_falcon": dict(L=4.91, W=1.86, nose=0.66, belt=0.84, tail=0.80,
|
|
bonnet=1.45, boot=1.25, roof=1.37, roof_rear=1.00,
|
|
ohang_f=0.90, ohang_r=1.00, wheel=0.30,
|
|
paint=(0.45, 0.55, 0.68, 1), chrome_bumpers=True, hatch=False),
|
|
"excel_x3": dict(L=4.10, W=1.62, nose=0.58, belt=0.80, tail=0.96,
|
|
bonnet=0.95, boot=0.30, roof=1.36, roof_rear=0.95,
|
|
ohang_f=0.78, ohang_r=0.70, wheel=0.27,
|
|
paint=(0.10, 0.55, 0.52, 1), chrome_bumpers=False, hatch=True),
|
|
}
|
|
|
|
|
|
def wipe():
|
|
for obj in list(bpy.data.objects):
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
for coll in (bpy.data.meshes, bpy.data.materials):
|
|
for block in list(coll):
|
|
if block.users == 0:
|
|
coll.remove(block)
|
|
|
|
|
|
def material(name, rgba, metallic=0.0, rough=0.7):
|
|
mat = bpy.data.materials.new(name)
|
|
mat.use_nodes = True
|
|
bsdf = next(n for n in mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED")
|
|
bsdf.inputs["Base Color"].default_value = rgba
|
|
bsdf.inputs["Metallic"].default_value = metallic
|
|
bsdf.inputs["Roughness"].default_value = rough
|
|
return mat
|
|
|
|
|
|
def add_obj(name, mesh, mat):
|
|
obj = bpy.data.objects.new(name, mesh)
|
|
obj.data.materials.append(mat)
|
|
bpy.context.collection.objects.link(obj)
|
|
return obj
|
|
|
|
|
|
def extrude_profile(name, profile, half_w, mat):
|
|
"""Closed (y,z) polygon extruded from -half_w to +half_w along x."""
|
|
n = len(profile)
|
|
verts = [(-half_w, y, z) for y, z in profile] + [(half_w, y, z) for y, z in profile]
|
|
faces = [list(range(n)), [2 * n - 1 - i for i in range(n)]]
|
|
for i in range(n):
|
|
j = (i + 1) % n
|
|
faces.append([i, j, n + j, n + i])
|
|
mesh = bpy.data.meshes.new(name)
|
|
mesh.from_pydata(verts, [], faces)
|
|
bm = bmesh.new()
|
|
bm.from_mesh(mesh)
|
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
mesh.update()
|
|
return add_obj(name, mesh, mat)
|
|
|
|
|
|
def box(name, center, size, mat, rot_x=0.0):
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm = bmesh.new()
|
|
bmesh.ops.create_cube(bm, size=1.0)
|
|
bmesh.ops.scale(bm, vec=size, verts=bm.verts)
|
|
if rot_x:
|
|
bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(rot_x, 3, "X"), verts=bm.verts)
|
|
bmesh.ops.translate(bm, vec=center, verts=bm.verts)
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
return add_obj(name, mesh, mat)
|
|
|
|
|
|
def wheel(name, x, y, r, mat):
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm = bmesh.new()
|
|
bmesh.ops.create_cone(bm, cap_ends=True, segments=14, radius1=r, radius2=r, depth=0.24)
|
|
bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "Y"), verts=bm.verts)
|
|
bmesh.ops.translate(bm, vec=(x, y, r), verts=bm.verts)
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
return add_obj(name, mesh, mat)
|
|
|
|
|
|
def build(car_id, s):
|
|
wipe()
|
|
paint = material("paint", s["paint"], rough=0.45)
|
|
paint_dk = material("paint_dark", tuple(c * 0.8 for c in s["paint"][:3]) + (1,), rough=0.5)
|
|
glass = material("glass", GLASS, rough=0.2)
|
|
tyre = material("tyre", TYRE, rough=0.9)
|
|
chrome = material("chrome", CHROME, metallic=1.0, rough=0.25)
|
|
dark = material("dark", DARK, rough=0.8)
|
|
bumper_mat = chrome if s["chrome_bumpers"] else paint_dk
|
|
|
|
L, W = s["L"], s["W"]
|
|
f, r, hw = L / 2, -L / 2, W / 2
|
|
bottom = 0.22
|
|
ws_base = f - s["bonnet"] # windscreen base y
|
|
boot_y = r + s["boot"] # rear window base y
|
|
nose2 = s["nose"] + 0.02
|
|
|
|
body_profile = [
|
|
(f, bottom + 0.15), (f, s["nose"]), (f - 0.3, nose2), (ws_base, s["belt"]),
|
|
(boot_y, s["belt"]), (r + 0.05, s["tail"]), (r, s["tail"] - 0.05),
|
|
(r, bottom + 0.15), (r + 0.2, bottom), (f - 0.2, bottom),
|
|
]
|
|
body = extrude_profile("body", body_profile, hw, paint)
|
|
|
|
rr_y = r + 0.12 if s["hatch"] else boot_y
|
|
rr_z = s["tail"] - 0.03 if s["hatch"] else s["belt"]
|
|
gh_profile = [
|
|
(ws_base, s["belt"] - 0.02),
|
|
(ws_base - 0.55, s["roof"]),
|
|
(boot_y + s["roof_rear"], s["roof"]),
|
|
(rr_y, rr_z),
|
|
]
|
|
parts = [extrude_profile("glass", gh_profile, hw * 0.84, glass)]
|
|
|
|
for side, sx in (("l", -1), ("r", 1)):
|
|
door_z = (bottom + 0.3 + s["belt"]) / 2
|
|
door_h = s["belt"] - bottom - 0.32
|
|
span = (ws_base - 0.05) - (boot_y + 0.05)
|
|
if s.get("doors", 4) == 2:
|
|
parts.append(box("det_door_%s" % side, (sx * hw, ws_base - 0.05 - span * 0.35, door_z),
|
|
(0.06, span * 0.68, door_h), paint_dk))
|
|
else:
|
|
half = span / 2
|
|
parts.append(box("det_door_f%s" % side, (sx * hw, ws_base - 0.05 - half / 2, door_z),
|
|
(0.06, half - 0.06, door_h), paint_dk))
|
|
parts.append(box("det_door_r%s" % side, (sx * hw, boot_y + 0.05 + half / 2, door_z),
|
|
(0.06, half - 0.06, door_h), paint_dk))
|
|
parts.append(box("det_mirror_%s" % side, (sx * (hw + 0.09), ws_base + 0.05, s["belt"] + 0.10),
|
|
(0.14, 0.09, 0.12), dark))
|
|
parts.append(box("det_bumper_f", (0, f + 0.05, bottom + 0.16), (W + 0.06, 0.15, 0.16), bumper_mat))
|
|
parts.append(box("det_bumper_r", (0, r - 0.05, bottom + 0.16), (W + 0.06, 0.15, 0.16), bumper_mat))
|
|
|
|
# bonnet plate follows the bonnet slope; optional two-tone accent colour
|
|
bonnet_mat = material("bonnet", s["bonnet_color"], rough=0.35) if "bonnet_color" in s else paint_dk
|
|
by1, bz1, by2, bz2 = f - 0.35, nose2, ws_base + 0.03, s["belt"]
|
|
ang = math.atan2(bz2 - bz1, by2 - by1)
|
|
parts.append(box("det_bonnet", ((0, (by1 + by2) / 2, (bz1 + bz2) / 2 + 0.02)),
|
|
(W * 0.7, math.hypot(by2 - by1, bz2 - bz1) - 0.1, 0.04), bonnet_mat, rot_x=-ang))
|
|
if not s["hatch"]:
|
|
ty1, tz1, ty2, tz2 = boot_y - 0.03, s["belt"], r + 0.1, s["tail"]
|
|
ang = math.atan2(tz2 - tz1, ty2 - ty1)
|
|
parts.append(box("det_boot", ((0, (ty1 + ty2) / 2, (tz1 + tz2) / 2 + 0.02)),
|
|
(W * 0.7, math.hypot(ty2 - ty1, tz2 - tz1) - 0.1, 0.04), paint_dk, rot_x=-ang))
|
|
|
|
trim = []
|
|
if "roof_cap" in s: # vinyl roof
|
|
cap_f, cap_r = ws_base - 0.55, boot_y + s["roof_rear"]
|
|
trim.append(box("trim_roof", (0, (cap_f + cap_r) / 2, s["roof"] + 0.012),
|
|
(W * 0.86, cap_f - cap_r + 0.1, 0.035), material("vinyl", s["roof_cap"], rough=0.9)))
|
|
if s.get("spoiler"): # bootlid ducktail
|
|
trim.append(box("trim_spoiler", (0, r + 0.22, s["tail"] + 0.05), (W * 0.8, 0.28, 0.09), paint))
|
|
trim += [
|
|
box("trim_grille", (0, f + 0.01, s["nose"] - 0.14), (W * 0.5, 0.05, 0.12), dark),
|
|
box("trim_light_fl", (-W * 0.33, f + 0.02, s["nose"] - 0.13), (0.24, 0.05, 0.13), material("lf", LIGHT_F)),
|
|
box("trim_light_fr", (W * 0.33, f + 0.02, s["nose"] - 0.13), (0.24, 0.05, 0.13), material("lf2", LIGHT_F)),
|
|
box("trim_light_rl", (-W * 0.32, r - 0.02, s["tail"] - 0.14), (0.26, 0.05, 0.12), material("lr", LIGHT_R)),
|
|
box("trim_light_rr", (W * 0.32, r - 0.02, s["tail"] - 0.14), (0.26, 0.05, 0.12), material("lr2", LIGHT_R)),
|
|
]
|
|
|
|
ax_f, ax_r = f - s["ohang_f"], r + s["ohang_r"]
|
|
wx = hw - 0.10
|
|
wheels = [wheel("wheel_fl", -wx, ax_f, s["wheel"], tyre), wheel("wheel_fr", wx, ax_f, s["wheel"], tyre),
|
|
wheel("wheel_rl", -wx, ax_r, s["wheel"], tyre), wheel("wheel_rr", wx, ax_r, s["wheel"], tyre)]
|
|
|
|
for obj in parts + trim + wheels:
|
|
obj.parent = body
|
|
body.location.z = -0.75 # node origin sits at the controller's ride height
|
|
|
|
out_dir = os.path.join(OUT, car_id)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
path = os.path.join(out_dir, "car.glb")
|
|
for obj in bpy.data.objects:
|
|
obj.select_set(True)
|
|
bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=True)
|
|
print("built", car_id, "->", path)
|
|
|
|
|
|
for car_id, spec in CARS.items():
|
|
build(car_id, spec)
|
|
print("done: %d cars" % len(CARS))
|