destroyulator/tools/gen_viewmodel.py
Monster Robot Party 61757d24bc LANE6: rigged FPS viewmodel, weapon loadout + material matrix, 4 HUD styles
Hands/POV
- Cut a real first-person arms rig out of the GODVERSE modular character kit
  (tools/gen_fps_arms.py): ch01 hands + per-side sleeves on the full 65-bone
  mixamorig skeleton, so all 20 finger bones per hand are poseable at runtime.
  Textures shrunk to 1k; 4.2 MB.
- ViewModel.gd instances that rig ONCE PER HAND and places each instance so its
  own hand bone lands on the grip — no IK. Grip orientation is measured off the
  rig at load (pinky->index knuckle = bore axis, elbow->hand = forearm dir), so
  it survives re-tuning grip_rest_rot instead of needing new euler angles.
- Motion layers: look-sway with spring-back, walk bob scaled by speed and weapon
  heft, idle breathe, landing dip, weapon lower/raise on swap.
- Sleeve material overridden (donor asset is a fantasy leather bracer); hand
  material forced non-metallic (its spec/gloss maps rendered skin as bronze).

Weapons
- Weapon.gd replaces MeleeAttack: 6 weapons, 4 swing archetypes, and the
  weapon-vs-material matrix from the founding chat.
- Smashable moves from binary hits_to_break to hp/toughness, giving three
  outcomes: break, dent, or futile (dead clank, no score, HUD nudge). The box
  cutter genuinely shreds cardboard and genuinely cannot hurt a filing cabinet.
- The hit lands at Weapon.contact THROUGH the swing, not on the click — that
  delay is most of why the sledge feels different from the cutter.
- Slots on 1-6 / wheel / Q; game modes moved to M, HUD cycle to H.

HUD
- Hud.gd: Arcade, Minimal, Work Order (a corporate destruction docket that fills
  in line items) and Dev, over one shared data feed. Scoring + combo multipliers.

Dev harness (not shipped)
- macOS screen-recording perms aren't available to the CLI, so the game records
  itself: dev/demo.tscn + DemoDriver.gd drive a scripted tour for --write-movie,
  and dev/probe_*.gd print rig/scale/placement numbers.

Fix: tools/gen_viewmodel.py box() scaled by size/2 on top of primitive_cube_add's
already-unit side length, halving every box — which detached the bat's blade.

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

565 lines
22 KiB
Python

