Files

785 lines
35 KiB
Python
Raw Permalink Normal View History

# G5 — posed penetration + gape sweep (and G3 = its `--rest` mode).
#
# The pipeline's every other QA artifact is the fitting pose — the one pose that
# cannot fail. Both shipped kapa haka defects (pari neckline gaping at the
# sternum, a thigh punching through the piupiu) were invisible to it. This gate
# poses the skinned checkpoint with real game clips and measures, per frame:
#
# (a) PENETRATION — garment verts strictly inside the BODY mesh, deeper than
# `expect.penetration.depth_mm`.
# (b) GAPE — body verts inside a part's declared coverage band whose outward
# normal ray misses the garment (or hits it only far away), i.e. skin the
# garment is supposed to cover but no longer does.
#
# Invocation (see PIPELINE-CONTRACT.md):
#
# "$BLENDER" --background --python gates/g5_posed_sweep.py -- \
# --config work/<name>/resolved.json --work work/<name> [--rest]
#
# full mode (G5, after `skin`): opens <work>/50_skin.blend, poses RIG.
# --rest (G3, after `fit`) : opens <work>/20_fit.blend, static, no armature.
#
# Exit codes: 0 pass · 2 fail (thresholds violated) · 3 error (could not evaluate).
# Report: <work>/qc/g5.json (or g3.json in --rest mode).
#
# One deviation from the contract's example report: `failures[].frame` is a
# STRING label ("Walk[Walk_Fwd_Loop]@13", "rest", "Synthetic:arm_raise_70"), not
# an int — a bare frame number is ambiguous once more than one clip is sampled.
# Every entry in metrics.per_frame also carries `"source"`:
# "rest" | "clip" | "synthetic", so a mixed sweep (pose_source "both") can be
# read without parsing labels; metrics.synthetic_frames counts the latter.
#
# ── expect schema ────────────────────────────────────────────────────────────
# Contract baseline (unchanged):
#
# "expect": { "penetration": {
# "max_verts": 0, # garment verts inside body, per frame
# "depth_mm": 1.0, # how deep before a vert counts
# "clips": ["Idle", "Walk"], # pack clips, matched by name
# "frames_per_clip": 6,
# "gape": { "<Part>": { "band_z": [0.95, 1.30], "max_exposed_verts": 0 } }
# } }
#
# EXTENSIONS added by this gate (all optional, all defaulted — a config that
# only carries the contract keys above works unchanged):
#
# penetration.anim_pack str|list GLB(s) to pull clips from. Default: BOTH
# anim/UAL1.glb and anim/UAL2.glb, because
# the game binds idle/dance from UAL1 but
# aliases "walk" onto UAL2's Walk_Fwd_Loop.
# penetration.clip_aliases obj {"Idle": "Idle_Loop", ...}. Overrides the
# built-in name map (which mirrors the
# game's own bindings), then fuzzy match.
# penetration.pose_source str "auto" (default) | "clips" | "synthetic"
# | "both".
# "auto" = clips, falling back to synthetic
# extremes when no clip resolves. "both" =
# the clips AND the synthetic extremes
# appended after them (the deep-QC sweep);
# it degrades to synthetic-only if no clip
# resolves. The gate NEVER passes for lack
# of animation.
# penetration.ignore_parts list part names to skip entirely.
# (parts with "type":"bodyshell" are always
# skipped — they ARE the body.)
# penetration.max_render_frames int QA PNGs are rendered for at most this many
# failing frames (default 6). 0 disables.
# penetration.max_verts_rest int separate, looser rest-pose budget in full
# mode (defaults to max_verts).
#
# gape.<Part> entries:
# band_z [lo, hi] REQUIRED. Rest-pose world Z band of body verts
# the part must cover. Selection happens ONCE in
# rest pose (anatomy is stable); the SAME vertex
# indices are then re-tested in every posed frame.
# band_x/band_y [lo, hi] extra rest-pose spatial restriction.
# facing [x,y,z] only body verts whose rest normal points this
# way are selected (character front is -Y).
# facing_min float dot(normal, facing) threshold, default 0.25.
# ray_mm float outward ray length, default 50 mm. A miss = the
# garment is simply not there any more.
# gap_mm float a hit FARTHER than this also counts as exposed —
# fabric ballooned off the skin, you can see in.
# Default 25 mm. Set 0 to score misses only.
# max_exposed_verts int threshold on (misses + far hits). Default 0.
# max_exposed_delta int alternative threshold on (posed - rest) exposure,
# so a band that is imperfectly drawn in rest pose
# still yields a meaningful signal. When both are
# present a frame must satisfy BOTH.
# check_rest bool apply the thresholds to the rest pose too
# (default false — rest exposure is reported as a
# baseline metric either way).
# parts list garment parts that count as cover for this band.
# Default: every garment part in the file.
#
# ── implementation notes ─────────────────────────────────────────────────────
# * Meshes are read through the depsgraph (`evaluated_get`), so the ARMATURE
# modifier is applied — we measure the deformed geometry, not the rest cage.
# * Inside/outside uses BVHTree.find_nearest + sign of dot(p - hit, hit_normal);
# ~2.5k garment verts cost ~15 ms per frame, so a full sweep is BVH-bound on
# the body rebuild (one per frame), not on the queries.
# * The UAL packs are authored on the same 65-bone Quaternius skeleton as the
# derived-body RIG (verified: exact name-set match), so actions are assigned
# directly — no retarget. Rigs with EXTRA bones (piupiu_sb's 16 skirt bones)
# are fine: unanimated bones ride their parents.
import argparse
import json
import math
import os
import sys
import time
import bpy
import bmesh
from mathutils import Vector, Matrix
from mathutils.bvhtree import BVHTree
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) # gates -> clothing -> animation -> tinqs
ANIM_DIR = os.path.join(REPO, "ariki-game", "assets", "quaternius", "anim")
# Both packs, because the game pulls from both: PlayerController.SetupAnimations
# binds "idle"/"dance" from UAL1 but aliases "walk" onto UAL2's Walk_Fwd_Loop.
DEFAULT_PACKS = [os.path.join(ANIM_DIR, f).replace("\\", "/")
for f in ("UAL1.glb", "UAL2.glb")]
MM = 0.001
def log(msg):
print(f"[g5] {msg}", flush=True)
class GateError(Exception):
"""Could not evaluate — exit 3."""
# ── geometry helpers ─────────────────────────────────────────────────────────
def eval_world_verts(o, dg):
"""World-space vertex coords of `o` with modifiers applied."""
ev = o.evaluated_get(dg)
me = ev.to_mesh()
mw = o.matrix_world
pts = [mw @ v.co for v in me.vertices]
ev.to_mesh_clear()
return pts
def eval_world_verts_normals(o, dg):
ev = o.evaluated_get(dg)
me = ev.to_mesh()
mw = o.matrix_world
nm = mw.to_3x3().inverted_safe().transposed()
pts = [mw @ v.co for v in me.vertices]
nrm = [(nm @ n.vector).normalized() for n in me.vertex_normals]
ev.to_mesh_clear()
return pts, nrm
def body_bvh(body, dg):
return BVHTree.FromObject(body, dg)
def garment_bvh(parts, dg):
"""One BVH over every garment part — cover is cover, whoever provides it."""
verts, faces = [], []
for o in parts:
ev = o.evaluated_get(dg)
me = ev.to_mesh()
mw = o.matrix_world
base = len(verts)
verts.extend([mw @ v.co for v in me.vertices])
me.calc_loop_triangles()
faces.extend([[base + i for i in t.vertices] for t in me.loop_triangles])
ev.to_mesh_clear()
if not faces:
return None
return BVHTree.FromPolygons(verts, faces, all_triangles=True)
def penetrating(points, bvh, depth_m):
"""-> (count, [(index, depth_m, point)]) for verts deeper than depth_m inside."""
hits = []
for i, p in enumerate(points):
loc, nor, _idx, dist = bvh.find_nearest(p)
if loc is None:
continue
if (p - loc).dot(nor) < 0.0 and dist > depth_m:
hits.append((i, dist, p))
return len(hits), hits
# ── pose sources ─────────────────────────────────────────────────────────────
# The game's own bindings, so the gate poses what ClothingTestBed's Idle/Walk/
# Dance buttons actually play (ariki-game src/Viewer/PlayerController.cs
# SetupAnimations: idle/dance from UAL1, walk aliased to UAL2's Walk_Fwd_Loop).
BUILTIN_ALIASES = {
"idle": "Idle_Loop", "walk": "Walk_Fwd_Loop", "dance": "Dance_Loop",
"run": "Jog_Fwd_Loop", "jog": "Jog_Fwd_Loop", "sprint": "Sprint_Loop",
}
def resolve_clip(name, aliases):
"""Requested clip name -> an action in bpy.data.actions, or None."""
want = aliases.get(name) or BUILTIN_ALIASES.get(name.lower(), name)
for cand in (want, name):
if cand in bpy.data.actions:
return bpy.data.actions[cand]
low = name.lower()
matches = [a for a in bpy.data.actions if low in a.name.lower()]
if matches:
# shortest name wins: "Walk" -> Walk_Loop, not Walk_Formal_Loop
return sorted(matches, key=lambda a: (len(a.name), a.name))[0]
return None
def import_anim_pack(path):
"""Import clip actions from a GLB, then drop its mesh/armature objects."""
if not os.path.exists(path):
raise GateError(f"anim pack not found: {path}")
before = set(bpy.data.objects.keys())
t = time.time()
bpy.ops.import_scene.gltf(filepath=path)
added = [o for o in bpy.data.objects if o.name not in before]
pack_rig = next((o for o in added if o.type == "ARMATURE"), None)
bones = sorted(b.name for b in pack_rig.data.bones) if pack_rig else []
for a in bpy.data.actions:
a.use_fake_user = True # survive the object purge
for o in added:
bpy.data.objects.remove(o, do_unlink=True)
log(f"anim pack imported in {time.time() - t:.1f}s: {os.path.basename(path)} "
f"({len(bpy.data.actions)} actions, {len(bones)} pack bones)")
return bones
def bone_compat(rig, pack_bones):
rig_bones = set(b.name for b in rig.data.bones)
pack = set(pack_bones)
return {
"rig_bones": len(rig_bones),
"pack_bones": len(pack),
"shared": len(rig_bones & pack),
"pack_only": sorted(pack - rig_bones),
"rig_only": sorted(rig_bones - pack),
}
def assign_action(rig, action):
ad = rig.animation_data or rig.animation_data_create()
ad.action = action
# Blender 4.4+/5.x slotted actions: an action without a bound slot animates
# nothing at all, and does so silently.
if hasattr(ad, "action_slot") and getattr(action, "slots", None):
slot = next((s for s in action.slots if s.target_id_type == "OBJECT"),
action.slots[0])
ad.action_slot = slot
bpy.context.view_layer.update()
def clear_pose(rig):
if rig.animation_data:
rig.animation_data.action = None
for pb in rig.pose.bones:
pb.matrix_basis = Matrix()
bpy.context.view_layer.update()
def _rotate_bone_world(rig, bone, axis, deg):
"""Rotate a pose bone about a world axis through its own head."""
pb = rig.pose.bones[bone]
R = Matrix.Rotation(math.radians(deg), 4, axis)
M = pb.matrix.copy()
piv = M.translation.copy()
pb.matrix = Matrix.Translation(piv) @ R @ Matrix.Translation(-piv) @ M
bpy.context.view_layer.update()
def _rotate_toward(rig, bone, axis, deg, probe, objective):
"""Rotate `bone`, choosing the sign that maximises `objective(probe head)`.
The Quaternius bone roll is not documented anywhere we control, so instead of
hard-coding a sign we try both and keep whichever actually moves the limb the
way the pose is named for. Makes the synthetic fallback self-correcting.
"""
pb = rig.pose.bones[bone]
saved = pb.matrix_basis.copy()
best, best_score = None, None
for s in (1.0, -1.0):
pb.matrix_basis = saved.copy()
bpy.context.view_layer.update()
_rotate_bone_world(rig, bone, axis, deg * s)
score = objective(rig.matrix_world @ rig.pose.bones[probe].head)
if best_score is None or score > best_score:
best, best_score = pb.matrix_basis.copy(), score
pb.matrix_basis = best
bpy.context.view_layer.update()
def synthetic_poses(rig):
"""Named extreme poses, applied by direct bone rotation.
Deliberately harsher than any shipped clip: if a garment survives these it
is not going to fail on Idle. Used when no pack clip resolves.
"""
have = set(b.name for b in rig.pose.bones)
def arm_raise():
for side in ("l", "r"):
b = f"upperarm_{side}"
if b in have and f"hand_{side}" in have:
_rotate_toward(rig, b, "Y", 70, f"hand_{side}", lambda p: p.z)
def step_flex():
if "thigh_l" in have and "foot_l" in have:
_rotate_toward(rig, "thigh_l", "X", 60, "foot_l", lambda p: -p.y)
if "thigh_r" in have and "foot_r" in have:
_rotate_toward(rig, "thigh_r", "X", 35, "foot_r", lambda p: p.y)
def torso_twist():
spine = "spine_02" if "spine_02" in have else "spine_01"
probe = "clavicle_l" if "clavicle_l" in have else "Head"
if spine in have and probe in have:
_rotate_toward(rig, spine, "Z", 30, probe, lambda p: -p.x)
def combo():
step_flex()
torso_twist()
arm_raise()
return [("Synthetic:arm_raise_70", arm_raise),
("Synthetic:step_thigh_60", step_flex),
("Synthetic:torso_twist_30", torso_twist),
("Synthetic:combined", combo)]
def sample_frames(action, n):
lo, hi = action.frame_range
lo, hi = int(math.floor(lo)), int(math.ceil(hi))
if n <= 1 or hi <= lo:
return [lo]
step = (hi - lo) / float(n)
# sample inside the range; the last frame of a loop duplicates the first
return [int(round(lo + step * i)) for i in range(n)]
# ── QA artifacts ─────────────────────────────────────────────────────────────
def make_markers(points, name, size=0.008, cap=500):
me = bpy.data.meshes.new(name)
bm = bmesh.new()
step = max(1, len(points) // cap + (1 if len(points) % cap else 0))
for p in points[::step]:
bmesh.ops.create_cube(bm, size=size, matrix=Matrix.Translation(p))
bm.to_mesh(me)
bm.free()
o = bpy.data.objects.new(name, me)
bpy.context.scene.collection.objects.link(o)
return o
def render_failure(work, tag, body, parts, pen_pts, gape_pts):
"""Front + closeup Workbench renders with failing verts marked. -> [paths]"""
scene = bpy.context.scene
hidden = []
for o in bpy.data.objects:
if o.type == "MESH" and (o.name == "BODY_SHELL" or o.name.startswith("HI_")):
if not o.hide_render:
o.hide_render = True
hidden.append(o)
markers = []
if pen_pts:
m = make_markers(pen_pts, "_g5_pen")
m.color = (1.0, 0.05, 0.05, 1.0)
markers.append(m)
if gape_pts:
m = make_markers(gape_pts, "_g5_gape", size=0.010)
m.color = (1.0, 0.85, 0.0, 1.0)
markers.append(m)
body.color = (0.86, 0.70, 0.60, 1.0)
for p in parts:
p.color = (0.20, 0.35, 0.75, 1.0)
scene.render.engine = "BLENDER_WORKBENCH"
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "OBJECT"
scene.render.resolution_x, scene.render.resolution_y = 640, 860
scene.render.film_transparent = False
body_pts = list(pen_pts) + list(gape_pts)
allz = [(body.matrix_world @ Vector(c)).z for c in body.bound_box]
zmin, zmax = min(allz), max(allz)
shots = [("front", Vector((0, -4, (zmin + zmax) / 2)),
(math.pi / 2, 0, 0), (zmax - zmin) * 1.15)]
if body_pts:
c = sum(body_pts, Vector((0, 0, 0))) / len(body_pts)
span = max(0.25, max((p - c).length for p in body_pts) * 2.4)
shots.append(("closeup", Vector((c.x + span * 0.9, c.y - span * 1.5, c.z + span * 0.25)),
(math.radians(83), 0, math.radians(31)), span))
out = []
qc = os.path.join(work, "qc")
os.makedirs(qc, exist_ok=True)
for view, loc, rot, ortho in shots:
cam_name = f"_g5_cam_{view}"
cam = bpy.data.objects.get(cam_name)
if cam is None:
cam = bpy.data.objects.new(cam_name, bpy.data.cameras.new(cam_name))
bpy.context.scene.collection.objects.link(cam)
cam.data.type = "ORTHO"
cam.data.ortho_scale = ortho
cam.location, cam.rotation_euler = loc, rot
scene.camera = cam
path = os.path.join(qc, f"{tag}_{view}.png")
scene.render.filepath = path
bpy.ops.render.render(write_still=True)
out.append("qc/" + os.path.basename(path))
for m in markers:
bpy.data.objects.remove(m, do_unlink=True)
for o in hidden:
o.hide_render = False
return out
# ── gape band ────────────────────────────────────────────────────────────────
class GapeBand:
def __init__(self, part_name, spec, body, dg, all_parts):
self.part = part_name
self.spec = spec
band_z = spec.get("band_z")
if not band_z or len(band_z) != 2:
raise GateError(f"gape.{part_name}: band_z [lo, hi] is required")
self.ray_m = float(spec.get("ray_mm", 50.0)) * MM
self.gap_m = float(spec.get("gap_mm", 25.0)) * MM
self.max_exposed = spec.get("max_exposed_verts")
self.max_delta = spec.get("max_exposed_delta")
if self.max_exposed is None and self.max_delta is None:
self.max_exposed = 0
self.check_rest = bool(spec.get("check_rest", False))
cover = spec.get("parts")
self.cover_parts = ([p for p in all_parts if p.name.replace("GARM_", "") in cover]
if cover else list(all_parts))
# Select the band ONCE, in rest pose: anatomy is stable, posed Z is not.
pts, nrm = eval_world_verts_normals(body, dg)
facing = spec.get("facing")
fvec = Vector(facing).normalized() if facing else None
fmin = float(spec.get("facing_min", 0.25))
bx, by = spec.get("band_x"), spec.get("band_y")
idx = []
for i, p in enumerate(pts):
if not (band_z[0] <= p.z <= band_z[1]):
continue
if bx and not (bx[0] <= p.x <= bx[1]):
continue
if by and not (by[0] <= p.y <= by[1]):
continue
if fvec is not None and nrm[i].dot(fvec) < fmin:
continue
idx.append(i)
self.indices = idx
self.rest_exposed = None
def measure(self, body, dg):
"""-> (exposed_count, miss, far, [world points]) for the current frame."""
gb = garment_bvh(self.cover_parts, dg)
if gb is None:
raise GateError(f"gape.{self.part}: no garment parts to test cover against")
pts, nrm = eval_world_verts_normals(body, dg)
miss, far, out = 0, 0, []
for i in self.indices:
p, n = pts[i], nrm[i]
loc, _nor, _idx, d = gb.ray_cast(p + n * 0.0005, n, self.ray_m)
if loc is None:
miss += 1
out.append(p)
elif self.gap_m > 0 and d > self.gap_m:
far += 1
out.append(p)
return miss + far, miss, far, out
# ── main ─────────────────────────────────────────────────────────────────────
def parse_args():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(prog="g5_posed_sweep")
ap.add_argument("--config", required=True)
ap.add_argument("--work", required=True)
ap.add_argument("--rest", action="store_true",
help="G3: static rest-pose test on 20_fit.blend")
ap.add_argument("--blend", default=None, help="override the checkpoint to open")
ap.add_argument("--anim-pack", default=None)
ap.add_argument("--pose-source", default=None,
choices=["auto", "clips", "synthetic", "both"])
ap.add_argument("--max-render-frames", type=int, default=None)
return ap.parse_args(argv)
def run(args):
with open(args.config, "r", encoding="utf-8") as fh:
cfg = json.load(fh)
exp = (cfg.get("expect") or {}).get("penetration")
if exp is None:
raise GateError("config has no expect.penetration block — nothing to check")
gate_id = "g3" if args.rest else "g5"
stage = "fit" if args.rest else "skin"
work = os.path.abspath(args.work)
qc = os.path.join(work, "qc")
os.makedirs(qc, exist_ok=True)
blend = args.blend or os.path.join(work, "20_fit.blend" if args.rest else "50_skin.blend")
if not os.path.exists(blend):
raise GateError(f"missing checkpoint {blend} — run stage '{stage}' first")
bpy.ops.wm.open_mainfile(filepath=blend)
depth_m = float(exp.get("depth_mm", 1.0)) * MM
max_verts = int(exp.get("max_verts", 0))
max_verts_rest = int(exp.get("max_verts_rest", max_verts))
max_renders = (args.max_render_frames if args.max_render_frames is not None
else int(exp.get("max_render_frames", 6)))
ignore = set(exp.get("ignore_parts") or [])
for name, pc in (cfg.get("parts") or {}).items():
if pc.get("type") == "bodyshell":
ignore.add(name)
body = bpy.data.objects.get("BODY")
if body is None:
raise GateError(f"{os.path.basename(blend)} has no BODY mesh")
parts = [o for o in bpy.data.objects
if o.type == "MESH" and o.name.startswith("GARM_")
and o.name.replace("GARM_", "") not in ignore]
if not parts:
raise GateError("no GARM_* parts to test (all ignored?)")
log(f"{gate_id}: {os.path.basename(blend)} — body {len(body.data.vertices)}v, "
f"parts {[p.name for p in parts]}")
rig = bpy.data.objects.get("RIG")
dg = bpy.context.evaluated_depsgraph_get()
failures, artifacts, frames_report = [], [], []
rendered = 0
# Render budget is spread across clips — a first-come budget spent every PNG
# on frame 0..N of the first clip and never showed what Walk/Dance did.
rendered_in_group = {}
per_group = [max_renders]
# Mutable global render cap. `pose_source: "both"` runs the clips and THEN the
# synthetic extremes; without reserving part of the budget the clips would spend
# all of it and the extremes — the frames deep QC is there for — would ship
# numbers with no picture.
render_cap = [max_renders]
# ── gape bands (selected in rest pose) ───────────────────────────────────
bands = []
for pname, spec in (exp.get("gape") or {}).items():
if pname in ignore:
continue
b = GapeBand(pname, spec, body, dg, parts)
log(f"gape band {pname}: {len(b.indices)} body verts selected "
f"(z {spec.get('band_z')}, ray {b.ray_m*1000:.0f}mm, gap {b.gap_m*1000:.0f}mm)")
if not b.indices:
raise GateError(f"gape.{pname}: band selected 0 body verts — check band_z")
bands.append(b)
def evaluate(label, do_render=True, is_rest=False, group=None, source="clip"):
"""Test one pose. -> dict of per-frame metrics; appends failures."""
nonlocal rendered
group = group or label
n_fail_before = len(failures)
d = bpy.context.evaluated_depsgraph_get()
bvh = body_bvh(body, d)
rec = {"frame": label, "source": "rest" if is_rest else source,
"penetrating": 0, "max_depth_mm": 0.0, "parts": {}}
pen_pts = []
budget = max_verts_rest if is_rest else max_verts
for p in parts:
pts = eval_world_verts(p, d)
n, hits = penetrating(pts, bvh, depth_m)
deepest = max((h[1] for h in hits), default=0.0)
rec["parts"][p.name] = {"penetrating": n, "max_depth_mm": round(deepest * 1000, 2),
"verts": len(pts)}
rec["penetrating"] += n
rec["max_depth_mm"] = max(rec["max_depth_mm"], round(deepest * 1000, 2))
pen_pts.extend(h[2] for h in hits)
if n > budget:
failures.append({
"part": p.name, "frame": label, "kind": "penetration",
"detail": f"{n} verts inside BODY deeper than {depth_m*1000:.1f} mm "
f"(max {deepest*1000:.1f} mm), budget {budget}"})
gape_pts = []
for b in bands:
total, miss, far, pts = b.measure(body, d)
if is_rest and b.rest_exposed is None:
b.rest_exposed = total
entry = {"exposed": total, "miss": miss, "far": far,
"band_verts": len(b.indices)}
if b.rest_exposed is not None:
entry["delta_vs_rest"] = total - b.rest_exposed
rec.setdefault("gape", {})[b.part] = entry
if is_rest and not b.check_rest:
continue
bad = []
if b.max_exposed is not None and total > b.max_exposed:
bad.append(f"{total} exposed verts (miss {miss}, far {far}) "
f"> max_exposed_verts {b.max_exposed}")
if b.max_delta is not None and b.rest_exposed is not None \
and (total - b.rest_exposed) > b.max_delta:
bad.append(f"exposure +{total - b.rest_exposed} vs rest ({b.rest_exposed}) "
f"> max_exposed_delta {b.max_delta}")
if bad:
gape_pts.extend(pts)
failures.append({"part": b.part, "frame": label, "kind": "gape",
"detail": "; ".join(bad)})
if (len(failures) > n_fail_before and do_render and rendered < render_cap[0]
and rendered_in_group.get(group, 0) < per_group[0]):
tag = f"{gate_id}_" + "".join(
c if (c.isalnum() or c in "._-") else "_" for c in label)
artifacts.extend(render_failure(work, tag, body, parts, pen_pts, gape_pts))
rendered += 1
rendered_in_group[group] = rendered_in_group.get(group, 0) + 1
rec["artifact_tag"] = tag
frames_report.append(rec)
return rec
pose_source = args.pose_source or exp.get("pose_source", "auto")
t0 = time.time()
# ── rest baseline (both modes) ───────────────────────────────────────────
if rig is not None and not args.rest:
clear_pose(rig)
rest = evaluate("rest", is_rest=True)
log(f"rest: penetrating={rest['penetrating']} "
f"gape={ {k: v['exposed'] for k, v in rest.get('gape', {}).items()} }")
compat = None
used_source = "rest-only"
if not args.rest:
if rig is None:
raise GateError("50_skin.blend has no RIG armature — cannot pose")
packs = args.anim_pack or exp.get("anim_pack") or DEFAULT_PACKS
packs = [packs] if isinstance(packs, str) else list(packs)
clips = list(exp.get("clips") or ["Idle", "Walk"])
n_frames = int(exp.get("frames_per_clip", 6))
aliases = exp.get("clip_aliases") or {}
resolved = []
if pose_source in ("auto", "clips", "both"):
pack_bones = set()
for p in packs:
pack_bones |= set(import_anim_pack(p))
compat = bone_compat(rig, sorted(pack_bones))
log(f"bone compat: {compat['shared']}/{compat['pack_bones']} pack bones "
f"present on RIG; rig-only {compat['rig_only']}; "
f"pack-only {compat['pack_only']}")
if compat["shared"] < 0.8 * compat["pack_bones"]:
log("bone overlap too low for direct assignment — using synthetic poses")
resolved = []
else:
for c in clips:
a = resolve_clip(c, aliases)
if a is None:
log(f"clip '{c}' not found in pack — skipped")
else:
resolved.append((c, a))
did_clips = False
if resolved and pose_source != "synthetic":
did_clips = True
used_source = "clips:" + "+".join(os.path.basename(p) for p in packs)
log("clips: " + ", ".join(f"{c}->{a.name}" for c, a in resolved))
# "both" appends the synthetic extremes afterwards — hold a quarter of the
# render budget (at least one frame) back for them.
reserve = min(2, max(1, max_renders // 4)) if pose_source == "both" else 0
render_cap[0] = max(1, max_renders - reserve) if max_renders else 0
per_group[0] = max(1, render_cap[0] // len(resolved))
for cname, act in resolved:
assign_action(rig, act)
for f in sample_frames(act, n_frames):
bpy.context.scene.frame_set(f)
bpy.context.view_layer.update()
r = evaluate(f"{cname}[{act.name}]@{f}", group=cname, source="clip")
log(f" {cname}@{f}: pen={r['penetrating']} "
f"gape={ {k: v['exposed'] for k, v in r.get('gape', {}).items()} }")
clear_pose(rig)
# Synthetic extremes: the fallback when nothing resolved, and the deliberate
# SECOND PASS when pose_source is "both" (deep QC). Deliberately harsher than
# any shipped clip.
if not did_clips or pose_source == "both":
if not did_clips and pose_source == "clips":
raise GateError(
"pose_source=clips but no requested clip resolved in "
+ ", ".join(packs))
used_source = f"{used_source}+synthetic" if did_clips else "synthetic"
render_cap[0] = max_renders
per_group[0] = max_renders
log("pose source: SYNTHETIC extremes ("
+ ("appended, pose_source=both" if did_clips
else "no pack clip resolved") + ")")
for label, apply in synthetic_poses(rig):
clear_pose(rig)
apply()
r = evaluate(label, source="synthetic")
log(f" {label}: pen={r['penetrating']} "
f"gape={ {k: v['exposed'] for k, v in r.get('gape', {}).items()} }")
clear_pose(rig)
elapsed = round(time.time() - t0, 1)
worst_pen = max((f["penetrating"] for f in frames_report), default=0)
worst_gape = {}
for f in frames_report:
for k, v in (f.get("gape") or {}).items():
worst_gape[k] = max(worst_gape.get(k, 0), v["exposed"])
report = {
"gate": gate_id,
"pass": not failures,
"checked_at_stage": stage,
"metrics": {
"frames": len(frames_report),
"pose_source": used_source,
"clips": [f["frame"] for f in frames_report if f.get("source") == "clip"],
"synthetic": [f["frame"] for f in frames_report
if f.get("source") == "synthetic"],
"synthetic_frames": sum(1 for f in frames_report
if f.get("source") == "synthetic"),
"worst_penetrating_verts": worst_pen,
"rest_penetrating_verts": rest["penetrating"],
"worst_exposed_verts": worst_gape,
"rest_exposed_verts": {k: v["exposed"] for k, v in (rest.get("gape") or {}).items()},
"depth_mm": round(depth_m * 1000, 3),
"max_verts": max_verts,
"seconds": elapsed,
"bone_compat": compat,
"per_frame": frames_report,
},
"failures": failures,
"artifacts": artifacts,
}
out = os.path.join(qc, f"{gate_id}.json")
with open(out, "w", encoding="utf-8") as fh:
json.dump(report, fh, indent=2)
log(f"report: {out}")
n_syn = report["metrics"]["synthetic_frames"]
log(f"{gate_id} {'PASS' if not failures else 'FAIL'}{len(failures)} failure(s), "
f"{len(frames_report)} frames ({n_syn} synthetic), {elapsed}s, "
f"pose source {used_source}")
for f in failures[:12]:
log(f" FAIL {f['kind']} {f['part']} @{f['frame']}: {f['detail']}")
return 0 if not failures else 2
def main():
args = parse_args()
try:
code = run(args)
except GateError as e:
log(f"ERROR: {e}")
try:
qc = os.path.join(os.path.abspath(args.work), "qc")
os.makedirs(qc, exist_ok=True)
gid = "g3" if args.rest else "g5"
with open(os.path.join(qc, f"{gid}.json"), "w", encoding="utf-8") as fh:
json.dump({"gate": gid, "pass": False,
"checked_at_stage": "fit" if args.rest else "skin",
"metrics": {}, "error": str(e),
"failures": [{"part": "-", "detail": str(e), "frame": -1}],
"artifacts": []}, fh, indent=2)
except Exception:
pass
code = 3
except Exception as e: # noqa: BLE001 — gate must not hang
import traceback
traceback.print_exc()
log(f"ERROR: unexpected: {e}")
code = 3
sys.stdout.flush()
sys.exit(code)
if __name__ == "__main__":
main()