Files
settled-reach/spikes/synty-intake/scripts/03_recon_bodies.py
T
jpmschweitzerandClaude Opus 5 201dabd19b refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
35 payloads move to tooling/scripts/blender/ and stay outside package scope.
They run under Blender's bundled Python, which cannot see the repo venv, so
they physically cannot import tooling.core — holding them to the D-263 contract
would either fail the gate forever or force the contract to be weakened for
everyone, and the second is how a gate stops meaning anything.

Count verified by import rather than filename: 33 import bpy/bmesh directly,
and the two that do not are still payloads per their own usage lines.
garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290.

The bash wrapper is retired rather than kept. Keeping it would have put the
install-resolution logic in two places, which is the duplication T-1286 had
just finished collapsing three copies of. domains/blender/service.py owns the
decisions — resolve_blender (native beats flatpak, ordering preserved),
resolve_payload, absolutise — and only run_payload performs. test_blender.py
pins all of them without launching Blender, which matters here more than
usual: the thing being launched is a 200 MB GUI application that writes GLBs.

`reach blender run` takes a registered payload name OR a path to any script,
because the wrapper served both — the spikes and the glb-gen skill hand it
one-off scripts of their own. An unknown name enumerates all 35 and exits 2.

The exclusion now defends itself. check_carve_out_stays_carved fails if
`scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty
exclusion proves nothing), or if an __init__.py appears there (which would make
the payloads importable — the coupling the carve-out exists to prevent). All
three arms mutation-proved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:55:52 +02:00

117 lines
3.9 KiB
Python

"""
Spike T-1089 / Synty Sidekick intake — step 3 recon: the 11 body types.
Questions this answers (feeds 04_batch_fit_bodies.py):
1. Do the garment-adjacent segments (torso/hips/neck/upper arms/upper legs)
share topology (vert/poly counts) across all 11 body types? If yes, the
Surface-Deform refit can be driven by direct vertex correspondence
(bind SD on average_m, write target coords into the reference mesh).
2. Do the per-segment embedded armatures differ across bodies (rest-pose
bake), or is it the shared skeleton everywhere?
Run:
reach blender run \
spikes/synty-intake/scripts/03_recon_bodies.py
"""
import bpy
import json
import os
REPO = "/var/mnt/data/projects/settled-reach"
BODIES_DIR = os.path.join(REPO, "client/assets/characters/bodies")
OUT_JSON = os.path.join(REPO, "spikes/synty-intake/out/bodies_recon.json")
BODY_TYPES = ["average_f", "average_m", "child", "heavy_f", "heavy_m",
"muscular_f", "muscular_m", "teen_f", "teen_m",
"thin_f", "thin_m"]
SEGS = ["seg_torso", "seg_hips", "seg_neck",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_leg_upper_l", "seg_leg_upper_r"]
LANDMARK_BONES = ["pelvis", "spine_01", "spine_02", "spine_03",
"clavicle_l", "upperarm_l", "thigh_l", "neck_01", "Head"]
def clear_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
for block_list in (bpy.data.meshes, bpy.data.armatures, bpy.data.images,
bpy.data.materials):
for block in list(block_list):
if block.users == 0:
block_list.remove(block)
def import_new(path):
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=path)
return list(set(bpy.data.objects) - before)
report = {}
for body in BODY_TYPES:
body_dir = os.path.join(BODIES_DIR, body)
entry = {"segments": {}, "armature": None}
for seg in SEGS:
path = os.path.join(body_dir, seg + ".glb")
if not os.path.exists(path):
entry["segments"][seg] = None
continue
clear_scene()
objs = import_new(path)
mesh = None
for o in objs:
if o.type == 'MESH' and len(o.vertex_groups) > 0:
mesh = o
arm = None
for o in objs:
if o.type == 'ARMATURE':
arm = o
seg_info = {}
if mesh is not None:
seg_info["verts"] = len(mesh.data.vertices)
seg_info["polys"] = len(mesh.data.polygons)
seg_info["vgroups"] = len(mesh.vertex_groups)
entry["segments"][seg] = seg_info
# record armature landmarks once per body (from seg_torso)
if seg == "seg_torso" and arm is not None:
lm = {}
for bn in LANDMARK_BONES:
b = arm.data.bones.get(bn)
if b is None:
b = arm.data.bones.get(bn.lower())
if b is not None:
w = arm.matrix_world @ b.head_local
lm[bn] = [round(v, 5) for v in w]
entry["armature"] = {
"bones": len(arm.data.bones),
"landmarks": lm,
}
report[body] = entry
# cross-body topology comparison vs average_m
ref = report["average_m"]["segments"]
topo = {}
for body in BODY_TYPES:
diffs = []
for seg in SEGS:
a = ref.get(seg)
b = report[body]["segments"].get(seg)
if not a or not b:
diffs.append(f"{seg}: missing")
elif (a["verts"], a["polys"]) != (b["verts"], b["polys"]):
diffs.append(f"{seg}: {b['verts']}v/{b['polys']}p vs "
f"ref {a['verts']}v/{a['polys']}p")
topo[body] = diffs if diffs else "MATCH"
report["_topology_vs_average_m"] = topo
os.makedirs(os.path.dirname(OUT_JSON), exist_ok=True)
with open(OUT_JSON, "w") as f:
json.dump(report, f, indent=2)
print("\n=== BODIES RECON ===")
print(json.dumps(report, indent=2))