chore(assets): Synty intake spike — scripts, evidence logs, verdict data (T-1089)

Bridge-C evidence spike: unitypackage extraction, rest-pose recon, garment
transplant onto the Quaternius rig (Data Transfer POLYINTERP_NEAREST), 11-body
batch fit, Godot QA scenes. Technical PASS; route nonetheless REJECTED by
product call (cost, baked body-segment modularity, style) — full trail on
T-1089. Salvage: source-agnostic transplant/batch-fit scripts (the G1-family
pipeline for any donor mesh) and the T-1090 discovery (5 body types misrender
bare). Extracted vendor payload (spikes/**/raw/) now gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 14:01:24 +02:00
co-authored by Claude Fable 5
parent a7801a942a
commit 4bcd778e16
18 changed files with 4337 additions and 0 deletions
+3
View File
@@ -55,6 +55,9 @@ spikes/**/*.blend
spikes/**/*.exr
spikes/**/*.hdr
spikes/**/*.zip
# Extracted vendor payload (reproducible from docs/assets/downloads/ archives
# via the spike's own extraction script) — includes Unity .asset/.sk metadata.
spikes/**/raw/
# Planet generator intermediates
*.tmp.npz
+793
View File
@@ -0,0 +1,793 @@
{
"garment_glb": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb",
"bodies": {
"average_m": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_hips",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 1.0,
"scale_z": 1.0,
"translate": [
-0.0,
-0.0,
-0.0
]
},
"aabb_post_affine": {
"min": [
-0.2082,
-0.1677,
0.8251
],
"max": [
0.2305,
0.1739,
1.59
]
},
"warp": {
"max_displacement_mm": 13.2,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 1.058,
"max_shrink": 0.919,
"edges_gt_2x": 0,
"edges_lt_0.5x": 0,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.2083,
-0.1707,
0.8236
],
"max": [
0.2337,
0.1739,
1.59
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -74.86,
"p05_signed_mm": -6.44,
"median_signed_mm": 36.03,
"max_signed_mm": 96.8,
"verts_inside_gt_3mm": 135,
"pct_inside": 6.26
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_average_m.glb",
"size_kb": 653
},
"result": "OK"
},
"average_f": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_hips",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 0.79,
"scale_z": 0.96031,
"translate": [
0.0,
0.01173,
0.02037
]
},
"aabb_post_affine": {
"min": [
-0.1645,
-0.1207,
0.8127
],
"max": [
0.1821,
0.1491,
1.5473
]
},
"warp": {
"max_displacement_mm": 65.3,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 1.712,
"max_shrink": 0.762,
"edges_gt_2x": 0,
"edges_lt_0.5x": 0,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.169,
-0.1566,
0.8021
],
"max": [
0.2341,
0.1404,
1.5467
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -67.38,
"p05_signed_mm": -12.82,
"median_signed_mm": 26.3,
"max_signed_mm": 85.13,
"verts_inside_gt_3mm": 206,
"pct_inside": 9.56
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_average_f.glb",
"size_kb": 653
},
"result": "OK"
},
"muscular_m": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_hips",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 1.1048,
"scale_z": 1.0,
"translate": [
0.0,
-0.00451,
0.0
]
},
"aabb_post_affine": {
"min": [
-0.23,
-0.1898,
0.8251
],
"max": [
0.2546,
0.1876,
1.59
]
},
"warp": {
"max_displacement_mm": 54.7,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 1.489,
"max_shrink": 0.638,
"edges_gt_2x": 0,
"edges_lt_0.5x": 0,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.239,
-0.2016,
0.8217
],
"max": [
0.2662,
0.2002,
1.5928
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -67.99,
"p05_signed_mm": -9.31,
"median_signed_mm": 38.97,
"max_signed_mm": 110.81,
"verts_inside_gt_3mm": 177,
"pct_inside": 8.21
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_muscular_m.glb",
"size_kb": 653
},
"result": "OK"
},
"muscular_f": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_hips",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 0.79,
"scale_z": 0.96031,
"translate": [
0.0,
0.01173,
0.02037
]
},
"aabb_post_affine": {
"min": [
-0.1645,
-0.1207,
0.8127
],
"max": [
0.1821,
0.1491,
1.5473
]
},
"warp": {
"max_displacement_mm": 89.1,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 1.815,
"max_shrink": 0.744,
"edges_gt_2x": 0,
"edges_lt_0.5x": 0,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.1747,
-0.1679,
0.8022
],
"max": [
0.237,
0.1415,
1.5473
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -72.04,
"p05_signed_mm": -12.99,
"median_signed_mm": 26.65,
"max_signed_mm": 85.26,
"verts_inside_gt_3mm": 189,
"pct_inside": 8.77
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_muscular_f.glb",
"size_kb": 653
},
"result": "OK"
},
"thin_m": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 1.0,
"scale_z": 1.0,
"translate": [
-0.0,
-0.0,
-0.0
]
},
"aabb_post_affine": {
"min": [
-0.2082,
-0.1677,
0.8251
],
"max": [
0.2305,
0.1739,
1.59
]
},
"warp": {
"max_displacement_mm": 148.8,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 2.091,
"max_shrink": 0.232,
"edges_gt_2x": 1,
"edges_lt_0.5x": 14,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.1968,
-0.1693,
0.8205
],
"max": [
0.212,
0.1959,
1.5056
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -63.51,
"p05_signed_mm": -8.58,
"median_signed_mm": 40.36,
"max_signed_mm": 124.52,
"verts_inside_gt_3mm": 126,
"pct_inside": 5.85
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_thin_m.glb",
"size_kb": 653
},
"result": "OK"
},
"thin_f": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 0.79,
"scale_z": 0.96031,
"translate": [
0.0,
0.01173,
0.02037
]
},
"aabb_post_affine": {
"min": [
-0.1645,
-0.1207,
0.8127
],
"max": [
0.1821,
0.1491,
1.5473
]
},
"warp": {
"max_displacement_mm": 105.2,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 2.589,
"max_shrink": 0.409,
"edges_gt_2x": 3,
"edges_lt_0.5x": 7,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.1598,
-0.1652,
0.824
],
"max": [
0.223,
0.1782,
1.4946
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -50.52,
"p05_signed_mm": -18.1,
"median_signed_mm": 25.69,
"max_signed_mm": 85.58,
"verts_inside_gt_3mm": 286,
"pct_inside": 13.27
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_thin_f.glb",
"size_kb": 653
},
"result": "OK"
},
"heavy_m": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 1.0,
"scale_z": 1.0,
"translate": [
-0.0,
-0.0,
-0.0
]
},
"aabb_post_affine": {
"min": [
-0.2082,
-0.1677,
0.8251
],
"max": [
0.2305,
0.1739,
1.59
]
},
"warp": {
"max_displacement_mm": 34.6,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 1.236,
"max_shrink": 0.839,
"edges_gt_2x": 0,
"edges_lt_0.5x": 0,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.22,
-0.1776,
0.8241
],
"max": [
0.253,
0.1837,
1.5938
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -88.67,
"p05_signed_mm": -6.24,
"median_signed_mm": 31.44,
"max_signed_mm": 103.76,
"verts_inside_gt_3mm": 148,
"pct_inside": 6.87
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_heavy_m.glb",
"size_kb": 653
},
"result": "OK"
},
"heavy_f": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 0.79,
"scale_z": 0.96031,
"translate": [
0.0,
0.01173,
0.02037
]
},
"aabb_post_affine": {
"min": [
-0.1645,
-0.1207,
0.8127
],
"max": [
0.1821,
0.1491,
1.5473
]
},
"warp": {
"max_displacement_mm": 104.0,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 1.839,
"max_shrink": 0.678,
"edges_gt_2x": 0,
"edges_lt_0.5x": 0,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.1891,
-0.1631,
0.8005
],
"max": [
0.1965,
0.1545,
1.549
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -69.85,
"p05_signed_mm": -15.38,
"median_signed_mm": 21.36,
"max_signed_mm": 87.94,
"verts_inside_gt_3mm": 275,
"pct_inside": 12.76
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_heavy_f.glb",
"size_kb": 653
},
"result": "OK"
},
"teen_m": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_hips",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 0.73705,
"scale_z": 0.84515,
"translate": [
0.0,
0.00491,
0.00582
]
},
"aabb_post_affine": {
"min": [
-0.1534,
-0.1187,
0.7031
],
"max": [
0.1699,
0.1331,
1.3496
]
},
"warp": {
"max_displacement_mm": 42.4,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 1.747,
"max_shrink": 0.819,
"edges_gt_2x": 0,
"edges_lt_0.5x": 0,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.1578,
-0.1319,
0.7008
],
"max": [
0.1859,
0.1363,
1.3515
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -63.32,
"p05_signed_mm": -7.51,
"median_signed_mm": 25.93,
"max_signed_mm": 74.03,
"verts_inside_gt_3mm": 170,
"pct_inside": 7.89
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_teen_m.glb",
"size_kb": 653
},
"result": "OK"
},
"teen_f": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_hips",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 0.68165,
"scale_z": 0.82861,
"translate": [
0.0,
0.01012,
0.01758
]
},
"aabb_post_affine": {
"min": [
-0.1419,
-0.1042,
0.7013
],
"max": [
0.1571,
0.1286,
1.3351
]
},
"warp": {
"max_displacement_mm": 46.5,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 1.72,
"max_shrink": 0.759,
"edges_gt_2x": 0,
"edges_lt_0.5x": 0,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.1403,
-0.1322,
0.684
],
"max": [
0.1987,
0.1183,
1.3355
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -41.05,
"p05_signed_mm": -6.08,
"median_signed_mm": 24.43,
"max_signed_mm": 70.84,
"verts_inside_gt_3mm": 120,
"pct_inside": 5.57
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_teen_f.glb",
"size_kb": 653
},
"result": "OK"
},
"child": {
"garment_verts": 2155,
"segments_used": [
"seg_torso",
"seg_hips",
"seg_neck",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_leg_upper_l",
"seg_leg_upper_r"
],
"affine": {
"scale_xy": 0.73705,
"scale_z": 0.84515,
"translate": [
0.0,
0.00491,
0.00582
]
},
"aabb_post_affine": {
"min": [
-0.1534,
-0.1187,
0.7031
],
"max": [
0.1699,
0.1331,
1.3496
]
},
"warp": {
"max_displacement_mm": 302.7,
"smooth_iterations": 20
},
"zero_weight_verts": 0,
"vgroups": 65,
"edge_distortion": {
"max_stretch": 2.619,
"max_shrink": 0.055,
"edges_gt_2x": 3,
"edges_lt_0.5x": 84,
"edges_total": 3799
},
"aabb_post_fit": {
"min": [
-0.1403,
-0.1072,
0.6974
],
"max": [
0.1622,
0.1086,
1.1344
]
},
"signed_distance": {
"verts_sampled": 2155,
"min_signed_mm": -54.44,
"p05_signed_mm": -13.32,
"median_signed_mm": 26.92,
"max_signed_mm": 79.3,
"verts_inside_gt_3mm": 187,
"pct_inside": 8.68
},
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_child.glb",
"size_kb": 653
},
"result": "OK"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,771 @@
{
"garment_objects": [
{
"name": "Armature",
"type": "ARMATURE",
"scale": [
0.01,
0.01,
0.01
],
"location": [
0.0,
0.0,
0.0
]
},
{
"name": "SK_SCFI_CIVL_09_10TORS_HU01",
"type": "MESH",
"scale": [
1.0,
1.0,
1.0
],
"location": [
0.0,
0.0,
0.0
]
}
],
"sidekick_bones": {
"pelvis": {
"head": [
0.0,
0.02817,
0.91021
],
"tail": [
0.0,
-0.0946,
0.91021
]
},
"thigh_l": {
"head": [
0.09613,
0.03111,
0.883
],
"tail": [
0.09622,
0.36502,
0.87676
]
},
"thigh_r": {
"head": [
-0.09613,
0.03111,
0.883
],
"tail": [
-0.09604,
-0.30279,
0.88923
]
},
"spine_01": {
"head": [
0.0,
0.02533,
0.9497
],
"tail": [
0.0,
0.09286,
0.96049
]
},
"spine_02": {
"head": [
0.0,
0.01595,
1.01744
],
"tail": [
0.0,
0.1409,
1.03122
]
},
"spine_03": {
"head": [
0.0,
0.0085,
1.14293
],
"tail": [
1e-05,
0.26263,
1.14473
]
},
"neck_01": {
"head": [
0.0,
0.04285,
1.46301
],
"tail": [
0.0,
0.13851,
1.49219
]
},
"head": {
"head": [
0.0,
0.01367,
1.55867
],
"tail": [
0.0,
0.13291,
1.55867
]
},
"clavicle_l": {
"head": [
0.0198,
0.0427,
1.36663
],
"tail": [
0.01704,
0.22638,
1.37059
]
},
"upperarm_l": {
"head": [
0.19434,
0.04519,
1.37329
],
"tail": [
0.18605,
0.25779,
1.37358
]
},
"clavicle_r": {
"head": [
-0.0198,
0.0427,
1.36663
],
"tail": [
-0.02256,
-0.14098,
1.36267
]
},
"upperarm_r": {
"head": [
-0.19434,
0.04519,
1.37329
],
"tail": [
-0.18605,
0.25779,
1.37358
]
}
},
"garment_meshes": {
"SK_SCFI_CIVL_09_10TORS_HU01": {
"verts": 919,
"bbox": {
"min": [
-0.21081,
-0.18519,
0.79682
],
"max": [
0.23342,
0.1607,
1.49631
],
"dims": [
0.44423,
0.34589,
0.69949
]
},
"vgroups": [
"pelvis",
"thigh_l",
"thigh_r",
"spine_01",
"spine_02",
"spine_03",
"neck_01",
"head",
"clavicle_l",
"upperarm_l",
"clavicle_r",
"upperarm_r"
],
"shape_keys": [
"Basis",
"defaultBuff",
"masculineFeminine",
"defaultHeavy",
"defaultSkinny"
]
}
},
"our_armature_bone_count": 65,
"our_bones": {
"pelvis": {
"head": [
-0.0,
0.043,
0.9491
],
"tail": [
-0.0,
0.01664,
1.03883
]
},
"thigh_l": {
"head": [
0.0906,
0.036,
0.9712
],
"tail": [
0.0906,
0.0361,
0.5424
]
},
"thigh_r": {
"head": [
-0.0906,
0.036,
0.9712
],
"tail": [
-0.0906,
0.0361,
0.5424
]
},
"spine_01": {
"head": [
-0.0,
0.0069,
1.072
],
"tail": [
-0.0,
-0.0039,
1.1778
]
},
"spine_02": {
"head": [
-0.0,
-0.0039,
1.1778
],
"tail": [
-0.0,
-0.0069,
1.3109
]
},
"spine_03": {
"head": [
-0.0,
-0.0069,
1.3109
],
"tail": [
-0.0,
0.03553,
1.49501
]
},
"neck_01": {
"head": [
-0.0,
0.0414,
1.5205
],
"tail": [
-0.0,
0.0174,
1.5998
]
},
"Head": {
"head": [
-0.0,
0.0174,
1.5998
],
"tail": [
-0.0,
0.015,
1.68262
]
},
"clavicle_l": {
"head": [
0.0314,
-0.0335,
1.4953
],
"tail": [
0.20039,
0.05906,
1.49517
]
},
"upperarm_l": {
"head": [
0.1919,
0.0654,
1.4555
],
"tail": [
0.4429,
0.073,
1.4555
]
},
"clavicle_r": {
"head": [
-0.0314,
-0.0335,
1.4953
],
"tail": [
-0.20039,
0.05906,
1.49517
]
},
"upperarm_r": {
"head": [
-0.1919,
0.0654,
1.4555
],
"tail": [
-0.4429,
0.073,
1.4555
]
}
},
"bone_head_deltas": {
"pelvis": {
"delta": [
0.0,
-0.01483,
-0.03889
],
"magnitude_m": 0.04162
},
"thigh_l": {
"delta": [
0.00553,
-0.00489,
-0.0882
],
"magnitude_m": 0.08851
},
"thigh_r": {
"delta": [
-0.00553,
-0.00489,
-0.0882
],
"magnitude_m": 0.08851
},
"spine_01": {
"delta": [
0.0,
0.01843,
-0.1223
],
"magnitude_m": 0.12368
},
"spine_02": {
"delta": [
0.0,
0.01985,
-0.16036
],
"magnitude_m": 0.16158
},
"spine_03": {
"delta": [
0.0,
0.0154,
-0.16797
],
"magnitude_m": 0.16867
},
"neck_01": {
"delta": [
0.0,
0.00145,
-0.05749
],
"magnitude_m": 0.05751
},
"head": {
"delta": [
0.0,
-0.00373,
-0.04113
],
"magnitude_m": 0.0413
},
"clavicle_l": {
"delta": [
-0.0116,
0.0762,
-0.12867
],
"magnitude_m": 0.14999
},
"upperarm_l": {
"delta": [
0.00244,
-0.02021,
-0.08221
],
"magnitude_m": 0.08469
},
"clavicle_r": {
"delta": [
0.0116,
0.0762,
-0.12867
],
"magnitude_m": 0.14999
},
"upperarm_r": {
"delta": [
-0.00244,
-0.02021,
-0.08221
],
"magnitude_m": 0.08469
}
},
"our_body_segments": {
"seg_torso": {
"verts": 375,
"bbox": {
"min": [
-0.15826,
-0.10161,
1.03118
],
"max": [
0.15826,
0.12067,
1.35323
],
"dims": [
0.31652,
0.22228,
0.32205
]
},
"vgroups": [
"root",
"pelvis",
"spine_01",
"spine_02",
"spine_03",
"neck_01",
"Head",
"clavicle_l",
"upperarm_l",
"lowerarm_l",
"hand_l",
"index_01_l",
"index_02_l",
"index_03_l",
"index_04_leaf_l",
"middle_01_l",
"middle_02_l",
"middle_03_l",
"middle_04_leaf_l",
"pinky_01_l",
"pinky_02_l",
"pinky_03_l",
"pinky_04_leaf_l",
"ring_01_l",
"ring_02_l",
"ring_03_l",
"ring_04_leaf_l",
"thumb_01_l",
"thumb_02_l",
"thumb_03_l",
"thumb_04_leaf_l",
"clavicle_r",
"upperarm_r",
"lowerarm_r",
"hand_r",
"index_01_r",
"index_02_r",
"index_03_r",
"index_04_leaf_r",
"middle_01_r",
"middle_02_r",
"middle_03_r",
"middle_04_leaf_r",
"pinky_01_r",
"pinky_02_r",
"pinky_03_r",
"pinky_04_leaf_r",
"ring_01_r",
"ring_02_r",
"ring_03_r",
"ring_04_leaf_r",
"thumb_01_r",
"thumb_02_r",
"thumb_03_r",
"thumb_04_leaf_r",
"thigh_l",
"calf_l",
"foot_l",
"ball_l",
"ball_leaf_l",
"thigh_r",
"calf_r",
"foot_r",
"ball_r",
"ball_leaf_r"
]
},
"seg_hips": {
"verts": 42,
"bbox": {
"min": [
-0.95106,
-1.0,
-1.0
],
"max": [
0.95106,
1.0,
1.0
],
"dims": [
1.90212,
2.0,
2.0
]
},
"vgroups": []
},
"seg_neck": {
"verts": 144,
"bbox": {
"min": [
-0.07635,
-0.02606,
1.53878
],
"max": [
0.07635,
0.10504,
1.61028
],
"dims": [
0.1527,
0.1311,
0.0715
]
},
"vgroups": [
"root",
"pelvis",
"spine_01",
"spine_02",
"spine_03",
"neck_01",
"Head",
"clavicle_l",
"upperarm_l",
"lowerarm_l",
"hand_l",
"index_01_l",
"index_02_l",
"index_03_l",
"index_04_leaf_l",
"middle_01_l",
"middle_02_l",
"middle_03_l",
"middle_04_leaf_l",
"pinky_01_l",
"pinky_02_l",
"pinky_03_l",
"pinky_04_leaf_l",
"ring_01_l",
"ring_02_l",
"ring_03_l",
"ring_04_leaf_l",
"thumb_01_l",
"thumb_02_l",
"thumb_03_l",
"thumb_04_leaf_l",
"clavicle_r",
"upperarm_r",
"lowerarm_r",
"hand_r",
"index_01_r",
"index_02_r",
"index_03_r",
"index_04_leaf_r",
"middle_01_r",
"middle_02_r",
"middle_03_r",
"middle_04_leaf_r",
"pinky_01_r",
"pinky_02_r",
"pinky_03_r",
"pinky_04_leaf_r",
"ring_01_r",
"ring_02_r",
"ring_03_r",
"ring_04_leaf_r",
"thumb_01_r",
"thumb_02_r",
"thumb_03_r",
"thumb_04_leaf_r",
"thigh_l",
"calf_l",
"foot_l",
"ball_l",
"ball_leaf_l",
"thigh_r",
"calf_r",
"foot_r",
"ball_r",
"ball_leaf_r"
]
},
"seg_arm_upper_l": {
"verts": 183,
"bbox": {
"min": [
0.15183,
-0.00827,
1.38075
],
"max": [
0.44693,
0.13217,
1.51998
],
"dims": [
0.2951,
0.14044,
0.13923
]
},
"vgroups": [
"root",
"pelvis",
"spine_01",
"spine_02",
"spine_03",
"neck_01",
"Head",
"clavicle_l",
"upperarm_l",
"lowerarm_l",
"hand_l",
"index_01_l",
"index_02_l",
"index_03_l",
"index_04_leaf_l",
"middle_01_l",
"middle_02_l",
"middle_03_l",
"middle_04_leaf_l",
"pinky_01_l",
"pinky_02_l",
"pinky_03_l",
"pinky_04_leaf_l",
"ring_01_l",
"ring_02_l",
"ring_03_l",
"ring_04_leaf_l",
"thumb_01_l",
"thumb_02_l",
"thumb_03_l",
"thumb_04_leaf_l",
"clavicle_r",
"upperarm_r",
"lowerarm_r",
"hand_r",
"index_01_r",
"index_02_r",
"index_03_r",
"index_04_leaf_r",
"middle_01_r",
"middle_02_r",
"middle_03_r",
"middle_04_leaf_r",
"pinky_01_r",
"pinky_02_r",
"pinky_03_r",
"pinky_04_leaf_r",
"ring_01_r",
"ring_02_r",
"ring_03_r",
"ring_04_leaf_r",
"thumb_01_r",
"thumb_02_r",
"thumb_03_r",
"thumb_04_leaf_r",
"thigh_l",
"calf_l",
"foot_l",
"ball_l",
"ball_leaf_l",
"thigh_r",
"calf_r",
"foot_r",
"ball_r",
"ball_leaf_r"
]
},
"seg_arm_upper_r": {
"verts": 42,
"bbox": {
"min": [
-0.95106,
-1.0,
-1.0
],
"max": [
0.95106,
1.0,
1.0
],
"dims": [
1.90212,
2.0,
2.0
]
},
"vgroups": []
}
}
}
+248
View File
@@ -0,0 +1,248 @@
{
"garment": {
"verts": 919,
"sidekick_vgroups": [
"pelvis",
"thigh_l",
"thigh_r",
"spine_01",
"spine_02",
"spine_03",
"neck_01",
"head",
"clavicle_l",
"upperarm_l",
"clavicle_r",
"upperarm_r"
]
},
"alignment": {
"scale_xy": 0.98744,
"scale_z": 1.09356,
"translate": [
-0.0,
0.01518,
-0.04628
],
"sk_pelvis": [
0.0,
0.02817,
0.91021
],
"our_pelvis": [
-0.0,
0.043,
0.9491
],
"sk_upperarm_l": [
0.19434,
0.04519,
1.37329
],
"our_upperarm_l": [
0.1919,
0.0654,
1.4555
]
},
"post_affine_bone_residuals": {
"pelvis": {
"residual_m": 0.0,
"delta": [
0.0,
0.0,
0.0
]
},
"thigh_l": {
"residual_m": 0.05298,
"delta": [
0.00432,
0.00991,
-0.05186
]
},
"thigh_r": {
"residual_m": 0.05298,
"delta": [
-0.00433,
0.00991,
-0.05186
]
},
"spine_01": {
"residual_m": 0.08639,
"delta": [
-0.0,
0.03329,
-0.07972
]
},
"spine_02": {
"residual_m": 0.11676,
"delta": [
-0.0,
0.03483,
-0.11144
]
},
"spine_03": {
"residual_m": 0.11156,
"delta": [
-0.0,
0.03047,
-0.10732
]
},
"neck_01": {
"residual_m": 0.03682,
"delta": [
-0.0,
0.0161,
0.03311
]
},
"head": {
"residual_m": 0.0595,
"delta": [
-0.0,
0.01128,
0.05843
]
},
"clavicle_l": {
"residual_m": 0.10301,
"delta": [
-0.01185,
0.09085,
-0.04708
]
},
"upperarm_l": {
"residual_m": 0.00559,
"delta": [
-0.0,
-0.00559,
0.0
]
},
"clavicle_r": {
"residual_m": 0.10301,
"delta": [
0.01185,
0.09085,
-0.04708
]
},
"upperarm_r": {
"residual_m": 0.00559,
"delta": [
-0.0,
-0.00559,
0.0
]
}
},
"weight_source_segments": [
{
"name": "RegularMale.001",
"verts": 375
},
{
"name": "RegularMale.002",
"verts": 255
},
{
"name": "RegularMale.003",
"verts": 144
},
{
"name": "RegularMale.004",
"verts": 183
},
{
"name": "RegularMale.005",
"verts": 183
},
{
"name": "RegularMale.006",
"verts": 240
},
{
"name": "RegularMale.007",
"verts": 241
}
],
"transferred_vgroups": [
"root",
"pelvis",
"spine_01",
"spine_02",
"spine_03",
"neck_01",
"Head",
"clavicle_l",
"upperarm_l",
"lowerarm_l",
"hand_l",
"index_01_l",
"index_02_l",
"index_03_l",
"index_04_leaf_l",
"middle_01_l",
"middle_02_l",
"middle_03_l",
"middle_04_leaf_l",
"pinky_01_l",
"pinky_02_l",
"pinky_03_l",
"pinky_04_leaf_l",
"ring_01_l",
"ring_02_l",
"ring_03_l",
"ring_04_leaf_l",
"thumb_01_l",
"thumb_02_l",
"thumb_03_l",
"thumb_04_leaf_l",
"clavicle_r",
"upperarm_r",
"lowerarm_r",
"hand_r",
"index_01_r",
"index_02_r",
"index_03_r",
"index_04_leaf_r",
"middle_01_r",
"middle_02_r",
"middle_03_r",
"middle_04_leaf_r",
"pinky_01_r",
"pinky_02_r",
"pinky_03_r",
"pinky_04_leaf_r",
"ring_01_r",
"ring_02_r",
"ring_03_r",
"ring_04_leaf_r",
"thumb_01_r",
"thumb_02_r",
"thumb_03_r",
"thumb_04_leaf_r",
"thigh_l",
"calf_l",
"foot_l",
"ball_l",
"ball_leaf_l",
"thigh_r",
"calf_r",
"foot_r",
"ball_r",
"ball_leaf_r"
],
"zero_weight_verts": 0,
"export": {
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb",
"size_kb": 653
}
}
@@ -0,0 +1,137 @@
"""
Spike T-1089 / Synty Sidekick intake — step 1: rest-pose recon.
Imports the Sidekick garment FBX (with its armature) and our Quaternius
armature.glb + average_m body segments, then prints:
- world-space bone head/tail positions for the 12 vertex-group bones
in BOTH armatures + per-bone delta
- bounding boxes of the garment vs our torso-region body segments
- object transforms as-imported (scale factors etc.)
Run:
tooling/blender --background --python \
spikes/synty-intake/scripts/01_recon_rest_pose.py
"""
import bpy
import json
import os
REPO = "/var/mnt/data/projects/settled-reach"
GARMENT_FBX = os.path.join(
REPO, "spikes/synty-intake/raw/Assets/Synty/SidekickCharacters/Resources",
"Meshes/Outfits/Starter/SK_SCFI_CIVL_09_10TORS_HU01.fbx")
OUR_ARMATURE_GLB = os.path.join(REPO, "client/assets/characters/skeleton/armature.glb")
BODY_DIR = os.path.join(REPO, "client/assets/characters/bodies/average_m")
BODY_SEGS = ["seg_torso", "seg_hips", "seg_neck", "seg_arm_upper_l", "seg_arm_upper_r"]
BOUND_BONES = ["pelvis", "thigh_l", "thigh_r", "spine_01", "spine_02", "spine_03",
"neck_01", "head", "clavicle_l", "upperarm_l", "clavicle_r",
"upperarm_r"]
OUT_JSON = os.path.join(REPO, "spikes/synty-intake/out/recon_rest_pose.json")
def clear_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
def import_new(op, path, **kw):
before = set(bpy.data.objects)
op(filepath=path, **kw)
return list(set(bpy.data.objects) - before)
def find_armature(objs):
for o in objs:
if o.type == 'ARMATURE':
return o
return None
def bone_world(arm_obj, name):
b = arm_obj.data.bones.get(name)
if b is None:
return None
mw = arm_obj.matrix_world
h = mw @ b.head_local
t = mw @ b.tail_local
return {"head": [round(v, 5) for v in h], "tail": [round(v, 5) for v in t]}
def mesh_world_bbox(obj):
import mathutils
pts = [obj.matrix_world @ mathutils.Vector(c) for c in obj.bound_box]
lo = [round(min(p[i] for p in pts), 5) for i in range(3)]
hi = [round(max(p[i] for p in pts), 5) for i in range(3)]
return {"min": lo, "max": hi,
"dims": [round(hi[i] - lo[i], 5) for i in range(3)]}
clear_scene()
report = {}
# --- garment FBX ---
g_objs = import_new(bpy.ops.import_scene.fbx, GARMENT_FBX)
g_arm = find_armature(g_objs)
g_meshes = [o for o in g_objs if o.type == 'MESH']
report["garment_objects"] = [
{"name": o.name, "type": o.type,
"scale": [round(s, 6) for s in o.scale],
"location": [round(v, 6) for v in o.location]} for o in g_objs]
report["sidekick_bones"] = {}
for bn in BOUND_BONES:
report["sidekick_bones"][bn] = bone_world(g_arm, bn)
report["garment_meshes"] = {}
for m in g_meshes:
report["garment_meshes"][m.name] = {
"verts": len(m.data.vertices),
"bbox": mesh_world_bbox(m),
"vgroups": [vg.name for vg in m.vertex_groups],
"shape_keys": ([kb.name for kb in m.data.shape_keys.key_blocks]
if m.data.shape_keys else []),
}
# --- our armature ---
o_objs = import_new(bpy.ops.import_scene.gltf, OUR_ARMATURE_GLB)
o_arm = find_armature(o_objs)
report["our_armature_bone_count"] = len(o_arm.data.bones)
report["our_bones"] = {}
deltas = {}
for bn in BOUND_BONES:
ours = bn if bn != "head" else "Head"
info = bone_world(o_arm, ours)
report["our_bones"][ours] = info
sk = report["sidekick_bones"][bn]
if info and sk:
d = [round(sk["head"][i] - info["head"][i], 5) for i in range(3)]
mag = round(sum(x * x for x in d) ** 0.5, 5)
deltas[bn] = {"delta": d, "magnitude_m": mag}
report["bone_head_deltas"] = deltas
# --- our body segments (proportion reference) ---
report["our_body_segments"] = {}
for seg in BODY_SEGS:
path = os.path.join(BODY_DIR, seg + ".glb")
objs = import_new(bpy.ops.import_scene.gltf, path)
for m in objs:
if m.type == 'MESH':
report["our_body_segments"][seg] = {
"verts": len(m.data.vertices),
"bbox": mesh_world_bbox(m),
"vgroups": [vg.name for vg in m.vertex_groups],
}
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=== RECON SUMMARY ===")
print(json.dumps(deltas, indent=2))
print("Garment bbox:", json.dumps(
{k: v["bbox"]["dims"] for k, v in report["garment_meshes"].items()}))
print("Body seg bbox:", json.dumps(
{k: v["bbox"]["dims"] for k, v in report["our_body_segments"].items()}))
print("Report written:", OUT_JSON)
@@ -0,0 +1,263 @@
"""
Spike T-1089 / Synty Sidekick intake — step 2: transplant.
Route decision (from 01_recon_rest_pose.py, out/recon_rest_pose.json):
Route 1 (rename-and-rebind, keep Sidekick weights verbatim) REJECTED on
measured evidence: spine_02/spine_03 joint heads sit 0.160/0.168 m lower in
the Sidekick rig than ours, shoulders 0.082 m lower, clavicles 0.150 m off.
Rest pose would render fine (inverse binds absorb it) but every spine/shoulder
rotation would pivot the garment around joints up to 17 cm away from where the
body underneath pivots -> swim/clip under animation.
Route 2 (proven pipeline): affine-align garment onto our average_m body,
then Data Transfer POLYINTERP_NEAREST weights from OUR body mesh, normalize,
bind to OUR armature.
Alignment transform (documented, measured from bone landmarks):
scale XY by ours_shoulder_x / sk_shoulder_x
scale Z by (ours_upperarm_z - ours_pelvis_z) / (sk_upperarm_z - sk_pelvis_z)
then translate so the pelvis landmarks coincide.
Run:
tooling/blender --background --python \
spikes/synty-intake/scripts/02_transplant_garment.py
"""
import bpy
import json
import os
from mathutils import Matrix, Vector
REPO = "/var/mnt/data/projects/settled-reach"
GARMENT_FBX = os.path.join(
REPO, "spikes/synty-intake/raw/Assets/Synty/SidekickCharacters/Resources",
"Meshes/Outfits/Starter/SK_SCFI_CIVL_09_10TORS_HU01.fbx")
OUR_ARMATURE_GLB = os.path.join(REPO, "client/assets/characters/skeleton/armature.glb")
BODY_DIR = os.path.join(REPO, "client/assets/characters/bodies/average_m")
# Segments covered by / adjacent to the torso garment — the weight-transfer source.
BODY_SEGS = ["seg_torso", "seg_hips", "seg_neck",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_leg_upper_l", "seg_leg_upper_r"]
OUT_GLB = os.path.join(REPO, "spikes/synty-intake/out",
"SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb")
OUT_LOG = os.path.join(REPO, "spikes/synty-intake/out", "transplant_log.json")
log = {}
def import_new(op, path, **kw):
before = set(bpy.data.objects)
op(filepath=path, **kw)
return list(set(bpy.data.objects) - before)
def find_armature(objs):
for o in objs:
if o.type == 'ARMATURE':
return o
return None
def bone_head_world(arm, name):
b = arm.data.bones.get(name)
return (arm.matrix_world @ b.head_local) if b else None
def deselect_all():
bpy.ops.object.select_all(action='DESELECT')
def set_active(obj):
bpy.context.view_layer.objects.active = obj
# --- fresh scene ---
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
# --- 1. import garment ---
g_objs = import_new(bpy.ops.import_scene.fbx, GARMENT_FBX)
g_arm = find_armature(g_objs)
garment = next(o for o in g_objs if o.type == 'MESH')
log["garment"] = {"verts": len(garment.data.vertices),
"sidekick_vgroups": [vg.name for vg in garment.vertex_groups]}
# landmarks in Sidekick space (world)
sk_pelvis = bone_head_world(g_arm, "pelvis").copy()
sk_upperarm = bone_head_world(g_arm, "upperarm_l").copy()
# --- 2. import our armature ---
o_objs = import_new(bpy.ops.import_scene.gltf, OUR_ARMATURE_GLB)
our_arm = find_armature(o_objs)
our_pelvis = bone_head_world(our_arm, "pelvis").copy()
our_upperarm = bone_head_world(our_arm, "upperarm_l").copy()
# --- 3. detach garment from Sidekick armature, freeze world coords ---
deselect_all()
set_active(garment)
garment.select_set(True)
mw = garment.matrix_world.copy()
garment.parent = None
garment.matrix_world = mw
for mod in list(garment.modifiers):
garment.modifiers.remove(mod)
# drop Sidekick body-blend shape keys (not part of our body-type system)
if garment.data.shape_keys:
set_active(garment)
bpy.ops.object.shape_key_remove(all=True)
# bake object transform into mesh data so object space == world space
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
# --- 4. affine alignment garment -> our proportions ---
sxy = our_upperarm.x / sk_upperarm.x
sz = (our_upperarm.z - our_pelvis.z) / (sk_upperarm.z - sk_pelvis.z)
S = Matrix.Diagonal(Vector((sxy, sxy, sz, 1.0)))
scaled_pelvis = Vector((sk_pelvis.x * sxy, sk_pelvis.y * sxy, sk_pelvis.z * sz))
t = our_pelvis - scaled_pelvis
M = Matrix.Translation(t) @ S
garment.data.transform(M)
garment.data.update()
log["alignment"] = {
"scale_xy": round(sxy, 5), "scale_z": round(sz, 5),
"translate": [round(v, 5) for v in t],
"sk_pelvis": [round(v, 5) for v in sk_pelvis],
"our_pelvis": [round(v, 5) for v in our_pelvis],
"sk_upperarm_l": [round(v, 5) for v in sk_upperarm],
"our_upperarm_l": [round(v, 5) for v in our_upperarm],
}
# residual landmark error after affine, for all 12 bound bones
residuals = {}
for bn in ["pelvis", "thigh_l", "thigh_r", "spine_01", "spine_02", "spine_03",
"neck_01", "head", "clavicle_l", "upperarm_l", "clavicle_r",
"upperarm_r"]:
sk = bone_head_world(g_arm, bn)
ours = bone_head_world(our_arm, bn if bn != "head" else "Head")
sk_t = M @ sk
d = sk_t - ours
residuals[bn] = {"residual_m": round(d.length, 5),
"delta": [round(v, 5) for v in d]}
log["post_affine_bone_residuals"] = residuals
# --- 5. delete Sidekick armature ---
deselect_all()
g_arm.select_set(True)
set_active(g_arm)
bpy.ops.object.delete(use_global=False)
# --- 6. import + join our body segments as the weight-transfer source ---
seg_meshes = []
for seg in BODY_SEGS:
objs = import_new(bpy.ops.import_scene.gltf,
os.path.join(BODY_DIR, seg + ".glb"))
picked = None
for o in objs:
if o.type == 'MESH' and len(o.vertex_groups) > 0:
picked = o
elif o.type == 'MESH':
# debris (bounds icosphere etc.) — delete
deselect_all()
o.select_set(True)
set_active(o)
bpy.ops.object.delete(use_global=False)
# delete the segment's own armature copy
arm = find_armature(objs)
if arm and picked is not None:
# unparent mesh first, keep transform
pmw = picked.matrix_world.copy()
picked.parent = None
picked.matrix_world = pmw
if arm:
deselect_all()
arm.select_set(True)
set_active(arm)
bpy.ops.object.delete(use_global=False)
if picked is None:
print(f"WARNING: no weighted mesh in {seg}")
continue
for mod in list(picked.modifiers):
picked.modifiers.remove(mod)
seg_meshes.append(picked)
log["weight_source_segments"] = [
{"name": m.name, "verts": len(m.data.vertices)} for m in seg_meshes]
deselect_all()
for m in seg_meshes:
m.select_set(True)
set_active(seg_meshes[0])
if len(seg_meshes) > 1:
bpy.ops.object.join()
body = bpy.context.view_layer.objects.active
body.name = "weight_source_body"
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
# --- 7. weight transfer: our body -> garment ---
# wipe Sidekick vertex groups first so only OUR weights remain
for vg in list(garment.vertex_groups):
garment.vertex_groups.remove(vg)
deselect_all()
set_active(garment)
garment.select_set(True)
dt = garment.modifiers.new(name="WeightTransfer", type='DATA_TRANSFER')
dt.object = body
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.vert_mapping = 'POLYINTERP_NEAREST'
dt.layers_vgroup_select_src = 'ALL'
dt.layers_vgroup_select_dst = 'NAME'
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
# normalize
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
bpy.ops.object.mode_set(mode='OBJECT')
log["transferred_vgroups"] = [vg.name for vg in garment.vertex_groups]
# sanity: count verts with (near-)zero total weight
zero = 0
for v in garment.data.vertices:
tot = sum(g.weight for g in v.groups)
if tot < 1e-4:
zero += 1
log["zero_weight_verts"] = zero
# --- 8. bind garment to OUR armature ---
garment.parent = our_arm
garment.matrix_parent_inverse = our_arm.matrix_world.inverted()
am = garment.modifiers.new(name="Armature", type='ARMATURE')
am.object = our_arm
# --- 9. delete the body source, export armature + garment ---
deselect_all()
body.select_set(True)
set_active(body)
bpy.ops.object.delete(use_global=False)
os.makedirs(os.path.dirname(OUT_GLB), exist_ok=True)
deselect_all()
our_arm.select_set(True)
garment.select_set(True)
set_active(our_arm)
bpy.ops.export_scene.gltf(
filepath=OUT_GLB,
export_format='GLB',
use_selection=True,
export_apply=False,
export_animations=False,
export_skins=True,
export_morph=False,
export_extras=False,
)
log["export"] = {"path": OUT_GLB, "size_kb": os.path.getsize(OUT_GLB) // 1024}
with open(OUT_LOG, "w") as f:
json.dump(log, f, indent=2)
print("\n=== TRANSPLANT SUMMARY ===")
print(json.dumps(log, indent=2))
@@ -0,0 +1,116 @@
"""
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:
tooling/blender --background --python \
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))
@@ -0,0 +1,420 @@
"""
Spike T-1089 / Synty Sidekick intake — step 4: 11-body batch fit.
Takes the transplanted torso garment (fitted to average_m, bound to our shared
armature = the average_m bind skeleton) through a multi-body refit, following
the fit_outfits_to_bodies.py Surface-Deform approach, adapted for the measured
reality that the 11 bodies do NOT share topology (03_recon_bodies.py:
every body is independently authored; thin_*/heavy_* even lack seg_hips).
Per target body:
1. Import the transplanted garment GLB; capture reference (average_m)
landmarks from its armature; detach + freeze the garment mesh.
2. Import the average_m reference segments, join -> ref_body.
3. Import the target body segments (skipping missing ones), keep the
seg_torso armature as the target bind skeleton, join -> tgt_body.
4. Landmark affine (same machinery as 02): scale XY by shoulder ratio,
scale Z by pelvis->upperarm span ratio, translate pelvis->pelvis.
Applied to BOTH the garment and ref_body.
5. BVH nearest-surface warp (replaces Surface Deform + Shrinkwrap —
first attempt showed SD driven by a shrinkwrapped driver mesh
spikes 14-28x on fold discontinuities, and SD bind itself is
unreliable against the multi-shell joined segment mesh):
for each garment vertex, find the nearest point on ref_body,
decompose the offset into (height along surface normal +
tangential residual), re-evaluate the same surface point on
tgt_body via its own nearest-surface lookup, and rebuild the
vertex at the target surface with the offset preserved. The
per-vertex displacement field is then Laplacian-smoothed over
the garment mesh connectivity to kill nearest-point-map
discontinuities while keeping rigid details (back device,
plating) coherent.
6. Wipe vgroups, Data Transfer POLYINTERP_NEAREST weights from tgt_body,
normalize, bind to the target body's embedded armature (same bind pose
as the body segments -> garment deforms identically to the body under
any runtime skeleton pose).
7. Export armature + garment GLB (export_skins=True) to
out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_<body>.glb
8. Metrics (out/bodies_fit_log.json): zero-weight verts, AABB,
edge-length distortion vs post-affine baseline (collapse/fold
detector), signed-distance stats garment vs body surface
(penetration detector).
Run:
tooling/blender --background --python \
spikes/synty-intake/scripts/04_batch_fit_bodies.py
"""
import bpy
import json
import math
import os
from mathutils import Matrix, Vector
from mathutils.bvhtree import BVHTree
REPO = "/var/mnt/data/projects/settled-reach"
GARMENT_GLB = os.path.join(REPO, "spikes/synty-intake/out",
"SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb")
BODIES_DIR = os.path.join(REPO, "client/assets/characters/bodies")
OUT_DIR = os.path.join(REPO, "spikes/synty-intake/out/bodies")
OUT_LOG = os.path.join(REPO, "spikes/synty-intake/out/bodies_fit_log.json")
BODY_TYPES = ["average_m", "average_f", "muscular_m", "muscular_f",
"thin_m", "thin_f", "heavy_m", "heavy_f",
"teen_m", "teen_f", "child"]
SEGS = ["seg_torso", "seg_hips", "seg_neck",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_leg_upper_l", "seg_leg_upper_r"]
PENETRATION_MM = 3.0 # garment vertex deeper than this inside the body -> flag
def deselect_all():
bpy.ops.object.select_all(action='DESELECT')
def set_active(obj):
bpy.context.view_layer.objects.active = obj
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(op, path, **kw):
before = set(bpy.data.objects)
op(filepath=path, **kw)
return list(set(bpy.data.objects) - before)
def find_armature(objs):
for o in objs:
if o.type == 'ARMATURE':
return o
return None
def bone_head_world(arm, name):
b = arm.data.bones.get(name)
return (arm.matrix_world @ b.head_local).copy() if b else None
def delete_obj(obj):
deselect_all()
obj.select_set(True)
set_active(obj)
bpy.ops.object.delete(use_global=False)
def freeze(obj):
"""Unparent keeping transform, strip modifiers, bake transform to data."""
deselect_all()
set_active(obj)
obj.select_set(True)
mw = obj.matrix_world.copy()
obj.parent = None
obj.matrix_world = mw
for mod in list(obj.modifiers):
obj.modifiers.remove(mod)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
def import_body(body, keep_armature):
"""Import + join a body's garment-adjacent segments.
Returns (joined_mesh, armature_or_None, segments_used).
"""
seg_meshes = []
kept_arm = None
used = []
for seg in SEGS:
path = os.path.join(BODIES_DIR, body, seg + ".glb")
if not os.path.exists(path):
continue
objs = import_new(bpy.ops.import_scene.gltf, path)
picked = None
for o in objs:
if o.type == 'MESH' and len(o.vertex_groups) > 0:
picked = o
elif o.type == 'MESH':
delete_obj(o) # debris (bounds helpers etc.)
arm = find_armature(objs)
if picked is not None:
pmw = picked.matrix_world.copy()
picked.parent = None
picked.matrix_world = pmw
if arm is not None:
if keep_armature and kept_arm is None:
kept_arm = arm
else:
delete_obj(arm)
if picked is None:
print(f" WARNING: no weighted mesh in {body}/{seg}")
continue
for mod in list(picked.modifiers):
picked.modifiers.remove(mod)
seg_meshes.append(picked)
used.append(seg)
deselect_all()
for m in seg_meshes:
m.select_set(True)
set_active(seg_meshes[0])
if len(seg_meshes) > 1:
bpy.ops.object.join()
joined = bpy.context.view_layer.objects.active
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
return joined, kept_arm, used
def edge_lengths(mesh_obj):
verts = mesh_obj.data.vertices
return [(verts[e.vertices[0]].co - verts[e.vertices[1]].co).length
for e in mesh_obj.data.edges]
def aabb(mesh_obj):
xs = [v.co for v in mesh_obj.data.vertices]
lo = Vector((min(v.x for v in xs), min(v.y for v in xs),
min(v.z for v in xs)))
hi = Vector((max(v.x for v in xs), max(v.y for v in xs),
max(v.z for v in xs)))
return lo, hi
def warp_garment(garment, ref_body, tgt_body, depsgraph,
smooth_iterations=20, smooth_lambda=0.5):
"""Nearest-surface warp: move each garment vertex from its offset
relative to ref_body onto the equivalent offset relative to tgt_body,
then Laplacian-smooth the displacement field over the garment mesh."""
bvh_ref = BVHTree.FromObject(ref_body, depsgraph)
bvh_tgt = BVHTree.FromObject(tgt_body, depsgraph)
verts = garment.data.vertices
n = len(verts)
disp = [None] * n
for i, v in enumerate(verts):
co_r, n_r, _idx, _d = bvh_ref.find_nearest(v.co)
if co_r is None:
disp[i] = Vector((0, 0, 0))
continue
delta = v.co - co_r
h = delta.dot(n_r)
tang = delta - h * n_r
co_t, n_t, _idx2, _d2 = bvh_tgt.find_nearest(co_r)
if co_t is None:
disp[i] = Vector((0, 0, 0))
continue
new_co = co_t + h * n_t + tang
disp[i] = new_co - v.co
# adjacency from edges
adj = [[] for _ in range(n)]
for e in garment.data.edges:
a, b = e.vertices
adj[a].append(b)
adj[b].append(a)
# Laplacian smoothing of the displacement field (not the geometry)
for _ in range(smooth_iterations):
new_disp = [None] * n
for i in range(n):
if not adj[i]:
new_disp[i] = disp[i]
continue
avg = Vector((0, 0, 0))
for j in adj[i]:
avg += disp[j]
avg /= len(adj[i])
new_disp[i] = disp[i].lerp(avg, smooth_lambda)
disp = new_disp
max_disp = 0.0
for i, v in enumerate(verts):
v.co = v.co + disp[i]
if disp[i].length > max_disp:
max_disp = disp[i].length
garment.data.update()
return {"max_displacement_mm": round(max_disp * 1000, 1),
"smooth_iterations": smooth_iterations}
def signed_distance_stats(garment, body_obj, depsgraph):
"""Signed distance of each garment vertex to the body surface.
Positive = outside the body (along surface normal), negative = inside.
"""
bvh = BVHTree.FromObject(body_obj, depsgraph)
dists = []
for v in garment.data.vertices:
co, normal, _idx, _d = bvh.find_nearest(v.co)
if co is None:
continue
dists.append((v.co - co).dot(normal))
dists.sort()
n = len(dists)
inside = [d for d in dists if d < -PENETRATION_MM / 1000.0]
return {
"verts_sampled": n,
"min_signed_mm": round(dists[0] * 1000, 2),
"p05_signed_mm": round(dists[max(0, int(n * 0.05) - 1)] * 1000, 2),
"median_signed_mm": round(dists[n // 2] * 1000, 2),
"max_signed_mm": round(dists[-1] * 1000, 2),
f"verts_inside_gt_{PENETRATION_MM:g}mm": len(inside),
"pct_inside": round(100.0 * len(inside) / n, 2),
}
log = {"garment_glb": GARMENT_GLB, "bodies": {}}
os.makedirs(OUT_DIR, exist_ok=True)
for body in BODY_TYPES:
print(f"\n{'='*60}\n BODY: {body}\n{'='*60}")
entry = {}
clear_scene()
# --- 1. garment + reference landmarks ---
g_objs = import_new(bpy.ops.import_scene.gltf, GARMENT_GLB)
ref_arm = find_armature(g_objs)
# the GLB also carries a bounds icosphere — the garment is the skinned
# mesh (has vertex groups); the set-diff order is nondeterministic, so
# pick explicitly and delete the rest
g_meshes = [o for o in g_objs if o.type == 'MESH']
garment = max((m for m in g_meshes if len(m.vertex_groups) > 0),
key=lambda m: len(m.data.vertices))
ref_pelvis = bone_head_world(ref_arm, "pelvis")
ref_upperarm = bone_head_world(ref_arm, "upperarm_l")
freeze(garment)
for m in g_meshes:
if m is not garment:
delete_obj(m)
delete_obj(ref_arm)
entry["garment_verts"] = len(garment.data.vertices)
# --- 2. reference body (average_m) ---
ref_body, _, _ = import_body("average_m", keep_armature=False)
ref_body.name = "ref_body"
# --- 3. target body ---
tgt_body, tgt_arm, used = import_body(body, keep_armature=True)
tgt_body.name = "tgt_body"
entry["segments_used"] = used
if tgt_arm is None:
entry["result"] = "FAIL: no target armature"
log["bodies"][body] = entry
continue
tgt_pelvis = bone_head_world(tgt_arm, "pelvis")
tgt_upperarm = bone_head_world(tgt_arm, "upperarm_l")
# --- 4. landmark affine ref -> tgt ---
sxy = tgt_upperarm.x / ref_upperarm.x
sz = (tgt_upperarm.z - tgt_pelvis.z) / (ref_upperarm.z - ref_pelvis.z)
S = Matrix.Diagonal(Vector((sxy, sxy, sz, 1.0)))
scaled_pelvis = Vector((ref_pelvis.x * sxy, ref_pelvis.y * sxy,
ref_pelvis.z * sz))
t = tgt_pelvis - scaled_pelvis
M = Matrix.Translation(t) @ S
garment.data.transform(M)
garment.data.update()
ref_body.data.transform(M)
ref_body.data.update()
entry["affine"] = {"scale_xy": round(sxy, 5), "scale_z": round(sz, 5),
"translate": [round(v, 5) for v in t]}
# post-affine baseline for distortion metrics
base_edges = edge_lengths(garment)
lo, hi = aabb(garment)
entry["aabb_post_affine"] = {"min": [round(v, 4) for v in lo],
"max": [round(v, 4) for v in hi]}
# --- 5. BVH nearest-surface warp ref_body -> tgt_body ---
depsgraph = bpy.context.evaluated_depsgraph_get()
entry["warp"] = warp_garment(garment, ref_body, tgt_body, depsgraph)
delete_obj(ref_body)
# --- 7. weights from the target body ---
for vg in list(garment.vertex_groups):
garment.vertex_groups.remove(vg)
deselect_all()
set_active(garment)
garment.select_set(True)
dt = garment.modifiers.new(name="WeightTransfer", type='DATA_TRANSFER')
dt.object = tgt_body
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.vert_mapping = 'POLYINTERP_NEAREST'
dt.layers_vgroup_select_src = 'ALL'
dt.layers_vgroup_select_dst = 'NAME'
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
bpy.ops.object.mode_set(mode='OBJECT')
zero = sum(1 for v in garment.data.vertices
if sum(g.weight for g in v.groups) < 1e-4)
entry["zero_weight_verts"] = zero
entry["vgroups"] = len(garment.vertex_groups)
# --- 8. metrics ---
post_edges = edge_lengths(garment)
ratios = [p / b for p, b in zip(post_edges, base_edges) if b > 1e-9]
entry["edge_distortion"] = {
"max_stretch": round(max(ratios), 3),
"max_shrink": round(min(ratios), 3),
"edges_gt_2x": sum(1 for r in ratios if r > 2.0),
"edges_lt_0.5x": sum(1 for r in ratios if r < 0.5),
"edges_total": len(ratios),
}
lo, hi = aabb(garment)
entry["aabb_post_fit"] = {"min": [round(v, 4) for v in lo],
"max": [round(v, 4) for v in hi]}
depsgraph = bpy.context.evaluated_depsgraph_get()
entry["signed_distance"] = signed_distance_stats(garment, tgt_body,
depsgraph)
# --- 9. bind + export ---
garment.parent = tgt_arm
garment.matrix_parent_inverse = tgt_arm.matrix_world.inverted()
am = garment.modifiers.new(name="Armature", type='ARMATURE')
am.object = tgt_arm
delete_obj(tgt_body)
out_glb = os.path.join(OUT_DIR,
f"SK_SCFI_CIVL_09_10TORS_HU01_{body}.glb")
deselect_all()
tgt_arm.select_set(True)
garment.select_set(True)
set_active(tgt_arm)
bpy.ops.export_scene.gltf(
filepath=out_glb,
export_format='GLB',
use_selection=True,
export_apply=False,
export_animations=False,
export_skins=True,
export_morph=False,
export_extras=False,
)
entry["export"] = {"path": out_glb,
"size_kb": os.path.getsize(out_glb) // 1024}
entry["result"] = "OK"
log["bodies"][body] = entry
with open(OUT_LOG, "w") as f:
json.dump(log, f, indent=2)
print("\n=== BATCH FIT SUMMARY ===")
for body, e in log["bodies"].items():
sd_stats = e.get("signed_distance", {})
print(f" {body:12s} {e.get('result','?'):14s} "
f"zero_w={e.get('zero_weight_verts','-')} "
f"inside%={sd_stats.get('pct_inside','-')} "
f"stretch={e.get('edge_distortion',{}).get('max_stretch','-')}")
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Extract a .unitypackage (gzipped tar of GUID dirs) into real paths.
Copies the raw `asset` bytes of every FBX/texture/metadata entry to
raw/<original pathname>. Prints a summary by extension.
"""
import sys
import tarfile
import shutil
from pathlib import Path
from collections import Counter
KEEP = {".fbx", ".png", ".tga", ".json", ".txt", ".sk", ".asset"}
def main(pkg: str, out_root: str) -> None:
out = Path(out_root)
out.mkdir(parents=True, exist_ok=True)
counts: Counter = Counter()
with tarfile.open(pkg, "r:gz") as tar:
members = {m.name: m for m in tar.getmembers()}
guids = sorted({name.split("/")[0] for name in members if "/" in name})
for guid in guids:
pn = members.get(f"{guid}/pathname")
asset = members.get(f"{guid}/asset")
if pn is None or asset is None:
continue
pathname = tar.extractfile(pn).read().decode("utf-8").splitlines()[0].strip()
ext = Path(pathname).suffix.lower()
if ext not in KEEP:
counts[f"(skipped {ext or 'dir'})"] += 1
continue
dest = out / pathname
dest.parent.mkdir(parents=True, exist_ok=True)
with tar.extractfile(asset) as src, open(dest, "wb") as dst:
shutil.copyfileobj(src, dst)
counts[ext] += 1
for ext, n in sorted(counts.items()):
print(f"{ext}\t{n}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
+193
View File
@@ -0,0 +1,193 @@
extends Node3D
## Spike T-1089 Synty intake — 11-body batch QA scene (THROWAWAY, lives in
## client/spike_synty_qa/ only for the res:// path; source of truth is
## spikes/synty-intake/scripts/qa_bodies.gd).
##
## For each body type on disk: builds a CharacterVisual, reparents that
## body's refitted Synty torso garment onto its skeleton (mirrors
## character_visual.gd:517-535), screenshots idle front, then plays Walk
## and screenshots two stride phases (front + side). Peasant pants/shoes
## are added only for bodies that have them on disk. Saves to
## spikes/synty-intake/out/qa_bodies/, then quits.
const BODIES_GLB_DIR := "res://spike_synty_qa/bodies/"
const SHOT_DIR := "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/qa_bodies/"
const CLOTHING_DIR := "res://assets/characters/clothing/"
const BODY_KEYS: Array[String] = [
"average_m", "average_f", "muscular_m", "muscular_f",
"thin_m", "thin_f", "heavy_m", "heavy_f",
"teen_m", "teen_f", "child",
]
const KEY_TO_ENUM := {
"average_m": CharacterVisualDescriptor.BodyType.AVERAGE_M,
"average_f": CharacterVisualDescriptor.BodyType.AVERAGE_F,
"muscular_m": CharacterVisualDescriptor.BodyType.MUSCULAR_M,
"muscular_f": CharacterVisualDescriptor.BodyType.MUSCULAR_F,
"thin_m": CharacterVisualDescriptor.BodyType.THIN_M,
"thin_f": CharacterVisualDescriptor.BodyType.THIN_F,
"heavy_m": CharacterVisualDescriptor.BodyType.HEAVY_M,
"heavy_f": CharacterVisualDescriptor.BodyType.HEAVY_F,
"teen_m": CharacterVisualDescriptor.BodyType.TEEN_M,
"teen_f": CharacterVisualDescriptor.BodyType.TEEN_F,
"child": CharacterVisualDescriptor.BodyType.CHILD,
}
var _cam: Camera3D = null
func _ready() -> void:
_setup_stage()
_run_qa.call_deferred()
func _setup_stage() -> void:
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-45, 30, 0)
light.light_energy = 1.2
add_child(light)
var env := Environment.new()
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.8, 0.8, 0.85)
env.ambient_light_energy = 0.9
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.18, 0.19, 0.22)
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
_cam = Camera3D.new()
add_child(_cam)
func _cam_front(h: float) -> void:
_cam.position = Vector3(0, h * 0.72, h * 0.85)
_cam.look_at_from_position(_cam.position, Vector3(0, h * 0.62, 0), Vector3.UP)
func _cam_side(h: float) -> void:
_cam.position = Vector3(h * 0.85, h * 0.72, 0)
_cam.look_at_from_position(_cam.position, Vector3(0, h * 0.62, 0), Vector3.UP)
func _attach_garment(skel: Skeleton3D, glb_path: String) -> Array[MeshInstance3D]:
var out: Array[MeshInstance3D] = []
var scene := load(glb_path) as PackedScene
if scene == null:
push_error("QA: garment GLB failed to load: " + glb_path)
return out
var inst := scene.instantiate()
var stack: Array[Node] = [inst]
while not stack.is_empty():
var n: Node = stack.pop_back()
for c in n.get_children():
stack.append(c)
if n is MeshInstance3D and (n as MeshInstance3D).skin != null:
var mi := n as MeshInstance3D
mi.get_parent().remove_child(mi)
mi.owner = null
skel.add_child(mi)
out.append(mi)
inst.queue_free()
return out
func _shot(fname: String) -> void:
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(SHOT_DIR + fname)
print("QA: saved ", fname)
func _wait_frames(n: int) -> void:
for i in n:
await get_tree().process_frame
func _run_qa() -> void:
DirAccess.make_dir_recursive_absolute(SHOT_DIR)
for body_key: String in BODY_KEYS:
var glb := BODIES_GLB_DIR + "SK_SCFI_CIVL_09_10TORS_HU01_%s.glb" % body_key
if not ResourceLoader.exists(glb):
print("QA: MISSING garment GLB for ", body_key, " — skipped")
continue
var cv := CharacterVisual.new()
add_child(cv)
var desc := CharacterVisualDescriptor.new()
desc.body_type = KEY_TO_ENUM[body_key]
desc.head_id = "head_001"
desc.hair_id = "buzzed"
desc.eyebrow_id = "regular"
desc.skin_tone = 3
var slots := {}
if ResourceLoader.exists(CLOTHING_DIR + "peasant_pants/%s.glb" % body_key):
slots["legs"] = "peasant_pants"
if ResourceLoader.exists(CLOTHING_DIR + "peasant_shoes/%s.glb" % body_key):
slots["feet"] = "peasant_shoes"
desc.clothing_slots = slots
cv.load_descriptor(desc)
var skel := cv.get_skeleton()
if skel == null:
push_error("QA: no skeleton for " + body_key)
cv.queue_free()
continue
print("QA[", body_key, "]: skeleton bones = ", skel.get_bone_count(),
" pants = ", slots.has("legs"))
# rough height for camera framing (child/teen are shorter)
var h0 := 1.75
if body_key == "child" or body_key.begins_with("teen"):
h0 = 1.45
# body-only control shot BEFORE the garment — separates body-asset
# rendering defects from garment fit defects
await _wait_frames(5)
_cam_front(h0)
await _shot("%s_0_body_only.png" % body_key)
var meshes := _attach_garment(skel, glb)
for mi in meshes:
print("QA[", body_key, "]: garment '", mi.name, "' skin binds = ",
mi.skin.get_bind_count(), " aabb = ", mi.get_aabb())
var h := h0
await _wait_frames(5)
_cam_front(h)
await _shot("%s_1_idle_front.png" % body_key)
var ap := cv.get_animation_player()
var walk := ""
if ap:
for lib_name in ap.get_animation_library_list():
var lib := ap.get_animation_library(lib_name)
for a in lib.get_animation_list():
var full := str(lib_name) + "/" + str(a)
if full.to_lower().contains("walk"):
walk = str(a)
if full.to_lower().ends_with("/walk"):
break
if walk != "":
cv.play_animation(walk)
await _wait_frames(24)
_cam_front(h)
await _shot("%s_2_walk_front.png" % body_key)
_cam_side(h)
await _wait_frames(8)
await _shot("%s_3_walk_side.png" % body_key)
else:
push_warning("QA: no walk animation for " + body_key)
for mi in meshes:
print("QA[", body_key, "]: post-walk garment aabb = ", mi.get_aabb())
cv.queue_free()
await _wait_frames(2)
print("QA: DONE")
get_tree().quit(0)
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://synty_qa_bodies1"]
[ext_resource type="Script" path="res://spike_synty_qa/qa_bodies.gd" id="1"]
[node name="QABodies" type="Node3D"]
script = ExtResource("1")
+167
View File
@@ -0,0 +1,167 @@
extends Node3D
## Spike T-1089 Synty intake — Godot QA scene (THROWAWAY, lives in
## client/spike_synty_qa/ only for the res:// path; source of truth is
## spikes/synty-intake/scripts/qa_scene.gd).
##
## Builds a CharacterVisual (average_m + peasant pants/shoes), reparents the
## transplanted Synty torso garment onto its skeleton (mirrors
## character_visual.gd:517-535), plays Walk, saves screenshots to
## spikes/synty-intake/out/qa/, then quits.
const GARMENT_GLB := "res://spike_synty_qa/SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb"
const SHOT_DIR := "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/qa/"
var _cv: CharacterVisual = null
var _cam: Camera3D = null
var _garment_meshes: Array[MeshInstance3D] = []
func _ready() -> void:
_setup_stage()
_cv = CharacterVisual.new()
add_child(_cv)
var desc := CharacterVisualDescriptor.new()
desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
desc.head_id = "head_001"
desc.hair_id = "buzzed"
desc.eyebrow_id = "regular"
desc.skin_tone = 3
desc.clothing_slots = {"legs": "peasant_pants", "feet": "peasant_shoes"}
_cv.load_descriptor(desc)
var skel := _cv.get_skeleton()
print("QA: skeleton bones = ", skel.get_bone_count() if skel else -1)
_attach_garment(skel)
_run_qa.call_deferred()
func _setup_stage() -> void:
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-45, 30, 0)
light.light_energy = 1.2
add_child(light)
var env := Environment.new()
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.8, 0.8, 0.85)
env.ambient_light_energy = 0.9
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.18, 0.19, 0.22)
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
_cam = Camera3D.new()
add_child(_cam)
_set_cam_front()
func _set_cam_front() -> void:
_cam.position = Vector3(0, 1.25, 1.4)
_cam.look_at_from_position(_cam.position, Vector3(0, 1.15, 0), Vector3.UP)
func _set_cam_side() -> void:
_cam.position = Vector3(1.4, 1.25, 0)
_cam.look_at_from_position(_cam.position, Vector3(0, 1.15, 0), Vector3.UP)
func _set_cam_back() -> void:
_cam.position = Vector3(0, 1.25, -1.4)
_cam.look_at_from_position(_cam.position, Vector3(0, 1.15, 0), Vector3.UP)
func _attach_garment(skel: Skeleton3D) -> void:
# Mirror character_visual.gd:517-535 — reparent skinned meshes onto OUR
# skeleton; matching bone names in the Skin resource drive them.
var scene := load(GARMENT_GLB) as PackedScene
if scene == null:
push_error("QA: garment GLB failed to load: " + GARMENT_GLB)
return
var inst := scene.instantiate()
var stack: Array[Node] = [inst]
while not stack.is_empty():
var n: Node = stack.pop_back()
for c in n.get_children():
stack.append(c)
if n is MeshInstance3D and (n as MeshInstance3D).skin != null:
var mi := n as MeshInstance3D
mi.get_parent().remove_child(mi)
mi.owner = null
skel.add_child(mi)
_garment_meshes.append(mi)
inst.queue_free()
print("QA: garment skinned meshes attached = ", _garment_meshes.size())
for mi in _garment_meshes:
print("QA: garment mesh '", mi.name, "' skin binds = ", mi.skin.get_bind_count())
func _shot(fname: String) -> void:
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(SHOT_DIR + fname)
print("QA: saved ", fname)
func _wait_frames(n: int) -> void:
for i in n:
await get_tree().process_frame
func _run_qa() -> void:
await _wait_frames(5)
# report garment AABB sanity (explosion check)
for mi in _garment_meshes:
print("QA: garment local AABB = ", mi.get_aabb())
# --- idle ---
await _shot("01_idle_front.png")
_set_cam_side()
await _shot("02_idle_side.png")
_set_cam_back()
await _shot("02b_idle_back.png")
# --- walk ---
var ap := _cv.get_animation_player()
var names: Array[String] = []
if ap:
for lib_name in ap.get_animation_library_list():
var lib := ap.get_animation_library(lib_name)
for a in lib.get_animation_list():
names.append(str(lib_name) + "/" + str(a))
print("QA: animations = ", names)
var walk := ""
for n in names:
if n.to_lower().contains("walk"):
walk = n.get_slice("/", 1)
if n.to_lower().ends_with("/walk"):
break
if walk == "":
push_warning("QA: no walk animation found — staying on idle")
else:
print("QA: playing '", walk, "'")
_cv.play_animation(walk)
# mid-stride frames at two phases, front + side
await _wait_frames(24)
_set_cam_front()
await _shot("03_walk_front_a.png")
_set_cam_side()
await _shot("04_walk_side_a.png")
await _wait_frames(14)
await _shot("05_walk_side_b.png")
_set_cam_front()
await _shot("06_walk_front_b.png")
_set_cam_back()
await _wait_frames(10)
await _shot("07_walk_back.png")
for mi in _garment_meshes:
print("QA: post-walk garment local AABB = ", mi.get_aabb())
print("QA: DONE")
get_tree().quit(0)
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://spike_synty_qa1"]
[ext_resource type="Script" path="res://spike_synty_qa/qa_scene.gd" id="1"]
[node name="QARoot" type="Node3D"]
script = ExtResource("1")
@@ -0,0 +1,37 @@
"""Quick recon: joined garment-adjacent body mesh AABB per body type."""
import bpy
import os
REPO = "/var/mnt/data/projects/settled-reach"
BODIES_DIR = os.path.join(REPO, "client/assets/characters/bodies")
BODY_TYPES = ["average_m", "average_f", "muscular_m", "muscular_f",
"thin_m", "thin_f", "heavy_m", "heavy_f",
"teen_m", "teen_f", "child"]
SEGS = ["seg_torso", "seg_hips", "seg_neck",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_leg_upper_l", "seg_leg_upper_r"]
for body in BODY_TYPES:
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
xs, ys, zs = [], [], []
t_xs, t_ys = [], []
for seg in SEGS:
path = os.path.join(BODIES_DIR, body, seg + ".glb")
if not os.path.exists(path):
continue
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=path)
for o in set(bpy.data.objects) - before:
if o.type == 'MESH' and len(o.vertex_groups) > 0:
for v in o.data.vertices:
w = o.matrix_world @ v.co
xs.append(w.x)
ys.append(w.y)
zs.append(w.z)
if seg == "seg_torso":
t_xs.append(w.x)
t_ys.append(w.y)
print(f"AABB {body}: full x={max(xs)-min(xs):.3f} "
f"y={max(ys)-min(ys):.3f} z_top={max(zs):.3f} "
f"| torso x={max(t_xs)-min(t_xs):.3f} depth y={max(t_ys)-min(t_ys):.3f}")
@@ -0,0 +1,23 @@
"""Quick recon: which bind skeleton do production per-body clothing GLBs embed?"""
import bpy
import os
REPO = "/var/mnt/data/projects/settled-reach"
FILES = [
"client/assets/characters/clothing/peasant_pants/teen_m.glb",
"client/assets/characters/clothing/peasant_pants/average_f.glb",
"client/assets/characters/clothing/peasant_pants/average_m.glb",
]
for rel in FILES:
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
bpy.ops.import_scene.gltf(filepath=os.path.join(REPO, rel))
for o in bpy.data.objects:
if o.type == 'ARMATURE':
for bn in ("pelvis", "upperarm_l"):
b = o.data.bones.get(bn)
if b:
w = o.matrix_world @ b.head_local
print(f"RECON {rel} {bn} "
f"({w.x:.5f}, {w.y:.5f}, {w.z:.5f})")
+42
View File
@@ -0,0 +1,42 @@
"""Blender recon: dump skeleton/mesh facts for a Sidekick FBX and our armature.
Usage: tooling/blender --background --python recon_dump.py -- <fbx> <our_armature_glb>
"""
import sys
import bpy
argv = sys.argv[sys.argv.index("--") + 1:]
fbx_path, glb_path = argv[0], argv[1]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=fbx_path)
print("=== SIDEKICK FBX:", fbx_path.split("/")[-1])
sk_bones = []
for obj in bpy.data.objects:
if obj.type == "ARMATURE":
sk_bones = sorted(b.name for b in obj.data.bones)
print("ARMATURE:", obj.name, "bones:", len(sk_bones))
elif obj.type == "MESH":
keys = obj.data.shape_keys
print(
"MESH:", obj.name,
"verts:", len(obj.data.vertices),
"dim:", [round(d, 3) for d in obj.dimensions],
"shapekeys:", [k.name for k in keys.key_blocks] if keys else [],
"vgroups:", len(obj.vertex_groups),
)
print("SK_BONES:", ",".join(sk_bones))
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=glb_path)
print("=== OURS:", glb_path.split("/")[-1])
our_bones = []
for obj in bpy.data.objects:
if obj.type == "ARMATURE":
our_bones = sorted(b.name for b in obj.data.bones)
print("ARMATURE:", obj.name, "bones:", len(our_bones))
print("OUR_BONES:", ",".join(our_bones))
overlap = set(sk_bones) & set(our_bones)
print("OVERLAP:", len(overlap), "of", len(sk_bones), "sidekick /", len(our_bones), "ours")
@@ -0,0 +1,11 @@
"""Dump vertex-group names of every mesh in an FBX."""
import sys
import bpy
fbx_path = sys.argv[sys.argv.index("--") + 1]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=fbx_path)
for obj in bpy.data.objects:
if obj.type == "MESH":
print("MESH:", obj.name, "VGROUPS:", ",".join(g.name for g in obj.vertex_groups))