`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>
76 lines
2.6 KiB
Python
76 lines
2.6 KiB
Python
"""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}")
|