From 923171f1dd34c2327ac8cca1e95d29a36f0b3af3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 04:52:19 +0300 Subject: [PATCH 01/11] [feat] 57-chars M2: VRM 1.0 humanoid map (detected from names, hierarchy, shape) + rest-corrected retarget core - humanoid/humanoidMap.js (leaf): the VRM 1.0 bone table, a name reader for Mixamo / KayKit / Meshy / VRoid (UniRig) / Unreal-Quaternius conventions, the spine order read off the hips->head path (Meshy writes it top-down), a structural fallback for numbered UniRig bones, overrides - humanoid/retarget.js: SkeletonUtils.retargetClip's method (world rotations sampled at a fixed fps, hips travel scaled by size) with the rest-pose correction and a facing correction, on plain snapshots so every peer computes the same clip locally - unit: 10 map + 6 retarget cases (A-pose target, -Z facing, 2x size, .bones[] tracks) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/humanoid/humanoidMap.js | 544 ++++++++++++++++++++++++++++++++ src/lib/humanoid/retarget.js | 321 +++++++++++++++++++ tests/unit/humanoidMap.test.js | 214 +++++++++++++ tests/unit/retarget.test.js | 152 +++++++++ 4 files changed, 1231 insertions(+) create mode 100644 src/lib/humanoid/humanoidMap.js create mode 100644 src/lib/humanoid/retarget.js create mode 100644 tests/unit/humanoidMap.test.js create mode 100644 tests/unit/retarget.test.js diff --git a/src/lib/humanoid/humanoidMap.js b/src/lib/humanoid/humanoidMap.js new file mode 100644 index 000000000..9d298257f --- /dev/null +++ b/src/lib/humanoid/humanoidMap.js @@ -0,0 +1,544 @@ +// 57 M2 — THE HUMANOID MAP: every character's bones named onto VRM 1.0's humanoid bone set. +// +// A humanoid clip (57 M1 `.tpanim`, VRM Animation inside) is stored on these names, so ONE clip +// plays on any character whose bones are mapped: the 36 avatars (KayKit), a VRM (its own map), +// a Mixamo / Meshy / UniRig / Quaternius rig. The map is DETECTED, never typed: names first (the +// conventions below), the HIERARCHY second — which bone is which spine segment is read off the +// path from the hips to the head, not off its name, because Meshy writes its spine top-down +// (Hips > Spine02 > Spine01 > Spine) where Mixamo writes it bottom-up. A rig with no usable names +// at all (UniRig's `bone_0 … bone_n`) falls back to its SHAPE (`structure`): two chains down from +// the hips are legs, the branch above them carries the arms and the head. +// +// A LEAF (imports nothing): a bone list is plain data `{name, parent, pos}` (`pos` = rest position +// in the model's own space, used only by the structural fallback and the left/right check), so +// the unit suite runs it with no three.js. `humanoidRig.js` builds the list from a live model. + +/** finger -> its three segments, base first (VRM 1.0: the thumb's first is the metacarpal) */ +const FINGER_SEGMENTS = /** @type {[string, string[]][]} */ ([ + ['Thumb', ['Metacarpal', 'Proximal', 'Distal']], + ['Index', ['Proximal', 'Intermediate', 'Distal']], + ['Middle', ['Proximal', 'Intermediate', 'Distal']], + ['Ring', ['Proximal', 'Intermediate', 'Distal']], + ['Little', ['Proximal', 'Intermediate', 'Distal']] +]); + +/** + * VRM 1.0 humanoid bones, parent-first, with the NOMINAL parent (the spec's; when a model lacks + * an optional bone the nearest present ancestor stands in — `presentParent`). + * @type {{name: string, parent: string | null, required?: boolean}[]} + */ +export const VRM_BONES = [ + { name: 'hips', parent: null, required: true }, + { name: 'spine', parent: 'hips', required: true }, + { name: 'chest', parent: 'spine' }, + { name: 'upperChest', parent: 'chest' }, + { name: 'neck', parent: 'upperChest' }, + { name: 'head', parent: 'neck', required: true }, + { name: 'leftEye', parent: 'head' }, + { name: 'rightEye', parent: 'head' }, + { name: 'jaw', parent: 'head' }, + { name: 'leftUpperLeg', parent: 'hips', required: true }, + { name: 'leftLowerLeg', parent: 'leftUpperLeg', required: true }, + { name: 'leftFoot', parent: 'leftLowerLeg', required: true }, + { name: 'leftToes', parent: 'leftFoot' }, + { name: 'rightUpperLeg', parent: 'hips', required: true }, + { name: 'rightLowerLeg', parent: 'rightUpperLeg', required: true }, + { name: 'rightFoot', parent: 'rightLowerLeg', required: true }, + { name: 'rightToes', parent: 'rightFoot' }, + { name: 'leftShoulder', parent: 'upperChest' }, + { name: 'leftUpperArm', parent: 'leftShoulder', required: true }, + { name: 'leftLowerArm', parent: 'leftUpperArm', required: true }, + { name: 'leftHand', parent: 'leftLowerArm', required: true }, + { name: 'rightShoulder', parent: 'upperChest' }, + { name: 'rightUpperArm', parent: 'rightShoulder', required: true }, + { name: 'rightLowerArm', parent: 'rightUpperArm', required: true }, + { name: 'rightHand', parent: 'rightLowerArm', required: true }, + ...fingerBones('left'), + ...fingerBones('right') +]; + +/** @param {'left'|'right'} side */ +function fingerBones(side) { + /** @type {{name: string, parent: string}[]} */ + const out = []; + const hand = side + 'Hand'; + for (const [finger, segs] of FINGER_SEGMENTS) { + let parent = hand; + for (const seg of segs) { + const name = side + finger + seg; + out.push({ name, parent }); + parent = name; + } + } + return out; +} + + +/** name -> the spec row @type {Map} */ +const BY_NAME = new Map(VRM_BONES.map((b) => [b.name, b])); + +export const REQUIRED_BONES = VRM_BONES.filter((b) => b.required).map((b) => b.name); + +/** @param {string} name */ +export function isHumanBone(name) { + return BY_NAME.has(name); +} + +/** + * The nearest ancestor of `bone` (by the spec's chain) that this map HAS — what a clip's parent + * means on a model without a chest or a neck. null at the hips. + * @param {string} bone @param {Record} map + */ +export function presentParent(bone, map) { + let p = BY_NAME.get(bone)?.parent ?? null; + while (p && !map[p]) p = BY_NAME.get(p)?.parent ?? null; + // a shoulder's nominal parent is the upper chest; a model with no spine segments above the + // spine hangs it off whatever is there + return p; +} + +/** the left/right twin of a bone name ('' for a centre bone) @param {string} name */ +export function mirrorBone(name) { + if (name.startsWith('left')) return 'right' + name.slice(4); + if (name.startsWith('right')) return 'left' + name.slice(5); + return ''; +} + +// ---- reading a bone's NAME ------------------------------------------------------------------- + +/** prefixes that carry no meaning (Mixamo's namespace, VRoid's J_Bip_, Blender's DEF-, 3ds Max's Bip01) */ +const PREFIX = /^(mixamorig\d*[:_]?|j_bip_|j_adj_|def[-_.]|bip0*1[\s_]?|armature[|_:])/i; +/** separators a normalized name drops */ +const SEP = /[\s_.\-:|]/g; + +/** words that name a body part once the side is gone, and what they mean (order matters: finger + * words before "hand", "upleg"/"thigh" before "leg", "forearm"/"lowerarm" before "arm") */ +const PART_RULES = /** @type {[RegExp, string][]} */ ([ + [/(thumb|index|middle|ring|pinky|little)/, 'finger'], + [/(toe|ball)/, 'toes'], + [/(foot|ankle)/, 'foot'], + [/(upleg|thigh|upperleg)/, 'upperLeg'], + [/(calf|shin|knee|lowerleg|leg)/, 'lowerLeg'], + [/(clavicle|shoulder|collar)/, 'shoulder'], + [/(forearm|lowerarm|elbow)/, 'lowerArm'], + [/(upperarm|arm)/, 'upperArm'], + [/(hand|wrist|palm|fist)/, 'hand'], + [/neck/, 'neck'], + [/eye/, 'eye'], + [/jaw/, 'jaw'], + [/head/, 'head'], + [/(hips|pelvis|hip)/, 'hips'], + [/(upperchest|chest|spine|torso|abdomen|waist)/, 'spine'] +]); + +/** a bone that is only an END marker or a helper — never a humanoid bone */ +const IGNORE = /(end$|_end|top$|front$|nub$|tip$|slot|ik|pole|target|control|roll|twist|socket|weapon|prop|root$|^root|armature|^rig$)/i; + +/** + * Read one bone name: its side and the body part it names. + * @param {string} raw @returns {{side: 'left'|'right'|'', part: string, rest: string, seg: number}} + */ +export function readBoneName(raw) { + let n = String(raw ?? '').replace(PREFIX, ''); + /** @type {'left'|'right'|''} */ + let side = ''; + /** @param {string} s */ + const sideOf = (s) => (/^l/i.test(s) ? 'left' : 'right'); + let m; + if ((m = n.match(/^(left|right)(?=[A-Z_.\-\s]|$)/i)) || (m = n.match(/^(left|right)/i))) { + side = sideOf(m[1]); + n = n.slice(m[1].length); + } else if ((m = n.match(/(left|right)$/i))) { + side = sideOf(m[1]); + n = n.slice(0, -m[1].length); + } else if ((m = n.match(/^([LR])[_.\-\s]/))) { + side = sideOf(m[1]); + n = n.slice(2); + } else if ((m = n.match(/^[cC][_.\-\s]/))) { + n = n.slice(2); // VRoid's centre marker + } else if ((m = n.match(/[_.\-\s]([LlRr])$/))) { + side = sideOf(m[1]); + n = n.slice(0, -2); + } + let rest = n.toLowerCase().replace(SEP, ''); + // three's GLTFLoader drops the '.' from "upperarm.l" ("upperarml"): a known part word followed + // by a bare l/r is a side too + if (!side) { + const tail = rest.match(/^(.*?)([lr])$/); + if (tail && /^(upperarm|lowerarm|forearm|hand|wrist|upperleg|lowerleg|upleg|leg|foot|toes?|shoulder|clavicle|thigh|calf|ball|arm|eye)$/.test(tail[1])) { + side = tail[2] === 'l' ? 'left' : 'right'; + rest = tail[1]; + } + } + if (IGNORE.test(rest) && !/^(hips?|head|neck)$/.test(rest)) return { side, part: '', rest, seg: 0 }; + let part = ''; + for (const [re, p] of PART_RULES) { + if (re.test(rest)) { + part = p; + break; + } + } + // a finger's segment: a trailing number (1, 01, 2 …) or a phalanx word + let seg = 0; + const num = rest.match(/(\d+)$/); + if (num) seg = Number(num[1]); + else if (/metacarpal/.test(rest)) seg = 1; + else if (/proximal/.test(rest)) seg = part === 'finger' && /thumb/.test(rest) ? 2 : 1; + else if (/intermediate/.test(rest)) seg = 2; + else if (/distal/.test(rest)) seg = 3; + return { side, part, rest, seg }; +} + +// ---- detecting a map --------------------------------------------------------------------------- + +/** + * @typedef {{name: string, parent: number, pos?: number[]}} BoneEntry `parent` = index in the + * same list, -1 for a root. Only bones (or nodes that could be bones) belong in the list. + * @typedef {{map: Record, rig: string, missing: string[], ok: boolean, via: 'names'|'structure'|'file'}} HumanoidMap + */ + +/** + * Guess which convention named this rig — a LABEL for the Inspector ("Mixamo rig"), never what + * the mapping depends on. + * @param {string[]} names + */ +export function rigFamily(names) { + const has = (/** @type {RegExp} */ re) => names.some((n) => re.test(n)); + if (has(/^mixamorig/i)) return 'mixamo'; + if (has(/^J_Bip_/)) return 'vroid'; + if (has(/^(pelvis|clavicle_[lr]|thigh_[lr]|calf_[lr])$/i)) return 'unreal'; + if (has(/^(upperarm\.?l|handslot\.?l|kneeIK\.?l)$/i)) return 'kaykit'; + if (has(/^Spine02$/) && has(/^neck$/) && has(/^head_end$/)) return 'meshy'; + if (has(/^(LeftUpLeg|LeftForeArm)$/)) return 'mixamo'; + if (names.length && names.every((n) => /^(bone|joint)[_.]?\d+$/i.test(n))) return 'unirig'; + return 'generic'; +} + +/** + * Detect the humanoid map of a bone list: names first, the hierarchy decides the spine order and + * which of two candidates is the real joint, the rig's SHAPE when the names say nothing. + * @param {BoneEntry[]} bones @returns {HumanoidMap} + */ +export function detectHumanoidMap(bones) { + const names = bones.map((b) => b.name); + const rig = rigFamily(names); + const byNames = mapFromNames(bones); + const missing = REQUIRED_BONES.filter((b) => !byNames[b]); + if (!missing.length) return { map: byNames, rig, missing, ok: true, via: 'names' }; + const shaped = mapFromStructure(bones); + const shapeMissing = REQUIRED_BONES.filter((b) => !shaped[b]); + if (shapeMissing.length < missing.length) + return { map: shaped, rig: rig === 'generic' ? 'unirig' : rig, missing: shapeMissing, ok: !shapeMissing.length, via: 'structure' }; + return { map: byNames, rig, missing, ok: false, via: 'names' }; +} + +/** @param {BoneEntry[]} bones */ +function childrenOf(bones) { + /** @type {number[][]} */ + const kids = bones.map(() => []); + bones.forEach((b, i) => { + if (b.parent >= 0 && b.parent < bones.length) kids[b.parent].push(i); + }); + return kids; +} + +/** @param {BoneEntry[]} bones @param {number} i */ +function depthOf(bones, i) { + let d = 0; + for (let p = bones[i].parent; p >= 0 && d < 1000; p = bones[p].parent) d++; + return d; +} + +/** is `a` an ancestor of `b`? @param {BoneEntry[]} bones @param {number} a @param {number} b */ +function isAncestor(bones, a, b) { + for (let p = bones[b].parent, n = 0; p >= 0 && n < 1000; p = bones[p].parent, n++) if (p === a) return true; + return false; +} + +/** descendants count @param {number[][]} kids @param {number} i */ +function subtreeSize(kids, i) { + let n = 0; + const stack = [...kids[i]]; + while (stack.length) { + const j = /** @type {number} */ (stack.pop()); + n++; + stack.push(...kids[j]); + } + return n; +} + +/** + * The names pass. Every bone is read (side + part); the hips are the 'hips' bone with the most + * below it; the spine segments are whatever lies on the hips → head path below the neck, in + * depth order; each limb joint is the candidate that sits below the previous one. + * @param {BoneEntry[]} bones @returns {Record} + */ +export function mapFromNames(bones) { + const kids = childrenOf(bones); + const info = bones.map((b) => readBoneName(b.name)); + /** @type {Record} */ + const map = {}; + const idx = (/** @type {(i: number) => boolean} */ fn) => bones.map((_, i) => i).filter(fn); + const hipsCands = idx((i) => info[i].part === 'hips' && !info[i].side); + hipsCands.sort((a, b) => subtreeSize(kids, b) - subtreeSize(kids, a) || depthOf(bones, a) - depthOf(bones, b)); + const hips = hipsCands[0]; + if (hips === undefined) return map; + map.hips = bones[hips].name; + const below = (/** @type {number} */ i) => isAncestor(bones, hips, i); + const heads = idx((i) => info[i].part === 'head' && !info[i].side && below(i)).sort((a, b) => depthOf(bones, a) - depthOf(bones, b)); + const head = heads[0]; + if (head !== undefined) { + map.head = bones[head].name; + /** the path hips -> head, exclusive at both ends, top-down */ + /** @type {number[]} */ + const path = []; + for (let p = bones[head].parent; p >= 0 && p !== hips; p = bones[p].parent) path.unshift(p); + const neckAt = path.findIndex((i) => info[i].part === 'neck'); + let spineSegs = path; + if (neckAt >= 0) { + map.neck = bones[path[neckAt]].name; + spineSegs = path.slice(0, neckAt); + } else if (path.length >= 2 && info[path[path.length - 1]].part !== 'spine') { + // an unnamed joint right below the head is its neck + map.neck = bones[path[path.length - 1]].name; + spineSegs = path.slice(0, -1); + } + assignSpine(map, spineSegs.map((i) => bones[i].name)); + } + for (const side of /** @type {const} */ (['left', 'right'])) { + const S = side; + const pick = (/** @type {string} */ part, /** @type {number | undefined} */ under, /** @type {(i: number) => number} */ rank = () => 0) => { + const c = idx((i) => info[i].side === S && info[i].part === part && (under === undefined ? below(i) : isAncestor(bones, under, i))); + c.sort((a, b) => rank(a) - rank(b) || depthOf(bones, a) - depthOf(bones, b)); + return c[0]; + }; + // legs + const upLeg = pick('upperLeg', undefined); + if (upLeg !== undefined) { + map[side + 'UpperLeg'] = bones[upLeg].name; + const low = pick('lowerLeg', upLeg); + if (low !== undefined) { + map[side + 'LowerLeg'] = bones[low].name; + const foot = pick('foot', low); + if (foot !== undefined) { + map[side + 'Foot'] = bones[foot].name; + const toes = pick('toes', foot); + if (toes !== undefined) map[side + 'Toes'] = bones[toes].name; + } + } + } + // arms: the upper arm is the 'arm' bone that has a forearm below it (Mixamo's LeftArm, not + // a stray "arm_twist" helper) + const ups = idx((i) => info[i].side === S && info[i].part === 'upperArm' && below(i) && idx((j) => info[j].side === S && info[j].part === 'lowerArm' && isAncestor(bones, i, j)).length > 0); + ups.sort((a, b) => depthOf(bones, a) - depthOf(bones, b)); + const upArm = ups[0] ?? pick('upperArm', undefined); + if (upArm !== undefined) { + map[side + 'UpperArm'] = bones[upArm].name; + const sh = idx((i) => info[i].side === S && info[i].part === 'shoulder' && isAncestor(bones, i, upArm)).sort((a, b) => depthOf(bones, b) - depthOf(bones, a))[0]; + if (sh !== undefined) map[side + 'Shoulder'] = bones[sh].name; + const low = pick('lowerArm', upArm); + if (low !== undefined) { + map[side + 'LowerArm'] = bones[low].name; + // "hand" beats "wrist" when a rig has both (KayKit: wrist.l > hand.l) + const hand = pick('hand', low, (i) => (/wrist/.test(info[i].rest) ? 1 : 0)); + if (hand !== undefined) { + map[side + 'Hand'] = bones[hand].name; + assignFingers(map, side, bones, kids, info, hand); + } + } + } + const eye = idx((i) => info[i].side === S && info[i].part === 'eye' && (head === undefined || isAncestor(bones, head, i)))[0]; + if (eye !== undefined) map[side + 'Eye'] = bones[eye].name; + } + if (head !== undefined) { + const jaw = idx((i) => info[i].part === 'jaw' && isAncestor(bones, head, i))[0]; + if (jaw !== undefined) map.jaw = bones[jaw].name; + } + return map; +} + +/** spine segments, bottom-up: 1 = spine; 2 = spine, chest; 3+ = spine, a middle one as chest, the + * top as upper chest @param {Record} map @param {string[]} segs */ +function assignSpine(map, segs) { + if (!segs.length) return; + map.spine = segs[0]; + if (segs.length === 2) map.chest = segs[1]; + else if (segs.length >= 3) { + map.chest = segs[Math.floor(segs.length / 2)]; + map.upperChest = segs[segs.length - 1]; + } +} + +/** + * @param {Record} map @param {'left'|'right'} side @param {BoneEntry[]} bones + * @param {number[][]} kids @param {ReturnType[]} info @param {number} hand + */ +function assignFingers(map, side, bones, kids, info, hand) { + for (const [finger, segs] of FINGER_SEGMENTS) { + const word = finger === 'Little' ? /(little|pinky)/ : new RegExp(finger.toLowerCase()); + const c = []; + const stack = [...kids[hand]]; + while (stack.length) { + const j = /** @type {number} */ (stack.pop()); + if (info[j].part === 'finger' && word.test(info[j].rest) && (!info[j].side || info[j].side === side)) c.push(j); + stack.push(...kids[j]); + } + // numbered (1..3 / 01..03, sometimes 0-based) or by depth when unnumbered + c.sort((a, b) => info[a].seg - info[b].seg || depthOf(bones, a) - depthOf(bones, b)); + c.slice(0, 3).forEach((j, k) => (map[side + finger + segs[k]] = bones[j].name)); + } +} + +/** + * The SHAPE pass, for a rig whose names say nothing (UniRig's numbered bones). Needs `pos`. + * The hips: the highest-up bone with two chains going DOWN and one going UP. Legs: the two down + * chains (left = +X, glTF's facing). The up chain: spine segments to the branch that carries + * three chains — two sideways (arms) and the one that keeps going up (neck → head). + * @param {BoneEntry[]} bones @returns {Record} + */ +export function mapFromStructure(bones) { + /** @type {Record} */ + const map = {}; + if (!bones.length || bones.some((b) => !Array.isArray(b.pos))) return map; + const kids = childrenOf(bones); + const P = (/** @type {number} */ i) => /** @type {number[]} */ (bones[i].pos); + /** the chain from i following the child with the biggest subtree */ + const chain = (/** @type {number} */ i) => { + const out = [i]; + let c = i; + while (kids[c].length) { + c = kids[c].slice().sort((a, b) => subtreeSize(kids, b) - subtreeSize(kids, a))[0]; + out.push(c); + } + return out; + }; + const extentY = (/** @type {number} */ i) => { + let lo = Infinity, hi = -Infinity; + const stack = [i]; + while (stack.length) { + const j = /** @type {number} */ (stack.pop()); + lo = Math.min(lo, P(j)[1]); + hi = Math.max(hi, P(j)[1]); + stack.push(...kids[j]); + } + return [lo, hi]; + }; + const height = (() => { + let lo = Infinity, hi = -Infinity; + for (const b of bones) { + lo = Math.min(lo, /** @type {number[]} */ (b.pos)[1]); + hi = Math.max(hi, /** @type {number[]} */ (b.pos)[1]); + } + return Math.max(1e-6, hi - lo); + })(); + let hips = -1; + const order = bones.map((_, i) => i).sort((a, b) => depthOf(bones, a) - depthOf(bones, b)); + for (const i of order) { + if (kids[i].length < 3) continue; + const y = P(i)[1]; + const downs = kids[i].filter((c) => extentY(c)[0] < y - height * 0.2); + const ups = kids[i].filter((c) => extentY(c)[1] > y + height * 0.2); + if (downs.length >= 2 && ups.length >= 1) { + hips = i; + break; + } + } + if (hips < 0) return map; + map.hips = bones[hips].name; + const y0 = P(hips)[1]; + const downs = kids[hips].filter((c) => extentY(c)[0] < y0 - height * 0.2).sort((a, b) => P(b)[0] - P(a)[0]); + const up = kids[hips].filter((c) => extentY(c)[1] > y0 + height * 0.2).sort((a, b) => extentY(b)[1] - extentY(a)[1])[0]; + const legs = [downs[0], downs[downs.length - 1]]; + ['left', 'right'].forEach((side, k) => { + const c = chain(legs[k]); + const parts = ['UpperLeg', 'LowerLeg', 'Foot', 'Toes']; + c.slice(0, 4).forEach((j, n) => (map[side + parts[n]] = bones[j].name)); + }); + // the up chain to the branch with arms + /** @type {number[]} */ + const spine = []; + let c = up; + let branch = -1; + while (c !== undefined) { + if (kids[c].length >= 3) { + branch = c; + spine.push(c); + break; + } + spine.push(c); + c = kids[c][0]; + } + if (branch < 0) return map; + const sideKids = kids[branch].slice().sort((a, b) => P(b)[0] - P(a)[0]); + const neckKid = kids[branch].slice().sort((a, b) => extentY(b)[1] - extentY(a)[1] || Math.abs(P(a)[0]) - Math.abs(P(b)[0]))[0]; + const arms = [sideKids.find((k) => k !== neckKid), sideKids.slice().reverse().find((k) => k !== neckKid)]; + assignSpine(map, spine.map((i) => bones[i].name)); + const neckChain = chain(neckKid); + if (neckChain.length >= 2) { + map.neck = bones[neckChain[0]].name; + map.head = bones[neckChain[1]].name; + } else map.head = bones[neckChain[0]].name; + ['left', 'right'].forEach((side, k) => { + const a = arms[k]; + if (a === undefined) return; + const ch = chain(a); + // four joints before the fingers spread = a shoulder first + const withShoulder = ch.length >= 4 && Math.abs(P(ch[1])[0] - P(ch[0])[0]) < Math.abs(P(ch[2])[0] - P(ch[1])[0]) * 1.5; + const parts = withShoulder ? ['Shoulder', 'UpperArm', 'LowerArm', 'Hand'] : ['UpperArm', 'LowerArm', 'Hand']; + ch.slice(0, parts.length).forEach((j, n) => (map[side + parts[n]] = bones[j].name)); + }); + return map; +} + +/** + * Normalize a stored map: known bone names only, string values, required-bone check. + * @param {any} raw @returns {Record} + */ +export function normalizeHumanoidMap(raw) { + /** @type {Record} */ + const out = {}; + if (!raw || typeof raw !== 'object') return out; + for (const [k, v] of Object.entries(raw)) if (BY_NAME.has(k) && typeof v === 'string' && v) out[k] = v; + return out; +} + +/** the required bones a map still lacks @param {Record} map */ +export function missingRequired(map) { + return REQUIRED_BONES.filter((b) => !map[b]); +} + +/** + * A detected map with the user's fixes on top (`overrides`: bone -> node name, '' = "not mapped"). + * @param {Record} detected @param {Record | null | undefined} overrides + */ +export function applyMapOverrides(detected, overrides) { + const out = { ...detected }; + for (const [k, v] of Object.entries(overrides ?? {})) { + if (!BY_NAME.has(k)) continue; + if (v) out[k] = v; + else delete out[k]; + } + return out; +} + +/** a human label for a bone ("leftUpperArm" -> "Left upper arm") @param {string} bone */ +export function boneLabel(bone) { + const words = bone.replace(/([A-Z])/g, ' $1').toLowerCase().trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** the rig family's label @param {string} rig */ +export function rigLabel(rig) { + return ( + /** @type {Record} */ ({ + mixamo: 'Mixamo', + vroid: 'VRoid', + unreal: 'Unreal / Quaternius', + kaykit: 'KayKit', + meshy: 'Meshy', + unirig: 'UniRig', + vrm: 'VRM', + generic: 'Custom' + })[rig] ?? 'Custom' + ); +} diff --git a/src/lib/humanoid/retarget.js b/src/lib/humanoid/retarget.js new file mode 100644 index 000000000..0c188614e --- /dev/null +++ b/src/lib/humanoid/retarget.js @@ -0,0 +1,321 @@ +// 57 M2 — RETARGETING: a clip made for one humanoid, played by another. +// +// The method is three's `SkeletonUtils.retargetClip` (copy each mapped bone's WORLD rotation from +// the source to the target, sampled at a fixed fps, the hips' position scaled by the size ratio) +// with the REST-POSE CORRECTION that function leaves to its caller (`localOffsets`): what moves +// across is each bone's world-space change FROM ITS REST POSE, so a T-posed clip on an A-posed +// character keeps the character's own arms at rest and swings them by the clip's amounts. The +// facing is corrected too (VRM 0.x models face -Z, glTF +Z): both rigs' rest poses give a +// basis (up = hips → head, left = right hip → left hip) and the change is conjugated into the +// target's basis. +// +// It is implemented here rather than by calling the SkeletonUtils function because that one +// works on a LIVE SkinnedMesh (it resets the target to its bind pose, writes its bones every +// frame and freezes any unmapped bone in world space) — this one works on two plain SNAPSHOTS +// (rest TRS per node), touches no scene object, runs headless in the unit suite and gives the +// same answer on every peer for the same inputs, which is what lets each peer retarget locally +// instead of sending the result. +// +// INPUT: a clip whose tracks name source nodes (`Hips.quaternion`, `.bones[Hips].quaternion`), +// the source and target snapshots and their humanoid maps. OUTPUT: a clip whose tracks name the +// TARGET's own nodes, so it binds on the target model by name like an embedded clip. + +import * as THREE from 'three'; + +/** + * @typedef {{name: string, parent: number, t: number[], q: number[], s: number[]}} RigNode + * @typedef {{nodes: RigNode[], index: Map, map: Record}} RigSnapshot + * `nodes` parent-first; `parent` -1 = directly under the model root (root space = the model + * root's own transform excluded). + */ + +/** + * The rest snapshot of a live model: every mapped bone and all of its ancestors below `root`, + * with their CURRENT local transforms (call it on a fresh parse, or after `skeleton.pose()`). + * @param {THREE.Object3D} root @param {Record} map humanoid bone -> node name + * @returns {RigSnapshot} + */ +export function rigSnapshot(root, map) { + /** @type {THREE.Object3D[]} */ + const keep = []; + const seen = new Set(); + for (const nodeName of Object.values(map)) { + const node = root.getObjectByName(nodeName); + if (!node) continue; + /** @type {THREE.Object3D[]} */ + const chain = []; + for (let o = /** @type {THREE.Object3D | null} */ (node); o && o !== root; o = o.parent) chain.unshift(o); + for (const o of chain) { + if (seen.has(o)) continue; + seen.add(o); + keep.push(o); + } + } + return snapshotFromObjects(keep, map); +} + +/** + * Every node under `root` (bones or not) as a snapshot — for a source clip that also moves + * unmapped nodes between mapped ones (a twist bone, KayKit's wrist). + * @param {THREE.Object3D[]} objects parent-first order not required @param {Record} map + */ +function snapshotFromObjects(objects, map) { + const set = new Set(objects); + const depth = (/** @type {THREE.Object3D} */ o) => { + let d = 0; + for (let p = o.parent; p && set.has(p); p = p.parent) d++; + return d; + }; + const sorted = objects.slice().sort((a, b) => depth(a) - depth(b)); + const at = new Map(sorted.map((o, i) => [o, i])); + /** @type {RigNode[]} */ + const nodes = sorted.map((o) => ({ + name: o.name, + parent: o.parent && at.has(o.parent) ? /** @type {number} */ (at.get(o.parent)) : -1, + t: o.position.toArray(), + q: o.quaternion.toArray(), + s: o.scale.toArray() + })); + return makeSnapshot(nodes, map); +} + +/** + * A snapshot from plain node rows (a clip file's own rest skeleton, the unit suite). + * @param {RigNode[]} nodes parent-first @param {Record} map + * @returns {RigSnapshot} + */ +export function makeSnapshot(nodes, map) { + const index = new Map(nodes.map((n, i) => [n.name, i])); + return { nodes, index, map: { ...map } }; +} + +const _q = new THREE.Quaternion(); +const _v = new THREE.Vector3(); +const _s = new THREE.Vector3(); + +/** + * Root-space world matrices + rotations of a snapshot for given locals. + * @param {RigSnapshot} snap @param {(i: number) => {t: number[], q: number[], s: number[]}} local + */ +function worldOf(snap, local) { + /** @type {THREE.Matrix4[]} */ + const M = []; + /** @type {THREE.Quaternion[]} */ + const Q = []; + snap.nodes.forEach((n, i) => { + const L = local(i); + const m = new THREE.Matrix4().compose(_v.fromArray(L.t), _q.fromArray(L.q), _s.fromArray(L.s)); + const q = new THREE.Quaternion().fromArray(L.q); + if (n.parent >= 0) { + m.premultiply(M[n.parent]); + q.premultiply(Q[n.parent]); + } + M.push(m); + Q.push(q); + }); + return { M, Q }; +} + +/** @param {RigSnapshot} snap */ +function restWorld(snap) { + return worldOf(snap, (i) => snap.nodes[i]); +} + +/** root-space position of a mapped humanoid bone at rest @param {RigSnapshot} snap @param {{M: THREE.Matrix4[]}} W @param {string} bone */ +function bonePos(snap, W, bone) { + const i = snap.index.get(snap.map[bone] ?? ''); + if (i === undefined) return null; + return new THREE.Vector3().setFromMatrixPosition(W.M[i]); +} + +/** + * The rig's facing at rest: an orthonormal basis (left, up, forward) as a rotation. Up = hips → + * head (or neck/spine); left = right → left upper leg (or arm). Falls back to identity. + * @param {RigSnapshot} snap @param {{M: THREE.Matrix4[]}} W @returns {THREE.Quaternion} + */ +export function restBasis(snap, W = restWorld(snap)) { + const hips = bonePos(snap, W, 'hips'); + const top = bonePos(snap, W, 'head') ?? bonePos(snap, W, 'neck') ?? bonePos(snap, W, 'upperChest') ?? bonePos(snap, W, 'chest'); + const l = bonePos(snap, W, 'leftUpperLeg') ?? bonePos(snap, W, 'leftUpperArm'); + const r = bonePos(snap, W, 'rightUpperLeg') ?? bonePos(snap, W, 'rightUpperArm'); + if (!hips || !top || !l || !r) return new THREE.Quaternion(); + const up = top.clone().sub(hips).normalize(); + const left = l.clone().sub(r).normalize(); + if (up.lengthSq() < 0.5 || left.lengthSq() < 0.5) return new THREE.Quaternion(); + const forward = new THREE.Vector3().crossVectors(left, up).normalize(); + left.crossVectors(up, forward).normalize(); + return new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(left, up, forward)); +} + +/** + * Snap a rotation to the nearest axis-aligned one when it is within ~15° of it: two upright + * rigs facing the same way must give EXACTLY identity, not a skew from a lopsided hip. + * @param {THREE.Quaternion} q + */ +function snapAxisAligned(q) { + const m = new THREE.Matrix4().makeRotationFromQuaternion(q); + const e = m.elements; + /** @type {number[]} */ + const out = new Array(16).fill(0); + out[15] = 1; + for (const col of [0, 1, 2]) { + let best = 0; + let bestRow = 0; + for (const row of [0, 1, 2]) { + const v = e[col * 4 + row]; + if (Math.abs(v) > Math.abs(best)) { + best = v; + bestRow = row; + } + } + if (Math.abs(best) < 0.96) return q; // not close to an axis: keep the measured basis + out[col * 4 + bestRow] = Math.sign(best); + } + const snapped = new THREE.Matrix4().fromArray(out); + if (Math.abs(snapped.determinant() - 1) > 1e-6) return q; + return new THREE.Quaternion().setFromRotationMatrix(snapped); +} + +/** the hips' height above the feet at rest (the size the hips' travel scales by) @param {RigSnapshot} snap @param {{M: THREE.Matrix4[]}} W */ +function hipsHeight(snap, W) { + const hips = bonePos(snap, W, 'hips'); + if (!hips) return 1; + const feet = ['leftFoot', 'rightFoot', 'leftToes', 'rightToes'].map((b) => bonePos(snap, W, b)).filter(Boolean); + const floor = feet.length ? Math.min(...feet.map((p) => /** @type {THREE.Vector3} */ (p).y)) : 0; + const h = hips.y - floor; + return h > 1e-6 ? h : Math.max(1e-6, Math.abs(hips.y)) || 1; +} + +/** + * Which source node a track drives: `Hips.quaternion`, `.bones[Hips].quaternion`, a sanitized + * name. @param {RigSnapshot} snap @param {string} trackName + */ +function trackTarget(snap, trackName) { + let parsed; + try { + parsed = THREE.PropertyBinding.parseTrackName(trackName); + } catch { + return null; + } + const node = parsed.objectName === 'bones' ? String(parsed.objectIndex ?? '') : parsed.nodeName; + let i = snap.index.get(node); + if (i === undefined) { + const clean = THREE.PropertyBinding.sanitizeNodeName(node); + i = snap.nodes.findIndex((n) => THREE.PropertyBinding.sanitizeNodeName(n.name) === clean); + if (i < 0) return null; + } + return { node: i, property: parsed.propertyName }; +} + +/** + * Retarget `clip` (on the source's nodes) onto the target rig. + * @param {THREE.AnimationClip} clip + * @param {RigSnapshot} source @param {RigSnapshot} target + * @param {{fps?: number, name?: string, hipsTranslation?: boolean}} [opts] + * `fps` sampling rate (default 30); `hipsTranslation` false keeps the target's hips where they + * rest (a clip used only for its rotations) + * @returns {THREE.AnimationClip} + */ +export function retargetClip(clip, source, target, opts = {}) { + const fps = Math.max(1, Math.min(120, opts.fps ?? 30)); + const duration = Math.max(0, clip.duration > 0 ? clip.duration : 0); + const frames = Math.max(2, Math.round(duration * fps) + 1); + const times = new Float32Array(frames); + for (let f = 0; f < frames; f++) times[f] = frames > 1 ? (duration * f) / (frames - 1) : 0; + + // the source's tracks, bound to its nodes + /** @type {{node: number, property: string, interp: any}[]} */ + const bound = []; + for (const track of clip.tracks) { + const hit = trackTarget(source, track.name); + if (!hit || !['quaternion', 'position', 'scale'].includes(hit.property)) continue; + bound.push({ ...hit, interp: /** @type {any} */ (track).createInterpolant() }); + } + + const srcRest = restWorld(source); + const dstRest = restWorld(target); + // facing: C maps a direction in the source's basis to the same direction in the target's + const C = snapAxisAligned(restBasis(target, dstRest).multiply(restBasis(source, srcRest).invert())); + const Ci = C.clone().invert(); + const scale = hipsHeight(target, dstRest) / hipsHeight(source, srcRest); + + /** the humanoid bones both rigs map, as [source node, target node] */ + /** @type {Map} target node -> source node */ + const pairs = new Map(); + for (const [bone, dstName] of Object.entries(target.map)) { + const srcName = source.map[bone]; + if (!srcName) continue; + const s = source.index.get(srcName); + const d = target.index.get(dstName); + if (s !== undefined && d !== undefined) pairs.set(d, s); + } + const srcHips = source.index.get(source.map.hips ?? ''); + const dstHips = target.index.get(target.map.hips ?? ''); + const srcHipsRest = srcHips !== undefined ? new THREE.Vector3().setFromMatrixPosition(srcRest.M[srcHips]) : null; + const dstHipsRest = dstHips !== undefined ? new THREE.Vector3().setFromMatrixPosition(dstRest.M[dstHips]) : null; + const hipsParent = dstHips !== undefined ? target.nodes[dstHips].parent : -1; + const hipsParentInv = hipsParent >= 0 ? dstRest.M[hipsParent].clone().invert() : new THREE.Matrix4(); + const moveHips = opts.hipsTranslation !== false && srcHips !== undefined && dstHips !== undefined && bound.some((b) => b.node === srcHips && b.property === 'position'); + + /** @type {Map} */ + const quatOut = new Map([...pairs.keys()].map((d) => [d, new Float32Array(frames * 4)])); + const posOut = moveHips ? new Float32Array(frames * 3) : null; + const srcRestInv = srcRest.Q.map((q) => q.clone().invert()); + + for (let f = 0; f < frames; f++) { + const t = times[f]; + // the source's locals at t: rest, overwritten by whatever its tracks say + const locals = source.nodes.map((n) => ({ t: n.t.slice(), q: n.q.slice(), s: n.s.slice() })); + for (const b of bound) { + const v = b.interp.evaluate(t); + const L = locals[b.node]; + if (b.property === 'quaternion') L.q = [v[0], v[1], v[2], v[3]]; + else if (b.property === 'position') L.t = [v[0], v[1], v[2]]; + else L.s = [v[0], v[1], v[2]]; + } + const W = worldOf(source, (i) => locals[i]); + // the target, parent-first: a mapped node takes its corrected world rotation, an unmapped + // one rides its parent at rest + /** @type {THREE.Quaternion[]} */ + const dstQ = []; + target.nodes.forEach((n, i) => { + const parentQ = n.parent >= 0 ? dstQ[n.parent] : new THREE.Quaternion(); + const s = pairs.get(i); + if (s === undefined) { + dstQ.push(parentQ.clone().multiply(_q.fromArray(n.q))); + return; + } + // the source bone's world change from rest, carried into the target's facing + const delta = W.Q[s].clone().multiply(srcRestInv[s]); + const want = C.clone().multiply(delta).multiply(Ci).multiply(dstRest.Q[i]); + dstQ.push(want); + const local = parentQ.clone().invert().multiply(want).normalize(); + const out = /** @type {Float32Array} */ (quatOut.get(i)); + // keep the track on one hemisphere so the interpolation never takes the long way round + if (f > 0) { + const dot = out[(f - 1) * 4] * local.x + out[(f - 1) * 4 + 1] * local.y + out[(f - 1) * 4 + 2] * local.z + out[(f - 1) * 4 + 3] * local.w; + if (dot < 0) local.set(-local.x, -local.y, -local.z, -local.w); + } + local.toArray(out, f * 4); + }); + if (posOut && srcHips !== undefined && srcHipsRest && dstHipsRest) { + const p = new THREE.Vector3().setFromMatrixPosition(W.M[srcHips]).sub(srcHipsRest).applyQuaternion(C).multiplyScalar(scale).add(dstHipsRest); + p.applyMatrix4(hipsParentInv).toArray(posOut, f * 3); + } + } + + /** @type {THREE.KeyframeTrack[]} */ + const tracks = []; + for (const [d, values] of quatOut) tracks.push(new THREE.QuaternionKeyframeTrack(target.nodes[d].name + '.quaternion', times, values)); + if (posOut && dstHips !== undefined) tracks.push(new THREE.VectorKeyframeTrack(target.nodes[dstHips].name + '.position', times, posOut)); + return new THREE.AnimationClip(opts.name ?? clip.name, duration, tracks); +} + +/** + * The size ratio a retarget scales the hips' travel by (target / source), for a UI note. + * @param {RigSnapshot} source @param {RigSnapshot} target + */ +export function retargetScale(source, target) { + return hipsHeight(target, restWorld(target)) / hipsHeight(source, restWorld(source)); +} + diff --git a/tests/unit/humanoidMap.test.js b/tests/unit/humanoidMap.test.js new file mode 100644 index 000000000..fce72cf07 --- /dev/null +++ b/tests/unit/humanoidMap.test.js @@ -0,0 +1,214 @@ +// @ts-nocheck — plain bone lists, no three.js +// 57 M2: the humanoid map is DETECTED from a rig's bone names (Mixamo, KayKit, Meshy, VRoid/UniRig, +// Unreal/Quaternius), the hierarchy decides the spine order (Meshy writes it top-down), and a rig with +// numbered bones falls back to its shape. +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import { + detectHumanoidMap, + readBoneName, + REQUIRED_BONES, + rigFamily, + presentParent, + applyMapOverrides, + normalizeHumanoidMap, + mirrorBone, + VRM_BONES +} from '../../src/lib/humanoid/humanoidMap.js'; + +/** ['Name', [x,y,z] local offset, ...children] -> BoneEntry[] with root-space positions */ +function tree(spec) { + const out = []; + const walk = (node, parent, base) => { + const [name, off, ...kids] = node; + const pos = [base[0] + off[0], base[1] + off[1], base[2] + off[2]]; + const i = out.length; + out.push({ name, parent, pos }); + for (const k of kids) walk(k, i, pos); + }; + walk(spec, -1, [0, 0, 0]); + return out; +} + +const side = (s, f) => [f(s, s === 'Left' ? 1 : -1)]; + +function mixamo(prefix = 'mixamorig:') { + const p = (n) => prefix + n; + const arm = (S, x) => [ + p(S + 'Shoulder'), [0.05 * x, 0.1, 0], + [p(S + 'Arm'), [0.1 * x, 0, 0], + [p(S + 'ForeArm'), [0.25 * x, 0, 0], + [p(S + 'Hand'), [0.25 * x, 0, 0], + [p(S + 'HandThumb1'), [0.02 * x, 0, 0.03], [p(S + 'HandThumb2'), [0.03 * x, 0, 0.01], [p(S + 'HandThumb3'), [0.03 * x, 0, 0], [p(S + 'HandThumb4'), [0.02 * x, 0, 0]]]]], + [p(S + 'HandIndex1'), [0.08 * x, 0, 0.02], [p(S + 'HandIndex2'), [0.03 * x, 0, 0], [p(S + 'HandIndex3'), [0.02 * x, 0, 0]]]] + ] + ] + ] + ]; + const leg = (S, x) => [p(S + 'UpLeg'), [0.1 * x, -0.05, 0], [p(S + 'Leg'), [0, -0.45, 0], [p(S + 'Foot'), [0, -0.45, 0], [p(S + 'ToeBase'), [0, -0.05, 0.1], [p(S + 'Toe_End'), [0, 0, 0.05]]]]]]; + return tree([ + p('Hips'), [0, 1, 0], + [p('Spine'), [0, 0.1, 0], [p('Spine1'), [0, 0.12, 0], [p('Spine2'), [0, 0.12, 0], [p('Neck'), [0, 0.15, 0], [p('Head'), [0, 0.1, 0], [p('HeadTop_End'), [0, 0.2, 0]]]], arm('Left', 1), arm('Right', -1)]]], + leg('Left', 1), + leg('Right', -1) + ]); +} + +describe('57 M2 bone-name reading', () => { + it('reads side + part across the conventions', () => { + expect(readBoneName('mixamorig:LeftForeArm')).toMatchObject({ side: 'left', part: 'lowerArm' }); + expect(readBoneName('mixamorigRightUpLeg')).toMatchObject({ side: 'right', part: 'upperLeg' }); + expect(readBoneName('upperarm.l')).toMatchObject({ side: 'left', part: 'upperArm' }); + expect(readBoneName('upperarml')).toMatchObject({ side: 'left', part: 'upperArm' }); // three sanitized the '.' + expect(readBoneName('J_Bip_R_LowerLeg')).toMatchObject({ side: 'right', part: 'lowerLeg' }); + expect(readBoneName('J_Bip_C_UpperChest')).toMatchObject({ side: '', part: 'spine' }); + expect(readBoneName('thigh_l')).toMatchObject({ side: 'left', part: 'upperLeg' }); + expect(readBoneName('calf_r')).toMatchObject({ side: 'right', part: 'lowerLeg' }); + expect(readBoneName('clavicle_l')).toMatchObject({ side: 'left', part: 'shoulder' }); + expect(readBoneName('index_02_l')).toMatchObject({ side: 'left', part: 'finger', seg: 2 }); + expect(readBoneName('LeftHandPinky3')).toMatchObject({ side: 'left', part: 'finger', seg: 3 }); + expect(readBoneName('pelvis')).toMatchObject({ part: 'hips' }); + }); + + it('helpers and end markers are never humanoid bones', () => { + for (const n of ['HeadTop_End', 'head_end', 'headfront', 'handslot.l', 'kneeIK.l', 'control-toe-roll.r', 'IK-foot.l', 'upperarm_twist_01_l', 'root', 'Armature']) + expect(readBoneName(n).part, n).toBe(''); + }); +}); + +describe('57 M2 humanoid map detection', () => { + it('Mixamo (colon and sanitized prefixes) maps every required bone + fingers', () => { + for (const prefix of ['mixamorig:', 'mixamorig', 'mixamorig1:', '']) { + const m = detectHumanoidMap(mixamo(prefix)); + expect(m.ok, prefix).toBe(true); + expect(m.missing).toEqual([]); + expect(m.map.hips).toBe(prefix + 'Hips'); + expect(m.map.spine).toBe(prefix + 'Spine'); + expect(m.map.chest).toBe(prefix + 'Spine1'); + expect(m.map.upperChest).toBe(prefix + 'Spine2'); + expect(m.map.leftUpperArm).toBe(prefix + 'LeftArm'); + expect(m.map.leftLowerArm).toBe(prefix + 'LeftForeArm'); + expect(m.map.rightUpperLeg).toBe(prefix + 'RightUpLeg'); + expect(m.map.rightLowerLeg).toBe(prefix + 'RightLeg'); + expect(m.map.leftToes).toBe(prefix + 'LeftToeBase'); + expect(m.map.leftThumbMetacarpal).toBe(prefix + 'LeftHandThumb1'); + expect(m.map.leftThumbDistal).toBe(prefix + 'LeftHandThumb3'); + expect(m.map.rightIndexIntermediate).toBe(prefix + 'RightHandIndex2'); + } + expect(rigFamily(mixamo().map((b) => b.name))).toBe('mixamo'); + }); + + it('Meshy: the spine is read off the hierarchy (Hips > Spine02 > Spine01 > Spine)', () => { + const arm = (S, x) => [S + 'Shoulder', [0.05 * x, 0.05, 0], [S + 'Arm', [0.1 * x, 0, 0], [S + 'ForeArm', [0.25 * x, 0, 0], [S + 'Hand', [0.25 * x, 0, 0]]]]]; + const leg = (S, x) => [S + 'UpLeg', [0.1 * x, -0.05, 0], [S + 'Leg', [0, -0.45, 0], [S + 'Foot', [0, -0.45, 0], [S + 'ToeBase', [0, -0.05, 0.1]]]]]; + const bones = tree(['Hips', [0, 1, 0], leg('Left', 1), leg('Right', -1), ['Spine02', [0, 0.1, 0], ['Spine01', [0, 0.12, 0], ['Spine', [0, 0.12, 0], arm('Left', 1), arm('Right', -1), ['neck', [0, 0.15, 0], ['Head', [0, 0.1, 0], ['head_end', [0, 0.2, 0]], ['headfront', [0, 0.1, 0.1]]]]]]]]); + const m = detectHumanoidMap(bones); + expect(m.ok).toBe(true); + expect([m.map.spine, m.map.chest, m.map.upperChest]).toEqual(['Spine02', 'Spine01', 'Spine']); + expect(m.map.neck).toBe('neck'); + expect(m.map.head).toBe('Head'); + expect(rigFamily(bones.map((b) => b.name))).toBe('meshy'); + }); + + it('KayKit (the shipped avatar GLB, raw and sanitized names): hand beats wrist, no neck', () => { + const b = fs.readFileSync(new URL('../../static/avatars/knight.glb', import.meta.url)); + const json = JSON.parse(b.subarray(20, 20 + b.readUInt32LE(12)).toString()); + const parent = new Map(); + json.nodes.forEach((n, i) => (n.children ?? []).forEach((c) => parent.set(c, i))); + for (const sanitize of [false, true]) { + const bones = json.nodes.map((n, i) => ({ + name: sanitize ? n.name.replace(/[\[\]\.:\/]/g, '') : n.name, + parent: parent.has(i) ? parent.get(i) : -1 + })); + const m = detectHumanoidMap(bones); + expect(m.ok, 'sanitized ' + sanitize).toBe(true); + const dot = sanitize ? '' : '.'; + expect(m.map).toMatchObject({ + hips: 'hips', + spine: 'spine', + chest: 'chest', + head: 'head', + leftUpperArm: 'upperarm' + dot + 'l', + leftLowerArm: 'lowerarm' + dot + 'l', + leftHand: 'hand' + dot + 'l', + rightUpperLeg: 'upperleg' + dot + 'r', + rightFoot: 'foot' + dot + 'r', + leftToes: 'toes' + dot + 'l' + }); + expect(m.map.neck).toBeUndefined(); + expect(rigFamily(bones.map((x) => x.name))).toBe('kaykit'); + } + }); + + it('VRoid / UniRig-VRoid (J_Bip_*) and Unreal / Quaternius (pelvis, clavicle_l, thigh_l)', () => { + const vroidArm = (S, x) => ['J_Bip_' + S + '_Shoulder', [0.05 * x, 0.1, 0], ['J_Bip_' + S + '_UpperArm', [0.1 * x, 0, 0], ['J_Bip_' + S + '_LowerArm', [0.25 * x, 0, 0], ['J_Bip_' + S + '_Hand', [0.25 * x, 0, 0], ['J_Bip_' + S + '_Little1', [0.08 * x, 0, -0.02], ['J_Bip_' + S + '_Little2', [0.02 * x, 0, 0], ['J_Bip_' + S + '_Little3', [0.02 * x, 0, 0]]]]]]]]; + const vroidLeg = (S, x) => ['J_Bip_' + S + '_UpperLeg', [0.1 * x, -0.05, 0], ['J_Bip_' + S + '_LowerLeg', [0, -0.45, 0], ['J_Bip_' + S + '_Foot', [0, -0.45, 0], ['J_Bip_' + S + '_ToeBase', [0, -0.05, 0.1]]]]]; + const vroid = tree(['Root', [0, 0, 0], ['J_Bip_C_Hips', [0, 1, 0], vroidLeg('L', 1), vroidLeg('R', -1), ['J_Bip_C_Spine', [0, 0.1, 0], ['J_Bip_C_Chest', [0, 0.1, 0], ['J_Bip_C_UpperChest', [0, 0.1, 0], vroidArm('L', 1), vroidArm('R', -1), ['J_Bip_C_Neck', [0, 0.1, 0], ['J_Bip_C_Head', [0, 0.1, 0]]]]]], ['J_Sec_Hair1_01', [0, 0.5, 0]]]]); + const v = detectHumanoidMap(vroid); + expect(v.ok).toBe(true); + expect(v.map).toMatchObject({ hips: 'J_Bip_C_Hips', upperChest: 'J_Bip_C_UpperChest', leftHand: 'J_Bip_L_Hand', rightLittleDistal: 'J_Bip_R_Little3', leftToes: 'J_Bip_L_ToeBase' }); + expect(rigFamily(vroid.map((b) => b.name))).toBe('vroid'); + + const ueArm = (s, x) => ['clavicle_' + s, [0.05 * x, 0.1, 0], ['upperarm_' + s, [0.1 * x, 0, 0], ['upperarm_twist_01_' + s, [0.1 * x, 0, 0]], ['lowerarm_' + s, [0.25 * x, 0, 0], ['hand_' + s, [0.25 * x, 0, 0], ['thumb_01_' + s, [0.02 * x, 0, 0.03], ['thumb_02_' + s, [0.02 * x, 0, 0], ['thumb_03_' + s, [0.02 * x, 0, 0]]]]]]]]; + const ueLeg = (s, x) => ['thigh_' + s, [0.1 * x, -0.05, 0], ['calf_' + s, [0, -0.45, 0], ['calf_twist_01_' + s, [0, -0.2, 0]], ['foot_' + s, [0, -0.45, 0], ['ball_' + s, [0, -0.05, 0.1]]]]]; + const ue = tree(['root', [0, 0, 0], ['pelvis', [0, 1, 0], ueLeg('l', 1), ueLeg('r', -1), ['spine_01', [0, 0.1, 0], ['spine_02', [0, 0.1, 0], ['spine_03', [0, 0.1, 0], ueArm('l', 1), ueArm('r', -1), ['neck_01', [0, 0.1, 0], ['head', [0, 0.1, 0]]]]]]], ['ik_foot_root', [0, 0, 0]]]); + const u = detectHumanoidMap(ue); + expect(u.ok).toBe(true); + expect(u.map).toMatchObject({ hips: 'pelvis', spine: 'spine_01', chest: 'spine_02', upperChest: 'spine_03', neck: 'neck_01', leftShoulder: 'clavicle_l', leftUpperArm: 'upperarm_l', rightLowerLeg: 'calf_r', leftToes: 'ball_l', leftThumbProximal: 'thumb_02_l' }); + expect(rigFamily(ue.map((b) => b.name))).toBe('unreal'); + }); + + it('numbered bones (UniRig) fall back to the SHAPE: legs down, arms sideways, left = +X', () => { + let n = 0; + const b = (off, ...kids) => ['bone_' + n++, off, ...kids]; + // build in an order where ids say nothing + const leftLeg = b([0.1, -0.05, 0], b([0, -0.45, 0], b([0, -0.45, 0], b([0, -0.05, 0.1])))); + const rightLeg = b([-0.1, -0.05, 0], b([0, -0.45, 0], b([0, -0.45, 0], b([0, -0.05, 0.1])))); + const leftArm = b([0.05, 0.05, 0], b([0.12, 0, 0], b([0.25, 0, 0], b([0.25, 0, 0])))); + const rightArm = b([-0.05, 0.05, 0], b([-0.12, 0, 0], b([-0.25, 0, 0], b([-0.25, 0, 0])))); + const neck = b([0, 0.15, 0], b([0, 0.12, 0])); + const bones = tree(b([0, 1, 0], rightLeg, b([0, 0.12, 0], b([0, 0.12, 0], leftArm, neck, rightArm)), leftLeg)); + const m = detectHumanoidMap(bones); + expect(m.via).toBe('structure'); + expect(m.ok).toBe(true); + const name = (spec) => spec[0]; + expect(m.map.leftUpperLeg).toBe(name(leftLeg)); + expect(m.map.rightUpperLeg).toBe(name(rightLeg)); + expect(m.map.leftShoulder).toBe(name(leftArm)); + expect(m.map.neck).toBe(name(neck)); + expect(m.map.head).toBe(name(neck[2])); + expect(rigFamily(bones.map((x) => x.name))).toBe('unirig'); + }); + + it('a rig without legs says what it is missing', () => { + const bones = tree(['Hips', [0, 1, 0], ['Spine', [0, 0.1, 0], ['Head', [0, 0.3, 0]]]]); + const m = detectHumanoidMap(bones); + expect(m.ok).toBe(false); + expect(m.missing).toContain('leftUpperLeg'); + expect(m.missing).toContain('rightHand'); + }); +}); + +describe('57 M2 map helpers', () => { + it('the bone table is VRM 1.0 (55 bones, 15 required, parent-first)', () => { + expect(VRM_BONES.length).toBe(55); + expect(REQUIRED_BONES.length).toBe(15); + const seen = new Set(); + for (const b of VRM_BONES) { + if (b.parent) expect(seen.has(b.parent), b.name).toBe(true); + seen.add(b.name); + } + }); + + it('presentParent skips bones a model lacks; overrides fix or unmap; normalize drops junk', () => { + const map = { hips: 'h', spine: 's', head: 'hd', leftShoulder: 'ls' }; + expect(presentParent('head', map)).toBe('spine'); + expect(presentParent('leftShoulder', map)).toBe('spine'); + expect(presentParent('hips', map)).toBe(null); + expect(applyMapOverrides(map, { head: 'Head2', leftShoulder: '', bogus: 'x' })).toEqual({ hips: 'h', spine: 's', head: 'Head2' }); + expect(normalizeHumanoidMap({ hips: 'h', nope: 'x', spine: 3 })).toEqual({ hips: 'h' }); + expect(mirrorBone('leftUpperArm')).toBe('rightUpperArm'); + expect(mirrorBone('rightIndexDistal')).toBe('leftIndexDistal'); + expect(mirrorBone('spine')).toBe(''); + }); +}); diff --git a/tests/unit/retarget.test.js b/tests/unit/retarget.test.js new file mode 100644 index 000000000..8c9ec393d --- /dev/null +++ b/tests/unit/retarget.test.js @@ -0,0 +1,152 @@ +// @ts-nocheck — plain rig rows +// 57 M2: retargeting moves each mapped bone's WORLD change from rest (rest-pose correction), conjugated +// into the target's facing, with the hips' travel scaled by the size ratio. Same inputs, same clip. +import { describe, it, expect } from 'vitest'; +import * as THREE from 'three'; +import { makeSnapshot, retargetClip, restBasis, retargetScale } from '../../src/lib/humanoid/retarget.js'; + +const I = [0, 0, 0, 1]; +const ONE = [1, 1, 1]; + +/** a small humanoid: [vrmBone, nodeName, parentVrm, localPos, localQuat?] */ +function rig(rows, { scale = 1, rest = {} } = {}) { + const nodes = []; + const map = {}; + const at = new Map(); + for (const [bone, name, parent, t, q] of rows) { + at.set(bone, nodes.length); + nodes.push({ name, parent: parent ? at.get(parent) : -1, t: t.map((v) => v * scale), q: rest[bone] ?? q ?? I, s: ONE }); + if (bone[0] !== '_') map[bone] = name; + } + return makeSnapshot(nodes, map); +} + +const BODY = (n = (b) => b) => [ + ['_armature', n('Armature'), null, [0, 0, 0]], + ['hips', n('hips'), '_armature', [0, 1, 0]], + ['spine', n('spine'), 'hips', [0, 0.2, 0]], + ['head', n('head'), 'spine', [0, 0.5, 0]], + ['leftUpperArm', n('upperarm.l'), 'spine', [0.2, 0.3, 0]], + ['leftLowerArm', n('lowerarm.l'), 'leftUpperArm', [0.3, 0, 0]], + ['leftHand', n('hand.l'), 'leftLowerArm', [0.25, 0, 0]], + ['rightUpperArm', n('upperarm.r'), 'spine', [-0.2, 0.3, 0]], + ['rightLowerArm', n('lowerarm.r'), 'rightUpperArm', [-0.3, 0, 0]], + ['rightHand', n('hand.r'), 'rightLowerArm', [-0.25, 0, 0]], + ['leftUpperLeg', n('upperleg.l'), 'hips', [0.1, -0.05, 0]], + ['leftLowerLeg', n('lowerleg.l'), 'leftUpperLeg', [0, -0.45, 0]], + ['leftFoot', n('foot.l'), 'leftLowerLeg', [0, -0.45, 0]], + ['rightUpperLeg', n('upperleg.r'), 'hips', [-0.1, -0.05, 0]], + ['rightLowerLeg', n('lowerleg.r'), 'rightUpperLeg', [0, -0.45, 0]], + ['rightFoot', n('foot.r'), 'rightLowerLeg', [0, -0.45, 0]] +]; + +const q = (axis, deg) => new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(...axis).normalize(), (deg * Math.PI) / 180); + +/** a 1 s clip: the left upper arm turns 90° (world) about Z by the end, the hips walk 1 m forward */ +function sourceClip(src) { + const arm = src.nodes[src.index.get(src.map.leftUpperArm)]; + const r0 = new THREE.Quaternion().fromArray(arm.q); + const r1 = q([0, 0, 1], 90).multiply(r0); // parent (spine) is identity in world → world == local here + return new THREE.AnimationClip('Wave', 1, [ + new THREE.QuaternionKeyframeTrack(src.map.leftUpperArm + '.quaternion', [0, 1], [...r0.toArray(), ...r1.toArray()]), + new THREE.VectorKeyframeTrack(src.map.hips + '.position', [0, 1], [0, 1, 0, 0, 1, 1]) + ]); +} + +/** the target's root-space world rotation of a node at the clip's frame f */ +function worldAt(snap, clip, frame) { + const local = snap.nodes.map((n) => ({ q: new THREE.Quaternion().fromArray(n.q), t: new THREE.Vector3().fromArray(n.t) })); + for (const tr of clip.tracks) { + const [node, prop] = tr.name.split(/\.(?=[^.]+$)/); + const i = snap.index.get(node); + const v = tr.values; + if (prop === 'quaternion') local[i].q.fromArray(v, frame * 4); + else local[i].t.fromArray(v, frame * 3); + } + const W = []; + const P = []; + snap.nodes.forEach((n, i) => { + const pq = n.parent >= 0 ? W[n.parent] : new THREE.Quaternion(); + const pp = n.parent >= 0 ? P[n.parent] : new THREE.Vector3(); + W.push(pq.clone().multiply(local[i].q)); + P.push(local[i].t.clone().applyQuaternion(pq).add(pp)); + }); + return { W, P }; +} + +const angle = (a, b) => (2 * Math.acos(Math.min(1, Math.abs(a.dot(b))))) * (180 / Math.PI); + +describe('57 M2 retarget', () => { + it('onto an identical rig: the clip comes back unchanged (and binds by the target names)', () => { + const src = rig(BODY()); + const dst = rig(BODY((n) => 'mixamorig' + n.replace('.', '_'))); + const clip = sourceClip(src); + const out = retargetClip(clip, src, dst, { fps: 10 }); + expect(out.duration).toBe(1); + expect(out.tracks.map((t) => t.name)).toContain('mixamorigupperarm_l.quaternion'); + expect(out.tracks.map((t) => t.name)).toContain('mixamorighips.position'); + const last = out.tracks[0].times.length - 1; + const { W, P } = worldAt(dst, out, last); + expect(angle(W[dst.index.get('mixamorigupperarm_l')], q([0, 0, 1], 90))).toBeLessThan(0.1); + expect(P[dst.index.get('mixamorighips')].toArray().map((v) => +v.toFixed(4))).toEqual([0, 1, 1]); + // an unanimated bone stays at rest + expect(angle(W[dst.index.get('mixamorighead')], new THREE.Quaternion())).toBeLessThan(0.1); + }); + + it('REST-POSE CORRECTION: an A-posed target keeps its own rest and moves by the clip\'s change', () => { + const src = rig(BODY()); + const aPose = q([0, 0, 1], -45); // left arm 45° down at rest + const dst = rig(BODY(), { rest: { leftUpperArm: aPose.toArray(), rightUpperArm: q([0, 0, 1], 45).toArray() } }); + const out = retargetClip(sourceClip(src), src, dst, { fps: 10 }); + const a = dst.index.get('upperarm.l'); + const first = worldAt(dst, out, 0).W[a]; + expect(angle(first, aPose)).toBeLessThan(0.1); // frame 0 = the target's own rest, not the source's T + const last = worldAt(dst, out, out.tracks[0].times.length - 1).W[a]; + expect(angle(last, q([0, 0, 1], 90).multiply(aPose))).toBeLessThan(0.1); // +90° from ITS rest + }); + + it('FACING: a target facing -Z (VRM 0.x) turns the same way relative to its own body', () => { + const src = rig(BODY()); + // mirror the layout through Y: x and z flip, the character now faces -Z with its left at -X + const flipped = BODY().map(([b, n, p, t]) => [b, n, p, [-t[0], t[1], -t[2]]]); + const dst = rig(flipped); + const basisS = restBasis(src); + const basisD = restBasis(dst); + expect(angle(basisD.clone().multiply(basisS.clone().invert()), q([0, 1, 0], 180))).toBeLessThan(0.1); + const out = retargetClip(sourceClip(src), src, dst, { fps: 10 }); + const last = out.tracks[0].times.length - 1; + const { W, P } = worldAt(dst, out, last); + // the source's +90° about +Z is, in a body turned 180° about Y, +90° about -Z + expect(angle(W[dst.index.get('upperarm.l')], q([0, 0, -1], 90))).toBeLessThan(0.1); + // and "1 m forward" is 1 m along -Z + expect(P[dst.index.get('hips')].toArray().map((v) => +v.toFixed(4))).toEqual([0, 1, -1].map((v) => (v === 0 ? 0 : v))); + }); + + it('SIZE: a twice-as-tall target walks twice as far', () => { + const src = rig(BODY()); + const dst = rig(BODY(), { scale: 2 }); + expect(retargetScale(src, dst)).toBeCloseTo(2, 6); + const out = retargetClip(sourceClip(src), src, dst, { fps: 10 }); + const { P } = worldAt(dst, out, out.tracks[0].times.length - 1); + expect(P[dst.index.get('hips')].toArray().map((v) => +v.toFixed(4))).toEqual([0, 2, 2]); + }); + + it('`.bones[name]` tracks bind; unmapped bones of the target are left alone; deterministic', () => { + const src = rig(BODY()); + const dst = rig(BODY().filter(([b]) => b !== 'rightHand')); + const clip = sourceClip(src); + clip.tracks[0].name = '.bones[upperarm.l].quaternion'; + const a = retargetClip(clip, src, dst, { fps: 24 }); + const b = retargetClip(clip, src, dst, { fps: 24 }); + expect(a.tracks.length).toBe(dst.nodes.length - 1 + 1); // every mapped node + the hips position, never the armature + expect(a.tracks.map((t) => t.name)).not.toContain('Armature.quaternion'); + expect(Array.from(a.tracks[3].values)).toEqual(Array.from(b.tracks[3].values)); + expect(a.tracks[0].times.length).toBe(25); + }); + + it('hipsTranslation:false keeps the hips where they rest', () => { + const src = rig(BODY()); + const out = retargetClip(sourceClip(src), src, rig(BODY()), { hipsTranslation: false }); + expect(out.tracks.some((t) => t.name.endsWith('.position'))).toBe(false); + }); +}); From 7fbe22fd4ded5d62999476a467d0945afa713ba2 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 05:12:53 +0300 Subject: [PATCH 02/11] [feat] 57-chars M2 Apply clip + M4 VRM characters + M7 provenance core - rigs WITHOUT clips (a VRM, a Meshy / UniRig character, the KayKit avatars) ride the raw-bytes objectfile path like animated ones (fileHandler / importOptimize hasSkin) - animatedImports: rest pose captured at registration, RUNTIME clips (computed per peer, never in the bytes), registration listeners; humanoid config / provenance / VRM expressions ride the bytes - humanoid/humanoidRig.js (lazy): map = file's (VRM) or detected + user fixes; Apply clip from a scene model or the built-in KayKit motions; config replicates (objectParameters 'humanoid'), saves, undoes; every peer + late joiner retargets for itself - Apply clip dialog, Inspector > Character (rig, applied clips, licence, VRM meta + expression), object menu Character > Apply clip..., Animation window 'Apply to...' - vrm/vrmRuntime.js (lazy chunk, @pixiv/three-vrm 3.5.5 MIT): VRM 0.x + 1.0 (.vrm or a .glb with the extension), spring bones + expressions ticked after the mixers, own humanoid map, licence meta -> provenance; Set Expression flow node (deterministic on the trigger stamp) - provenance.js (leaf): {tool, model, version, licence, prompt?, date} records, licence flags (credit / NC / no-redistribution / ND), warnings, credits text, VRM meta -> licence - unit: provenance (5); e2e: character-apply, character-vrm (fixtures built in-test) Co-Authored-By: Claude Opus 5.5 (1M context) --- package-lock.json | 137 +++++ package.json | 1 + src/App.svelte | 5 +- .../characters/ApplyClipDialog.svelte | 184 ++++++ .../characters/CharacterDialogs.svelte | 11 + src/components/editors/AnimationWindow.svelte | 12 +- src/components/editors/Nodes.svelte | 1 + src/components/menu/Inspector.svelte | 9 +- src/components/menu/Sidebar.svelte | 2 +- .../menu/inspector/CharacterSection.svelte | 182 ++++++ src/lib/animatedImports.js | 227 +++++++- src/lib/characterUi.js | 31 ++ src/lib/commandsHandler.svelte.js | 9 +- src/lib/debugHooks.js | 4 + src/lib/explorer.js | 2 +- src/lib/fileHandler.svelte.js | 61 +- src/lib/flowRuntime.js | 40 ++ src/lib/flowSockets.js | 1 + src/lib/humanoid/humanoidMap.js | 125 ++++- src/lib/humanoid/humanoidRig.js | 526 ++++++++++++++++++ src/lib/humanoid/retarget.js | 60 +- src/lib/importOptimize.js | 7 +- src/lib/nodeCatalog.js | 13 + src/lib/nodeDocs.js | 1 + src/lib/objectMenu.js | 17 + src/lib/provenance.js | 281 ++++++++++ src/lib/vrm/vrmDetect.js | 18 + src/lib/vrm/vrmRuntime.js | 170 ++++++ src/lib/wireValidate.js | 3 + tests/e2e/character-apply.test.cjs | 308 ++++++++++ tests/e2e/character-vrm.test.cjs | 276 +++++++++ tests/e2e/characterFixtures.cjs | 248 +++++++++ tests/unit/provenance.test.js | 84 +++ 33 files changed, 2987 insertions(+), 69 deletions(-) create mode 100644 src/components/characters/ApplyClipDialog.svelte create mode 100644 src/components/characters/CharacterDialogs.svelte create mode 100644 src/components/menu/inspector/CharacterSection.svelte create mode 100644 src/lib/characterUi.js create mode 100644 src/lib/humanoid/humanoidRig.js create mode 100644 src/lib/provenance.js create mode 100644 src/lib/vrm/vrmDetect.js create mode 100644 src/lib/vrm/vrmRuntime.js create mode 100644 tests/e2e/character-apply.test.cjs create mode 100644 tests/e2e/character-vrm.test.cjs create mode 100644 tests/e2e/characterFixtures.cjs create mode 100644 tests/unit/provenance.test.js diff --git a/package-lock.json b/package-lock.json index 1a04546db..3cdc8948f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@gltf-transform/extensions": "^4.5.1", "@gltf-transform/functions": "^4.5.1", "@lucide/svelte": "^1.48.0", + "@pixiv/three-vrm": "3.5.5", "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", "@threlte/core": "^8.6.1", @@ -933,6 +934,142 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@pixiv/three-vrm": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm/-/three-vrm-3.5.5.tgz", + "integrity": "sha512-RPXy7jYAXs704NIpZlosB0U2ENu21G9DrqGWdQgRe8dShaCo1ugpj+6BVPRCy91nt+MPMA96j5rbsSzEl0HlQA==", + "license": "MIT", + "dependencies": { + "@pixiv/three-vrm-core": "3.5.5", + "@pixiv/three-vrm-materials-hdr-emissive-multiplier": "3.5.5", + "@pixiv/three-vrm-materials-mtoon": "3.5.5", + "@pixiv/three-vrm-materials-v0compat": "3.5.5", + "@pixiv/three-vrm-node-constraint": "3.5.5", + "@pixiv/three-vrm-springbone": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-core": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-core/-/three-vrm-core-3.5.5.tgz", + "integrity": "sha512-jjaXZbMVRsk2HAGVxqDLmSJyGOJqbMcJpuTHR1TY/TN1FxiKdAzSqdr/ZUcczYipNf8IL9vDT9NX2XmWj+ybRQ==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.5", + "@pixiv/types-vrmc-vrm-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-materials-hdr-emissive-multiplier": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-hdr-emissive-multiplier/-/three-vrm-materials-hdr-emissive-multiplier-3.5.5.tgz", + "integrity": "sha512-XnHEqpY4ctYMucpeRcptdphOe0RrW7V5fUvojasD8Ja4MTjjrjCNFxsAvp3+HBkPZbkTM2sDCjJBJNzxN1scrA==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-materials-mtoon": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-mtoon/-/three-vrm-materials-mtoon-3.5.5.tgz", + "integrity": "sha512-7lThHf4OKBOsVenD+R6L6HffNUlKO3Twnhr3+z77MnUsSzsOanjVKJtjSIly9QJ6hgl8YJmI6RnxCKqXcpplog==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.5", + "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-materials-v0compat": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-v0compat/-/three-vrm-materials-v0compat-3.5.5.tgz", + "integrity": "sha512-NjsTK4P4qdcr+RoL2gEUNgF2CY/aplNvyHHxI99N7D6CxDZ2H93FQ9aIuLCABj5b+4CIvldWMCcnS7YuLIr3Lw==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.5", + "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-node-constraint": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-node-constraint/-/three-vrm-node-constraint-3.5.5.tgz", + "integrity": "sha512-+ShEXioZ5Ctf0EZ5ZViZ4n0jhV/qtJRH0Juw8nRnMNODZXDSr+B3An2lrfRoMhowCzG63GiUG4lXWV9noMXosA==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrmc-node-constraint-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-springbone": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-springbone/-/three-vrm-springbone-3.5.5.tgz", + "integrity": "sha512-GWAWrIegGH/u1ZA+Es03Qzc2yafrQOrhtal9jzUIAgU8Wm2DYuqAABOY+du/9RpBRK8D4T8DBPvRGQqvfrQ/uA==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.5", + "@pixiv/types-vrmc-springbone-1.0": "3.5.5", + "@pixiv/types-vrmc-springbone-extended-collider-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/types-vrm-0.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrm-0.0/-/types-vrm-0.0-3.5.5.tgz", + "integrity": "sha512-2S/0jswMjJTwL9ky8YzDR0CIaP6ZmE+HFuyW/0h+SA+awELf+J7WGgC8lTLM1MXzokO7Z2YVljU2F0fEhlKDIQ==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0/-/types-vrmc-materials-hdr-emissive-multiplier-1.0-3.5.5.tgz", + "integrity": "sha512-IuHw9ehXt2bymqfdFV+f2cCXo8kvJUfh0Zuut8qmKXoEb7HVyLGIQyS4TcQHG5yY22UTWHI1Axo5tQv35lPTgQ==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-materials-mtoon-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-mtoon-1.0/-/types-vrmc-materials-mtoon-1.0-3.5.5.tgz", + "integrity": "sha512-GFVuksAJVhrWTXYmI/ucNsE611/d8N6BoZrzhUAAINndt98loyzapW2+DJu+GsUBO5yxd+vLsL0FB16XD9+TGg==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-node-constraint-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-node-constraint-1.0/-/types-vrmc-node-constraint-1.0-3.5.5.tgz", + "integrity": "sha512-8EbJ1uzkn9yJQarCChmpG12+mc2c7PimHvQ8H6/tMh/hqp5NtJND0z2lR2KeVxw9A3M+TpF+xgImhLBxGjqwzQ==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-springbone-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-1.0/-/types-vrmc-springbone-1.0-3.5.5.tgz", + "integrity": "sha512-gRQ5Y7PaGiMefvOrJ3hvoSPqpA67jiwyhwh8ItjsPi3yri/Hn3CeXplW3Cbx3ZPbfu+C3Q2MCRa2TqAUOjeMRA==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-springbone-extended-collider-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-extended-collider-1.0/-/types-vrmc-springbone-extended-collider-1.0-3.5.5.tgz", + "integrity": "sha512-+6RxLaAvVguP7upjS/OT4viNPWvac1HBRQY1nmdvfYNQuCMdrMsCZqvy2af0Fcjnda4V9JbKFIpZDC0lrw1+uQ==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-vrm-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-vrm-1.0/-/types-vrmc-vrm-1.0-3.5.5.tgz", + "integrity": "sha512-WnemHHo2375BcBdumEhvrupZzsMSzTfdWlokmV9sUX2oC+OAQqJFfBjLF8ppkOhzHpl4MAPJqEfuhBJtNPm9ww==", + "license": "MIT" + }, "node_modules/@pmndrs/handle": { "version": "6.6.32", "resolved": "https://registry.npmjs.org/@pmndrs/handle/-/handle-6.6.32.tgz", diff --git a/package.json b/package.json index e46cc71aa..35be64bab 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@gltf-transform/extensions": "^4.5.1", "@gltf-transform/functions": "^4.5.1", "@lucide/svelte": "^1.48.0", + "@pixiv/three-vrm": "3.5.5", "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", "@threlte/core": "^8.6.1", diff --git a/src/App.svelte b/src/App.svelte index db386f8ce..3a1c40c32 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -29,6 +29,8 @@ // panel, so it sits with ConfirmModal where every entry point can reach it. import StorageModal from './components/menu/StorageModal.svelte' import ReplaceModelModal from './components/menu/ReplaceModelModal.svelte' + // 57: Apply clip… / Download… / the Skeleton view — a tiny host, the dialogs load on open + import CharacterDialogs from './components/characters/CharacterDialogs.svelte' import PlaceholderTooltip from './components/menu/PlaceholderTooltip.svelte' import Flow from './components/Flow.svelte' import FlowCode from './components/editors/FlowCode.svelte' @@ -383,7 +385,7 @@ import { startMusicToolbox } from './lib/musicToolbox' const files = [...(event.dataTransfer?.files ?? [])] if (files.length === 0) return const skipped = [] - const modelExt = /\.(glb|gltf|obj|stl|fbx)$/ + const modelExt = /\.(glb|gltf|obj|stl|fbx|vrm)$/ // 17-D2: a .mtl / texture dropped ALONGSIDE a model is its companion, not // an unsupported file — importFile pairs them up. const companionExt = /\.(mtl|png|jpe?g|webp|bmp|gif)$/i @@ -447,6 +449,7 @@ import { startMusicToolbox } from './lib/musicToolbox' + diff --git a/src/components/characters/ApplyClipDialog.svelte b/src/components/characters/ApplyClipDialog.svelte new file mode 100644 index 000000000..1a595f063 --- /dev/null +++ b/src/components/characters/ApplyClipDialog.svelte @@ -0,0 +1,184 @@ + + + { + if (e.key === 'Escape') close(); + }} + oncancel={close} + outsideclose + width="sm" + id="apply-clip-dialog" +> +
+

+ Plays the clip on each character you tick: it is fitted to that character's own skeleton on + every peer. The models themselves never change. +

+ + {#if items.length} + + {:else} +

+ No clips yet — import an animated model or a clip file. +

+ {/if} + Characters +
+ {#if !targets.length} +

+ No rigged characters in the scene. Import a .vrm, .glb or .fbx with a skeleton. +

+ {/if} + {#each targets as t (t.uuid)} +
+ + + {rigLabel(t.rig)}{t.ok ? '' : ' · not mapped'} + +
+ {/each} +
+
+ {#snippet footer()} +
+ + +
+ {/snippet} +
+ + diff --git a/src/components/characters/CharacterDialogs.svelte b/src/components/characters/CharacterDialogs.svelte new file mode 100644 index 000000000..77a091f3b --- /dev/null +++ b/src/components/characters/CharacterDialogs.svelte @@ -0,0 +1,11 @@ + + +{#if $applyClipRequest} + {#await import('./ApplyClipDialog.svelte') then m} + + {/await} +{/if} diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index cfa1efd65..52e18eec0 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -33,7 +33,8 @@ // posed from the synced clock) — the window used to ignore them entirely, so // a rigged model showed "no movements yet" and its own animations were // reachable only from the Inspector. - import { animatedObjects, setAnimationState, clipInfo, behaviorOf } from '$lib/animatedImports'; + import { animatedObjects, setAnimationState, clipInfo, behaviorOf, restRowsOf } from '$lib/animatedImports'; + import { openApplyClip } from '$lib/characterUi.js'; import Icon from '../ui/Icon.svelte'; import { minimalScroll } from '$lib/ui/minimalScroll.js'; import ContextMenu from '../ContextMenu.svelte'; @@ -2037,6 +2038,15 @@
Imported clips + + {#if target && !behavior && restRowsOf(target.uuid).length} + + {/if} {clips.length}
diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index dcdabb388..f7b64402a 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -267,6 +267,7 @@ animstate: AnimStateNode, // 17-E F3: the readable half of it playseq: TimelineSeqNode, // 48 S2: Play Sequence onseqevent: TimelineSeqNode, // 48 S2: On Sequence Event + setexpression: AnimationNode, // 57 M4: spec-driven (expression text + weight) // A3: ONE generic card for the whole HUD group (the ShaderNode precedent) hudscreen: HudNode, hudtext: HudNode, diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index e37d9de07..45831c0b5 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -92,7 +92,8 @@ import { nameOf } from '$lib/lockControl'; import { geometrySpec } from '$lib/geometryParams'; import { LIGHT_PARAMS, SHADOW_TYPES, SHADOW_SIZES, setShadowMapSize, cappedShadowSize } from '$lib/lightParams'; - import { animatedObjects, setAnimationState } from '$lib/animatedImports'; + import { animatedObjects, setAnimationState, restRowsOf } from '$lib/animatedImports'; + import CharacterSection from './inspector/CharacterSection.svelte'; import { captureAutoKey, playheadOf } from '$lib/animationPreview'; import { moveObjectToGroup, selectObject, flyTo, setPickThrough } from '$lib/objectActions'; import { listPhysicsObjects, enablePhysicsOnSelection, setPhysicsFor, PHYSICS_MATERIALS } from '$lib/physics'; @@ -3188,7 +3189,7 @@
{/if} - {#if $animatedObjects[$selectedObject.uuid]} + {#if $animatedObjects[$selectedObject.uuid]?.clips?.length} {@const anim = $animatedObjects[$selectedObject.uuid]}
@@ -3224,6 +3225,10 @@
{/if} + + {#if $animatedObjects[$selectedObject.uuid] && restRowsOf($selectedObject.uuid).length} + + {/if} - importModelFiles(e.target.files)} accept=".gltf, .glb, .obj, .stl, .fbx, .mtl, .png, .jpg, .jpeg, .webp" /> + importModelFiles(e.target.files)} accept=".gltf, .glb, .obj, .stl, .fbx, .vrm, .mtl, .png, .jpg, .jpeg, .webp" /> load(e.target.files[0])} accept=".json, .tpscene, .tp" /> + {#if $publishSlot} {:else} @@ -66,9 +69,9 @@ {/if}
{:else if $publishExportTab === 'export'} -
+
{:else if $publishExportTab === 'gallery'} -
+
{:else}
{/if} diff --git a/src/components/menu/inspector/CharacterSection.svelte b/src/components/menu/inspector/CharacterSection.svelte index 0835c3cb1..93601bbaf 100644 --- a/src/components/menu/inspector/CharacterSection.svelte +++ b/src/components/menu/inspector/CharacterSection.svelte @@ -8,7 +8,7 @@ import UiButton from '../../ui/Button.svelte'; import { sceneRevision } from '../../../stores/sceneStore'; import { animatedObjects, vrmExpressionNames, setVrmExpression } from '$lib/animatedImports'; - import { openApplyClip } from '$lib/characterUi.js'; + import { openApplyClip, openDownload } from '$lib/characterUi.js'; import { licenceOf, licenceLabel, licenceFlags, normalizeProvenance } from '$lib/provenance'; /** @type {{ object: any }} */ @@ -116,6 +116,13 @@ title="Play a clip from another model or the built-in motions on this character" onclick={() => openApplyClip({ targets: [object.uuid] })}>Apply clip… + openDownload([object.uuid])}>Download… {#if meta}
diff --git a/src/lib/animatedImports.js b/src/lib/animatedImports.js index 31b0e783c..e73371ca2 100644 --- a/src/lib/animatedImports.js +++ b/src/lib/animatedImports.js @@ -239,6 +239,12 @@ async function parseAnimatedBytes(bytes, kind) { return { root: gltf.scene, animations: gltf.animations ?? [] }; } +/** 57 M9: a fresh parse of an import's bytes (a download re-exports the PRISTINE tree, never the + * posed live one) @param {ArrayBuffer} bytes @param {'gltf'|'fbx'|'vrm'} [kind] */ +export function parseImportBytes(bytes, kind) { + return parseAnimatedBytes(bytes, kind); +} + /** @param {string} uuid */ export function hasAnimatedImport(uuid) { return fileBytes.has(uuid); diff --git a/src/lib/download/bvhWriter.js b/src/lib/download/bvhWriter.js new file mode 100644 index 000000000..42fc549fc --- /dev/null +++ b/src/lib/download/bvhWriter.js @@ -0,0 +1,153 @@ +// 57 M9 — THE BVH WRITER: one clip on one rig as Biovision Hierarchy text, for mocap and animation +// tools (Blender, MotionBuilder, Cascadeur). A LAZY chunk (only a BVH download loads it). +// +// BVH joints have an OFFSET and rotation channels but no rest ROTATION, so the writer bakes: each +// joint's channels are its rotation relative to its BVH parent, computed from the rig's root-space +// WORLD rotations (rest rotations and any non-bone ancestor — Mixamo's 0.01-scaled Armature — are +// folded in), and each offset is the joint's rest translation scaled by its parent's world scale. +// Every joint then lands exactly where the rig puts it, frame by frame: what the round-trip suite +// checks against three's BVHLoader. Channels are ZXY (the common mocap order). + +import * as THREE from 'three'; +import { bindClip, localsAt, worldMatrices, frameTimes } from './rigSample.js'; + +/** @typedef {import('./rigSample.js').RestRow} RestRow */ + +const fmt = (/** @type {number} */ n) => + Math.abs(n) < 1e-9 ? '0' : Number(n.toFixed(6)).toString(); + +/** a BVH-safe joint name @param {string} n @param {Set} used */ +function jointName(n, used) { + let base = String(n || 'joint').replace(/[^A-Za-z0-9_:.\-]/g, '_') || 'joint'; + let name = base; + for (let k = 2; used.has(name); k++) name = base + '_' + k; + used.add(name); + return name; +} + +/** + * Write a BVH. `rows` = the rig's rest rows (bones + their ancestors, parent-first); only BONE + * rows become joints (a non-bone ancestor is folded into the root). The root is the first bone + * whose parents are not bones. + * @param {RestRow[]} rows @param {THREE.AnimationClip} clip + * @param {{fps?: number, scale?: number}} [opts] `scale` multiplies every distance (1 = model units) + * @returns {{text: string, joints: string[], frames: number}} + */ +export function writeBvh(rows, clip, opts = {}) { + const fps = Math.max(1, Math.min(120, opts.fps ?? 30)); + const scale = opts.scale ?? 1; + const isBone = rows.map((r) => r.bone !== false); + /** nearest bone ancestor per row (-1 = none) */ + const boneParent = rows.map((r) => { + let p = r.parent; + while (p >= 0 && !isBone[p]) p = rows[p].parent; + return p; + }); + const roots = rows.map((_, i) => i).filter((i) => isBone[i] && boneParent[i] < 0); + if (!roots.length) throw new Error('no bones to write'); + // several top-level bones (IK helpers beside the hips): keep the one with the most below it + const below = (/** @type {number} */ i) => + rows.reduce((n, _, j) => { + let p = boneParent[j]; + while (p >= 0 && p !== i) p = boneParent[p]; + return n + (p === i ? 1 : 0); + }, 0); + const root = roots.sort((a, b) => below(b) - below(a))[0]; + /** @type {number[][]} */ + const kids = rows.map(() => []); + rows.forEach((_, i) => { + if (isBone[i] && boneParent[i] >= 0) kids[boneParent[i]].push(i); + }); + /** @type {number[]} joint order = depth-first from the root */ + const order = []; + const used = new Set(); + /** @type {Map} */ + const names = new Map(); + const rest = worldMatrices(rows, rows); + const ws = new THREE.Vector3(); + const wp = new THREE.Vector3(); + const wq = new THREE.Quaternion(); + /** @param {number} i @returns {number[]} */ + const offsetOf = (i) => { + // the ROOT's offset is zero and its position channels absolute: readers ADD the offset to + // the channels (three's BVHLoader, Blender), which every other exporter relies on too + if (i === root) return [0, 0, 0]; + // the joint's rest position in its BVH parent's (unrotated, unscaled) frame + rest[boneParent[i]].decompose(wp, wq, ws); + const p = new THREE.Vector3() + .setFromMatrixPosition(rest[i]) + .sub(wp) + .applyQuaternion(wq.clone().invert()); + return p.multiplyScalar(scale).toArray(); + }; + /** @type {string[]} */ + const lines = ['HIERARCHY']; + /** @param {number} i @param {number} depth */ + const emit = (i, depth) => { + const pad = '\t'.repeat(depth); + const name = jointName(rows[i].name, used); + names.set(i, name); + order.push(i); + lines.push(pad + (i === root ? 'ROOT ' : 'JOINT ') + name); + lines.push(pad + '{'); + lines.push(pad + '\tOFFSET ' + offsetOf(i).map(fmt).join(' ')); + lines.push( + pad + + '\tCHANNELS ' + + (i === root + ? '6 Xposition Yposition Zposition Zrotation Xrotation Yrotation' + : '3 Zrotation Xrotation Yrotation') + ); + if (kids[i].length) for (const k of kids[i]) emit(k, depth + 1); + else { + // an End Site a short way along the bone's own direction + const o = + i === root ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3().fromArray(offsetOf(i)); + const tip = + o.lengthSq() > 1e-12 + ? o.normalize().multiplyScalar(0.05 * scale) + : new THREE.Vector3(0, 0.05 * scale, 0); + lines.push(pad + '\tEnd Site'); + lines.push(pad + '\t{'); + lines.push(pad + '\t\tOFFSET ' + tip.toArray().map(fmt).join(' ')); + lines.push(pad + '\t}'); + } + lines.push(pad + '}'); + }; + emit(root, 0); + + const times = frameTimes(clip.duration, fps); + const bound = bindClip(rows, clip); + lines.push('MOTION'); + lines.push('Frames: ' + times.length); + lines.push('Frame Time: ' + fmt(1 / fps)); + const e = new THREE.Euler(); + const q = new THREE.Quaternion(); + const pq = new THREE.Quaternion(); + for (const t of times) { + const M = worldMatrices(rows, localsAt(rows, bound, t)); + /** @type {number[]} */ + const vals = []; + for (const i of order) { + M[i].decompose(wp, q, ws); + if (i === root) { + vals.push(wp.x * scale, wp.y * scale, wp.z * scale); + } else { + M[boneParent[i]].decompose(new THREE.Vector3(), pq, new THREE.Vector3()); + q.premultiply(pq.invert()); + } + e.setFromQuaternion(q.normalize(), 'ZXY'); + vals.push( + THREE.MathUtils.radToDeg(e.z), + THREE.MathUtils.radToDeg(e.x), + THREE.MathUtils.radToDeg(e.y) + ); + } + lines.push(vals.map(fmt).join(' ')); + } + return { + text: lines.join('\n') + '\n', + joints: order.map((i) => /** @type {string} */ (names.get(i))), + frames: times.length + }; +} diff --git a/src/lib/download/characterDownload.js b/src/lib/download/characterDownload.js new file mode 100644 index 000000000..b24554176 --- /dev/null +++ b/src/lib/download/characterDownload.js @@ -0,0 +1,232 @@ +// 57 M9 — DOWNLOAD A CHARACTER WITH ITS CLIPS. Object ▸ Download… asks which clips and which layout: +// (a) ONE GLB with the clips embedded (the default — what Blender, Unity, Unreal and Godot expect) +// (b) the model + one `.tpanim` per clip, in a zip +// (c) the model only (its embedded clips stripped) +// A single clip downloads as `.tpanim`, `.glb`, `.vrma` (humanoid only) or `.bvh`. Several objects +// download as one zip. Clips applied from another rig are already BAKED onto this character's own +// skeleton (they were retargeted onto its nodes), so they embed like the model's own. +// THE BYTES ARE THE MODEL'S OWN: a GLB / VRM source is edited in place (glbEdit — nothing else in the +// file changes, a VRM keeps its VRM data); only a source that is not a GLB (an FBX, a .gltf) goes +// through three's exporter, from a FRESH parse of its bytes (never the posed live tree). +// LAZY: the dialog imports this; nothing here is on the boot graph. Local: a download replicates +// nothing. + +import { get } from 'svelte/store'; +import { objectsGroup } from '../../stores/sceneStore'; +import { + animatedImportPayload, + animatedImportKind, + animatedObjects, + runtimeClipNames, + clipOf, + restRowsOf, + parseImportBytes +} from '../animatedImports'; +import { isGlb, readGlb, animationNames } from './glbEdit.js'; +import { glbWithClips, writeClipFile } from './clipFiles.js'; +import { addProvenance, normalizeProvenance } from '../provenance.js'; +import { APP_VERSION } from '../version'; + +/** @typedef {'a'|'b'|'c'} Layout */ +/** @typedef {'tpanim'|'glb'|'vrma'|'bvh'} ClipFormat */ + +/** the formats a clip can be, and why one is unavailable @type {{id: string, label: string, needs?: string}[]} */ +export const CLIP_FORMATS = [ + { id: 'tpanim', label: '.tpanim (theprototype clip)' }, + { id: 'glb', label: '.glb (skeleton + clip)' }, + { id: 'vrma', label: '.vrma (VRM Animation)', needs: 'humanoid' }, + { id: 'bvh', label: '.bvh (mocap: Blender, MotionBuilder, Cascadeur)' } +]; + +/** formats that need an AI box with the `convert` capability (roadmap 58 R19) */ +export const CONVERT_FORMATS = [ + { id: 'fbx', label: '.fbx' }, + { id: 'usd', label: '.usd' } +]; + +/** @param {string} uuid */ +function rootOf(uuid) { + return /** @type {any} */ (get(objectsGroup))?.getObjectByProperty('uuid', uuid) ?? null; +} + +/** a file-name stem @param {string} name */ +export function stem(name) { + return ( + String(name || 'character') + .trim() + .replace(/[^\w\-. ]+/g, '_') + .replace(/\s+/g, '_') || 'character' + ); +} + +/** + * What a download of this object can offer: its clips (embedded vs applied), whether its bytes + * are a GLB, whether it is a humanoid (for .vrma). + * @param {string} uuid + */ +export async function downloadPlan(uuid) { + const root = rootOf(uuid); + const bytes = animatedImportPayload(uuid); + const kind = animatedImportKind(uuid); + const runtime = new Set(runtimeClipNames(uuid)); + const all = get(animatedObjects)[uuid]?.clips ?? []; + const { humanoidOf } = await import('../humanoid/humanoidRig.js'); + const h = humanoidOf(uuid); + return { + uuid, + name: root?.name || 'Character', + kind, + glb: !!bytes && isGlb(bytes), + humanoid: !!h?.ok, + clips: all.map((name) => ({ + name, + applied: runtime.has(name), + duration: clipOf(uuid, name)?.duration ?? 0 + })) + }; +} + +/** the provenance a downloaded file records: the model's own steps + this download @param {any} root @param {string} what */ +function provenanceFor(root, what) { + const ud = { provenance: normalizeProvenance(root?.userData?.provenance) }; + addProvenance(ud, { tool: 'download', model: what, version: APP_VERSION, source: root?.name }); + return ud.provenance; +} + +/** + * The model's file with exactly these clips embedded (layout (a); `clips = []` is layout (c)). + * @param {string} uuid @param {string[]} clips @returns {Promise<{name: string, bytes: Uint8Array, notes: string[]}>} + */ +export async function modelFile(uuid, clips) { + const root = rootOf(uuid); + const bytes = animatedImportPayload(uuid); + if (!root || !bytes) throw new Error('not a downloadable model'); + const kind = animatedImportKind(uuid); + const runtime = new Set(runtimeClipNames(uuid)); + const extra = clips + .filter((c) => runtime.has(c)) + .map((c) => clipOf(uuid, c)) + .filter(Boolean); + /** @type {string[]} */ + const notes = []; + if (isGlb(bytes)) { + const embedded = animationNames(readGlb(bytes).json); + const keep = clips.filter((c) => !runtime.has(c) && embedded.includes(c)); + const out = glbWithClips(bytes, extra, keep, { + provenance: provenanceFor(root, clips.length ? 'model + clips' : 'model') + }); + if (out.dropped) + notes.push(out.dropped + ' track(s) named bones the file does not have and were left out.'); + return { name: stem(root.name) + (kind === 'vrm' ? '.vrm' : '.glb'), bytes: out.bytes, notes }; + } + // an FBX / .gltf source: a fresh parse, exported with three's GLTFExporter (rigs survive it from + // a pristine tree; materials may simplify — said in the dialog) + const parsed = await parseImportBytes(bytes, kind); + const animations = [ + ...(parsed.animations ?? []).filter( + (/** @type {any} */ a) => clips.includes(a.name) && !runtime.has(a.name) + ), + ...extra + ]; + parsed.root.userData = { + ...(parsed.root.userData ?? {}), + provenance: provenanceFor(root, 'converted to glTF') + }; + const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js'); + const glb = await new Promise((resolve, reject) => + new GLTFExporter().parse( + parsed.root, + (/** @type {any} */ b) => resolve(new Uint8Array(b)), + reject, + { binary: true, animations } + ) + ); + notes.push( + 'The source was ' + + kind.toUpperCase() + + ', so it was converted to glTF (materials may look simpler).' + ); + return { name: stem(root.name) + '.glb', bytes: /** @type {Uint8Array} */ (glb), notes }; +} + +/** + * One clip as its own file. + * @param {string} uuid @param {string} clipName @param {ClipFormat} format + * @returns {Promise<{name: string, bytes: Uint8Array}>} + */ +export async function clipFile(uuid, clipName, format) { + const root = rootOf(uuid); + const clip = clipOf(uuid, clipName); + const rows = restRowsOf(uuid); + if (!clip || !rows.length) throw new Error('no such clip'); + const base = stem(root?.name) + '-' + stem(clipName); + if (format === 'bvh') { + const { writeBvh } = await import('./bvhWriter.js'); + return { name: base + '.bvh', bytes: new TextEncoder().encode(writeBvh(rows, clip).text) }; + } + const { humanoidOf } = await import('../humanoid/humanoidRig.js'); + const h = humanoidOf(uuid); + if (format === 'vrma' && !h?.ok) throw new Error('.vrma needs a humanoid'); + const map = h?.ok ? h.map : null; + const bytes = writeClipFile(rows, clip, { + map, + provenance: provenanceFor(root, 'clip ' + clipName) + }); + return { name: base + '.' + format, bytes }; +} + +/** + * Build what the dialog asked for. One object + (a)/(c) = one file; (b) or several objects = a zip. + * @param {{uuid: string, clips: string[]}[]} picks @param {Layout} layout + * @returns {Promise<{name: string, bytes: Uint8Array, notes: string[]}>} + */ +export async function buildDownload(picks, layout) { + /** @type {Record} */ + const files = {}; + /** @type {string[]} */ + const notes = []; + const used = new Set(); + const unique = (/** @type {string} */ name) => { + let n = name; + for (let k = 2; used.has(n); k++) n = name.replace(/(\.[^.]+)$/, '-' + k + '$1'); + used.add(n); + return n; + }; + for (const { uuid, clips } of picks) { + const model = await modelFile(uuid, layout === 'a' ? clips : []); + notes.push(...model.notes); + const dir = picks.length > 1 ? stem(rootOf(uuid)?.name) + '/' : ''; + files[unique(dir + model.name)] = model.bytes; + if (layout === 'b') + for (const c of clips) { + const f = await clipFile(uuid, c, 'tpanim'); + files[unique(dir + f.name)] = f.bytes; + } + } + const names = Object.keys(files); + if (names.length === 1) + return { name: names[0].split('/').pop() ?? names[0], bytes: files[names[0]], notes }; + const { creditsFor } = await import('../sceneLicences.js'); + const credits = creditsFor(picks.map((p) => p.uuid)); + if (credits) files['CREDITS.txt'] = new TextEncoder().encode(credits); + const { zipSync } = await import('fflate'); + const zipName = picks.length > 1 ? 'characters.zip' : stem(rootOf(picks[0].uuid)?.name) + '.zip'; + return { name: zipName, bytes: zipSync(files, { level: 6 }), notes }; +} + +/** start the browser download @param {{name: string, bytes: Uint8Array}} file */ +export async function saveFile(file) { + const { saveBytes } = await import('../saveAs'); + const type = /\.zip$/.test(file.name) + ? 'application/zip' + : /\.bvh$/.test(file.name) + ? 'text/plain' + : 'model/gltf-binary'; + saveBytes( + /** @type {ArrayBuffer} */ ( + file.bytes.buffer.slice(file.bytes.byteOffset, file.bytes.byteOffset + file.bytes.byteLength) + ), + file.name, + type + ); +} diff --git a/src/lib/download/clipFiles.js b/src/lib/download/clipFiles.js new file mode 100644 index 000000000..6af6806cd --- /dev/null +++ b/src/lib/download/clipFiles.js @@ -0,0 +1,151 @@ +// 57 M9 — CLIPS AS FILES: a three AnimationClip as glTF channels, and a clip as its own small file +// (a skeleton + one animation) in the 57 M1 `.tpanim` shape: +// - humanoid clips are VALID VRM ANIMATION (`VRMC_vrm_animation`, humanBones -> nodes), so the +// same bytes renamed `.vrma` play in VRM tools; +// - other clips are a plain GLB holding only the skeleton + one animation; +// - both carry `extras.theprototype = {format: 1, kind: 'clip', skeleton: {sig, humanoid}, fps, +// frames, loop, rootMotion, provenance}` (on the glTF root, mirrored on the animation). +// 57-clips (M1) owns the format and its reader; until its writer lands this one writes exactly that +// shape (QUESTIONS-57-chars Q1), and either can replace the other at the union. + +import * as THREE from 'three'; +import { readGlb, writeGlb, appendAnimations } from './glbEdit.js'; +import { frameTimes, skeletonSignature } from './rigSample.js'; + +/** @typedef {import('./rigSample.js').RestRow} RestRow */ +/** @typedef {import('./glbEdit.js').PlainClip} PlainClip */ + +/** + * A three clip as plain glTF channels. Smooth (cubic) tracks are resampled to linear at `fps`; + * morph-target and property tracks are skipped (a rig's clip is bones). + * @param {THREE.AnimationClip} clip @param {{fps?: number}} [opts] @returns {PlainClip} + */ +export function plainClip(clip, opts = {}) { + const fps = opts.fps ?? 30; + /** @type {import('./glbEdit.js').ClipChannel[]} */ + const channels = []; + for (const track of clip.tracks) { + let parsed; + try { + parsed = THREE.PropertyBinding.parseTrackName(track.name); + } catch { + continue; + } + const node = parsed.objectName === 'bones' ? String(parsed.objectIndex ?? '') : parsed.nodeName; + /** @type {'rotation'|'translation'|'scale' | null} */ + const path = + parsed.propertyName === 'quaternion' + ? 'rotation' + : parsed.propertyName === 'position' + ? 'translation' + : parsed.propertyName === 'scale' + ? 'scale' + : null; + if (!path || !node) continue; + const mode = track.getInterpolation(); + if (mode === THREE.InterpolateSmooth) { + const times = frameTimes(clip.duration, fps); + const n = path === 'rotation' ? 4 : 3; + const values = new Float32Array(times.length * n); + const interp = /** @type {any} */ (track).createInterpolant(); + times.forEach((t, f) => values.set(interp.evaluate(t).subarray(0, n), f * n)); + channels.push({ node, path, times, values }); + } else + channels.push({ + node, + path, + times: new Float32Array(track.times), + values: new Float32Array(track.values), + step: mode === THREE.InterpolateDiscrete + }); + } + return { name: clip.name, channels }; +} + +/** + * Write one clip as its own file: the skeleton (the rig's rest rows; bones and their ancestors) + * and one animation. With a humanoid map it is VRM Animation. + * @param {RestRow[]} rows @param {THREE.AnimationClip} clip + * @param {{map?: Record | null, fps?: number, loop?: boolean, rootMotion?: boolean, provenance?: any[]}} [opts] + * @returns {Uint8Array} + */ +export function writeClipFile(rows, clip, opts = {}) { + const fps = opts.fps ?? 30; + /** @type {any[]} */ + const nodes = rows.map((r) => ({ name: r.name, translation: r.t, rotation: r.q, scale: r.s })); + rows.forEach((r, i) => { + if (r.parent >= 0) (nodes[r.parent].children = nodes[r.parent].children ?? []).push(i); + }); + for (const n of nodes) { + if (n.scale.every((/** @type {number} */ v) => v === 1)) delete n.scale; + if (n.rotation[0] === 0 && n.rotation[1] === 0 && n.rotation[2] === 0 && n.rotation[3] === 1) + delete n.rotation; + } + const roots = rows.map((r, i) => (r.parent < 0 ? i : -1)).filter((i) => i >= 0); + const humanoid = !!opts.map && Object.keys(opts.map).length > 0; + const meta = { + format: 1, + kind: 'clip', + skeleton: { sig: skeletonSignature(rows), humanoid }, + fps, + frames: Math.max(1, Math.round(clip.duration * fps) + 1), + loop: opts.loop !== false, + rootMotion: !!opts.rootMotion, + provenance: opts.provenance ?? [] + }; + /** @type {any} */ + const json = { + asset: { version: '2.0', generator: 'theprototype.app' }, + scene: 0, + scenes: [{ name: clip.name, nodes: roots, extras: { theprototype: meta } }], + nodes, + extras: { theprototype: meta } + }; + if (humanoid) { + /** @type {Record} */ + const humanBones = {}; + const at = new Map(rows.map((r, i) => [r.name, i])); + for (const [bone, name] of Object.entries(/** @type {Record} */ (opts.map))) { + const i = at.get(name); + if (i !== undefined) humanBones[bone] = { node: i }; + } + json.extensionsUsed = ['VRMC_vrm_animation']; + json.extensions = { VRMC_vrm_animation: { specVersion: '1.0', humanoid: { humanBones } } }; + } + const doc = { json, bin: new Uint8Array(0) }; + appendAnimations(doc, [{ ...plainClip(clip, { fps }), extras: { theprototype: meta } }]); + return writeGlb(doc); +} + +/** + * Add clips to a model's ORIGINAL GLB and keep only the named embedded ones (layout (a); (c) is + * `clips = []`, `keepEmbedded = []`). Nothing else in the file changes. + * @param {ArrayBuffer | Uint8Array} bytes @param {THREE.AnimationClip[]} clips @param {string[] | null} keepEmbedded + * @param {{fps?: number, provenance?: any[]}} [opts] `provenance` is written to the file's extras (57 M7) + */ +export function glbWithClips(bytes, clips, keepEmbedded, opts = {}) { + const doc = readGlb(bytes); + if (opts.provenance?.length) { + const extras = doc.json.extras && typeof doc.json.extras === 'object' ? doc.json.extras : {}; + doc.json.extras = { + ...extras, + theprototype: { ...(extras.theprototype ?? {}), provenance: opts.provenance } + }; + } + const names = (doc.json.animations ?? []).map( + (/** @type {any} */ a, /** @type {number} */ i) => a.name ?? 'animation_' + i + ); + if (keepEmbedded) { + const keep = new Set(keepEmbedded); + const next = (doc.json.animations ?? []).filter( + (/** @type {any} */ _, /** @type {number} */ i) => keep.has(names[i]) + ); + if (next.length) doc.json.animations = next; + else delete doc.json.animations; + } + const res = appendAnimations( + doc, + clips.map((c) => plainClip(c, opts)) + ); + return { bytes: writeGlb(doc), ...res }; +} diff --git a/src/lib/download/glbEdit.js b/src/lib/download/glbEdit.js new file mode 100644 index 000000000..e020abfa8 --- /dev/null +++ b/src/lib/download/glbEdit.js @@ -0,0 +1,214 @@ +// 57 M9 / M6 — EDIT A GLB WITHOUT RE-EXPORTING IT. A rig's bytes are the truth (CLAUDE.md rule 6: +// three's GLTFExporter is lossy on rigs, and it drops a VRM's extensions), so a download that adds +// clips, strips clips or rewrites skin weights edits the file's own JSON + BIN chunks: everything +// not touched stays byte-for-byte what the user imported (materials, extensions, extras, names). +// +// A LEAF (imports nothing; unit-tested in node). + +const MAGIC = 0x46546c67; // 'glTF' +const JSON_CHUNK = 0x4e4f534a; +const BIN_CHUNK = 0x004e4942; + +/** is this a binary glTF? @param {ArrayBuffer | Uint8Array} bytes */ +export function isGlb(bytes) { + const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + return ( + u.byteLength >= 20 && + new DataView(u.buffer, u.byteOffset, u.byteLength).getUint32(0, true) === MAGIC + ); +} + +/** + * @typedef {{json: any, bin: Uint8Array}} GlbDoc `bin` grows as accessors are appended + */ + +/** @param {ArrayBuffer | Uint8Array} bytes @returns {GlbDoc} */ +export function readGlb(bytes) { + const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + const dv = new DataView(u.buffer, u.byteOffset, u.byteLength); + if (dv.getUint32(0, true) !== MAGIC) throw new Error('not a GLB'); + let at = 12; + /** @type {any} */ + let json = null; + let bin = new Uint8Array(0); + while (at + 8 <= u.byteLength) { + const len = dv.getUint32(at, true); + const type = dv.getUint32(at + 4, true); + const body = u.subarray(at + 8, at + 8 + len); + if (type === JSON_CHUNK) json = JSON.parse(new TextDecoder().decode(body)); + else if (type === BIN_CHUNK) bin = body.slice(); + at += 8 + len; + } + if (!json) throw new Error('GLB has no JSON chunk'); + return { json, bin }; +} + +/** @param {GlbDoc} doc @returns {Uint8Array} */ +export function writeGlb(doc) { + const json = doc.json; + if (doc.bin.byteLength) { + json.buffers = json.buffers?.length ? json.buffers : [{}]; + json.buffers[0] = { ...json.buffers[0], byteLength: doc.bin.byteLength }; + delete json.buffers[0].uri; + } + let j = new TextEncoder().encode(JSON.stringify(json)); + const jpad = (4 - (j.byteLength % 4)) % 4; + const bpad = (4 - (doc.bin.byteLength % 4)) % 4; + const binLen = doc.bin.byteLength ? doc.bin.byteLength + bpad : 0; + const total = 12 + 8 + j.byteLength + jpad + (binLen ? 8 + binLen : 0); + const out = new Uint8Array(total); + const dv = new DataView(out.buffer); + dv.setUint32(0, MAGIC, true); + dv.setUint32(4, 2, true); + dv.setUint32(8, total, true); + dv.setUint32(12, j.byteLength + jpad, true); + dv.setUint32(16, JSON_CHUNK, true); + out.set(j, 20); + out.fill(0x20, 20 + j.byteLength, 20 + j.byteLength + jpad); + if (binLen) { + const at = 20 + j.byteLength + jpad; + dv.setUint32(at, binLen, true); + dv.setUint32(at + 4, BIN_CHUNK, true); + out.set(doc.bin, at + 8); + } + return out; +} + +const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 }; + +/** + * Append a float accessor (its own bufferView in the BIN chunk, 4-byte aligned). + * @param {GlbDoc} doc @param {Float32Array} data @param {'SCALAR'|'VEC2'|'VEC3'|'VEC4'|'MAT4'} type + * @param {{minmax?: boolean}} [opts] `minmax` (required for animation input times) @returns {number} + */ +export function appendFloatAccessor(doc, data, type, opts = {}) { + const json = doc.json; + const pad = (4 - (doc.bin.byteLength % 4)) % 4; + const offset = doc.bin.byteLength + pad; + const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const next = new Uint8Array(offset + bytes.byteLength); + next.set(doc.bin, 0); + next.set(bytes, offset); + doc.bin = next; + json.buffers = json.buffers?.length ? json.buffers : [{ byteLength: 0 }]; + json.bufferViews = json.bufferViews ?? []; + json.accessors = json.accessors ?? []; + json.bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: bytes.byteLength }); + const n = COMPONENTS[type]; + /** @type {any} */ + const acc = { + bufferView: json.bufferViews.length - 1, + componentType: 5126, + count: data.length / n, + type + }; + if (opts.minmax) { + const min = new Array(n).fill(Infinity); + const max = new Array(n).fill(-Infinity); + for (let i = 0; i < data.length; i++) { + const c = i % n; + if (data[i] < min[c]) min[c] = data[i]; + if (data[i] > max[c]) max[c] = data[i]; + } + acc.min = min; + acc.max = max; + } + json.accessors.push(acc); + return json.accessors.length - 1; +} + +/** three's PropertyBinding.sanitizeNodeName, so a GLB node name matches the name three gave it @param {string} n */ +export function sanitizeNodeName(n) { + return String(n ?? '') + .replace(/\s/g, '_') + .replace(/[\[\]\.:\/]/g, ''); +} + +/** + * A map from the node names three uses (sanitized) to node indices. GLTFLoader also suffixes a + * duplicate name (`name_1`), which this mirrors. + * @param {any} json @returns {Map} + */ +export function nodeIndexByName(json) { + /** @type {Map} */ + const out = new Map(); + /** @type {Map} */ + const used = new Map(); + (json.nodes ?? []).forEach((/** @type {any} */ n, /** @type {number} */ i) => { + const base = sanitizeNodeName(n.name ?? ''); + if (!base) return; + const k = used.get(base) ?? 0; + used.set(base, k + 1); + const name = k ? base + '_' + k : base; + if (!out.has(name)) out.set(name, i); + if (!out.has(n.name)) out.set(n.name, i); + }); + return out; +} + +/** + * @typedef {{node: string, path: 'rotation'|'translation'|'scale', times: Float32Array, values: Float32Array, step?: boolean}} ClipChannel + * @typedef {{name: string, channels: ClipChannel[], extras?: any}} PlainClip + */ + +/** + * Append clips as glTF animations; channels naming a node the file does not have are dropped + * (and counted). + * @param {GlbDoc} doc @param {PlainClip[]} clips @returns {{added: number, dropped: number}} + */ +export function appendAnimations(doc, clips) { + const json = doc.json; + const byName = nodeIndexByName(json); + json.animations = json.animations ?? []; + let dropped = 0; + let added = 0; + for (const clip of clips) { + /** @type {any[]} */ + const samplers = []; + /** @type {any[]} */ + const channels = []; + for (const ch of clip.channels) { + const node = byName.get(ch.node) ?? byName.get(sanitizeNodeName(ch.node)); + if (node === undefined || !ch.times.length) { + dropped++; + continue; + } + const input = appendFloatAccessor(doc, ch.times, 'SCALAR', { minmax: true }); + const output = appendFloatAccessor(doc, ch.values, ch.path === 'rotation' ? 'VEC4' : 'VEC3'); + samplers.push({ input, output, interpolation: ch.step ? 'STEP' : 'LINEAR' }); + channels.push({ sampler: samplers.length - 1, target: { node, path: ch.path } }); + } + if (!channels.length) continue; + /** @type {any} */ + const anim = { name: clip.name, samplers, channels }; + if (clip.extras) anim.extras = clip.extras; + json.animations.push(anim); + added++; + } + return { added, dropped }; +} + +/** + * Keep only the named embedded animations (`keep` = null removes none, [] removes all). The + * accessors they used stay in the BIN (unreferenced) — the file stays valid, the rest untouched. + * @param {GlbDoc} doc @param {string[] | null} keep @returns {number} how many were removed + */ +export function keepAnimations(doc, keep) { + const list = doc.json.animations ?? []; + if (!keep) return 0; + const set = new Set(keep); + const next = list.filter((/** @type {any} */ a, /** @type {number} */ i) => + set.has(a.name ?? 'animation_' + i) + ); + const removed = list.length - next.length; + if (next.length) doc.json.animations = next; + else delete doc.json.animations; + return removed; +} + +/** the names three gives the embedded animations (GLTFLoader: `name` or `animation_`) @param {any} json */ +export function animationNames(json) { + return (json.animations ?? []).map( + (/** @type {any} */ a, /** @type {number} */ i) => a.name ?? 'animation_' + i + ); +} diff --git a/src/lib/download/rigSample.js b/src/lib/download/rigSample.js new file mode 100644 index 000000000..e34230780 --- /dev/null +++ b/src/lib/download/rigSample.js @@ -0,0 +1,94 @@ +// 57 M9 — pose a rig's REST ROWS (animatedImports' capture: {name, parent, t, q, s, bone}) with a +// clip at a time, without touching the scene: the BVH writer and the clip-file writer sample with +// it. Tracks bind by node name (`Hips.quaternion`, `.bones[Hips].quaternion`, sanitized names). + +import * as THREE from 'three'; + +/** + * @typedef {{name: string, parent: number, t: number[], q: number[], s: number[], bone?: boolean}} RestRow + */ + +/** + * Bind a clip's tracks to rows. @param {RestRow[]} rows @param {THREE.AnimationClip} clip + * @returns {{row: number, property: 'quaternion'|'position'|'scale', interp: any}[]} + */ +export function bindClip(rows, clip) { + const exact = new Map(rows.map((r, i) => [r.name, i])); + const clean = new Map(rows.map((r, i) => [THREE.PropertyBinding.sanitizeNodeName(r.name), i])); + /** @type {{row: number, property: any, interp: any}[]} */ + const out = []; + for (const track of clip.tracks) { + let parsed; + try { + parsed = THREE.PropertyBinding.parseTrackName(track.name); + } catch { + continue; + } + const node = parsed.objectName === 'bones' ? String(parsed.objectIndex ?? '') : parsed.nodeName; + const row = exact.get(node) ?? clean.get(THREE.PropertyBinding.sanitizeNodeName(node)); + if (row === undefined || !['quaternion', 'position', 'scale'].includes(parsed.propertyName)) + continue; + out.push({ + row, + property: parsed.propertyName, + interp: /** @type {any} */ (track).createInterpolant() + }); + } + return out; +} + +/** + * The rows' locals at time t (rest where no track says otherwise). + * @param {RestRow[]} rows @param {ReturnType} bound @param {number} t + */ +export function localsAt(rows, bound, t) { + const L = rows.map((r) => ({ t: r.t.slice(), q: r.q.slice(), s: r.s.slice() })); + for (const b of bound) { + const v = b.interp.evaluate(t); + if (b.property === 'quaternion') L[b.row].q = [v[0], v[1], v[2], v[3]]; + else if (b.property === 'position') L[b.row].t = [v[0], v[1], v[2]]; + else L[b.row].s = [v[0], v[1], v[2]]; + } + return L; +} + +/** + * Root-space world matrices for given locals (rows parent-first). + * @param {RestRow[]} rows @param {{t: number[], q: number[], s: number[]}[]} L @returns {THREE.Matrix4[]} + */ +export function worldMatrices(rows, L) { + /** @type {THREE.Matrix4[]} */ + const M = []; + const v = new THREE.Vector3(); + const q = new THREE.Quaternion(); + const s = new THREE.Vector3(); + rows.forEach((r, i) => { + const m = new THREE.Matrix4().compose( + v.fromArray(L[i].t), + q.fromArray(L[i].q), + s.fromArray(L[i].s) + ); + if (r.parent >= 0) m.premultiply(M[r.parent]); + M.push(m); + }); + return M; +} + +/** frame times for a clip at fps (first and last frame included) @param {number} duration @param {number} fps */ +export function frameTimes(duration, fps) { + const frames = Math.max(1, Math.round(Math.max(0, duration) * fps) + 1); + const out = new Float32Array(frames); + for (let f = 0; f < frames; f++) out[f] = frames > 1 ? (duration * f) / (frames - 1) : 0; + return out; +} + +/** a stable signature of a skeleton: bone names + who their parent is (FNV-1a, hex) @param {RestRow[]} rows */ +export function skeletonSignature(rows) { + let h = 0x811c9dc5; + const text = rows.map((r) => r.name + '>' + (r.parent >= 0 ? rows[r.parent].name : '')).join('|'); + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h.toString(16).padStart(8, '0'); +} diff --git a/src/lib/export/exportBuilder.js b/src/lib/export/exportBuilder.js index a11bd2b67..a7218790e 100644 --- a/src/lib/export/exportBuilder.js +++ b/src/lib/export/exportBuilder.js @@ -327,6 +327,12 @@ export async function buildExport(opts, onProgress = () => {}) { if (cover) out['thumbnail.png'] = cover; } out['README.txt'] = enc.encode(readmeText({ title, preset: opts.preset, id, cdn: !!(paths.size && opts.useCdnForPacks) })); + // 57 M7: the credits every licensed asset (and applied clip) asks for, beside the readme + { + const { creditsFor } = await import('../sceneLicences.js'); + const credits = creditsFor(null); + if (credits) out['CREDITS.txt'] = enc.encode(credits); + } if (opts.preset === 'static') out['.nojekyll'] = new Uint8Array(0); // 6. validate with the proof's own rules, then zip diff --git a/src/lib/objectMenu.js b/src/lib/objectMenu.js index 5edbdbde3..9c901dfc4 100644 --- a/src/lib/objectMenu.js +++ b/src/lib/objectMenu.js @@ -19,8 +19,8 @@ import { createJoint, detachJoints, jointsFor } from './joints'; import { addParticlesPreset, removeObjectParticles, burstObjectParticles } from './particleActions'; import { PARTICLE_PRESETS } from './particlePresets'; import { SAVE_AS_FORMATS, saveSelectionAs } from './saveAs'; -import { restRowsOf } from './animatedImports'; -import { openApplyClip } from './characterUi.js'; +import { restRowsOf, hasAnimatedImport } from './animatedImports'; +import { openApplyClip, openDownload } from './characterUi.js'; import { enterEditMode } from './meshEdit'; // 24-E3: a group's origin from its context menu, and the set's pivot point import { originPreset, resetOrigin } from './objectOrigin'; @@ -725,6 +725,17 @@ export function buildObjectMenuItems(uuid, opts = {}) { { label: 'Physics & effects', icon: 'atom', children: behaviour }, // 37 R4 (1.26): a placed prefab copy's "Prefab: ▸" sits beside Save as… ▸ ...(multi ? [] : prefabInstanceMenu(object, group)), + // 57 M9: a model that rides its bytes (a rig, an animated import) downloads WITH its clips + ...(targets.some((t) => hasAnimatedImport(t)) + ? [ + { + label: 'Download…' + suffix, + icon: 'arrow-down-to-line', + tooltip: 'Download the model with the clips you pick: one GLB, model + .tpanim clips, or the model only — or one clip as .tpanim / .vrma / .bvh', + action: () => openDownload(targets.filter((t) => hasAnimatedImport(t))) + } + ] + : []), { // R22 round 11 (user): "I would like to be able to save prefabs as they are now with // right click 'Save as...'". The single "Save as prefab" row became a submenu whose diff --git a/src/lib/sceneLicences.js b/src/lib/sceneLicences.js new file mode 100644 index 000000000..e9ac02265 --- /dev/null +++ b/src/lib/sceneLicences.js @@ -0,0 +1,57 @@ +// 57 M7 — the licences a set of scene objects carries, for the Export / Download / Publish warnings +// and the CREDITS a bundle writes. An object counts with its own provenance AND the licences of the +// clips applied to it (a CC-BY walk on a CC0 character still needs its credit), and a group counts +// with everything under it. Reads the scene; replicates nothing. + +import { get } from 'svelte/store'; +import { objectsGroup } from '../stores/sceneStore'; +import { collectLicenceWarnings, creditsText, normalizeLicence } from './provenance.js'; + +/** @typedef {{uuid: string, name: string, userData: any, extra: any[]}} LicensedAsset */ + +/** + * Every object (and descendant) with a provenance record or applied-clip licences. + * @param {string[] | null} [uuids] null = the whole scene @returns {LicensedAsset[]} + */ +export function licensedAssets(uuids = null) { + const group = /** @type {any} */ (get(objectsGroup)); + if (!group) return []; + const roots = uuids + ? uuids.map((u) => group.getObjectByProperty('uuid', u)).filter(Boolean) + : group.children; + /** @type {LicensedAsset[]} */ + const out = []; + const seen = new Set(); + for (const root of roots) { + root.traverse((/** @type {any} */ o) => { + if (seen.has(o.uuid)) return; + seen.add(o.uuid); + const ud = o.userData ?? {}; + const clips = Array.isArray(ud.humanoid?.clips) ? ud.humanoid.clips : []; + const extra = clips + .map((/** @type {any} */ c) => normalizeLicence(c?.licence)) + .filter(Boolean); + if (!ud.provenance && !extra.length) return; + out.push({ uuid: o.uuid, name: o.name || o.type || 'Object', userData: ud, extra }); + }); + } + return out; +} + +/** @param {string[] | null} uuids @param {'export'|'download'|'publish'} where */ +export function licenceWarningsFor(uuids, where) { + return collectLicenceWarnings(licensedAssets(uuids), where); +} + +/** the CREDITS.txt body for these objects ('' = nothing to credit) @param {string[] | null} [uuids] */ +export function creditsFor(uuids = null) { + const assets = licensedAssets(uuids); + // an applied clip is credited as its own line + const clipRows = assets.flatMap((a) => + a.extra.map((lic) => ({ + name: (lic.title || 'Animation clip') + ' (on ' + a.name + ')', + userData: { provenance: [{ tool: 'retarget', licence: lic, date: '' }] } + })) + ); + return creditsText([...assets, ...clipRows]); +} diff --git a/tests/e2e/character-download.test.cjs b/tests/e2e/character-download.test.cjs new file mode 100644 index 000000000..a1d6e8997 --- /dev/null +++ b/tests/e2e/character-download.test.cjs @@ -0,0 +1,317 @@ +// 57 M9 + M7 — Download… a character with its clips: layout (a) one GLB re-imports with EVERY clip +// (its own + one applied from the avatar motions, baked onto its skeleton), (b) gives the model + +// one .tpanim per clip, a clip as .bvh reads back (three's BVHLoader) with the same joint positions +// at frame 0, .vrma names the human bones, FBX / USD are listed but off. A VRM whose meta says no +// redistribution shows the licence warning in Download, Export and Publish. The writers are lazy. +const h = require('./helpers.cjs'); +const fs = require('fs'); +const { mixamoGlb, toVrm, readGlb } = require('./characterFixtures.cjs'); + +const states = (page) => + page.evaluate( + () => + new Promise((r) => + window.__stores.animatedImports.animatedObjects.subscribe((m) => + r(JSON.parse(JSON.stringify(m))) + )() + ) + ); +const uuidOf = (page, name) => + page.evaluate( + (name) => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => + r(g?.children.find((c) => c.name === name)?.uuid ?? null) + )() + ), + name + ); +const loaded = (page, re) => + page.evaluate( + (src) => performance.getEntriesByType('resource').some((e) => new RegExp(src).test(e.name)), + re + ); + +/** click and capture the download's bytes */ +async function download(page, selector) { + const [dl] = await Promise.all([ + page.waitForEvent('download', { timeout: 30000 }), + page.click(selector) + ]); + return { name: dl.suggestedFilename(), bytes: fs.readFileSync(await dl.path()) }; +} + +async function openDownload(page, uuid) { + await page.evaluate((u) => window.__stores.characterUi.downloadRequest.set(null), uuid); + await page.waitForTimeout(150); + await page.evaluate((u) => window.__stores.characterUi.openDownload([u]), uuid); + await page.waitForSelector('#download-dialog #download-go', { timeout: 20000 }); +} + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const { page } = A; + h.check( + !(await loaded(page, 'characterDownload|bvhWriter|glbEdit|clipFiles|three-vrm|vrmRuntime')), + 'boot: no download / BVH / VRM code is loaded' + ); + + // a Mixamo-named rig with its OWN clip (Kick) + an APPLIED one (Walking_A) + const rig = await mixamoGlb(page, { clip: true }); + await page.evaluate(async (bytes) => { + await window.__stores.fileHandler.importFile( + new File([new Uint8Array(bytes)], 'hero.glb'), + 'Hero', + undefined, + [0, 0, 0] + ); + }, rig); + await h.eventually( + () => uuidOf(page, 'Hero'), + (u) => !!u, + 'the rig is placed' + ); + const hero = await uuidOf(page, 'Hero'); + await page.evaluate( + (u) => window.__stores.humanoidRig.applyClipTo([u], { lib: 'avatars', clip: 'Walking_A' }), + hero + ); + await h.eventually( + () => states(page), + (m) => m[hero]?.clips?.includes('Kick') && m[hero]?.clips?.includes('Walking_A'), + 'premise: Hero has its own Kick + the applied Walking_A' + ); + + // 1. through the Inspector: Character ▸ Download…, layout (a) + await page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), hero); + await page.waitForSelector('#character-download', { timeout: 15000 }); + await page.click('#character-download'); + await page.waitForSelector('#download-dialog #download-clips [data-clip="Walking_A"]', { + timeout: 20000 + }); + h.check( + await loaded(page, 'characterDownload'), + 'the download code loaded when the dialog opened' + ); + h.check( + (await page.locator('#download-dialog #download-clips .dl-row').count()) === 2, + 'the dialog lists both clips' + ); + const a = await download(page, '#download-go'); + h.check(a.name === 'Hero.glb', `(a) downloads one GLB (${a.name})`); + const aDoc = readGlb(a.bytes); + const aNames = (aDoc.json.animations ?? []).map((x) => x.name).sort(); + h.check( + JSON.stringify(aNames) === '["Kick","Walking_A"]', + `(a) the GLB embeds every clip (${aNames})` + ); + h.check( + aDoc.json.extras?.theprototype?.provenance?.some((p) => p.tool === 'download'), + '(a) the GLB records its provenance in extras' + ); + const orig = readGlb(rig); + h.check( + JSON.stringify(aDoc.json.skins) === JSON.stringify(orig.json.skins) && + aDoc.json.nodes.length === orig.json.nodes.length, + '(a) the model itself is untouched (same skins, same nodes)' + ); + // ...and it re-imports with every clip + await page.evaluate(async (bytes) => { + await window.__stores.fileHandler.importFile( + new File([new Uint8Array(bytes)], 'Hero.glb'), + 'HeroAgain', + undefined, + [2, 0, 0] + ); + }, Array.from(a.bytes)); + await h.eventually( + () => uuidOf(page, 'HeroAgain'), + (u) => !!u, + '(a) re-imported' + ); + const again = await uuidOf(page, 'HeroAgain'); + await h.eventually( + () => states(page), + (m) => m[again]?.clips?.includes('Kick') && m[again]?.clips?.includes('Walking_A'), + '(a) the re-import plays BOTH clips as its own' + ); + + // 2. layout (b): model + one .tpanim per clip + await openDownload(page, hero); + await page.click('#download-layout-b'); + const b = await download(page, '#download-go'); + h.check(b.name === 'Hero.zip', `(b) downloads a zip (${b.name})`); + const { unzipSync } = require('fflate'); + const files = unzipSync(new Uint8Array(b.bytes)); + const list = Object.keys(files).sort(); + h.check( + JSON.stringify(list) === '["Hero-Kick.tpanim","Hero-Walking_A.tpanim","Hero.glb"]', + `(b) model + one .tpanim per clip (${list})` + ); + h.check( + !(readGlb(files['Hero.glb']).json.animations ?? []).length, + '(b) the model in the zip carries no clips' + ); + const t = readGlb(files['Hero-Walking_A.tpanim']).json; + h.check( + t.extras?.theprototype?.kind === 'clip' && + t.extras.theprototype.format === 1 && + t.animations?.length === 1, + '(b) a .tpanim is a skeleton + one animation with extras.theprototype' + ); + h.check( + t.extensionsUsed?.includes('VRMC_vrm_animation') && + t.extensions.VRMC_vrm_animation.humanoid.humanBones.hips != null, + '(b) a humanoid .tpanim is VRM Animation inside' + ); + + // 3. one clip as .bvh: same joint positions at frame 0 (three's BVHLoader in node) + await openDownload(page, hero); + await page.click('#download-mode-clip'); + await page.selectOption('#download-clip', 'Walking_A'); + await page.check('#download-formats input[value="bvh"]'); + h.check( + (await page.locator('#download-formats [data-format="fbx"] input').isDisabled()) && + (await page.locator('#download-formats [data-format="usd"] input').isDisabled()), + 'FBX / USD are listed, off, with the reason' + ); + const bvh = await download(page, '#download-go'); + h.check( + bvh.name === 'Hero-Walking_A.bvh' && bvh.bytes.toString().startsWith('HIERARCHY'), + `a clip downloads as BVH (${bvh.name})` + ); + h.check(await loaded(page, 'bvhWriter'), 'the BVH writer loaded only for the BVH download'); + const want = await page.evaluate((u) => { + const THREE = window.__stores.THREE; + const ai = window.__stores.animatedImports; + const rows = ai.restRowsOf(u); + const clip = ai.clipOf(u, 'Walking_A'); + const L = rows.map((r) => ({ t: r.t.slice(), q: r.q.slice(), s: r.s.slice() })); + for (const tr of clip.tracks) { + const [node, prop] = tr.name.split(/\.(?=[^.]+$)/); + const i = rows.findIndex((r) => r.name === node); + if (i < 0) continue; + const v = Array.from(tr.values.slice(0, prop === 'quaternion' ? 4 : 3)); + if (prop === 'quaternion') L[i].q = v; + else if (prop === 'position') L[i].t = v; + } + const M = []; + const out = {}; + rows.forEach((r, i) => { + const m = new THREE.Matrix4().compose( + new THREE.Vector3(...L[i].t), + new THREE.Quaternion(...L[i].q), + new THREE.Vector3(...L[i].s) + ); + if (r.parent >= 0) m.premultiply(M[r.parent]); + M.push(m); + if (r.bone) out[r.name] = new THREE.Vector3().setFromMatrixPosition(m).toArray(); + }); + return out; + }, hero); + const THREE = await import('three'); + const { BVHLoader } = await import('three/examples/jsm/loaders/BVHLoader.js'); + const res = new BVHLoader().parse(bvh.bytes.toString()); + const mixer = new THREE.AnimationMixer(res.skeleton.bones[0]); + mixer.clipAction(res.clip).play(); + mixer.setTime(0); + res.skeleton.bones[0].updateMatrixWorld(true); + let worst = 0; + let compared = 0; + for (const b of res.skeleton.bones) { + const w = want[b.name]; + if (!w) continue; + compared++; + worst = Math.max( + worst, + new THREE.Vector3().setFromMatrixPosition(b.matrixWorld).distanceTo(new THREE.Vector3(...w)) + ); + } + h.check( + compared >= 20 && worst < 1e-3, + `.bvh re-imports with the same pose at frame 0 (${compared} joints, worst ${worst.toExponential(2)})` + ); + + // 4. .vrma + await openDownload(page, hero); + await page.click('#download-mode-clip'); + await page.selectOption('#download-clip', 'Kick'); + await page.check('#download-formats input[value="vrma"]'); + const vrma = await download(page, '#download-go'); + const vj = readGlb(vrma.bytes).json; + h.check( + vrma.name === 'Hero-Kick.vrma' && + vj.extensions?.VRMC_vrm_animation?.humanoid?.humanBones?.leftUpperLeg != null, + `.vrma names the human bones (${vrma.name})` + ); + + // 5. licence warnings: a VRM that says "no redistribution" + const vrm = toVrm(await mixamoGlb(page, { hair: true }), { version: '1' }); + await page.evaluate(async (bytes) => { + await window.__stores.fileHandler.importFile( + new File([new Uint8Array(bytes)], 'locked.vrm'), + 'Locked', + undefined, + [-2, 0, 0] + ); + }, vrm); + await h.eventually( + () => uuidOf(page, 'Locked'), + (u) => !!u, + 'the restricted VRM is placed' + ); + const locked = await uuidOf(page, 'Locked'); + await h.eventually( + () => page.evaluate((u) => window.__stores.animatedImports.vrmDebug(u)?.vrm, locked), + (v) => v === true, + 'premise: it is a VRM' + ); + await openDownload(page, locked); + const dlWarn = await page.evaluate(() => { + const el = document.querySelector('#licence-warnings-download'); + return el ? { restricted: el.getAttribute('data-restricted'), text: el.textContent } : null; + }); + h.check( + dlWarn && + Number(dlWarn.restricted) >= 1 && + /Locked/.test(dlWarn.text) && + /passing the file on/.test(dlWarn.text), + `Download warns (${dlWarn?.text?.replace(/\s+/g, ' ').slice(0, 120)})` + ); + h.check( + !(await page.locator('#download-go').isDisabled()), + '...and still lets you download (warn, never block)' + ); + await page.evaluate(() => window.__stores.characterUi.downloadRequest.set(null)); + // Export + Publish tabs + const opened = await page.evaluate(() => { + const s = window.__stores.exportStores; + if (!s?.publishExportOpen) return false; + s.publishExportTab.set('export'); + s.publishExportOpen.set(true); + return true; + }); + if (opened) { + await page.waitForSelector('#licence-warnings-export', { timeout: 20000 }).catch(() => null); + const ex = await page.evaluate( + () => document.querySelector('#licence-warnings-export')?.textContent ?? '' + ); + h.check( + /Locked/.test(ex) && /redistribut/.test(ex), + `Export warns (${ex.replace(/\s+/g, ' ').slice(0, 100)})` + ); + await page.evaluate(() => window.__stores.exportStores.publishExportTab.set('publish')); + await page.waitForSelector('#licence-warnings-publish', { timeout: 20000 }).catch(() => null); + h.check( + /Locked/.test( + await page.evaluate( + () => document.querySelector('#licence-warnings-publish')?.textContent ?? '' + ) + ), + 'Publish warns' + ); + } else h.check(false, 'premise: the Publish / Export stores are on the debug hook'); + + await h.finish(browser); +}); diff --git a/tests/unit/characterDownload.test.js b/tests/unit/characterDownload.test.js new file mode 100644 index 000000000..3bed028ef --- /dev/null +++ b/tests/unit/characterDownload.test.js @@ -0,0 +1,262 @@ +// @ts-nocheck — drives the writers on the shipped KayKit GLB and hand-made rigs +// 57 M9: clips added to a model's OWN bytes (the rest of the file untouched), clips as their own +// files (.tpanim / .glb / .vrma shape), and the BVH writer round trip through three's BVHLoader: +// every joint lands where the rig puts it, at frame 0 and mid-clip. +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { BVHLoader } from 'three/examples/jsm/loaders/BVHLoader.js'; +import { + readGlb, + writeGlb, + nodeIndexByName, + isGlb, + animationNames +} from '../../src/lib/download/glbEdit.js'; +import { writeClipFile, glbWithClips, plainClip } from '../../src/lib/download/clipFiles.js'; +import { writeBvh } from '../../src/lib/download/bvhWriter.js'; +import { + bindClip, + localsAt, + worldMatrices, + skeletonSignature +} from '../../src/lib/download/rigSample.js'; + +globalThis.self = globalThis; +const knightBytes = () => { + const b = fs.readFileSync(new URL('../../static/avatars/knight.glb', import.meta.url)); + return new Uint8Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); +}; +const parse = (bytes) => + new Promise((resolve, reject) => { + const err = console.error; + const warn = console.warn; + console.error = () => {}; + console.warn = () => {}; + const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + new GLTFLoader().parse( + u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength), + '', + (g) => { + console.error = err; + console.warn = warn; + resolve(g); + }, + (e) => { + console.error = err; + console.warn = warn; + reject(e); + } + ); + }); + +const q = (axis, deg) => + new THREE.Quaternion() + .setFromAxisAngle(new THREE.Vector3(...axis).normalize(), (deg * Math.PI) / 180) + .toArray(); + +/** a rig with a scaled non-bone Armature (Mixamo FBX style), rest rotations and a hips walk */ +function rig() { + const rows = [ + { + name: 'Armature', + parent: -1, + t: [0, 0, 0], + q: q([1, 0, 0], 0), + s: [0.01, 0.01, 0.01], + bone: false + }, + { name: 'Hips', parent: 0, t: [0, 100, 0], q: q([0, 1, 0], 10), s: [1, 1, 1], bone: true }, + { name: 'Spine', parent: 1, t: [0, 10, 0], q: q([1, 0, 0], 5), s: [1, 1, 1], bone: true }, + { name: 'Head', parent: 2, t: [0, 50, 0], q: q([0, 0, 1], 0), s: [1, 1, 1], bone: true }, + { name: 'LeftArm', parent: 2, t: [20, 30, 0], q: q([0, 0, 1], -40), s: [1, 1, 1], bone: true }, + { + name: 'LeftForeArm', + parent: 4, + t: [30, 0, 0], + q: q([0, 1, 0], 15), + s: [1, 1, 1], + bone: true + }, + { + name: 'LeftUpLeg', + parent: 1, + t: [10, -5, 0], + q: q([0, 0, 1], 180), + s: [1, 1, 1], + bone: true + }, + { name: 'LeftLeg', parent: 6, t: [0, 45, 0], q: q([1, 0, 0], 3), s: [1, 1, 1], bone: true } + ]; + const arm0 = new THREE.Quaternion().fromArray(rows[4].q); + const arm1 = new THREE.Quaternion().fromArray(q([1, 0, 0], 70)).multiply(arm0); + const clip = new THREE.AnimationClip('Wave', 2, [ + new THREE.QuaternionKeyframeTrack( + 'LeftArm.quaternion', + [0, 1, 2], + [...arm0.toArray(), ...arm1.toArray(), ...arm0.toArray()] + ), + new THREE.QuaternionKeyframeTrack( + '.bones[LeftLeg].quaternion', + [0, 2], + [...rows[7].q, ...q([1, 0, 0], 60)] + ), + new THREE.VectorKeyframeTrack('Hips.position', [0, 2], [0, 100, 0, 0, 100, 80]) + ]); + return { rows, clip }; +} + +describe('57 M9 GLB edit', () => { + it('read -> write keeps the file (JSON equal, BIN byte-identical)', () => { + const src = knightBytes(); + expect(isGlb(src)).toBe(true); + const doc = readGlb(src); + const out = readGlb(writeGlb(doc)); + expect(out.json).toEqual(readGlb(src).json); + expect(Buffer.from(out.bin).equals(Buffer.from(readGlb(src).bin))).toBe(true); + }); + + it('node names resolve the way three names them ("upperarm.l" -> "upperarml")', () => { + const idx = nodeIndexByName(readGlb(knightBytes()).json); + expect(idx.get('upperarml')).toBe(idx.get('upperarm.l')); + expect(idx.get('upperarml')).toBeGreaterThanOrEqual(0); + }); + + it("layout (a): a clip added to the model's own bytes parses back and binds to its bones", async () => { + const clip = new THREE.AnimationClip('Wave', 1, [ + new THREE.QuaternionKeyframeTrack( + 'upperarml.quaternion', + [0, 1], + [...q([0, 0, 1], 0), ...q([0, 0, 1], 90)] + ) + ]); + const { bytes, added, dropped } = glbWithClips(knightBytes(), [clip], null); + expect(added).toBe(1); + expect(dropped).toBe(0); + const g = await parse(bytes); + expect(g.animations.map((a) => a.name)).toEqual(['Wave']); + expect(g.animations[0].tracks[0].name).toBe('upperarml.quaternion'); + expect(Array.from(g.animations[0].tracks[0].values.slice(4))).toEqual( + q([0, 0, 1], 90).map((v) => Math.fround(v)) + ); + // layout (c) of the result: strip every clip, keep the model + const stripped = glbWithClips(bytes, [], []); + expect(animationNames(readGlb(stripped.bytes).json)).toEqual([]); + expect(readGlb(stripped.bytes).json.skins.length).toBe( + readGlb(knightBytes()).json.skins.length + ); + }); +}); + +describe('57 M9 clip files', () => { + it('a non-humanoid clip: skeleton + one animation + extras.theprototype', async () => { + const { rows, clip } = rig(); + const bytes = writeClipFile(rows, clip, { fps: 30, loop: true }); + const doc = readGlb(bytes); + expect(doc.json.extras.theprototype).toMatchObject({ + format: 1, + kind: 'clip', + skeleton: { sig: skeletonSignature(rows), humanoid: false }, + fps: 30, + frames: 61, + loop: true, + rootMotion: false + }); + expect(doc.json.extensionsUsed).toBeUndefined(); + const g = await parse(bytes); + expect(g.animations).toHaveLength(1); + expect(g.animations[0].tracks.map((t) => t.name).sort()).toEqual([ + 'Hips.position', + 'LeftArm.quaternion', + 'LeftLeg.quaternion' + ]); + expect(g.scene.getObjectByName('LeftForeArm')).toBeTruthy(); + }); + + it('a humanoid clip is VRM Animation: VRMC_vrm_animation names the human bones', () => { + const { rows, clip } = rig(); + const doc = readGlb( + writeClipFile(rows, clip, { + map: { hips: 'Hips', spine: 'Spine', head: 'Head', leftUpperArm: 'LeftArm' } + }) + ); + expect(doc.json.extensionsUsed).toEqual(['VRMC_vrm_animation']); + expect(doc.json.extensions.VRMC_vrm_animation).toEqual({ + specVersion: '1.0', + humanoid: { + humanBones: { + hips: { node: 1 }, + spine: { node: 2 }, + head: { node: 3 }, + leftUpperArm: { node: 4 } + } + } + }); + expect(doc.json.extras.theprototype.skeleton.humanoid).toBe(true); + }); + + it('plainClip resamples a smooth track to linear', () => { + const t = new THREE.VectorKeyframeTrack( + 'Hips.position', + [0, 1], + [0, 0, 0, 1, 1, 1], + THREE.InterpolateSmooth + ); + const p = plainClip(new THREE.AnimationClip('s', 1, [t]), { fps: 10 }); + expect(p.channels[0].times.length).toBe(11); + expect(p.channels[0].step).toBeUndefined(); + }); +}); + +describe('57 M9 BVH writer round trip', () => { + it('every joint lands where the rig puts it (frame 0, mid-clip, last frame), through BVHLoader', () => { + const { rows, clip } = rig(); + const { text, joints, frames } = writeBvh(rows, clip, { fps: 30 }); + expect(joints).toEqual([ + 'Hips', + 'Spine', + 'Head', + 'LeftArm', + 'LeftForeArm', + 'LeftUpLeg', + 'LeftLeg' + ]); + expect(frames).toBe(61); + expect(text.startsWith('HIERARCHY\nROOT Hips')).toBe(true); + const res = new BVHLoader().parse(text); + const bones = res.skeleton.bones; + const mixer = new THREE.AnimationMixer(bones[0]); + mixer.clipAction(res.clip).play(); + const bound = bindClip(rows, clip); + for (const t of [0, 1, 2]) { + mixer.setTime(Math.min(t, res.clip.duration - 1e-6)); + bones[0].updateMatrixWorld(true); + const M = worldMatrices(rows, localsAt(rows, bound, t)); + for (const name of joints) { + const want = new THREE.Vector3().setFromMatrixPosition( + M[rows.findIndex((r) => r.name === name)] + ); + const got = new THREE.Vector3().setFromMatrixPosition( + bones.find((b) => b.name === name).matrixWorld + ); + expect(got.distanceTo(want), name + ' @' + t).toBeLessThan(1e-3); + } + } + }); + + it('names a BVH tool can read; a lone IK helper beside the hips is not the root', () => { + const { rows, clip } = rig(); + rows.push({ + name: 'IK foot.l', + parent: 0, + t: [0, 0, 0], + q: [0, 0, 0, 1], + s: [1, 1, 1], + bone: true + }); + const { text } = writeBvh(rows, clip); + expect(text).toMatch(/^ROOT Hips$/m); + expect(text).not.toMatch(/IK foot/); + }); +}); From 378b92a06765bdb26a318509a7cbd6365cfe4995 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 05:33:13 +0300 Subject: [PATCH 05/11] [feat] 57-chars M6 rig fix-ups (lazy) + the M2 humanoid map editor in the Skeleton view - rig/rigOps.js: rename / move in the rest pose / add / delete a bone, mirror left <-> right, write skin weights - each edits the GLB itself and KEEPS THE BIND (newIBM = inv(newWorld) * oldWorld * oldIBM), so every skinned vertex stays where it was - rig/weightsCore.js (leaf) + weightsWorker.js: automatic weights = bone-heat seeding (nearest bone) diffused over the welded SURFACE (never across air), smooth, normalize (<= 4), the brush - rig/rigFixes.js: one operation = one new file, replaced on every peer (objectfile replace:true) and undoable (history 'rigbytes'); a brush stroke paints locally and is written on lift; test pose local; skeleton helper at the scene root - SkeletonWindow (floating, lazy): Humanoid map (per-bone select, detected vs fixed, reset; a replicated config), Bones, Weights + brush; Inspector > Character > Skeleton..., object menu - animatedImports: glTF primitive stamps per skinned mesh, replaceAnimatedBytes - unit: rigFixes (10: bind kept for every op, surface heat); e2e: character-skeleton Co-Authored-By: Claude Opus 5.5 (1M context) --- .../characters/CharacterDialogs.svelte | 7 +- .../characters/SkeletonWindow.svelte | 501 ++++++++++++++++++ .../menu/inspector/CharacterSection.svelte | 11 +- src/components/ui/Icon.svelte | 2 + src/lib/animatedImports.js | 136 ++++- src/lib/download/glbEdit.js | 65 +++ src/lib/fileHandler.svelte.js | 4 +- src/lib/objectMenu.js | 8 +- src/lib/rig/rigFixes.js | 435 +++++++++++++++ src/lib/rig/rigOps.js | 325 ++++++++++++ src/lib/rig/weightsCore.js | 265 +++++++++ src/lib/rig/weightsWorker.js | 25 + src/lib/vrm/vrmRuntime.js | 4 +- tests/e2e/character-skeleton.test.cjs | 261 +++++++++ tests/unit/rigFixes.test.js | 235 ++++++++ 15 files changed, 2263 insertions(+), 21 deletions(-) create mode 100644 src/components/characters/SkeletonWindow.svelte create mode 100644 src/lib/rig/rigFixes.js create mode 100644 src/lib/rig/rigOps.js create mode 100644 src/lib/rig/weightsCore.js create mode 100644 src/lib/rig/weightsWorker.js create mode 100644 tests/e2e/character-skeleton.test.cjs create mode 100644 tests/unit/rigFixes.test.js diff --git a/src/components/characters/CharacterDialogs.svelte b/src/components/characters/CharacterDialogs.svelte index 7195af757..f469078db 100644 --- a/src/components/characters/CharacterDialogs.svelte +++ b/src/components/characters/CharacterDialogs.svelte @@ -1,7 +1,7 @@ {#if $applyClipRequest} @@ -14,3 +14,8 @@ {/await} {/if} +{#if $skeletonTarget} + {#await import('./SkeletonWindow.svelte') then m} + + {/await} +{/if} diff --git a/src/components/characters/SkeletonWindow.svelte b/src/components/characters/SkeletonWindow.svelte new file mode 100644 index 000000000..51e41ced8 --- /dev/null +++ b/src/components/characters/SkeletonWindow.svelte @@ -0,0 +1,501 @@ + + + { + if (e.key === 'Escape') close(); + }} + oncancel={close} + outsideclose={false} + width="sm" + frame={false} + class="sk-floating" + id="skeleton-window" +> + {#snippet bar()} + (tab = id)} + /> + {/snippet} + {#if info} +
+ {#if tab === 'map'} +

+ {human?.ok + ? 'A full humanoid' + : 'Missing: ' + (human?.missing ?? REQUIRED_BONES).map(boneLabel).join(', ')} — {Object.keys( + human?.map ?? {} + ).length} of 55 bones mapped ({human?.via === 'file' + ? 'from the file' + : human?.via === 'structure' + ? 'from its shape — check it' + : 'from bone names'}). A fix here changes no file. +

+
+ {#each rows as b (b.name)} + {@const cur = human?.map[b.name] ?? ''} + {@const fixed = human ? b.name in human.overrides : false} + + {/each} +
+
+ + +
+ {:else} +
+ {#each info.bones as b (b.name)} + + {/each} +
+ {#if !info.editable} +

+ This model is a {info.kind.toUpperCase()} file: rig fix-ups edit GLB / VRM files. Download + it as GLB and import that. +

+ {:else if tab === 'bones'} + {#if $pickedBone} +
+ + +
+
+ Rest + {#each [0, 1, 2] as k (k)} + + (restT = restT.map((v, i) => (i === k ? +e.currentTarget.value : v)))} + /> + {/each} + +
+
+ + + +
+ {:else} +

Pick a bone in the list.

+ {/if} +
+ + +
+ {:else} +
+ + + +
+ + {#if brushOn} +
+ Radius +
+
+ Strength + +
+ {/if} +
+ Test pose + + {bend}° +
+

+ Bends the picked bone here only, to see the weights work. Each stroke is saved and sent + when you lift. +

+ {/if} + {/if} +
+ {/if} +
+ + diff --git a/src/components/menu/inspector/CharacterSection.svelte b/src/components/menu/inspector/CharacterSection.svelte index 93601bbaf..612e498ac 100644 --- a/src/components/menu/inspector/CharacterSection.svelte +++ b/src/components/menu/inspector/CharacterSection.svelte @@ -8,7 +8,7 @@ import UiButton from '../../ui/Button.svelte'; import { sceneRevision } from '../../../stores/sceneStore'; import { animatedObjects, vrmExpressionNames, setVrmExpression } from '$lib/animatedImports'; - import { openApplyClip, openDownload } from '$lib/characterUi.js'; + import { openApplyClip, openDownload, openSkeleton } from '$lib/characterUi.js'; import { licenceOf, licenceLabel, licenceFlags, normalizeProvenance } from '$lib/provenance'; /** @type {{ object: any }} */ @@ -87,7 +87,7 @@
{#if !rig.ok}

- Not a full humanoid yet — missing {rig.missingText}. Fix the map in Skeleton…. + Not a full humanoid yet — missing {rig.missingText}. Fix the map in Skeleton…

{/if} {#if rig.clips.length} @@ -116,6 +116,13 @@ title="Play a clip from another model or the built-in motions on this character" onclick={() => openApplyClip({ targets: [object.uuid] })}>Apply clip… + openSkeleton(object.uuid)}>Skeleton… { + const a = assoc.get(o); + if (o.isMesh && a && a.meshes !== undefined && a.primitives !== undefined) + o.userData.gltfPrimitive = [a.meshes, a.primitives]; + }); +} + /** @param {string} uuid */ export function hasAnimatedImport(uuid) { return fileBytes.has(uuid); @@ -296,7 +314,14 @@ export function isVrmImport(uuid) { /** a VRM's runtime facts for the suites (null = the runtime never loaded) @param {string} uuid */ export function vrmDebug(uuid) { - return vrmMod ? { vrm: vrmMod.isVrm(uuid), joints: vrmMod.springJointCount(uuid), expressions: vrmMod.expressionNames(uuid), value: (/** @type {string} */ n) => vrmMod.expressionValue(uuid, n) } : null; + return vrmMod + ? { + vrm: vrmMod.isVrm(uuid), + joints: vrmMod.springJointCount(uuid), + expressions: vrmMod.expressionNames(uuid), + value: (/** @type {string} */ n) => vrmMod.expressionValue(uuid, n) + } + : null; } /** the expressions a VRM offers ([] for anything else) @param {string} uuid @returns {string[]} */ @@ -374,7 +399,9 @@ export function registerAnimatedImport(root, animations, bytes, kind = 'gltf') { // 57 M2: a humanoid config (clips applied from another rig) is rebuilt on THIS peer; the code // for it loads only for a model that has one if (root.userData?.humanoid?.clips?.length) - void vrmReady.then(() => import('./humanoid/humanoidRig.js')).then((m) => m.rebuildHumanoidClips(root.uuid)); + void vrmReady + .then(() => import('./humanoid/humanoidRig.js')) + .then((m) => m.rebuildHumanoidClips(root.uuid)); for (const fn of registeredListeners) { try { fn(root.uuid); @@ -460,7 +487,8 @@ export function setAnimationState(uuid, next, replicate = true) { // or the Inspector is a LOCAL PREVIEW — never sent, never saved, back to rest at the end // (the user's rule: previewing a door in Edit must not leave it open in the file) if (behaviors.has(uuid)) { - if (replicate && (next.playing || next.clip)) behaviorHooks.preview?.(uuid, next.clip ?? entry.clip); + if (replicate && (next.playing || next.clip)) + behaviorHooks.preview?.(uuid, next.clip ?? entry.clip); return; } const state = { ...entry, ...next }; @@ -497,7 +525,14 @@ export function setAnimationState(uuid, next, replicate = true) { export async function applyObjectFile(data) { const group = get(objectsGroup); if (!group) return; - const held = group.getObjectByProperty('uuid', data.uuid); + let held = group.getObjectByProperty('uuid', data.uuid); + // 57 M6: a rig fix REPLACES the bytes — drop the held tree and parse the new file in its place + // (its transform, config and clip state ride the message like any arrival) + if (held && data.replace) { + held.parent?.remove(held); + dropAnimatedImport(data.uuid); + held = null; + } if (held) { // R22 round 32 — AN ARRIVAL HEAL, and deliberately NOT a re-parse. A rig's // geometry is not scene-editable (it travels as its original file bytes precisely @@ -509,17 +544,20 @@ export async function applyObjectFile(data) { if (data.name) held.name = data.name; if (data.lod && typeof data.lod === 'object') held.userData.lod = data.lod; applyRootData(held, data); - if (data.humanoid) void import('./humanoid/humanoidRig.js').then((m) => m.rebuildHumanoidClips(data.uuid)); + if (data.humanoid) + void import('./humanoid/humanoidRig.js').then((m) => m.rebuildHumanoidClips(data.uuid)); if (data.pos) held.position.fromArray(data.pos); if (data.rot) held.rotation.set(data.rot[0], data.rot[1], data.rot[2]); if (data.scale) held.scale.fromArray(data.scale); pokeScene(); - if (data.behaviorState && behaviors.has(data.uuid)) behaviorHooks.apply?.(data.uuid, data.behaviorState); + if (data.behaviorState && behaviors.has(data.uuid)) + behaviorHooks.apply?.(data.uuid, data.behaviorState); else if (data.anim) setAnimationState(data.uuid, data.anim, false); return; } try { - const bytes = data.buffer instanceof ArrayBuffer ? data.buffer : data.buffer?.buffer ?? data.buffer; + const bytes = + data.buffer instanceof ArrayBuffer ? data.buffer : (data.buffer?.buffer ?? data.buffer); // absent kind = 'gltf' (every pre-17-D2 sender) const { root, animations } = await parseAnimatedBytes(bytes, data.kind); // 36 F20: a scene load restoring this rig was superseded while it parsed @@ -540,7 +578,12 @@ export async function applyObjectFile(data) { if (data.scale) root.scale.fromArray(data.scale); group.add(root); pokeScene(); - registerAnimatedImport(root, animations, bytes, data.kind === 'fbx' ? 'fbx' : data.kind === 'vrm' ? 'vrm' : 'gltf'); + registerAnimatedImport( + root, + animations, + bytes, + data.kind === 'fbx' ? 'fbx' : data.kind === 'vrm' ? 'vrm' : 'gltf' + ); if (behaviors.has(data.uuid)) { if (data.behaviorState) behaviorHooks.apply?.(data.uuid, data.behaviorState); } else if (data.anim) setAnimationState(data.uuid, data.anim, false); @@ -553,14 +596,16 @@ export async function applyObjectFile(data) { /** * Broadcast one animated root to a connection (or everyone) * @param {any} conn @param {any} root - * @param {{override?: boolean}} [opts] R22 round 32 — an ARRIVAL HEAL (see sendObject); - * absent for every other caller, and then the message is byte-identical to before. + * @param {{override?: boolean, replace?: boolean}} [opts] R22 round 32 — an ARRIVAL HEAL (see sendObject); + * absent for every other caller, and then the message is byte-identical to before. 57 M6 + * `replace`: new bytes for a model the receiver already holds (a rig fix). */ export function sendAnimatedImport(conn, root, opts = {}) { const state = get(animatedObjects)[root.uuid]; conn.send({ type: 'objectfile', ...(opts.override ? { override: true } : {}), + ...(opts.replace ? { replace: true } : {}), uuid: root.uuid, name: root.name, kind: animatedImportKind(root.uuid), @@ -668,7 +713,9 @@ export function animatedImportsSnapshot(group) { /** the reference an animated pack piece saves as, or null @param {any} root */ function animRefOf(root) { const ref = root?.userData?.animRef; - return ref && typeof ref.path === 'string' && ref.path ? { pack: String(ref.pack ?? ''), item: String(ref.item ?? ''), path: ref.path } : null; + return ref && typeof ref.path === 'string' && ref.path + ? { pack: String(ref.pack ?? ''), item: String(ref.item ?? ''), path: ref.path } + : null; } /** url -> the file's bytes (a failure is forgotten so a retry can win) @type {Map>} */ @@ -724,7 +771,11 @@ export async function animatedImportsRestore(entries, replicate = true, live = ( } catch (error) { if (!refFailures.has(entry.animRef.path)) { refFailures.add(entry.animRef.path); - showToast('Could not load "' + (entry.animRef.item || entry.name || 'a pack piece') + '" — its pack is unreachable.'); + showToast( + 'Could not load "' + + (entry.animRef.item || entry.name || 'a pack piece') + + '" — its pack is unreachable.' + ); } throw error; } @@ -843,3 +894,60 @@ export function recordAnimatedImport(root) { after: { present: true } }); } + +// ---- 57 M6: a rig fix REPLACES the model's bytes ---------------------------------------- +// A rig edit (a bone moved / added / deleted / renamed, new weights) rewrites the file, so the +// file is what changes: here and on every peer the old tree is dropped and the new bytes parsed +// at the same uuid, transform, config and clip state. Sent ONCE per operation (a brush stroke is +// one operation) and undoable — the history entry holds both files. + +/** + * @param {string} uuid @param {ArrayBuffer} bytes the new file + * @param {{history?: boolean, label?: string}} [opts] + */ +export async function replaceAnimatedBytes(uuid, bytes, opts = {}) { + const group = get(objectsGroup); + const old = group?.getObjectByProperty('uuid', uuid); + const before = fileBytes.get(uuid); + if (!old || !before) return false; + const kind = animatedImportKind(uuid); + const state = get(animatedObjects)[uuid]; + const data = { + uuid, + name: old.name, + kind, + buffer: bytes, + pos: old.position.toArray(), + rot: [old.rotation.x, old.rotation.y, old.rotation.z], + scale: old.scale.toArray(), + anim: state ? { clip: state.clip, playing: state.playing, speed: state.speed } : null, + ...behaviorFields(uuid), + ...rootDataFields(old) + }; + old.parent?.remove(old); + dropAnimatedImport(uuid); + await applyObjectFile(data); + const root = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + if (!root) return false; + /** @type {any} */ + const peer = get(peers); + if (peer) sendAnimatedImport(peer, root, { replace: true }); + if (opts.history !== false) + recordEntry({ + kind: 'rigbytes', + uuid, + label: opts.label ?? 'Rig edit', + before: { bytes: before }, + after: { bytes } + }); + return true; +} + +registerHistoryKind('rigbytes', (entry, state) => { + if (!get(objectsGroup)?.getObjectByProperty('uuid', entry.uuid)) { + showToast('Cannot undo/redo: the object no longer exists'); + return false; + } + void replaceAnimatedBytes(entry.uuid, state.bytes, { history: false }); + return true; +}); diff --git a/src/lib/download/glbEdit.js b/src/lib/download/glbEdit.js index e020abfa8..298df8d27 100644 --- a/src/lib/download/glbEdit.js +++ b/src/lib/download/glbEdit.js @@ -212,3 +212,68 @@ export function animationNames(json) { (/** @type {any} */ a, /** @type {number} */ i) => a.name ?? 'animation_' + i ); } + +const TYPED = /** @type {Record} */ ({ + 5120: Int8Array, + 5121: Uint8Array, + 5122: Int16Array, + 5123: Uint16Array, + 5125: Uint32Array, + 5126: Float32Array +}); + +/** + * An accessor's data as a fresh typed array (strided views de-interleaved; normalized ints are + * returned as stored). @param {GlbDoc} doc @param {number} index + */ +export function readAccessor(doc, index) { + const acc = doc.json.accessors[index]; + const n = COMPONENTS[/** @type {'SCALAR'} */ (acc.type)]; + const Typed = TYPED[acc.componentType]; + const out = new Typed(acc.count * n); + if (acc.bufferView === undefined) return out; // all zeros (sparse not supported here) + const view = doc.json.bufferViews[acc.bufferView]; + const base = (view.byteOffset ?? 0) + (acc.byteOffset ?? 0); + const size = Typed.BYTES_PER_ELEMENT; + const stride = view.byteStride || n * size; + const dv = new DataView(doc.bin.buffer, doc.bin.byteOffset, doc.bin.byteLength); + const get = /** @type {Record number>} */ ({ + 5120: (o) => dv.getInt8(o), + 5121: (o) => dv.getUint8(o), + 5122: (o) => dv.getInt16(o, true), + 5123: (o) => dv.getUint16(o, true), + 5125: (o) => dv.getUint32(o, true), + 5126: (o) => dv.getFloat32(o, true) + })[acc.componentType]; + for (let i = 0; i < acc.count; i++) + for (let c = 0; c < n; c++) out[i * n + c] = get(base + i * stride + c * size); + return out; +} + +/** + * Append an accessor of any component type (5121 uint8, 5123 uint16, 5125 uint32, 5126 float). + * @param {GlbDoc} doc @param {Uint8Array | Uint16Array | Uint32Array | Float32Array} data + * @param {'SCALAR'|'VEC2'|'VEC3'|'VEC4'|'MAT4'} type @returns {number} + */ +export function appendAccessor(doc, data, type) { + if (data instanceof Float32Array) return appendFloatAccessor(doc, data, type); + const json = doc.json; + const pad = (4 - (doc.bin.byteLength % 4)) % 4; + const offset = doc.bin.byteLength + pad; + const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const next = new Uint8Array(offset + bytes.byteLength); + next.set(doc.bin, 0); + next.set(bytes, offset); + doc.bin = next; + json.buffers = json.buffers?.length ? json.buffers : [{ byteLength: 0 }]; + json.bufferViews = json.bufferViews ?? []; + json.accessors = json.accessors ?? []; + json.bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: bytes.byteLength }); + json.accessors.push({ + bufferView: json.bufferViews.length - 1, + componentType: data instanceof Uint8Array ? 5121 : data instanceof Uint16Array ? 5123 : 5125, + count: data.length / COMPONENTS[type], + type + }); + return json.accessors.length - 1; +} diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js index 04374d054..66e126a33 100644 --- a/src/lib/fileHandler.svelte.js +++ b/src/lib/fileHandler.svelte.js @@ -23,7 +23,7 @@ import { downscaleImage } from '$lib/materialsHandler'; import { shaderDrivenCount } from '$lib/shaderGraph'; import { originOf, bakeOriginForExport } from '$lib/objectOrigin'; import { bakeAnimationsForExport } from '$lib/animationPreview'; -import { createGltfLoader, registerAnimatedImport, recordAnimatedImport, sendAnimatedImport } from '$lib/animatedImports'; +import { createGltfLoader, registerAnimatedImport, recordAnimatedImport, sendAnimatedImport, stampGltfPrimitives } from '$lib/animatedImports'; import { parseGltfSceneSafe, withTwins } from '$lib/ktx2Twins'; import { environment } from './environment'; import { parkAnimatedAtBase } from '$lib/flowRuntime'; @@ -769,9 +769,11 @@ export async function parseModelFile(file, extension, extras) { if (extension === 'vrm' || glbIsVrm(buffer)) { const { parseVrmBytes } = await import('./vrm/vrmRuntime.js'); const vrm = await parseVrmBytes(buffer); + stampGltfPrimitives(vrm.gltf); return { root: vrm.root, animated: { result: { scene: vrm.root, animations: vrm.animations }, buffer, kind: 'vrm' }, notes }; } const result = await parseGltfSceneSafe(buffer); + stampGltfPrimitives(result); // 57 M6: a weight edit writes back into the right primitive // animated rigs keep their own pipeline (raw-bytes sync) — unplaced. 57 M2: so does a rig // without clips (a VRM, a Meshy / UniRig character): clips get applied to it later const animated = result.animations?.length > 0 || hasSkin(result.scene) ? { result, buffer, kind: 'gltf' } : null; diff --git a/src/lib/objectMenu.js b/src/lib/objectMenu.js index 9c901dfc4..1c17c96d5 100644 --- a/src/lib/objectMenu.js +++ b/src/lib/objectMenu.js @@ -20,7 +20,7 @@ import { addParticlesPreset, removeObjectParticles, burstObjectParticles } from import { PARTICLE_PRESETS } from './particlePresets'; import { SAVE_AS_FORMATS, saveSelectionAs } from './saveAs'; import { restRowsOf, hasAnimatedImport } from './animatedImports'; -import { openApplyClip, openDownload } from './characterUi.js'; +import { openApplyClip, openDownload, openSkeleton } from './characterUi.js'; import { enterEditMode } from './meshEdit'; // 24-E3: a group's origin from its context menu, and the set's pivot point import { originPreset, resetOrigin } from './objectOrigin'; @@ -713,6 +713,12 @@ export function buildObjectMenuItems(uuid, opts = {}) { icon: 'person-standing', tooltip: 'Play a clip from another model or the built-in motions on this character (fitted to its skeleton)', action: () => openApplyClip({ targets: [uuid] }) + }, + { + label: 'Skeleton…', + icon: 'bone', + tooltip: 'The humanoid map, the bones (rename, move, add, delete, mirror) and the skin weights', + action: () => openSkeleton(uuid) } ] : []; diff --git a/src/lib/rig/rigFixes.js b/src/lib/rig/rigFixes.js new file mode 100644 index 000000000..7a39d85e8 --- /dev/null +++ b/src/lib/rig/rigFixes.js @@ -0,0 +1,435 @@ +// 57 M6 — RIG FIX-UPS, the session: what the Skeleton view does to a model. LAZY (the view imports +// it; a player of an exported game never downloads it). +// +// Every edit is ONE operation on the model's FILE (rigOps.js), sent once when it ends: the new +// bytes replace the old on every peer (animatedImports.replaceAnimatedBytes, undoable as one step), +// and a provenance step records it ({tool: 'rig-fix', model: }). A brush STROKE paints the live +// mesh locally while the pointer moves (no shared live painting) and is written when it lifts. +// The helpers drawn while the view is open (the skeleton lines, the picked bone) live at the scene +// ROOT under a fixed name — local, never replicated (CLAUDE.md rule 5). + +import * as THREE from 'three'; +import { get, writable } from 'svelte/store'; +import { objectsGroup, globalScene, pokeScene } from '../../stores/sceneStore'; +import { showToast } from '../../stores/appStore'; +import { + animatedImportPayload, + animatedImportKind, + restRowsOf, + replaceAnimatedBytes, + setAnimationState, + animatedObjects +} from '../animatedImports'; +import { isGlb } from '../download/glbEdit.js'; +import * as ops from './rigOps.js'; +import { normalizeWeights, brushWeights } from './weightsCore.js'; +import { addProvenance } from '../provenance.js'; +import { APP_VERSION } from '../version'; + +const HELPER_NAME = 'rig-fix-helpers'; + +/** the picked bone of the open view ('' = none) */ +export const pickedBone = writable(''); +/** an operation is running (the view greys its buttons) */ +export const rigBusy = writable(false); + +/** @param {string} uuid */ +function rootOf(uuid) { + return /** @type {any} */ (get(objectsGroup))?.getObjectByProperty('uuid', uuid) ?? null; +} + +/** + * The view's model: the bone tree (from the rest rows), whether the file can be edited here. + * @param {string} uuid + */ +export function rigInfo(uuid) { + const rows = restRowsOf(uuid); + const bytes = animatedImportPayload(uuid); + const depth = rows.map((r) => { + let d = 0; + for (let p = r.parent; p >= 0; p = rows[p].parent) d++; + return d; + }); + return { + uuid, + kind: animatedImportKind(uuid), + editable: !!bytes && isGlb(bytes), + bones: rows.map((r, i) => ({ + name: r.name, + parent: r.parent >= 0 ? rows[r.parent].name : '', + depth: depth[i], + bone: r.bone, + t: r.t + })) + }; +} + +/** + * Run one file edit and send it. + * @param {string} uuid @param {string} label @param {(bytes: ArrayBuffer) => Uint8Array | Promise} fn + */ +async function runOp(uuid, label, fn) { + const root = rootOf(uuid); + const bytes = animatedImportPayload(uuid); + if (!root || !bytes) return false; + if (!isGlb(bytes)) { + showToast('Rig fix-ups edit GLB / VRM files — download this one as GLB and import that.'); + return false; + } + rigBusy.set(true); + try { + const next = await fn(bytes); + addProvenance(root.userData, { + tool: 'rig-fix', + model: label, + version: APP_VERSION, + source: root.name + }); + const { forgetHumanoid } = await import('../humanoid/humanoidRig.js'); + forgetHumanoid(uuid); + const ab = /** @type {ArrayBuffer} */ ( + next.buffer.slice(next.byteOffset, next.byteOffset + next.byteLength) + ); + const ok = await replaceAnimatedBytes(uuid, ab, { label }); + refreshHelpers(uuid); + pokeScene(); + return ok; + } catch (error) { + showToast(label + ' failed: ' + String(/** @type {any} */ (error)?.message ?? error)); + return false; + } finally { + rigBusy.set(false); + } +} + +/** rename a bone; a humanoid map fix naming it follows @param {string} uuid @param {string} bone @param {string} name */ +export async function renameBone(uuid, bone, name) { + const ok = await runOp(uuid, 'rename bone', (b) => ops.renameBone(b, bone, name)); + if (ok) { + const { configOf, applyHumanoidConfig } = await import('../humanoid/humanoidRig.js'); + const cfg = configOf(uuid); + if (cfg.map && Object.values(cfg.map).includes(bone)) { + const map = Object.fromEntries( + Object.entries(cfg.map).map(([k, v]) => [k, v === bone ? name : v]) + ); + applyHumanoidConfig(uuid, { ...cfg, map }, { history: false }); + } + pickedBone.set(name); + } + return ok; +} + +/** @param {string} uuid @param {string} bone @param {number[]} t */ +export const moveBone = (uuid, bone, t) => + runOp(uuid, 'move bone', (b) => ops.moveBoneRest(b, bone, t)); +/** @param {string} uuid @param {string} parent @param {string} name @param {number[]} [offset] */ +export const addBone = (uuid, parent, name, offset) => + runOp(uuid, 'add bone', (b) => ops.addBone(b, parent, name, offset)); +/** @param {string} uuid @param {string} bone */ +export const deleteBone = (uuid, bone) => + runOp(uuid, 'delete bone', (b) => ops.deleteBone(b, bone)); +/** @param {string} uuid @param {'left'|'right'} from */ +export const mirrorBones = (uuid, from) => + runOp(uuid, 'mirror ' + from + ' to ' + (from === 'left' ? 'right' : 'left'), (b) => { + const r = ops.mirrorBones(b, from); + if (!r.pairs.length) throw new Error('no left / right bone pairs found by name'); + return r.bytes; + }); + +// ---- weights --------------------------------------------------------------------------------- + +/** the model's skinned meshes that map to a glTF primitive @param {string} uuid @returns {any[]} */ +function skinnedMeshes(uuid) { + /** @type {any[]} */ + const out = []; + rootOf(uuid)?.traverse( + (/** @type {any} */ o) => + o.isSkinnedMesh && Array.isArray(o.userData.gltfPrimitive) && out.push(o) + ); + return out; +} + +/** bind-space vertex positions + triangles of a skinned mesh @param {any} mesh */ +function bindGeometry(mesh) { + const pos = mesh.geometry.attributes.position; + const out = new Float32Array(pos.count * 3); + const v = new THREE.Vector3(); + for (let i = 0; i < pos.count; i++) { + v.fromBufferAttribute(pos, i).applyMatrix4(mesh.bindMatrix); + out[i * 3] = v.x; + out[i * 3 + 1] = v.y; + out[i * 3 + 2] = v.z; + } + const index = mesh.geometry.index ? new Uint32Array(mesh.geometry.index.array) : null; + return { positions: out, index }; +} + +/** helpers that must not deform the mesh @param {string} name */ +const NOT_DEFORMING = /(^root$|ik|control|slot|pole|target|roll|_end$|end$)/i; + +/** bone segments in bind space (head = the joint, tail = its children's mean, or one bone-length on) @param {any} mesh */ +function boneSegments(mesh) { + const bones = mesh.skeleton.bones; + const heads = mesh.skeleton.boneInverses.map((/** @type {THREE.Matrix4} */ inv) => + new THREE.Vector3().setFromMatrixPosition(inv.clone().invert()) + ); + return bones.map((/** @type {any} */ b, /** @type {number} */ i) => { + if (NOT_DEFORMING.test(b.name)) return null; + const kids = bones + .map((/** @type {any} */ c, /** @type {number} */ j) => (c.parent === b ? j : -1)) + .filter((/** @type {number} */ j) => j >= 0); + const head = heads[i]; + let tail; + if (kids.length) + tail = kids + .reduce( + (/** @type {THREE.Vector3} */ s, /** @type {number} */ j) => s.add(heads[j]), + new THREE.Vector3() + ) + .multiplyScalar(1 / kids.length); + else { + const p = bones.indexOf(b.parent); + tail = + p >= 0 + ? head.clone().add(head.clone().sub(heads[p]).multiplyScalar(0.5)) + : head.clone().add(new THREE.Vector3(0, 0.05, 0)); + } + return { head: head.toArray(), tail: tail.toArray() }; + }); +} + +/** @type {Worker | null} */ +let worker = null; +let nextId = 1; +/** a worker job @param {any} msg @param {Transferable[]} transfer @returns {Promise<{joints: Uint16Array, weights: Float32Array}>} */ +function work(msg, transfer) { + if (!worker) + worker = new Worker(new URL('./weightsWorker.js', import.meta.url), { type: 'module' }); + const w = worker; + const id = nextId++; + return new Promise((resolve, reject) => { + const onMessage = (/** @type {MessageEvent} */ e) => { + if (e.data?.id !== id) return; + w.removeEventListener('message', onMessage); + if (e.data.error) reject(new Error(e.data.error)); + else resolve(e.data); + }; + w.addEventListener('message', onMessage); + w.postMessage({ ...msg, id }, transfer); + }); +} + +/** write per-mesh weights into the file @param {ArrayBuffer} bytes @param {{mesh: any, joints: Uint16Array, weights: Float32Array}[]} rows */ +function writeWeights(bytes, rows) { + /** @type {Uint8Array | ArrayBuffer} */ + let b = bytes; + for (const { mesh, joints, weights } of rows) { + const [m, p] = mesh.userData.gltfPrimitive; + b = ops.setPrimitiveWeights(b, m, p, joints, weights); + } + return /** @type {Uint8Array} */ (b); +} + +/** Automatic weights (heat diffusion, in a worker) for every skinned mesh @param {string} uuid */ +export function autoWeights(uuid) { + return runOp(uuid, 'automatic weights', async (bytes) => { + const meshes = skinnedMeshes(uuid); + if (!meshes.length) throw new Error('no skinned mesh to weight'); + const rows = []; + for (const mesh of meshes) { + const g = bindGeometry(mesh); + const out = await work( + { op: 'heat', positions: g.positions, index: g.index, bones: boneSegments(mesh) }, + [g.positions.buffer, ...(g.index ? [g.index.buffer] : [])] + ); + rows.push({ mesh, ...out }); + } + return writeWeights(bytes, rows); + }); +} + +/** current skin attributes of a mesh as vec4 arrays @param {any} mesh */ +function currentWeights(mesh) { + const si = mesh.geometry.attributes.skinIndex; + const sw = mesh.geometry.attributes.skinWeight; + return { joints: new Uint16Array(si.array), weights: new Float32Array(sw.array) }; +} + +/** Smooth every mesh's weights @param {string} uuid */ +export function smoothAll(uuid) { + return runOp(uuid, 'smooth weights', async (bytes) => { + const rows = []; + for (const mesh of skinnedMeshes(uuid)) { + const g = bindGeometry(mesh); + const cur = currentWeights(mesh); + const out = await work( + { + op: 'smooth', + positions: g.positions, + index: g.index, + joints: cur.joints, + weights: cur.weights, + steps: 3 + }, + [g.positions.buffer, cur.joints.buffer, cur.weights.buffer] + ); + rows.push({ mesh, ...out }); + } + return writeWeights(bytes, rows); + }); +} + +/** Normalize every mesh's weights @param {string} uuid */ +export function normalizeAll(uuid) { + return runOp(uuid, 'normalize weights', (bytes) => + writeWeights( + bytes, + skinnedMeshes(uuid).map((mesh) => { + const cur = currentWeights(mesh); + return { mesh, ...normalizeWeights(cur.joints, cur.weights) }; + }) + ) + ); +} + +// ---- the brush ------------------------------------------------------------------------------- + +/** @type {{uuid: string, bone: string, meshes: {mesh: any, positions: Float32Array}[], dirty: boolean} | null} */ +let stroke = null; + +/** + * Start a stroke (the model stops its clip: the brush paints the REST pose). + * @param {string} uuid @param {string} bone + */ +export function beginStroke(uuid, bone) { + const state = get(animatedObjects)[uuid]; + if (state?.playing) setAnimationState(uuid, { playing: false }); + stroke = { + uuid, + bone, + meshes: skinnedMeshes(uuid).map((mesh) => ({ mesh, positions: bindGeometry(mesh).positions })), + dirty: false + }; +} + +/** + * Paint at a world point. LOCAL (the live attributes); the file is written on endStroke. + * @param {number[]} world @param {number} radius world units @param {number} strength -1..1 + */ +export function strokeAt(world, radius, strength) { + if (!stroke) return 0; + let touched = 0; + for (const { mesh, positions } of stroke.meshes) { + const k = mesh.skeleton.bones.findIndex((/** @type {any} */ b) => b.name === stroke?.bone); + if (k < 0) continue; + const local = mesh.worldToLocal(new THREE.Vector3(...world)).applyMatrix4(mesh.bindMatrix); + const si = mesh.geometry.attributes.skinIndex; + const sw = mesh.geometry.attributes.skinWeight; + const J = new Uint16Array(si.array); + const W = new Float32Array(sw.array); + const n = brushWeights(positions, J, W, local.toArray(), radius, k, strength); + if (!n) continue; + si.array.set(J); + sw.array.set(W); + si.needsUpdate = true; + sw.needsUpdate = true; + touched += n; + stroke.dirty = true; + } + return touched; +} + +/** End the stroke: write the painted weights into the file and send it (one undo step) */ +export async function endStroke() { + const s = stroke; + stroke = null; + if (!s?.dirty) return false; + return runOp(s.uuid, 'paint weights', (bytes) => + writeWeights( + bytes, + s.meshes.map(({ mesh }) => ({ mesh, ...currentWeights(mesh) })) + ) + ); +} + +// ---- the test pose + helpers ----------------------------------------------------------------- + +/** @type {Map} bone -> its quaternion before the test pose */ +const posed = new Map(); + +/** + * Bend a bone for a look (LOCAL, never sent; cleared when the view closes or an edit lands). + * @param {string} uuid @param {string} bone @param {number} degrees about its local X + */ +export function testPose(uuid, bone, degrees) { + const b = rootOf(uuid)?.getObjectByName(bone); + if (!b) return; + const state = get(animatedObjects)[uuid]; + if (state?.playing) setAnimationState(uuid, { playing: false }); + if (!posed.has(b)) posed.set(b, b.quaternion.clone()); + const base = /** @type {THREE.Quaternion} */ (posed.get(b)); + b.quaternion + .copy(base) + .multiply( + new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(1, 0, 0), + THREE.MathUtils.degToRad(degrees) + ) + ); + pokeScene(); +} + +export function clearTestPose() { + for (const [b, q] of posed) b.quaternion.copy(q); + posed.clear(); +} + +/** draw the skeleton (+ the picked bone) at the scene root @param {string} uuid */ +export function refreshHelpers(uuid) { + clearHelpers(); + const root = rootOf(uuid); + const scene = /** @type {any} */ (get(globalScene)); + if (!root || !scene) return; + const group = new THREE.Group(); + group.name = HELPER_NAME; + const helper = new THREE.SkeletonHelper(root); + /** @type {any} */ (helper.material).depthTest = false; + helper.renderOrder = 999; + group.add(helper); + const picked = get(pickedBone); + const bone = picked ? root.getObjectByName(picked) : null; + if (bone) { + const marker = new THREE.Mesh( + new THREE.SphereGeometry(0.03, 12, 8), + new THREE.MeshBasicMaterial({ color: 0xffaa00, depthTest: false }) + ); + marker.name = 'rig-fix-picked'; + marker.renderOrder = 1000; + marker.onBeforeRender = () => { + bone.getWorldPosition(marker.position); + }; + group.add(marker); + } + scene.add(group); + pokeScene(); +} + +export function clearHelpers() { + const scene = /** @type {any} */ (get(globalScene)); + const old = scene?.getObjectByName(HELPER_NAME); + if (!old) return; + scene.remove(old); + old.traverse((/** @type {any} */ o) => { + o.geometry?.dispose?.(); + o.material?.dispose?.(); + }); +} + +/** the view closed @param {string} uuid */ +export function closeSession(uuid) { + void uuid; + clearTestPose(); + clearHelpers(); + pickedBone.set(''); + stroke = null; +} diff --git a/src/lib/rig/rigOps.js b/src/lib/rig/rigOps.js new file mode 100644 index 000000000..961779d15 --- /dev/null +++ b/src/lib/rig/rigOps.js @@ -0,0 +1,325 @@ +// 57 M6 — RIG FIX-UPS ON THE FILE: rename a bone, move one in the rest pose, add or delete one, +// mirror one side onto the other, write new skin weights. Each op edits the model's GLB (glbEdit: +// everything else in the file stays as imported) and returns the new bytes; the caller replaces the +// model's bytes on every peer once the operation ends (rigFixes.js). LAZY (rig fix-ups load only +// when the Skeleton view opens), never on a player's boot graph. +// +// THE BIND IS KEPT: a skinned vertex lands at Σ w · (jointWorld · inverseBind) · v, so a joint that +// moves in the REST pose would drag its vertices along. Each op therefore rewrites the inverse bind +// matrices: newIBM = inverse(newJointWorld) · oldJointWorld · oldIBM, which leaves every vertex +// where it was while the joint itself moves (for a new bone, its parent's bind stands in). + +import * as THREE from 'three'; +import { + readGlb, + writeGlb, + readAccessor, + appendAccessor, + nodeIndexByName +} from '../download/glbEdit.js'; + +/** @typedef {import('../download/glbEdit.js').GlbDoc} GlbDoc */ + +/** node -> parent index (-1 = a root) @param {any} json */ +export function parentsOf(json) { + const parent = new Int32Array((json.nodes ?? []).length).fill(-1); + (json.nodes ?? []).forEach((/** @type {any} */ n, /** @type {number} */ i) => + (n.children ?? []).forEach((/** @type {number} */ c) => (parent[c] = i)) + ); + return parent; +} + +/** a node's local matrix @param {any} n */ +export function localMatrix(n) { + if (Array.isArray(n.matrix)) return new THREE.Matrix4().fromArray(n.matrix); + return new THREE.Matrix4().compose( + new THREE.Vector3().fromArray(n.translation ?? [0, 0, 0]), + new THREE.Quaternion().fromArray(n.rotation ?? [0, 0, 0, 1]), + new THREE.Vector3().fromArray(n.scale ?? [1, 1, 1]) + ); +} + +/** set a node's local transform from a matrix (TRS form) @param {any} n @param {THREE.Matrix4} m */ +function setLocal(n, m) { + const t = new THREE.Vector3(); + const q = new THREE.Quaternion(); + const s = new THREE.Vector3(); + m.decompose(t, q, s); + delete n.matrix; + n.translation = t.toArray().map((v) => +v.toFixed(7)); + n.rotation = q.toArray().map((v) => +v.toFixed(7)); + n.scale = s.toArray().map((v) => +v.toFixed(7)); + if (n.scale.every((/** @type {number} */ v) => Math.abs(v - 1) < 1e-7)) delete n.scale; + if (Math.abs(n.rotation[3] - 1) < 1e-7) delete n.rotation; +} + +/** every node's world matrix @param {any} json @returns {THREE.Matrix4[]} */ +export function worldOf(json) { + const parent = parentsOf(json); + /** @type {(THREE.Matrix4 | null)[]} */ + const W = (json.nodes ?? []).map(() => null); + const get = (/** @type {number} */ i) => { + if (W[i]) return /** @type {THREE.Matrix4} */ (W[i]); + const m = localMatrix(json.nodes[i]); + if (parent[i] >= 0) m.premultiply(get(parent[i])); + W[i] = m; + return m; + }; + return (json.nodes ?? []).map((/** @type {any} */ _, /** @type {number} */ i) => get(i)); +} + +/** @param {GlbDoc} doc @param {number} skin @returns {THREE.Matrix4[]} */ +function ibmsOf(doc, skin) { + const s = doc.json.skins[skin]; + if (s.inverseBindMatrices === undefined) return s.joints.map(() => new THREE.Matrix4()); + const a = readAccessor(doc, s.inverseBindMatrices); + return s.joints.map((/** @type {number} */ _, /** @type {number} */ k) => + new THREE.Matrix4().fromArray(a, k * 16) + ); +} + +/** @param {GlbDoc} doc @param {number} skin @param {THREE.Matrix4[]} ibms */ +function writeIbms(doc, skin, ibms) { + const a = new Float32Array(ibms.length * 16); + ibms.forEach((m, k) => m.toArray(a, k * 16)); + doc.json.skins[skin].inverseBindMatrices = appendAccessor(doc, a, 'MAT4'); +} + +/** + * Run an edit of the node graph and keep every skin's bind: `edit` changes nodes (and may add + * joints, returning their stand-in bind per new joint index). + * @param {GlbDoc} doc @param {(json: any) => (void | Map)} edit returns newJoint -> standInJoint (node indices) + */ +function keepBind(doc, edit) { + const json = doc.json; + const oldW = worldOf(json); + /** @type {Map[]} bind per joint node, per skin */ + const binds = (json.skins ?? []).map((/** @type {any} */ s, /** @type {number} */ si) => { + const ibm = ibmsOf(doc, si); + return new Map( + s.joints.map((/** @type {number} */ j, /** @type {number} */ k) => [ + j, + oldW[j].clone().multiply(ibm[k]) + ]) + ); + }); + const standIn = edit(json) ?? new Map(); + const newW = worldOf(json); + (json.skins ?? []).forEach((/** @type {any} */ s, /** @type {number} */ si) => { + const ibms = s.joints.map((/** @type {number} */ j) => { + const bind = binds[si].get(j) ?? binds[si].get(standIn.get(j) ?? -1) ?? newW[j].clone(); + return newW[j].clone().invert().multiply(bind); + }); + writeIbms(doc, si, ibms); + }); +} + +/** the node index three calls `name` (sanitized names resolve too) @param {any} json @param {string} name */ +export function nodeIndex(json, name) { + const i = nodeIndexByName(json).get(name); + if (i === undefined) throw new Error('no bone "' + name + '"'); + return i; +} + +/** @param {ArrayBuffer | Uint8Array} bytes @param {(doc: GlbDoc) => void} fn */ +function edit(bytes, fn) { + const doc = readGlb(bytes); + fn(doc); + return writeGlb(doc); +} + +/** rename a bone (its animations and the VRM humanoid follow: they name it by index) @param {ArrayBuffer | Uint8Array} bytes @param {string} bone @param {string} name */ +export function renameBone(bytes, bone, name) { + const clean = String(name).trim().slice(0, 120); + if (!clean) throw new Error('a bone needs a name'); + return edit(bytes, (doc) => { + doc.json.nodes[nodeIndex(doc.json, bone)].name = clean; + }); +} + +/** + * Move a bone in the rest pose (its local translation; its children follow, the mesh does not). + * @param {ArrayBuffer | Uint8Array} bytes @param {string} bone @param {number[]} translation + */ +export function moveBoneRest(bytes, bone, translation) { + return edit(bytes, (doc) => + keepBind(doc, (json) => { + const n = json.nodes[nodeIndex(json, bone)]; + const m = localMatrix(n); + const t = new THREE.Vector3(), + q = new THREE.Quaternion(), + s = new THREE.Vector3(); + m.decompose(t, q, s); + setLocal(n, new THREE.Matrix4().compose(new THREE.Vector3().fromArray(translation), q, s)); + }) + ); +} + +/** + * Add a bone under `parent` at a local offset; it joins every skin its parent is in (weightless + * until weights are painted or recomputed). + * @param {ArrayBuffer | Uint8Array} bytes @param {string} parent @param {string} name @param {number[]} [offset] + */ +export function addBone(bytes, parent, name, offset = [0, 0.1, 0]) { + return edit(bytes, (doc) => + keepBind(doc, (json) => { + const p = nodeIndex(json, parent); + const idx = json.nodes.length; + json.nodes.push({ name: String(name).trim().slice(0, 120) || 'bone', translation: offset }); + (json.nodes[p].children = json.nodes[p].children ?? []).push(idx); + for (const s of json.skins ?? []) if (s.joints.includes(p)) s.joints.push(idx); + return new Map([[idx, p]]); + }) + ); +} + +/** + * Delete a bone: its children move up to its parent (keeping where they are), its weights go to + * the parent, and it leaves the skeleton. (The node itself stays in the file, detached, so no other + * index in the file — an animation, a VRM extension — has to change.) + * @param {ArrayBuffer | Uint8Array} bytes @param {string} bone + */ +export function deleteBone(bytes, bone) { + return edit(bytes, (doc) => { + const json = doc.json; + const i = nodeIndex(json, bone); + const parent = parentsOf(json)[i]; + if (parent < 0) throw new Error('the root bone cannot be deleted'); + /** @type {{skin: number, gone: number, to: number}[]} */ + const remaps = []; + keepBind(doc, (j) => { + const W = worldOf(j); + const pInv = W[parent].clone().invert(); + for (const c of j.nodes[i].children ?? []) { + setLocal(j.nodes[c], pInv.clone().multiply(W[c])); + j.nodes[parent].children.push(c); + } + j.nodes[i].children = []; + j.nodes[parent].children = j.nodes[parent].children.filter( + (/** @type {number} */ c) => c !== i + ); + (j.skins ?? []).forEach((/** @type {any} */ s, /** @type {number} */ si) => { + const k = s.joints.indexOf(i); + if (k < 0) return; + const to = s.joints.indexOf(parent); + remaps.push({ skin: si, gone: k, to: to < 0 ? 0 : to }); + }); + }); + // drop the joint (its IBM row with it) and remap every primitive's JOINTS_0 + for (const { skin, gone, to } of remaps) { + const s = json.skins[skin]; + const ibm = readAccessor(doc, s.inverseBindMatrices); + const keep = new Float32Array(ibm.length - 16); + keep.set(ibm.subarray(0, gone * 16), 0); + keep.set(ibm.subarray((gone + 1) * 16), gone * 16); + s.joints.splice(gone, 1); + s.inverseBindMatrices = appendAccessor(doc, keep, 'MAT4'); + if (s.skeleton === i) s.skeleton = parent; + for (const prim of primitivesOfSkin(json, skin)) { + const J = readAccessor(doc, prim.attributes.JOINTS_0); + const out = new Uint16Array(J.length); + const target = to > gone ? to - 1 : to; + for (let v = 0; v < J.length; v++) + out[v] = J[v] === gone ? target : J[v] > gone ? J[v] - 1 : J[v]; + prim.attributes.JOINTS_0 = appendAccessor(doc, out, 'VEC4'); + } + } + }); +} + +/** the primitives skinned by a skin @param {any} json @param {number} skin @returns {any[]} */ +export function primitivesOfSkin(json, skin) { + const meshes = new Set( + (json.nodes ?? []) + .filter((/** @type {any} */ n) => n.skin === skin && n.mesh !== undefined) + .map((/** @type {any} */ n) => n.mesh) + ); + return [...meshes].flatMap((m) => + (json.meshes[m].primitives ?? []).filter( + (/** @type {any} */ p) => p.attributes?.JOINTS_0 !== undefined + ) + ); +} + +/** the other side's name, or '' (Left/Right, left/right, .l/.r, _l/_r, L_/R_, -l/-r) @param {string} name */ +export function mirrorName(name) { + const rules = /** @type {[RegExp, (m: string) => string][]} */ ([ + [/Left/, () => 'Right'], + [/Right/, () => 'Left'], + [/left/, () => 'right'], + [/right/, () => 'left'], + [/([._\-])l$/, (m) => m[0] + 'r'], + [/([._\-])r$/, (m) => m[0] + 'l'], + [/([._\-])L$/, (m) => m[0] + 'R'], + [/([._\-])R$/, (m) => m[0] + 'L'], + [/^L([._\-])/, (m) => 'R' + m[1]], + [/^R([._\-])/, (m) => 'L' + m[1]], + [/_L_/, () => '_R_'], + [/_R_/, () => '_L_'] + ]); + for (const [re, sub] of rules) if (re.test(name)) return name.replace(re, sub); + return ''; +} + +/** which side a bone name says it is on ('' = neither) @param {string} name @returns {'left'|'right'|''} */ +export function sideOfName(name) { + if (!mirrorName(name)) return ''; + return /Left|left|[._\-][lL]$|^L[._\-]|_L_/.test(name) ? 'left' : 'right'; +} + +/** + * Mirror one side's rest pose onto the other (across the model's X = 0 plane): every bone named + * for `from` puts its twin at the mirrored place and orientation. Returns the new bytes and the + * pairs it moved. + * @param {ArrayBuffer | Uint8Array} bytes @param {'left'|'right'} from + */ +export function mirrorBones(bytes, from = 'left') { + /** @type {[string, string][]} */ + const pairs = []; + const out = edit(bytes, (doc) => + keepBind(doc, (json) => { + const isFrom = (/** @type {string} */ n) => !!mirrorName(n) && sideOfName(n) === from; + const byName = new Map( + json.nodes.map((/** @type {any} */ n, /** @type {number} */ i) => [n.name, i]) + ); + const S = new THREE.Matrix4().makeScale(-1, 1, 1); + const parent = parentsOf(json); + // top-down, so a twin's parent is already in place when it is computed + const depth = (/** @type {number} */ i) => { + let d = 0; + for (let p = parent[i]; p >= 0; p = parent[p]) d++; + return d; + }; + const sources = json.nodes + .map((/** @type {any} */ _, /** @type {number} */ i) => i) + .filter( + (/** @type {number} */ i) => + isFrom(json.nodes[i].name ?? '') && byName.has(mirrorName(json.nodes[i].name)) + ); + sources.sort((/** @type {number} */ a, /** @type {number} */ b) => depth(a) - depth(b)); + for (const i of sources) { + const twin = /** @type {number} */ (byName.get(mirrorName(json.nodes[i].name))); + if (twin === i) continue; + const W = worldOf(json); + const want = S.clone().multiply(W[i]).multiply(S); + const pInv = parent[twin] >= 0 ? W[parent[twin]].clone().invert() : new THREE.Matrix4(); + setLocal(json.nodes[twin], pInv.multiply(want)); + pairs.push([json.nodes[i].name, json.nodes[twin].name]); + } + }) + ); + return { bytes: out, pairs }; +} + +/** + * Write new weights for one primitive (JOINTS_0 as uint16, WEIGHTS_0 as float). + * @param {ArrayBuffer | Uint8Array} bytes @param {number} mesh @param {number} primitive + * @param {Uint16Array} joints @param {Float32Array} weights + */ +export function setPrimitiveWeights(bytes, mesh, primitive, joints, weights) { + return edit(bytes, (doc) => { + const prim = doc.json.meshes[mesh].primitives[primitive]; + prim.attributes.JOINTS_0 = appendAccessor(doc, joints, 'VEC4'); + prim.attributes.WEIGHTS_0 = appendAccessor(doc, weights, 'VEC4'); + }); +} diff --git a/src/lib/rig/weightsCore.js b/src/lib/rig/weightsCore.js new file mode 100644 index 000000000..e8dec9e75 --- /dev/null +++ b/src/lib/rig/weightsCore.js @@ -0,0 +1,265 @@ +// 57 M6 — SKIN WEIGHTS, the arithmetic (a LEAF: no three, runs in the worker and the unit suite). +// +// AUTOMATIC WEIGHTS are a heat-diffusion pass in the spirit of Baran & Popović's "bone heat": every +// vertex starts hot for the bone NEAREST to it (distance to the bone's segment, head -> tail), then +// the heat DIFFUSES over the mesh's own surface (explicit steps of the heat equation on the welded +// vertex graph: each step moves a vertex's weights part-way to its neighbours' average), so the +// weights blend smoothly across a joint instead of cutting along a Voronoi line, and a vertex is +// never weighted to a bone it is only near through empty space (the heat travels over the surface, +// not through the air between two legs). Each vertex keeps its 4 strongest bones, normalized. +// SMOOTH is the same diffusion on existing weights; NORMALIZE makes every vertex sum to 1 with at +// most 4 influences. The brush adds weight for one bone around a point with a smooth falloff. +// +// Weights are SPARSE while they diffuse (a small per-vertex map), so the cost is vertices × steps × +// neighbours × (a few bones), not vertices × bones. + +/** + * @typedef {{positions: Float32Array, index: Uint32Array | Uint16Array | null}} WeightMesh + * positions in the BIND space the bones are given in; index = triangles (null = a soup) + * @typedef {{head: number[], tail: number[]}} BoneSeg + */ + +/** squared distance from p to the segment a-b @param {number[]} p @param {number[]} a @param {number[]} b */ +export function segDist2(p, a, b) { + const abx = b[0] - a[0], + aby = b[1] - a[1], + abz = b[2] - a[2]; + const apx = p[0] - a[0], + apy = p[1] - a[1], + apz = p[2] - a[2]; + const len = abx * abx + aby * aby + abz * abz; + const t = len > 0 ? Math.max(0, Math.min(1, (apx * abx + apy * aby + apz * abz) / len)) : 0; + const dx = apx - abx * t, + dy = apy - aby * t, + dz = apz - abz * t; + return dx * dx + dy * dy + dz * dz; +} + +/** + * Weld coincident vertices (a UV seam splits one surface point into several) and list each welded + * vertex's neighbours over the triangles. + * @param {WeightMesh} mesh @returns {{weld: Int32Array, neighbours: number[][], count: number}} + */ +export function surfaceGraph(mesh) { + const p = mesh.positions; + const n = p.length / 3; + const weld = new Int32Array(n); + /** @type {Map} */ + const key = new Map(); + let count = 0; + for (let i = 0; i < n; i++) { + const k = + Math.round(p[i * 3] * 1e5) + + ',' + + Math.round(p[i * 3 + 1] * 1e5) + + ',' + + Math.round(p[i * 3 + 2] * 1e5); + let w = key.get(k); + if (w === undefined) { + w = count++; + key.set(k, w); + } + weld[i] = w; + } + /** @type {Set[]} */ + const sets = Array.from({ length: count }, () => new Set()); + const tri = mesh.index ?? null; + const triCount = tri ? tri.length / 3 : n / 3; + for (let t = 0; t < triCount; t++) { + const a = weld[tri ? tri[t * 3] : t * 3]; + const b = weld[tri ? tri[t * 3 + 1] : t * 3 + 1]; + const c = weld[tri ? tri[t * 3 + 2] : t * 3 + 2]; + if (a !== b) (sets[a].add(b), sets[b].add(a)); + if (b !== c) (sets[b].add(c), sets[c].add(b)); + if (a !== c) (sets[a].add(c), sets[c].add(a)); + } + return { weld, neighbours: sets.map((s) => [...s]), count }; +} + +/** keep the k strongest, normalized @param {Map} m @param {number} k */ +function topK(m, k = 4) { + const list = [...m.entries()] + .filter(([, w]) => w > 1e-6) + .sort((a, b) => b[1] - a[1] || a[0] - b[0]) + .slice(0, k); + const sum = list.reduce((s, [, w]) => s + w, 0); + /** @type {Map} */ + const out = new Map(); + for (const [j, w] of list) out.set(j, sum > 0 ? w / sum : 0); + return out; +} + +/** + * Diffuse sparse per-vertex weights over the welded surface. + * @param {Map[]} w per WELDED vertex @param {number[][]} neighbours @param {number} steps @param {number} rate 0..1 + * @param {Uint8Array} [pinned] welded vertices that keep their weights + */ +function diffuse(w, neighbours, steps, rate, pinned) { + let cur = w; + for (let s = 0; s < steps; s++) { + /** @type {Map[]} */ + const next = new Array(cur.length); + for (let i = 0; i < cur.length; i++) { + if (pinned?.[i] || !neighbours[i].length) { + next[i] = cur[i]; + continue; + } + /** @type {Map} */ + const m = new Map(); + for (const [j, v] of cur[i]) m.set(j, v * (1 - rate)); + const share = rate / neighbours[i].length; + for (const nb of neighbours[i]) + for (const [j, v] of cur[nb]) m.set(j, (m.get(j) ?? 0) + v * share); + next[i] = topK(m, 6); + } + cur = next; + } + return cur.map((m) => topK(m, 4)); +} + +/** sparse welded weights -> per-vertex vec4 arrays @param {Map[]} w @param {Int32Array} weld */ +function unpack(w, weld) { + const n = weld.length; + const joints = new Uint16Array(n * 4); + const weights = new Float32Array(n * 4); + for (let i = 0; i < n; i++) { + let k = 0; + for (const [j, v] of w[weld[i]]) { + if (k >= 4) break; + joints[i * 4 + k] = j; + weights[i * 4 + k] = v; + k++; + } + if (!k) weights[i * 4] = 1; + } + return { joints, weights }; +} + +/** per-vertex vec4 arrays -> sparse welded weights (the first vertex of a weld group wins) @param {Uint16Array | Uint8Array | Uint32Array} joints @param {Float32Array} weights @param {Int32Array} weld @param {number} count */ +function pack(joints, weights, weld, count) { + /** @type {Map[]} */ + const w = new Array(count); + for (let i = 0; i < weld.length; i++) { + if (w[weld[i]]) continue; + /** @type {Map} */ + const m = new Map(); + for (let k = 0; k < 4; k++) + if (weights[i * 4 + k] > 0) + m.set(joints[i * 4 + k], (m.get(joints[i * 4 + k]) ?? 0) + weights[i * 4 + k]); + w[weld[i]] = m; + } + for (let i = 0; i < count; i++) w[i] = w[i] ?? new Map(); + return w; +} + +/** + * Automatic weights. + * @param {WeightMesh} mesh @param {BoneSeg[]} bones (index = the skin's joint index; a bone that + * must not deform — a root, an IK helper — is passed as null) + * @param {{steps?: number, rate?: number}} [opts] diffusion steps (default 12) and rate (0.5) + */ +export function heatWeights(mesh, bones, opts = {}) { + const g = surfaceGraph(mesh); + const p = mesh.positions; + /** @type {Map[]} */ + const w = new Array(g.count); + const first = new Int32Array(g.count).fill(-1); + for (let i = 0; i < g.weld.length; i++) if (first[g.weld[i]] < 0) first[g.weld[i]] = i; + for (let v = 0; v < g.count; v++) { + const i = first[v]; + const pt = [p[i * 3], p[i * 3 + 1], p[i * 3 + 2]]; + // heat from the NEAREST bone only (bone heat's rule) — ties included, so a vertex right at a + // joint starts shared; a bone across empty space never seeds a vertex, and the diffusion + // below spreads heat only along the surface + const d = /** @type {{j: number, d: number}[]} */ ( + bones.map((b, j) => (b ? { j, d: segDist2(pt, b.head, b.tail) } : null)).filter(Boolean) + ); + d.sort((a, b) => a.d - b.d); + /** @type {Map} */ + const m = new Map(); + for (const e of d) if (e.d <= d[0].d * 1.2 + 1e-12) m.set(e.j, 1 / Math.max(1e-8, e.d)); + w[v] = topK(m, 4); + } + const out = diffuse(w, g.neighbours, opts.steps ?? 12, opts.rate ?? 0.5); + return unpack(out, g.weld); +} + +/** + * Smooth existing weights (a few diffusion steps). @param {WeightMesh} mesh + * @param {Uint16Array | Uint8Array | Uint32Array} joints @param {Float32Array} weights @param {{steps?: number, rate?: number}} [opts] + */ +export function smoothWeights(mesh, joints, weights, opts = {}) { + const g = surfaceGraph(mesh); + return unpack( + diffuse( + pack(joints, weights, g.weld, g.count), + g.neighbours, + opts.steps ?? 3, + opts.rate ?? 0.5 + ), + g.weld + ); +} + +/** every vertex sums to 1 with at most 4 influences @param {Uint16Array | Uint8Array | Uint32Array} joints @param {Float32Array} weights */ +export function normalizeWeights(joints, weights) { + const n = weights.length / 4; + const J = new Uint16Array(n * 4); + const W = new Float32Array(n * 4); + for (let i = 0; i < n; i++) { + /** @type {Map} */ + const m = new Map(); + for (let k = 0; k < 4; k++) + if (weights[i * 4 + k] > 0) + m.set(joints[i * 4 + k], (m.get(joints[i * 4 + k]) ?? 0) + weights[i * 4 + k]); + const t = topK(m, 4); + let k = 0; + for (const [j, v] of t) { + J[i * 4 + k] = j; + W[i * 4 + k] = v; + k++; + } + if (!k) W[i * 4] = 1; + } + return { joints: J, weights: W }; +} + +/** + * The brush: add `strength` × falloff weight for `bone` to vertices within `radius` of `center` + * (positions in the same space as center), then renormalize. Returns the vertex count touched. + * @param {Float32Array} positions @param {Uint16Array} joints @param {Float32Array} weights + * @param {number[]} center @param {number} radius @param {number} bone @param {number} strength -1..1 (negative removes) + */ +export function brushWeights(positions, joints, weights, center, radius, bone, strength) { + const n = positions.length / 3; + const r2 = radius * radius; + let touched = 0; + for (let i = 0; i < n; i++) { + const dx = positions[i * 3] - center[0], + dy = positions[i * 3 + 1] - center[1], + dz = positions[i * 3 + 2] - center[2]; + const d2 = dx * dx + dy * dy + dz * dz; + if (d2 > r2) continue; + const f = Math.pow(1 - d2 / r2, 2) * strength; // smooth falloff + /** @type {Map} */ + const m = new Map(); + for (let k = 0; k < 4; k++) + if (weights[i * 4 + k] > 0) + m.set(joints[i * 4 + k], (m.get(joints[i * 4 + k]) ?? 0) + weights[i * 4 + k]); + m.set(bone, Math.max(0, (m.get(bone) ?? 0) + f)); + const t = topK(m, 4); + if (!t.size) continue; // erasing a vertex's only bone leaves it as it was + let k = 0; + for (const [j, v] of t) { + joints[i * 4 + k] = j; + weights[i * 4 + k] = v; + k++; + } + for (; k < 4; k++) { + joints[i * 4 + k] = 0; + weights[i * 4 + k] = 0; + } + touched++; + } + return touched; +} diff --git a/src/lib/rig/weightsWorker.js b/src/lib/rig/weightsWorker.js new file mode 100644 index 000000000..4705a3db9 --- /dev/null +++ b/src/lib/rig/weightsWorker.js @@ -0,0 +1,25 @@ +// 57 M6 — the automatic-weights WORKER: weightsCore's heat diffusion off the main thread (a dense +// character is seconds of diffusion). One message each way, keyed by `id`: +// in: {id, op: 'heat'|'smooth', positions, index, bones?, joints?, weights?, steps?} +// out: {id, joints, weights} | {id, error} (typed arrays transferred) +import { heatWeights, smoothWeights } from './weightsCore.js'; + +self.onmessage = (/** @type {MessageEvent} */ event) => { + const d = event.data ?? {}; + try { + const mesh = { positions: d.positions, index: d.index ?? null }; + const out = + d.op === 'smooth' + ? smoothWeights(mesh, d.joints, d.weights, { steps: d.steps }) + : heatWeights(mesh, d.bones, { steps: d.steps }); + /** @type {any} */ (self).postMessage({ id: d.id, joints: out.joints, weights: out.weights }, [ + out.joints.buffer, + out.weights.buffer + ]); + } catch (error) { + /** @type {any} */ (self).postMessage({ + id: d.id, + error: String(/** @type {any} */ (error)?.message ?? error) + }); + } +}; diff --git a/src/lib/vrm/vrmRuntime.js b/src/lib/vrm/vrmRuntime.js index 0c5279538..bc774aba3 100644 --- a/src/lib/vrm/vrmRuntime.js +++ b/src/lib/vrm/vrmRuntime.js @@ -29,7 +29,7 @@ let last = 0; /** * Parse VRM bytes. Resolves the same shape animatedImports' parser does, plus the VRM. - * @param {ArrayBuffer} bytes @returns {Promise<{root: any, animations: any[], vrm: any}>} + * @param {ArrayBuffer} bytes @returns {Promise<{root: any, animations: any[], vrm: any, gltf: any}>} */ export async function parseVrmBytes(bytes) { const loader = createGltfLoader(); @@ -44,7 +44,7 @@ export async function parseVrmBytes(bytes) { root.userData.vrmLicence = licenceFromVrmMeta(vrm.meta); root.userData.vrmMeta = metaSummary(vrm.meta); } - return { root, animations: gltf.animations ?? [], vrm }; + return { root, animations: gltf.animations ?? [], vrm, gltf }; } /** diff --git a/tests/e2e/character-skeleton.test.cjs b/tests/e2e/character-skeleton.test.cjs new file mode 100644 index 000000000..f3e10905a --- /dev/null +++ b/tests/e2e/character-skeleton.test.cjs @@ -0,0 +1,261 @@ +// 57 M6 + M2 map editor — the Skeleton view on a Mixamo-named rig, two peers. The humanoid map fix +// is a replicated CONFIG (bytes unchanged); every bone / weight edit rewrites the GLB and replaces +// it on the other peer ONCE when the operation ends; undo puts the old file back everywhere. The rig +// code loads only when the view opens. +const h = require('./helpers.cjs'); +const { mixamoGlb } = require('./characterFixtures.cjs'); + +const uuidOf = (page, name) => + page.evaluate( + (name) => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => + r(g?.children.find((c) => c.name === name)?.uuid ?? null) + )() + ), + name + ); +const loaded = (page, re) => + page.evaluate( + (src) => performance.getEntriesByType('resource').some((e) => new RegExp(src).test(e.name)), + re + ); +const digest = (page, uuid) => + page.evaluate((uuid) => { + const b = window.__stores.animatedImports.animatedImportPayload(uuid); + if (!b) return null; + const u = new Uint8Array(b); + let x = 0; + for (let i = 0; i < u.length; i++) x = (x * 31 + u[i]) >>> 0; + return u.length + ':' + x; + }, uuid); +const boneNames = (page, uuid) => + page.evaluate( + (u) => + window.__stores.animatedImports + .restRowsOf(u) + .filter((r) => r.bone) + .map((r) => r.name), + uuid + ); +const boneWorld = (page, uuid, bone) => + page.evaluate( + ({ uuid, bone }) => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => { + const b = g?.getObjectByProperty('uuid', uuid)?.getObjectByName(bone); + if (!b) return r(null); + b.updateWorldMatrix(true, false); + r(new window.__stores.THREE.Vector3().setFromMatrixPosition(b.matrixWorld).toArray()); + })() + ), + { uuid, bone } + ); +/** how many vertices of the body are NOT 100% on the hips (joint 0) */ +const offHips = (page, uuid) => + page.evaluate( + (uuid) => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => { + let mesh = null; + g?.getObjectByProperty('uuid', uuid)?.traverse((o) => o.isSkinnedMesh && (mesh = o)); + if (!mesh) return r(-1); + const si = mesh.geometry.attributes.skinIndex.array; + const sw = mesh.geometry.attributes.skinWeight.array; + let n = 0; + for (let i = 0; i < sw.length / 4; i++) if (!(si[i * 4] === 0 && sw[i * 4] > 0.999)) n++; + r(n); + })() + ), + uuid + ); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + const { page } = A; + + const rig = await mixamoGlb(page); + await page.evaluate(async (bytes) => { + await window.__stores.fileHandler.importFile( + new File([new Uint8Array(bytes)], 'rig.glb'), + 'Rigged', + undefined, + [0, 0, 0] + ); + }, rig); + await h.eventually( + () => uuidOf(page, 'Rigged'), + (u) => !!u, + 'the rig is placed' + ); + const u = await uuidOf(page, 'Rigged'); + await h.eventually( + () => uuidOf(B.page, 'Rigged'), + (x) => x === u, + 'B holds it' + ); + h.check(!(await loaded(page, 'rigFixes|rigOps|weightsCore')), 'boot: no rig fix-up code loaded'); + + // 1. open the view from the Inspector + await page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), u); + await page.waitForSelector('#character-skeleton', { timeout: 15000 }); + await page.click('#character-skeleton'); + await page.waitForSelector('#skeleton-window #skeleton-map [data-vrm-bone="leftHand"] select', { + timeout: 20000 + }); + h.check(await loaded(page, 'rigFixes'), 'the rig code loaded with the view'); + + // 2. the map editor: unmap a required bone -> not a humanoid, on both peers; bytes untouched + const bytes0 = await digest(page, u); + await page.selectOption('#skeleton-map [data-vrm-bone="leftHand"] select', '__none'); + await h.eventually( + () => page.evaluate((u) => window.__stores.humanoidRig.humanoidOf(u)?.missing, u), + (m) => Array.isArray(m) && m.includes('leftHand'), + 'map fix: leftHand unmapped -> missing on A' + ); + await h.eventually( + () => B.page.evaluate((u) => window.__stores.humanoidRig?.humanoidOf(u)?.missing ?? null, u), + (m) => Array.isArray(m) && m.includes('leftHand'), + 'map fix replicated to B' + ); + await page.selectOption( + '#skeleton-map [data-vrm-bone="leftHand"] select', + 'mixamorigLeftForeArm' + ); + await h.eventually( + () => page.evaluate((u) => window.__stores.humanoidRig.humanoidOf(u)?.map.leftHand, u), + (v) => v === 'mixamorigLeftForeArm', + 'map fix: a bone can be pointed anywhere' + ); + await page.click('#skeleton-map-reset'); + await h.eventually( + () => B.page.evaluate((u) => window.__stores.humanoidRig.humanoidOf(u)?.map.leftHand, u), + (v) => v === 'mixamorigLeftHand', + 'Reset fixes: back to the detected bone on B' + ); + h.check((await digest(page, u)) === bytes0, 'map fixes never touch the bytes'); + + // 3. Bones: rename the head (rewrite + resend), then undo + await page.click('#skeleton-tab-bones'); + await page.click('#skeleton-bones [data-bone="mixamorigHead"]'); + await page.fill('#skeleton-rename', 'HeadBone'); + await page.click('#skeleton-window button:has-text("Rename")'); + await h.eventually( + () => boneNames(page, u), + (n) => n.includes('HeadBone'), + 'rename: A has HeadBone' + ); + await h.eventually( + () => boneNames(B.page, u), + (n) => n.includes('HeadBone') && !n.includes('mixamorigHead'), + 'rename: B got the new file' + ); + h.check((await digest(B.page, u)) === (await digest(page, u)), 'both peers hold the same bytes'); + h.check( + (await page.evaluate((u) => window.__stores.humanoidRig.humanoidOf(u)?.map.head, u)) === + 'HeadBone', + 'the humanoid map follows the rename' + ); + await page.evaluate(() => window.__stores.history.undo()); + await h.eventually( + () => boneNames(B.page, u), + (n) => n.includes('mixamorigHead'), + 'undo: B is back on the old file' + ); + + // 4. move a bone in the rest pose; mirror puts the other side on it + await page.click('#skeleton-bones [data-bone="mixamorigLeftArm"]'); + const w0 = await boneWorld(page, u, 'mixamorigLeftArm'); + await page.fill('#skeleton-rest input[aria-label="Rest x"]', '0.2'); + await page.click('#skeleton-rest button:has-text("Move")'); + await h.eventually( + () => boneWorld(B.page, u, 'mixamorigLeftArm'), + (w) => w && Math.abs(w[0] - w0[0]) > 0.05, + 'move: the left arm moved in the rest pose (B)' + ); + await page.click('#skeleton-mirror-lr'); + await h.eventually( + async () => [ + await boneWorld(B.page, u, 'mixamorigLeftArm'), + await boneWorld(B.page, u, 'mixamorigRightArm') + ], + ([l, r]) => l && r && Math.abs(r[0] + l[0]) < 1e-3 && Math.abs(r[1] - l[1]) < 1e-3, + 'mirror left -> right: the right arm is the mirror of the moved left one (B)' + ); + + // 5. add + delete + await page.click('#skeleton-bones [data-bone="mixamorigHead"]'); + await page.fill('#skeleton-child', 'Ponytail'); + await page.click('#skeleton-window button:has-text("Add child")'); + await h.eventually( + () => boneNames(B.page, u), + (n) => n.includes('Ponytail'), + 'add: B has the new bone' + ); + await page.waitForSelector('#skeleton-bones [data-bone="mixamorigLeftToeBase"]'); + await page.click('#skeleton-bones [data-bone="mixamorigLeftToeBase"]'); + await page.click('#skeleton-window button:has-text("Delete")'); + await h.eventually( + () => boneNames(B.page, u), + (n) => !n.includes('mixamorigLeftToeBase'), + 'delete: the bone left the skeleton (B)' + ); + + // 6. Weights: automatic weights (worker) spread the body over the bones + await page.click('#skeleton-tab-weights'); + const off0 = await offHips(page, u); + h.check(off0 >= 0 && off0 < 24, `premise: the fixture body is mostly on the hips (${off0} off)`); + await page.click('#skeleton-auto-weights'); + await h.eventually( + () => offHips(B.page, u), + (n) => n >= off0 + 8, + 'automatic weights: the body is spread over its bones (B)' + ); + h.check(await loaded(page, 'weightsWorker'), 'the weights ran in a worker'); + + // 7. the brush on the real canvas: paint the left thigh onto the upper body + await page.click('#skeleton-bones [data-bone="mixamorigLeftUpLeg"]'); + await page.check('#skeleton-brush'); + const before = await digest(page, u); + const target = await page.evaluate( + (u) => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => { + const root = g.getObjectByProperty('uuid', u); + const v = new window.__stores.THREE.Vector3(0.15, 1.2, 0.15); + root.localToWorld(v); + r(v.toArray()); + })() + ), + u + ); + await page.evaluate(() => + window.__stores.objectActions.flyTo?.([-0.6, 1.2, 2.2], [0.4, 1.2, 0], 200) + ); + await page.waitForTimeout(700); + const px = await h.projectPoint(page, target); + const onCanvas = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, px); + h.check( + onCanvas === 'CANVAS', + `premise: the brush point is on the viewport, not under a panel (${onCanvas})` + ); + await page.mouse.move(px.x, px.y); + await page.mouse.down(); + await page.mouse.move(px.x + 6, px.y + 4, { steps: 4 }); + await page.mouse.up(); + await h.eventually( + () => digest(page, u), + (d) => d !== before, + 'brush: the stroke rewrote the file once it lifted' + ); + await h.eventually( + async () => (await digest(B.page, u)) === (await digest(page, u)), + (v) => v === true, + 'brush: B got the painted file' + ); + + await h.finish(browser); +}); diff --git a/tests/unit/rigFixes.test.js b/tests/unit/rigFixes.test.js new file mode 100644 index 000000000..39ba95a1a --- /dev/null +++ b/tests/unit/rigFixes.test.js @@ -0,0 +1,235 @@ +// @ts-nocheck — the rig ops on the shipped KayKit GLB, parsed back with three +// 57 M6: rename / move-in-rest / add / delete / mirror edit the GLB and KEEP THE BIND (every skinned +// vertex stays where it was at rest), and the automatic weights are a surface heat diffusion. +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { + renameBone, + moveBoneRest, + addBone, + deleteBone, + mirrorBones, + mirrorName, + sideOfName, + setPrimitiveWeights, + worldOf, + nodeIndex +} from '../../src/lib/rig/rigOps.js'; +import { readGlb, readAccessor } from '../../src/lib/download/glbEdit.js'; +import { + heatWeights, + smoothWeights, + normalizeWeights, + brushWeights, + surfaceGraph +} from '../../src/lib/rig/weightsCore.js'; + +globalThis.self = globalThis; +const knight = () => { + const b = fs.readFileSync(new URL('../../static/avatars/knight.glb', import.meta.url)); + return new Uint8Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); +}; +const parse = (bytes) => + new Promise((resolve, reject) => { + const err = console.error; + const warn = console.warn; + console.error = () => {}; + console.warn = () => {}; + new GLTFLoader().parse( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + '', + (g) => ((console.error = err), (console.warn = warn), resolve(g)), + (e) => ((console.error = err), (console.warn = warn), reject(e)) + ); + }); + +/** every skinned vertex of the first skinned mesh, at rest, in world space */ +async function restVertices(bytes) { + const g = await parse(bytes); + g.scene.updateMatrixWorld(true); + let mesh = null; + g.scene.traverse((o) => o.isSkinnedMesh && !mesh && (mesh = o)); + const out = []; + const v = new THREE.Vector3(); + for (let i = 0; i < mesh.geometry.attributes.position.count; i += 37) { + mesh.getVertexPosition(i, v); + out.push(v.clone().applyMatrix4(mesh.matrixWorld)); + } + return { out, g, mesh }; +} +const maxGap = (a, b) => Math.max(...a.map((p, i) => p.distanceTo(b[i]))); + +describe('57 M6 rig ops keep the bind', () => { + it('rename: the bone has its new name, nothing else changed', async () => { + const bytes = renameBone(knight(), 'upperarml', 'arm_upper_L'); + const { g } = await parse(bytes).then((g) => ({ g })); + expect(g.scene.getObjectByName('arm_upper_L')?.isBone).toBe(true); + expect(g.scene.getObjectByName('upperarml')).toBeUndefined(); + expect(() => renameBone(knight(), 'nope', 'x')).toThrow(/no bone/); + }); + + it('move in the rest pose: the joint moves, the skinned mesh does not', async () => { + const before = await restVertices(knight()); + const doc = readGlb(knight()); + const i = nodeIndex(doc.json, 'upperarml'); + const t = doc.json.nodes[i].translation ?? [0, 0, 0]; + const bytes = moveBoneRest(knight(), 'upperarml', [t[0] + 0.05, t[1] - 0.03, t[2]]); + const after = await restVertices(bytes); + expect(maxGap(before.out, after.out)).toBeLessThan(1e-4); + const p0 = new THREE.Vector3(), + p1 = new THREE.Vector3(); + before.g.scene.getObjectByName('upperarml').getWorldPosition(p0); + after.g.scene.getObjectByName('upperarml').getWorldPosition(p1); + expect(p0.distanceTo(p1)).toBeGreaterThan(0.02); + }); + + it('add a bone: one more joint, weightless, mesh unchanged', async () => { + const before = await restVertices(knight()); + const bytes = addBone(knight(), 'head', 'ponytail', [0, 0.2, -0.1]); + const doc = readGlb(bytes); + expect(doc.json.skins[0].joints.length).toBe(readGlb(knight()).json.skins[0].joints.length + 1); + expect(doc.json.accessors[doc.json.skins[0].inverseBindMatrices].count).toBe( + doc.json.skins[0].joints.length + ); + const after = await restVertices(bytes); + expect(after.g.scene.getObjectByName('ponytail')?.isBone).toBe(true); + expect(maxGap(before.out, after.out)).toBeLessThan(1e-4); + }); + + it('delete a bone: children move up, weights go to the parent, the mesh stays put', async () => { + const before = await restVertices(knight()); + const bytes = deleteBone(knight(), 'wristl'); + const doc = readGlb(bytes); + const skin = doc.json.skins[0]; + expect(skin.joints.length).toBe(readGlb(knight()).json.skins[0].joints.length - 1); + const J = readAccessor( + doc, + doc.json.meshes[doc.json.nodes.find((n) => n.skin === 0 && n.mesh !== undefined).mesh] + .primitives[0].attributes.JOINTS_0 + ); + expect(Math.max(...J)).toBeLessThan(skin.joints.length); + const after = await restVertices(bytes); + expect(after.g.scene.getObjectByName('handl')?.parent?.name).toBe('lowerarml'); + expect(maxGap(before.out, after.out)).toBeLessThan(1e-4); + expect(() => deleteBone(knight(), 'Rig')).toThrow(); + }); + + it('mirror left -> right: a displaced right arm comes back to the mirror of the left', async () => { + const doc = readGlb(knight()); + const r = nodeIndex(doc.json, 'upperarmr'); + const t = doc.json.nodes[r].translation; + const moved = moveBoneRest(knight(), 'upperarmr', [t[0] - 0.1, t[1] + 0.1, t[2]]); + const { bytes, pairs } = mirrorBones(moved, 'left'); + expect(pairs.some(([a, b]) => a === 'upperarm.l' && b === 'upperarm.r')).toBe(true); + const W = worldOf(readGlb(bytes).json); + const j = readGlb(bytes).json; + const pl = new THREE.Vector3().setFromMatrixPosition(W[nodeIndex(j, 'upperarml')]); + const pr = new THREE.Vector3().setFromMatrixPosition(W[nodeIndex(j, 'upperarmr')]); + expect(pr.distanceTo(new THREE.Vector3(-pl.x, pl.y, pl.z))).toBeLessThan(1e-4); + const before = await restVertices(knight()); + expect(maxGap(before.out, (await restVertices(bytes)).out)).toBeLessThan(1e-4); + }); + + it('mirror names across conventions', () => { + expect(mirrorName('mixamorigLeftArm')).toBe('mixamorigRightArm'); + expect(mirrorName('upperarm.l')).toBe('upperarm.r'); + expect(mirrorName('thigh_r')).toBe('thigh_l'); + expect(mirrorName('J_Bip_L_Hand')).toBe('J_Bip_R_Hand'); + expect(mirrorName('spine')).toBe(''); + expect(sideOfName('upperarm.l')).toBe('left'); + expect(sideOfName('RightHand')).toBe('right'); + }); + + it('new weights land in the file', async () => { + const doc = readGlb(knight()); + const meshIdx = doc.json.nodes.find((n) => n.skin === 0 && n.mesh !== undefined).mesh; + const count = + doc.json.accessors[doc.json.meshes[meshIdx].primitives[0].attributes.POSITION].count; + const J = new Uint16Array(count * 4); + const W = new Float32Array(count * 4); + for (let i = 0; i < count; i++) W[i * 4] = 1; // everything on joint 0 + const { mesh } = await restVertices(setPrimitiveWeights(knight(), meshIdx, 0, J, W)); + expect(Array.from(mesh.geometry.attributes.skinWeight.array.slice(0, 4))).toEqual([1, 0, 0, 0]); + }); +}); + +describe('57 M6 weights', () => { + /** a bar along +Y, 0..2, 8 rings of 6 vertices, two bones (0..1, 1..2) */ + function bar() { + const g = new THREE.CylinderGeometry(0.1, 0.1, 2, 6, 16, false); + g.translate(0, 1, 0); + return { + positions: new Float32Array(g.attributes.position.array), + index: new Uint32Array(g.index.array) + }; + } + const bones = [ + { head: [0, 0, 0], tail: [0, 1, 0] }, + { head: [0, 1, 0], tail: [0, 2, 0] } + ]; + it('heat weights: each half follows its bone, the joint blends, every vertex sums to 1', () => { + const m = bar(); + const { joints, weights } = heatWeights(m, bones); + const n = m.positions.length / 3; + const w1 = (i) => + [0, 1, 2, 3].reduce((s, k) => s + (joints[i * 4 + k] === 1 ? weights[i * 4 + k] : 0), 0); + for (let i = 0; i < n; i++) { + const sum = weights[i * 4] + weights[i * 4 + 1] + weights[i * 4 + 2] + weights[i * 4 + 3]; + expect(sum).toBeCloseTo(1, 5); + const y = m.positions[i * 3 + 1]; + if (y < 0.3) expect(w1(i)).toBeLessThan(0.2); + if (y > 1.7) expect(w1(i)).toBeGreaterThan(0.8); + if (Math.abs(y - 1) < 0.07) + (expect(w1(i)).toBeGreaterThan(0.2), expect(w1(i)).toBeLessThan(0.8)); + } + }); + + it('heat travels over the SURFACE: two separate legs never share a bone through the air', () => { + const a = new THREE.CylinderGeometry(0.05, 0.05, 1, 6, 6); + a.translate(-0.12, 0.5, 0); + const b = a.clone(); + b.translate(0.24, 0, 0); + const pos = new Float32Array([...a.attributes.position.array, ...b.attributes.position.array]); + const off = a.attributes.position.count; + const idx = new Uint32Array([...a.index.array, ...Array.from(b.index.array, (i) => i + off)]); + // bones running down the middle of each leg; a vertex on the inner side of the right leg is + // closer to the LEFT bone through the air than... it must still be the right leg's + const legs = [ + { head: [-0.12, 1, 0], tail: [-0.12, 0, 0] }, + { head: [0.12, 1, 0], tail: [0.12, 0, 0] } + ]; + const { joints, weights } = heatWeights({ positions: pos, index: idx }, legs); + for (let i = 0; i < pos.length / 3; i++) { + const own = pos[i * 3] < 0 ? 0 : 1; + const w = [0, 1, 2, 3].reduce( + (s, k) => s + (joints[i * 4 + k] === own ? weights[i * 4 + k] : 0), + 0 + ); + expect(w).toBeGreaterThan(0.99); + } + }); + + it('smooth softens a hard cut; normalize caps at 4 and sums to 1; the brush adds around a point', () => { + const m = bar(); + const n = m.positions.length / 3; + const J = new Uint16Array(n * 4); + const W = new Float32Array(n * 4); + for (let i = 0; i < n; i++) { + J[i * 4] = m.positions[i * 3 + 1] < 1 ? 0 : 1; + W[i * 4] = 1; + } + const s = smoothWeights(m, J, W, { steps: 3 }); + let blended = 0; + for (let i = 0; i < n; i++) if (s.weights[i * 4] < 0.999) blended++; + expect(blended).toBeGreaterThan(0); + const raw = new Float32Array([0.5, 0.5, 0.5, 0.5]); + const nz = normalizeWeights(new Uint16Array([3, 2, 1, 0]), raw); + expect(nz.weights.reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6); + const touched = brushWeights(m.positions, J, W, [0, 0.5, 0], 0.3, 1, 0.8); + expect(touched).toBeGreaterThan(0); + const g = surfaceGraph(m); + expect(g.count).toBeLessThan(n); // the cylinder seam welds + }); +}); From 43811766e03b3b7842e749b213c150a7d7cd7ed5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 05:42:24 +0300 Subject: [PATCH 06/11] [fix] 57-chars M2: read Rigify / Quaternius UAL names (DEF-, three-sanitized .L/.R) and skip UE5 leaf tips + finger metacarpals - the UAL 1 Standard rig is Rigify (DEF-upper_arm.L -> 'DEF-upper_armL' once three drops the '.'), UAL 2 is Unreal-named with _leaf_ tip bones that were read as finger roots - unit: a Rigify case (raw + sanitized) and UE5 leaf / metacarpal bones Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/humanoid/humanoidMap.js | 15 ++++++++++++--- tests/unit/humanoidMap.test.js | 30 ++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/lib/humanoid/humanoidMap.js b/src/lib/humanoid/humanoidMap.js index 31c4adbb4..1a60fac29 100644 --- a/src/lib/humanoid/humanoidMap.js +++ b/src/lib/humanoid/humanoidMap.js @@ -132,7 +132,7 @@ const PART_RULES = /** @type {[RegExp, string][]} */ ([ /** a bone that is only an END marker or a helper — never a humanoid bone */ const IGNORE = - /(end$|_end|top$|front$|nub$|tip$|slot|ik|pole|target|control|roll|twist|socket|weapon|prop|root$|^root|armature|^rig$)/i; + /(end$|_end|top$|front$|nub$|tip$|leaf|slot|ik|pole|target|control|roll|twist|socket|weapon|prop|root$|^root|armature|^rig$)/i; /** * Read one bone name: its side and the body part it names. @@ -159,6 +159,11 @@ export function readBoneName(raw) { } else if ((m = n.match(/[_.\-\s]([LlRr])$/))) { side = sideOf(m[1]); n = n.slice(0, -2); + } else if ((m = n.match(/[a-z0-9]([LR])$/)) || (m = n.match(/\d([lr])$/))) { + // three's GLTFLoader drops the '.' of Blender / Rigify names: "upper_arm.L" -> "upper_armL", + // "f_index.01.L" -> "f_index01L" (a capital L/R after lowercase, or l/r after a digit) + side = sideOf(m[1]); + n = n.slice(0, -1); } let rest = n.toLowerCase().replace(SEP, ''); // three's GLTFLoader drops the '.' from "upperarm.l" ("upperarml"): a known part word followed @@ -214,6 +219,7 @@ export function rigFamily(names) { if (has(/^J_Bip_/)) return 'vroid'; if (has(/^(pelvis|clavicle_[lr]|thigh_[lr]|calf_[lr])$/i)) return 'unreal'; if (has(/^(upperarm\.?l|handslot\.?l|kneeIK\.?l)$/i)) return 'kaykit'; + if (has(/^DEF-(hips|spine|upper_arm)/)) return 'rigify'; if (has(/^Spine02$/) && has(/^neck$/) && has(/^head_end$/)) return 'meshy'; if (has(/^(LeftUpLeg|LeftForeArm)$/)) return 'mixamo'; if (names.length && names.every((n) => /^(bone|joint)[_.]?\d+$/i.test(n))) return 'unirig'; @@ -423,10 +429,12 @@ function assignFingers(map, side, bones, kids, info, hand) { const stack = [...kids[hand]]; while (stack.length) { const j = /** @type {number} */ (stack.pop()); + // an index / middle / ring / little METACARPAL (UE5) is not a VRM finger bone — the thumb's is if ( info[j].part === 'finger' && word.test(info[j].rest) && - (!info[j].side || info[j].side === side) + (!info[j].side || info[j].side === side) && + (finger === 'Thumb' || !/metacarpal/.test(info[j].rest)) ) c.push(j); stack.push(...kids[j]); @@ -606,7 +614,8 @@ export function rigLabel(rig) { /** @type {Record} */ ({ mixamo: 'Mixamo', vroid: 'VRoid', - unreal: 'Unreal / Quaternius', + unreal: 'Unreal', + rigify: 'Rigify / Quaternius', kaykit: 'KayKit', meshy: 'Meshy', unirig: 'UniRig', diff --git a/tests/unit/humanoidMap.test.js b/tests/unit/humanoidMap.test.js index fce72cf07..e1c7ff006 100644 --- a/tests/unit/humanoidMap.test.js +++ b/tests/unit/humanoidMap.test.js @@ -149,15 +149,41 @@ describe('57 M2 humanoid map detection', () => { expect(v.map).toMatchObject({ hips: 'J_Bip_C_Hips', upperChest: 'J_Bip_C_UpperChest', leftHand: 'J_Bip_L_Hand', rightLittleDistal: 'J_Bip_R_Little3', leftToes: 'J_Bip_L_ToeBase' }); expect(rigFamily(vroid.map((b) => b.name))).toBe('vroid'); - const ueArm = (s, x) => ['clavicle_' + s, [0.05 * x, 0.1, 0], ['upperarm_' + s, [0.1 * x, 0, 0], ['upperarm_twist_01_' + s, [0.1 * x, 0, 0]], ['lowerarm_' + s, [0.25 * x, 0, 0], ['hand_' + s, [0.25 * x, 0, 0], ['thumb_01_' + s, [0.02 * x, 0, 0.03], ['thumb_02_' + s, [0.02 * x, 0, 0], ['thumb_03_' + s, [0.02 * x, 0, 0]]]]]]]]; + const ueArm = (s, x) => ['clavicle_' + s, [0.05 * x, 0.1, 0], ['upperarm_' + s, [0.1 * x, 0, 0], ['upperarm_twist_01_' + s, [0.1 * x, 0, 0]], ['lowerarm_' + s, [0.25 * x, 0, 0], ['hand_' + s, [0.25 * x, 0, 0], ['thumb_01_' + s, [0.02 * x, 0, 0.03], ['thumb_02_' + s, [0.02 * x, 0, 0], ['thumb_03_' + s, [0.02 * x, 0, 0], ['thumb_04_leaf_' + s, [0.01 * x, 0, 0]]]]], ['index_metacarpal_' + s, [0.03 * x, 0, 0.01], ['index_01_' + s, [0.05 * x, 0, 0], ['index_02_' + s, [0.02 * x, 0, 0], ['index_03_' + s, [0.02 * x, 0, 0], ['index_04_leaf_' + s, [0.01 * x, 0, 0]]]]]]]]]]; const ueLeg = (s, x) => ['thigh_' + s, [0.1 * x, -0.05, 0], ['calf_' + s, [0, -0.45, 0], ['calf_twist_01_' + s, [0, -0.2, 0]], ['foot_' + s, [0, -0.45, 0], ['ball_' + s, [0, -0.05, 0.1]]]]]; const ue = tree(['root', [0, 0, 0], ['pelvis', [0, 1, 0], ueLeg('l', 1), ueLeg('r', -1), ['spine_01', [0, 0.1, 0], ['spine_02', [0, 0.1, 0], ['spine_03', [0, 0.1, 0], ueArm('l', 1), ueArm('r', -1), ['neck_01', [0, 0.1, 0], ['head', [0, 0.1, 0]]]]]]], ['ik_foot_root', [0, 0, 0]]]); const u = detectHumanoidMap(ue); expect(u.ok).toBe(true); - expect(u.map).toMatchObject({ hips: 'pelvis', spine: 'spine_01', chest: 'spine_02', upperChest: 'spine_03', neck: 'neck_01', leftShoulder: 'clavicle_l', leftUpperArm: 'upperarm_l', rightLowerLeg: 'calf_r', leftToes: 'ball_l', leftThumbProximal: 'thumb_02_l' }); + expect(u.map).toMatchObject({ hips: 'pelvis', spine: 'spine_01', chest: 'spine_02', upperChest: 'spine_03', neck: 'neck_01', leftShoulder: 'clavicle_l', leftUpperArm: 'upperarm_l', rightLowerLeg: 'calf_r', leftToes: 'ball_l', leftThumbProximal: 'thumb_02_l', leftThumbMetacarpal: 'thumb_01_l', leftIndexProximal: 'index_01_l', rightIndexDistal: 'index_03_r' }); expect(rigFamily(ue.map((b) => b.name))).toBe('unreal'); }); + it('Rigify / Quaternius UAL (DEF- names, raw and as three sanitizes them)', () => { + const fingers = (S, x) => ['index', 'middle', 'ring', 'pinky'].map((f, k) => ['DEF-f_' + f + '.01.' + S, [0.08 * x, 0, 0.02 - k * 0.01], ['DEF-f_' + f + '.02.' + S, [0.02 * x, 0, 0], ['DEF-f_' + f + '.03.' + S, [0.02 * x, 0, 0]]]]); + const arm = (S, x) => ['DEF-shoulder.' + S, [0.05 * x, 0.1, 0], ['DEF-upper_arm.' + S, [0.1 * x, 0, 0], ['DEF-forearm.' + S, [0.25 * x, 0, 0], ['DEF-hand.' + S, [0.25 * x, 0, 0], ...fingers(S, x), ['DEF-thumb.01.' + S, [0.02 * x, 0, 0.03], ['DEF-thumb.02.' + S, [0.02 * x, 0, 0], ['DEF-thumb.03.' + S, [0.02 * x, 0, 0]]]]]]]]; + const leg = (S, x) => ['DEF-thigh.' + S, [0.1 * x, -0.05, 0], ['DEF-shin.' + S, [0, -0.45, 0], ['DEF-foot.' + S, [0, -0.45, 0], ['DEF-toe.' + S, [0, -0.05, 0.1]]]]]; + const raw = tree(['root', [0, 0, 0], ['DEF-hips', [0, 1, 0], ['DEF-spine.001', [0, 0.1, 0], ['DEF-spine.002', [0, 0.1, 0], ['DEF-spine.003', [0, 0.1, 0], ['DEF-neck', [0, 0.1, 0], ['DEF-head', [0, 0.1, 0]]], arm('L', 1), arm('R', -1)]]], leg('L', 1), leg('R', -1)]]); + for (const sanitize of [false, true]) { + const bones = raw.map((b) => ({ ...b, name: sanitize ? b.name.replace(/[\[\]\.:\/]/g, '') : b.name })); + const m = detectHumanoidMap(bones); + const n = (x) => (sanitize ? x.replace(/\./g, '') : x); + expect(m.ok, 'sanitized ' + sanitize).toBe(true); + expect(m.map).toMatchObject({ + hips: 'DEF-hips', + spine: n('DEF-spine.001'), + upperChest: n('DEF-spine.003'), + leftUpperArm: n('DEF-upper_arm.L'), + rightLowerArm: n('DEF-forearm.R'), + leftLowerLeg: n('DEF-shin.L'), + rightToes: n('DEF-toe.R'), + leftIndexDistal: n('DEF-f_index.03.L'), + rightLittleProximal: n('DEF-f_pinky.01.R'), + leftThumbMetacarpal: n('DEF-thumb.01.L') + }); + expect(rigFamily(bones.map((b) => b.name))).toBe('rigify'); + } + }); + it('numbered bones (UniRig) fall back to the SHAPE: legs down, arms sideways, left = +X', () => { let n = 0; const b = (off, ...kids) => ['bone_' + n++, off, ...kids]; From 1d28062f5fc0f42a9da0bbf3e1d763db1ad4408c Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 05:51:08 +0300 Subject: [PATCH 07/11] =?UTF-8?q?[fix]=2057=20M1:=20format=20fixes=20?= =?UTF-8?q?=E2=80=94=20floor-rest=20rigs,=20raw=20node=20names,=20skeleton?= =?UTF-8?q?=20positions;=20converter=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toHumanoid: a rig whose REST hips sit at the floor (a BVH keeps the height in the motion) stands at its first frame — VRMA scales the hips by the rest height, and zero means nothing - convert (BVH): centimetres detected from the first frame's root height, not the rest pose - tpToThreeClip(tp, root): each node name resolves against THAT model (GLTFLoader sanitizes 'Door Left' -> 'Door_Left'; an object named in the app keeps its name); clipFit matches both - nodePositionsAt(tp, t): the file's own skeleton posed at t (thumbnails, model-less previews) - unit: tpanimConvert (BVH -> humanoid in metres, an animation GLB split per animation, .vrma keeps its humanoid, a model GLB is left alone), nodePositionsAt Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/tpanim/convert.js | 10 ++-- src/lib/tpanim/format.js | 44 +++++++++++++++--- tests/unit/tpanimConvert.test.js | 80 ++++++++++++++++++++++++++++++++ tests/unit/tpanimFormat.test.js | 14 +++++- 4 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 tests/unit/tpanimConvert.test.js diff --git a/src/lib/tpanim/convert.js b/src/lib/tpanim/convert.js index 9ae29a4b3..6b2f08eb5 100644 --- a/src/lib/tpanim/convert.js +++ b/src/lib/tpanim/convert.js @@ -78,9 +78,13 @@ export async function convertAnimationFile(file) { root.add(holder); holder.add(res.skeleton.bones[0]); root.updateMatrixWorld(true); - // mocap is usually in centimetres: a hips height over 20 units is, so the clip is stored in metres - const hipsY = new THREE.Vector3().setFromMatrixPosition(res.skeleton.bones[0].matrixWorld).y; - if (hipsY > 20) holder.scale.setScalar(0.01); + // mocap is usually in centimetres: a standing hips height over 20 units is, so the clip is + // stored in metres. A BVH rests its root at the floor and puts the height in the MOTION, so + // the height is read from the first frame's root position, not the rest pose. + const rootBone = res.skeleton.bones[0]; + const rootPos = res.clip.tracks.find((/** @type {any} */ t) => t.name === rootBone.name + '.position'); + const hipsY = rootPos ? rootPos.values[1] : new THREE.Vector3().setFromMatrixPosition(rootBone.matrixWorld).y; + if (Math.abs(hipsY) > 20) holder.scale.setScalar(0.01); const tp = maybeHumanoid(tpFromThreeClip(root, res.clip, { name: base, provenance: provenance(file.name) })); return [clipFile(base, encodeTpanim(tp))]; } diff --git a/src/lib/tpanim/format.js b/src/lib/tpanim/format.js index ec29b581f..05c9d980c 100644 --- a/src/lib/tpanim/format.js +++ b/src/lib/tpanim/format.js @@ -365,11 +365,23 @@ export function tpFromThreeClip(root, clip, opts = {}) { /** * TpClip -> an AnimationClip bound by node name (the file's own skeleton, or a model with those * bones). A humanoid clip's tracks then target its humanoid-named nodes — bind it to a model with - * `bindHumanoid` instead. - * @param {TpClip} tp @returns {THREE.AnimationClip} + * `bindHumanoid` instead. With `root`, each name resolves against THAT model: GLTFLoader sanitizes + * node names ('Door Left' -> 'Door_Left'), an object made or renamed in the app does not. + * @param {TpClip} tp @param {THREE.Object3D} [root] @returns {THREE.AnimationClip} */ -export function tpToThreeClip(tp) { - const tracks = tp.tracks.map((tr) => makeTrack(sanitizeName(tp.nodes[tr.node].name) + '.' + PROPS[tr.path], tr)); +export function tpToThreeClip(tp, root) { + /** @type {Set | null} */ + let names = null; + if (root) { + names = new Set(); + root.traverse((o) => /** @type {Set} */ (names).add(o.name)); + } + const bindName = (/** @type {string} */ raw) => { + const clean = sanitizeName(raw); + if (names && !names.has(clean) && names.has(raw) && !/[[\].:/]/.test(raw)) return raw; + return clean; + }; + const tracks = tp.tracks.map((tr) => makeTrack(bindName(tp.nodes[tr.node].name) + '.' + PROPS[tr.path], tr)); return new THREE.AnimationClip(tp.name, tp.duration, tracks); } @@ -500,6 +512,9 @@ export function toHumanoid(tp, map) { const off = pi >= 0 ? restPos[i].clone().sub(restPos[pi]) : restPos[i].clone(); return { name: b, parent: pi, t: off.toArray(), r: [0, 0, 0, 1], s: [1, 1, 1] }; }); + // a rig whose REST hips sit at the floor (a BVH: the height lives in the motion) stands at its + // first frame instead — VRMA scales the hips by the rest height, and a zero there means nothing + if (restPos[0].y < 1e-3 && hipsPos[1] > 1e-3) nodes[0].t = [hipsPos[0], hipsPos[1], hipsPos[2]]; /** @type {TpTrack[]} */ const tracks = []; bones.forEach((b, i) => { @@ -676,9 +691,12 @@ export function clipFit(tp, root, map) { return { fit: ok ? 'humanoid' : 'none', matched: ok ? 1 : 0, total: 1 }; } const names = new Set(); - root.traverse((o) => names.add(sanitizeName(o.name))); - const animated = [...new Set(tp.tracks.map((tr) => sanitizeName(tp.nodes[tr.node].name)))]; - const matched = animated.filter((n) => names.has(n)).length; + root.traverse((o) => { + names.add(sanitizeName(o.name)); + names.add(o.name); + }); + const animated = [...new Set(tp.tracks.map((tr) => tp.nodes[tr.node].name))]; + const matched = animated.filter((n) => names.has(n) || names.has(sanitizeName(n))).length; if (tp.sig && tp.sig === modelSignature(root) && matched === animated.length) return { fit: 'exact', matched, total: animated.length }; return { fit: matched === 0 ? 'none' : matched === animated.length ? 'exact' : 'partial', matched, total: animated.length }; } @@ -697,3 +715,15 @@ export function clipFacts(tp) { tracks: tp.tracks.length }; } + +/** + * Every node's position in the file's root frame at time t (xyz per node) — a skeleton drawn + * from the clip alone (the Explorer thumbnail, a preview with no model). + * @param {TpClip} tp @param {number} t @returns {Float32Array} + */ +export function nodePositionsAt(tp, t) { + const world = worldMatrices(tp, poseAt(tp, interpolants(tp), t)); + const out = new Float32Array(world.length * 3); + world.forEach((m, i) => posOf(m).toArray(out, i * 3)); + return out; +} diff --git a/tests/unit/tpanimConvert.test.js b/tests/unit/tpanimConvert.test.js new file mode 100644 index 000000000..09c5909d5 --- /dev/null +++ b/tests/unit/tpanimConvert.test.js @@ -0,0 +1,80 @@ +// @ts-nocheck — synthetic animation files built inline +// 57 M1 — import conversion: a Mixamo-named BVH becomes a HUMANOID .tpanim (in metres), an +// animation-only GLB becomes one .tpanim per animation, a .vrma keeps its humanoid and gains our +// extras, a model GLB is left alone. +import { describe, it, expect } from 'vitest'; +import * as THREE from 'three'; +import { convertAnimationFile } from '../../src/lib/tpanim/convert.js'; +import { decodeTpanim, encodeTpanim, tpFromThreeClip, toHumanoid, humanoidFrames } from '../../src/lib/tpanim/format.js'; +import { packGlb } from '../../src/lib/tpanim/glb.js'; +import { makeRig, makeClip, captureRest } from './helpers/tpanimRig.js'; + +/** a small Mixamo-named BVH: hips in cm, the spine, a head, both arms and legs; 3 frames at 30 fps */ +function bvh() { + const j = (name, off, kids = '', end = false) => + `JOINT ${name}\n{\nOFFSET ${off}\nCHANNELS 3 Zrotation Xrotation Yrotation\n${kids}${end ? 'End Site\n{\nOFFSET 0 5 0\n}\n' : ''}}\n`; + const arm = (s, x) => j(s + 'Shoulder', `${x * 5} 10 0`, j(s + 'Arm', `${x * 10} 0 0`, j(s + 'ForeArm', `${x * 25} 0 0`, j(s + 'Hand', `${x * 22} 0 0`, '', true)))); + const leg = (s, x) => j(s + 'UpLeg', `${x * 9} -5 0`, j(s + 'Leg', '0 -42 0', j(s + 'Foot', '0 -42 0', '', true))); + const spine = j('Spine', '0 10 0', j('Spine1', '0 12 0', j('Spine2', '0 12 0', j('Neck', '0 14 0', j('Head', '0 8 0', '', true)) + arm('Left', 1) + arm('Right', -1)))); + const hier = `HIERARCHY\nROOT Hips\n{\nOFFSET 0 0 0\nCHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation\n${spine}${leg('Left', 1)}${leg('Right', -1)}}\n`; + const joints = (hier.match(/CHANNELS 3/g) ?? []).length; + const frame = (f) => [0, 100, f * 4, 0, 0, f * 5, ...Array.from({ length: joints * 3 }, (_, i) => (i % 3 === 1 ? f * 10 : 0))].join(' '); + return hier + `MOTION\nFrames: 3\nFrame Time: 0.0333333\n${[0, 1, 2].map(frame).join('\n')}\n`; +} + +describe('convertAnimationFile', () => { + it('a Mixamo-named BVH becomes one HUMANOID clip, stored in metres', async () => { + const out = await convertAnimationFile(new File([bvh()], 'walk.bvh')); + expect(out.map((f) => f.name)).toEqual(['walk.tpanim']); + const tp = decodeTpanim(await out[0].arrayBuffer()); + expect(tp.tpExtras).toBe(true); + expect(tp.humanoid?.hips).toBeDefined(); + expect(tp.humanoid?.leftUpperArm).toBeDefined(); + expect(tp.fps).toBe(30); + expect(tp.provenance?.source).toBe('walk.bvh'); + const fr = humanoidFrames(tp); + expect(fr.restHipsY).toBeCloseTo(1, 3); // 100 cm hips -> 1 m + // the hips travel 4 cm a frame along z in the source + expect(fr.hips[2 * 3 + 2] - fr.hips[2]).toBeCloseTo(0.08, 3); + }); + + it('an animation-only GLB becomes one clip per animation; a model GLB is not touched', async () => { + const { root, map } = makeRig(); + const rest = captureRest(root); + const a = tpFromThreeClip(root, makeClip(), { rest }); + // a plain two-animation GLB (no extras): nodes + two animations + const bytes = encodeTpanim({ ...a, tpExtras: false }); + const { unpackGlb } = await import('../../src/lib/tpanim/glb.js'); + const { json, bin } = unpackGlb(bytes); + delete json.extras; + json.animations.push({ ...json.animations[0], name: 'Wave' }); + const two = packGlb(json, bin); + const out = await convertAnimationFile(new File([two], 'moves.glb')); + expect(out.map((f) => f.name)).toEqual(['moves - Walk.tpanim', 'moves - Wave.tpanim']); + const wave = decodeTpanim(await out[1].arrayBuffer()); + expect(wave.name).toBe('Wave'); + expect(wave.humanoid?.hips).toBeDefined(); // the Mixamo names made it humanoid + // a GLB with a mesh is a model + const model = packGlb({ asset: { version: '2.0' }, meshes: [{ primitives: [] }], nodes: [{ mesh: 0 }], scenes: [{ nodes: [0] }] }, null); + expect(await convertAnimationFile(new File([model], 'chair.glb'))).toBe(null); + expect(map.hips).toBe('mixamorigHips'); + }); + + it('a .vrma keeps its humanoid and gains our extras', async () => { + const { root, map } = makeRig(); + const hum = toHumanoid(tpFromThreeClip(root, makeClip(), { rest: captureRest(root) }), map); + const raw = encodeTpanim(hum); + const { unpackGlb } = await import('../../src/lib/tpanim/glb.js'); + const { json, bin } = unpackGlb(raw); + delete json.extras; // as another tool would write it + const out = await convertAnimationFile(new File([packGlb(json, bin)], 'dance.vrma')); + const tp = decodeTpanim(await out[0].arrayBuffer()); + expect(out[0].name).toBe('dance.tpanim'); + expect(tp.tpExtras).toBe(true); + expect(Object.keys(tp.humanoid).length).toBe(Object.keys(hum.humanoid).length); + }); + + it('other files are not animation files', async () => { + expect(await convertAnimationFile(new File(['x'], 'notes.txt'))).toBe(null); + }); +}); diff --git a/tests/unit/tpanimFormat.test.js b/tests/unit/tpanimFormat.test.js index 15f0755c9..3def1bee1 100644 --- a/tests/unit/tpanimFormat.test.js +++ b/tests/unit/tpanimFormat.test.js @@ -14,7 +14,8 @@ import { clipFit, detectFps, glbAnimationNames, - glbHasMeshes + glbHasMeshes, + nodePositionsAt } from '../../src/lib/tpanim/format.js'; import { isHumanoidMap, VRM_HUMAN_BONE_PARENTS } from '../../src/lib/tpanim/humanoidBones.js'; import { makeRig, makeClip, captureRest } from './helpers/tpanimRig.js'; @@ -114,6 +115,17 @@ describe('tpanim: skeleton clips', () => { } }); + it('poses its own skeleton (the thumbnail stick figure) where the model would be', () => { + const { root } = makeRig(); + const rest = captureRest(root); + const clip = makeClip(); + const tp = tpFromThreeClip(root, clip, { rest }); + const pos = nodePositionsAt(tp, 0.5); + const want = sampleWorld(root, clip, 0.5, ['mixamorigHead'])[0]; + const i = tp.nodes.findIndex((n) => n.name === 'mixamorigHead'); + expect(Math.hypot(pos[i * 3] - want.x, pos[i * 3 + 1] - want.y, pos[i * 3 + 2] - want.z)).toBeLessThan(1e-4); + }); + it('reports fit by signature and by node name', () => { const a = makeRig(); const tp = tpFromThreeClip(a.root, makeClip()); From 53f84517d75e24b903d77384356fafd0fdb15192 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 05:52:09 +0300 Subject: [PATCH 08/11] [feat] 57-chars M5: an Avatars tab in the Asset Browser (the Open Source Avatars registry) - avatars/osaRegistry.js: the registry at a PINNED commit through the CDN and the CC0 list cache; CC0 collections by default, CC-BY only on request; VRM + public + https rows only - Add to scene places the VRM as a character with a provenance step naming the collection's licence and author (fileHandler importFile opts.provenance), so a CC-BY avatar's credit reaches the M7 warnings and CREDITS.txt - unit: osaRegistry (2); e2e: character-avatars-source (routed registry + VRM fixture) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/editors/AssetBrowser.svelte | 344 +++++++++++++++++--- src/lib/avatars/osaRegistry.js | 125 +++++++ src/lib/cc0/cc0Core.js | 7 +- src/lib/fileHandler.svelte.js | 8 +- tests/e2e/character-avatars-source.test.cjs | 143 ++++++++ tests/unit/osaRegistry.test.js | 85 +++++ 6 files changed, 664 insertions(+), 48 deletions(-) create mode 100644 src/lib/avatars/osaRegistry.js create mode 100644 tests/e2e/character-avatars-source.test.cjs create mode 100644 tests/unit/osaRegistry.test.js diff --git a/src/components/editors/AssetBrowser.svelte b/src/components/editors/AssetBrowser.svelte index b9ed92684..fb0a52e81 100644 --- a/src/components/editors/AssetBrowser.svelte +++ b/src/components/editors/AssetBrowser.svelte @@ -17,7 +17,11 @@ import { bottomDockable } from '$lib/bottomDockDrop'; import { focusStack } from '$lib/windowFocus'; import { minimalScroll } from '$lib/ui/minimalScroll.js'; - import { assetBrowserOpen, assetBrowserKind, assetBrowserRes } from '$lib/cc0/assetBrowserState.js'; + import { + assetBrowserOpen, + assetBrowserKind, + assetBrowserRes + } from '$lib/cc0/assetBrowserState.js'; import { CC0_KINDS, CC0_SITES, @@ -31,8 +35,22 @@ filterRows, creditLine } from '$lib/cc0/cc0Core.js'; - import { phList, phCategories, acgPage, acgCategories, forgetCc0Memo } from '$lib/cc0/cc0Fetch.js'; - import { importCc0, cc0Jobs, rowKey, heldItemFor, placeItem, applyPresetToSelection, errorText } from '$lib/cc0/cc0Import.js'; + import { + phList, + phCategories, + acgPage, + acgCategories, + forgetCc0Memo + } from '$lib/cc0/cc0Fetch.js'; + import { + importCc0, + cc0Jobs, + rowKey, + heldItemFor, + placeItem, + applyPresetToSelection, + errorText + } from '$lib/cc0/cc0Import.js'; import { explorerItems, revealItem } from '$lib/explorer'; import { editEnvSky } from '$lib/environment'; import { materialPresets } from '$lib/materialPresets'; @@ -65,9 +83,12 @@ /** a load in flight is superseded by the next one (tab switch, a new search) */ let ticket = 0; - const filtered = $derived(kind.source === 'polyhaven' ? filterRows(rows, { query, category }) : rows); - const visible = $derived(kind.source === 'polyhaven' ? filtered.slice(0, shown) : filtered); - const more = $derived(kind.source === 'polyhaven' ? filtered.length > shown : rows.length < acgTotal); + /** 57 M5: the Avatars tab lists CC-BY collections only when asked (their credit is kept) */ + let includeCcBy = $state(false); + const listed = $derived(kind.source === 'polyhaven' || kind.source === 'osa'); + const filtered = $derived(listed ? filterRows(rows, { query, category }) : rows); + const visible = $derived(listed ? filtered.slice(0, shown) : filtered); + const more = $derived(listed ? filtered.length > shown : rows.length < acgTotal); const selectionCount = $derived(($selectedObjects ?? []).length); /** @param {boolean} [fresh] */ @@ -79,13 +100,27 @@ staleWhy = ''; try { if (kind.source === 'polyhaven') { - const [list, cats] = await Promise.all([phList(kind.phType ?? '', fresh), phCategories(kind.phType ?? '', fresh).catch(() => null)]); + const [list, cats] = await Promise.all([ + phList(kind.phType ?? '', fresh), + phCategories(kind.phType ?? '', fresh).catch(() => null) + ]); if (my !== ticket) return; rows = normalizePolyHaven(list.data, /** @type {any} */ (kind.key)); categories = cats ? normalizePhCategories(cats.data) : []; noteStale(list); + } else if (kind.source === 'osa') { + // 57 M5: the Open Source Avatars registry (VRM avatars; CC0 unless CC-BY is asked for) + const { osaList } = await import('$lib/avatars/osaRegistry.js'); + const list = await osaList({ fresh, includeCcBy }); + if (my !== ticket) return; + rows = list.rows; + categories = list.categories; + noteStale(list); } else { - const [page, cats] = await Promise.all([acgPage({ query: query.trim(), category, fresh }), acgCategories(fresh).catch(() => null)]); + const [page, cats] = await Promise.all([ + acgPage({ query: query.trim(), category, fresh }), + acgCategories(fresh).catch(() => null) + ]); if (my !== ticket) return; rows = normalizeAmbientCg(page.data); acgTotal = ambientCgTotal(page.data); @@ -172,7 +207,11 @@ /** the preset this browser made for a material row, if it is still in the library * @param {any} row @param {any[]} list */ function heldPreset(row, list) { - return list.find((p) => p.payload?.source?.site === row.source && p.payload?.source?.id === row.id) ?? null; + return ( + list.find( + (p) => p.payload?.source?.site === row.source && p.payload?.source?.id === row.id + ) ?? null + ); } /** @param {any} row @param {any[]} _items (an unused argument so the card re-derives on a Library change) */ const heldItem = (row, _items) => heldItemFor(row); @@ -184,10 +223,21 @@ try { const made = await importCc0(row, $assetBrowserRes, opts); if (made.item) { - if (row.kind === 'hdris') showToast((opts.applySky === false ? 'Added ' : 'Lighting the scene with ') + made.item.name); - else showToast('Added ' + made.item.name + ' to the Library', [{ label: 'Show', action: () => show(made.item) }]); + if (row.kind === 'hdris') + showToast( + (opts.applySky === false ? 'Added ' : 'Lighting the scene with ') + made.item.name + ); + else + showToast('Added ' + made.item.name + ' to the Library', [ + { label: 'Show', action: () => show(made.item) } + ]); } else if (made.preset) { - showToast('Saved ' + made.preset.name + ' to your materials' + (opts.applyToSelection && selectionCount ? ' and applied it' : '')); + showToast( + 'Saved ' + + made.preset.name + + ' to your materials' + + (opts.applyToSelection && selectionCount ? ' and applied it' : '') + ); } return made; } catch (err) { @@ -209,6 +259,46 @@ if (held) await placeItem(held); } + /** 57 M5: an avatar goes straight into the scene as a VRM character, its credit kept @type {Record} */ + let placing = $state({}); + /** @param {any} row */ + async function placeAvatar(row) { + const key = rowKey(row); + placing = { ...placing, [key]: true }; + cardError = { ...cardError, [key]: '' }; + try { + const res = await fetch(row.vrm); + if (!res.ok) throw new Error('the avatar file is unreachable (HTTP ' + res.status + ')'); + const bytes = await res.arrayBuffer(); + const { importFile } = await import('$lib/fileHandler.svelte.js'); + const { osaProvenance } = await import('$lib/avatars/osaRegistry.js'); + const uuid = await importFile( + new File([bytes], row.name + '.vrm'), + row.name, + 'vrm', + undefined, + undefined, + { provenance: osaProvenance(row) } + ); + if (uuid) + showToast( + 'Placed ' + + row.name + + (row.licence === 'CC0-1.0' + ? ' (CC0)' + : ' — ' + + row.licence + + ': credit ' + + (row.authors[0] || row.project) + + ' where you share it') + ); + } catch (err) { + cardError = { ...cardError, [key]: errorText(err) }; + } finally { + placing = { ...placing, [key]: false }; + } + } + /** @param {any} row */ async function useSky(row) { const held = heldItemFor(row); @@ -253,19 +343,37 @@ id="asset-browser-window" class="ui-panel tp-ui tp-window tp-themed fixed flex flex-col overflow-hidden outline-hidden" tabindex="-1" - use:dragWindow={{ key: 'assetBrowserWindow', defaultRect: { left: 160, top: 96 }, resizable: true, minW: 300, minH: 260 }} + use:dragWindow={{ + key: 'assetBrowserWindow', + defaultRect: { left: 160, top: 96 }, + resizable: true, + minW: 300, + minH: 260 + }} use:bottomDockable={{ key: 'assets' }} use:focusStack={'assets'} style="z-index: var(--z-window); width: 560px; height: 600px" - data-keywords="cc0 free assets poly haven ambientcg models hdri sky textures materials download" + data-keywords="cc0 free assets poly haven ambientcg models hdri sky textures materials download avatars vrm characters" > -
+
@@ -279,23 +387,57 @@ onchange={(v) => assetBrowserKind.set(v)} />
- onSearch('')} /> + onSearch('')} + /> ({ value: c.id, name: c.label + ' (' + c.count + ')' }))]} + items={[ + { value: '', name: 'All categories' }, + ...categories.map((c) => ({ value: c.id, name: c.label + ' (' + c.count + ')' })) + ]} onchange={onCategory} /> ({ value: r, label: r, title: r === '1k' ? 'Small files — right for a Quest or a phone' : r === '2k' ? 'The desktop default' : 'Big files: slow to share with peers' }))} + options={RESOLUTIONS.map((r) => ({ + value: r, + label: r, + title: + r === '1k' + ? 'Small files — right for a Quest or a phone' + : r === '2k' + ? 'The desktop default' + : 'Big files: slow to share with peers' + }))} value={$assetBrowserRes} onchange={(v) => assetBrowserRes.set(v)} /> + {#if kind.source === 'osa'} + + {/if}
@@ -303,59 +445,154 @@

{/if} -
+
{#if listError} {:else if loading && !rows.length} -

+

+

{:else if !visible.length} -

Nothing matches{query ? ' “' + query + '”' : ''}. Try another word or category.

+

+ Nothing matches{query ? ' “' + query + '”' : ''}. Try another word or category. +

{/if}
    {#each visible as row (rowKey(row))} {@const key = rowKey(row)} {@const job = $cc0Jobs[key]} - {@const item = row.kind === 'models' || row.kind === 'hdris' ? heldItem(row, $explorerItems) : null} - {@const preset = row.kind === 'textures' || row.kind === 'materials' ? heldPreset(row, $materialPresets) : null} -
  • + {@const item = + row.kind === 'models' || row.kind === 'hdris' ? heldItem(row, $explorerItems) : null} + {@const preset = + row.kind === 'textures' || row.kind === 'materials' + ? heldPreset(row, $materialPresets) + : null} +
  • {#if row.thumb} - /** @type {any} */ (e.currentTarget).classList.add('ab-noimg')} /> + /** @type {any} */ (e.currentTarget).classList.add('ab-noimg')} + /> {/if} - {#if item || preset}{/if} + {#if item || preset}{/if}

    {row.name}

    -

    {row.authors.join(', ') || CC0_SITES[/** @type {'polyhaven'|'ambientcg'} */ (row.source)].label}

    +

    + {row.authors.join(', ') || + CC0_SITES[/** @type {'polyhaven'|'ambientcg'} */ (row.source)].label}{row.source === + 'osa' + ? ' · ' + (row.licence === 'CC0-1.0' ? 'CC0' : row.licence) + : ''} +

    {#if job} -
    - +
    +
    -

    {job.phase} {job.total ? mb(job.done) + ' / ' + mb(job.total) : ''}

    +

    + {job.phase} + {job.total ? mb(job.done) + ' / ' + mb(job.total) : ''} +

    {:else}
    - {#if row.kind === 'models'} + {#if row.kind === 'avatars'} + + {:else if row.kind === 'models'} {#if item} - - + + {:else} - + {/if} {:else if row.kind === 'hdris'} - + {:else} {#if !preset} - + {/if} {/if} @@ -380,7 +619,8 @@ variant="ghost" size="sm" disabled={loading} - onclick={() => (kind.source === 'polyhaven' ? (shown += PAGE) : loadMoreAcg())}>Show more (kind.source === 'polyhaven' ? (shown += PAGE) : loadMoreAcg())} + >Show more
    {/if} @@ -388,8 +628,15 @@

    Every asset here is CC0 (public domain) — - Poly Haven and - ambientCG. Credits stay with each file. + Poly Haven + and + ambientCG{kind.source === 'osa' ? '' : '.'} Credits stay with each file. + {#if kind.source === 'osa'}Avatars: the Open Source Avatars registry — CC0 collections, CC-BY ones on request (their credit is kept).{/if}

    {/if} @@ -402,6 +649,13 @@ gap: 6px; padding: 6px 0 4px; } + .ab-ccby { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text-muted); + } .ab-filters :global(.ab-cat) { min-width: 120px; flex: 1 1 140px; diff --git a/src/lib/avatars/osaRegistry.js b/src/lib/avatars/osaRegistry.js new file mode 100644 index 000000000..1341ab015 --- /dev/null +++ b/src/lib/avatars/osaRegistry.js @@ -0,0 +1,125 @@ +// 57 M5 — the OPEN SOURCE AVATARS registry as an Asset Browser source ("Avatars"): free VRM avatars +// listed by github.com/ToxSam/open-source-avatars (the registry itself is CC0). Each COLLECTION states +// its licence; CC0 collections show by default, CC-BY ones only when asked for, and a placed avatar +// keeps its credit (its provenance licence names the collection's author → the M7 warnings and +// CREDITS.txt). The registry JSON comes through the CDN at a PINNED commit (a moved file can never +// change what a row means) and the local response cache; the VRM files are Arweave URLs (CORS *). +// Normalizing is pure (unit-tested); fetching reuses cc0Fetch's cache. + +export const OSA_COMMIT = '0f9a1b2fd99894736563d55b2c9dc9125700d081'; +export const OSA_BASE = + 'https://cdn.jsdelivr.net/gh/ToxSam/open-source-avatars@' + OSA_COMMIT + '/data'; +export const OSA_HOME = 'https://github.com/ToxSam/open-source-avatars'; + +/** the registry's licence names -> ids @param {any} l */ +export function osaLicence(l) { + const s = String(l ?? '') + .toUpperCase() + .replace(/\s+/g, ''); + if (s === 'CC0' || s === 'CC0-1.0') return 'CC0-1.0'; + if (s === 'CC-BY' || s === 'CCBY' || s === 'CC-BY-4.0') return 'CC-BY-4.0'; + return s || 'unknown'; +} + +/** + * Projects + their avatar lists -> Asset Browser rows (VRM only, public, not drafts). + * @param {any[]} projects @param {Record} avatarsByProject + * @param {{includeCcBy?: boolean}} [opts] + * @returns {any[]} + */ +export function normalizeOsa(projects, avatarsByProject, opts = {}) { + /** @type {any[]} */ + const out = []; + for (const p of Array.isArray(projects) ? projects : []) { + const licence = osaLicence(p?.license); + if (licence !== 'CC0-1.0' && !(opts.includeCcBy && licence === 'CC-BY-4.0')) continue; + const list = avatarsByProject[p.id] ?? []; + for (const a of Array.isArray(list) ? list : []) { + if (!a || a.is_public === false || a.is_draft === true) continue; + if ( + String(a.format ?? 'VRM').toUpperCase() !== 'VRM' || + typeof a.model_file_url !== 'string' || + !/^https:\/\//.test(a.model_file_url) + ) + continue; + out.push({ + source: 'osa', + kind: 'avatars', + id: String(a.id), + name: String(a.name ?? 'Avatar'), + thumb: typeof a.thumbnail_url === 'string' ? a.thumbnail_url : '', + vrm: a.model_file_url, + authors: [String(p.creator_id ?? '').replace(/-/g, ' ') || String(p.name ?? '')].filter( + Boolean + ), + categories: [String(p.id)], + tags: [String(p.name ?? ''), licence], + project: String(p.name ?? p.id), + licence, + url: OSA_HOME + }); + } + } + return out; +} + +/** the projects as categories @param {any[]} projects @param {any[]} rows */ +export function osaCategories(projects, rows) { + return (Array.isArray(projects) ? projects : []) + .map((p) => ({ + id: String(p.id), + label: String(p.name ?? p.id), + count: rows.filter((r) => r.categories[0] === String(p.id)).length + })) + .filter((c) => c.count > 0); +} + +/** the provenance licence a placed avatar carries @param {any} row */ +export function osaProvenance(row) { + return { + tool: 'import', + model: 'Open Source Avatars registry', + source: row.vrm, + licence: { + id: row.licence, + title: row.name + ' (' + row.project + ')', + author: row.authors?.[0] || row.project, + url: OSA_HOME, + credit: row.licence !== 'CC0-1.0' + } + }; +} + +/** + * Fetch the registry (projects + each listed collection), cached like the CC0 lists. + * @param {{fresh?: boolean, includeCcBy?: boolean}} [opts] + */ +export async function osaList(opts = {}) { + const { cachedJson, CC0_TTL } = await import('../cc0/cc0Fetch.js'); + const projects = await cachedJson(OSA_BASE + '/projects.json', CC0_TTL.list, { + fresh: opts.fresh + }); + const list = Array.isArray(projects.data) ? projects.data : (projects.data?.projects ?? []); + /** @type {Record} */ + const byProject = {}; + await Promise.all( + list.map(async (/** @type {any} */ p) => { + const file = String(p.avatar_data_file ?? p.avatar_file ?? ''); + if (!/^avatars\/[\w.-]+\.json$/.test(file)) return; + try { + const r = await cachedJson(OSA_BASE + '/' + file, CC0_TTL.list, { fresh: opts.fresh }); + byProject[p.id] = Array.isArray(r.data) ? r.data : (r.data?.avatars ?? []); + } catch { + /* one collection down: the others still list */ + } + }) + ); + const rows = normalizeOsa(list, byProject, opts); + return { + rows, + categories: osaCategories(list, rows), + stale: projects.stale, + at: projects.at, + error: projects.error + }; +} diff --git a/src/lib/cc0/cc0Core.js b/src/lib/cc0/cc0Core.js index 5aed78aa6..a6c2e6b7c 100644 --- a/src/lib/cc0/cc0Core.js +++ b/src/lib/cc0/cc0Core.js @@ -20,7 +20,9 @@ /** the two sites, by key */ export const CC0_SITES = { polyhaven: { label: 'Poly Haven', home: 'https://polyhaven.com', license: 'CC0' }, - ambientcg: { label: 'ambientCG', home: 'https://ambientcg.com', license: 'CC0' } + ambientcg: { label: 'ambientCG', home: 'https://ambientcg.com', license: 'CC0' }, + // 57 M5: VRM avatars from the Open Source Avatars registry (licence per collection: CC0, or CC-BY on request) + osa: { label: 'Open Source Avatars', home: 'https://github.com/ToxSam/open-source-avatars', license: 'CC0' } }; /** the browser's tabs: what each lists and where it comes from */ @@ -28,7 +30,8 @@ export const CC0_KINDS = [ { key: 'models', label: 'Models', source: 'polyhaven', phType: 'models' }, { key: 'hdris', label: 'HDRIs', source: 'polyhaven', phType: 'hdris' }, { key: 'textures', label: 'Textures', source: 'polyhaven', phType: 'textures' }, - { key: 'materials', label: 'Materials', source: 'ambientcg', phType: null } + { key: 'materials', label: 'Materials', source: 'ambientcg', phType: null }, + { key: 'avatars', label: 'Avatars', source: 'osa', phType: null } ]; export const PH_API = 'https://api.polyhaven.com'; diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js index 66e126a33..7f52054a6 100644 --- a/src/lib/fileHandler.svelte.js +++ b/src/lib/fileHandler.svelte.js @@ -821,7 +821,8 @@ function optimizeCheckNeeded(parsed, opts) { * @param {any} file @param {string=} name @param {string=} ext - explicit extension when the blob has no name (Library) * @param {number[]=} position - world drop point (Explorer drag-out, 96) * @param {any[]=} extras - companion files picked/dropped alongside (.mtl + its textures) - * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null, lod?: any, behavior?: any, quaternion?: number[] | null}} [opts] + * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null, lod?: any, behavior?: any, quaternion?: number[] | null, provenance?: any}} [opts] + * `provenance` (57 M7): a {tool, model, source, licence} step recorded on the placed root * `quaternion` (39): the placed root's rotation — a drag-to-place drop turned with R / the wheel * or aligned to a surface. Set BEFORE the add, so the create entry and the wire carry it. * `behavior` (33 P2): the pack item's functional spec — a door, a lid, a fan @@ -898,6 +899,11 @@ export async function importFile(file, name, ext, position, extras, opts = {}) { // instead of carrying it (animatedImports `animRef`; the wire still sends the bytes) if (opts.packRef?.path && parsed.animated) parsed.root.userData.animRef = { pack: opts.packRef.pack, item: opts.packRef.item, path: opts.packRef.path }; + // 57 M7: a caller that knows where the file came from (the Asset Browser's avatars) says so + if (opts.provenance) { + const root = parsed.animated ? parsed.animated.result?.scene : parsed.root; + if (root) addProvenance((root.userData = root.userData ?? {}), { ...opts.provenance, version: APP_VERSION }); + } if (parsed.animated) addAnimatedImport(parsed.animated.result, parsed.animated.buffer, label, parsed.animated.kind, position); else addImported(parsed.root, label, position); diff --git a/tests/e2e/character-avatars-source.test.cjs b/tests/e2e/character-avatars-source.test.cjs new file mode 100644 index 000000000..749008fc2 --- /dev/null +++ b/tests/e2e/character-avatars-source.test.cjs @@ -0,0 +1,143 @@ +// 57 M5 — the Asset Browser's Avatars tab (the Open Source Avatars registry): CC0 collections list by +// default, CC-BY ones only when asked, "Add to scene" places the VRM as a character, and a CC-BY +// avatar keeps its credit — Export then asks for it. The registry and the files are ROUTED to local +// fixtures (a VRM built in-test), so the suite needs no network. +const h = require('./helpers.cjs'); +const { mixamoGlb, toVrm } = require('./characterFixtures.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const { page } = A; + const vrm = Buffer.from( + toVrm(await mixamoGlb(page, { hair: true }), { + version: '0', + meta: { licenseName: 'CC0', commercialUssageName: 'Allow', title: 'Fixture' } + }) + ); + const projects = [ + { + id: 'free-set', + name: 'Free Set', + creator_id: 'Test-Maker', + license: 'CC0', + avatar_data_file: 'avatars/free-set.json' + }, + { + id: 'credit-set', + name: 'Credit Set', + creator_id: 'Credit-Maker', + license: 'CC-BY', + avatar_data_file: 'avatars/credit-set.json' + } + ]; + const avatar = (id, name) => ({ + id, + name, + format: 'VRM', + is_public: true, + is_draft: false, + model_file_url: 'https://arweave.net/' + id, + thumbnail_url: '' + }); + const lists = { + 'free-set': [avatar('free1', 'Freebie'), avatar('free2', 'Freebie Two')], + 'credit-set': [avatar('cred1', 'Credited')] + }; + let registryHits = 0; + await page.route( + /cdn\.jsdelivr\.net\/gh\/ToxSam\/open-source-avatars@[0-9a-f]+\/data\/.*/, + (route) => { + registryHits++; + const url = route.request().url(); + if (url.endsWith('/projects.json')) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(projects) + }); + const m = url.match(/avatars\/([\w-]+)\.json$/); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(lists[m?.[1]] ?? []) + }); + } + ); + await page.route(/arweave\.net\/(free1|free2|cred1)$/, (route) => + route.fulfill({ + status: 200, + contentType: 'application/octet-stream', + body: vrm, + headers: { 'access-control-allow-origin': '*' } + }) + ); + + await page.evaluate(() => window.__stores.cc0State.assetBrowserOpen.set(true)); + await page.waitForSelector('#asset-browser-window', { timeout: 20000 }); + await page.click('#asset-browser-tab-avatars'); + await h.eventually( + () => page.locator('#asset-browser-grid .ab-card').count(), + (n) => n === 2, + 'Avatars: the two CC0 avatars list by default (the CC-BY one does not)' + ); + h.check(registryHits >= 2, 'the registry was read through the pinned CDN path'); + await page.check('#asset-browser-ccby'); + await h.eventually( + () => page.locator('#asset-browser-grid .ab-card').count(), + (n) => n === 3, + 'Include CC-BY: the credited one joins' + ); + const by = await page + .locator('#asset-browser-grid .ab-card[data-asset-id="cred1"] .ab-by') + .textContent(); + h.check(/CC-BY-4\.0/.test(by ?? ''), `the card says its licence (${by})`); + + await page.click('#asset-browser-grid .ab-card[data-asset-id="cred1"] [data-act="place-avatar"]'); + const uuidOf = () => + page.evaluate( + () => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => + r(g?.children.find((c) => c.name === 'Credited')?.uuid ?? null) + )() + ) + ); + await h.eventually(uuidOf, (u) => !!u, 'Add to scene: the avatar is placed'); + const u = await uuidOf(); + h.check( + (await page.evaluate((u) => window.__stores.animatedImports.animatedImportKind(u), u)) === + 'vrm', + 'as a VRM character' + ); + const lic = await page.evaluate( + (u) => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => + r(window.__stores.provenance.licenceOf(g.getObjectByProperty('uuid', u).userData)) + )() + ), + u + ); + h.check( + lic?.id === 'CC-BY-4.0' && lic.author === 'Credit Maker' && lic.credit === true, + `it keeps its credit (${JSON.stringify(lic)})` + ); + + const ex = await page.evaluate(() => { + const s = window.__stores.exportStores; + s.publishExportTab.set('export'); + s.publishExportOpen.set(true); + return true; + }); + await page.waitForSelector('#licence-warnings-export', { timeout: 20000 }); + const text = await page.evaluate( + () => document.querySelector('#licence-warnings-export')?.textContent ?? '' + ); + h.check( + ex && /Credited/.test(text) && /credit Credit Maker/.test(text), + `Export asks for the credit (${text.replace(/\s+/g, ' ').slice(0, 120)})` + ); + + await h.finish(browser); +}); diff --git a/tests/unit/osaRegistry.test.js b/tests/unit/osaRegistry.test.js new file mode 100644 index 000000000..3ec2d03f5 --- /dev/null +++ b/tests/unit/osaRegistry.test.js @@ -0,0 +1,85 @@ +// @ts-nocheck +// 57 M5: the Open Source Avatars registry as an Asset Browser source — CC0 collections by default, +// CC-BY only on request, VRM + public + not-draft rows only, and the credit a placed avatar keeps. +import { describe, it, expect } from 'vitest'; +import { + normalizeOsa, + osaCategories, + osaLicence, + osaProvenance, + OSA_BASE +} from '../../src/lib/avatars/osaRegistry.js'; +import { licenceFlags } from '../../src/lib/provenance.js'; + +const projects = [ + { + id: '100avatars-r1', + name: '100Avatars R1', + creator_id: 'Polygonal-Mind', + license: 'CC0', + avatar_data_file: 'avatars/100avatars-r1.json' + }, + { + id: 'vipe', + name: 'VIPE Heroes', + creator_id: 'VIPE', + license: 'CC-BY', + avatar_data_file: 'avatars/vipe.json' + }, + { id: 'odd', name: 'Odd', license: 'Proprietary', avatar_data_file: 'avatars/odd.json' } +]; +const av = (id, extra = {}) => ({ + id, + name: 'A' + id, + format: 'VRM', + is_public: true, + is_draft: false, + model_file_url: 'https://arweave.net/' + id, + thumbnail_url: 'https://arweave.net/t' + id, + ...extra +}); +const byProject = { + '100avatars-r1': [ + av('1'), + av('2', { is_draft: true }), + av('3', { format: 'FBX' }), + av('4', { model_file_url: 'http://insecure' }) + ], + vipe: [av('9')], + odd: [av('7')] +}; + +describe('57 M5 Open Source Avatars source', () => { + it('CC0 only by default; CC-BY on request; never another licence; VRM, public, https only', () => { + const rows = normalizeOsa(projects, byProject); + expect(rows.map((r) => r.id)).toEqual(['1']); + expect(rows[0]).toMatchObject({ + source: 'osa', + kind: 'avatars', + licence: 'CC0-1.0', + authors: ['Polygonal Mind'], + categories: ['100avatars-r1'], + vrm: 'https://arweave.net/1' + }); + const all = normalizeOsa(projects, byProject, { includeCcBy: true }); + expect(all.map((r) => r.id)).toEqual(['1', '9']); + expect(all[1].licence).toBe('CC-BY-4.0'); + expect(osaCategories(projects, all)).toEqual([ + { id: '100avatars-r1', label: '100Avatars R1', count: 1 }, + { id: 'vipe', label: 'VIPE Heroes', count: 1 } + ]); + }); + + it('a placed CC-BY avatar keeps its credit (the M7 warnings see it); CC0 asks nothing', () => { + const [cc0, ccby] = normalizeOsa(projects, byProject, { includeCcBy: true }); + expect(licenceFlags(osaProvenance(cc0).licence)).toMatchObject({ + credit: false, + restricted: false + }); + const p = osaProvenance(ccby); + expect(p.licence).toMatchObject({ id: 'CC-BY-4.0', author: 'VIPE', credit: true }); + expect(licenceFlags(p.licence).credit).toBe(true); + expect(osaLicence('cc0')).toBe('CC0-1.0'); + expect(OSA_BASE).toMatch(/@[0-9a-f]{40}\/data$/); // pinned + }); +}); From 20ece8be753a5c6a0fd193e5df137d2162d4fab8 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 05:54:07 +0300 Subject: [PATCH 09/11] [fix] 57-chars: the character config lives in userData.character (userData.humanoid is 57-clips' map key) - 57-clips M1 reads root.userData.humanoid as a plain {vrmBone: node} map; this lane's config ({map fixes, applied clips}) moves to userData.character, and its wire parameter to 'character', so the two never read each other's shape at the union Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/animatedImports.js | 12 ++++++------ src/lib/commandsHandler.svelte.js | 2 +- src/lib/humanoid/humanoidRig.js | 16 ++++++++-------- src/lib/sceneLicences.js | 2 +- src/lib/wireValidate.js | 4 ++-- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/lib/animatedImports.js b/src/lib/animatedImports.js index f3a2474e3..e0e212610 100644 --- a/src/lib/animatedImports.js +++ b/src/lib/animatedImports.js @@ -193,7 +193,7 @@ function rootDataFields(root) { const out = {}; // 57 M2 / M7: the humanoid config (map fixes + applied clips) and the provenance record ride // the bytes for the same reason (absent = none, byte-identical messages for everything else) - for (const key of ['lod', 'humanoid', 'provenance', 'vrmExpressions']) { + for (const key of ['lod', 'character', 'provenance', 'vrmExpressions']) { const v = root?.userData?.[key]; if (!v || typeof v !== 'object') continue; try { @@ -207,7 +207,7 @@ function rootDataFields(root) { /** 57: put the riding fields back on a freshly parsed (or healed) root @param {any} root @param {any} data */ function applyRootData(root, data) { - if (data.humanoid && typeof data.humanoid === 'object') root.userData.humanoid = data.humanoid; + if (data.character && typeof data.character === 'object') root.userData.character = data.character; if (Array.isArray(data.provenance)) root.userData.provenance = data.provenance; if (data.vrmExpressions && typeof data.vrmExpressions === 'object') root.userData.vrmExpressions = data.vrmExpressions; @@ -398,7 +398,7 @@ export function registerAnimatedImport(root, animations, bytes, kind = 'gltf') { const vrmReady = kind === 'vrm' ? adoptVrmRoot(root) : Promise.resolve(); // 57 M2: a humanoid config (clips applied from another rig) is rebuilt on THIS peer; the code // for it loads only for a model that has one - if (root.userData?.humanoid?.clips?.length) + if (root.userData?.character?.clips?.length) void vrmReady .then(() => import('./humanoid/humanoidRig.js')) .then((m) => m.rebuildHumanoidClips(root.uuid)); @@ -544,7 +544,7 @@ export async function applyObjectFile(data) { if (data.name) held.name = data.name; if (data.lod && typeof data.lod === 'object') held.userData.lod = data.lod; applyRootData(held, data); - if (data.humanoid) + if (data.character) void import('./humanoid/humanoidRig.js').then((m) => m.rebuildHumanoidClips(data.uuid)); if (data.pos) held.position.fromArray(data.pos); if (data.rot) held.rotation.set(data.rot[0], data.rot[1], data.rot[2]); @@ -792,7 +792,7 @@ export async function animatedImportsRestore(entries, replicate = true, live = ( anim: entry.anim, behavior: entry.behavior, lod: entry.lod, - humanoid: entry.humanoid, + character: entry.character, provenance: entry.provenance, vrmExpressions: entry.vrmExpressions, live @@ -858,7 +858,7 @@ registerHistoryKind('animimport', (entry, state) => { pos: entry.pos, behavior: entry.behavior, lod: entry.lod, - humanoid: entry.humanoid, + character: entry.character, provenance: entry.provenance, vrmExpressions: entry.vrmExpressions, animRef: entry.animRef diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index 9688a8cbc..48deb7794 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -582,7 +582,7 @@ export async function objectParameters(data) { setMaterialParam(data.uuid, data.key, data.value, false); } else if (data.parameter == 'animation') { setAnimationState(data.uuid, { clip: data.clip, playing: data.playing, speed: data.speed }, false); - } else if (data.parameter == 'humanoid') { + } else if (data.parameter == 'character') { // 57 M2: a character's humanoid map fixes + applied clips; each peer retargets for itself const { receiveHumanoid } = await import('$lib/humanoid/humanoidRig.js'); receiveHumanoid(data); diff --git a/src/lib/humanoid/humanoidRig.js b/src/lib/humanoid/humanoidRig.js index 6d3bd90d0..5877275e6 100644 --- a/src/lib/humanoid/humanoidRig.js +++ b/src/lib/humanoid/humanoidRig.js @@ -1,8 +1,8 @@ // 57 M2 — HUMANOIDS IN THE SCENE: each rigged model's map, and clips applied to it from elsewhere. // -// THE CONFIG is `root.userData.humanoid = {map?: {bone: nodeName | ''}, clips?: [{name, src}]}` — +// THE CONFIG is `root.userData.character = {map?: {bone: nodeName | ''}, clips?: [{name, src}]}` — // the user's map FIXES on top of the detected map, and the clips applied to this character. It is -// replicated (`objectParameters` parameter 'humanoid'), saved, sent with the bytes (rootDataFields) +// replicated (`objectParameters` parameter 'character'), saved, sent with the bytes (rootDataFields) // and undoable. What it NAMES is computed here, on every peer: the source clip is retargeted onto // this rig and added to its mixer as a RUNTIME clip. The model's bytes never change. // @@ -214,7 +214,7 @@ function normalizeSource(s) { /** @param {string} uuid @returns {HumanoidConfig} */ export function configOf(uuid) { - return normalizeHumanoidConfig(rootOf(uuid)?.userData?.humanoid); + return normalizeHumanoidConfig(rootOf(uuid)?.userData?.character); } /** @@ -225,16 +225,16 @@ export function configOf(uuid) { export function applyHumanoidConfig(uuid, cfg, opts = {}) { const root = rootOf(uuid); if (!root) return false; - const before = normalizeHumanoidConfig(root.userData.humanoid); + const before = normalizeHumanoidConfig(root.userData.character); const next = normalizeHumanoidConfig(cfg); - if (next.map || next.clips) root.userData.humanoid = next; - else delete root.userData.humanoid; + if (next.map || next.clips) root.userData.character = next; + else delete root.userData.character; if (opts.history !== false && opts.replicate !== false) recordEntry({ kind: 'humanoidcfg', uuid, before: { cfg: before }, after: { cfg: next } }); if (opts.replicate !== false) { /** @type {any} */ const peer = get(peers); - peer?.send({ type: 'objectParameters', parameter: 'humanoid', uuid, humanoid: next }); + peer?.send({ type: 'objectParameters', parameter: 'character', uuid, character: next }); } void rebuildHumanoidClips(uuid); pokeScene(); @@ -252,7 +252,7 @@ registerHistoryKind('humanoidcfg', (entry, state) => { /** the wire applier (commandsHandler `objectParameters`) @param {any} data */ export function receiveHumanoid(data) { - applyHumanoidConfig(data.uuid, data.humanoid, { replicate: false }); + applyHumanoidConfig(data.uuid, data.character, { replicate: false }); } // ---- sources ------------------------------------------------------------------------------- diff --git a/src/lib/sceneLicences.js b/src/lib/sceneLicences.js index e9ac02265..9db7cd23e 100644 --- a/src/lib/sceneLicences.js +++ b/src/lib/sceneLicences.js @@ -27,7 +27,7 @@ export function licensedAssets(uuids = null) { if (seen.has(o.uuid)) return; seen.add(o.uuid); const ud = o.userData ?? {}; - const clips = Array.isArray(ud.humanoid?.clips) ? ud.humanoid.clips : []; + const clips = Array.isArray(ud.character?.clips) ? ud.character.clips : []; const extra = clips .map((/** @type {any} */ c) => normalizeLicence(c?.licence)) .filter(Boolean); diff --git a/src/lib/wireValidate.js b/src/lib/wireValidate.js index c26e3be12..2154ef9c2 100644 --- a/src/lib/wireValidate.js +++ b/src/lib/wireValidate.js @@ -332,9 +332,9 @@ export const VALIDATORS = { // 48 B2: a measurement's record — null is not a record (an item is deleted, never emptied) if (d.parameter === 'measure') return isUuid(d.uuid) && !!d.measure && typeof d.measure === 'object' && isArray(d.measure.points) && d.measure.points.length <= 64; - // 57 M2: a humanoid config is a plain object (normalized again by its applier) + // 57 M2: a character config (map fixes + applied clips) is a plain object (normalized again by its applier) if (d.parameter === 'expression') return isUuid(d.uuid) && typeof d.name === 'string' && d.name.length <= 64 && Number.isFinite(d.weight); - if (d.parameter === 'humanoid') return isUuid(d.uuid) && !!d.humanoid && typeof d.humanoid === 'object' && !isArray(d.humanoid); + if (d.parameter === 'character') return isUuid(d.uuid) && !!d.character && typeof d.character === 'object' && !isArray(d.character); if (d.parameter === 'water' || d.parameter === 'bubbles' || d.parameter === 'pour') { const v = d[d.parameter]; return isUuid(d.uuid) && (v === null || (!!v && typeof v === 'object' && !isArray(v))); From b68b67f63d6a2718b2140168cf704d8a2af5eab1 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 09:16:33 +0300 Subject: [PATCH 10/11] [fix] 57 M1: clip cards draw their skeleton, double-click plays, bind by raw node names The Explorer's clip cards get a 2D stick-figure thumbnail (lazy, no WebGL context per card); double-clicking a clip plays it on the selected character; bindClip passes the model so node names that sanitize differently still bind; the unsupported-file toast names clips. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/editors/Explorer.svelte | 18 +++++++ src/lib/explorer.js | 7 ++- src/lib/tpanim/clipAssets.js | 2 +- src/lib/tpanim/thumb.js | 71 ++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/lib/tpanim/thumb.js diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index b2f941878..c347d0b7f 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -6531,6 +6531,24 @@ url: URL.createObjectURL(blob), onClose: () => gridEl?.focus() }); + } else if (item.kind === 'anim') { + // 57 M1: a clip PLAYS on the selected character (the drop's assign, without the drag) + const pick = selectedSet()[0]; + if (!pick) { + showToast('Select a character, then double-click the clip — or drag it onto one'); + return; + } + const { assignClipAsset } = await import('$lib/tpanim/clipAssets.js'); + const { objectsGroup } = await import('../../stores/sceneStore.js'); + const { hasAnimatedImport } = await import('$lib/animatedImports'); + const group: any = get(objectsGroup); + let root = group?.getObjectByProperty('uuid', pick); + // the rig the selection belongs to; else (a parts model) its top-level object + let rig = root; + while (rig && !hasAnimatedImport(rig.uuid)) rig = rig.parent; + while (!rig && root && root.parent && root.parent !== group) root = root.parent; + const target = rig ?? root; + if (target) assignClipAsset(target.uuid, { hash: item.hash, name: item.name }, { play: true }); } else if (item.kind === 'object' && !item.packEntry) { // R22 round 12 (user): "double click on 3d objects should open same preview as // when opening image". ONE window for every kind, so the arrows walk from a diff --git a/src/lib/explorer.js b/src/lib/explorer.js index f9094fcea..ee0e71bbd 100644 --- a/src/lib/explorer.js +++ b/src/lib/explorer.js @@ -447,6 +447,11 @@ async function thumbnailFor(blob, name, kind) { async function itemFacts(blob, name, kind) { try { if (kind === 'image') return { thumbnail: await imageThumbnail(blob), dims: null }; + // 57 M1: a clip draws its own skeleton (lazy; a 2D canvas, no WebGL context per card) + if (kind === 'anim') { + const { clipThumbnail } = await import('./tpanim/thumb.js'); + return { thumbnail: clipThumbnail(await blob.arrayBuffer()), dims: null }; + } if (kind === 'object') { const ext = name.split('.').pop()?.toLowerCase() ?? ''; const object = await parseObjectFile(await blob.arrayBuffer(), ext); @@ -518,7 +523,7 @@ export async function importFiles(files, folderId = null, opts = {}) { } const kind = kindOf(file.name); if (!kind) { - showToast('Unsupported: ' + file.name + ' (images incl. HEIC photos, audio, text/config, 3D objects)'); + showToast('Unsupported: ' + file.name + ' (images incl. HEIC photos, audio, text/config, 3D objects, animation clips)'); continue; } if (file.size > MAX_ITEM_BYTES) { diff --git a/src/lib/tpanim/clipAssets.js b/src/lib/tpanim/clipAssets.js index b87cdcd94..6cf9d1c22 100644 --- a/src/lib/tpanim/clipAssets.js +++ b/src/lib/tpanim/clipAssets.js @@ -101,7 +101,7 @@ export function bindClip(tp, root, name) { } const fit = clipFit(tp, root); if (fit.fit === 'none') return warn('"' + name + '" was made for another skeleton — none of its bones are in "' + (root.name || 'this model') + '".'); - const clip = tpToThreeClip(tp); + const clip = tpToThreeClip(tp, root); clip.name = name; return clip; } diff --git a/src/lib/tpanim/thumb.js b/src/lib/tpanim/thumb.js new file mode 100644 index 000000000..d05dfec6c --- /dev/null +++ b/src/lib/tpanim/thumb.js @@ -0,0 +1,71 @@ +// 57 M1: an Explorer card for a clip — its skeleton as a stick figure at mid-clip, over a faint +// trail of four other moments, so a walk, a wave and a jump look different at a glance. A 2D +// canvas (no WebGL context per card), front view, fitted to every sampled pose. Lazy: loaded by +// the Explorer only when a clip item is written. + +import { decodeTpanim, nodePositionsAt } from './format.js'; + +/** a theme colour for the drawing (the card sits on the theme's surface) @param {string} token @param {string} fallback */ +function tokenColor(token, fallback) { + try { + const v = getComputedStyle(document.documentElement).getPropertyValue(token).trim(); + return v || fallback; + } catch { + return fallback; + } +} + +/** + * @param {ArrayBuffer} bytes @param {number} [size] px + * @returns {string | null} a data URL + */ +export function clipThumbnail(bytes, size = 128) { + try { + const tp = decodeTpanim(bytes); + if (!tp.tracks.length || tp.nodes.length < 2) return null; + const moments = [0, 0.2, 0.4, 0.8, 0.6].map((k) => k * tp.duration); + const poses = moments.map((t) => nodePositionsAt(tp, t)); + let x0 = Infinity; + let x1 = -Infinity; + let y0 = Infinity; + let y1 = -Infinity; + for (const p of poses) + for (let i = 0; i < p.length; i += 3) { + x0 = Math.min(x0, p[i]); + x1 = Math.max(x1, p[i]); + y0 = Math.min(y0, p[i + 1]); + y1 = Math.max(y1, p[i + 1]); + } + const span = Math.max(x1 - x0, y1 - y0, 1e-6); + const pad = size * 0.12; + const k = (size - 2 * pad) / span; + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const g = canvas.getContext('2d'); + if (!g) return null; + g.lineCap = 'round'; + const ink = tokenColor('--accent', 'currentColor'); + poses.forEach((p, n) => { + const last = n === poses.length - 1; + g.globalAlpha = last ? 1 : 0.18 + n * 0.06; + g.strokeStyle = ink; + g.lineWidth = last ? 3 : 1.5; + g.beginPath(); + tp.nodes.forEach((node, i) => { + if (node.parent < 0) return; + const a = node.parent * 3; + const b = i * 3; + g.moveTo(size / 2 + (p[a] - cx) * k, size / 2 - (p[a + 1] - cy) * k); + g.lineTo(size / 2 + (p[b] - cx) * k, size / 2 - (p[b + 1] - cy) * k); + }); + g.stroke(); + }); + return canvas.toDataURL('image/webp', 0.8); + } catch (error) { + console.log('clip thumbnail skipped', error); + return null; + } +} From 0e61480e318d7e98419b1f9e1e28dab5315d656b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 10:04:06 +0300 Subject: [PATCH 11/11] [fix] 57-chars: the Skeleton window no longer loops on its own scene poke; character e2e measure what they claim - SkeletonWindow: the helpers effect depended on `info` (derived from sceneRevision) while refreshHelpers pokes the scene, so it re-ran until svelte stopped it and the window never rendered; it is keyed on a string of the bones, their rest places and the live root id now - the Bones fields (rename, rest) follow the picked bone and the rig, not every scene poke, so a test-pose drag no longer wipes a name being typed - e2e: FULL_RESOURCE_TIMING init script (the 250-entry buffer fills during a dev boot, so every "lazy chunk fetched" check read false and every "not loaded at boot" check passed vacuously) - character-vrm: a two-bone spring chain is ONE three-vrm joint; the swing is sampled while the body moves (the damped spring settles before a 600 ms sample) - character-download: the (b) zip carries CREDITS.txt, crediting the applied clip's source - character-skeleton: prints the window's DOM + page errors when the map does not show Co-Authored-By: Claude Opus 5.5 (1M context) --- .../characters/SkeletonWindow.svelte | 28 ++++++++--- tests/e2e/character-download.test.cjs | 15 ++++-- tests/e2e/character-skeleton.test.cjs | 37 +++++++++++--- tests/e2e/character-vrm.test.cjs | 49 ++++++++++++------- tests/e2e/characterFixtures.cjs | 11 ++++- 5 files changed, 105 insertions(+), 35 deletions(-) diff --git a/src/components/characters/SkeletonWindow.svelte b/src/components/characters/SkeletonWindow.svelte index 51e41ced8..24ed20785 100644 --- a/src/components/characters/SkeletonWindow.svelte +++ b/src/components/characters/SkeletonWindow.svelte @@ -7,7 +7,7 @@ // brush for one bad area with a live test pose // Bones and Weights edit the FILE: each operation (a brush stroke is one) rewrites the GLB and // sends it once, undoable. Opened from Inspector ▸ Character ▸ Skeleton… (lazy, CharacterDialogs). - import { onMount, onDestroy } from 'svelte'; + import { onMount, onDestroy, untrack } from 'svelte'; import ModalDialog from '../ui/ModalDialog.svelte'; import Button from '../ui/Button.svelte'; import Tabs from '../ui/Tabs.svelte'; @@ -64,15 +64,31 @@ human ? Object.fromEntries(Object.entries(human.map).map(([k, v]) => [v, k])) : {} ); + /** what the drawn skeleton depends on: the bones, their rest places and the live root (a file + * edit swaps it). A STRING, so a scene poke that changes none of them re-runs nothing — + * refreshHelpers itself pokes the scene, and keyed on `info` this effect re-ran forever. */ + const helperKey = $derived.by(() => { + void $sceneRevision; + const root = /** @type {any} */ ($objectsGroup)?.getObjectByProperty('uuid', uuid); + if (!info || !root) return ''; + return ( + root.id + '|' + info.bones.map((b) => b.name + '<' + b.parent + '@' + b.t.join(',')).join('|') + ); + }); + // the Bones fields follow the picked bone and the rig (an edit, an undo) — not every scene poke, + // or a test-pose drag would wipe a name being typed $effect(() => { - const b = info?.bones.find((x) => x.name === $pickedBone); - renameTo = $pickedBone; - if (b) restT = b.t.map((/** @type {number} */ v) => +v.toFixed(4)); + void helperKey; + const name = $pickedBone; + untrack(() => { + const b = info?.bones.find((x) => x.name === name); + renameTo = name; + if (b) restT = b.t.map((/** @type {number} */ v) => +v.toFixed(4)); + }); }); $effect(() => { void $pickedBone; - void info; - if (uuid) rig.refreshHelpers(uuid); + if (helperKey) rig.refreshHelpers(uuid); }); $effect(() => { if (uuid && $pickedBone) rig.testPose(uuid, $pickedBone, bend); diff --git a/tests/e2e/character-download.test.cjs b/tests/e2e/character-download.test.cjs index a1d6e8997..75f99423e 100644 --- a/tests/e2e/character-download.test.cjs +++ b/tests/e2e/character-download.test.cjs @@ -5,7 +5,7 @@ // redistribution shows the licence warning in Download, Export and Publish. The writers are lazy. const h = require('./helpers.cjs'); const fs = require('fs'); -const { mixamoGlb, toVrm, readGlb } = require('./characterFixtures.cjs'); +const { mixamoGlb, toVrm, readGlb, FULL_RESOURCE_TIMING } = require('./characterFixtures.cjs'); const states = (page) => page.evaluate( @@ -50,7 +50,7 @@ async function openDownload(page, uuid) { h.run(async () => { const browser = await h.launch({ args: h.GPU_ARGS }); - const A = await h.setupPage(browser, 'A'); + const A = await h.setupPage(browser, 'A', { init: FULL_RESOURCE_TIMING }); const { page } = A; h.check( !(await loaded(page, 'characterDownload|bvhWriter|glbEdit|clipFiles|three-vrm|vrmRuntime')), @@ -146,8 +146,15 @@ h.run(async () => { const files = unzipSync(new Uint8Array(b.bytes)); const list = Object.keys(files).sort(); h.check( - JSON.stringify(list) === '["Hero-Kick.tpanim","Hero-Walking_A.tpanim","Hero.glb"]', - `(b) model + one .tpanim per clip (${list})` + JSON.stringify(list) === + '["CREDITS.txt","Hero-Kick.tpanim","Hero-Walking_A.tpanim","Hero.glb"]', + `(b) model + one .tpanim per clip + CREDITS.txt (${list})` + ); + // the applied Walking_A came from the built-in avatar motions: the zip credits their source + const credits = new TextDecoder().decode(files['CREDITS.txt']); + h.check( + /KayKit/.test(credits) && /CC0/.test(credits), + `(b) CREDITS.txt credits the applied clip's source (${credits.replace(/\s+/g, ' ').slice(0, 160)})` ); h.check( !(readGlb(files['Hero.glb']).json.animations ?? []).length, diff --git a/tests/e2e/character-skeleton.test.cjs b/tests/e2e/character-skeleton.test.cjs index f3e10905a..5c06a6972 100644 --- a/tests/e2e/character-skeleton.test.cjs +++ b/tests/e2e/character-skeleton.test.cjs @@ -3,7 +3,7 @@ // it on the other peer ONCE when the operation ends; undo puts the old file back everywhere. The rig // code loads only when the view opens. const h = require('./helpers.cjs'); -const { mixamoGlb } = require('./characterFixtures.cjs'); +const { mixamoGlb, FULL_RESOURCE_TIMING } = require('./characterFixtures.cjs'); const uuidOf = (page, name) => page.evaluate( @@ -72,8 +72,8 @@ const offHips = (page, uuid) => h.run(async () => { const browser = await h.launch({ args: h.GPU_ARGS }); - const A = await h.setupPage(browser, 'A'); - const B = await h.setupPage(browser, 'B'); + const A = await h.setupPage(browser, 'A', { init: FULL_RESOURCE_TIMING }); + const B = await h.setupPage(browser, 'B', { init: FULL_RESOURCE_TIMING }); await h.connect(B, A); const { page } = A; @@ -103,9 +103,34 @@ h.run(async () => { await page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), u); await page.waitForSelector('#character-skeleton', { timeout: 15000 }); await page.click('#character-skeleton'); - await page.waitForSelector('#skeleton-window #skeleton-map [data-vrm-bone="leftHand"] select', { - timeout: 20000 - }); + await page + .waitForSelector('#skeleton-window #skeleton-map [data-vrm-bone="leftHand"] select', { + timeout: 20000 + }) + .catch(async (e) => { + // what is on screen instead (the window's chunk loads inside an {#await} with no catch) + const dom = await page.evaluate(() => { + const w = document.querySelector('#skeleton-window'); + return { + window: !!w, + open: w?.hasAttribute('open'), + map: !!document.querySelector('#skeleton-map'), + rows: document.querySelectorAll('#skeleton-map [data-vrm-bone]').length, + text: (w?.textContent ?? '').replace(/\s+/g, ' ').slice(0, 300) + }; + }); + console.log('skeleton window:', JSON.stringify(dom)); + console.log('page errors:', JSON.stringify(h.pageErrors(A).slice(-8))); + console.log( + 'console:', + JSON.stringify( + (page.__console ?? []) + .filter((m) => m.type === 'error' || /fail|error/i.test(m.text)) + .slice(-8) + ) + ); + throw e; + }); h.check(await loaded(page, 'rigFixes'), 'the rig code loaded with the view'); // 2. the map editor: unmap a required bone -> not a humanoid, on both peers; bytes untouched diff --git a/tests/e2e/character-vrm.test.cjs b/tests/e2e/character-vrm.test.cjs index f399c379c..a481edde0 100644 --- a/tests/e2e/character-vrm.test.cjs +++ b/tests/e2e/character-vrm.test.cjs @@ -3,7 +3,7 @@ // ride the raw-bytes path as kind 'vrm' on every peer, bring their own humanoid map, spring bones // and expressions, show their licence meta in the Inspector and carry it as provenance. const h = require('./helpers.cjs'); -const { mixamoGlb, toVrm } = require('./characterFixtures.cjs'); +const { mixamoGlb, toVrm, FULL_RESOURCE_TIMING } = require('./characterFixtures.cjs'); const states = (page) => page.evaluate( @@ -45,8 +45,8 @@ const qDiff = (a, b) => (a && b ? Math.max(...a.map((v, i) => Math.abs(v - b[i]) h.run(async () => { const browser = await h.launch({ args: h.GPU_ARGS }); - const A = await h.setupPage(browser, 'A'); - const B = await h.setupPage(browser, 'B'); + const A = await h.setupPage(browser, 'A', { init: FULL_RESOURCE_TIMING }); + const B = await h.setupPage(browser, 'B', { init: FULL_RESOURCE_TIMING }); await h.connect(B, A); // 0. boot graph: no VRM code until a VRM arrives @@ -87,8 +87,9 @@ h.run(async () => { await h.eventually( () => A.page.evaluate((u) => window.__stores.animatedImports.vrmDebug(u), v1), (d) => - d?.vrm && d.joints >= 2 && d.expressions.includes('happy') && d.expressions.includes('wink'), - 'A: the VRM runtime adopted it (spring joints, expressions incl. a custom one)' + // three-vrm makes ONE joint per bone that has a next bone in its chain: Hair1 -> Hair2 is 1 + d?.vrm && d.joints === 1 && d.expressions.includes('happy') && d.expressions.includes('wink'), + 'A: the VRM runtime adopted it (the spring joint, expressions incl. a custom one)' ); const hm = await A.page.evaluate((u) => { const x = window.__stores.humanoidRig.humanoidOf(u); @@ -129,7 +130,7 @@ h.run(async () => { ); await h.eventually( () => B.page.evaluate((u) => window.__stores.animatedImports.vrmDebug(u), v1), - (d) => d?.vrm && d.joints >= 2, + (d) => d?.vrm && d.joints === 1, 'B: adopted as a VRM with its spring bones' ); h.check( @@ -161,19 +162,31 @@ h.run(async () => { 'the provenance line names the licence' ); - // 4. spring bones move the hair while the head moves (a local visual) - const hair0 = await boneQ(A.page, v1, 'mixamorigHair1'); - await A.page.evaluate((u) => { - window.__stores.objectsGroup.subscribe((g) => { - const root = g.getObjectByProperty('uuid', u); - root.position.x += 0.6; - root.updateMatrixWorld(true); - })(); - }, v1); - await A.page.waitForTimeout(600); + // 4. spring bones move the hair while the body moves (a local visual). Sampled DURING the + // motion: the spring is damped, so a while after a move it has settled back to rest. + const swing = await A.page.evaluate( + (u) => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => { + const root = g.getObjectByProperty('uuid', u); + const hair = root.getObjectByName('mixamorigHair1'); + const q0 = hair.quaternion.clone(); + let n = 0; + let most = 0; + const step = () => { + root.position.x += 0.04; + most = Math.max(most, 1 - Math.abs(q0.dot(hair.quaternion))); + if (++n < 40) requestAnimationFrame(step); + else r({ most, frames: n }); + }; + requestAnimationFrame(step); + })() + ), + v1 + ); h.check( - qDiff(hair0, await boneQ(A.page, v1, 'mixamorigHair1')) > 1e-4, - 'spring bones: the hair swings when the body moves' + swing.most > 1e-5, + `spring bones: the hair swings while the body moves (${JSON.stringify(swing)})` ); // 5. expressions: the Inspector slider replicates; the weight reaches B's face diff --git a/tests/e2e/characterFixtures.cjs b/tests/e2e/characterFixtures.cjs index 28744f5d8..892799ad2 100644 --- a/tests/e2e/characterFixtures.cjs +++ b/tests/e2e/characterFixtures.cjs @@ -245,4 +245,13 @@ function toVrm(glb, { version = '1', meta = {} } = {}) { return Array.from(writeGlb(json, bin)); } -module.exports = { mixamoGlb, toVrm, readGlb, writeGlb }; +/** + * An init script for `h.setupPage(browser, name, { init: FULL_RESOURCE_TIMING })`: the browser keeps + * only 250 resource-timing entries, and the dev server's boot alone fetches more modules than that, + * so a "was this lazy chunk fetched?" check against the default buffer reads false whatever loads. + */ +function FULL_RESOURCE_TIMING() { + performance.setResourceTimingBufferSize(100000); +} + +module.exports = { mixamoGlb, toVrm, readGlb, writeGlb, FULL_RESOURCE_TIMING };