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>
565 lines
22 KiB
Python
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()
|