ShitboxInfinity/tools/build_cars.py
m3ultra 9e98d5ba65 DIEHATSU FACADE 1.0: the 1984 Charade, from the owner's own exhibits
3.55 m, 680 kg, one-litre three-cylinder -- gutless beyond parody in life,
which under the reversed-stats rule makes it elite: grip 8.0, the most
chuckable thing in the fleet, slotted just under the BUBBLA on power.

Modelled from reference photos: upright glasshouse, short bonnet, wide dark
grille band with amber corner indicators, black side strip, tiny 12-inch
wheels, and rear hatch louvres (det_, shed slat by slat) per the Cape Town
specimen. New builder key black_bumpers: 80s econoboxes wear true black
plastic, not body-tone dark -- the Lazza can adopt it too.

35 cars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 10:29:17 +10:00

554 lines
27 KiB
Python

"""Procedural Aussie shitbox factory.
Run: /Applications/Blender.app/Contents/MacOS/Blender -b -P tools/build_cars.py
(append `-- <id> [<id>...]` to rebuild only those cars)
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 sys
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,
# louvres (hatch-glass slat stack, each slat sheds on impact), indicators (amber
# corner blinkers), side_strip (black rubbing strip along the belt line).
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),
"chaebol_x1": dict(L=4.28, W=1.60, nose=0.56, belt=0.78, tail=0.74,
bonnet=1.15, boot=0.95, roof=1.33, roof_rear=0.85,
ohang_f=0.80, ohang_r=0.85, wheel=0.27,
paint=(0.72, 0.72, 0.74, 1), chrome_bumpers=False, hatch=False),
"chaebol_x2": dict(L=4.15, W=1.61, nose=0.58, belt=0.80, tail=0.90,
bonnet=1.05, boot=0.35, roof=1.36, roof_rear=0.90,
ohang_f=0.78, ohang_r=0.72, wheel=0.27,
paint=(0.35, 0.52, 0.75, 1), chrome_bumpers=False, hatch=True),
"hardon_cmere": dict(L=4.48, W=1.66, nose=0.58, belt=0.80, tail=0.77,
bonnet=1.25, boot=1.10, roof=1.35, roof_rear=0.90,
ohang_f=0.82, ohang_r=0.90, wheel=0.28,
paint=(0.93, 0.90, 0.70, 1), chrome_bumpers=False, hatch=False),
"hardon_shaggen": dict(L=4.55, W=1.72, nose=0.66, belt=0.86, tail=0.84,
bonnet=1.35, boot=2.05, roof=1.42, roof_rear=0.25,
ohang_f=0.85, ohang_r=0.95, wheel=0.29,
paint=(0.70, 0.72, 0.58, 1), chrome_bumpers=True, hatch=False,
doors=2, van=True),
# John's actual first car: '82 KA Laser 1.5 hatch, white, rear louvres.
# Gutless IRL (54 kW carby) -> reversed-stats rule makes it a weapon here.
"lazza_ka": dict(L=3.96, W=1.61, nose=0.58, belt=0.79, tail=0.90,
bonnet=1.02, boot=0.35, roof=1.37, roof_rear=0.72,
ohang_f=0.79, ohang_r=0.72, wheel=0.26,
paint=(0.92, 0.92, 0.89, 1), chrome_bumpers=False, hatch=True,
louvres=True, indicators=True, side_strip=True, wide_grille=True),
# The ute: cab ends behind the seats, everything aft is open tray. The
# single most load-bearing vehicle shape in Australian culture.
"hardon_wb_ute": dict(L=4.92, W=1.85, nose=0.66, belt=0.84, tail=0.80,
bonnet=1.50, boot=2.30, roof=1.38, roof_rear=0.18,
ohang_f=0.90, ohang_r=1.00, wheel=0.31,
paint=(0.93, 0.93, 0.90, 1), chrome_bumpers=True, hatch=False,
doors=2, ute=True, side_strip=True),
# wagon: the roof just refuses to end
"hardon_waggo": dict(L=4.88, W=1.88, nose=0.68, belt=0.85, tail=1.02,
bonnet=1.50, boot=0.50, roof=1.40, roof_rear=0.42,
ohang_f=0.90, ohang_r=1.00, wheel=0.31,
paint=(0.58, 0.44, 0.28, 1), chrome_bumpers=True, hatch=True,
roof_cap=(0.30, 0.22, 0.14, 1)),
# muscle coupe: all bark, worst grip in the game (reversed-stats rule)
"falcodore_gtho": dict(L=4.78, W=1.90, nose=0.62, belt=0.86, tail=0.82,
bonnet=1.55, boot=1.10, roof=1.30, roof_rear=0.70,
ohang_f=0.88, ohang_r=0.95, wheel=0.31,
paint=(0.55, 0.05, 0.07, 1), chrome_bumpers=False, hatch=False,
doors=2, bonnet_color=(0.05, 0.05, 0.05, 1), spoiler=True),
# microcar: a jellybean with delusions
"bubbla_600": dict(L=3.02, W=1.36, nose=0.52, belt=0.72, tail=0.86,
bonnet=0.62, boot=0.24, roof=1.30, roof_rear=0.55,
ohang_f=0.52, ohang_r=0.50, wheel=0.22,
paint=(0.55, 0.75, 0.88, 1), chrome_bumpers=True, hatch=True,
indicators=True),
# Exhibit A: 1984 G11 Charade 5-door. 650 kg, a one-litre THREE-cylinder,
# rear louvres on the good ones. Gutless beyond parody IRL -> elite here.
"diehatsu_facade": dict(L=3.55, W=1.55, nose=0.60, belt=0.78, tail=0.92,
bonnet=0.85, boot=0.30, roof=1.36, roof_rear=0.62,
ohang_f=0.62, ohang_r=0.60, wheel=0.24,
paint=(0.78, 0.10, 0.10, 1), chrome_bumpers=False, hatch=True,
black_bumpers=True, louvres=True, indicators=True,
side_strip=True, wide_grille=True),
# mokes use the open-tub builder: style="moke"
"mini_merk": dict(style="moke", L=3.05, W=1.42, nose=0.60, tub=0.66, canopy=1.38,
bonnet=0.85, ohang_f=0.55, ohang_r=0.55, wheel=0.26,
paint=(0.95, 0.62, 0.72, 1)),
"maxi_merk": dict(style="moke", L=4.35, W=1.45, nose=0.60, tub=0.66, canopy=1.38,
bonnet=1.00, ohang_f=0.60, ohang_r=0.60, wheel=0.26,
paint=(0.93, 0.78, 0.05, 1), extra_axles=[1.35], rear_bench=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 smoothstep(x):
x = max(0.0, min(1.0, x))
return x * x * (3 - 2 * x)
def warp_obj(obj, L, belt, roof):
"""Slab-buster: plan taper (narrower nose/tail), tumblehome (glasshouse
leans in above the belt) and rocker tuck (sides pull under below the sill).
Applied to world-space verts, so det_ panels stay flush with the body."""
f, r = L / 2, -L / 2
for v in obj.data.vertices:
y, z = v.co.y, v.co.z
t = (y - r) / L
plan = 1.0 \
- 0.11 * smoothstep((t - 0.70) / 0.30) ** 1.4 \
- 0.07 * smoothstep((0.16 - t) / 0.16) ** 1.4
tumble = 1.0
if z > belt and roof > belt:
tumble = 1.0 - 0.15 * min((z - belt) / (roof - belt), 1.0) ** 1.15
tuck = 1.0
if z < 0.34:
tuck = 1.0 - 0.07 * ((0.34 - z) / 0.34) ** 2
v.co.x *= plan * tumble * tuck
def bevel_body(obj, width=0.035):
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.bevel(bm, geom=list(bm.edges), offset=width, segments=1,
profile=0.7, affect='EDGES')
bm.to_mesh(obj.data)
bm.free()
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, tyre_mat, rim_mat):
"""Tyre + inset rim, mesh centred on the hub and the OBJECT placed at the
hub -- the old version baked position into the verts, which meant a spinning
wheel in-game would orbit the car's origin instead of its own axle."""
mesh = bpy.data.meshes.new(name)
bm = bmesh.new()
res = bmesh.ops.create_cone(bm, cap_ends=True, segments=14, radius1=r, radius2=r, depth=0.24)
for f in {f for v in res["verts"] for f in v.link_faces}:
f.material_index = 0
res = bmesh.ops.create_cone(bm, cap_ends=True, segments=12,
radius1=r * 0.58, radius2=r * 0.58, depth=0.26)
for f in {f for v in res["verts"] for f in v.link_faces}:
f.material_index = 1
bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "Y"), verts=bm.verts)
bm.to_mesh(mesh)
bm.free()
obj = add_obj(name, mesh, tyre_mat)
obj.data.materials.append(rim_mat)
obj.location = (x, y, r)
return obj
def hubcap(name, x, y, r, side, mat):
"""Chrome dish on the wheel's outer face. det_ so it pings off on impact --
every shitbox loses a hubcap eventually, that's canon."""
mesh = bpy.data.meshes.new(name)
bm = bmesh.new()
res = bmesh.ops.create_cone(bm, cap_ends=True, segments=12,
radius1=r * 0.42, radius2=r * 0.30, depth=0.05)
bmesh.ops.rotate(bm, cent=(0, 0, 0),
matrix=Matrix.Rotation(math.pi / 2 * (-1 if side < 0 else 1), 3, "Y"),
verts=bm.verts)
bm.to_mesh(mesh)
bm.free()
obj = add_obj(name, mesh, mat)
obj.location = (x + side * 0.135, y, r)
return obj
def arch(name, x, y, r, mat):
"""Dark half-shroud over the wheel so it reads as sitting in a well
rather than bolted under a slab."""
mesh = bpy.data.meshes.new(name)
bm = bmesh.new()
res = bmesh.ops.create_cone(bm, cap_ends=False, segments=10,
radius1=r * 1.22, radius2=r * 1.22, depth=0.30)
bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "Y"), verts=bm.verts)
# keep only the top half of the drum
doomed = [v for v in bm.verts if v.co.z < 0.05]
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
bm.to_mesh(mesh)
bm.free()
obj = add_obj(name, mesh, mat)
obj.location = (x, y, r * 0.95)
return obj
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)
# chrome > true black plastic > body-tone dark; 80s econoboxes are black
bumper_mat = chrome if s["chrome_bumpers"] else (dark if s.get("black_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))
# hugs the warped body: mirrors skip warp_obj (they stay proud), so
# anchor them to roughly where the tapered flank actually ends up
parts.append(box("det_mirror_%s" % side, (sx * (hw * 0.94 + 0.08), 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))
if s.get("louvres") and s["hatch"]:
# venetian-blind slat stack over the hatch glass; every slat is its own
# det_ part, so a rear-ender sheds them one by one like a dropped pie
ty, tz = boot_y + s["roof_rear"], s["roof"] # glass top edge
ly, lz = rr_y, rr_z # glass bottom edge
g_ang = math.atan2(tz - lz, ty - ly)
for i in range(5):
t = 0.14 + i * 0.18
sy = ty + (ly - ty) * t
sz = tz + (lz - tz) * t + 0.045
parts.append(box("det_louvre_%d" % i, (0, sy, sz),
(W * 0.80, 0.20, 0.022), dark, rot_x=-g_ang * 0.45))
if s.get("van"): # cargo box from cab rear to tail, plus rip-off tailgate
parts.append(box("van_box", (0, (boot_y + r) / 2, (s["belt"] + s["roof"]) / 2),
(W * 0.94, boot_y - r - 0.05, s["roof"] - s["belt"]), paint))
parts.append(box("det_tailgate", (0, r - 0.03, (s["belt"] + s["roof"]) / 2 - 0.04),
(W * 0.72, 0.05, s["roof"] - s["belt"] - 0.22), paint_dk))
if s.get("ute"): # open tray behind the cab: visible floor + rip-off tailgate
tray_f = boot_y + 0.08 # tray starts where the cab glass ends
parts.append(box("tray_floor", (0, (tray_f + r + 0.15) / 2, s["belt"] - 0.28),
(W * 0.82, tray_f - r - 0.25, 0.05), paint_dk))
parts.append(box("tray_bulkhead", (0, tray_f, (s["belt"] - 0.28 + s["belt"]) / 2 + 0.06),
(W * 0.82, 0.05, 0.34), paint_dk))
parts.append(box("det_tailgate", (0, r + 0.10, s["belt"] - 0.12),
(W * 0.76, 0.05, 0.30), paint_dk))
# 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"] and not s.get("van") and not s.get("ute"):
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))
grille_w = W * (0.78 if s.get("wide_grille") else 0.5) # wide = lights sit in a dark bezel band
trim += [
box("trim_grille", (0, f + 0.01, s["nose"] - 0.14), (grille_w, 0.05, 0.14), 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)),
]
if s.get("indicators"):
amber = material("amber", (0.95, 0.55, 0.05, 1))
trim.append(box("trim_ind_l", (-W * 0.44, f + 0.02, s["nose"] - 0.13), (0.11, 0.05, 0.12), amber))
trim.append(box("trim_ind_r", (W * 0.44, f + 0.02, s["nose"] - 0.13), (0.11, 0.05, 0.12), amber))
if s.get("side_strip"):
strip_len = L * 0.80
for sx in (-1, 1):
trim.append(box("trim_strip_%s" % ("l" if sx < 0 else "r"),
(sx * (hw + 0.015), -L * 0.02, 0.52), (0.03, strip_len, 0.055), dark))
# licence plates, exhaust and door handles -- the cheap details that read
plate_f = material("plate_f", (0.88, 0.88, 0.84, 1), rough=0.5)
plate_r = material("plate_r", (0.82, 0.74, 0.38, 1), rough=0.5)
trim.append(box("trim_plate_f", (0, f + 0.09, bottom + 0.17), (0.36, 0.02, 0.11), plate_f))
trim.append(box("trim_plate_r", (0, r - 0.09, bottom + 0.17), (0.36, 0.02, 0.11), plate_r))
ex = bpy.data.meshes.new("exhaust")
bmx = bmesh.new()
bmesh.ops.create_cone(bmx, cap_ends=True, segments=8, radius1=0.045, radius2=0.045, depth=0.3)
bmesh.ops.rotate(bmx, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "X"), verts=bmx.verts)
bmx.to_mesh(ex)
bmx.free()
exo = add_obj("trim_exhaust", ex, dark)
exo.location = (-W * 0.28, r - 0.02, bottom - 0.04)
trim.append(exo)
span_h = (ws_base - 0.05) - (boot_y + 0.05)
for sx in (-1, 1):
for k in range(1 if s.get("doors", 4) == 2 else 2):
hy = ws_base - 0.05 - span_h * (0.30 + 0.5 * k)
trim.append(box("trim_handle", (sx * hw, hy, s["belt"] - 0.10),
(0.05, 0.14, 0.03), chrome))
# B-pillar over the one-piece glasshouse so the cabin reads as windows, not canopy
if s.get("doors", 4) != 2:
py = ws_base - 0.05 - span_h / 2
for sx in (-1, 1):
trim.append(box("trim_bpillar", (sx * hw * 0.82, py, (s["belt"] + s["roof"]) / 2),
(0.035, 0.07, s["roof"] - s["belt"] - 0.02), paint_dk))
# sculpt: bevel the slab edges, then taper/tumblehome/tuck everything but
# mirrors (stay proud) and wheels (positioned by object, not verts)
bevel_body(obj=body)
for o in [body] + parts + trim:
if "mirror" in o.name:
continue
warp_obj(o, L, s["belt"], s["roof"])
def plan_at(y):
t = (y - r) / L
return 1.0 - 0.11 * smoothstep((t - 0.70) / 0.30) ** 1.4 \
- 0.07 * smoothstep((0.16 - t) / 0.16) ** 1.4
ax_f, ax_r = f - s["ohang_f"], r + s["ohang_r"]
wheels = []
for wn, wx_s, ay in (("fl", -1, ax_f), ("fr", 1, ax_f), ("rl", -1, ax_r), ("rr", 1, ax_r)):
wx = (hw - 0.10) * plan_at(ay)
wheels.append(wheel("wheel_%s" % wn, wx_s * wx, ay, s["wheel"], tyre, material("rim", (0.28, 0.28, 0.30, 1), rough=0.6)))
wheels.append(hubcap("det_hub_%s" % wn, wx_s * wx, ay, s["wheel"], wx_s, chrome))
wheels.append(arch("arch_%s" % wn, wx_s * wx, ay, s["wheel"], dark))
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)
def build_moke(car_id, s):
"""Open-top Moke: slab tub, no doors, exposed seats, rip-off canopy."""
wipe()
paint = material("paint", s["paint"], rough=0.45)
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)
canvas = material("canvas", (0.07, 0.07, 0.07, 1), rough=0.95)
L, W = s["L"], s["W"]
f, r, hw = L / 2, -L / 2, W / 2
bottom = 0.25
tub = s["tub"]
ws_base = f - s["bonnet"]
body = extrude_profile("body", [
(f, bottom + 0.1), (f, s["nose"]), (f - 0.15, tub), (r + 0.05, tub),
(r, s["nose"]), (r, bottom + 0.1), (r + 0.15, bottom), (f - 0.15, bottom),
], hw, paint)
parts = [box("windscreen", (0, ws_base, tub + 0.26), (W * 0.88, 0.04, 0.5), glass, rot_x=0.12)]
canopy_len = ws_base - r - 0.05
parts.append(box("det_roof", (0, (ws_base + r) / 2, s["canopy"]), (W * 0.98, canopy_len, 0.06), canvas))
for py in (ws_base, r + 0.15):
for sx in (-1, 1):
parts.append(box("post", (sx * (hw - 0.08), py, (tub + s["canopy"]) / 2),
(0.05, 0.05, s["canopy"] - tub), dark))
seats = [(ws_base - 0.55, -0.33), (ws_base - 0.55, 0.33)]
if s.get("rear_bench"):
seats += [(ws_base - 1.45, -0.33), (ws_base - 1.45, 0.33)]
for i, (sy, sx) in enumerate(seats):
parts.append(box("trim_seat%d" % i, (sx, sy, tub + 0.10), (0.45, 0.45, 0.22), dark))
parts.append(box("trim_seatback%d" % i, (sx, sy - 0.22, tub + 0.32), (0.45, 0.08, 0.42), dark))
for side, sx in (("l", -1), ("r", 1)):
parts.append(box("det_mirror_%s" % side, (sx * (hw + 0.07), ws_base + 0.02, tub + 0.42),
(0.12, 0.08, 0.12), dark))
parts.append(box("det_bumper_f", (0, f + 0.05, bottom + 0.14), (W + 0.04, 0.13, 0.13), chrome))
parts.append(box("det_bumper_r", (0, r - 0.05, bottom + 0.14), (W + 0.04, 0.13, 0.13), chrome))
parts.append(box("det_bonnet", (0, (f - 0.12 + ws_base) / 2, tub + 0.015),
(W * 0.6, f - 0.12 - ws_base - 0.05, 0.04), paint))
trim = [
box("trim_grille", (0, f + 0.01, s["nose"] - 0.13), (W * 0.45, 0.05, 0.11), dark),
box("trim_light_fl", (-W * 0.33, f + 0.02, s["nose"] - 0.12), (0.18, 0.05, 0.14), material("lf", LIGHT_F)),
box("trim_light_fr", (W * 0.33, f + 0.02, s["nose"] - 0.12), (0.18, 0.05, 0.14), material("lf2", LIGHT_F)),
box("trim_light_rl", (-W * 0.32, r - 0.02, s["nose"] - 0.12), (0.2, 0.05, 0.11), material("lr", LIGHT_R)),
box("trim_light_rr", (W * 0.32, r - 0.02, s["nose"] - 0.12), (0.2, 0.05, 0.11), material("lr2", LIGHT_R)),
]
ax_f, ax_r = f - s["ohang_f"], r + s["ohang_r"]
wx = hw - 0.09
axles = [ax_f, ax_r] + [r + e for e in s.get("extra_axles", [])]
wheels = []
for i, ay in enumerate(axles):
rim = material("rim%d" % i, (0.28, 0.28, 0.30, 1), rough=0.6)
wheels.append(wheel("wheel_%dl" % i, -wx, ay, s["wheel"], tyre, rim))
wheels.append(wheel("wheel_%dr" % i, wx, ay, s["wheel"], tyre, rim))
for obj in parts + trim + wheels:
obj.parent = body
body.location.z = -0.75
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)
only = set(sys.argv[sys.argv.index("--") + 1:]) if "--" in sys.argv else set()
built = 0
for car_id, spec in CARS.items():
if only and car_id not in only:
continue
if spec.get("style") == "moke":
build_moke(car_id, spec)
else:
build(car_id, spec)
built += 1
print("done: %d cars" % built)