import sys import os import math import struct import zlib import zipfile import shutil try: import bpy import mathutils except ImportError: print("[Error] This script must be run inside Blender's python environment.") sys.exit(1) def strip_gltf_extensions(glb_path): """Strip unsupported extensions like EXT_texture_webp from GLB headers.""" try: with open(glb_path, "rb") as f: data = f.read() if len(data) < 20 or data[:4] != b'glTF': return json_len = int.from_bytes(data[12:16], byteorder='little') json_bytes = data[20:20+json_len] import json gltf_json = json.loads(json_bytes.decode('utf-8', errors='ignore')) modified = False for key in ['extensionsRequired', 'extensionsUsed']: if key in gltf_json and 'EXT_texture_webp' in gltf_json[key]: gltf_json[key].remove('EXT_texture_webp') modified = True if modified: new_bytes = json.dumps(gltf_json).encode('utf-8') if len(new_bytes) <= len(json_bytes): new_bytes = new_bytes.ljust(len(json_bytes), b' ') new_data = data[:20] + new_bytes + data[20+len(json_bytes):] with open(glb_path, "wb") as f: f.write(new_data) except Exception as e: print(f"[GMod Exporter] Extension strip note: {e}") def write_smd_reference(filepath, mesh_obj, armature_obj, bones_map): """Writes reference.smd (visual mesh with full weights, UVs, normals).""" mesh = mesh_obj.data bpy.context.view_layer.objects.active = mesh_obj bpy.ops.object.mode_set(mode='OBJECT') uv_layer = mesh.uv_layers.active.data if mesh.uv_layers.active else None with open(filepath, "w", encoding="utf-8") as f: f.write("version 1\n") f.write("nodes\n") for b_name, b_idx in bones_map.items(): parent_idx = -1 if armature_obj and b_name in armature_obj.data.bones: p = armature_obj.data.bones[b_name].parent if p and p.name in bones_map: parent_idx = bones_map[p.name] f.write(f' {b_idx} "{b_name}" {parent_idx}\n') f.write("end\n") f.write("skeleton\n") for t in (0, 1): f.write(f"time {t}\n") for b_name, b_idx in bones_map.items(): if armature_obj and b_name in armature_obj.data.bones: b = armature_obj.data.bones[b_name] if b.parent: local_mat = b.parent.matrix_local.inverted() @ b.matrix_local else: local_mat = b.matrix_local loc, rot, _ = local_mat.decompose() rot_euler = rot.to_euler('XYZ') f.write(f" {b_idx} {loc.x:.6f} {loc.y:.6f} {loc.z:.6f} {rot_euler.x:.6f} {rot_euler.y:.6f} {rot_euler.z:.6f}\n") else: f.write(f" {b_idx} 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000\n") f.write("end\n") f.write("triangles\n") mat_name = "material_ref" if mesh_obj.data.materials and mesh_obj.data.materials[0]: mat_name = mesh_obj.data.materials[0].name.replace(" ", "_") mesh.calc_loop_triangles() mesh_to_arm = (armature_obj.matrix_world.inverted() @ mesh_obj.matrix_world) if (armature_obj and mesh_obj) else mathutils.Matrix.Identity(4) norm_mat = mesh_to_arm.to_3x3().inverted().transposed() for tri in mesh.loop_triangles: f.write(f"{mat_name}\n") for loop_idx, vert_idx in zip(tri.loops, tri.vertices): v = mesh.vertices[vert_idx] co = mesh_to_arm @ v.co no = (norm_mat @ v.normal).normalized() u, v_coord = (0.0, 0.0) if uv_layer: u = uv_layer[loop_idx].uv[0] v_coord = uv_layer[loop_idx].uv[1] # Extract bone weights weight_pairs = [] for g in v.groups: if g.group < len(mesh_obj.vertex_groups): vg_name = mesh_obj.vertex_groups[g.group].name if vg_name in bones_map: weight_pairs.append((bones_map[vg_name], g.weight)) if not weight_pairs: weight_pairs = [(0, 1.0)] total_w = sum(w for _, w in weight_pairs) if total_w > 0: weight_pairs = [(idx, w / total_w) for idx, w in weight_pairs] parent_bone = weight_pairs[0][0] links_count = len(weight_pairs) weights_str = " ".join(f"{idx} {w:.4f}" for idx, w in weight_pairs) f.write(f" {parent_bone} {co.x:.6f} {co.y:.6f} {co.z:.6f} {no.x:.6f} {no.y:.6f} {no.z:.6f} {u:.6f} {v_coord:.6f} {links_count} {weights_str}\n") f.write("end\n") def generate_physics_collision_boxes(mesh_obj, armature_obj, bones_map): """Generates simplified convex collision boxes attached 1:1 per bone for Source Ragdolls.""" phys_mesh_data = bpy.data.meshes.new("PhysicsMesh") phys_obj = bpy.data.objects.new("PhysicsObject", phys_mesh_data) bpy.context.scene.collection.objects.link(phys_obj) verts = [] faces = [] vert_bone_assignments = [] vert_hull_assignments = [] if not armature_obj: bbox_corners = [mesh_obj.matrix_world @ mathutils.Vector(corner) for corner in mesh_obj.bound_box] min_x = min(c.x for c in bbox_corners) max_x = max(c.x for c in bbox_corners) min_y = min(c.y for c in bbox_corners) max_y = max(c.y for c in bbox_corners) min_z = min(c.z for c in bbox_corners) max_z = max(c.z for c in bbox_corners) box_v = [ (min_x, min_y, min_z), (max_x, min_y, min_z), (max_x, max_y, min_z), (min_x, max_y, min_z), (min_x, min_y, max_z), (max_x, min_y, max_z), (max_x, max_y, max_z), (min_x, max_y, max_z) ] box_f = [ (0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1), (1, 5, 6, 2), (2, 6, 7, 3), (3, 7, 4, 0) ] verts.extend(box_v) faces.extend(box_f) vert_bone_assignments.extend([0] * 8) vert_hull_assignments.extend([0] * 8) else: # Keywords to skip micro-bones (fingers, facial, hair) skip_keywords = [ 'thumb', 'index', 'middle', 'ring', 'pinky', 'finger', 'toe', 'twist', 'eye', 'tongue', 'hair', 'cloth', 'leaf', 'nub' ] # Helper to compute subtree height def get_subtree_height(bone): if not bone.children: return 0 return 1 + max(get_subtree_height(ch) for ch in bone.children) # Topological branching detection: terminal extremities (hands/feet: >= 2 children and subtree height <= 3) branching_bones = set() descendant_map = {} banned_finger_bones = set() for b_name in bones_map.keys(): if armature_obj and b_name in armature_obj.data.bones: b = armature_obj.data.bones[b_name] if len(b.children) >= 2 and get_subtree_height(b) <= 3: branching_bones.add(b_name) descendants = [ch.name for ch in b.children_recursive] descendant_map[b_name] = descendants for desc in descendants: banned_finger_bones.add(desc) else: descendant_map[b_name] = [] else: descendant_map[b_name] = [] # Rank bones by vertex count and physical length (strictly excluding micro-descendants) bone_candidates = [] for b_name, b_idx in bones_map.items(): b_lower = b_name.lower() if any(k in b_lower for k in skip_keywords) or b_name in banned_finger_bones: continue # Count influenced vertices (including finger cluster only if it is a branching hand/foot root) cluster_bones = [b_name] + descendant_map.get(b_name, []) vert_count = 0 for cb in cluster_bones: if cb in mesh_obj.vertex_groups: vg_idx = mesh_obj.vertex_groups[cb].index vert_count += sum(1 for v in mesh_obj.data.vertices if any(g.group == vg_idx and g.weight > 0.20 for g in v.groups)) bone_length = 0.0 if armature_obj and b_name in armature_obj.data.bones: b = armature_obj.data.bones[b_name] bone_len = (b.tail_local - b.head_local).length bone_length = max(bone_len, 0.05) # Boost branching roots (wrists/ankles/paws) and primary limb segments so they always get selected branch_bonus = 2000.0 if b_name in branching_bones else 0.0 significance = vert_count + (bone_length * 400.0) + branch_bonus bone_candidates.append({ 'name': b_name, 'idx': b_idx, 'vert_count': vert_count, 'significance': significance, 'cluster': cluster_bones }) # Select active primary collision boxes (optimal 14 hulls) bone_candidates.sort(key=lambda x: x['significance'], reverse=True) active_collision_bones = {} absorbed_bones = set(banned_finger_bones) for cand in bone_candidates: if cand['name'] in absorbed_bones: continue if len(active_collision_bones) >= 14: break # Prevent overlapping internal/torso/parent-child collision boxes too_close = False b_cand = armature_obj.data.bones.get(cand['name']) if armature_obj else None if b_cand and cand['name'] not in branching_bones: for sel_name, sel_cand in active_collision_bones.items(): if sel_name in branching_bones: continue b_sel = armature_obj.data.bones.get(sel_name) if b_sel: # Check ancestry (is ancestor/descendant in same trunk chain) is_related = (b_cand in b_sel.parent_recursive) or (b_sel in b_cand.parent_recursive) dist = (b_cand.head_local - b_sel.head_local).length # Consolidate torso/spine chain or any bones sharing tight proximity (< 0.38m related, < 0.15m any) if (is_related and dist < 0.38) or (dist < 0.15): too_close = True sel_cand['cluster'].extend(cand['cluster']) break if too_close: continue active_collision_bones[cand['name']] = cand for desc in cand['cluster']: if desc != cand['name']: absorbed_bones.add(desc) print(f"[GMod Exporter] Clustered {len(bones_map)} bones into {len(active_collision_bones)} unified primary collision boxes.") for b_name, cand in active_collision_bones.items(): b_idx = cand['idx'] cluster_bones = cand['cluster'] bone = armature_obj.data.bones.get(b_name) if armature_obj else None inv_bone_mat = bone.matrix_local.inverted() if bone else mathutils.Matrix.Identity(4) mesh_to_arm = (armature_obj.matrix_world.inverted() @ mesh_obj.matrix_world) if (armature_obj and mesh_obj) else mathutils.Matrix.Identity(4) # Collect all vertices and transform into bone's LOCAL coordinate space (OBB) local_bone_verts = [] for cb in cluster_bones: if cb in mesh_obj.vertex_groups: vg_idx = mesh_obj.vertex_groups[cb].index for v in mesh_obj.data.vertices: if any(g.group == vg_idx and g.weight > 0.35 for g in v.groups): local_bone_verts.append(inv_bone_mat @ (mesh_to_arm @ v.co)) if not local_bone_verts or len(local_bone_verts) < 3: if bone: bone_len = (bone.tail_local - bone.head_local).length size = max(bone_len * 0.20, 0.03) min_x, max_x = -size, size min_y, max_y = 0.0, bone_len min_z, max_z = -size, size else: continue else: min_x = min(v.x for v in local_bone_verts) max_x = max(v.x for v in local_bone_verts) min_y = min(v.y for v in local_bone_verts) max_y = max(v.y for v in local_bone_verts) min_z = min(v.z for v in local_bone_verts) max_z = max(v.z for v in local_bone_verts) # Calculate center point cx = (min_x + max_x) * 0.5 cy = (min_y + max_y) * 0.5 cz = (min_z + max_z) * 0.5 # Guarantee minimum 3D thickness (>= 0.06m) so StudioMDL never flags 2D geometry (<0.5 inch) hx = max((max_x - min_x) * 0.38, 0.05) hy = max((max_y - min_y) * 0.38, 0.05) hz = max((max_z - min_z) * 0.38, 0.05) min_x, max_x = cx - hx, cx + hx min_y, max_y = cy - hy, cy + hy min_z, max_z = cz - hz, cz + hz box_idx = len(active_collision_bones) start_v_idx = len(verts) # 8 local corners local_box_v = [ mathutils.Vector((min_x, min_y, min_z)), mathutils.Vector((max_x, min_y, min_z)), mathutils.Vector((max_x, max_y, min_z)), mathutils.Vector((min_x, max_y, min_z)), mathutils.Vector((min_x, min_y, max_z)), mathutils.Vector((max_x, min_y, max_z)), mathutils.Vector((max_x, max_y, max_z)), mathutils.Vector((min_x, max_y, max_z)) ] # Transform local corners back to model space oriented by the bone matrix if bone: box_v = [(bone.matrix_local @ lv).to_tuple() for lv in local_box_v] else: box_v = [lv.to_tuple() for lv in local_box_v] box_f = [ (start_v_idx+0, start_v_idx+1, start_v_idx+2, start_v_idx+3), (start_v_idx+4, start_v_idx+7, start_v_idx+6, start_v_idx+5), (start_v_idx+0, start_v_idx+4, start_v_idx+5, start_v_idx+1), (start_v_idx+1, start_v_idx+5, start_v_idx+6, start_v_idx+2), (start_v_idx+2, start_v_idx+6, start_v_idx+7, start_v_idx+3), (start_v_idx+3, start_v_idx+7, start_v_idx+4, start_v_idx+0) ] verts.extend(box_v) faces.extend(box_f) vert_bone_assignments.extend([b_idx] * 8) vert_hull_assignments.extend([len(active_collision_bones) - 1] * 8) phys_mesh_data.from_pydata(verts, [], faces) phys_mesh_data.update() for b_name, b_idx in bones_map.items(): vg = phys_obj.vertex_groups.new(name=b_name) assigned_indices = [i for i, assigned_idx in enumerate(vert_bone_assignments) if assigned_idx == b_idx] if assigned_indices: vg.add(assigned_indices, 1.0, 'REPLACE') return phys_obj, vert_bone_assignments, vert_hull_assignments, list(active_collision_bones.keys()) def write_smd_physics(filepath, phys_obj, armature_obj, bones_map, vert_bone_assignments, vert_hull_assignments): """Writes physics.smd with independent convex hulls and 1:1 bone weights.""" mesh = phys_obj.data mesh.calc_loop_triangles() with open(filepath, "w", encoding="utf-8") as f: f.write("version 1\n") f.write("nodes\n") for b_name, b_idx in bones_map.items(): parent_idx = -1 if armature_obj and b_name in armature_obj.data.bones: p = armature_obj.data.bones[b_name].parent if p and p.name in bones_map: parent_idx = bones_map[p.name] f.write(f' {b_idx} "{b_name}" {parent_idx}\n') f.write("end\n") f.write("skeleton\n") for t in (0, 1): f.write(f"time {t}\n") for b_name, b_idx in bones_map.items(): if armature_obj and b_name in armature_obj.data.bones: b = armature_obj.data.bones[b_name] if b.parent: local_mat = b.parent.matrix_local.inverted() @ b.matrix_local else: local_mat = b.matrix_local loc, rot, _ = local_mat.decompose() rot_euler = rot.to_euler('XYZ') f.write(f" {b_idx} {loc.x:.6f} {loc.y:.6f} {loc.z:.6f} {rot_euler.x:.6f} {rot_euler.y:.6f} {rot_euler.z:.6f}\n") else: f.write(f" {b_idx} 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000\n") f.write("end\n") f.write("triangles\n") for tri in mesh.loop_triangles: hull_id = vert_hull_assignments[tri.vertices[0]] if tri.vertices[0] < len(vert_hull_assignments) else 0 f.write(f"phys_hull_{hull_id}\n") for vert_idx in tri.vertices: v = mesh.vertices[vert_idx] b_id = vert_bone_assignments[vert_idx] if vert_idx < len(vert_bone_assignments) else 0 co = v.co no = v.normal f.write(f" {b_id} {co.x:.6f} {co.y:.6f} {co.z:.6f} {no.x:.6f} {no.y:.6f} {no.z:.6f} 0.000000 0.000000 1 {b_id} 1.0000\n") f.write("end\n") def write_qc_script(filepath, model_name, is_ragdoll, bones_map, mat_name="CleanMaterial", active_bones=None, armature_obj=None): """Generates a complete, ready-to-compile Source Engine QC file for GMod with real human scale.""" root_bone = active_bones[0] if active_bones else (list(bones_map.keys())[0] if bones_map else "root") with open(filepath, "w", encoding="utf-8") as f: f.write("// Generated by 23DFactory Source Engine / Garry's Mod Exporter\n") f.write(f'$modelname "23dfactory/{model_name}.mdl"\n\n') f.write('// Auto-scaled from meters to Source Engine units (1m = ~39.37 units)\n') f.write('$scale 39.37\n\n') f.write('$bodygroup "body"\n') f.write('{\n') f.write(' studio "reference.smd"\n') f.write('}\n\n') f.write('$cdmaterials "models/23dfactory/"\n') f.write('$surfaceprop "flesh"\n') f.write('$contents "solid"\n\n') f.write('$sequence "idle" "reference.smd" fps 30 loop\n\n') if is_ragdoll: f.write('$sequence "ragdoll" "reference.smd" fps 1 activity "ACT_DIERAGDOLL" 1\n\n') f.write('$collisionjoints "physics.smd"\n') f.write('{\n') f.write(' $mass 80.0\n') f.write(' $inertia 10.00\n') f.write(' $damping 0.05\n') f.write(' $rotdamping 5.00\n') f.write(f' $rootbone "{root_bone}"\n') if active_bones: for b in active_bones: if b != root_bone: f.write(f' $jointconstrain "{b}" x limit -80.00 80.00 0.00\n') f.write(f' $jointconstrain "{b}" y limit -80.00 80.00 0.00\n') f.write(f' $jointconstrain "{b}" z limit -80.00 80.00 0.00\n') f.write('}\n') else: f.write('$collisionmodel "physics.smd"\n') f.write('{\n') f.write(' $mass 40.0\n') f.write(' $concave\n') f.write('}\n') def create_vtf_from_image(png_path, vtf_path): """Creates a native Valve Texture Format (.VTF) file with zero external dependencies (pure Python).""" import zlib try: with open(png_path, 'rb') as f: png_bytes = f.read() if png_bytes[:8] != b'\x89PNG\r\n\x1a\n': print(f"[GMod Exporter] Not a standard PNG: {png_path}") return offset = 8 width = height = color_type = 0 idat_data = bytearray() while offset < len(png_bytes): length = struct.unpack('>I', png_bytes[offset:offset+4])[0] chunk_type = png_bytes[offset+4:offset+8] chunk_data = png_bytes[offset+8:offset+8+length] offset += 12 + length if chunk_type == b'IHDR': width, height, _, color_type = struct.unpack('>IIBB', chunk_data[:10]) elif chunk_type == b'IDAT': idat_data.extend(chunk_data) elif chunk_type == b'IEND': break decompressed = zlib.decompress(idat_data) bpp = 4 if color_type == 6 else (3 if color_type == 2 else 1) stride = 1 + width * bpp raw_rgba = bytearray(width * height * 4) prev_row = bytearray(width * bpp) def paeth_calc(a, b, c): p = a + b - c pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) if pa <= pb and pa <= pc: return a elif pb <= pc: return b else: return c for y in range(height): filter_type = decompressed[y * stride] curr_row = bytearray(decompressed[y * stride + 1 : (y + 1) * stride]) for i in range(len(curr_row)): left = curr_row[i - bpp] if i >= bpp else 0 up = prev_row[i] up_left = prev_row[i - bpp] if i >= bpp else 0 if filter_type == 1: curr_row[i] = (curr_row[i] + left) & 0xFF elif filter_type == 2: curr_row[i] = (curr_row[i] + up) & 0xFF elif filter_type == 3: curr_row[i] = (curr_row[i] + ((left + up) >> 1)) & 0xFF elif filter_type == 4: curr_row[i] = (curr_row[i] + paeth_calc(left, up, up_left)) & 0xFF prev_row = curr_row[:] for x in range(width): dst_idx = (y * width + x) * 4 src_idx = x * bpp if bpp == 4: raw_rgba[dst_idx:dst_idx+4] = curr_row[src_idx:src_idx+4] elif bpp == 3: raw_rgba[dst_idx:dst_idx+3] = curr_row[src_idx:src_idx+3] raw_rgba[dst_idx+3] = 255 else: v = curr_row[src_idx] raw_rgba[dst_idx:dst_idx+4] = bytes([v, v, v, 255]) def to_pow2(val): p = 1 while p < val: p *= 2 return min(p, 1024) tw = to_pow2(width) th = to_pow2(height) resampled = bytearray(tw * th * 4) scale_x = float(width) / tw scale_y = float(height) / th for ty in range(th): sy = min(int(ty * scale_y), height - 1) for tx in range(tw): sx = min(int(tx * scale_x), width - 1) src_idx = (sy * width + sx) * 4 dst_idx = (ty * tw + tx) * 4 resampled[dst_idx:dst_idx+4] = raw_rgba[src_idx:src_idx+4] # VTF 7.0 Header (64 bytes - Universally compatible with all VTF tools & Source Engine) header = bytearray(64) header[0:4] = b'VTF\x00' struct.pack_into(' GMod Source Engine package conversion.""" print(f"[GMod Exporter] Processing: {input_glb}") # 1. Strip WebP/Extensions if needed strip_gltf_extensions(input_glb) # 2. Reset Blender Scene & Import GLB bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.gltf(filepath=input_glb) # 3. Detect Armature and Mesh Objects armature_obj = next((o for o in bpy.context.scene.objects if o.type == 'ARMATURE'), None) mesh_objs = [o for o in bpy.context.scene.objects if o.type == 'MESH'] if not mesh_objs: raise Exception("No 3D Mesh found in imported GLTF/GLB.") # Join meshes into single character body bpy.ops.object.select_all(action='DESELECT') for m in mesh_objs: m.select_set(True) bpy.context.view_layer.objects.active = mesh_objs[0] if len(mesh_objs) > 1: bpy.ops.object.join() char_mesh = bpy.context.view_layer.objects.active # 3.0. Apply all transforms to align world and local matrices try: bpy.ops.object.select_all(action='DESELECT') if armature_obj: armature_obj.select_set(True) bpy.context.view_layer.objects.active = armature_obj bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) if char_mesh: char_mesh.select_set(True) bpy.context.view_layer.objects.active = char_mesh bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) except Exception as t_err: print(f"[GMod Exporter] Transform apply note: {t_err}") # 3.1. Ensure mesh has skin weights bound to the Armature if armature_obj: has_weights = any(len(v.groups) > 0 for v in char_mesh.data.vertices) if not has_weights or len(char_mesh.vertex_groups) == 0: print("[GMod Exporter] Binding unweighted mesh to Armature with Automatic Weights...") bpy.ops.object.select_all(action='DESELECT') char_mesh.select_set(True) armature_obj.select_set(True) bpy.context.view_layer.objects.active = armature_obj try: bpy.ops.object.parent_set(type='ARMATURE_AUTO') print("[GMod Exporter] ✓ Automatic skin weights applied successfully!") except Exception as w_err: print(f"[GMod Exporter] Auto-weighting heat map note: {w_err}, trying envelope fallback...") try: bpy.ops.object.parent_set(type='ARMATURE_ENVELOPE') print("[GMod Exporter] ✓ Envelope skin weights applied successfully!") except Exception as e2: print(f"[GMod Exporter] Envelope fallback note: {e2}") # 3.2. Optimize Vertex Budget for Source Engine (StudioMDL VertexSortFixup limit: 24,000 vertices) vert_count = len(char_mesh.data.vertices) if vert_count > 24000: ratio = max(24000.0 / vert_count, 0.25) print(f"[GMod Exporter] Model has {vert_count} vertices (exceeds Source Engine 24k limit). Auto-decimating with ratio {ratio:.3f}...") bpy.ops.object.select_all(action='DESELECT') char_mesh.select_set(True) bpy.context.view_layer.objects.active = char_mesh mod = char_mesh.modifiers.new(name="GMod_Decimate", type='DECIMATE') mod.ratio = ratio bpy.ops.object.modifier_apply(modifier="GMod_Decimate") print(f"[GMod Exporter] [OK] Optimized to {len(char_mesh.data.vertices)} vertices for clean Source Engine compilation.") # 3.3. Delete loose stray geometry and orphan vertices try: bpy.ops.object.select_all(action='DESELECT') char_mesh.select_set(True) bpy.context.view_layer.objects.active = char_mesh bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.delete_loose() bpy.ops.object.mode_set(mode='OBJECT') except Exception as e: print(f"[GMod Exporter] Clean loose notice: {e}") # Determine material name mat_name = "CleanMaterial" if char_mesh.data.materials and char_mesh.data.materials[0]: mat_name = char_mesh.data.materials[0].name.replace(" ", "_") # Extract Bone Mapping bones_map = {} if armature_obj: for idx, bone in enumerate(armature_obj.data.bones): bones_map[bone.name] = idx else: bones_map["root"] = 0 is_ragdoll = bool(armature_obj and len(bones_map) > 1) # Create temp packaging directory pkg_dir = os.path.join(output_dir, f"{model_name}_gmod_package") materials_folder = os.path.join(pkg_dir, "materials", "models", "23dfactory") os.makedirs(materials_folder, exist_ok=True) ref_smd_path = os.path.join(pkg_dir, "reference.smd") phys_smd_path = os.path.join(pkg_dir, "physics.smd") qc_path = os.path.join(pkg_dir, f"{model_name}.qc") readme_path = os.path.join(pkg_dir, "INSTRUCCIONES_GMOD.txt") # 4. Write Visual Reference SMD print(f"[GMod Exporter] Generating Visual Reference SMD...") write_smd_reference(ref_smd_path, char_mesh, armature_obj, bones_map) # 5. Generate and Write Physics Collision Mesh SMD print(f"[GMod Exporter] Generating Collision Physics SMD ({'Ragdoll' if is_ragdoll else 'Prop'})...") phys_obj, vert_bone_assignments, vert_hull_assignments, active_bones = generate_physics_collision_boxes(char_mesh, armature_obj, bones_map) write_smd_physics(phys_smd_path, phys_obj, armature_obj, bones_map, vert_bone_assignments, vert_hull_assignments) # 6. Generate QC script print(f"[GMod Exporter] Writing Source Engine QC script with scale 39.37...") write_qc_script(qc_path, model_name, is_ragdoll, bones_map, mat_name, active_bones, armature_obj) # 7. Extract Image Textures and generate native .VMT & .VTF # Ensure all possible material names (CleanMaterial, Material_0, mat_name) have valid matching .vmt & .vtf all_mat_names = set(["CleanMaterial", "Material_0", mat_name]) if char_mesh.data.materials: for m in char_mesh.data.materials: if m and m.name: all_mat_names.add(m.name.replace(" ", "_")) primary_png_path = os.path.join(materials_folder, f"{mat_name}.png") # 1. Try extracting via Blender Material Nodes (most reliable) extracted_via_node = False for mat in bpy.data.materials: if mat.use_nodes and mat.node_tree: for node in mat.node_tree.nodes: if node.type == 'TEX_IMAGE' and node.image: try: img = node.image img.file_format = 'PNG' img.filepath_raw = primary_png_path img.save() extracted_via_node = True print(f"[GMod Exporter] ✓ Saved texture PNG via Material node '{node.name}': {mat_name}.png") break except Exception as node_err: print(f"[GMod Exporter] Node image save note: {node_err}") if extracted_via_node: break # 2. Try extracting via Blender Images list if not os.path.exists(primary_png_path): for img in bpy.data.images: if img.name in ("Render Result", "Viewer Node"): continue try: if img.packed_file: img.unpack(method='USE_LOCAL') img.filepath_raw = primary_png_path img.file_format = 'PNG' img.save() print(f"[GMod Exporter] ✓ Saved texture PNG via Blender image list: {mat_name}.png") break except Exception as tex_err: print(f"[GMod Exporter] Blender image save note: {tex_err}") # 3. Fallback: Extract image directly from GLB binary using Pillow if not os.path.exists(primary_png_path): print(f"[GMod Exporter] Extracting texture directly from GLB binary: {input_glb}") try: with open(input_glb, 'rb') as gf: glb_raw = gf.read() import io from PIL import Image for magic, end_magic in [(b'\x89PNG\r\n\x1a\n', b'IEND'), (b'\xff\xd8\xff', b'\xff\xd9')]: pos = glb_raw.find(magic) if pos != -1: end_pos = glb_raw.find(end_magic, pos) if end_pos != -1: end_offset = 8 if end_magic == b'IEND' else 2 chunk = glb_raw[pos : end_pos + end_offset] try: pil_img = Image.open(io.BytesIO(chunk)) pil_img.save(primary_png_path, "PNG") print(f"[GMod Exporter] [OK] Extracted and verified {pil_img.size} texture from GLB") break except Exception: pass except Exception as glb_err: print(f"[GMod Exporter] GLB binary extraction note: {glb_err}") # 4. Ultimate Fallback: Generate clean diffuse PNG so VTF is NEVER missing if not os.path.exists(primary_png_path): try: from PIL import Image fallback_img = Image.new("RGBA", (512, 512), (210, 175, 140, 255)) fallback_img.save(primary_png_path, "PNG") print(f"[GMod Exporter] Generated default diffuse texture PNG: {primary_png_path}") except Exception as f_err: print(f"[GMod Exporter] Fallback image note: {f_err}") # Generate native Valve Texture Format (.VTF) and .VMT for all material aliases if os.path.exists(primary_png_path): primary_vtf_path = os.path.join(materials_folder, f"{mat_name}.vtf") create_vtf_from_image(primary_png_path, primary_vtf_path) print(f"[GMod Exporter] [OK] Generated primary Valve VTF texture: {mat_name}.vtf") # Create .vmt and duplicate .vtf for all alias names so Garry's Mod always finds the texture for m_alias in all_mat_names: alias_vmt = os.path.join(materials_folder, f"{m_alias}.vmt") alias_vtf = os.path.join(materials_folder, f"{m_alias}.vtf") with open(alias_vmt, "w", encoding="utf-8") as f: f.write('"VertexLitGeneric"\n') f.write('{\n') f.write(f' "$basetexture" "models/23dfactory/{m_alias}"\n') f.write(' "$surfaceprop" "flesh"\n') f.write(' "$model" 1\n') f.write(' "$nocull" 1\n') f.write('}\n') if not os.path.exists(alias_vtf) and os.path.exists(primary_vtf_path): shutil.copy2(primary_vtf_path, alias_vtf) print(f"[GMod Exporter] [OK] Created alias VTF: {m_alias}.vtf") # 8. Add Complete Step-by-Step Instructions with open(readme_path, "w", encoding="utf-8") as f: f.write("=== PACK DE MODELO PARA SOURCE ENGINE & GARRY'S MOD ===\n\n") f.write("Este paquete fue generado automáticamente por 23DFactory.\n\n") f.write("ARCHIVOS INCLUIDOS:\n") f.write(f"1. {model_name}.qc -> Script de compilación Source Engine (con $scale 39.37 ajustado).\n") f.write("2. reference.smd -> Malla visual 3D con texturas y esqueleto de animación.\n") f.write("3. physics.smd -> Malla de colisión física (cajas de ragdoll por cada hueso).\n") f.write("4. materials/models/23dfactory/ -> Archivos .vmt y .vtf ya listos y compilados en formato Valve.\n\n") f.write("CÓMO INSTALAR EN GMOD:\n") f.write("PASO 1: COPIAR MATERIALES\n") f.write(" - Copia la carpeta 'materials/' directamente dentro de tu carpeta 'garrysmod/garrysmod/materials/'.\n\n") f.write("PASO 2: COMPILAR MODELO (.MDL)\n") f.write(" 1. Abre 'Crowbar'.\n") f.write(f" 2. En la pestaña 'Compile', selecciona el archivo '{model_name}.qc'.\n") f.write(" 3. Elige como juego 'Garrys Mod'.\n") f.write(" 4. Haz clic en 'Compile'.\n\n") f.write("PASO 3: JUGAR EN GMOD\n") f.write(" - Abre Garry's Mod -> Menú Q -> Browse -> Games -> Garrys Mod -> models/23dfactory/\n") # 9. Compress package into a single ZIP file zip_output_path = os.path.join(output_dir, f"GMod_Source_Pack_{model_name}.zip") with zipfile.ZipFile(zip_output_path, 'w', zipfile.ZIP_DEFLATED) as zipf: for root, _, files in os.walk(pkg_dir): for file in files: file_full = os.path.join(root, file) rel_path = os.path.relpath(file_full, pkg_dir) zipf.write(file_full, rel_path) # Clean up temp folder shutil.rmtree(pkg_dir, ignore_errors=True) print(f"[GMod Exporter] ✓ Package generated successfully: {zip_output_path}") return zip_output_path if __name__ == "__main__": argv = sys.argv if "--" in argv: args = argv[argv.index("--") + 1:] else: args = [] if len(args) < 2: print("[Error] Usage: blender --background --python export_source_gmod.py -- [model_name]") sys.exit(1) input_glb = args[0] output_dir = args[1] model_name = args[2] if len(args) > 2 else os.path.splitext(os.path.basename(input_glb))[0] try: export_source_gmod_package(input_glb, output_dir, model_name) except Exception as e: print(f"[Error] GMod export failed: {e}") import traceback traceback.print_exc() sys.exit(1)