"""Generate Destroyulator's first-person WEAPON meshes as GLBs, procedurally, in Blender.
Why procedural instead of a generated mesh: a viewmodel weapon lives or dies on its
PIVOT. The grip has to sit exactly on the shaft axis, at the origin, or the hand floats
off the bat. That is a modelling constraint, not an art one, so it is authored in code
where the numbers are exact and re-runnable.
Hands and arms are NOT built here — see tools/gen_fps_arms.py, which cuts a properly
rigged pair (with per-finger mixamorig bones) out of the GODVERSE modular character kit.
build_hand()/build_arm() survive below as a no-dependency fallback only.
/Applications/Blender.app/Contents/MacOS/Blender --background \
--python tools/gen_viewmodel.py -- [--render]
CONVENTIONS (shared with ViewModel.gd — change both or neither)
* Author in Blender Z-up; the glTF exporter rewrites to Y-up, so Blender +Z becomes
glTF +Y. Everything below is described in BLENDER space.
* Weapons: grip centre at the ORIGIN, shaft running +Z (so the head is up). ViewModel
slides a second hand up a two-hander by translating along that axis alone.
* 1 unit = 1 m, matching the GLB convention in the repo README.
"""
import bpy
import bmesh
import math
import os
import sys
from mathutils import Vector, Matrix
OUT_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"game", "assets", "viewmodel")
RENDER = "--render" in sys.argv
# Previews are build artefacts, not game assets — keep them OUT of the Godot project
# or the importer picks them up as textures.
PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews")
# ---------------------------------------------------------------- scene helpers
def reset_scene():
bpy.ops.wm.read_factory_settings(use_empty=True)
def mat(name, color, rough=0.6, metal=0.0):
"""A plain Principled material. Colors are linear-ish sRGB values."""
m = bpy.data.materials.new(name)
m.use_nodes = True
bsdf = m.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0)
bsdf.inputs["Roughness"].default_value = rough
bsdf.inputs["Metallic"].default_value = metal
return m
def assign(obj, material):
obj.data.materials.clear()
obj.data.materials.append(material)
return obj
def shade_smooth(obj, angle_deg=35.0):
"""Smooth-by-angle. Blender 4.1+ dropped mesh.use_auto_smooth for an operator."""
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
try:
bpy.ops.object.shade_auto_smooth(angle=math.radians(angle_deg))
except Exception:
bpy.ops.object.shade_smooth()
obj.select_set(False)
def bevel(obj, width=0.0015, segments=2):
m = obj.modifiers.new("bevel", "BEVEL")
m.width = width
m.segments = segments
m.limit_method = 'ANGLE'
m.angle_limit = math.radians(40)
def join(objs, name):
"""Join a list of meshes into one object (the first is the target)."""
objs = [o for o in objs if o is not None]
if not objs:
return None
if len(objs) == 1:
objs[0].name = name
return objs[0]
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
bpy.ops.object.join()
joined = bpy.context.view_layer.objects.active
joined.name = name
bpy.ops.object.select_all(action='DESELECT')
return joined
def apply_modifiers(obj):
bpy.context.view_layer.objects.active = obj
for m in list(obj.modifiers):
try:
bpy.ops.object.modifier_apply(modifier=m.name)
except Exception:
obj.modifiers.remove(m)
# ---------------------------------------------------------------- primitive builders
def cyl(r, depth, loc=(0, 0, 0), rot=(0, 0, 0), verts=16, r2=None):
"""Cylinder / tapered cone along +Z, centred on `loc`."""
if r2 is None:
bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=depth, vertices=verts,
location=loc, rotation=rot)
else:
bpy.ops.mesh.primitive_cone_add(radius1=r, radius2=r2, depth=depth,
vertices=verts, location=loc, rotation=rot)
return bpy.context.active_object
def box(size, loc=(0, 0, 0), rot=(0, 0, 0)):
# primitive_cube_add(size=s) already spans -s/2..+s/2, i.e. SIDE LENGTH s.
# Scaling by size/2 on top of that halves every box — which is exactly what
# detached the cricket bat's blade from its handle the first time round.
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot)
o = bpy.context.active_object
o.scale = (size[0], size[1], size[2])
bpy.ops.object.transform_apply(scale=True)
return o
def ball(r, loc=(0, 0, 0), segs=12, rings=8):
bpy.ops.mesh.primitive_uv_sphere_add(radius=r, segments=segs, ring_count=rings,
location=loc)
return bpy.context.active_object
def segment_between(p0, p1, r0, r1, verts=10):
"""A tapered tube from p0 to p1. Used for arm bones."""
p0, p1 = Vector(p0), Vector(p1)
d = p1 - p0
length = d.length
if length < 1e-6:
return None
o = cyl(r0, length, loc=(0, 0, 0), verts=verts, r2=r1)
quat = Vector((0, 0, 1)).rotation_difference(d.normalized())
o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ quat.to_matrix().to_4x4()
bpy.ops.object.transform_apply(location=True, rotation=True)
return o
def seg_box(p0, p1, thick, width, bev=0.006, segs=3):
"""A heavily-bevelled box spanning p0->p1. `thick` is the radial dimension,
`width` the across-the-knuckles one. This is the glove's building block: fat
rounded slabs, not thin tubes, so adjacent fingers read as one mitt."""
p0, p1 = Vector(p0), Vector(p1)
d = p1 - p0
length = d.length
if length < 1e-6:
return None
o = box((thick, length, width))
quat = Vector((0, 1, 0)).rotation_difference(d.normalized())
o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ quat.to_matrix().to_4x4()
bpy.ops.object.transform_apply(location=True, rotation=True)
bevel(o, width=bev, segments=segs)
apply_modifiers(o)
return o
# ---------------------------------------------------------------- the hand
#
# A chunky WORK GLOVE, not an anatomical hand. Two reasons: a viewmodel hand is 40 cm
# from the lens and half off-screen, where big readable masses beat fourteen thin
# phalanges (which just read as loose sausages); and a work glove is exactly right for
# a game about clocking out and wrecking your workplace.
#
# Grip bore axis = +Z, palm on -X, fingers wrap over +Y -> +X -> -Y.
# `curl` scales how far the fingers close: 1.0 = fist around a handle, 0.0 = flat.
FINGER_Z = [0.033, 0.011, -0.011, -0.033] # index -> pinky, along the grip
FINGER_LEN = [
(0.030, 0.023, 0.017),
(0.032, 0.025, 0.018),
(0.029, 0.023, 0.017),
(0.024, 0.019, 0.014),
]
FINGER_W = [0.022, 0.023, 0.022, 0.019] # nearly touching -> one mitt
FINGER_T = [0.021, 0.022, 0.021, 0.018] # radial thickness
GRIP_R = 0.019 # radius of the handle it closes on
def build_finger(z, lengths, width, thick, curl, start_ang_deg, ring_r):
"""FK chain of 3 fat phalanges wrapping clockwise around the grip axis."""
parts = []
ang = math.radians(start_ang_deg)
knuckle_ang = math.radians(start_ang_deg + 90.0)
pos = Vector((ring_r * math.cos(knuckle_ang), ring_r * math.sin(knuckle_ang), z))
curls = [math.radians(50.0), math.radians(52.0), math.radians(44.0)]
for i, ln in enumerate(lengths):
d = Vector((math.cos(ang), math.sin(ang), 0.0))
nxt = pos + d * ln
taper = 1.0 - i * 0.09
# overshoot each segment slightly so consecutive knuckles overlap and read solid
s = seg_box(pos - d * 0.004, nxt + d * 0.004,
thick * taper, width * taper, bev=0.0065, segs=3)
if s:
parts.append(s)
pos = nxt
ang -= curls[i] * curl
return parts
def build_hand(curl=1.0, name="hand_grip", skin=None, sleeve=None):
"""A right hand. The left is the same mesh mirrored by scale in Godot."""
parts = []
# --- palm: one rounded slab from the heel up to the knuckles ---
palm = box((0.034, 0.082, 0.101), loc=(-(GRIP_R + 0.014), 0.000, -0.002))
bevel(palm, width=0.014, segments=4)
apply_modifiers(palm)
parts.append(palm)
# heel, overlapping the palm so the two merge into one mass toward the wrist
heel = box((0.036, 0.062, 0.086), loc=(-(GRIP_R + 0.012), -0.012, -0.040))
bevel(heel, width=0.016, segments=4)
apply_modifiers(heel)
parts.append(heel)
# --- four fingers ---
ring_r = GRIP_R + FINGER_T[0] * 0.5
for i, z in enumerate(FINGER_Z):
start = 24.0 + (1.0 - curl) * 30.0 # opening the hand unwinds the start
parts += build_finger(z, FINGER_LEN[i], FINGER_W[i], FINGER_T[i],
curl, start, ring_r)
# --- thumb: two chunky segments crossing the front of the fingers ---
t_pos = Vector((-(GRIP_R + 0.008), -0.030, 0.048))
t_ang = math.radians(62.0 - 44.0 * curl)
for i, ln in enumerate((0.036, 0.028)):
d = Vector((math.cos(t_ang), math.sin(t_ang), 0.0))
nxt = t_pos + d * ln
s = seg_box(t_pos - d * 0.005, nxt + d * 0.004,
0.026 - i * 0.003, 0.027 - i * 0.003, bev=0.0085, segs=3)
if s:
parts.append(s)
t_pos = nxt
t_ang -= math.radians(44.0) * curl
hand = join(parts, name)
assign(hand, skin)
shade_smooth(hand, 34.0)
# --- wrist cuff: overlaps the heel so the glove meets the sleeve with no gap ---
cuff = cyl(0.040, 0.052, loc=(-0.014, -0.006, -0.066), r2=0.044, verts=18)
bevel(cuff, width=0.004, segments=2)
apply_modifiers(cuff)
assign(cuff, sleeve)
shade_smooth(cuff, 34.0)
cuff.name = name + "_cuff"
return hand, cuff
# ---------------------------------------------------------------- arm segments
def build_arm(skin, sleeve):
"""Upper arm and forearm, origin at the proximal joint, extending +Z."""
out = {}
# forearm: elbow -> wrist, 0.26 m, tapering, with the shirt cuff rolled at the elbow
fore = segment_between((0, 0, 0), (0, 0, 0.255), 0.049, 0.033, verts=14)
fore.name = "forearm"
assign(fore, skin)
shade_smooth(fore, 40.0)
roll = cyl(0.056, 0.052, loc=(0, 0, 0.012), r2=0.050, verts=16)
assign(roll, sleeve)
shade_smooth(roll, 40.0)
roll.name = "forearm_sleeve"
out["forearm"] = [fore, roll]
# upper arm: shoulder -> elbow, sleeved almost the whole way (short-sleeve tee)
upper = segment_between((0, 0, 0), (0, 0, 0.275), 0.058, 0.050, verts=14)
upper.name = "upperarm"
assign(upper, skin)
shade_smooth(upper, 40.0)
sleeve_m = segment_between((0, 0, -0.010), (0, 0, 0.150), 0.068, 0.058, verts=16)
sleeve_m.name = "upperarm_sleeve"
assign(sleeve_m, sleeve)
shade_smooth(sleeve_m, 40.0)
out["upperarm"] = [upper, sleeve_m]
return out
# ---------------------------------------------------------------- weapons
# Grip centre at origin, shaft +Z. Each returns a list of objects to join.
def w_bat(M):
"""Cricket bat — the founding doc's first weapon. Willow blade, rubber grip."""
parts = []
handle = cyl(0.0175, 0.30, loc=(0, 0, 0.09), r2=0.0195, verts=14)
parts.append(assign(handle, M["rubber"]))
for i in range(7): # grip rings
r = cyl(0.0198, 0.008, loc=(0, 0, -0.045 + i * 0.030), verts=14)
parts.append(assign(r, M["rubber"]))
shoulder = cyl(0.021, 0.075, loc=(0, 0, 0.276), r2=0.030, verts=14)
parts.append(assign(shoulder, M["willow"]))
blade = box((0.108, 0.042, 0.430), loc=(0, 0.004, 0.525))
bevel(blade, width=0.006, segments=2)
apply_modifiers(blade)
parts.append(assign(blade, M["willow"]))
spine = box((0.052, 0.030, 0.380), loc=(0, -0.030, 0.520)) # the ridge on the back
bevel(spine, width=0.010, segments=2)
apply_modifiers(spine)
parts.append(assign(spine, M["willow"]))
toe = box((0.108, 0.042, 0.030), loc=(0, 0.004, 0.742))
bevel(toe, width=0.012, segments=2)
apply_modifiers(toe)
parts.append(assign(toe, M["willow"]))
return parts
def w_sledge(M):
"""Sledgehammer — slow, enormous, the doc's answer to heavy wooden racks."""
parts = []
haft = cyl(0.019, 0.78, loc=(0, 0, 0.30), r2=0.024, verts=14)
parts.append(assign(haft, M["hickory"]))
for i in range(5):
r = cyl(0.0205, 0.010, loc=(0, 0, -0.075 + i * 0.036), verts=14)
parts.append(assign(r, M["rubber"]))
head = box((0.098, 0.098, 0.215), loc=(0, 0, 0.700), rot=(math.radians(90), 0, 0))
bevel(head, width=0.007, segments=2)
apply_modifiers(head)
parts.append(assign(head, M["steel"]))
collar = cyl(0.030, 0.055, loc=(0, 0, 0.678), verts=14)
parts.append(assign(collar, M["steel"]))
for s in (-1, 1): # slightly domed striking faces
face = cyl(0.043, 0.016, loc=(0, s * 0.109, 0.700),
rot=(math.radians(90), 0, 0), verts=16)
parts.append(assign(face, M["steel_dark"]))
return parts
def w_crowbar(M):
"""Crowbar — medium speed, bites into steel. Painted red, worn to bare metal."""
parts = []
shaft = cyl(0.0115, 0.62, loc=(0, 0, 0.18), verts=6) # hex stock
parts.append(assign(shaft, M["paint_red"]))
grip = cyl(0.0135, 0.14, loc=(0, 0, -0.055), verts=6)
parts.append(assign(grip, M["rubber"]))
# the curved claw: short chords stepping through ~85 degrees
ang = 0.0
pos = Vector((0, 0, 0.49))
for i in range(6):
d = Vector((0.0, math.sin(ang), math.cos(ang)))
nxt = pos + d * 0.030
s = segment_between(tuple(pos), tuple(nxt), 0.0115, 0.0112, verts=6)
if s:
parts.append(assign(s, M["steel"]))
pos = nxt
ang += math.radians(15.5)
claw = box((0.026, 0.050, 0.014), loc=(0, pos.y + 0.020, pos.z + 0.004),
rot=(math.radians(-22), 0, 0))
bevel(claw, width=0.003, segments=2)
apply_modifiers(claw)
parts.append(assign(claw, M["steel"]))
chisel = box((0.028, 0.011, 0.055), loc=(0, 0, -0.150), rot=(0, math.radians(9), 0))
bevel(chisel, width=0.003, segments=2)
apply_modifiers(chisel)
parts.append(assign(chisel, M["steel"]))
return parts
def w_cutter(M):
"""Box cutter — useless on furniture, devastating on cardboard and paper."""
parts = []
body = box((0.020, 0.038, 0.150), loc=(0, 0, 0.030))
bevel(body, width=0.005, segments=2)
apply_modifiers(body)
parts.append(assign(body, M["plastic_yellow"]))
track = box((0.022, 0.012, 0.100), loc=(0, 0.016, 0.030))
parts.append(assign(track, M["steel_dark"]))
slider = box((0.014, 0.010, 0.022), loc=(0, 0.024, 0.020))
parts.append(assign(slider, M["steel"]))
blade = box((0.010, 0.030, 0.062), loc=(0, 0.002, 0.132), rot=(math.radians(-8), 0, 0))
parts.append(assign(blade, M["blade"]))
tip = box((0.010, 0.020, 0.020), loc=(0, -0.006, 0.168), rot=(math.radians(-32), 0, 0))
parts.append(assign(tip, M["blade"]))
return parts
def w_extinguisher(M):
"""Fire extinguisher — heavy two-hand swing, and the best throwable in the store."""
parts = []
bottle = cyl(0.058, 0.330, loc=(0, 0, -0.035), verts=20)
parts.append(assign(bottle, M["paint_red"]))
for z, r in ((-0.200, 0.052), (0.130, 0.050)): # domed ends
d = ball(r, loc=(0, 0, z), segs=20, rings=10)
d.scale = (1.12, 1.12, 0.62)
bpy.ops.object.transform_apply(scale=True)
parts.append(assign(d, M["paint_red"]))
band = cyl(0.060, 0.045, loc=(0, 0, 0.020), verts=20)
parts.append(assign(band, M["paint_dark"]))
neck = cyl(0.020, 0.070, loc=(0, 0, 0.175), verts=14)
parts.append(assign(neck, M["steel_dark"]))
head = box((0.048, 0.062, 0.048), loc=(0, 0, 0.212))
bevel(head, width=0.005, segments=2)
apply_modifiers(head)
parts.append(assign(head, M["steel_dark"]))
lever = box((0.030, 0.100, 0.014), loc=(0, 0.034, 0.240), rot=(math.radians(10), 0, 0))
bevel(lever, width=0.004, segments=2)
apply_modifiers(lever)
parts.append(assign(lever, M["steel"]))
carry = box((0.028, 0.086, 0.013), loc=(0, 0.030, 0.196))
bevel(carry, width=0.004, segments=2)
apply_modifiers(carry)
parts.append(assign(carry, M["steel_dark"]))
# hose looping down the side
pos = Vector((0.0, 0.058, 0.196))
ang = math.radians(96)
for i in range(7):
d = Vector((0.0, math.cos(ang), -math.sin(ang)))
nxt = pos + d * 0.042
s = segment_between(tuple(pos), tuple(nxt), 0.010, 0.010, verts=6)
if s:
parts.append(assign(s, M["rubber"]))
pos = nxt
ang -= math.radians(13)
horn = cyl(0.014, 0.070, loc=tuple(pos + Vector((0, 0.012, -0.030))), r2=0.030, verts=14)
parts.append(assign(horn, M["paint_dark"]))
return parts
# ---------------------------------------------------------------- export / render
def export(obj_or_objs, name):
objs = obj_or_objs if isinstance(obj_or_objs, list) else [obj_or_objs]
objs = [o for o in objs if o is not None]
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
path = os.path.join(OUT_DIR, name + ".glb")
bpy.ops.export_scene.gltf(
filepath=path,
export_format='GLB',
use_selection=True,
export_apply=True,
export_yup=True,
)
tris = sum(len(o.data.loop_triangles) if o.data.loop_triangles else 0 for o in objs)
print("[gen] %-18s -> %s" % (name, os.path.basename(path)))
bpy.ops.object.select_all(action='DESELECT')
def render_preview(name):
"""Optional turnaround still, so the generator can be checked without Godot."""
scene = bpy.context.scene
# engine id moved around across versions (EEVEE -> EEVEE_NEXT -> EEVEE); take what exists
engines = scene.render.bl_rna.properties['engine'].enum_items.keys()
for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
if want in engines:
scene.render.engine = want
break
scene.render.resolution_x = 640
scene.render.resolution_y = 640
scene.render.film_transparent = False
# read_factory_settings(use_empty=True) leaves no world, so renders come out black
if scene.world is None:
scene.world = bpy.data.worlds.new("preview")
scene.world.use_nodes = True
bg = scene.world.node_tree.nodes.get("Background")
if bg is not None:
bg.inputs[0].default_value = (0.05, 0.05, 0.06, 1.0)
bg.inputs[1].default_value = 1.0
# --- frame whatever is in the scene: these assets range 13 cm to 85 cm, so a
# --- fixed camera either crops the sledge or loses the box cutter in the distance.
meshes = [o for o in scene.objects if o.type == 'MESH']
if not meshes:
return
lo = Vector((1e9, 1e9, 1e9))
hi = Vector((-1e9, -1e9, -1e9))
for o in meshes:
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z)))
hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z)))
centre = (lo + hi) * 0.5
radius = max((hi - lo).length * 0.5, 0.02)
dist = radius * 3.0
direction = Vector((0.62, -0.72, 0.36)).normalized()
bpy.ops.object.camera_add(location=tuple(centre + direction * dist))
cam = bpy.context.active_object
cam.data.lens = 55
# point the camera down its -Z at the centre
cam.rotation_mode = 'QUATERNION'
cam.rotation_quaternion = (-direction).to_track_quat('-Z', 'Y')
scene.camera = cam
# lights scale with the subject so a 13 cm cutter isn't lit like an 85 cm sledge
for offset, energy, size in ((Vector((1.1, -1.0, 1.2)), 55.0, 1.2),
(Vector((-1.2, -0.5, 0.2)), 16.0, 1.8)):
bpy.ops.object.light_add(type='AREA',
location=tuple(centre + offset * dist))
L = bpy.context.active_object
L.data.energy = energy * (dist ** 2)
L.data.size = size * radius
scene.render.filepath = os.path.join(PREVIEW_DIR, "" + name + ".png")
bpy.ops.render.render(write_still=True)
def materials():
return {
"skin": mat("skin", (0.78, 0.55, 0.44), rough=0.72),
"sleeve": mat("sleeve", (0.13, 0.13, 0.17), rough=0.85),
"willow": mat("willow", (0.80, 0.68, 0.47), rough=0.62),
"hickory": mat("hickory", (0.52, 0.36, 0.20), rough=0.68),
"rubber": mat("rubber", (0.07, 0.07, 0.08), rough=0.92),
"steel": mat("steel", (0.62, 0.63, 0.66), rough=0.34, metal=1.0),
"steel_dark": mat("steel_dark", (0.24, 0.25, 0.28), rough=0.46, metal=1.0),
"blade": mat("blade", (0.86, 0.87, 0.90), rough=0.16, metal=1.0),
"paint_red": mat("paint_red", (0.62, 0.06, 0.06), rough=0.40),
"paint_dark": mat("paint_dark", (0.10, 0.10, 0.12), rough=0.55),
"plastic_yellow": mat("plastic_yellow", (0.85, 0.66, 0.10), rough=0.44),
}
WEAPONS = {
"bat": w_bat,
"sledge": w_sledge,
"crowbar": w_crowbar,
"cutter": w_cutter,
"extinguisher": w_extinguisher,
}
def main():
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(PREVIEW_DIR, exist_ok=True)
# Hands and arms are NOT built here — they come from the GODVERSE modular
# character kit via tools/gen_fps_arms.py, which yields a properly rigged pair
# with per-finger mixamorig bones. The procedural glove this script used to emit
# was a stopgap and read as loose sausages next to the real thing.
# build_hand()/build_arm() are kept below as a no-dependency fallback.
# --- weapons ---
for name, builder in WEAPONS.items():
reset_scene()
M = materials()
parts = builder(M)
for p in parts:
apply_modifiers(p)
export(parts, name)
if RENDER:
render_preview(name)
print("[gen] done -> %s" % OUT_DIR)
main()