`reach character {logo, strip-glb, qa, qa-analyze}` replaces make_logo.py,
glb_strip_utility_nodes.py, analyze_captures.py and the run-garment-qa bash
driver. The QA configs and method doc move beside the domain (qa_configs/,
GARMENT_QA.md), and `qa` takes a config name (`reach character qa hoodie_modern`)
or a path.
Parity, from baselines taken before anything moved:
- the logo PNG is byte-identical
- a synthetic GLB with three real utility nodes strips to identical bytes
(the committed bodies strip 0 nodes, so they proved nothing)
- re-analyzing a cached capture set gives a byte-identical report.json and
summary
run-garment-qa is rewritten, not wrapped (D-263). Its decisions — which config,
which Godot ($GODOT, then ~/bin/godot4, then PATH), and whether xvfb-run is
needed — are capture_plan(), pinned by tooling/test_character.py without
launching Godot. The bash exit codes are kept: 2 for a missing config, 3 for
no Godot.
The T-1271 domain map was wrong about this domain. Six of its ten files import
bpy: convert_outfit, inspect_glb, check_hair_symmetry, check_icosphere,
render_quaternius_test and test_quaternius_raw. They are Blender payloads and
joined the carve-out as blender_* (41 payloads now). The 22 existing payloads'
docstrings still cited tooling/garment-fit/ from before T-1273; fixed.
Archived, with reasons in tooling/archive/README.md:
- setup_clothing_metadata.py wrote coverage data for five garments that no
longer exist in the 24-garment wardrobe
- wipe-bodies.sh ran raw DELETEs on systems.db
segment_reference_distribution.md moved to docs/assets/visual/.
Behaviour changes:
- The QA analyzer exited 0 whatever it found, though its own README says
clip-through "is the real defect and it gates". qa and qa-analyze now exit 1
on clip-through, and the remedy names --min-pixels (Wave 1/2 were accepted
at 150). The cached peasant set has 33 failures at the default 8 px.
- glb strip re-reported the same nodes as stripped on every re-run and
rewrote an unchanged file: it left them as orphans and then found them
again. Only nodes still linked into the graph count now, and a first pass
writes the same bytes as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
6.8 KiB
Python
196 lines
6.8 KiB
Python
"""
|
|
glb_strip — remove utility nodes from a GLB's scene graph.
|
|
Usage: reach character strip-glb <input.glb> [<output.glb>]
|
|
reach character strip-glb --dir <directory>
|
|
|
|
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('<III', data, 0)
|
|
assert magic == GLB_MAGIC, f"Not a GLB file (magic={magic:#010x})"
|
|
assert version == 2, f"Unexpected GLB version {version}"
|
|
|
|
offset = 12
|
|
gltf_json = None
|
|
bin_data = b''
|
|
|
|
while offset < length:
|
|
chunk_len, chunk_type = struct.unpack_from('<II', data, offset)
|
|
offset += 8
|
|
chunk_data = data[offset: offset + chunk_len]
|
|
offset += chunk_len
|
|
|
|
if chunk_type == CHUNK_JSON:
|
|
gltf_json = json.loads(chunk_data.decode('utf-8'))
|
|
elif chunk_type == CHUNK_BIN:
|
|
bin_data = chunk_data
|
|
|
|
assert gltf_json is not None, "No JSON chunk found in GLB"
|
|
return gltf_json, bin_data
|
|
|
|
|
|
def write_glb(gltf_dict: dict, bin_data: bytes, path: str):
|
|
"""Write a GLB file from a GLTF dict and binary data."""
|
|
json_bytes = json.dumps(gltf_dict, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
|
|
# Pad JSON to 4-byte alignment with spaces
|
|
pad = (4 - len(json_bytes) % 4) % 4
|
|
json_bytes += b' ' * pad
|
|
|
|
# Pad binary to 4-byte alignment with zeros
|
|
bin_pad = (4 - len(bin_data) % 4) % 4 if bin_data else 0
|
|
bin_padded = bin_data + b'\x00' * bin_pad
|
|
|
|
json_chunk = struct.pack('<II', len(json_bytes), CHUNK_JSON) + json_bytes
|
|
bin_chunk = (struct.pack('<II', len(bin_padded), CHUNK_BIN) + bin_padded) if bin_data else b''
|
|
|
|
total_length = 12 + len(json_chunk) + len(bin_chunk)
|
|
header = struct.pack('<III', GLB_MAGIC, 2, total_length)
|
|
|
|
with open(path, 'wb') as f:
|
|
f.write(header + json_chunk + bin_chunk)
|
|
|
|
|
|
def strip_utility_nodes(gltf: dict) -> 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}
|