@bitmagic/cli 0.1.65 → 0.1.66-dev.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -1
- package/assets/blender-level/find_blender.py +3 -1
- package/assets/character-rigging/SKILL.md +80 -0
- package/assets/character-rigging/references/articulation.md +55 -0
- package/assets/character-rigging/references/new-rig.md +51 -0
- package/assets/character-rigging/references/repairs.md +44 -0
- package/assets/character-rigging/references/verification.md +49 -0
- package/assets/character-rigging/scripts/rig_tool.py +197 -0
- package/assets/character-skin-authoring/SKILL.md +43 -0
- package/assets/character-skin-authoring/references/exporters.md +134 -0
- package/assets/character-skin-authoring/references/eyes.md +215 -0
- package/assets/character-skin-authoring/references/materials.md +115 -0
- package/assets/character-skin-authoring/references/playground.md +78 -0
- package/assets/character-skin-authoring/references/profiles.md +97 -0
- package/assets/character-skin-authoring/references/projection.md +190 -0
- package/assets/character-skin-authoring/references/regional-detail.md +72 -0
- package/assets/character-skin-authoring/scripts/assemble_character.py +205 -0
- package/assets/character-skin-authoring/scripts/authored_detail.py +47 -0
- package/assets/character-skin-authoring/scripts/authored_surface_patch.py +83 -0
- package/assets/character-skin-authoring/scripts/brow_groom.py +62 -0
- package/assets/character-skin-authoring/scripts/build_face_detail.py +107 -0
- package/assets/character-skin-authoring/scripts/build_surface_candidate.py +28 -0
- package/assets/character-skin-authoring/scripts/canthus_wall.py +57 -0
- package/assets/character-skin-authoring/scripts/character_build.py +54 -0
- package/assets/character-skin-authoring/scripts/eye_assets.py +333 -0
- package/assets/character-skin-authoring/scripts/eye_opening.py +114 -0
- package/assets/character-skin-authoring/scripts/eye_texture.py +35 -0
- package/assets/character-skin-authoring/scripts/eyelid_detail.py +34 -0
- package/assets/character-skin-authoring/scripts/eyelid_morph.py +171 -0
- package/assets/character-skin-authoring/scripts/eyelid_patch.py +173 -0
- package/assets/character-skin-authoring/scripts/face_atlas.py +51 -0
- package/assets/character-skin-authoring/scripts/geometry_repair.py +112 -0
- package/assets/character-skin-authoring/scripts/glb_arrays.py +13 -0
- package/assets/character-skin-authoring/scripts/glb_output.py +21 -0
- package/assets/character-skin-authoring/scripts/inspect_skin_point.py +66 -0
- package/assets/character-skin-authoring/scripts/normal_patch_height.py +38 -0
- package/assets/character-skin-authoring/scripts/prepare_face_reference.py +23 -0
- package/assets/character-skin-authoring/scripts/regional_atlas.py +51 -0
- package/assets/character-skin-authoring/scripts/select_preview.py +90 -0
- package/assets/character-skin-authoring/scripts/skin_views.py +267 -0
- package/assets/character-skin-authoring/scripts/surface_pbr_maps.py +46 -0
- package/assets/character-skin-authoring/scripts/texture_repair.py +98 -0
- package/assets/character-skin-authoring/scripts/vertex_detail_support.py +80 -0
- package/assets/character-skin-authoring/scripts/vision_blend_materials.py +74 -0
- package/assets/character-skin-authoring/scripts/vision_materials.py +276 -0
- package/assets/character-skin-authoring/scripts/workspace_paths.py +18 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanActor.ts +580 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanLabHooks.ts +122 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanMaterials.ts +500 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanSpeech.ts +242 -0
- package/assets/metahuman/src/work/metahuman/StudioEnvironment.ts +142 -0
- package/assets/metahuman/tools/lipsync/make_line.py +181 -0
- package/assets/metahuman/tools/metahuman/README.md +21 -0
- package/assets/metahuman/tools/metahuman/e2e/eval.mjs +34 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/arm-pose-check.js +32 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/bone-displacement.js +26 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/clip-shoulder.js +41 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/grafted-bones.js +11 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-descent.js +38 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-sweep.js +31 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/helper-joints-inspect.js +10 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/outfit-bad-vertices.js +26 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/outfit-body-material.js +7 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/outfit-own-skeleton.js +8 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/shirt-bone-ownership.js +25 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/shirt-edge-stretch.js +26 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/shirt-long-edges.js +23 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/walk-sampler.js +105 -0
- package/assets/metahuman/tools/metahuman/e2e/shot.mjs +125 -0
- package/assets/metahuman/tools/metahuman/e2e/walk-probe.mjs +43 -0
- package/assets/metahuman/tools/metahuman/merge-skeletons.mjs +69 -0
- package/assets/metahuman/tools/metahuman/package.json +13 -0
- package/assets/metahuman/tools/metahuman/prepare.sh +25 -0
- package/assets/metahuman/tools/metahuman/reorder-body-first.mjs +20 -0
- package/assets/metahuman/tools/metahuman/smooth-outfit-weights.mjs +48 -0
- package/assets/metahuman/tools/metahuman/strip-draco.mjs +32 -0
- package/assets/metahuman/tools/metahuman/transfer-skin-weights.mjs +90 -0
- package/dist/assets/voxelize-glb.d.ts +5 -1
- package/dist/assets/voxelize-glb.js +1 -0
- package/dist/assets/voxelize-glb.js.map +1 -1
- package/dist/assets/vxl-summary.js +2 -2
- package/dist/commands/forge.js +3 -1
- package/dist/commands/forge.js.map +1 -1
- package/dist/commands/generate.d.ts +62 -0
- package/dist/commands/generate.js +241 -2
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.js +2 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/judge.js +5 -0
- package/dist/commands/judge.js.map +1 -1
- package/dist/commands/verify.js +7 -0
- package/dist/commands/verify.js.map +1 -1
- package/dist/editor/asset-viewer.js +10 -5
- package/dist/editor/asset-viewer.js.map +1 -1
- package/dist/generate/audio-file.d.ts +6 -0
- package/dist/generate/audio-file.js +42 -0
- package/dist/generate/audio-file.js.map +1 -0
- package/dist/generate/model.js +3 -3
- package/dist/generate/model.js.map +1 -1
- package/dist/generate/music.d.ts +4 -3
- package/dist/generate/music.js +6 -23
- package/dist/generate/music.js.map +1 -1
- package/dist/generate/prop.d.ts +19 -1
- package/dist/generate/prop.js +34 -6
- package/dist/generate/prop.js.map +1 -1
- package/dist/generate/stream.d.ts +20 -0
- package/dist/generate/stream.js +15 -1
- package/dist/generate/stream.js.map +1 -1
- package/dist/scaffold/asset-pipeline-skill-files.js +7 -2
- package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
- package/dist/scaffold/character-rigging-skill-files.d.ts +2 -0
- package/dist/scaffold/character-rigging-skill-files.js +17 -0
- package/dist/scaffold/character-rigging-skill-files.js.map +1 -0
- package/dist/scaffold/character-skin-skill-files.d.ts +2 -0
- package/dist/scaffold/character-skin-skill-files.js +49 -0
- package/dist/scaffold/character-skin-skill-files.js.map +1 -0
- package/dist/scaffold/metahuman-skill-files.d.ts +21 -0
- package/dist/scaffold/metahuman-skill-files.js +306 -0
- package/dist/scaffold/metahuman-skill-files.js.map +1 -0
- package/dist/scaffold/project-files.js +28 -19
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +6 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.js +5 -2
- package/dist/scaffold/skill-files.js.map +1 -1
- package/dist/telemetry/command-context.d.ts +23 -0
- package/dist/telemetry/command-context.js +11 -0
- package/dist/telemetry/command-context.js.map +1 -1
- package/dist/telemetry/report.d.ts +1 -0
- package/dist/telemetry/report.js +4 -1
- package/dist/telemetry/report.js.map +1 -1
- package/dist/tools/bundles.js +8 -0
- package/dist/tools/bundles.js.map +1 -1
- package/dist/verify/browser.js +25 -0
- package/dist/verify/browser.js.map +1 -1
- package/dist/verify/categories.d.ts +16 -0
- package/dist/verify/categories.js +51 -0
- package/dist/verify/categories.js.map +1 -0
- package/package.json +10 -7
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const model = window.__lab.actor.getModel();
|
|
3
|
+
let body = null; const outfit = [];
|
|
4
|
+
model.traverse((o) => {
|
|
5
|
+
if (o.isSkinnedMesh && /Body_Baked/.test(o.material.name)) body = o;
|
|
6
|
+
if (o.isSkinnedMesh && /bodyShapeD/.test(o.material.name)) outfit.push(o);
|
|
7
|
+
});
|
|
8
|
+
outfit.forEach((m) => { m.visible = false; });
|
|
9
|
+
window.__lab.setLife(false);
|
|
10
|
+
const M4 = body.matrixWorld.constructor, V = body.position.constructor, Q = body.quaternion.constructor;
|
|
11
|
+
model.updateMatrixWorld(true);
|
|
12
|
+
const modelInv = new M4().copy(model.matrixWorld).invert();
|
|
13
|
+
const sk = body.skeleton; const res = [];
|
|
14
|
+
const bindM = new M4(), curM = new M4(), p = new V(), q = new Q(), sc = new V(), p2 = new V(), q2 = new Q();
|
|
15
|
+
sk.bones.forEach((b, i) => {
|
|
16
|
+
bindM.copy(sk.boneInverses[i]).invert().decompose(p, q, sc);
|
|
17
|
+
curM.copy(modelInv).multiply(b.matrixWorld).decompose(p2, q2, sc);
|
|
18
|
+
const rot = Math.round(2 * Math.acos(Math.min(1, Math.abs(q.dot(q2)))) * 180 / Math.PI);
|
|
19
|
+
res.push({ bone: b.name, dPos: +p.distanceTo(p2).toFixed(3), dRot: rot, parent: b.parent && b.parent.name });
|
|
20
|
+
});
|
|
21
|
+
const fmt = (r) => r.bone + ' ' + r.dPos + 'm ' + r.dRot + 'deg (parent ' + r.parent + ')';
|
|
22
|
+
return {
|
|
23
|
+
topByPosition: [...res].sort((a, b) => b.dPos - a.dPos).slice(0, 12).map(fmt),
|
|
24
|
+
topByRotation: [...res].sort((a, b) => b.dRot - a.dRot).slice(0, 16).map(fmt),
|
|
25
|
+
};
|
|
26
|
+
})()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Raw clip analysis: for each loaded locomotion clip, the mean local rotation of key bones and how far
|
|
2
|
+
// the pose swings around that mean. Compares Walking/Running to Idle so a baked-in offset (asset) is
|
|
3
|
+
// distinguishable from motion (animation).
|
|
4
|
+
(() => {
|
|
5
|
+
const plRaw = window.__lab.playerLoader; const pl = typeof plRaw === 'function' ? plRaw() : plRaw;
|
|
6
|
+
const ac = pl.getAnimationController();
|
|
7
|
+
const players = [ac.getTrackAMixamoPlayer(), ac.getTrackBMixamoPlayer()].filter(Boolean);
|
|
8
|
+
const mp = players[0];
|
|
9
|
+
const clips = new Map();
|
|
10
|
+
for (const p of players) {
|
|
11
|
+
const mixer = p.mixer || p._mixer; const actions = (mixer && mixer._actions) || [];
|
|
12
|
+
for (const a of actions) { const c = a.getClip ? a.getClip() : a._clip; if (c && !clips.has(c.name)) clips.set(c.name, c); }
|
|
13
|
+
const cur = p.currentAction && (p.currentAction.getClip ? p.currentAction.getClip() : p.currentAction._clip); if (cur && !clips.has(cur.name)) clips.set(cur.name, cur);
|
|
14
|
+
for (const k of Object.keys(p)) { const v = p[k]; if (v && typeof v === 'object' && v.isAnimationClip && !clips.has(v.name)) clips.set(v.name, v); if (v instanceof Map) for (const c of v.values()) if (c && c.isAnimationClip && !clips.has(c.name)) clips.set(c.name, c); }
|
|
15
|
+
}
|
|
16
|
+
const Q = window.__lab.engine().camera.quaternion.constructor; const E = window.__lab.engine().camera.rotation.constructor;
|
|
17
|
+
const bones = ['LeftShoulder', 'RightShoulder', 'Spine', 'Spine1', 'Spine2', 'Neck', 'Hips', 'LeftUpLeg', 'LeftLeg'];
|
|
18
|
+
const trackFor = (clip, bone, kind) => clip.tracks.find((t) => t.name.replace(/^mixamorig\d*/, '').replace(/^\./, '') === `${bone}.${kind}`);
|
|
19
|
+
const meanQuat = (t) => { const n = t.values.length / 4; const acc = [0, 0, 0, 0]; const q0 = [t.values[0], t.values[1], t.values[2], t.values[3]]; for (let i = 0; i < n; i++) { let q = [t.values[i * 4], t.values[i * 4 + 1], t.values[i * 4 + 2], t.values[i * 4 + 3]]; const d = q[0] * q0[0] + q[1] * q0[1] + q[2] * q0[2] + q[3] * q0[3]; if (d < 0) q = q.map((x) => -x); for (let k = 0; k < 4; k++) acc[k] += q[k]; } const l = Math.hypot(...acc); return new Q(acc[0] / l, acc[1] / l, acc[2] / l, acc[3] / l); };
|
|
20
|
+
const angleBetween = (a, b) => { const d = Math.abs(a.dot(b)); return 2 * Math.acos(Math.min(1, d)) * 180 / Math.PI; };
|
|
21
|
+
const eulerDeg = (q) => { const e = new E().setFromQuaternion(q, 'XYZ'); return [e.x, e.y, e.z].map((v) => +(v * 180 / Math.PI).toFixed(1)); };
|
|
22
|
+
const out = { clips: [...clips.keys()], rows: [] };
|
|
23
|
+
const idle = clips.get('Idle');
|
|
24
|
+
for (const [name, clip] of clips) {
|
|
25
|
+
const row = { clip: name, duration: +clip.duration.toFixed(2), tracks: clip.tracks.length, bones: {} };
|
|
26
|
+
for (const b of bones) {
|
|
27
|
+
const t = trackFor(clip, b, 'quaternion'); if (!t) continue;
|
|
28
|
+
const m = meanQuat(t); const n = t.values.length / 4; let maxDev = 0;
|
|
29
|
+
for (let i = 0; i < n; i++) { const q = new Q(t.values[i * 4], t.values[i * 4 + 1], t.values[i * 4 + 2], t.values[i * 4 + 3]); maxDev = Math.max(maxDev, angleBetween(q, m)); }
|
|
30
|
+
const entry = { meanEulerXYZ: eulerDeg(m), swingDeg: +maxDev.toFixed(1) };
|
|
31
|
+
if (idle && idle !== clip) { const ti = trackFor(idle, b, 'quaternion'); if (ti) entry.offsetFromIdleDeg = +angleBetween(m, meanQuat(ti)).toFixed(1); }
|
|
32
|
+
row.bones[b] = entry;
|
|
33
|
+
}
|
|
34
|
+
const hp = trackFor(clip, 'Hips', 'position');
|
|
35
|
+
if (hp) { const n = hp.values.length / 3; let sy = 0, mn = 1e9, mx = -1e9; for (let i = 0; i < n; i++) { const y = hp.values[i * 3 + 1]; sy += y; mn = Math.min(mn, y); mx = Math.max(mx, y); } row.hipsY = { mean: +(sy / n).toFixed(3), min: +mn.toFixed(3), max: +mx.toFixed(3) }; }
|
|
36
|
+
out.rows.push(row);
|
|
37
|
+
}
|
|
38
|
+
out.mpKeys = Object.keys(mp).filter((k) => /url|src|source|asset|pack|clip|name/i.test(k)).slice(0, 12);
|
|
39
|
+
out.acKeys = Object.keys(ac).filter((k) => /url|src|source|asset|pack|clip/i.test(k)).slice(0, 12);
|
|
40
|
+
return out;
|
|
41
|
+
})()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const a = window.__lab.actor; const model = a.getModel();
|
|
3
|
+
const names = ['thigh_correctiveRoot_r', 'lowerarm_correctiveRoot_l', 'calf_correctiveRoot_r', 'upperarm_twistCor_01_l', 'upperarm_bicep_l', 'ankle_bck_r', 'upperarm_correctiveRoot_l', 'thigh_l', 'lowerarm_l'];
|
|
4
|
+
const out = {};
|
|
5
|
+
for (const n of names) {
|
|
6
|
+
const b = a.getBone(n); let found = null; model.traverse((o) => { if (!found && o.name === n) found = o; });
|
|
7
|
+
out[n] = { viaActor: !!b, inModel: !!found, isBone: found ? !!found.isBone : null, type: found ? found.type : null, parent: found && found.parent ? found.parent.name : null };
|
|
8
|
+
}
|
|
9
|
+
let bones = 0, nonBoneNamedJoints = 0; model.traverse((o) => { if (o.isBone) bones++; else if (/_[lr]$/.test(o.name) && !o.isMesh) nonBoneNamedJoints++; });
|
|
10
|
+
return { out, bonesInModel: bones, nonBoneJointLikeNodes: nonBoneNamedJoints };
|
|
11
|
+
})()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
(async () => {
|
|
2
|
+
const model = window.__lab.actor.getModel(); const meshes = [];
|
|
3
|
+
model.traverse((o) => { if (o.isSkinnedMesh && /Shirt|Short_|Body_Baked/.test(o.material.name)) meshes.push(o); });
|
|
4
|
+
window.__lab.setLife(false);
|
|
5
|
+
const V = meshes[0].position.constructor;
|
|
6
|
+
const torn = (mesh) => {
|
|
7
|
+
const g = mesh.geometry, pos = g.attributes.position, idx = g.index; const v = new V();
|
|
8
|
+
const sk = new Float32Array(pos.count * 3);
|
|
9
|
+
for (let i = 0; i < pos.count; i++) { v.set(pos.getX(i), pos.getY(i), pos.getZ(i)); mesh.applyBoneTransform(i, v); sk[i * 3] = v.x; sk[i * 3 + 1] = v.y; sk[i * 3 + 2] = v.z; }
|
|
10
|
+
let n = 0; const a = new V(), b = new V(), a2 = new V(), b2 = new V();
|
|
11
|
+
for (let t = 0; t < idx.count; t += 3) for (let e = 0; e < 3; e++) {
|
|
12
|
+
const i0 = idx.getX(t + e), i1 = idx.getX(t + (e + 1) % 3);
|
|
13
|
+
a.set(pos.getX(i0), pos.getY(i0), pos.getZ(i0)); b.set(pos.getX(i1), pos.getY(i1), pos.getZ(i1));
|
|
14
|
+
a2.set(sk[i0 * 3], sk[i0 * 3 + 1], sk[i0 * 3 + 2]); b2.set(sk[i1 * 3], sk[i1 * 3 + 1], sk[i1 * 3 + 2]);
|
|
15
|
+
const l0 = a.distanceTo(b); if (l0 > 1e-6 && a2.distanceTo(b2) / l0 > 3) n++;
|
|
16
|
+
}
|
|
17
|
+
return n;
|
|
18
|
+
};
|
|
19
|
+
const frames = (n) => new Promise((r) => { let k = 0; const step = () => (++k >= n ? r() : requestAnimationFrame(step)); requestAnimationFrame(step); });
|
|
20
|
+
const score = async (cfg) => { window.__lab.setHelperFollow(cfg); await frames(4); return meshes.reduce((s, m) => s + torn(m), 0); };
|
|
21
|
+
const groups = ['shoulder', 'thigh', 'elbow', 'knee', 'twistCor', 'muscle', 'clavicle', 'lat', 'wrist', 'ankle', 'finger'];
|
|
22
|
+
const cfg = Object.fromEntries(groups.map((g) => [g, 1]));
|
|
23
|
+
const log = [];
|
|
24
|
+
let best = await score(cfg); log.push('rigid FK everywhere: ' + best);
|
|
25
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
26
|
+
for (const g of groups) {
|
|
27
|
+
let bestVal = cfg[g];
|
|
28
|
+
for (const val of [0, 0.5, 1]) {
|
|
29
|
+
if (val === cfg[g]) continue;
|
|
30
|
+
const trial = { ...cfg, [g]: val }; const sc = await score(trial);
|
|
31
|
+
if (sc < best) { best = sc; bestVal = val; }
|
|
32
|
+
}
|
|
33
|
+
if (bestVal !== cfg[g]) { cfg[g] = bestVal; log.push(`pass ${pass} ${g} → ${bestVal}: ${best}`); }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
await score(cfg);
|
|
37
|
+
return { best, cfg, log, perMesh: meshes.map((m) => m.name + ':' + torn(m)) };
|
|
38
|
+
})()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
(async () => {
|
|
2
|
+
const model = window.__lab.actor.getModel(); let shirt = null, shorts = null;
|
|
3
|
+
model.traverse((o) => { if (o.isSkinnedMesh && /Shirt/.test(o.material.name)) shirt = o; if (o.isSkinnedMesh && /Short_/.test(o.material.name)) shorts = o; });
|
|
4
|
+
window.__lab.setLife(false);
|
|
5
|
+
const V = shirt.position.constructor;
|
|
6
|
+
const stretched = (mesh) => {
|
|
7
|
+
const g = mesh.geometry, pos = g.attributes.position, idx = g.index; const v = new V();
|
|
8
|
+
const sk = new Float32Array(pos.count * 3);
|
|
9
|
+
for (let i = 0; i < pos.count; i++) { v.set(pos.getX(i), pos.getY(i), pos.getZ(i)); mesh.applyBoneTransform(i, v); sk[i * 3] = v.x; sk[i * 3 + 1] = v.y; sk[i * 3 + 2] = v.z; }
|
|
10
|
+
let n = 0, worst = 0; const a = new V(), b = new V(), a2 = new V(), b2 = new V();
|
|
11
|
+
for (let t = 0; t < idx.count; t += 3) for (let e = 0; e < 3; e++) {
|
|
12
|
+
const i0 = idx.getX(t + e), i1 = idx.getX(t + (e + 1) % 3);
|
|
13
|
+
a.set(pos.getX(i0), pos.getY(i0), pos.getZ(i0)); b.set(pos.getX(i1), pos.getY(i1), pos.getZ(i1));
|
|
14
|
+
a2.set(sk[i0 * 3], sk[i0 * 3 + 1], sk[i0 * 3 + 2]); b2.set(sk[i1 * 3], sk[i1 * 3 + 1], sk[i1 * 3 + 2]);
|
|
15
|
+
const l0 = a.distanceTo(b), l1 = a2.distanceTo(b2); if (l0 > 1e-6) { const r = l1 / l0; if (r > 3) n++; if (r > worst) worst = r; }
|
|
16
|
+
}
|
|
17
|
+
return { over3x: n, worst: +worst.toFixed(1) };
|
|
18
|
+
};
|
|
19
|
+
const frames = (n) => new Promise((r) => { let k = 0; const step = () => (++k >= n ? r() : requestAnimationFrame(step)); requestAnimationFrame(step); });
|
|
20
|
+
const settings = [
|
|
21
|
+
{ shoulder: 1, thigh: 1, elbow: 1, knee: 1 },
|
|
22
|
+
{ shoulder: 0, thigh: 0, elbow: 0, knee: 0 },
|
|
23
|
+
{ shoulder: 0.25, thigh: 0.5, elbow: 0.5, knee: 0.5 },
|
|
24
|
+
{ shoulder: 0.5, thigh: 0.5, elbow: 0.5, knee: 0.5 },
|
|
25
|
+
{ shoulder: 0, thigh: 0.5, elbow: 1, knee: 1 },
|
|
26
|
+
{ shoulder: 0, thigh: 1, elbow: 1, knee: 1 },
|
|
27
|
+
];
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const s of settings) { window.__lab.setHelperFollow(s); await frames(6); out.push({ s: JSON.stringify(s), shirt: stretched(shirt), shorts: stretched(shorts) }); }
|
|
30
|
+
return out;
|
|
31
|
+
})()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const a = window.__lab.actor;
|
|
3
|
+
const hj = a.helperJoints || [];
|
|
4
|
+
const counts = {}; for (const h of hj) counts[h.group] = (counts[h.group] || 0) + 1;
|
|
5
|
+
const bones = a.bones; const names = ['thigh_correctiveRoot_r', 'lowerarm_correctiveRoot_l', 'calf_correctiveRoot_r', 'upperarm_twistCor_01_l', 'upperarm_bicep_l', 'ankle_bck_r'];
|
|
6
|
+
const inMap = names.map((n) => n + ':' + (bones && bones.has(n)));
|
|
7
|
+
const re = /^thigh_correctiveRoot_[lr]$/;
|
|
8
|
+
const matchingKeys = bones ? [...bones.keys()].filter((k) => re.test(k)) : null;
|
|
9
|
+
return { helperJoints: hj.length, counts, bonesSize: bones ? bones.size : null, inMap, matchingKeys, follow: a.helperFollow };
|
|
10
|
+
})()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const model = window.__lab.actor.getModel();
|
|
3
|
+
const meshes = [];
|
|
4
|
+
model.traverse((o) => { if (o.isSkinnedMesh && /bodyShapeD/.test(o.material.name)) meshes.push(o); });
|
|
5
|
+
window.__lab.setLife(false);
|
|
6
|
+
model.updateMatrixWorld(true);
|
|
7
|
+
return meshes.map((m) => {
|
|
8
|
+
const pos = m.geometry.attributes.position, si = m.geometry.attributes.skinIndex, sw = m.geometry.attributes.skinWeight;
|
|
9
|
+
const V = m.position.constructor; const v = new V(), bind = new V();
|
|
10
|
+
const tally = new Map(); let bad = 0, maxD = 0; const examples = [];
|
|
11
|
+
for (let i = 0; i < pos.count; i++) {
|
|
12
|
+
bind.set(pos.getX(i), pos.getY(i), pos.getZ(i)); v.copy(bind);
|
|
13
|
+
m.applyBoneTransform(i, v);
|
|
14
|
+
const d = v.distanceTo(bind);
|
|
15
|
+
if (d > maxD) maxD = d;
|
|
16
|
+
if (d > 0.6) {
|
|
17
|
+
bad++;
|
|
18
|
+
const idx = [si.getX(i), si.getY(i), si.getZ(i), si.getW(i)], w = [sw.getX(i), sw.getY(i), sw.getZ(i), sw.getW(i)];
|
|
19
|
+
for (let k = 0; k < 4; k++) if (w[k] > 0.05) { const n = m.skeleton.bones[idx[k]].name; tally.set(n, (tally.get(n) || 0) + 1); }
|
|
20
|
+
if (examples.length < 3) examples.push({ i, d: +d.toFixed(2), bones: idx.map((j, k) => m.skeleton.bones[j].name + ':' + w[k].toFixed(2)) });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { mesh: m.name, vertices: pos.count, badVertices: bad, maxDisplacement: +maxD.toFixed(2),
|
|
24
|
+
topBones: [...tally.entries()].sort((a, b) => b[1] - a[1]).slice(0, 14).map(([n, c]) => n + ' x' + c), examples };
|
|
25
|
+
});
|
|
26
|
+
})()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const model = window.__lab.actor.getModel(); const meshes = []; let body = null;
|
|
3
|
+
model.traverse((o) => { if (o.isSkinnedMesh && /bodyShapeD/.test(o.material.name)) meshes.push(o); if (o.isSkinnedMesh && /Body_Baked/.test(o.material.name)) body = o; });
|
|
4
|
+
meshes.forEach((m) => { m.material = body.material; });
|
|
5
|
+
window.__lab.setLife(false);
|
|
6
|
+
return meshes.map((m) => m.name + ' now uses ' + body.material.type);
|
|
7
|
+
})()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const model = window.__lab.actor.getModel(); const meshes = [];
|
|
3
|
+
model.traverse((o) => { if (o.isSkinnedMesh && /bodyShapeD/.test(o.material.name)) meshes.push(o); });
|
|
4
|
+
const Skel = meshes[0].skeleton.constructor;
|
|
5
|
+
meshes.forEach((m) => { const old = m.skeleton; const fresh = new Skel(old.bones.slice(), old.boneInverses.map((x) => x.clone())); m.bind(fresh, m.bindMatrix); });
|
|
6
|
+
window.__lab.setLife(false);
|
|
7
|
+
return meshes.map((m) => m.name + ' rebound to its own Skeleton (' + m.skeleton.bones.length + ' bones)');
|
|
8
|
+
})()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const model = window.__lab.actor.getModel(); let shirt = null;
|
|
3
|
+
model.traverse((o) => { if (o.isSkinnedMesh && /Shirt/.test(o.material.name)) shirt = o; });
|
|
4
|
+
window.__lab.setLife(false);
|
|
5
|
+
model.updateMatrixWorld(true);
|
|
6
|
+
const M4 = shirt.matrixWorld.constructor, V = shirt.position.constructor, Q = shirt.quaternion.constructor;
|
|
7
|
+
const modelInv = new M4().copy(model.matrixWorld).invert();
|
|
8
|
+
const sk = shirt.skeleton;
|
|
9
|
+
const own = new Map();
|
|
10
|
+
const si = shirt.geometry.attributes.skinIndex, sw = shirt.geometry.attributes.skinWeight;
|
|
11
|
+
for (let i = 0; i < si.count; i++) {
|
|
12
|
+
const idx = [si.getX(i), si.getY(i), si.getZ(i), si.getW(i)], w = [sw.getX(i), sw.getY(i), sw.getZ(i), sw.getW(i)];
|
|
13
|
+
for (let k = 0; k < 4; k++) if (w[k] > 0.25) own.set(idx[k], (own.get(idx[k]) || 0) + 1);
|
|
14
|
+
}
|
|
15
|
+
const bindM = new M4(), curM = new M4(), p = new V(), q = new Q(), sc = new V(), p2 = new V(), q2 = new Q();
|
|
16
|
+
const rows = [...own.entries()].map(([j, count]) => {
|
|
17
|
+
const b = sk.bones[j];
|
|
18
|
+
bindM.copy(sk.boneInverses[j]).invert().decompose(p, q, sc);
|
|
19
|
+
curM.copy(modelInv).multiply(b.matrixWorld).decompose(p2, q2, sc);
|
|
20
|
+
const rot = Math.round(2 * Math.acos(Math.min(1, Math.abs(q.dot(q2)))) * 180 / Math.PI);
|
|
21
|
+
return { bone: b.name, count, dPos: +p.distanceTo(p2).toFixed(2), dRot: rot, parent: b.parent ? b.parent.name : '-' };
|
|
22
|
+
});
|
|
23
|
+
rows.sort((a, b) => b.count - a.count);
|
|
24
|
+
return rows.slice(0, 40).map((r) => `${r.bone}: ${r.count} verts, moved ${r.dPos} m / ${r.dRot} deg, parent ${r.parent}`);
|
|
25
|
+
})()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const model = window.__lab.actor.getModel(); let shirt = null;
|
|
3
|
+
model.traverse((o) => { if (o.isSkinnedMesh && /Shirt/.test(o.material.name)) shirt = o; });
|
|
4
|
+
window.__lab.setLife(false);
|
|
5
|
+
model.updateMatrixWorld(true);
|
|
6
|
+
const g = shirt.geometry, pos = g.attributes.position, si = g.attributes.skinIndex, sw = g.attributes.skinWeight, idx = g.index;
|
|
7
|
+
const V = shirt.position.constructor; const v = new V();
|
|
8
|
+
const skinned = new Float32Array(pos.count * 3);
|
|
9
|
+
for (let i = 0; i < pos.count; i++) { v.set(pos.getX(i), pos.getY(i), pos.getZ(i)); shirt.applyBoneTransform(i, v); skinned[i * 3] = v.x; skinned[i * 3 + 1] = v.y; skinned[i * 3 + 2] = v.z; }
|
|
10
|
+
const bones = (i) => { const out = []; const ids = [si.getX(i), si.getY(i), si.getZ(i), si.getW(i)], w = [sw.getX(i), sw.getY(i), sw.getZ(i), sw.getW(i)]; for (let k = 0; k < 4; k++) if (w[k] > 0.1) out.push(shirt.skeleton.bones[ids[k]].name + ':' + w[k].toFixed(2)); return out.join(' '); };
|
|
11
|
+
const edges = [];
|
|
12
|
+
const a = new V(), b = new V(), a2 = new V(), b2 = new V();
|
|
13
|
+
for (let t = 0; t < idx.count; t += 3) {
|
|
14
|
+
for (let e = 0; e < 3; e++) {
|
|
15
|
+
const i0 = idx.getX(t + e), i1 = idx.getX(t + (e + 1) % 3);
|
|
16
|
+
a.set(pos.getX(i0), pos.getY(i0), pos.getZ(i0)); b.set(pos.getX(i1), pos.getY(i1), pos.getZ(i1));
|
|
17
|
+
a2.set(skinned[i0 * 3], skinned[i0 * 3 + 1], skinned[i0 * 3 + 2]); b2.set(skinned[i1 * 3], skinned[i1 * 3 + 1], skinned[i1 * 3 + 2]);
|
|
18
|
+
const l0 = a.distanceTo(b), l1 = a2.distanceTo(b2);
|
|
19
|
+
if (l0 > 1e-6 && l1 / l0 > 3) edges.push({ ratio: +(l1 / l0).toFixed(1), len: +l1.toFixed(3), i0, i1 });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
edges.sort((x, y) => y.ratio - x.ratio);
|
|
23
|
+
const pairs = new Map();
|
|
24
|
+
for (const e of edges.slice(0, 400)) { const key = bones(e.i0) + ' <-> ' + bones(e.i1); pairs.set(key, (pairs.get(key) || 0) + 1); }
|
|
25
|
+
return { stretchedEdges: edges.length, worst: edges.slice(0, 8), commonPairs: [...pairs.entries()].sort((x, y) => y[1] - x[1]).slice(0, 12).map(([k, c]) => c + 'x ' + k) };
|
|
26
|
+
})()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const model = window.__lab.actor.getModel(); let shirt = null;
|
|
3
|
+
model.traverse((o) => { if (o.isSkinnedMesh && /Shirt/.test(o.material.name)) shirt = o; });
|
|
4
|
+
window.__lab.setLife(false);
|
|
5
|
+
model.updateMatrixWorld(true);
|
|
6
|
+
const g = shirt.geometry, pos = g.attributes.position, si = g.attributes.skinIndex, sw = g.attributes.skinWeight, idx = g.index;
|
|
7
|
+
const V = shirt.position.constructor; const v = new V();
|
|
8
|
+
const sk = new Float32Array(pos.count * 3);
|
|
9
|
+
for (let i = 0; i < pos.count; i++) { v.set(pos.getX(i), pos.getY(i), pos.getZ(i)); shirt.applyBoneTransform(i, v); sk[i * 3] = v.x; sk[i * 3 + 1] = v.y; sk[i * 3 + 2] = v.z; }
|
|
10
|
+
const bones = (i) => { const out = []; const ids = [si.getX(i), si.getY(i), si.getZ(i), si.getW(i)], w = [sw.getX(i), sw.getY(i), sw.getZ(i), sw.getW(i)]; for (let k = 0; k < 4; k++) if (w[k] > 0.1) out.push(shirt.skeleton.bones[ids[k]].name + ':' + w[k].toFixed(2)); return out.join(' '); };
|
|
11
|
+
const edges = []; const a = new V(), b = new V(), a2 = new V(), b2 = new V();
|
|
12
|
+
let bindLenMax = 0, over15 = 0, over30 = 0;
|
|
13
|
+
for (let t = 0; t < idx.count; t += 3) for (let e = 0; e < 3; e++) {
|
|
14
|
+
const i0 = idx.getX(t + e), i1 = idx.getX(t + (e + 1) % 3); if (i0 > i1) continue;
|
|
15
|
+
a.set(pos.getX(i0), pos.getY(i0), pos.getZ(i0)); b.set(pos.getX(i1), pos.getY(i1), pos.getZ(i1));
|
|
16
|
+
a2.set(sk[i0 * 3], sk[i0 * 3 + 1], sk[i0 * 3 + 2]); b2.set(sk[i1 * 3], sk[i1 * 3 + 1], sk[i1 * 3 + 2]);
|
|
17
|
+
const l0 = a.distanceTo(b), l1 = a2.distanceTo(b2); if (l0 > bindLenMax) bindLenMax = l0;
|
|
18
|
+
if (l1 > 0.15) over15++; if (l1 > 0.30) over30++;
|
|
19
|
+
if (l1 > 0.12) edges.push({ len: +l1.toFixed(3), bind: +l0.toFixed(4), i0, i1, b0: bones(i0), b1: bones(i1), p0: [a.x, a.y, a.z].map((x) => +x.toFixed(2)), p1: [b.x, b.y, b.z].map((x) => +x.toFixed(2)) });
|
|
20
|
+
}
|
|
21
|
+
edges.sort((x, y) => y.len - x.len);
|
|
22
|
+
return { longestBindEdge: +bindLenMax.toFixed(3), edgesOver15cm: over15, edgesOver30cm: over30, top: edges.slice(0, 10) };
|
|
23
|
+
})()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Installs window.__walk: samples bone heights + shirt-vs-shoulder offsets every frame.
|
|
2
|
+
(() => {
|
|
3
|
+
const model = window.__lab.actor.getModel();
|
|
4
|
+
const V = model.position.constructor;
|
|
5
|
+
let shirt = null, body = null; const bones = {};
|
|
6
|
+
model.traverse((o) => {
|
|
7
|
+
if (o.isBone) bones[o.name] = o;
|
|
8
|
+
if (o.isSkinnedMesh && /Shirt/i.test(o.material.name)) shirt = o;
|
|
9
|
+
if (o.isSkinnedMesh && /Body|Skin/i.test(o.material.name) && !body) body = o;
|
|
10
|
+
});
|
|
11
|
+
const want = ['root', 'pelvis', 'spine_01', 'spine_03', 'spine_05', 'neck_01', 'head', 'clavicle_l', 'upperarm_l', 'upperarm_r', 'thigh_l', 'calf_l', 'foot_l', 'foot_r', 'ball_l'];
|
|
12
|
+
const v = new V(), wp = new V();
|
|
13
|
+
const pos = shirt.geometry.attributes.position;
|
|
14
|
+
// Collar markers: the 40 highest shirt vertices at rest (bind pose) — track their skinned mean height.
|
|
15
|
+
const order = Array.from({ length: pos.count }, (_, i) => i).sort((a, b) => pos.getY(b) - pos.getY(a)).slice(0, 40);
|
|
16
|
+
// Deltoid exposure: body vertices near/above the shoulder joint at rest vs shirt vertices in the same region.
|
|
17
|
+
const bindPosOf = (name) => { const sk = body.skeleton; const i = sk.bones.findIndex((b) => b.name === name); const m = sk.boneInverses[i].clone().invert(); return new V().setFromMatrixPosition(m); };
|
|
18
|
+
const region = (mesh, joint, r) => { const p = mesh.geometry.attributes.position; const out = []; for (let i = 0; i < p.count; i++) { const dx = p.getX(i) - joint.x, dy = p.getY(i) - joint.y, dz = p.getZ(i) - joint.z; if (dy > 0.0 && dx * dx + dz * dz < r * r && dy < 0.12) out.push(i); } return out; };
|
|
19
|
+
const jL = bindPosOf('upperarm_l');
|
|
20
|
+
const bodyL = region(body, jL, 0.07), shirtL = region(shirt, jL, 0.09);
|
|
21
|
+
const maxY = (mesh, ids) => { const p = mesh.geometry.attributes.position; let m = -Infinity; for (const i of ids) { v.set(p.getX(i), p.getY(i), p.getZ(i)); mesh.applyBoneTransform(i, v); v.applyMatrix4(mesh.matrixWorld); if (v.y > m) m = v.y; } return m; };
|
|
22
|
+
// Skin-through-shirt penetration at the shoulder tops. The exported body mesh is culled under the
|
|
23
|
+
// shirt (it tops out at y≈1.37), so the visible skin there is the FACE mesh's neck skirt plus the
|
|
24
|
+
// arm tops: pair skin vertices from every skin mesh in the shoulder band with the nearest shirt
|
|
25
|
+
// vertex at rest, keep the rest outward gap, and per frame measure how far the skin moved outward
|
|
26
|
+
// relative to the shirt (positive = penetration depth in mm beyond the rest gap).
|
|
27
|
+
const skinMeshes = []; model.traverse((o) => { if (o.isSkinnedMesh && /Body|Face_Skin/i.test(o.material.name)) skinMeshes.push(o); });
|
|
28
|
+
const neck = bindPosOf('neck_01');
|
|
29
|
+
const pairs = [];
|
|
30
|
+
{
|
|
31
|
+
const sp = shirt.geometry.attributes.position;
|
|
32
|
+
const shirtIdx = []; for (let i = 0; i < sp.count; i++) if (sp.getY(i) > 1.30) shirtIdx.push(i);
|
|
33
|
+
for (const mesh of skinMeshes) {
|
|
34
|
+
const bp = mesh.geometry.attributes.position;
|
|
35
|
+
for (let i = 0; i < bp.count; i++) {
|
|
36
|
+
const x = bp.getX(i), y = bp.getY(i), z = bp.getZ(i);
|
|
37
|
+
if (y < neck.y - 0.13 || y > neck.y + 0.03) continue;
|
|
38
|
+
if (Math.abs(x) < 0.03 || Math.abs(x) > 0.24) continue;
|
|
39
|
+
let best = -1, bd = 1e9;
|
|
40
|
+
for (const j of shirtIdx) { const dx = sp.getX(j) - x, dy = sp.getY(j) - y, dz = sp.getZ(j) - z; const d2 = dx * dx + dy * dy + dz * dz; if (d2 < bd) { bd = d2; best = j; } }
|
|
41
|
+
if (best < 0 || bd > 0.025 * 0.025) continue;
|
|
42
|
+
const n = new V(sp.getX(best) - x, sp.getY(best) - y, sp.getZ(best) - z); const len = n.length(); if (len < 1e-4) continue;
|
|
43
|
+
n.multiplyScalar(1 / len);
|
|
44
|
+
// Only skin that the shirt actually covers: the shirt vertex must lie along the skin normal.
|
|
45
|
+
const nn = mesh.geometry.attributes.normal; const sn = new V(nn.getX(i), nn.getY(i), nn.getZ(i)).normalize();
|
|
46
|
+
if (n.dot(sn) < 0.6) continue;
|
|
47
|
+
pairs.push({ mesh, b: i, s: best, n, rest: len });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const bonesOf = (mesh, i) => { const si = mesh.geometry.attributes.skinIndex, sw = mesh.geometry.attributes.skinWeight; const ids = [si.getX(i), si.getY(i), si.getZ(i), si.getW(i)], w = [sw.getX(i), sw.getY(i), sw.getZ(i), sw.getW(i)]; const out = []; for (let k = 0; k < 4; k++) if (w[k] > 0.05) out.push(mesh.skeleton.bones[ids[k]].name + ':' + w[k].toFixed(2)); return out.join(' '); };
|
|
52
|
+
const vb = new V(), vs = new V(), nq = new V();
|
|
53
|
+
const poke = (wantWorst) => {
|
|
54
|
+
const sp = shirt.geometry.attributes.position;
|
|
55
|
+
let worst = -Infinity, sum = 0, n = 0, worstPair = null; const depths = [];
|
|
56
|
+
for (const pr of pairs) {
|
|
57
|
+
const bp = pr.mesh.geometry.attributes.position;
|
|
58
|
+
vb.set(bp.getX(pr.b), bp.getY(pr.b), bp.getZ(pr.b)); pr.mesh.applyBoneTransform(pr.b, vb); vb.applyMatrix4(pr.mesh.matrixWorld);
|
|
59
|
+
vs.set(sp.getX(pr.s), sp.getY(pr.s), sp.getZ(pr.s)); shirt.applyBoneTransform(pr.s, vs); vs.applyMatrix4(shirt.matrixWorld);
|
|
60
|
+
nq.copy(pr.n).applyQuaternion(model.quaternion);
|
|
61
|
+
const gap = vs.sub(vb).dot(nq);
|
|
62
|
+
const depth = (pr.rest - gap) * 1000;
|
|
63
|
+
depths.push(depth);
|
|
64
|
+
if (depth > worst) { worst = depth; worstPair = pr; }
|
|
65
|
+
sum += depth; n++;
|
|
66
|
+
}
|
|
67
|
+
depths.sort((a, b) => b - a);
|
|
68
|
+
const r = { worst: +worst.toFixed(1), p90: +(depths[Math.floor(depths.length * 0.1)] || 0).toFixed(1), mean: +(sum / Math.max(1, n)).toFixed(1) };
|
|
69
|
+
if (wantWorst && worstPair) { r.skinBones = bonesOf(worstPair.mesh, worstPair.b); r.shirtBones = bonesOf(shirt, worstPair.s); r.mesh = worstPair.mesh.material.name; }
|
|
70
|
+
return r;
|
|
71
|
+
};
|
|
72
|
+
window.__poke = poke;
|
|
73
|
+
const samples = [];
|
|
74
|
+
const pl = typeof window.__lab.playerLoader === 'function' ? window.__lab.playerLoader() : window.__lab.playerLoader;
|
|
75
|
+
const ac = pl.getAnimationController && pl.getAnimationController();
|
|
76
|
+
const mixBones = (name) => { const mp = ac && (ac.getTrackAMixamoPlayer() || ac.getTrackBMixamoPlayer()); const sk = mp && mp.skeleton; let hit = null; if (sk) sk.traverse((o) => { if (!hit && o.name.replace(/^mixamorig\d*/, '') === name) hit = o; }); return hit; };
|
|
77
|
+
const pitch = (a, b) => { if (!a || !b) return null; const pa = a.getWorldPosition(new V()), pb = b.getWorldPosition(new V()); const d = pb.sub(pa); return +(Math.asin(d.y / d.length()) * 180 / Math.PI).toFixed(1); };
|
|
78
|
+
let raf = 0; const t0 = performance.now();
|
|
79
|
+
const tick = () => {
|
|
80
|
+
model.updateMatrixWorld(true);
|
|
81
|
+
const s = { t: +((performance.now() - t0) / 1000).toFixed(2) };
|
|
82
|
+
const playerY = window.__lab.playerPosition().y;
|
|
83
|
+
s.rigClavPitchL = pitch(bones.clavicle_l, bones.upperarm_l);
|
|
84
|
+
s.rigClavPitchR = pitch(bones.clavicle_r, bones.upperarm_r);
|
|
85
|
+
s.clipClavPitchL = pitch(mixBones('LeftShoulder'), mixBones('LeftArm'));
|
|
86
|
+
const mh = mixBones('Hips'), mf = mixBones('LeftFoot'), mf2 = mixBones('RightFoot'), mhead = mixBones('Head'), msh = mixBones('LeftArm');
|
|
87
|
+
if (mh && mf && mf2) { const hy = mh.getWorldPosition(new V()).y, fy = Math.min(mf.getWorldPosition(new V()).y, mf2.getWorldPosition(new V()).y); s.clipHipsAboveFoot = +(hy - fy).toFixed(3); if (mhead) s.clipHeadAboveFoot = +(mhead.getWorldPosition(new V()).y - fy).toFixed(3); if (msh) s.clipShoulderAboveHips = +(msh.getWorldPosition(new V()).y - hy).toFixed(3); }
|
|
88
|
+
for (const n of want) { const b = bones[n]; if (!b) continue; b.getWorldPosition(wp); s[n] = +(wp.y - playerY).toFixed(3); }
|
|
89
|
+
s.pelvisLocal = bones.pelvis ? bones.pelvis.position.toArray().map((x) => +x.toFixed(3)) : null;
|
|
90
|
+
let sy = 0; for (const i of order) { v.set(pos.getX(i), pos.getY(i), pos.getZ(i)); shirt.applyBoneTransform(i, v); v.applyMatrix4(shirt.matrixWorld); sy += v.y; }
|
|
91
|
+
s.collarY = +(sy / order.length - playerY).toFixed(3);
|
|
92
|
+
s.shoulderY = +(((s.upperarm_l ?? 0) + (s.upperarm_r ?? 0)) / 2).toFixed(3);
|
|
93
|
+
s.collarAboveShoulder = +(s.collarY - s.shoulderY).toFixed(3);
|
|
94
|
+
const pk = poke(false); s.pokeWorstMm = pk.worst; s.pokeP90Mm = pk.p90; s.pokeMeanMm = pk.mean;
|
|
95
|
+
if (body && bodyL.length && shirtL.length) s.skinAboveShirtL = +(maxY(body, bodyL) - maxY(shirt, shirtL)).toFixed(3);
|
|
96
|
+
const mp0 = ac && (ac.getTrackAMixamoPlayer() || ac.getTrackBMixamoPlayer());
|
|
97
|
+
s.clip = mp0 && (mp0.currentClipName || (mp0.currentAction && mp0.currentAction.getClip && mp0.currentAction.getClip().name) || null);
|
|
98
|
+
samples.push(s);
|
|
99
|
+
raf = requestAnimationFrame(tick);
|
|
100
|
+
};
|
|
101
|
+
raf = requestAnimationFrame(tick);
|
|
102
|
+
window.__walk = { finish: () => { cancelAnimationFrame(raf); return samples; }, shirtVerts: pos.count, shirtName: shirt.material.name, bodyName: body && body.material.name };
|
|
103
|
+
const mp = ac && (ac.getTrackAMixamoPlayer() || ac.getTrackBMixamoPlayer());
|
|
104
|
+
return { installed: true, shirt: shirt.material.name, skinMeshes: skinMeshes.map((m) => m.material.name), pairs: pairs.length, hasMixamoSkeleton: !!(mp && mp.skeleton), mixamoKeys: mp ? Object.keys(mp).filter((k) => /clip|action|name/i.test(k)).slice(0, 12) : [] };
|
|
105
|
+
})()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Headless screenshot rig for a MetaHuman character: real Chrome (WebGPU), starts the game, checks that
|
|
2
|
+
// the hair stays on the head while walking and turning, that locomotion moves the player, then frames the
|
|
3
|
+
// character from several angles. Run from the project root, against the game port `bitmagic dev` printed:
|
|
4
|
+
// node tools/metahuman/e2e/shot.mjs [port] → tools/metahuman/e2e/shots/*.png
|
|
5
|
+
// Needs `window.__lab` (installMetaHumanLabHooks in src/work/metahuman/MetaHumanLabHooks.ts).
|
|
6
|
+
import { chromium } from 'playwright-core';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
|
|
9
|
+
const PORT = process.argv[2] || process.env.GAME_PORT || '3010';
|
|
10
|
+
const OUT = new URL('./shots/', import.meta.url).pathname;
|
|
11
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
12
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
13
|
+
|
|
14
|
+
const browser = await chromium.launch({
|
|
15
|
+
channel: 'chrome', headless: true,
|
|
16
|
+
args: ['--use-angle=metal', '--enable-unsafe-webgpu', '--window-size=1600,900', '--autoplay-policy=no-user-gesture-required'],
|
|
17
|
+
});
|
|
18
|
+
const watchdog = setTimeout(async () => { console.log('[e2e] watchdog: closing the browser'); try { await browser.close(); } catch {} process.exit(2); }, 240000);
|
|
19
|
+
try {
|
|
20
|
+
const page = await browser.newPage({ viewport: { width: 1600, height: 900 }, deviceScaleFactor: 1 });
|
|
21
|
+
const errors = [];
|
|
22
|
+
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
|
23
|
+
page.on('console', (m) => {
|
|
24
|
+
const t = m.text();
|
|
25
|
+
if (m.type() === 'error' || /\[MetaHuman|skinned|retarget|PlayerLoader: (Rendering|Applied|Creating)/.test(t)) console.log(`PAGE[${m.type()}]:`, t.slice(0, 300));
|
|
26
|
+
});
|
|
27
|
+
const shot = async (name) => { await page.screenshot({ path: OUT + name + '.png' }); console.log('[shot]', name); };
|
|
28
|
+
const bodyText = () => page.evaluate(() => document.body.innerText || '');
|
|
29
|
+
async function waitFor(fn, timeoutMs, label) {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
while (Date.now() - start < timeoutMs) {
|
|
32
|
+
if (await fn()) { console.log(`[e2e] ${label} (+${((Date.now() - start) / 1000).toFixed(1)}s)`); return true; }
|
|
33
|
+
await sleep(400);
|
|
34
|
+
}
|
|
35
|
+
console.log(`[e2e] TIMEOUT: ${label}`); await shot('TIMEOUT-' + label.replace(/\W+/g, '_')); return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
|
|
39
|
+
await waitFor(async () => /PLAY/i.test(await bodyText()), 60000, 'menu');
|
|
40
|
+
await sleep(800);
|
|
41
|
+
const playBtn = page.getByText(/^\s*PLAY\s*$/i).first();
|
|
42
|
+
if (await playBtn.count()) await playBtn.click({ force: true }); else await page.mouse.click(800, 650);
|
|
43
|
+
await waitFor(() => page.evaluate(() => !!(window.__lab && window.__lab.actor && window.__lab.actor.getModel())), 90000, 'metahuman loaded');
|
|
44
|
+
await sleep(3500); // textures, shadows, floor snap
|
|
45
|
+
const lab = (code) => page.evaluate(code);
|
|
46
|
+
await lab('window.__lab.setLife(false)');
|
|
47
|
+
await shot('01-wide-back');
|
|
48
|
+
|
|
49
|
+
// Locomotion: hold W and watch the engine drive the skeleton; the hair must ride the head throughout.
|
|
50
|
+
const p0 = await lab('window.__lab.playerPosition()');
|
|
51
|
+
const hairProbe = `(() => { const a = window.__lab.actor; const head = a.getBone('head'); const model = a.getModel(); let hair = null;
|
|
52
|
+
model.traverse((o) => { if (!hair && o.isMesh && /^Hair_/.test(o.name) && !/__blend/.test(o.name)) hair = o; });
|
|
53
|
+
if (!hair || !head) return null;
|
|
54
|
+
hair.geometry.computeBoundingBox(); const c = hair.geometry.boundingBox.getCenter(hair.position.clone());
|
|
55
|
+
hair.updateWorldMatrix(true, false); const cw = hair.localToWorld(c); const hp = head.getWorldPosition(hair.position.clone()); return cw.distanceTo(hp); })()`;
|
|
56
|
+
const hairSamples = [];
|
|
57
|
+
await page.keyboard.down('KeyW');
|
|
58
|
+
for (let i = 0; i < 9; i++) { await sleep(150); hairSamples.push(await lab(hairProbe)); }
|
|
59
|
+
await shot('14-walk'); await sleep(500); await shot('14-walk-b');
|
|
60
|
+
await page.keyboard.up('KeyW'); await sleep(300);
|
|
61
|
+
await page.keyboard.down('Space'); await sleep(120); await page.keyboard.up('Space');
|
|
62
|
+
for (let i = 0; i < 8; i++) { await sleep(120); hairSamples.push(await lab(hairProbe)); }
|
|
63
|
+
await sleep(700);
|
|
64
|
+
const valid = hairSamples.filter((x) => typeof x === 'number');
|
|
65
|
+
if (valid.length) {
|
|
66
|
+
const hMin = Math.min(...valid), hMax = Math.max(...valid);
|
|
67
|
+
console.log('[e2e] hair↔head distance while walking + jumping:', hMin.toFixed(4), '…', hMax.toFixed(4), 'm', (hMax - hMin) < 0.003 ? 'HAIR STAYS ON HEAD ✓' : 'HAIR DRIFTS ✗');
|
|
68
|
+
} else console.log('[e2e] no Hair_* card mesh found — hair check skipped');
|
|
69
|
+
const p1 = await lab('window.__lab.playerPosition()');
|
|
70
|
+
const moved = p0 && p1 ? Math.hypot(p1.x - p0.x, p1.z - p0.z) : 0;
|
|
71
|
+
console.log('[e2e] walked', moved.toFixed(2), 'm', moved > 0.5 ? 'LOCOMOTION ✓' : 'DID NOT MOVE ✗');
|
|
72
|
+
await shot('15-idle-after-walk');
|
|
73
|
+
|
|
74
|
+
// Deterministic framing around the head: dx = to the character's right, dz = in front of it.
|
|
75
|
+
const head = await lab('window.__lab.headPosition()');
|
|
76
|
+
const facing = await lab('window.__lab.facing()');
|
|
77
|
+
const right = { x: -facing.z, z: facing.x };
|
|
78
|
+
console.log('[e2e] facing', JSON.stringify(facing), 'head', JSON.stringify(head));
|
|
79
|
+
const frame = async (name, dx, dy, dz, lookDy, fov) => {
|
|
80
|
+
const px = head.x + right.x * dx + facing.x * dz, pz = head.z + right.z * dx + facing.z * dz;
|
|
81
|
+
await lab(`window.__lab.freeCam({x:${px},y:${head.y + dy},z:${pz}},{x:${head.x},y:${head.y + lookDy},z:${head.z}},${fov})`);
|
|
82
|
+
await sleep(700); await shot(name);
|
|
83
|
+
};
|
|
84
|
+
await frame('02-front-full', 0, -0.35, 3.6, -0.75, 38);
|
|
85
|
+
await frame('03-face', 0, 0.02, 0.62, 0.0, 30);
|
|
86
|
+
await frame('04-face-three-quarter', 0.42, 0.06, 0.5, 0.0, 30);
|
|
87
|
+
await frame('05-face-profile', 0.7, 0.03, 0.06, 0.0, 30);
|
|
88
|
+
await frame('06-hair-back-top', -0.1, 0.45, -0.55, 0.15, 32);
|
|
89
|
+
await frame('07-eye-macro', 0.05, 0.02, 0.28, 0.02, 22);
|
|
90
|
+
await lab('window.__lab.setLife(true)');
|
|
91
|
+
await lab(`window.__lab.freeCam({x:${head.x + right.x * 0.25 + facing.x * 0.75},y:${head.y + 0.05},z:${head.z + right.z * 0.25 + facing.z * 0.75}},{x:${head.x},y:${head.y},z:${head.z}},30)`);
|
|
92
|
+
await sleep(2500); await shot('10-life-on-front');
|
|
93
|
+
|
|
94
|
+
// Hair-follows-head: the head-bone → hair-bbox-centre distance must not change between rest and a big
|
|
95
|
+
// head turn (the cards are rigidly parented to the head bone).
|
|
96
|
+
const hairMetric = () => page.evaluate(() => {
|
|
97
|
+
const a = window.__lab.actor; const head = a.getBone('head'); const model = a.getModel();
|
|
98
|
+
let hair = null; model.traverse((o) => { if (!hair && o.isMesh && /Hair_/.test(o.name) && !/__blend/.test(o.name)) hair = o; });
|
|
99
|
+
if (!hair || !head) return null;
|
|
100
|
+
hair.geometry.computeBoundingBox(); const c = hair.geometry.boundingBox.getCenter(hair.position.clone());
|
|
101
|
+
hair.updateWorldMatrix(true, false); const cw = hair.localToWorld(c);
|
|
102
|
+
const hp = head.getWorldPosition(hair.position.clone());
|
|
103
|
+
const rest = a.head && a.head.restQuat; const qn = head.quaternion;
|
|
104
|
+
const d = rest ? Math.abs(rest.x * qn.x + rest.y * qn.y + rest.z * qn.z + rest.w * qn.w) : 1;
|
|
105
|
+
return { dist: cw.distanceTo(hp), headTurnDeg: 2 * Math.acos(Math.min(1, d)) * 180 / Math.PI };
|
|
106
|
+
});
|
|
107
|
+
await lab('window.__lab.setLife(false)'); await sleep(600);
|
|
108
|
+
const restMetric = await hairMetric();
|
|
109
|
+
await lab('window.__lab.setLife(true)');
|
|
110
|
+
await lab(`window.__lab.freeCam({x:${head.x + right.x * 0.9 + facing.x * 0.9},y:${head.y + 1.1},z:${head.z + right.z * 0.9 + facing.z * 0.9}},{x:${head.x},y:${head.y},z:${head.z}},34)`);
|
|
111
|
+
await sleep(3000); await shot('11-life-head-turn-high');
|
|
112
|
+
const turnMetric = await hairMetric();
|
|
113
|
+
if (restMetric && turnMetric) {
|
|
114
|
+
console.log('[e2e] hair↔head distance at rest', restMetric.dist.toFixed(4), 'm; after head turn', turnMetric.dist.toFixed(4), 'm;',
|
|
115
|
+
'head turn from rest', restMetric.headTurnDeg.toFixed(1), '°→', turnMetric.headTurnDeg.toFixed(1), '°;',
|
|
116
|
+
Math.abs(restMetric.dist - turnMetric.dist) < 0.002 ? 'HAIR FOLLOWS HEAD ✓' : 'HAIR DETACHED ✗');
|
|
117
|
+
}
|
|
118
|
+
await lab('window.__lab.freeCamOff()');
|
|
119
|
+
const info = await page.evaluate(() => {
|
|
120
|
+
const a = window.__lab.actor; const r = a.getReport();
|
|
121
|
+
return { morphs: a.getMorphNames().length, byPart: r && r.byPart, hidden: r && r.hiddenMeshes, blendClones: r && r.blendClones.length };
|
|
122
|
+
});
|
|
123
|
+
console.log('[e2e] report', JSON.stringify(info));
|
|
124
|
+
if (errors.length) console.log('[e2e] page errors:\n' + errors.join('\n'));
|
|
125
|
+
} finally { clearTimeout(watchdog); try { await browser.close(); } catch {} }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Boot the game, install the walk sampler, hold W for a while, release, and print idle-vs-walking stats.
|
|
2
|
+
// node tools/metahuman/e2e/walk-probe.mjs [port] (env FOLLOW='{"shoulder":0.4}' sets helperFollow first; SHOT=file.png;
|
|
3
|
+
// PROBE=file.js / PROBE_MID=file.js evaluate extra probes after / during the walk). Outputs: tools/metahuman/e2e/shots/.
|
|
4
|
+
import { chromium } from 'playwright-core';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
const PORT = process.argv[2] || '3010';
|
|
7
|
+
const HERE = new URL('./', import.meta.url).pathname; const SHOTS = HERE + 'shots/'; fs.mkdirSync(SHOTS, { recursive: true });
|
|
8
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
|
+
const browser = await chromium.launch({ channel: 'chrome', headless: true, args: ['--use-angle=metal', '--enable-unsafe-webgpu', '--window-size=1280,720', '--autoplay-policy=no-user-gesture-required'] });
|
|
10
|
+
const watchdog = setTimeout(async () => { try { await browser.close(); } catch {} process.exit(2); }, 180000);
|
|
11
|
+
try {
|
|
12
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
13
|
+
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
|
|
14
|
+
const start = Date.now();
|
|
15
|
+
while (Date.now() - start < 90000 && !/PLAY/i.test(await page.evaluate(() => document.body.innerText || ''))) await sleep(400);
|
|
16
|
+
await sleep(600);
|
|
17
|
+
const btn = page.getByText(/^\s*PLAY\s*$/i).first(); if (await btn.count()) await btn.click({ force: true }); else await page.mouse.click(640, 520);
|
|
18
|
+
while (Date.now() - start < 150000 && !(await page.evaluate(() => !!(window.__lab && window.__lab.actor && window.__lab.actor.getModel())))) await sleep(400);
|
|
19
|
+
await sleep(4000);
|
|
20
|
+
await page.evaluate(() => { window.__lab.freeCamOff(); if (window.__lab.orbitTo) window.__lab.orbitTo(0, 1.25, 3.4); });
|
|
21
|
+
const info = await page.evaluate(fs.readFileSync(HERE + 'probes/walk-sampler.js', 'utf8'));
|
|
22
|
+
console.log('[sampler]', JSON.stringify(info));
|
|
23
|
+
const FOLLOW = process.env.FOLLOW ? JSON.parse(process.env.FOLLOW) : null;
|
|
24
|
+
if (FOLLOW) { await page.evaluate((f) => window.__lab.setHelperFollow(f), FOLLOW); console.log('[follow]', JSON.stringify(FOLLOW)); }
|
|
25
|
+
await sleep(1200); // idle baseline
|
|
26
|
+
const idlePoke = await page.evaluate(() => window.__poke(true)); console.log('[poke idle]', JSON.stringify(idlePoke));
|
|
27
|
+
await page.keyboard.down('KeyW'); await sleep(1400);
|
|
28
|
+
if (process.env.SHOT) { await page.screenshot({ path: process.env.SHOT }); console.log('[shot]', process.env.SHOT); }
|
|
29
|
+
const midWalk = await page.evaluate(() => window.__poke(true)); console.log('[poke mid-walk]', JSON.stringify(midWalk));
|
|
30
|
+
if (process.env.PROBE_MID) { const extra = await page.evaluate(fs.readFileSync(process.env.PROBE_MID, 'utf8')); fs.writeFileSync(SHOTS + 'walk-extra-mid.json', JSON.stringify(extra)); console.log('[extra-mid] written'); }
|
|
31
|
+
await sleep(1200); await page.keyboard.up('KeyW');
|
|
32
|
+
await sleep(1500); // back to idle
|
|
33
|
+
const samples = await page.evaluate(() => window.__walk.finish());
|
|
34
|
+
if (process.env.PROBE) { const extra = await page.evaluate(fs.readFileSync(process.env.PROBE, 'utf8')); fs.writeFileSync(SHOTS + 'walk-extra.json', JSON.stringify(extra)); console.log('[extra] written tools/metahuman/e2e/shots/walk-extra.json'); }
|
|
35
|
+
const clips = [...new Set(samples.map((x) => x.clip))]; console.log('[clips]', JSON.stringify(clips));
|
|
36
|
+
fs.writeFileSync(SHOTS + 'walk-samples.json', JSON.stringify(samples));
|
|
37
|
+
const idle = samples.filter((s) => s.t < 1.1), walk = samples.filter((s) => s.t > 1.8 && s.t < 3.6), after = samples.filter((s) => s.t > 4.6);
|
|
38
|
+
const stat = (arr, k) => { const v = arr.map((s) => s[k]).filter((x) => typeof x === 'number'); return v.length ? `${Math.min(...v).toFixed(3)}..${Math.max(...v).toFixed(3)} (mean ${(v.reduce((a, b) => a + b, 0) / v.length).toFixed(3)})` : '-'; };
|
|
39
|
+
for (const k of (process.env.KEYS ? process.env.KEYS.split(',') : ['pelvis', 'shoulderY', 'collarAboveShoulder', 'skinAboveShirtL', 'rigClavPitchL', 'clipClavPitchL', 'clipHipsAboveFoot', 'clipShoulderAboveHips'])) console.log(`${k.padEnd(20)} idle ${stat(idle, k).padEnd(30)} walk ${stat(walk, k).padEnd(30)} after ${stat(after, k)}`);
|
|
40
|
+
const pl = (arr) => arr.slice(0, 3).map((s) => JSON.stringify(s.pelvisLocal)).join(' ');
|
|
41
|
+
console.log('pelvisLocal idle', pl(idle), ' walk', pl(walk));
|
|
42
|
+
console.log('samples', samples.length);
|
|
43
|
+
} finally { clearTimeout(watchdog); await browser.close(); }
|