""" glb_strip — remove utility nodes from a GLB's scene graph. Usage: reach character strip-glb [] reach character strip-glb --dir Formerly tooling/glb_strip_utility_nodes.py (T-1290); the GLB rewriting is unchanged and writes the same bytes. Removes utility mesh nodes from a GLB's GLTF scene graph. A "utility node" is any mesh node where: - The mesh has 0 primitives, OR - The mesh name matches a known utility pattern (Icosphere, WGT-*, etc.) - The mesh has no skinning joint data (vgroups=0 in Blender terms → no skin references) Strips the node from the scene graph (and its parent's children list) without touching the underlying mesh/accessor data. The mesh data becomes orphaned (not referenced by any node) and most GLTF consumers (Godot, Blender) ignore it. GLB format: - 12-byte header: magic (0x46546C67), version (2), total length - Chunk 0: JSON (type 0x4E4F534A) - Chunk 1: BIN (type 0x004E4942) If no output path is given, overwrites the input file. """ import json import os import re import shutil import struct from tooling.core import console GLB_MAGIC = 0x46546C67 CHUNK_JSON = 0x4E4F534A CHUNK_BIN = 0x004E4942 # Mesh names considered utility/non-geometry UTILITY_PATTERNS = [ re.compile(r'^Icosphere', re.IGNORECASE), re.compile(r'^WGT-', re.IGNORECASE), re.compile(r'^DEF-', re.IGNORECASE), re.compile(r'^ORG-', re.IGNORECASE), ] def is_utility_mesh(mesh: dict) -> bool: name = mesh.get('name', '') for pat in UTILITY_PATTERNS: if pat.match(name): return True # Also treat meshes with no primitives as utility if not mesh.get('primitives'): return True return False def read_glb(path: str): """Read a GLB file and return (gltf_dict, bin_data).""" with open(path, 'rb') as f: data = f.read() magic, version, length = struct.unpack_from(' tuple[int, list[str]]: """ Remove utility mesh nodes from the scene graph. Returns (count_removed, names_removed). """ meshes = gltf.get('meshes', []) nodes = gltf.get('nodes', []) scenes = gltf.get('scenes', []) # Find utility mesh indices utility_mesh_indices = { i for i, m in enumerate(meshes) if is_utility_mesh(m) } if not utility_mesh_indices: return 0, [] # Only nodes still LINKED into the graph count. Stripping leaves nodes in # place as orphans (see below), so without this a second run re-reported the # same nodes as stripped and rewrote an unchanged file (T-1290). linked = {n for scene in scenes for n in scene.get('nodes', [])} linked.update(c for node in nodes for c in node.get('children', [])) # Find linked node indices that reference utility meshes utility_node_indices = { i for i, n in enumerate(nodes) if n.get('mesh') in utility_mesh_indices and i in linked } if not utility_node_indices: return 0, [] removed_names = [nodes[i].get('name', f'node_{i}') for i in sorted(utility_node_indices)] # Remove utility nodes from scene top-level node lists for scene in scenes: scene['nodes'] = [n for n in scene.get('nodes', []) if n not in utility_node_indices] # Remove utility nodes from every other node's children list for node in nodes: if 'children' in node: node['children'] = [c for c in node['children'] if c not in utility_node_indices] if not node['children']: del node['children'] # clean up empty children array # Note: we intentionally leave the mesh data and nodes in place as orphaned entries. # Reindexing would require updating all skin joint arrays and is complex. # Orphaned nodes/meshes are ignored by Godot and Blender on import. return len(utility_node_indices), removed_names def process_file(input_path: str, output_path: str) -> dict: console.event(f"Processing: {input_path}") gltf, bin_data = read_glb(input_path) count, names = strip_utility_nodes(gltf) if count == 0: console.event("No utility nodes found — file unchanged") if output_path != input_path: shutil.copy2(input_path, output_path) return {"file": output_path, "stripped": 0, "names": []} write_glb(gltf, bin_data, output_path) console.event(f"Stripped {count} utility node(s): {names}") console.event(f"Written: {output_path} ({os.path.getsize(output_path):,} bytes)") return {"file": output_path, "stripped": count, "names": names} def process_directory(input_dir: str) -> dict: """Process all .glb files in a directory tree, in place.""" total = 0 changed = [] for root, _dirs, files in os.walk(input_dir): for fname in sorted(files): if fname.lower().endswith(".glb"): fpath = os.path.join(root, fname) gltf, bin_data = read_glb(fpath) count, names = strip_utility_nodes(gltf) if count > 0: write_glb(gltf, bin_data, fpath) rel = os.path.relpath(fpath, input_dir) console.event(f"{rel}: stripped {count} nodes {names}") changed.append({"file": rel, "stripped": count, "names": names}) total += count return {"dir": input_dir, "stripped": total, "files": changed}