3ba86b2ea8
Bulk import of the working lanes that were living untracked on the PC. Content: - characters/ Lena/male body lanes, bakes, texture work, run logs - clothing/ garment pipeline, configs, gates, contract docs - garments/ MD-authored garment sources (.zprj/.zpac) - UAL-Lib/ Universal Animation Library 2 source (.blend/.fbx/.glb) - tools/ blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent - docs/, plans/, dev/, .agents/plans/ Repo hygiene: - .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out on purpose — ~250 are already tracked raw and converting them would rewrite every one without shrinking history. - .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies dropped by a `>/dev/null` redirect on Windows). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
785 lines
35 KiB
Python
785 lines
35 KiB
Python
# 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()
|