refactor(tooling): T-1290 — the character domain, and six payloads the map misfiled

`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>
This commit is contained in:
2026-09-23 19:55:46 +02:00
co-authored by Claude Opus 5.5
parent ddce4441a9
commit 26cc8de7f3
72 changed files with 609 additions and 188 deletions
@@ -71,7 +71,7 @@ default_tints (sole near-black, upper work brown, laces dark).
Usage (boots_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_boots.py -- \
tooling/scripts/blender/blender_author_boots.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/boots_modern \
[--bodies average_m,child,...] [--offset 0.013] [--shaft-frac 0.30] \
@@ -29,7 +29,7 @@ channel is still authored (costs nothing; enables future brand variants).
Run (per-body only — this garment ships the per-body route):
tooling/blender --background --python \
tooling/garment-fit/blender_author_buttondown.py -- \
tooling/scripts/blender/blender_author_buttondown.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/buttondown_modern \
[--bodies average_m,child,...] [--offset 0.009]
@@ -39,7 +39,7 @@ modelled — no 3D pocket geometry (texture carries identity).
Usage (cargo_pants reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_cargo_pants.py -- \
tooling/scripts/blender/blender_author_cargo_pants.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/cargo_pants \
[--bodies average_m,child,...] [--offset 0.013] [--hem-frac 1.0] \
@@ -52,7 +52,7 @@ bodies. Per-body mode only (offset shells author per body, Q-060).
Usage (jeans_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_denim_pants.py -- \
tooling/scripts/blender/blender_author_denim_pants.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/jeans_modern \
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 1.0] \
@@ -28,7 +28,7 @@ Hoodie deltas over the base t-shirt shell:
PER-BODY ONLY (Q-060: offset-shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_hoodie.py -- \
tooling/scripts/blender/blender_author_hoodie.py -- \
client/assets/characters/bodies client/assets/characters/clothing/hoodie_modern \
[--bodies a,b,c] [--offset M]
@@ -36,7 +36,7 @@ PER-BODY ONLY: offset-shell garments are authored per body (Q-060 route
guidance) — there is no single-reference mode here.
tooling/blender --background --python \
tooling/garment-fit/blender_author_jacket_shell.py -- \
tooling/scripts/blender/blender_author_jacket_shell.py -- \
<bodies_root> <out_dir> \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-frac 0.85]
@@ -48,7 +48,7 @@ boundaries give the waist opening and ankle hems for free. Per-body mode only
Usage (joggers_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_joggers.py -- \
tooling/scripts/blender/blender_author_joggers.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/joggers_modern \
[--bodies average_m,child,...] [--offset 0.014] [--mid-offset 0.010] \
@@ -30,7 +30,7 @@ offset-shell garments author per body; weights inherited by construction).
Usage:
tooling/blender --background --python \
tooling/garment-fit/blender_author_lower_shell.py -- \
tooling/scripts/blender/blender_author_lower_shell.py -- \
<bodies_root> <out_dir> \
[--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \
[--waistband-frac 1.0] [--cuff-frac 0.0] \
@@ -38,7 +38,7 @@ Painted albedo details (flat, toon-friendly; identity lives in the texture):
Per-body only (this garment ships per-body per the Q-060 route guidance):
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_coverall.py -- \
tooling/scripts/blender/blender_author_offset_coverall.py -- \
<bodies_root> <out_dir> [--bodies a,b,c] [--offset 0.012]
Writes per body:
@@ -34,7 +34,7 @@ SINGLE-REFERENCE (default) — author on one body (average_m); G1
derived/hand-authored garments that share one UV layout + one mask.
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell.py -- \
tooling/scripts/blender/blender_author_offset_shell.py -- \
<bodies_dir>/average_m <out_dir> [--offset 0.020] [--sleeve-frac 0.40]
Writes:
@@ -52,7 +52,7 @@ from each body's OWN bone landmarks using the same proportional ratios
regions stay consistent across bodies.
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell.py -- \
tooling/scripts/blender/blender_author_offset_shell.py -- \
<bodies_dir> <out_dir> --per-body \
[--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40]
@@ -37,7 +37,7 @@ Modes mirror the base script:
PER-BODY (preferred for offset shells, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
tooling/scripts/blender/blender_author_offset_shell_legs.py -- \
<bodies_dir> <out_dir> --per-body \
[--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35]
@@ -47,7 +47,7 @@ PER-BODY (preferred for offset shells, Q-060):
SINGLE-REFERENCE:
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
tooling/scripts/blender/blender_author_offset_shell_legs.py -- \
<bodies_dir>/average_m <out_dir> [--offset 0.020] [...]
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
@@ -26,7 +26,7 @@ What it adds over the base t-shirt shell:
Per-body only (this family is the poster child for the Q-060 per-body route):
tooling/blender --background --python \
tooling/garment-fit/blender_author_outerwear.py -- \
tooling/scripts/blender/blender_author_outerwear.py -- \
<bodies_root> <out_dir> \
[--bodies a,b,c] [--offset 0.022] [--cuff-keep 0.88]
@@ -37,7 +37,7 @@ top, combining the proven practices of the wave-1 family:
Per-body only (Q-060: offset shells author per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_parka.py -- \
tooling/scripts/blender/blender_author_parka.py -- \
client/assets/characters/bodies client/assets/characters/clothing/parka_thrds \
[--bodies a,b,c] [--offset 0.026] [--hem-frac 0.15]
@@ -64,7 +64,7 @@ only (offset shells author per body, Q-060).
Usage (shoes_formal reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_shoes_formal.py -- \
tooling/scripts/blender/blender_author_shoes_formal.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/shoes_formal \
[--bodies average_m,child,...] [--offset 0.006] [--sole-mm 0.008] \
@@ -53,7 +53,7 @@ torso/leg companion provides, as reusable parameters:
Usage (per-body only — offset shells author per body, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_slides.py -- \
tooling/scripts/blender/blender_author_slides.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/slides \
[--bodies average_m,child,...] [--offset 0.005] \
@@ -57,7 +57,7 @@ symmetric, so overlapping texels agree by construction.
Usage (sneakers_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_sneakers.py -- \
tooling/scripts/blender/blender_author_sneakers.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/sneakers_modern \
[--bodies average_m,child,...] [--offset 0.009] [--sole-drop 0.015] \
@@ -51,7 +51,7 @@ as reusable parameters:
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_sweater.py -- \
tooling/scripts/blender/blender_author_sweater.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/sweater_modern \
[--bodies average_m,child,...] [--offset 0.014] [--hem-drop 0.30] \
@@ -41,7 +41,7 @@ proportional-ratio philosophy as base.derive_thresholds. Per-body mode only
Usage (swim_trunks reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_swim_trunks.py -- \
tooling/scripts/blender/blender_author_swim_trunks.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/swim_trunks \
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 0.60] \
@@ -46,7 +46,7 @@ logo_uv TEXCOORD_1 layer ships for channel consistency (not logo-capable).
Per-body mode only (offset shells author per body, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_swimsuit.py -- \
tooling/scripts/blender/blender_author_swimsuit.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/swimsuit_onepiece \
[--bodies average_m,child,...] [--offset 0.012]
@@ -42,7 +42,7 @@ texture carries identity, flat toon-friendly).
Run (per-body only — offset shells author per body, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_tank_top.py -- \
tooling/scripts/blender/blender_author_tank_top.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/tank_top \
[--bodies average_m,child,...] [--offset 0.012] \
@@ -40,7 +40,7 @@ kit needs beyond that script is delivered as reusable parameters, not hacks:
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_track_jacket.py -- \
tooling/scripts/blender/blender_author_track_jacket.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/track_jacket \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-keep 0.90]
@@ -26,7 +26,7 @@ Per body type:
Run:
tooling/blender --background --python \
tooling/garment-fit/blender_batch_fit_skinned.py -- \
tooling/scripts/blender/blender_batch_fit_skinned.py -- \
<reference_glb> <bodies_dir> <output_dir> \
[--only average_m,average_f,...] [--solidify 0.0] [--self-check]
@@ -0,0 +1,31 @@
"""Check if a hair mesh is symmetric across the X axis."""
import sys
import bpy
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
sys.exit(1)
path = argv[argv.index("--") + 1]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=path)
for obj in bpy.context.scene.objects:
if obj.type != 'MESH' or 'Hair' not in obj.name:
continue
verts = obj.data.vertices
xs = [v.co.x for v in verts]
zs = [v.co.z for v in verts]
print(f"{obj.name}: {len(verts)} verts")
print(f" X range: {min(xs):.4f} to {max(xs):.4f}")
print(f" Z range: {min(zs):.4f} to {max(zs):.4f}")
left = sum(1 for x in xs if x > 0.01)
right = sum(1 for x in xs if x < -0.01)
center = sum(1 for x in xs if abs(x) <= 0.01)
print(f" left(+X): {left}, right(-X): {right}, center: {center}")
# Check normals for consistency
normals = [v.normal for v in verts]
outward = sum(1 for n in normals if n.length() > 0.5)
print(f" verts with normals: {outward}/{len(verts)}")
@@ -0,0 +1,21 @@
"""List all objects in a Quaternius .blend file."""
import sys
import bpy
if __name__ == "__main__":
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.open_mainfile(filepath=args[0])
print("\nAll scene objects:")
for o in sorted(bpy.context.scene.objects, key=lambda x: x.name):
parent_info = f" (parent: {o.parent.name}" + (f", bone: {o.parent_bone}" if o.parent_bone else "") + ")" if o.parent else ""
verts = f", {len(o.data.vertices)} verts" if o.type == 'MESH' else ""
loc = tuple(round(v, 3) for v in o.matrix_world.translation)
print(f" {o.name:30s} type={o.type:10s} loc={loc}{verts}{parent_info}")
arm = next((o for o in bpy.context.scene.objects if o.type == 'ARMATURE'), None)
if arm:
head_bone = arm.data.bones.get('Head')
if head_bone:
print(f"\nHead bone top: z={round(head_bone.tail_local[2], 4)}")
@@ -0,0 +1,53 @@
"""Convert a Quaternius outfit gltf to glb with optional solidify."""
import sys
import bpy
CLOTHING_THICKNESS = 0.025 # 25mm outward solidify
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: reach blender run blender_convert_outfit <input.gltf> <output.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
input_path = args[0]
output_path = args[1]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=input_path)
# Solidify clothing meshes for thickness
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
for m in meshes:
if m.name.startswith("Icosphere") or len(m.data.vertices) < 50:
continue
bpy.context.view_layer.objects.active = m
m.select_set(True)
# Recalculate normals for consistent outward direction
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
# Solidify
sol = m.modifiers.new(name="Solidify", type='SOLIDIFY')
sol.thickness = CLOTHING_THICKNESS
sol.offset = 1.0 # grow outward only
sol.use_rim = True
bpy.ops.object.modifier_apply(modifier=sol.name)
m.select_set(False)
print(f" Solidified: {m.name} ({CLOTHING_THICKNESS*1000:.0f}mm)")
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(
filepath=output_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_texcoords=True,
export_normals=True,
export_skins=True,
export_materials='EXPORT',
)
print(f"Exported: {output_path}")
@@ -0,0 +1,32 @@
"""Inspect a GLB file's objects, vertex groups, and parent hierarchy."""
import sys
import bpy
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: reach blender run blender_inspect_glb <input.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
input_path = args[0]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=input_path)
for obj in bpy.context.scene.objects:
print(f"OBJ: {obj.name} type={obj.type} pos={tuple(round(v,3) for v in obj.location)}")
if obj.type == 'MESH':
vg_names = [vg.name for vg in obj.vertex_groups]
print(f" vertex_groups ({len(vg_names)}): {vg_names[:10]}")
print(f" parent: {obj.parent.name if obj.parent else 'None'}")
print(f" verts: {len(obj.data.vertices)}")
# Check skin modifier
for mod in obj.modifiers:
print(f" modifier: {mod.name} type={mod.type} object={mod.object.name if hasattr(mod, 'object') and mod.object else 'None'}")
if obj.type == 'ARMATURE':
bones = [b.name for b in obj.data.bones]
print(f" bones ({len(bones)}): {bones[:8]}...")
# Print head bone position if exists
for b in obj.data.bones:
if b.name == 'Head':
print(f" Head bone head_local: {tuple(round(v,3) for v in b.head_local)}")
@@ -0,0 +1,41 @@
"""Load original Quaternius body + hair as-is and export a combined GLB for visual inspection."""
import sys
import bpy
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: reach blender run blender_quaternius_raw <output.glb>")
sys.exit(1)
output = argv[argv.index("--") + 1]
bpy.ops.wm.read_factory_settings(use_empty=True)
# Load the Superhero Female body (original, unmodified)
body_path = "/var/mnt/data/projects/settled-reach/main/docs/assets/downloads/Universal Base Characters[Source]/Base Characters/Exports/Godot - UE/Superhero_Female_FullBody.gltf"
bpy.ops.import_scene.gltf(filepath=body_path)
print("=== After body import ===")
for obj in bpy.context.scene.objects:
print(f" {obj.name} type={obj.type}")
# Load bob hair (Rigged to Head Bone, Female)
hair_path = "/var/mnt/data/projects/settled-reach/main/docs/assets/downloads/Universal Base Characters[Source]/Hairstyles/Rigged to Head Bone/glTF (Godot -Unreal)/Female/Hair_Bob.gltf"
bpy.ops.import_scene.gltf(filepath=hair_path)
print("=== After hair import ===")
for obj in bpy.context.scene.objects:
print(f" {obj.name} type={obj.type} parent={obj.parent.name if obj.parent else 'None'}")
# Export everything as one GLB
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(
filepath=output,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_skins=True,
export_materials='EXPORT',
)
print(f"Exported: {output}")
@@ -0,0 +1,75 @@
"""Render the raw Quaternius body + hair from multiple angles to check for clipping."""
import sys
import os
import math
import bpy
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
sys.exit(1)
out_dir = argv[argv.index("--") + 1]
os.makedirs(out_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
# Load body
body_path = "/var/mnt/data/projects/settled-reach/main/docs/assets/downloads/Universal Base Characters[Source]/Base Characters/Exports/Godot - UE/Superhero_Female_FullBody.gltf"
bpy.ops.import_scene.gltf(filepath=body_path)
# Load bob hair
hair_path = "/var/mnt/data/projects/settled-reach/main/docs/assets/downloads/Universal Base Characters[Source]/Hairstyles/Rigged to Head Bone/glTF (Godot -Unreal)/Female/Hair_Bob.gltf"
bpy.ops.import_scene.gltf(filepath=hair_path)
# Remove icospheres
for obj in list(bpy.context.scene.objects):
if "Icosphere" in obj.name:
bpy.data.objects.remove(obj, do_unlink=True)
# Setup camera
cam_data = bpy.data.cameras.new("TestCam")
cam_data.lens = 85
cam = bpy.data.objects.new("TestCam", cam_data)
bpy.context.scene.collection.objects.link(cam)
bpy.context.scene.camera = cam
# Light
light_data = bpy.data.lights.new("TestLight", type='SUN')
light_data.energy = 3.0
light = bpy.data.objects.new("TestLight", light_data)
light.rotation_euler = (math.radians(45), 0, math.radians(30))
bpy.context.scene.collection.objects.link(light)
# Render settings
bpy.context.scene.render.engine = 'BLENDER_EEVEE_NEXT'
bpy.context.scene.render.resolution_x = 800
bpy.context.scene.render.resolution_y = 600
bpy.context.scene.render.film_transparent = True
# Head center (approx)
target = (0.0, 0.0, 1.65)
dist = 0.5
angles = {
"front": 0,
"right": 90,
"back": 180,
"left": 270,
}
for name, deg in angles.items():
rad = math.radians(deg)
cx = target[0] + dist * math.sin(rad)
cy = target[1] - dist * math.cos(rad)
cz = target[2]
cam.location = (cx, cy, cz)
# Point at target
direction = (target[0] - cx, target[1] - cy, target[2] - cz)
rot_z = math.atan2(direction[0], -direction[1])
rot_x = math.atan2(math.sqrt(direction[0]**2 + direction[1]**2), direction[2])
cam.rotation_euler = (rot_x, 0, rot_z)
filepath = os.path.join(out_dir, f"raw_bob_{name}.png")
bpy.context.scene.render.filepath = filepath
bpy.ops.render.render(write_still=True)
print(f"Rendered: {filepath}")