From 923171f1dd34c2327ac8cca1e95d29a36f0b3af3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 04:52:19 +0300 Subject: [PATCH 01/26] [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/26] [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/26] [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/26] [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/26] =?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/26] [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/26] [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/26] [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/26] [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 }; From 35a581ef8e1d8bfba6815570b8ca1e11c1041663 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 20:53:30 +0300 Subject: [PATCH 12/26] [fix] 57 M1: clip cards draw the body, not the rig's IK targets and controls A KayKit rig's IK targets, poles and foot-roll controls hang off the root; drawn, they fanned out from the floor over the figure. The card now draws only the bones below the root / armature container that are not helpers (or under one), fitted to those. Unit tpanimThumb 4 (KayKit, Mixamo under an Armature, VRM hips at the top). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/tpanim/thumb.js | 42 ++++++++++++++++---- tests/unit/tpanimThumb.test.js | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 tests/unit/tpanimThumb.test.js diff --git a/src/lib/tpanim/thumb.js b/src/lib/tpanim/thumb.js index d05dfec6c..20d2dd02a 100644 --- a/src/lib/tpanim/thumb.js +++ b/src/lib/tpanim/thumb.js @@ -15,6 +15,29 @@ function tokenColor(token, fallback) { } } +const HELPER = /ik(?![a-z])|^ik|control|ctrl|pole|target/i; +const CONTAINER = /^(root|armature|rig)$/i; + +/** + * Which nodes' lines to their parent are BODY: not a rig helper (IK targets, poles and controls hang + * off the root — a fan of lines from the floor) or under one, and not hanging from the root / + * armature container (its line to the hips is a tail). + * @param {import('./format.js').TpClip} tp @returns {boolean[]} + */ +export function bodyLines(tp) { + return tp.nodes.map((node) => { + if (node.parent < 0) return false; + const up = tp.nodes[node.parent]; + if (CONTAINER.test(up.name)) return false; + for (let n = node, guard = 0; n && guard < 256; n = tp.nodes[n.parent], guard++) { + if (CONTAINER.test(n.name)) break; + if (HELPER.test(n.name)) return false; + if (n.parent < 0) break; + } + return true; + }); +} + /** * @param {ArrayBuffer} bytes @param {number} [size] px * @returns {string | null} a data URL @@ -25,17 +48,22 @@ export function clipThumbnail(bytes, size = 128) { 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)); + const body = bodyLines(tp); 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]); - } + tp.nodes.forEach((node, i) => { + if (!body[i]) return; + for (const j of [i, node.parent]) { + x0 = Math.min(x0, p[j * 3]); + x1 = Math.max(x1, p[j * 3]); + y0 = Math.min(y0, p[j * 3 + 1]); + y1 = Math.max(y1, p[j * 3 + 1]); + } + }); + if (!Number.isFinite(x0)) return null; const span = Math.max(x1 - x0, y1 - y0, 1e-6); const pad = size * 0.12; const k = (size - 2 * pad) / span; @@ -55,7 +83,7 @@ export function clipThumbnail(bytes, size = 128) { g.lineWidth = last ? 3 : 1.5; g.beginPath(); tp.nodes.forEach((node, i) => { - if (node.parent < 0) return; + if (!body[i]) 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); diff --git a/tests/unit/tpanimThumb.test.js b/tests/unit/tpanimThumb.test.js new file mode 100644 index 000000000..d13180375 --- /dev/null +++ b/tests/unit/tpanimThumb.test.js @@ -0,0 +1,72 @@ +// @ts-nocheck — fixture clips built inline +// 57 M1 — the Explorer clip card draws the BODY: a KayKit-style rig's IK targets, poles and foot-roll +// controls hang off the root, and drawn they fan out from the floor; the root's line to the hips is a tail. +import { describe, it, expect } from 'vitest'; +import { bodyLines } from '../../src/lib/tpanim/thumb.js'; + +/** @param {[string, string | null][]} pairs node, parent — children listed BEFORE their parents, as a GLB can */ +function rig(pairs) { + const index = new Map(pairs.map(([n], i) => [n, i])); + return { + nodes: pairs.map(([name, parent]) => ({ + name, + parent: parent === null ? -1 : index.get(parent) + })), + tracks: [] + }; +} + +const kaykit = rig([ + ['hand.l', 'upperarm.l'], + ['upperarm.l', 'chest'], + ['head', 'chest'], + ['chest', 'spine'], + ['spine', 'hips'], + ['foot.l', 'upperleg.l'], + ['upperleg.l', 'hips'], + ['hips', 'root'], + ['kneeIK.l', 'root'], + ['heelIK.l', 'control-foot-roll.l'], + ['control-foot-roll.l', 'control-toe-roll.l'], + ['control-toe-roll.l', 'root'], + ['handIK.l', 'root'], + ['root', 'Rig'], + ['Rig', null] +]); + +const drawn = (tp) => tp.nodes.filter((_, i) => bodyLines(tp)[i]).map((n) => n.name); + +describe('clip card body lines', () => { + it('draws the skeleton and none of the rig helpers', () => { + expect(drawn(kaykit).sort()).toEqual([ + 'chest', + 'foot.l', + 'hand.l', + 'head', + 'spine', + 'upperarm.l', + 'upperleg.l' + ]); + }); + it('a helper under a helper is skipped too (listed before its parent)', () => { + expect(drawn(kaykit)).not.toContain('heelIK.l'); + }); + it('a Mixamo rig under an Armature keeps Hips -> Spine', () => { + const mixamo = rig([ + ['Armature', null], + ['mixamorig:Hips', 'Armature'], + ['mixamorig:Spine', 'mixamorig:Hips'], + ['mixamorig:LeftUpLeg', 'mixamorig:Hips'] + ]); + expect(drawn(mixamo).sort()).toEqual(['mixamorig:LeftUpLeg', 'mixamorig:Spine']); + }); + it('a VRM humanoid clip (hips at the top) draws every bone below the hips', () => { + const vrm = rig([ + ['hips', null], + ['spine', 'hips'], + ['chest', 'spine'], + ['leftUpperLeg', 'hips'] + ]); + expect(drawn(vrm).sort()).toEqual(['chest', 'leftUpperLeg', 'spine']); + }); +}); From f0099b08dfff30493d7fcd8909f8f3d3786612ba Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 05:40:39 +0300 Subject: [PATCH 13/26] [feat] 57 M3 + M8 + M11 + M12: game-ready clips, split/join, Animation states, animate parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One commit for four phases: their runtime shares the same mixer/driver plumbing in animatedImports + flowRuntime, and splitting those hunks would leave commits that do not build. M3 — game-ready clips - per clip ref (no new file): In place / Moving (root travel stripped at bind), loop or once, speed; fps · frames · length + the LOOP SEAM check; Loop it (a new file, ends blend into start) - crossfade transport: setAnimationState {at, from, fade} — every peer blends the same two clips from the same synced stamp; Play Animation gains Crossfade (s) - Blend by Speed node: idle/walk/run 1D blend by speed (its own speed when unwired, from the replicated transform via the avatars' tracker), eased, as a LOCAL pose driver on every peer M8 — split and join - clip tools panel in the Animation window: Extract clips (+ use the files instead: embedded clips switched off, model bytes untouched, one undo), Trim by in/out markers, Split at the playhead (local preview playhead = a held pose driver), Join with a crossfade, Rename / Remove M11 — Animation states - userData.animGraph (normalized; objectfile/save/undo + one 'animGraph' message) evaluated on every peer (animGraph.js pure + animGraphRuntime.js lazy); motion driver = speed/vy/grounded from how the object moves; synced params ('animParam', latest-wins); Locomotion preset (Move blend / Jump / Fall); the Animation states window (ToolboxWindow); Set Animation Parameter + On Animation State nodes M12 — animate imported parts - parts models: any object takes clip refs, bound by node name (a sweep registers arrivals); animateParts() + 'animate_parts' in executeAiTool; Attach to Socket node + attachToSocket (a per-frame follow constraint, one 'attachTo' message + undo) Also: double-click a clip = play it on the selected character; .tpanim/.vrma/.bvh dropped on the viewport go to the Explorer and onto the rig under the cursor. Tests: unit tpanimOps, blendSpeed, animGraph, partsClip; e2e tpanim-motion, tpanim-states. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 5 + .../editors/AnimStatesWindow.svelte | 259 +++++++++++++ src/components/editors/AnimationWindow.svelte | 10 + src/components/editors/ClipToolsPanel.svelte | 309 +++++++++++++++ src/components/editors/Nodes.svelte | 5 + .../editors/nodes/BlendSpeedNode.svelte | 66 ++++ .../editors/nodes/PlayAnimNode.svelte | 14 + src/lib/ai/tools.js | 51 +++ src/lib/animatedImports.js | 351 ++++++++++++++++-- src/lib/commandsHandler.svelte.js | 13 +- src/lib/debugHooks.js | 5 + src/lib/explorerDrop.js | 28 +- src/lib/flowRuntime.js | 216 ++++++++++- src/lib/flowSockets.js | 4 + src/lib/nodeCatalog.js | 55 ++- src/lib/nodeDocs.js | 6 +- src/lib/tpanim/animGraph.js | 245 ++++++++++++ src/lib/tpanim/animGraphRuntime.js | 144 +++++++ src/lib/tpanim/animateParts.js | 42 +++ src/lib/tpanim/blendSpeed.js | 79 ++++ src/lib/tpanim/clipAssets.js | 16 +- src/lib/tpanim/clipEdit.js | 199 ++++++++++ src/lib/tpanim/partsClip.js | 160 ++++++++ src/lib/wireValidate.js | 12 + tests/e2e/tpanim-motion.test.cjs | 254 +++++++++++++ tests/e2e/tpanim-states.test.cjs | 233 ++++++++++++ tests/unit/animGraph.test.js | 129 +++++++ tests/unit/blendSpeed.test.js | 77 ++++ tests/unit/partsClip.test.js | 107 ++++++ 29 files changed, 3054 insertions(+), 40 deletions(-) create mode 100644 src/components/editors/AnimStatesWindow.svelte create mode 100644 src/components/editors/ClipToolsPanel.svelte create mode 100644 src/components/editors/nodes/BlendSpeedNode.svelte create mode 100644 src/lib/tpanim/animGraph.js create mode 100644 src/lib/tpanim/animGraphRuntime.js create mode 100644 src/lib/tpanim/animateParts.js create mode 100644 src/lib/tpanim/blendSpeed.js create mode 100644 src/lib/tpanim/clipEdit.js create mode 100644 src/lib/tpanim/partsClip.js create mode 100644 tests/e2e/tpanim-motion.test.cjs create mode 100644 tests/e2e/tpanim-states.test.cjs create mode 100644 tests/unit/animGraph.test.js create mode 100644 tests/unit/blendSpeed.test.js create mode 100644 tests/unit/partsClip.test.js diff --git a/src/App.svelte b/src/App.svelte index db386f8ce..8203571ee 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -388,8 +388,13 @@ import { startMusicToolbox } from './lib/musicToolbox' // an unsupported file — importFile pairs them up. const companionExt = /\.(mtl|png|jpe?g|webp|bmp|gif)$/i const companions = files.filter((file) => companionExt.test(file.name)) + // 57 M1: animation clip files go to the Explorer (converted to .tpanim) and onto the rig under the cursor + const clipExt = /\.(tpanim|vrma|bvh)$/i + const clipFiles = files.filter((file) => clipExt.test(file.name)) + if (clipFiles.length) import('./lib/explorerDrop').then((m) => m.dropClipFiles(clipFiles, event.clientX, event.clientY)) files.forEach((file) => { const name = file.name.toLowerCase() + if (clipFiles.includes(file)) return if (modelExt.test(name)) importFile(file, file.name.replace(modelExt, ''), undefined, undefined, companions) else if (name.endsWith('.json')) load(file) else if (!companions.includes(file)) skipped.push(file.name) diff --git a/src/components/editors/AnimStatesWindow.svelte b/src/components/editors/AnimStatesWindow.svelte new file mode 100644 index 000000000..c646d2542 --- /dev/null +++ b/src/components/editors/AnimStatesWindow.svelte @@ -0,0 +1,259 @@ + + + +
    +
    + + {#if graph}{/if} +
    + {#if !clips.length} +

    This model has no clips yet — drag animation clips onto it first.

    + {/if} + + {#if graph} +
    States {reading ? '— now: ' + stateName(reading.state) : ''}
    + {#each graph.states as st, i (st.id)} +
    + edit((g) => (g.states[i].name = e.currentTarget.value.trim() || st.id))} + /> + {#if st.blend} + blend · {st.blend.param} + {#each ['idle', 'walk', 'run'] as slot (slot)} + + {/each} + {:else} + + + {/if} + {#if graph.entry === st.id}entry{:else}{/if} + +
    + {/each} +
    + + +
    + +
    Transitions
    + {#each graph.transitions as tr, ti (ti)} +
    +
    + + → + + + +
    + {#each tr.when as c, ci (ci)} +
    + {ci ? 'and' : 'when'} + + + {#if !['true', 'false', 'trigger'].includes(c.op)} + edit((g) => (g.transitions[ti].when[ci].value = Number(e.currentTarget.value) || 0))} /> + {/if} + +
    + {/each} + +
    + {/each} + {#if graph.states.length > 1} + + {/if} + +
    Parameters {graph.driver === 'motion' ? '— speed, vy, grounded come from how it moves' : ''}
    + {#each graph.params as p, pi (p.name)} +
    + {p.name} + {p.type} + {fmtValue(reading?.params?.[p.name])} + {#if p.type === 'trigger'} + + {/if} + +
    + {/each} +
    + + + + +
    + {/if} +
    +
    + + diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index cfa1efd65..8ffe80744 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -34,6 +34,7 @@ // 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 ClipToolsPanel from './ClipToolsPanel.svelte'; // 57 M3/M8 import Icon from '../ui/Icon.svelte'; import { minimalScroll } from '$lib/ui/minimalScroll.js'; import ContextMenu from '../ContextMenu.svelte'; @@ -2078,6 +2079,15 @@
{/if} + + {#if target && clipState && !behavior} + {#if !clips.length} +

+ No clips yet — drag an animation clip (.tpanim) from the Explorer onto the model. +

+ {/if} + + {/if} {#if authoredClips.length} diff --git a/src/components/editors/ClipToolsPanel.svelte b/src/components/editors/ClipToolsPanel.svelte new file mode 100644 index 000000000..b4df7359f --- /dev/null +++ b/src/components/editors/ClipToolsPanel.svelte @@ -0,0 +1,309 @@ + + +
+ {#if embedded.names.length && !embedded.off} +
+ + +
+ {/if} + + {#if activeRef} + +
+ {#if fact} + {fact.fps} fps · {fact.frames} f · {fmt(fact.duration)} + {#if fact.humanoid}humanoid{/if} + + {fact.seam.ok ? 'seamless loop' : 'loop seam ' + fact.seam.angle.toFixed(1) + '°'} + + {:else} + reading the clip… + {/if} +
+ + +
+ + + + + + + {#if renaming} + + { + renaming = false; + void update({ name: e.currentTarget.value }); + }} + onkeydown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + if (e.key === 'Escape') renaming = false; + }} + /> + {:else} + + {/if} + + +
+ + + {#if duration > 0} +
+ +
+ in / out + + +
+
+ {fmt(Math.min(markIn, markOut))} – {fmt(Math.max(markIn, markOut))} + + + + + {#if previewing} + + {/if} +
+
+ {/if} + {/if} + + +
+ +
+ {#if statesOpen} + (statesOpen = false)} /> + {/if} + + + {#if refs.length > 1} +
+ Join clips… +
+ {#each refs as r (r.name)} + + {/each} + + +
+
+ {/if} +
diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index dcdabb388..aa874e7e0 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -66,6 +66,7 @@ import KeyPressNode from './nodes/KeyPressNode.svelte'; import GamepadNode from './nodes/GamepadNode.svelte'; import PlayAnimNode from './nodes/PlayAnimNode.svelte'; + import BlendSpeedNode from './nodes/BlendSpeedNode.svelte'; // 57 M3 import TimelineSeqNode from './nodes/TimelineSeqNode.svelte'; import AnimStateNode from './nodes/AnimStateNode.svelte'; import OnHitNode from './nodes/OnHitNode.svelte'; @@ -262,6 +263,10 @@ gamepadbutton: GamepadNode, gamepadaxis: GamepadNode, playanim: PlayAnimNode, // 17-E A5 + blendspeed: BlendSpeedNode, // 57 M3 + setanimparam: AnimationNode, // 57 M11 (spec-driven: named inputs + text/select/range) + onanimstate: AnimationNode, // 57 M11 (the onrest precedent: a trigger with params) + attachsocket: AnimationNode, // 57 M12 animfinished: OnClickNode, // 17-E: a pulse when a clip ends animmarker: OnClickNode, // 17-E F5: a pulse at a named point in a clip animstate: AnimStateNode, // 17-E F3: the readable half of it diff --git a/src/components/editors/nodes/BlendSpeedNode.svelte b/src/components/editors/nodes/BlendSpeedNode.svelte new file mode 100644 index 000000000..ea83b8d86 --- /dev/null +++ b/src/components/editors/nodes/BlendSpeedNode.svelte @@ -0,0 +1,66 @@ + + + + + +
+ speed (m/s) — unwired: its own + {#each SLOTS as slot (slot.key)} + + {/each} + + {#each suggestions as name (name)} + + {/each} + + {#each [{ key: 'walkSpeed', label: 'walk speed', min: 0.2, max: 4, step: 0.1, unit: 'm/s', fallback: 1.4 }, { key: 'runSpeed', label: 'run speed', min: 0.5, max: 10, step: 0.1, unit: 'm/s', fallback: 3.5 }, { key: 'crossfade', label: 'crossfade', min: 0, max: 1, step: 0.05, unit: 's', fallback: 0.25 }] as r (r.key)} + + {/each} +
+
diff --git a/src/components/editors/nodes/PlayAnimNode.svelte b/src/components/editors/nodes/PlayAnimNode.svelte index 5b2f0abc5..fa6035cad 100644 --- a/src/components/editors/nodes/PlayAnimNode.svelte +++ b/src/components/editors/nodes/PlayAnimNode.svelte @@ -81,6 +81,20 @@ on:input={(e) => setNodeData(id, { speed: +e.currentTarget.value })} /> + +
{running ? 'playing' : 'idle'} diff --git a/src/lib/ai/tools.js b/src/lib/ai/tools.js index 6d31f429a..7e20dee32 100644 --- a/src/lib/ai/tools.js +++ b/src/lib/ai/tools.js @@ -362,6 +362,7 @@ function updateOne(spec) { const TOOL_NAMES = [ 'list_scene', + 'animate_parts', // 57 M12 'create_objects', 'update_objects', 'delete_objects', @@ -567,6 +568,14 @@ export async function executeAiTool(rawName, rawArgs) { case 'list_scene': return summarizeScene(); + case 'animate_parts': { + // 57 M12: one clip on a model's named parts, assigned + played through the replicated, + // undoable clip path (lazy: the clip tools load only when asked) + const { animateParts } = await import('$lib/tpanim/animateParts.js'); + if (args?.object && lockedByPeer(String(args.object))) return { error: 'that object is being edited by another peer' }; + return await animateParts(args ?? {}); + } + case 'create_objects': { const specs = Array.isArray(args?.objects) ? args.objects : []; if (!specs.length) @@ -820,6 +829,48 @@ export const AI_TOOLS = [ required: ['confirm'] } } + }, + { + type: 'function', + function: { + name: 'animate_parts', + description: + "Animate a model's NAMED PARTS (child nodes from list_scene: a rotor, a door, a lid) with one clip — e.g. spin a rotor: tracks [{part: 'rotor', keys: [{t: 0, rot: [0, 0, 0]}, {t: 1, rot: [0, 360, 0]}]}], loop true. Keys are offsets from each part's rest pose (mode 'relative', the default; 'absolute' = local values). rot is Euler degrees [x, y, z]; pos metres [x, y, z]; scale a factor. The clip is saved as a .tpanim file, plays on every peer, and is one undo step.", + parameters: { + type: 'object', + properties: { + object: { type: 'string', description: 'the model: its uuid (from list_scene) or exact name' }, + clip: { type: 'string', description: 'the clip name, e.g. "Spin" or "Open"' }, + tracks: { + type: 'array', + items: { + type: 'object', + properties: { + part: { type: 'string', description: 'the part (child node) name' }, + keys: { + type: 'array', + items: { + type: 'object', + properties: { + t: { type: 'number', description: 'seconds' }, + pos: { type: 'array', items: { type: 'number' } }, + rot: { type: 'array', items: { type: 'number' } }, + scale: { type: 'array', items: { type: 'number' } } + }, + required: ['t'] + } + } + }, + required: ['part', 'keys'] + } + }, + loop: { type: 'boolean', description: 'repeat (default true); false = play once and hold' }, + mode: { type: 'string', enum: ['relative', 'absolute'] }, + play: { type: 'boolean', description: 'start it now (default true)' } + }, + required: ['object', 'clip', 'tracks'] + } + } } ]; diff --git a/src/lib/animatedImports.js b/src/lib/animatedImports.js index 48d23443d..c7b0e6b84 100644 --- a/src/lib/animatedImports.js +++ b/src/lib/animatedImports.js @@ -28,7 +28,7 @@ export const animatedObjects = writable({}); const fileBytes = new Map(); /** @type {Map} rootUuid -> which parser those bytes need */ const fileKinds = new Map(); -/** @type {Map, durations: Record, rest?: Map, assets?: Record, clipSpeed?: Record, clipOnce?: Record, assetKeys?: Record}>} */ +/** @type {Map, durations: Record, rest?: Map, assets?: Record, clipSpeed?: Record, clipOnce?: Record, assetKeys?: Record, embeddedOff?: boolean, embedded?: string[], stashed?: Record, parts?: boolean}>} */ const mixers = new Map(); // ---- 33 P2: animated FUNCTIONAL items (a door, a chest, a lever, a fan) --------------- @@ -83,12 +83,17 @@ function behaviorFields(uuid) { * behavior spec does — the peer RE-PARSES the file, so anything stamped on the root after * the parse is otherwise lost on peers, after a reload and across an undo. Absent = none, * so every message without one is byte-identical. - * @param {any} root @returns {{lod?: any, clipRefs?: any[]}} + * @param {any} root @returns {{lod?: any, clipRefs?: any[], clipsOff?: boolean, animGraph?: any}} */ function rootDataFields(root) { // 57 M1: the clip ASSETS assigned to this model ride beside the bytes for the same reason const refs = clipRefsOf(root); + /** @type {any} */ const clips = refs.length ? { clipRefs: refs } : {}; + // 57 M8: the model's EMBEDDED clips are switched off (Extract clips ▸ use the files instead) + if (root?.userData?.clipsOff) clips.clipsOff = true; + // 57 M11: its Animation states graph (plain JSON, normalized on every apply) + if (root?.userData?.animGraph && typeof root.userData.animGraph === 'object') clips.animGraph = JSON.parse(JSON.stringify(root.userData.animGraph)); const lod = root?.userData?.lod; if (!lod || typeof lod !== 'object') return clips; try { @@ -138,6 +143,17 @@ export function normalizeClipRefs(raw) { return out; } +/** 57 M8: the clips the model's own file carries, and whether they are switched off @param {string} uuid */ +export function embeddedClipsOf(uuid) { + const record = mixers.get(uuid); + return { names: record?.embedded ?? [], off: !!record?.embeddedOff }; +} + +/** the bound AnimationClip for a clip name (Extract clips reads the embedded ones) @param {string} uuid @param {string} name */ +export function threeClipOf(uuid, name) { + return mixers.get(uuid)?.actions[name]?.getClip() ?? null; +} + /** what a bound clip-asset action was bound from (hash|mode), or null @param {string} uuid @param {string} name */ export function clipAssetKey(uuid, name) { return mixers.get(uuid)?.assetKeys?.[name] ?? null; @@ -172,7 +188,10 @@ export function addClipAction(uuid, clip, hash, key = hash) { const old = record.actions[clip.name]; if (old) { old.stop(); - record.mixer.uncacheAction(old.getClip()); + // an EMBEDDED clip of the same name is stashed, not dropped (Extract ▸ use the files keeps + // the names, so a graph naming "Walk" plays the file) — it comes back when the file goes + if (!record.assets?.[clip.name]) record.stashed = { ...(record.stashed ?? {}), [clip.name]: old }; + else record.mixer.uncacheAction(old.getClip()); } record.actions[clip.name] = record.mixer.clipAction(clip); record.durations[clip.name] = clip.duration; @@ -210,11 +229,19 @@ export function pruneClipActions(uuid, keep) { delete record.durations[name]; delete record.assets[name]; if (record.assetKeys) delete record.assetKeys[name]; + const stashed = record.stashed?.[name]; + if (stashed) { + record.actions[name] = stashed; + record.durations[name] = stashed.getClip().duration; + delete record.stashed?.[name]; + } } const entry = get(animatedObjects)[uuid]; if (entry) { - const clips = entry.clips.filter((c) => !gone.includes(c)); - const clip = gone.includes(entry.clip) ? clips[0] : entry.clip; + // a restored embedded clip stays listed (unless the embedded clips are off) + const back = (/** @type {string} */ c) => !!record.actions[c] && !record.embeddedOff; + const clips = entry.clips.filter((c) => !gone.includes(c) || back(c)); + const clip = gone.includes(entry.clip) && !back(entry.clip) ? clips[0] : entry.clip; animatedObjects.update((map) => ({ ...map, [uuid]: { ...entry, clips, clip, playing: gone.includes(entry.clip) ? false : entry.playing } })); } } @@ -230,33 +257,58 @@ function attachClipRefs(uuid) { /** * Set a model's clip refs: applied here, bound, and (unless `replicate` is false) broadcast as ONE - * message and recorded as ONE undo entry. - * @param {string} uuid @param {any[]} refs @param {{replicate?: boolean, record?: boolean}} [opts] + * message and recorded as ONE undo entry. 57 M8: `clipsOff` switches the model's EMBEDDED clips + * off (or back on) in the same step — absent = unchanged. + * @param {string} uuid @param {any[]} refs @param {{replicate?: boolean, record?: boolean, clipsOff?: boolean}} [opts] */ export function setClipRefs(uuid, refs, opts = {}) { const root = get(objectsGroup)?.getObjectByProperty('uuid', uuid); - if (!root || !fileBytes.has(uuid)) return false; + if (!root || !ensureClipModel(root)) return false; const before = clipRefsOf(root); const after = normalizeClipRefs(refs); - if (JSON.stringify(before) === JSON.stringify(after)) return false; + const offBefore = !!root.userData.clipsOff; + const offAfter = typeof opts.clipsOff === 'boolean' ? opts.clipsOff : offBefore; + if (JSON.stringify(before) === JSON.stringify(after) && offBefore === offAfter) return false; if (after.length) root.userData.clipRefs = after; else delete root.userData.clipRefs; + if (offAfter !== offBefore) setEmbeddedOff(uuid, offAfter); attachClipRefs(uuid); - if (opts.record !== false) recordEntry({ kind: 'clipRefs', uuid, before, after }); + pokeClipModels(); // a settings-only change (mode / loop / speed) moves no clip list + if (opts.record !== false) + recordEntry({ kind: 'clipRefs', uuid, before: { refs: before, clipsOff: offBefore }, after: { refs: after, clipsOff: offAfter } }); if (opts.replicate !== false) { /** @type {any} */ const peer = get(peers); - if (peer) peer.send({ type: 'objectParameters', parameter: 'clipRefs', uuid, clipRefs: after }); + if (peer) peer.send({ type: 'objectParameters', parameter: 'clipRefs', uuid, clipRefs: after, ...(offAfter || offBefore ? { clipsOff: offAfter } : {}) }); } return true; } +/** 57 M8: switch a model's embedded clips off/on (the bytes are untouched; the list hides them) @param {string} uuid @param {boolean} off */ +function setEmbeddedOff(uuid, off) { + const root = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + const record = mixers.get(uuid); + if (!root || !record) return; + if (off) root.userData.clipsOff = true; + else delete root.userData.clipsOff; + record.embeddedOff = off; + const entry = get(animatedObjects)[uuid]; + if (!entry) return; + const assets = Object.keys(record.assets ?? {}); + const clips = off ? entry.clips.filter((c) => assets.includes(c)) : [...(record.embedded ?? []), ...entry.clips.filter((c) => !(record.embedded ?? []).includes(c))]; + const hidden = off && !assets.includes(entry.clip); + if (hidden) record.actions[entry.clip]?.stop(); + animatedObjects.update((map) => ({ ...map, [uuid]: { ...entry, clips, clip: hidden ? clips[0] : entry.clip, playing: hidden ? false : entry.playing } })); +} + registerHistoryKind('clipRefs', (entry, state) => { - if (!fileBytes.has(entry.uuid)) { + if (!get(objectsGroup)?.getObjectByProperty('uuid', entry.uuid)) { showToast('Cannot undo/redo: the object no longer exists'); return false; } - setClipRefs(entry.uuid, state, { record: false }); + // a pre-M8 entry is the refs array alone + if (Array.isArray(state)) setClipRefs(entry.uuid, state, { record: false }); + else setClipRefs(entry.uuid, state?.refs ?? [], { record: false, clipsOff: !!state?.clipsOff }); return true; }); @@ -286,6 +338,61 @@ export function hasAnimatedImport(uuid) { return fileBytes.has(uuid); } +// ---- 57 M12: PARTS MODELS --------------------------------------------------------------- +// A model with no rig and no clips (an img2threejs import, a kit piece with a rotor and a door) +// can still be animated: its NAMED NODES are its parts. On the first clip ref it gets a mixer of +// its own (no bytes — it replicates and saves through the ordinary object path, which carries +// `userData.clipRefs` / `animGraph`), and node-name `.tpanim` clips bind to it like any skeleton +// clip. A sweep registers the parts models that arrive by a load or a peer's object sync. + +/** does this object have a mixer for clips (an animated import, or a parts model)? @param {string} uuid */ +export function hasClipModel(uuid) { + return mixers.has(uuid); +} + +/** + * Make sure an object can take clips: an animated import already can; anything else becomes a + * PARTS model (a mixer over its own nodes). A functional item (a pack door) cannot. + * @param {any} root @returns {boolean} + */ +export function ensureClipModel(root) { + if (!root?.uuid) return false; + if (mixers.has(root.uuid)) return !behaviors.has(root.uuid); + const mixer = new THREE.AnimationMixer(root); + mixers.set(root.uuid, { mixer, actions: {}, durations: {}, rest: captureRest(root), embedded: [], embeddedOff: false, parts: true }); + animatedObjects.update((map) => ({ ...map, [root.uuid]: { clips: [], clip: '', playing: false, speed: 1 } })); + return true; +} + +let lastSweep = 0; +/** register parts models that arrived carrying refs or a graph; forget the ones that left @param {number} time */ +function sweepPartsModels(time) { + if (Math.abs(time - lastSweep) < 0.5) return; + lastSweep = time; + const group = get(objectsGroup); + if (!group) return; + for (const child of group.children) { + const ud = child.userData; + if (ud?.attachTo && !socketFollowers.has(child.uuid)) syncSocketFollower(child); + if (mixers.has(child.uuid) || (!ud?.clipRefs && !ud?.animGraph)) continue; + if (ensureClipModel(child)) { + attachClipRefs(child.uuid); + loadGraphRuntime(child); + } + } + for (const [uuid, record] of mixers) + if (record.parts && !group.getObjectByProperty('uuid', uuid)) { + record.mixer.stopAllAction(); + mixers.delete(uuid); + drivers.delete(uuid); + animatedObjects.update((map) => { + const next = { ...map }; + delete next[uuid]; + return next; + }); + } +} + /** This model's clip transport, for a caller that must not import the store * (17-E: the Play Animation node reaches this module through a primed dynamic * import, so it reads state through accessors). @param {string} uuid */ @@ -303,7 +410,7 @@ export function animatedImportKind(uuid) { export function clipInfo(uuid) { const record = mixers.get(uuid); if (!record) return []; - return Object.keys(record.actions).map((name) => ({ + return Object.keys(record.actions).filter((name) => !record.embeddedOff || record.assets?.[name]).map((name) => ({ name, duration: record.durations[name] ?? 0, // 57 M1: a clip that came from a `.tpanim` asset names its hash @@ -337,7 +444,8 @@ export function registerAnimatedImport(root, animations, bytes, kind = 'gltf') { actions[clip.name] = mixer.clipAction(clip); durations[clip.name] = clip.duration; }); - mixers.set(root.uuid, { mixer, actions, durations, rest: captureRest(root) }); + const embeddedOff = !!root.userData.clipsOff; + mixers.set(root.uuid, { mixer, actions, durations, rest: captureRest(root), embedded: animations.map((c) => c.name), embeddedOff }); root.userData.animatedClips = animations.map((c) => c.name); const spec = normalizeBehavior(root.userData.behavior); if (spec && !actions[spec.clip]) { @@ -348,13 +456,14 @@ export function registerAnimatedImport(root, animations, bytes, kind = 'gltf') { root.userData.behavior = spec; behaviors.set(root.uuid, spec); } else delete root.userData.behavior; - const first = spec && actions[spec.clip] ? spec.clip : animations[0]?.name; + const first = spec && actions[spec.clip] ? spec.clip : embeddedOff ? undefined : animations[0]?.name; animatedObjects.update((map) => ({ ...map, - [root.uuid]: { clips: animations.map((c) => c.name), clip: first, playing: false, speed: 1 } + [root.uuid]: { clips: embeddedOff ? [] : animations.map((c) => c.name), clip: first, playing: false, speed: 1 } })); if (behaviors.has(root.uuid)) behaviorHooks.registered?.(root.uuid); attachClipRefs(root.uuid); // 57 M1 + loadGraphRuntime(root); // 57 M11 } /** @@ -410,11 +519,15 @@ export function dropProceduralBehavior(uuid) { export function tickAnimatedMixers() { const states = get(animatedObjects); const time = runtimeNow(); + // 57 M12: parts models arriving by a load or a peer's sync (throttled) + sweepPartsModels(time); + // 57 M11: the Animation states graphs feed their pose drivers first (lazy; absent = none) + if (graphTick) graphTick(time); mixers.forEach((record, uuid) => { if (behaviors.has(uuid)) return; // 33 P2: packBehavior poses these // 57 M3: a Blend by Speed node (or a state machine) drives this model's weights LOCALLY const driver = drivers.get(uuid); - if (driver && time - driver.stamp < DRIVER_STALE_S && time >= driver.stamp - 1) return applyDriver(record, driver, time); + if (driver && (driver.hold || (time - driver.stamp < DRIVER_STALE_S && time >= driver.stamp - 1))) return applyDriver(record, driver, time); if (driver) releaseDriver(uuid, record); const state = states[uuid]; if (!state || !state.playing || !state.clip) return; @@ -452,6 +565,126 @@ export function tickAnimatedMixers() { } mixer.setTime(t); }); + // 57 M12: objects attached to a socket follow it, after the sockets were posed this frame + if (socketFollowers.size) followSockets(); +} + +// ---- 57 M12: ATTACH TO A SOCKET ------------------------------------------------------------ +// `userData.attachTo = {uuid, socket, offset: [px,py,pz, qx,qy,qz,qw]}` on an object makes it +// follow a named node of a model (a sword in a hand, a lamp on a rotor arm). A CONSTRAINT, not a +// re-parent: the object stays a top-level object (its own saves, sync and undo are untouched) and +// every peer places it each frame from the socket it poses itself — no message per frame. + +/** uuid of the follower -> its attach spec @type {Map} */ +const socketFollowers = new Map(); +const _sm = new THREE.Matrix4(); +const _so = new THREE.Matrix4(); +const _sp = new THREE.Vector3(); +const _sq = new THREE.Quaternion(); +const _ss = new THREE.Vector3(); + +/** (re)read one object's attach spec @param {any} object */ +export function syncSocketFollower(object) { + const a = object?.userData?.attachTo; + if (a && typeof a.uuid === 'string' && typeof a.socket === 'string') socketFollowers.set(object.uuid, { uuid: a.uuid, socket: a.socket, offset: Array.isArray(a.offset) && a.offset.length === 7 ? a.offset : [0, 0, 0, 0, 0, 0, 1] }); + else socketFollowers.delete(object?.uuid); +} + +/** + * Attach an object to a model's socket (a named node), or detach it (`socket` null). `snap` puts + * it ON the socket; otherwise it keeps where it stands relative to the socket. Applied here and — + * unless `replicate` / `record` is false — ONE `objectParameters 'attachTo'` message and ONE + * `attachTo` undo entry. A detach leaves the object where it was last placed. + * @param {string} itemUuid @param {string | null} modelUuid @param {string | null} socket + * @param {{snap?: boolean, replicate?: boolean, record?: boolean, spec?: any}} [opts] `spec` = an exact stored spec (undo / a peer) + * @returns {boolean} + */ +export function attachToSocket(itemUuid, modelUuid, socket, opts = {}) { + const group = get(objectsGroup); + const item = group?.getObjectByProperty('uuid', itemUuid); + if (!item) return false; + const before = item.userData?.attachTo ?? null; + /** @type {any} */ + let after = null; + if (opts.spec !== undefined) after = opts.spec; + else if (modelUuid && socket) { + const model = group.getObjectByProperty('uuid', modelUuid); + const node = model?.getObjectByName(socket) ?? model?.getObjectByName(THREE.PropertyBinding.sanitizeNodeName(socket)); + if (!model || !node || model === item || item.getObjectByProperty('uuid', model.uuid)) return false; + let offset = [0, 0, 0, 0, 0, 0, 1]; + if (!opts.snap) { + node.updateWorldMatrix(true, false); + item.updateWorldMatrix(true, false); + const rel = new THREE.Matrix4().copy(node.matrixWorld).invert().multiply(item.matrixWorld); + const p = new THREE.Vector3(); + const q = new THREE.Quaternion(); + rel.decompose(p, q, new THREE.Vector3()); + offset = [...p.toArray(), ...q.toArray()]; + } + after = { uuid: model.uuid, socket: node.name, offset }; + } + if (JSON.stringify(before) === JSON.stringify(after)) return false; + if (after) item.userData.attachTo = after; + else delete item.userData.attachTo; + syncSocketFollower(item); + if (!after) { + // detached: tell everyone where it was left (the follow was local on every peer) + /** @type {any} */ + const peer = get(peers); + if (peer && opts.replicate !== false) peer.send({ type: 'move', uuid: item.uuid, pos: item.position.toArray(), rot: item.rotation.toArray(), scale: item.scale.toArray() }); + } + if (opts.record !== false) recordEntry({ kind: 'attachTo', uuid: item.uuid, before, after }); + if (opts.replicate !== false) { + /** @type {any} */ + const peer = get(peers); + if (peer) peer.send({ type: 'objectParameters', parameter: 'attachTo', uuid: item.uuid, attachTo: after }); + } + return true; +} + +registerHistoryKind('attachTo', (entry, state) => { + if (!get(objectsGroup)?.getObjectByProperty('uuid', entry.uuid)) { + showToast('Cannot undo/redo: the object no longer exists'); + return false; + } + attachToSocket(entry.uuid, null, null, { spec: state ?? null, record: false }); + return true; +}); + +/** the sockets (named nodes) a model offers, for a picker @param {string} uuid @returns {string[]} */ +export function socketsOf(uuid) { + const model = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + /** @type {string[]} */ + const out = []; + model?.traverse((/** @type {any} */ o) => { + if (o !== model && o.name && !o.isMesh && !o.isSkinnedMesh) out.push(o.name); + }); + return out; +} + +function followSockets() { + const group = get(objectsGroup); + if (!group) return; + for (const [uuid, a] of socketFollowers) { + const obj = group.getObjectByProperty('uuid', uuid); + const model = group.getObjectByProperty('uuid', a.uuid); + const socket = model?.getObjectByName(a.socket) ?? model?.getObjectByName(THREE.PropertyBinding.sanitizeNodeName(a.socket)); + if (!obj || !socket || obj.userData?.attachTo?.uuid !== a.uuid) { + if (!obj || !obj.userData?.attachTo) socketFollowers.delete(uuid); + continue; + } + socket.updateWorldMatrix(true, false); + _so.compose(_sp.set(a.offset[0], a.offset[1], a.offset[2]), _sq.set(a.offset[3], a.offset[4], a.offset[5], a.offset[6]), _ss.set(1, 1, 1)); + _sm.multiplyMatrices(socket.matrixWorld, _so); + // into the follower's parent frame (normally the scene root) + if (obj.parent) { + obj.parent.updateWorldMatrix(true, false); + _sm.premultiply(_so.copy(obj.parent.matrixWorld).invert()); + } + _sm.decompose(_sp, _sq, _ss); + obj.position.copy(_sp); + obj.quaternion.copy(_sq); + } } // ---- 57 M3: LOCAL POSE DRIVERS (Blend by Speed, the Animation states graph) --------------- @@ -461,21 +694,83 @@ export function tickAnimatedMixers() { // node stops feeding it (deleted, or the graph stopped). const DRIVER_STALE_S = 0.5; -/** @type {Map, rates: Record, phase: Record, stamp: number, last: number}>} */ + +// ---- 57 M11: the ANIMATION STATES graph on a model ----------------------------------------- +// `userData.animGraph` (tpanim/animGraph.js's shape) is config: it rides the objectfile message, +// saves and undo (rootDataFields), a change is ONE `objectParameters 'animGraph'` message + ONE +// `animGraph` undo entry, and every peer evaluates it itself (tpanim/animGraphRuntime.js, loaded +// only when a model has one) into the model's pose driver. + +/** @type {((time: number) => void) | null} */ +let graphTick = null; +/** the graph runtime plugs its per-frame step in here @param {((time: number) => void) | null} fn */ +export function setAnimGraphTick(fn) { + graphTick = fn; +} + +/** userData changed under the clip panels: a shallow copy re-runs every subscriber */ +function pokeClipModels() { + animatedObjects.update((map) => ({ ...map })); +} + +/** @param {any} root */ +function loadGraphRuntime(root) { + if (!root?.userData?.animGraph && !graphTick) return; + void import('./tpanim/animGraphRuntime.js').then((m) => m.syncAnimGraph(root.uuid)); +} + +/** + * Set (or clear, with null) a model's Animation states graph — ONE message, ONE undo step. + * @param {string} uuid @param {any} graph @param {{replicate?: boolean, record?: boolean}} [opts] + */ +export async function setAnimGraph(uuid, graph, opts = {}) { + const root = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + if (!root || !ensureClipModel(root)) return false; + const { normalizeAnimGraph } = await import('./tpanim/animGraph.js'); + const before = root.userData.animGraph ?? null; + const after = graph ? normalizeAnimGraph(graph) : null; + if (JSON.stringify(before) === JSON.stringify(after)) return false; + if (after) root.userData.animGraph = after; + else delete root.userData.animGraph; + loadGraphRuntime(root); + pokeClipModels(); + if (opts.record !== false) recordEntry({ kind: 'animGraph', uuid, before, after }); + if (opts.replicate !== false) { + /** @type {any} */ + const peer = get(peers); + if (peer) peer.send({ type: 'objectParameters', parameter: 'animGraph', uuid, animGraph: after }); + } + return true; +} + +registerHistoryKind('animGraph', (entry, state) => { + if (!get(objectsGroup)?.getObjectByProperty('uuid', entry.uuid)) { + showToast('Cannot undo/redo: the object no longer exists'); + return false; + } + void setAnimGraph(entry.uuid, state, { record: false }); + return true; +}); +/** @type {Map, rates: Record, phase: Record, stamp: number, last: number, hold?: boolean, times?: Record | null}>} */ const drivers = new Map(); /** * Feed a model's blend for this frame: `weights` per clip name (normalized here), `rates` = clip - * seconds per second for each (1 = its own speed). LOCAL, never sent. + * seconds per second for each (1 = its own speed). LOCAL, never sent. 57 M8: `times` pins clips + * at absolute clip times (the Animation window's scrub preview) and `hold` keeps the driver until + * `clearPoseDriver`, with no per-frame feeding. * @param {string} uuid @param {Record} weights @param {Record} [rates] + * @param {{times?: Record, hold?: boolean}} [opts] */ -export function setPoseDriver(uuid, weights, rates = {}) { +export function setPoseDriver(uuid, weights, rates = {}, opts = {}) { if (!mixers.has(uuid) || behaviors.has(uuid)) return false; const now = runtimeNow(); const d = drivers.get(uuid) ?? { weights: {}, rates: {}, phase: {}, stamp: now, last: now }; d.weights = weights; d.rates = rates; d.stamp = now; + d.hold = !!opts.hold; + d.times = opts.times ?? null; drivers.set(uuid, d); return true; } @@ -509,7 +804,9 @@ function applyDriver(record, d, time) { if (!a.isScheduled()) a.play(); const duration = record.durations[name] || 1; const rate = (d.rates[name] ?? 1) * (record.clipSpeed?.[name] ?? 1); - d.phase[name] = ((d.phase[name] ?? (time * rate) % duration) + dt * rate) % duration; + const pinned = d.times?.[name]; + if (Number.isFinite(pinned)) d.phase[name] = Math.min(Math.max(/** @type {number} */ (pinned), 0), duration - 1e-4); + else d.phase[name] = ((d.phase[name] ?? (time * rate) % duration) + dt * rate) % duration; a.setEffectiveWeight(w); a.time = d.phase[name]; } @@ -628,7 +925,9 @@ export async function applyObjectFile(data) { if (!data.override) return; if (data.name) held.name = data.name; if (data.lod && typeof data.lod === 'object') held.userData.lod = data.lod; - if (data.clipRefs) setClipRefs(data.uuid, data.clipRefs, { replicate: false, record: false }); + if (data.clipRefs || data.clipsOff !== undefined) + setClipRefs(data.uuid, data.clipRefs ?? clipRefsOf(held), { replicate: false, record: false, clipsOff: !!data.clipsOff }); + if (data.animGraph !== undefined) void setAnimGraph(data.uuid, data.animGraph, { replicate: false, record: false }); 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); @@ -653,6 +952,8 @@ export async function applyObjectFile(data) { // 57 M1: the clip assets this model plays (bound once registered) const refs = normalizeClipRefs(data.clipRefs); if (refs.length) root.userData.clipRefs = refs; + if (data.clipsOff === true) root.userData.clipsOff = true; + if (data.animGraph && typeof data.animGraph === 'object') root.userData.animGraph = data.animGraph; // 57 M11 // 33-scenes: the pack file these bytes are, so this copy SAVES as a reference too const ref = animRefOf({ userData: { animRef: data.animRef } }); if (ref) root.userData.animRef = ref; @@ -863,6 +1164,8 @@ export async function animatedImportsRestore(entries, replicate = true, live = ( behavior: entry.behavior, lod: entry.lod, clipRefs: entry.clipRefs, + clipsOff: entry.clipsOff, + animGraph: entry.animGraph, live }); if (!live()) break; @@ -921,6 +1224,8 @@ registerHistoryKind('animimport', (entry, state) => { behavior: entry.behavior, lod: entry.lod, clipRefs: entry.clipRefs, + clipsOff: entry.clipsOff, + animGraph: entry.animGraph, animRef: entry.animRef }).then(() => { const root = get(objectsGroup)?.getObjectByProperty('uuid', entry.uuid); diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index d83678e56..805a6a4c4 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -13,7 +13,7 @@ import { dropPeerClock } from '$lib/musicClock' import { applyRemoteDevice } from '$lib/audioDevices' import { sessionHost, dropPeerJoined } from '$lib/connectionState' import { environment, editEnvSky } from '$lib/environment' -import { hasAnimatedImport, sendAnimatedImport, setAnimationState, setClipRefs, dropAllAnimatedImports } from '$lib/animatedImports' +import { hasAnimatedImport, sendAnimatedImport, setAnimationState, setClipRefs, setAnimGraph, attachToSocket, dropAllAnimatedImports } from '$lib/animatedImports' import { dropAllAnimations } from '$lib/animationPreview' import { parkAnimatedAtBase } from '$lib/flowRuntime' import { stripEditOverlays } from '$lib/editOverlays' @@ -590,7 +590,16 @@ export async function objectParameters(data) { setAnimationState(data.uuid, next, false); } else if (data.parameter == 'clipRefs') { // 57 M1: the clip assets a model plays (bytes pulled by hash, bound per peer) - setClipRefs(data.uuid, data.clipRefs, { replicate: false, record: false }); + setClipRefs(data.uuid, data.clipRefs, { replicate: false, record: false, ...(typeof data.clipsOff === 'boolean' ? { clipsOff: data.clipsOff } : {}) }); + } else if (data.parameter == 'animGraph') { + // 57 M11: a model's Animation states graph (normalized on apply; null = removed) + setAnimGraph(data.uuid, data.animGraph, { replicate: false, record: false }); + } else if (data.parameter == 'attachTo') { + // 57 M12: an object follows a model's socket (or no longer does) + attachToSocket(data.uuid, null, null, { spec: data.attachTo ?? null, replicate: false, record: false }); + } else if (data.parameter == 'animParam') { + // 57 M11: a synced Animation states parameter (latest-wins by its stamp) + import('$lib/tpanim/animGraphRuntime.js').then((m) => m.setAnimParam(data.uuid, data.name, data.value, { replicate: false, at: data.at })); } else if (data.parameter == 'castShadow') { let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid); if (mesh) mesh.castShadow = data.castShadow; diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 644d73273..28f625a96 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -126,6 +126,11 @@ export const DEBUG_HOOKS = [ ['tpanim', () => import('./tpanim/format.js')], ['clipAssets', () => import('./tpanim/clipAssets.js')], ['humanoidMap', () => import('./tpanim/humanoidMap.js')], + // 57 M3/M8/M11: clip edits + ops, the Animation states graph + runtime + ['clipEdit', () => import('./tpanim/clipEdit.js')], + ['clipOps', () => import('./tpanim/clipOps.js')], + ['animGraph', () => import('./tpanim/animGraph.js')], + ['animGraphRuntime', () => import('./tpanim/animGraphRuntime.js')], ['fileHandler', () => import('./fileHandler.svelte')], ['fileWindows', () => import('./fileWindows')], ['imageEditor', () => import('./image/imageEditor')], diff --git a/src/lib/explorerDrop.js b/src/lib/explorerDrop.js index bcf81fc44..6bf640027 100644 --- a/src/lib/explorerDrop.js +++ b/src/lib/explorerDrop.js @@ -3,7 +3,7 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; import { globalCamera, selectedObject } from '../stores/sceneStore'; import { peers, showToast, toastStore, stackOnDrop } from '../stores/appStore'; -import { explorerItems, itemBlob } from './explorer'; +import { explorerItems, itemBlob, importFiles } from './explorer'; import { prefabs, instantiatePrefab } from './prefabs'; import { importFile, addImported } from './fileHandler.svelte'; import { packRefFromUrl, peekPackTemplate, loadPackTemplate } from './packRefs'; @@ -177,6 +177,24 @@ function spreadPoint(point, i, count) { ]; } +/** + * 57 M1: animation clip FILES dropped on the viewport (.tpanim / .vrma / .bvh from the desktop): into + * the Explorer (converted to .tpanim on the way), then onto the model under the cursor, the first + * one playing — the same as dragging the new cards onto it. + * @param {File[]} files @param {number} clientX @param {number} clientY + */ +export async function dropClipFiles(files, clientX, clientY) { + const target = dropTarget(clientX, clientY); + const items = (await importFiles(files, null)).filter((/** @type {any} */ it) => it?.kind === 'anim'); + if (!items.length) return; + if (!target.object) return showToast('Clips added to the Explorer — drag one onto a character to play it'); + let first = true; + for (const item of items) { + await placeExplorerPayload({ id: item.id, kind: 'anim', name: item.name }, first ? target : { ...target, noPlay: true }); + first = false; + } +} + /** * Handle a viewport drop of an Explorer card. * @@ -204,7 +222,7 @@ export async function dropExplorerItem(payload, clientX, clientY) { * Place ONE payload at an already-resolved drop target (21-H3 split this out of * `dropExplorerItem` unchanged so the multi path could reuse the single one verbatim). * @param {any} payload - * @param {{point: number[] | null, object: any, normal: number[] | null, hit?: any}} target + * @param {{point: number[] | null, object: any, normal: number[] | null, hit?: any, noPlay?: boolean}} target */ async function placeExplorerPayload(payload, target) { if (payload.prefabId) { @@ -260,12 +278,14 @@ async function placeExplorerPayload(payload, target) { const { hasAnimatedImport } = await import('./animatedImports'); let hit = target.hit ?? target.object; while (hit && !hasAnimatedImport(hit.uuid)) hit = hit.parent; + // 57 M12: no rig under the drop — the object itself takes it (its named nodes are its parts) + hit ??= target.object; if (!hit) { - showToast('Drop the clip ON a character or another rigged model to play it'); + showToast('Drop the clip ON a character or another model to play it'); return; } const { assignClipAsset } = await import('./tpanim/clipAssets.js'); - assignClipAsset(hit.uuid, { hash: item.hash, name: item.name }, { play: true }); + assignClipAsset(hit.uuid, { hash: item.hash, name: item.name }, { play: !target.noPlay }); } else if (item.kind === 'image') { if (!target.object) { showToast('Drop the image ON an object to texture it'); diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 619962fa9..f53a37006 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -104,6 +104,8 @@ import { // 37 (R6): variadic math/gate + the Switcher multiplexer (a leaf, no imports) import { rigGoal, rigStep, validVec } from './cameraRig.js'; // 37 (R8): a three leaf import { INPUT_LETTERS, socketCount, opFolds, foldMath, foldGate, switcherItems, switcherHandle, switcherIndexOf, switcherRadioIndex } from './variadicNodes.js'; +import { blendSpeedWeights, smoothWeights, clipForSlot } from './tpanim/blendSpeed.js'; // 57 M3 (a leaf) +import { newTracker, trackPose } from './avatars/locomotion.js'; // 57 M3: a stream-safe speed (a leaf) // H3: inputRuntime is reached via a PRIMED dynamic import (the moduleSDK // pattern) — a static edge would close the TDZ cycle history -> flowRuntime -> @@ -607,7 +609,9 @@ function updatePlayAnim(pairs, ctx) { const state = animImportsRef.animationState?.(uuid); const playing = !!state?.playing && state?.clip === clip; const next = action === 'stop' ? false : action === 'toggle' ? !playing : true; - animImportsRef.setAnimationState(uuid, { clip, playing: next, speed }); + // 57 M3: stamped with the trigger, and a CROSSFADE from whatever plays when asked for + const fade = Math.min(Math.max(Number(data.crossfade) || 0, 0), 5); + animImportsRef.setAnimationState(uuid, { clip, playing: next, speed, at, ...(fade > 0 ? { fade } : {}) }); continue; } if (!animRef) continue; @@ -636,6 +640,163 @@ function updatePlayAnim(pairs, ctx) { for (const id of [...playAnimEdge.keys()]) if (!seen.has(id)) playAnimEdge.delete(id); } +/** 57 M3: per Blend by Speed node + model, the eased weights and when they were last eased + * @type {Map, t: number}>} */ +const blendState = new Map(); +/** 57 M3: per model, its own horizontal speed from its pose — fed every frame on EVERY peer (the + * peer moving it and the ones receiving ~10 Hz moves alike; the avatars' tracker differentiates per + * SAMPLE and low-passes, so a move stream does not read as a stutter) @type {Map} */ +const blendTrackers = new Map(); +const _bsPos = new THREE.Vector3(); + +/** @param {any} sceneObjects @param {string} uuid @param {number} t */ +function ownSpeed(sceneObjects, uuid, t) { + const obj = sceneObjects?.getObjectByProperty?.('uuid', uuid); + if (!obj) return 0; + obj.getWorldPosition(_bsPos); + let tr = blendTrackers.get(uuid); + if (!tr) blendTrackers.set(uuid, (tr = newTracker())); + trackPose(tr, [_bsPos.x, _bsPos.y, _bsPos.z], t); + return Math.hypot(tr.vel[0], tr.vel[2]); +} + +/** + * 57 M3: BLEND BY SPEED. Every frame, on EVERY peer and with no message of its own: the speed (the + * wired input, else the model's own speed — the Velocity node's reading, which every peer derives + * from the replicated transform) picks idle / walk / run weights, eased over `crossfade` seconds, + * and feeds them to the model's mixer as a local pose driver (animatedImports.setPoseDriver). + * @param {{node: any, uuid: string, wired: boolean}[]} pairs @param {any} ctx @param {number} time @param {any} sceneObjects + */ +function updateBlendSpeed(pairs, ctx, time, sceneObjects) { + const seen = new Set(); + for (const { node, uuid, wired } of pairs) { + if (!animImportsRef?.setPoseDriver) return; + const key = node.id + '|' + uuid; + seen.add(key); + const d = node.data ?? {}; + const names = (animImportsRef.clipInfo?.(uuid) ?? []).map((/** @type {any} */ c) => c.name); + if (!names.length) continue; + const clips = { + idle: clipForSlot(names, 'idle', d.idle), + walk: clipForSlot(names, 'walk', d.walk), + run: clipForSlot(names, 'run', d.run) + }; + const speed = wired ? Number(d.speed) || 0 : ownSpeed(sceneObjects, uuid, performance.now() / 1000); + const { weights, rates } = blendSpeedWeights(speed, { + walkSpeed: Number(d.walkSpeed) || 1.4, + runSpeed: Number(d.runSpeed) || 3.5, + idle: !!clips.idle, + walk: !!clips.walk, + run: !!clips.run + }); + const prev = blendState.get(key); + const eased = smoothWeights(prev?.w ?? weights, weights, prev ? time - prev.t : 0, Number(d.crossfade ?? 0.25)); + blendState.set(key, { w: eased, t: time }); + /** @type {Record} */ + const byClip = {}; + /** @type {Record} */ + const rateByClip = {}; + for (const slot of /** @type {const} */ (['idle', 'walk', 'run'])) { + const clip = clips[slot]; + if (!clip) continue; + byClip[clip] = (byClip[clip] ?? 0) + (eased[slot] ?? 0); + rateByClip[clip] = rates[slot]; + } + animImportsRef.setPoseDriver(uuid, byClip, rateByClip); + } + for (const key of [...blendState.keys()]) if (!seen.has(key)) blendState.delete(key); + const live = new Set(pairs.map((p) => p.uuid)); + for (const uuid of [...blendTrackers.keys()]) if (!live.has(uuid)) blendTrackers.delete(uuid); +} + +/** 57 M11: the Animation states runtime, primed the first time a graph names one of its nodes + * @type {any} */ +let animGraphRef = null; +let animGraphPriming = false; +function primeAnimGraph() { + if (animGraphPriming) return; + animGraphPriming = true; + import('./tpanim/animGraphRuntime.js').then((m) => { + animGraphRef = m; + m.onAnimStateEnter(fireAnimState); + }); +} + +/** 57 M11: Set Animation Parameter's rising-edge memory @type {Map} */ +const animParamEdge = new Map(); + +/** + * 57 M11: SET ANIMATION PARAMETER. With its trigger wired it sets on the rising edge, stamped + * with the trigger's shared time; unwired it holds the value every frame. LOCAL on every peer: + * the graph and the trigger stamp replicate, so each peer sets the same parameter itself. + * @param {{node: any, uuid: string, triggered: boolean}[]} pairs @param {any} ctx + */ +function updateAnimParams(pairs, ctx) { + if (!animGraphRef) return primeAnimGraph(); + const seen = new Set(); + for (const { node, uuid, triggered } of pairs) { + const d = node.data ?? {}; + const name = String(d.name ?? '').trim(); + if (!name) continue; + const key = node.id + '|' + uuid; + seen.add(key); + const value = typeof d.value === 'boolean' ? d.value : Number(d.value) || 0; + if (triggered) { + const high = !!d.trigger; + const was = animParamEdge.get(key) ?? false; + animParamEdge.set(key, high); + if (!high || was) continue; + const at = triggerStampFor(node.id, ctx) ?? logicNow(); + if (d.mode === 'trigger') animGraphRef.fireAnimTrigger(uuid, name, { replicate: false, at }); + else animGraphRef.setAnimParam(uuid, name, value, { replicate: false, at }); + } else if (d.mode !== 'trigger') animGraphRef.setAnimParam(uuid, name, value, { replicate: false, at: logicNow() }); + } + for (const key of [...animParamEdge.keys()]) if (!seen.has(key)) animParamEdge.delete(key); +} + +/** 57 M12: Attach to Socket's rising-edge memory @type {Map} */ +const attachEdge = new Map(); + +/** + * 57 M12: ATTACH TO SOCKET — on the trigger's rising edge, the `item` object follows the model's + * named node (or lets go). LOCAL on every peer, like Play Animation: the graph and the trigger + * stamp replicate, so each peer makes the same attach itself (no message, no undo entry). + * @param {{node: any, uuid: string}[]} pairs + */ +function updateAttachSockets(pairs) { + const seen = new Set(); + for (const { node, uuid } of pairs) { + const key = node.id + '|' + uuid; + seen.add(key); + const d = node.data ?? {}; + const high = !!d.trigger; + const was = attachEdge.get(key) ?? false; + attachEdge.set(key, high); + if (!high || was || !animImportsRef?.attachToSocket) continue; + const item = typeof d.item === 'string' && d.item !== '-None-' ? d.item : null; + if (!item) continue; + if ((d.action ?? 'attach') === 'detach') animImportsRef.attachToSocket(item, null, null, { replicate: false, record: false }); + else if (d.socket) animImportsRef.attachToSocket(item, uuid, String(d.socket), { snap: d.snap !== false, replicate: false, record: false }); + } + for (const key of [...attachEdge.keys()]) if (!seen.has(key)) attachEdge.delete(key); +} + +/** + * 57 M11: a model's Animation states machine just ENTERED a state — pulse every On Animation State + * node aimed at it whose state matches (by name or id; empty = any). LOCAL on every peer, the + * animmarker reasoning: each peer runs the same machine from the same inputs. + * @param {string} uuid @param {string} id @param {string} name + */ +export function fireAnimState(uuid, id, name) { + nodes.forEach((node) => { + if (node.type !== 'onanimstate') return; + const wanted = String(node.data?.state ?? '').trim().toLowerCase(); + if (wanted && wanted !== String(name).toLowerCase() && wanted !== String(id).toLowerCase()) return; + if (!reachesObjectSelector(node.id, uuid) && implicitOwnerOf(node) !== uuid) return; + applyNodeTrigger(node.id, syncedNow(), false); + }); +} + /** * B6: the physics ACTION nodes (Impulse, Set Velocity, Joint). Modelled on * updatePlayAnim: the same pair collection, the same resolveInputs, and the same @@ -2265,6 +2426,7 @@ export const valueTypes = [ 'measure', // B6: an object's top / bottom / height / y / speed 'animstate', // 17-E F3: the readable half of animfinished 'animmarker', // 17-E F5: the playhead crossed a named point in a clip + 'onanimstate', // 57 M11: an Animation states machine entered a state 'hudtimer', // A3: the remaining seconds, derived from the shared trigger stamp 'hudbutton', // A3: an event source, pulsed by fireHudButton 'hudinput', // 21-D4: the HUD as a SOURCE - what the player set on a slider/toggle/etc @@ -2836,6 +2998,7 @@ function evalNodeBody(node, allNodes, allEdges, time, seen, ctx) { } case 'animfinished': // 17-E: fired locally when a clip reaches its end case 'animmarker': // 17-E F5: fired locally when the playhead crosses one + case 'onanimstate': // 57 M11: fired locally when a state machine enters a state case 'ongrab': // 30b (core-games): the On Click window, fired on a player's grab case 'hudtimer': // 36 audit: listed as a value source since A3 but never evaluated, so a wire from @@ -4627,6 +4790,57 @@ function runTick(now) { }); updatePlayAnim(animPairs, ctx); + // 57 M3: Blend by Speed — the same pair collection; `wired` = something feeds its speed socket + /** @type {{node: any, uuid: string, wired: boolean}[]} */ + const blendPairs = []; + const speedWired = (/** @type {string} */ id) => edges.some((e) => e.target === id && e.targetHandle === 'speed'); + edges.forEach((edge) => { + const source = nodeById(nodes, edge.source); + if (source?.type !== 'blendspeed') return; + const uuid = targetUuidOf(edge); + if (uuid) blendPairs.push({ node: { ...source, data: resolveInputs(source, nodes, edges, time, ctx) }, uuid, wired: speedWired(source.id) }); + }); + nodes.forEach((node) => { + if (node.type !== 'blendspeed') return; + const uuid = implicitOwnerOf(node); + if (uuid) blendPairs.push({ node: { ...node, data: resolveInputs(node, nodes, edges, time, ctx) }, uuid, wired: speedWired(node.id) }); + }); + if (blendPairs.length || blendTrackers.size) updateBlendSpeed(blendPairs, ctx, logicNow(), sceneObjects); + + // 57 M11: Set Animation Parameter (the same pair collection) + priming for On Animation State + /** @type {{node: any, uuid: string, triggered: boolean}[]} */ + const paramPairs = []; + const triggerWired = (/** @type {string} */ id) => edges.some((e) => e.target === id && e.targetHandle === 'trigger'); + edges.forEach((edge) => { + const source = nodeById(nodes, edge.source); + if (source?.type !== 'setanimparam') return; + const uuid = targetUuidOf(edge); + if (uuid) paramPairs.push({ node: { ...source, data: resolveInputs(source, nodes, edges, time, ctx) }, uuid, triggered: triggerWired(source.id) }); + }); + nodes.forEach((node) => { + if (node.type === 'onanimstate' && !animGraphRef) primeAnimGraph(); + if (node.type !== 'setanimparam') return; + const uuid = implicitOwnerOf(node); + if (uuid) paramPairs.push({ node: { ...node, data: resolveInputs(node, nodes, edges, time, ctx) }, uuid, triggered: triggerWired(node.id) }); + }); + if (paramPairs.length || animParamEdge.size) updateAnimParams(paramPairs, ctx); + + // 57 M12: Attach to Socket (the same pair collection: the owner / Object Selector is the MODEL) + /** @type {{node: any, uuid: string}[]} */ + const attachPairs = []; + edges.forEach((edge) => { + const source = nodeById(nodes, edge.source); + if (source?.type !== 'attachsocket') return; + const uuid = targetUuidOf(edge); + if (uuid) attachPairs.push({ node: { ...source, data: resolveInputs(source, nodes, edges, time, ctx) }, uuid }); + }); + nodes.forEach((node) => { + if (node.type !== 'attachsocket') return; + const uuid = implicitOwnerOf(node); + if (uuid) attachPairs.push({ node: { ...node, data: resolveInputs(node, nodes, edges, time, ctx) }, uuid }); + }); + if (attachPairs.length || attachEdge.size) updateAttachSockets(attachPairs); + // B6: physics ACTIONS, collected exactly like the animation pairs. Called // here — after the flow poses, before the physics post-tick hook — so an // impulse fired this frame integrates this frame. diff --git a/src/lib/flowSockets.js b/src/lib/flowSockets.js index 6b83063bb..13d1be87e 100644 --- a/src/lib/flowSockets.js +++ b/src/lib/flowSockets.js @@ -59,6 +59,7 @@ const OUTPUT = { onenter: 'event', onexit: 'event', // CL-C: sensor overlap edges animfinished: 'event', // 17-E: a clip reached its end animmarker: 'event', // 17-E F5: the playhead crossed a named point in a clip + onanimstate: 'event', // 57 M11: an Animation states machine entered a state onseqevent: 'event', // 48 S2: a running sequence crossed an event item (or ended) animstate: 'number', // 17-E F3: progress / playing / position, one at a time velocity: 'number', // CL-C: live speed readout @@ -182,6 +183,9 @@ const INPUT = { once: { trigger: 'event', rearm: 'event' }, // 17-E A5: the trigger that starts/stops an authored clip, plus a wired speed playanim: { trigger: 'event', speed: 'number' }, + blendspeed: { speed: 'number' }, // 57 M3 + setanimparam: { trigger: 'event', value: 'number' }, // 57 M11 + attachsocket: { trigger: 'event', item: 'object' }, // 57 M12 playseq: { trigger: 'event' }, // 48 S2 // PFX-B: drive emission from flow — density, tint, spawn offset, motion, and // a burst fired from any event (On Click / Key Press / On Impact) diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index 19f94bf2d..9772e0092 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -1114,12 +1114,63 @@ export const nodeCatalog = [ // that starts a keyed clip, not a per-frame offset. type: 'playanim', label: 'Play Animation', - defaults: { clip: '', action: 'toggle', speed: 1 }, + defaults: { clip: '', action: 'toggle', speed: 1, crossfade: 0 }, params: [ { key: 'action', kind: 'select', options: ['toggle', 'play', 'stop', 'restart'] }, - { key: 'speed', kind: 'range', min: 0.1, max: 4, step: 0.1 } + { key: 'speed', kind: 'range', min: 0.1, max: 4, step: 0.1 }, + // 57 M3: seconds a clip change blends over (imported / clip-asset clips) instead of snapping + { key: 'crossfade', kind: 'range', min: 0, max: 2, step: 0.05 } ] }, + { + // 57 M3: idle / walk / run by a SPEED, blended and eased — a character stops snapping + // between poses. Unwired, the speed is the object's own; empty clips pick by name. + type: 'blendspeed', + label: 'Blend by Speed', + defaults: { idle: '', walk: '', run: '', walkSpeed: 1.4, runSpeed: 3.5, crossfade: 0.25, speed: 0 }, + inputs: ['speed'], + params: [ + { key: 'walkSpeed', kind: 'range', min: 0.2, max: 4, step: 0.1 }, + { key: 'runSpeed', kind: 'range', min: 0.5, max: 10, step: 0.1 }, + { key: 'crossfade', kind: 'range', min: 0, max: 1, step: 0.05 } + ] + }, + { + // 57 M11: drives a model's Animation states graph from logic — set a parameter (on a + // pulse, or continuously when nothing is wired to trigger) or fire a trigger (Jump) + type: 'setanimparam', + label: 'Set Animation Parameter', + defaults: { name: 'jump', mode: 'trigger', value: 0 }, + inputs: ['trigger', 'value'], + inputLabels: { trigger: 'trigger — sets on the pulse', value: 'value — wire a number' }, + params: [ + { key: 'name', kind: 'text', placeholder: 'speed / jump', maxLength: 40 }, + { key: 'mode', kind: 'select', options: ['trigger', 'set'] }, + { key: 'value', kind: 'range', min: -10, max: 10, step: 0.1 } + ] + }, + { + // 57 M12: a model's named node (a hand, a hook, a rotor arm) becomes an attach point — + // on a pulse the wired `item` follows it (or lets go). The model is the node's owner. + type: 'attachsocket', + label: 'Attach to Socket', + defaults: { socket: '', action: 'attach', snap: true }, + inputs: ['trigger', 'item'], + inputLabels: { trigger: 'trigger — attach on the pulse', item: 'item — the object to attach' }, + params: [ + { key: 'socket', kind: 'text', placeholder: 'hand_r', maxLength: 80 }, + { key: 'action', kind: 'select', options: ['attach', 'detach'] }, + { key: 'snap', kind: 'toggle' } + ] + }, + { + // 57 M11: pulses when the model's Animation states machine ENTERS a state (empty = any). + // Fired locally on every peer: each runs the same machine from the same inputs. + type: 'onanimstate', + label: 'On Animation State', + defaults: { state: '', pulse: 0.3 }, + params: [{ key: 'state', kind: 'text', placeholder: 'any state', maxLength: 40 }] + }, { // 48 S2: plays a Timeline SEQUENCE (camera shots, clips, audio, events) on the // trigger's rising stamp — on EVERY peer from that shared stamp, so the cutscene diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js index e2686bb78..fc30178f4 100644 --- a/src/lib/nodeDocs.js +++ b/src/lib/nodeDocs.js @@ -111,7 +111,11 @@ export const NODE_DOCS = { animfinished: "Fires a pulse when the connected object's animation clip reaches its end.", animmarker: "Fires a pulse when the connected object's animation passes a named marker.", animstate: "Reads the connected object's animation transport as a number - playing, position or progress.", - playanim: "Starts, stops or restarts an animation clip on the connected object when a pulse arrives.", + playanim: "Starts, stops or restarts an animation clip on the connected object when a pulse arrives. Crossfade blends from the clip already playing instead of snapping.", + blendspeed: "Blends a character's idle, walk and run clips by a speed (its own speed when nothing is wired), so it never snaps between poses.", + setanimparam: "Sets a parameter of the object's Animation states (on a pulse, or all the time when nothing is wired to trigger), or fires a trigger such as jump.", + onanimstate: "Fires a pulse when the object's Animation states enter a state (leave the name empty for any state).", + attachsocket: "On a pulse, makes the wired item follow one of this model's named nodes (a hand, a hook), or lets it go.", playseq: "Plays, stops, toggles or restarts a Timeline sequence (camera shots, clips, audio, events) for every player when a pulse arrives.", onseqevent: "Fires a pulse when a running Timeline sequence passes a named event, or reaches its end ('end').", mass: "Gives the connected object weight for the physics simulation - objects with a Mass fall and collide.", diff --git a/src/lib/tpanim/animGraph.js b/src/lib/tpanim/animGraph.js new file mode 100644 index 000000000..9ef4386b9 --- /dev/null +++ b/src/lib/tpanim/animGraph.js @@ -0,0 +1,245 @@ +// 57 M11 — the ANIMATION STATES graph (a locomotion state machine), as data + a pure evaluator. +// A LEAF (plain numbers; blendSpeed is a leaf too) so the whole machine is unit-tested. +// +// The graph is part of the model's config (`userData.animGraph`: replicates, saves, undoes, exports) +// and EVERY PEER EVALUATES IT ITSELF from the same inputs — parameters derived from the replicated +// transform (speed, vertical speed, grounded) and synced ones (Set Animation Parameter, a trigger's +// stamp) — the way clip playback already works, so no message per frame and no per-state message. +// +// Shape (version 1): +// {version: 1, entry: stateId, driver: 'motion' | 'none', +// params: [{name, type: 'float'|'bool'|'trigger', default?}], +// states: [{id, name, clip?, blend?: {param, idle, walk, run, walkSpeed, runSpeed}, speed?, loop?}], +// transitions: [{from: stateId | '*', to: stateId, fade, when: [{param, op, value?}], exitTime?}]} +// ops: '>' '<' '>=' '<=' '==' '!=' (numbers), 'true' / 'false' (bools), 'trigger' (fired since the +// state was entered and not yet used). `driver: 'motion'` fills speed / vy / grounded from how the +// object moves (the character controller, an NPC's logic, a peer's replicated moves alike). + +import { blendSpeedWeights, clipForSlot } from './blendSpeed.js'; + +export const ANIM_GRAPH_VERSION = 1; +export const OPS = ['>', '<', '>=', '<=', '==', '!=', 'true', 'false', 'trigger']; +/** the params a motion driver fills */ +export const MOTION_PARAMS = ['speed', 'vy', 'grounded']; + +const MAX = { states: 32, transitions: 64, params: 32, when: 8 }; +const str = (/** @type {any} */ v, n = 60) => String(v ?? '').slice(0, n); +const num = (/** @type {any} */ v, d = 0) => (Number.isFinite(Number(v)) ? Number(v) : d); + +/** + * A graph as stored and sent: unknown fields dropped, lists capped, ids unique, references valid. + * null = not a graph. + * @param {any} raw @returns {any} + */ +export function normalizeAnimGraph(raw) { + if (!raw || typeof raw !== 'object' || !Array.isArray(raw.states)) return null; + const ids = new Set(); + const states = []; + for (const s of raw.states.slice(0, MAX.states)) { + const id = str(s?.id, 40); + if (!id || ids.has(id)) continue; + ids.add(id); + /** @type {any} */ + const st = { id, name: str(s.name) || id }; + if (s.blend && typeof s.blend === 'object') + st.blend = { + param: str(s.blend.param) || 'speed', + idle: str(s.blend.idle, 80), + walk: str(s.blend.walk, 80), + run: str(s.blend.run, 80), + walkSpeed: Math.max(0.05, num(s.blend.walkSpeed, 1.4)), + runSpeed: Math.max(0.1, num(s.blend.runSpeed, 3.5)) + }; + else st.clip = str(s.clip, 80); + if (num(s.speed, 1) !== 1) st.speed = Math.min(8, Math.max(0.05, num(s.speed, 1))); + if (s.loop === false) st.loop = false; + if (Number.isFinite(s.x)) st.x = num(s.x); + if (Number.isFinite(s.y)) st.y = num(s.y); + states.push(st); + } + if (!states.length) return null; + const params = []; + const pnames = new Set(); + for (const p of Array.isArray(raw.params) ? raw.params.slice(0, MAX.params) : []) { + const name = str(p?.name, 40); + if (!name || pnames.has(name)) continue; + pnames.add(name); + const type = p.type === 'bool' || p.type === 'trigger' ? p.type : 'float'; + /** @type {any} */ + const out = { name, type }; + if (type === 'float' && Number.isFinite(p.default)) out.default = num(p.default); + if (type === 'bool' && typeof p.default === 'boolean') out.default = p.default; + params.push(out); + } + const transitions = []; + for (const t of Array.isArray(raw.transitions) ? raw.transitions.slice(0, MAX.transitions) : []) { + const from = t?.from === '*' ? '*' : str(t?.from, 40); + const to = str(t?.to, 40); + if (!ids.has(to) || (from !== '*' && !ids.has(from))) continue; + const when = (Array.isArray(t.when) ? t.when : []) + .slice(0, MAX.when) + .filter((/** @type {any} */ c) => c && typeof c.param === 'string' && OPS.includes(c.op)) + .map((/** @type {any} */ c) => ({ param: str(c.param, 40), op: c.op, ...(c.value !== undefined && c.op !== 'true' && c.op !== 'false' && c.op !== 'trigger' ? { value: num(c.value) } : {}) })); + /** @type {any} */ + const tr = { from, to, fade: Math.min(3, Math.max(0, num(t.fade, 0.2))), when }; + if (Number.isFinite(t.exitTime)) tr.exitTime = Math.min(1, Math.max(0, num(t.exitTime))); + transitions.push(tr); + } + const entry = ids.has(raw.entry) ? raw.entry : states[0].id; + return { version: ANIM_GRAPH_VERSION, entry, driver: raw.driver === 'motion' ? 'motion' : 'none', params, states, transitions }; +} + +/** a fresh run of a graph (where it is, what it has used) @param {any} graph @param {number} t */ +export function newRun(graph, t) { + return { state: graph.entry, entered: t, prev: null, fadeStart: t, fadeDur: 0, used: /** @type {Record} */ ({}), seen: /** @type {Record} */ ({}) }; +} + +/** @param {any} c @param {Record} params @param {any} run */ +function holds(c, params, run) { + const v = params[c.param]; + switch (c.op) { + case 'true': + return !!v; + case 'false': + return !v; + case 'trigger': { + // a trigger param holds the STAMP it last fired at; it counts once, and only if it fired + // after this state was entered (an old jump press must not replay on entry) + const at = Number(v); + return Number.isFinite(at) && at > (run.used[c.param] ?? -Infinity) && at >= run.entered - 0.05; + } + case '>': + return Number(v) > c.value; + case '<': + return Number(v) < c.value; + case '>=': + return Number(v) >= c.value; + case '<=': + return Number(v) <= c.value; + case '==': + return Number(v) === c.value; + case '!=': + return Number(v) !== c.value; + } + return false; +} + +/** + * One state's clip weights + rates (+ a pinned time for a play-once clip). + * @param {any} st @param {Record} params @param {string[]} clips @param {Record} durations @param {number} sinceEntered + */ +export function stateWeights(st, params, clips, durations, sinceEntered) { + /** @type {Record} */ + const weights = {}; + /** @type {Record} */ + const rates = {}; + /** @type {Record} */ + const times = {}; + if (!st) return { weights, rates, times }; + const k = st.speed ?? 1; + if (st.blend) { + const b = st.blend; + const slot = { idle: clipForSlot(clips, 'idle', b.idle), walk: clipForSlot(clips, 'walk', b.walk), run: clipForSlot(clips, 'run', b.run) }; + const r = blendSpeedWeights(Number(params[b.param]) || 0, { walkSpeed: b.walkSpeed, runSpeed: b.runSpeed, idle: !!slot.idle, walk: !!slot.walk, run: !!slot.run }); + for (const s of /** @type {const} */ (['idle', 'walk', 'run'])) { + const clip = slot[s]; + if (!clip || !(r.weights[s] > 0)) continue; + weights[clip] = (weights[clip] ?? 0) + r.weights[s]; + rates[clip] = r.rates[s] * k; + } + } else if (st.clip && clips.includes(st.clip)) { + weights[st.clip] = 1; + rates[st.clip] = k; + if (st.loop === false) times[st.clip] = Math.min(Math.max(sinceEntered * k, 0), Math.max((durations[st.clip] ?? 1) - 1e-4, 0)); + } + return { weights, rates, times }; +} + +/** + * Advance a run by one frame: take the first transition whose conditions all hold (from this + * state, or from '*'), then blend the previous state out over the transition's fade. + * @param {any} graph @param {any} run (mutated) @param {Record} params + * @param {number} t now (s) @param {string[]} clips the model's clip names @param {Record} durations + * @returns {{weights: Record, rates: Record, times: Record, state: string, entered: boolean}} + */ +export function stepGraph(graph, run, params, t, clips, durations) { + let entered = false; + const cur = graph.states.find((/** @type {any} */ s) => s.id === run.state) ?? graph.states[0]; + if (cur.id !== run.state) run.state = cur.id; + for (const tr of graph.transitions) { + if (tr.to === run.state || (tr.from !== '*' && tr.from !== run.state)) continue; + if (tr.exitTime !== undefined && cur.clip) { + const d = durations[cur.clip] ?? 0; + if (d > 0 && (t - run.entered) * (cur.speed ?? 1) < tr.exitTime * d) continue; + } + if (!tr.when.every((/** @type {any} */ c) => holds(c, params, run))) continue; + for (const c of tr.when) if (c.op === 'trigger') run.used[c.param] = Number(params[c.param]); + run.prev = { state: run.state, entered: run.entered }; + run.state = tr.to; + run.entered = t; + run.fadeStart = t; + run.fadeDur = tr.fade; + entered = true; + break; + } + const now = graph.states.find((/** @type {any} */ s) => s.id === run.state); + const a = stateWeights(now, params, clips, durations, t - run.entered); + const k = run.fadeDur > 0 ? Math.min(1, Math.max(0, (t - run.fadeStart) / run.fadeDur)) : 1; + if (k >= 1 || !run.prev) { + run.prev = null; + return { ...a, state: run.state, entered }; + } + const e = k * k * (3 - 2 * k); + const prevState = graph.states.find((/** @type {any} */ s) => s.id === run.prev.state); + const b = stateWeights(prevState, params, clips, durations, t - run.prev.entered); + /** @type {Record} */ + const weights = {}; + for (const [c, w] of Object.entries(b.weights)) weights[c] = (weights[c] ?? 0) + w * (1 - e); + for (const [c, w] of Object.entries(a.weights)) weights[c] = (weights[c] ?? 0) + w * e; + return { weights, rates: { ...b.rates, ...a.rates }, times: { ...b.times, ...a.times }, state: run.state, entered }; +} + +/** the motion params from a body's velocity (m/s): horizontal speed, vertical speed, grounded + * @param {number[]} vel @param {{groundedFor: number}} memo (mutated: how long it has been level) @param {number} dt */ +export function motionParams(vel, memo, dt) { + const vy = vel[1]; + const level = Math.abs(vy) < 0.35; + memo.groundedFor = level ? memo.groundedFor + dt : 0; + // level for longer than a jump's apex lasts (|vy| < 0.35 for ~70 ms at g = 9.8) = on the ground + return { speed: Math.hypot(vel[0], vel[2]), vy, grounded: memo.groundedFor > 0.1 }; +} + +/** + * THE LOCOMOTION PRESET: idle / walk / run as one speed blend, jump and fall, wired to the motion + * driver. Clips come from the model's own (or assigned motion-kit) clips by name; a state whose clip + * is missing keeps its slot empty (it simply shows the previous pose's blend). + * @param {string[]} clips the model's clip names + * @returns {any} a normalized graph + */ +export function locomotionPreset(clips) { + const jump = clips.find((c) => /jump(?!.*idle)|jump_start|takeoff/i.test(c)) ?? clips.find((c) => /jump/i.test(c)) ?? ''; + const fall = clips.find((c) => /fall|jump_idle|airborne|in_air/i.test(c)) ?? jump; + return normalizeAnimGraph({ + entry: 'move', + driver: 'motion', + params: [ + { name: 'speed', type: 'float', default: 0 }, + { name: 'vy', type: 'float', default: 0 }, + { name: 'grounded', type: 'bool', default: true }, + { name: 'jump', type: 'trigger' } + ], + states: [ + { id: 'move', name: 'Move', blend: { param: 'speed', idle: clipForSlot(clips, 'idle'), walk: clipForSlot(clips, 'walk'), run: clipForSlot(clips, 'run'), walkSpeed: 1.4, runSpeed: 3.5 }, x: 0, y: 0 }, + { id: 'jump', name: 'Jump', clip: jump, loop: false, x: 1, y: -1 }, + { id: 'fall', name: 'Fall', clip: fall, x: 2, y: 0 } + ], + transitions: [ + { from: 'move', to: 'jump', fade: 0.12, when: [{ param: 'vy', op: '>', value: 1 }] }, + { from: 'move', to: 'jump', fade: 0.12, when: [{ param: 'jump', op: 'trigger' }] }, + { from: 'move', to: 'fall', fade: 0.25, when: [{ param: 'grounded', op: 'false' }, { param: 'vy', op: '<', value: -1.5 }] }, + { from: 'jump', to: 'fall', fade: 0.25, when: [{ param: 'vy', op: '<', value: -0.2 }] }, + { from: 'jump', to: 'move', fade: 0.2, when: [{ param: 'grounded', op: 'true' }], exitTime: 0.6 }, + { from: 'fall', to: 'move', fade: 0.2, when: [{ param: 'grounded', op: 'true' }] } + ] + }); +} diff --git a/src/lib/tpanim/animGraphRuntime.js b/src/lib/tpanim/animGraphRuntime.js new file mode 100644 index 000000000..cc0a9905a --- /dev/null +++ b/src/lib/tpanim/animGraphRuntime.js @@ -0,0 +1,144 @@ +// 57 M11: the Animation states graphs, running. Every frame (animatedImports.tickAnimatedMixers +// calls in through setAnimGraphTick) each model with a graph gets its parameters — the graph's +// defaults, the SYNCED ones (Set Animation Parameter, the window, the API) and, for a `motion` +// driver, speed / vy / grounded read from how the object moves — steps its machine (animGraph.js) +// and feeds the result to its pose driver. Every peer does this itself from the same inputs: +// nothing per frame or per state travels. A parameter set outside a graph (the window, the API) is +// ONE `objectParameters 'animParam'` message, latest-wins by its stamp. +// +// Lazy: animatedImports loads it only when a model carries a graph. + +import { get, writable } from 'svelte/store'; +import * as THREE from 'three'; +import { objectsGroup } from '../../stores/sceneStore'; +import { peers } from '../../stores/appStore'; +import { setAnimGraphTick, setPoseDriver, clearPoseDriver, clipInfo, hasClipModel } from '../animatedImports'; +import { runtimeNow } from '../moduleSDK'; +import { normalizeAnimGraph, newRun, stepGraph, motionParams } from './animGraph.js'; +import { newTracker, trackPose } from '../avatars/locomotion.js'; + +/** @type {Map, root: any}>} */ +const graphs = new Map(); +/** uuid -> name -> {v, at} @type {Map>} */ +const synced = new Map(); +/** what the window shows: uuid -> {state, params}, refreshed ~6 times a second @type {import('svelte/store').Writable}>>} */ +export const animStates = writable({}); +/** On Animation State: (uuid, stateId, stateName) => void @type {Set<(uuid: string, id: string, name: string) => void>} */ +const enterListeners = new Set(); +const _pos = new THREE.Vector3(); +let lastPublish = 0; + +/** be told when a model's machine enters a state @param {(uuid: string, id: string, name: string) => void} fn */ +export function onAnimStateEnter(fn) { + enterListeners.add(fn); + return () => enterListeners.delete(fn); +} + +/** (re)read one model's graph from its userData @param {string} uuid */ +export function syncAnimGraph(uuid) { + const root = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + const graph = normalizeAnimGraph(root?.userData?.animGraph); + const held = graphs.get(uuid); + if (!graph || !root) { + if (held) { + graphs.delete(uuid); + clearPoseDriver(uuid); + animStates.update((m) => { + const next = { ...m }; + delete next[uuid]; + return next; + }); + } + return; + } + const t = runtimeNow(); + // an edit keeps the run where it is when its state still exists + const run = held && graph.states.some((/** @type {any} */ s) => s.id === held.run.state) ? held.run : newRun(graph, t); + graphs.set(uuid, { graph, run, tracker: held?.tracker ?? newTracker(), memo: held?.memo ?? { groundedFor: 1 }, last: held?.last ?? t, params: held?.params ?? {}, root }); + setAnimGraphTick(tick); +} + +/** the live reading of a model's machine (state id + parameters), or null @param {string} uuid */ +export function animGraphReading(uuid) { + const e = graphs.get(uuid); + return e ? { state: e.run.state, params: { ...e.params } } : null; +} + +/** + * Set a parameter. `replicate` false = this peer only (a graph node: every peer runs it from the + * same trigger); otherwise ONE message. Latest-wins by `at` (a synced stamp). + * @param {string} uuid @param {string} name @param {number | boolean} value @param {{replicate?: boolean, at?: number}} [opts] + */ +export function setAnimParam(uuid, name, value, opts = {}) { + const at = Number.isFinite(opts.at) ? /** @type {number} */ (opts.at) : runtimeNow(); + let map = synced.get(uuid); + if (!map) synced.set(uuid, (map = new Map())); + const held = map.get(name); + if (held && held.at > at) return false; + map.set(name, { v: value, at }); + if (opts.replicate !== false) { + /** @type {any} */ + const peer = get(peers); + if (peer) peer.send({ type: 'objectParameters', parameter: 'animParam', uuid, name, value, at }); + } + return true; +} + +/** fire a trigger parameter (its value IS the stamp) @param {string} uuid @param {string} name @param {{replicate?: boolean, at?: number}} [opts] */ +export function fireAnimTrigger(uuid, name, opts = {}) { + const at = Number.isFinite(opts.at) ? /** @type {number} */ (opts.at) : runtimeNow(); + return setAnimParam(uuid, name, at, { ...opts, at }); +} + +/** @param {number} time */ +function tick(time) { + if (!graphs.size) return; + const publish = time - lastPublish > 0.16 || time < lastPublish; + /** @type {Record}>} */ + const reading = {}; + for (const [uuid, e] of graphs) { + const root = e.root?.parent ? e.root : get(objectsGroup)?.getObjectByProperty('uuid', uuid); + if (!root || !hasClipModel(uuid)) { + graphs.delete(uuid); + continue; + } + e.root = root; + const dt = Math.min(Math.max(time - e.last, 0), 0.1); + e.last = time; + /** @type {Record} */ + const params = {}; + for (const p of e.graph.params) params[p.name] = p.default ?? (p.type === 'bool' ? false : p.type === 'trigger' ? -Infinity : 0); + if (e.graph.driver === 'motion') { + root.getWorldPosition(_pos); + trackPose(e.tracker, [_pos.x, _pos.y, _pos.z], time); + Object.assign(params, motionParams(e.tracker.vel, e.memo, dt)); + } + for (const [name, { v }] of synced.get(uuid) ?? []) params[name] = v; + const info = clipInfo(uuid); + /** @type {Record} */ + const durations = {}; + for (const c of info) durations[c.name] = c.duration; + const out = stepGraph(e.graph, e.run, params, time, Object.keys(durations), durations); + setPoseDriver(uuid, out.weights, out.rates, { times: out.times }); + e.params = params; + if (out.entered) { + const st = e.graph.states.find((/** @type {any} */ s) => s.id === out.state); + for (const fn of enterListeners) { + try { + fn(uuid, out.state, st?.name ?? out.state); + } catch (error) { + console.log('animation state listener failed', error); + } + } + } + if (publish) reading[uuid] = { state: out.state, params }; + } + if (publish) { + lastPublish = time; + animStates.set(reading); + } + if (!graphs.size) setAnimGraphTick(null); +} + +// a scene load that brings graphs: every model already registered gets read once +for (const child of get(objectsGroup)?.children ?? []) if (child?.userData?.animGraph) syncAnimGraph(child.uuid); diff --git a/src/lib/tpanim/animateParts.js b/src/lib/tpanim/animateParts.js new file mode 100644 index 000000000..0fbf6305f --- /dev/null +++ b/src/lib/tpanim/animateParts.js @@ -0,0 +1,42 @@ +// 57 M12 — ANIMATE IMPORTED PARTS: a clip keyed on a model's NAMED NODES (a rotor, a door, a +// lid — what 56 C9's import_model keeps from an img2threejs GLB), written as a node-name `.tpanim` +// (the skeleton-clip shape: the parts' rest nodes + one animation) and assigned to the model in ONE +// undo step. The core of the agent tool `animate_parts` and of anything else that keys parts by +// name — so Claude can make a rotor spin or a door open through the same replicated, undoable path +// a person's edit takes. + +import { get } from 'svelte/store'; +import { objectsGroup } from '../../stores/sceneStore'; +import { clipRefsOf, setClipRefs, setAnimationState, ensureClipModel, restPoseOf } from '../animatedImports'; +import { buildPartsClip } from './partsClip.js'; +import { saveClip } from './clipEdit.js'; + +/** @typedef {import('./partsClip.js').PartTrack} PartTrack */ + +/** + * animateParts — write ONE clip on a model's named parts, assign it (one undo step) and, unless + * `play` is false, play it on every peer. `object` = a uuid or a name. + * @param {{object: string, clip: string, tracks: PartTrack[], loop?: boolean, mode?: 'relative'|'absolute', play?: boolean}} spec + * @returns {Promise<{ok: true, uuid: string, clip: string, hash: string, parts: string[], missing: string[]} | {error: string, missing?: string[]}>} + */ +export async function animateParts(spec) { + const group = get(objectsGroup); + const want = String(spec?.object ?? ''); + const root = group?.getObjectByProperty('uuid', want) ?? group?.children.find((/** @type {any} */ c) => c.name === want) ?? null; + if (!root) return { error: 'no object "' + want + '" — pass its uuid (list_scene) or exact name' }; + if (!Array.isArray(spec.tracks) || !spec.tracks.length) return { error: 'tracks is empty — pass [{part, keys: [{t, rot: [x, y, z]}]}]' }; + if (!ensureClipModel(root)) return { error: '"' + root.name + '" is a functional item (it has its own behaviour) and cannot take a clip' }; + const { tp, parts, missing } = buildPartsClip(root, spec, restPoseOf(root.uuid)); + if (!tp) { + /** @type {string[]} */ + const names = []; + root.traverse((/** @type {any} */ o) => o !== root && o.name && names.push(o.name)); + return { error: 'none of the parts were found (or no keys had times) — the model has: ' + names.slice(0, 40).join(', '), missing }; + } + const saved = await saveClip(tp, (root.name ? root.name + ' - ' : '') + tp.name); + if (!saved) return { error: 'the clip could not be written to the Explorer' }; + const refs = clipRefsOf(root).filter((r) => r.name !== tp.name); + setClipRefs(root.uuid, [...refs, { hash: saved.hash, name: tp.name, ...(spec.loop === false ? { loop: false } : {}) }]); + if (spec.play !== false) setAnimationState(root.uuid, { clip: tp.name, playing: true }); + return { ok: true, uuid: root.uuid, clip: tp.name, hash: saved.hash, parts, missing }; +} diff --git a/src/lib/tpanim/blendSpeed.js b/src/lib/tpanim/blendSpeed.js new file mode 100644 index 000000000..a619c56f5 --- /dev/null +++ b/src/lib/tpanim/blendSpeed.js @@ -0,0 +1,79 @@ +// 57 M3: the Blend by Speed node's arithmetic — a LEAF of plain numbers (unit-tested). +// A 1D blend space: idle→walk between 0 and the walk speed, walk→run between the walk and run +// speeds, the walk/run clips played faster or slower so the feet keep pace (within limits, past +// which a stride looks wrong). A missing slot hands its range to its neighbour. + +const RATE_MIN = 0.6; +const RATE_MAX = 1.6; +/** below this the body stands (m/s) — the avatars' locomotion constant */ +export const IDLE_SPEED = 0.15; + +/** + * Target weights + clip rates for a speed. + * @param {number} speed m/s + * @param {{walkSpeed?: number, runSpeed?: number, idle?: boolean, walk?: boolean, run?: boolean}} o which slots have a clip + * @returns {{weights: {idle: number, walk: number, run: number}, rates: {idle: number, walk: number, run: number}}} + */ +export function blendSpeedWeights(speed, o = {}) { + const s = Math.max(0, Number(speed) || 0); + const W = Math.max(0.05, o.walkSpeed ?? 1.4); + const R = Math.max(W + 0.05, o.runSpeed ?? 3.5); + const has = { idle: o.idle !== false, walk: o.walk !== false, run: o.run !== false }; + let idle = 0; + let walk = 0; + let run = 0; + if (s <= IDLE_SPEED) idle = 1; + else if (s < W) { + const k = (s - IDLE_SPEED) / (W - IDLE_SPEED); + idle = 1 - k; + walk = k; + } else if (s < R) { + const k = (s - W) / (R - W); + walk = 1 - k; + run = k; + } else run = 1; + // a slot with no clip gives its weight to the nearest one that has one + if (!has.run) [walk, run] = [walk + run, 0]; + if (!has.walk) { + if (has.run && s >= W / 2) [run, walk] = [run + walk, 0]; + else [idle, walk] = [idle + walk, 0]; + } + if (!has.idle) [walk, idle] = has.walk ? [walk + idle, 0] : [walk, idle]; + if (!has.idle && !has.walk && has.run) [run, idle] = [run + idle, 0]; + const clamp = (/** @type {number} */ x) => Math.min(RATE_MAX, Math.max(RATE_MIN, x)); + return { + weights: { idle, walk, run }, + rates: { idle: 1, walk: has.walk ? clamp(s / W) : 1, run: has.run ? clamp(s / R) : has.walk ? clamp(s / W) : 1 } + }; +} + +/** + * Ease current weights toward the targets: 95 % of the way in `crossfade` seconds (0 = snap). + * @param {Record} cur @param {Record} target @param {number} dt @param {number} crossfade + * @returns {Record} + */ +export function smoothWeights(cur, target, dt, crossfade) { + if (!(crossfade > 0)) return { ...target }; + const k = 1 - Math.exp((-3 * Math.max(0, dt)) / crossfade); + /** @type {Record} */ + const out = {}; + for (const key of new Set([...Object.keys(cur), ...Object.keys(target)])) out[key] = (cur[key] ?? 0) + ((target[key] ?? 0) - (cur[key] ?? 0)) * k; + return out; +} + +const AUTO = { + idle: /idle|stand|breath/i, + walk: /walk(?!.*back)|stroll/i, + run: /run(?!.*strafe)|sprint|jog/i +}; + +/** + * The clip for a slot: the one named in the node, else the first of the model's clips whose name + * says it (Idle / Walking_A / Running_A …). '' = none. + * @param {string[]} names the model's clips @param {'idle'|'walk'|'run'} slot @param {string} [wanted] + */ +export function clipForSlot(names, slot, wanted) { + const w = String(wanted ?? '').trim(); + if (w) return names.includes(w) ? w : ''; + return names.find((n) => AUTO[slot].test(n)) ?? ''; +} diff --git a/src/lib/tpanim/clipAssets.js b/src/lib/tpanim/clipAssets.js index 6cf9d1c22..a7d63ab7a 100644 --- a/src/lib/tpanim/clipAssets.js +++ b/src/lib/tpanim/clipAssets.js @@ -23,8 +23,8 @@ import { restPoseOf, setClipRefs, setAnimationState, - hasAnimatedImport, - animatedImportUuids + hasClipModel, + ensureClipModel } from '../animatedImports'; import { decodeTpanim, tpToThreeClip, bindHumanoid, clipFit } from './format.js'; import { stripRootMotion } from './clipOps.js'; @@ -112,7 +112,7 @@ export function bindClip(tp, root, name) { */ export async function attachClipAssets(uuid) { const root = rootOf(uuid); - if (!root || !hasAnimatedImport(uuid)) return; + if (!root || !hasClipModel(uuid)) return; const refs = clipRefsOf(root); pruneClipActions(uuid, new Set(refs.map((r) => r.name))); setClipPlayback(uuid, refs); // 57 M8: speed / play-once per ref @@ -146,8 +146,9 @@ function uniqueClipName(root, wanted) { */ export function assignClipAsset(uuid, item, opts = {}) { const root = rootOf(uuid); - if (!root || !hasAnimatedImport(uuid)) { - showToast('Drop a clip on a rigged model (a character, an animated import)'); + // 57 M12: a model that is not a rig takes clips too — its named nodes are its parts + if (!root || !ensureClipModel(root)) { + showToast('This object cannot take an animation clip'); return null; } const refs = clipRefsOf(root); @@ -206,8 +207,9 @@ registerSceneAssetSource(() => { const group = get(objectsGroup); /** @type {{hash: string, name: string, kind: string}[]} */ const out = []; - for (const uuid of animatedImportUuids(group)) { - const root = group?.getObjectByProperty('uuid', uuid); + // rigs (bytes) and parts models alike: every top-level object carrying refs + for (const root of group?.children ?? []) { + if (!root?.userData?.clipRefs) continue; for (const ref of clipRefsOf(root)) out.push({ hash: ref.hash, name: itemByHash(ref.hash)?.name ?? ref.name + '.tpanim', kind: 'anim' }); } return out; diff --git a/src/lib/tpanim/clipEdit.js b/src/lib/tpanim/clipEdit.js new file mode 100644 index 000000000..4267b28f4 --- /dev/null +++ b/src/lib/tpanim/clipEdit.js @@ -0,0 +1,199 @@ +// 57 M3 + M8: what the Animation window's clip tools DO — every edit that changes a clip's motion +// writes a NEW `.tpanim` (a different clip is a different file: peers holding the old one still +// hold exactly that, and the original stays in the Explorer) and swaps the model's ref to it in +// ONE ref change (one message, one undo step). Settings that are not motion (name, loop, speed, +// In place / Moving) are ref fields and never write a file. +// +// Lazy: loaded by the clip tools panel only. + +import { get } from 'svelte/store'; +import { addItemFromBytes, itemByHash } from '../explorer'; +import { sendAsset } from '../assetShare'; +import { objectsGroup } from '../../stores/sceneStore'; +import { showToast } from '../../stores/appStore'; +import { clipRefsOf, setClipRefs, embeddedClipsOf, threeClipOf, restPoseOf, hasAnimatedImport } from '../animatedImports'; +import { encodeTpanim, tpFromThreeClip, toHumanoid, clipFacts } from './format.js'; +import { loopSeam, makeLoop, trimClip, splitClip, joinClips, joinProblem, rootTravel } from './clipOps.js'; +import { loadClip } from './clipAssets.js'; +import { humanoidMapOf } from './humanoidMap.js'; + +/** @param {string} uuid */ +function rootOf(uuid) { + return get(objectsGroup)?.getObjectByProperty('uuid', uuid) ?? null; +} + +/** @param {string} uuid @param {string} name */ +function refOf(uuid, name) { + return clipRefsOf(rootOf(uuid)).find((r) => r.name === name) ?? null; +} + +/** + * The facts a clip row shows: fps, frames, length, humanoid, travel, and the loop seam. + * @param {string} hash @returns {Promise & {seam: ReturnType, travel: number}>} + */ +export async function clipFactsFor(hash) { + const tp = await loadClip(hash); + if (!tp) return null; + return { ...clipFacts(tp), seam: loopSeam(tp), travel: rootTravel(tp).distance }; +} + +/** + * Write a clip as a new Explorer item beside `nearHash`'s (same folder) and push it to peers. + * @param {import('./format.js').TpClip} tp @param {string} name @param {string} [nearHash] + * @returns {Promise<{hash: string, name: string} | null>} + */ +export async function saveClip(tp, name, nearHash) { + const folder = nearHash ? itemByHash(nearHash)?.folderId ?? null : null; + const fileName = name.replace(/\.tpanim$/i, '') + '.tpanim'; + const item = await addItemFromBytes(encodeTpanim({ ...tp, name: name.replace(/\.tpanim$/i, '') }), fileName, folder, { kind: 'anim' }); + if (!item?.hash) return null; + void sendAsset(item.hash); + return { hash: item.hash, name: item.name }; +} + +/** swap one ref's file (keeping its name + settings) — one undo step @param {string} uuid @param {string} name @param {string} hash */ +function swapRef(uuid, name, hash) { + const refs = clipRefsOf(rootOf(uuid)); + return setClipRefs(uuid, refs.map((r) => (r.name === name ? { ...r, hash } : r))); +} + +/** @param {string} uuid @param {string} name */ +async function clipOfRef(uuid, name) { + const ref = refOf(uuid, name); + if (!ref) return null; + const tp = await loadClip(ref.hash); + if (!tp) showToast('"' + name + '" is still arriving — try again in a moment'); + return tp ? { ref, tp } : null; +} + +/** + * LOOP IT (M3): a new file whose last frames blend into its first; the model plays it. + * @param {string} uuid @param {string} name + */ +export async function loopClipAsset(uuid, name) { + const got = await clipOfRef(uuid, name); + if (!got) return false; + const looped = makeLoop(got.tp); + const saved = await saveClip(looped, got.tp.name + ' (loop)', got.ref.hash); + if (!saved) return false; + swapRef(uuid, name, saved.hash); + const seam = loopSeam(looped); + showToast('Looped "' + name + '" — seam ' + seam.angle.toFixed(1) + '°'); + return true; +} + +/** + * TRIM (M8): keep the part between the in and out markers (a new file; the model plays it). + * @param {string} uuid @param {string} name @param {number} from @param {number} to + */ +export async function trimClipAsset(uuid, name, from, to) { + const got = await clipOfRef(uuid, name); + if (!got) return false; + if (Math.abs(to - from) < 1 / got.tp.fps) return showToast('Set the in and out markers at least a frame apart'), false; + const saved = await saveClip(trimClip(got.tp, from, to), got.tp.name + ' (trim)', got.ref.hash); + if (!saved) return false; + return swapRef(uuid, name, saved.hash); +} + +/** + * SPLIT (M8): two clips at the playhead — the first replaces this one, the second is added after it. + * Names come from the caller (cut a long take into Walk, Turn, Stop). + * @param {string} uuid @param {string} name @param {number} at @param {[string, string]} [names] + */ +export async function splitClipAsset(uuid, name, at, names) { + const got = await clipOfRef(uuid, name); + if (!got) return false; + if (at <= 1 / got.tp.fps || at >= got.tp.duration - 1 / got.tp.fps) return showToast('Move the playhead inside the clip to split it'), false; + const [a, b] = splitClip(got.tp, at); + const na = names?.[0]?.trim() || name + ' A'; + const nb = names?.[1]?.trim() || name + ' B'; + const sa = await saveClip(a, na, got.ref.hash); + const sb = await saveClip(b, nb, got.ref.hash); + if (!sa || !sb) return false; + const refs = clipRefsOf(rootOf(uuid)); + const taken = new Set(refs.map((r) => r.name)); + taken.delete(name); + const uniq = (/** @type {string} */ n) => { + let out = n; + for (let i = 2; taken.has(out); i++) out = n + ' ' + i; + taken.add(out); + return out; + }; + const next = []; + for (const r of refs) { + if (r.name !== name) next.push(r); + else { + const { hash: _h, name: _n, ...settings } = r; + next.push({ ...settings, hash: sa.hash, name: uniq(na) }, { ...settings, hash: sb.hash, name: uniq(nb) }); + } + } + return setClipRefs(uuid, next); +} + +/** + * JOIN (M8): the picked clips one after another with a crossfade at each seam, as a new clip + * added to the model (the parts stay). + * @param {string} uuid @param {string[]} names in order @param {number} fade seconds @param {string} [joinedName] + */ +export async function joinClipAssets(uuid, names, fade, joinedName) { + const parts = []; + for (const n of names) { + const got = await clipOfRef(uuid, n); + if (!got) return false; + parts.push(got); + } + const why = joinProblem(parts.map((p) => p.tp)); + if (why) return showToast('Cannot join: ' + why), false; + const joined = joinClips(parts.map((p) => p.tp), fade, joinedName); + const saved = await saveClip(joined, joined.name, parts[0].ref.hash); + if (!saved) return false; + const refs = clipRefsOf(rootOf(uuid)); + let nm = joined.name; + for (let i = 2; refs.some((r) => r.name === nm); i++) nm = joined.name + ' ' + i; + return setClipRefs(uuid, [...refs, { hash: saved.hash, name: nm }]); +} + +/** + * EXTRACT CLIPS (M8): the model's embedded clips become `.tpanim` files (humanoid when the model's + * bones map, so they play on any humanoid) assigned to it; `useFiles` also switches the embedded + * ones off — in the same undo step, and the model's bytes are untouched. + * @param {string} uuid @param {{useFiles?: boolean}} [opts] @returns {Promise} clips written + */ +export async function extractClips(uuid, opts = {}) { + const root = rootOf(uuid); + if (!root || !hasAnimatedImport(uuid)) return 0; + const { names } = embeddedClipsOf(uuid); + if (!names.length) { + showToast('"' + (root.name || 'This model') + '" has no clips of its own to extract'); + return 0; + } + const map = humanoidMapOf(root); + const rest = restPoseOf(uuid); + const refs = [...clipRefsOf(root)]; + const taken = new Set(refs.map((r) => r.name)); + let written = 0; + for (const name of names) { + const clip = threeClipOf(uuid, name); + if (!clip) continue; + let tp = tpFromThreeClip(root, clip, { rest, provenance: { tool: 'extract', source: root.name || 'model', date: new Date().toISOString().slice(0, 10) } }); + if (map) { + try { + tp = toHumanoid(tp, map); + } catch (error) { + console.log('extract: kept a skeleton clip', error); + } + } + const saved = await saveClip(tp, (root.name ? root.name + ' - ' : '') + name); + if (!saved) continue; + written++; + if (refs.some((r) => r.hash === saved.hash)) continue; + // with the embedded clips off the file takes the clip's own name; beside them it needs another + let nm = opts.useFiles ? name : name + ' (file)'; + for (let i = 2; taken.has(nm); i++) nm = (opts.useFiles ? name : name + ' (file)') + ' ' + i; + taken.add(nm); + refs.push({ hash: saved.hash, name: nm }); + } + setClipRefs(uuid, refs, opts.useFiles ? { clipsOff: true } : {}); + showToast('Extracted ' + written + ' clip' + (written === 1 ? '' : 's') + ' to the Explorer'); + return written; +} diff --git a/src/lib/tpanim/partsClip.js b/src/lib/tpanim/partsClip.js new file mode 100644 index 000000000..467e64caf --- /dev/null +++ b/src/lib/tpanim/partsClip.js @@ -0,0 +1,160 @@ +// 57 M12: the PURE half of animate_parts — keys on named parts -> a node-name TpClip. three's math +// only (the model is read, never changed), so the unit suite runs it in node. + +import * as THREE from 'three'; +import { sanitizeName, signatureOf, TPANIM_FORMAT } from './format.js'; + +const MAX_TRACKS = 64; +const MAX_KEYS = 512; +/** a rotation segment longer than this is split, so a 360° spin is a spin and not a no-op slerp */ +const MAX_STEP_DEG = 90; + +/** + * @typedef {{t: number, pos?: number[], rot?: number[], scale?: number[]}} PartKey + * `rot` = Euler degrees [x, y, z] (XYZ) or a quaternion [x, y, z, w] + * @typedef {{part: string, keys: PartKey[]}} PartTrack + */ + +/** @param {any} root @param {string} name */ +export function findPart(root, name) { + const want = String(name ?? ''); + let hit = root.getObjectByName(want) ?? root.getObjectByName(sanitizeName(want)); + if (!hit) { + const low = want.toLowerCase(); + root.traverse((/** @type {any} */ o) => { + if (!hit && o !== root && (o.name?.toLowerCase() === low || sanitizeName(o.name).toLowerCase() === sanitizeName(low).toLowerCase())) hit = o; + }); + } + return hit && hit !== root ? hit : null; +} + +/** @param {number[]} r @returns {THREE.Quaternion} */ +function quatOf(r) { + if (r.length === 4) return new THREE.Quaternion(r[0], r[1], r[2], r[3]).normalize(); + const d = THREE.MathUtils.degToRad; + return new THREE.Quaternion().setFromEuler(new THREE.Euler(d(r[0] ?? 0), d(r[1] ?? 0), d(r[2] ?? 0), 'XYZ')); +} + +/** + * Euler keys -> quaternion keys, inserting in-between keys wherever one segment turns more than + * MAX_STEP_DEG (a quaternion slerp takes the SHORT way, so 0° → 360° would not move at all). + * @param {{t: number, rot: number[]}[]} keys @returns {{t: number, q: THREE.Quaternion}[]} + */ +export function rotationKeys(keys) { + /** @type {{t: number, q: THREE.Quaternion}[]} */ + const out = []; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (i === 0 || k.rot.length === 4 || keys[i - 1].rot.length === 4) { + out.push({ t: k.t, q: quatOf(k.rot) }); + continue; + } + const a = keys[i - 1].rot; + const b = k.rot; + const span = Math.max(...[0, 1, 2].map((c) => Math.abs((b[c] ?? 0) - (a[c] ?? 0)))); + const steps = Math.max(1, Math.ceil(span / MAX_STEP_DEG)); + for (let s = 1; s <= steps; s++) { + const f = s / steps; + out.push({ t: keys[i - 1].t + (k.t - keys[i - 1].t) * f, q: quatOf([0, 1, 2].map((c) => (a[c] ?? 0) + ((b[c] ?? 0) - (a[c] ?? 0)) * f)) }); + } + } + return out; +} + +/** + * Build the clip (pure: the model is only read). `relative` (default) = keys are offsets from each + * part's rest pose (pos added, rot turned after the rest, scale multiplied); `absolute` = local values. + * @param {any} root @param {{clip: string, tracks: PartTrack[], loop?: boolean, mode?: 'relative'|'absolute'}} spec + * @param {(o: any) => {p: number[], q: number[], s: number[]} | null} [rest] + * @returns {{tp: import('./format.js').TpClip | null, parts: string[], missing: string[]}} + */ +export function buildPartsClip(root, spec, rest) { + const relative = spec.mode !== 'absolute'; + const restOf = (/** @type {any} */ o) => rest?.(o) ?? { p: o.position.toArray(), q: o.quaternion.toArray(), s: o.scale.toArray() }; + /** @type {any[]} */ + const found = []; + const missing = []; + for (const tr of (spec.tracks ?? []).slice(0, MAX_TRACKS)) { + const node = findPart(root, tr?.part); + if (!node) { + missing.push(String(tr?.part ?? '')); + continue; + } + const keys = (Array.isArray(tr.keys) ? tr.keys : []) + .filter((k) => k && Number.isFinite(Number(k.t))) + .map((k) => ({ ...k, t: Math.max(0, Number(k.t)) })) + .sort((a, b) => a.t - b.t) + .slice(0, MAX_KEYS); + if (keys.length) found.push({ node, keys }); + } + if (!found.length) return { tp: null, parts: [], missing }; + // the nodes: every animated part + its ancestors up to (not including) the model root + /** @type {Set} */ + const keep = new Set(); + for (const { node } of found) for (let p = node; p && p !== root; p = p.parent) keep.add(p); + /** @type {any[]} */ + const list = []; + root.traverse((/** @type {any} */ o) => keep.has(o) && list.push(o)); + const index = new Map(list.map((o, i) => [o, i])); + const nodes = list.map((o) => { + const r = restOf(o); + return { name: o.name, parent: o.parent && index.has(o.parent) ? /** @type {number} */ (index.get(o.parent)) : -1, t: [...r.p], r: [...r.q], s: [...r.s] }; + }); + /** @type {import('./format.js').TpTrack[]} */ + const tracks = []; + let duration = 0; + for (const { node, keys } of found) { + const r = restOf(node); + const ni = /** @type {number} */ (index.get(node)); + const pos = keys.filter((/** @type {any} */ k) => Array.isArray(k.pos) && k.pos.length === 3); + const rot = keys.filter((/** @type {any} */ k) => Array.isArray(k.rot) && (k.rot.length === 3 || k.rot.length === 4)); + const scl = keys.filter((/** @type {any} */ k) => Array.isArray(k.scale) && (k.scale.length === 3 || k.scale.length === 1)); + if (pos.length) { + const values = pos.flatMap((/** @type {any} */ k) => (relative ? k.pos.map((/** @type {number} */ v, /** @type {number} */ c) => r.p[c] + Number(v)) : k.pos.map(Number))); + tracks.push({ node: ni, path: 'translation', times: Float32Array.from(pos.map((/** @type {any} */ k) => k.t)), values: Float32Array.from(values), interp: 'LINEAR' }); + } + if (rot.length) { + const restQ = new THREE.Quaternion().fromArray(r.q); + const qk = rotationKeys(rot); + /** @type {number[]} */ + const values = []; + const prev = new THREE.Quaternion(); + qk.forEach(({ q }, i) => { + const v = relative ? restQ.clone().multiply(q) : q.clone(); + if (i && v.dot(prev) < 0) v.set(-v.x, -v.y, -v.z, -v.w); // keep the hemisphere: no flip mid-spin + prev.copy(v); + values.push(...v.toArray()); + }); + tracks.push({ node: ni, path: 'rotation', times: Float32Array.from(qk.map((k) => k.t)), values: Float32Array.from(values), interp: 'LINEAR' }); + } + if (scl.length) { + const values = scl.flatMap((/** @type {any} */ k) => { + const v = k.scale.length === 1 ? [k.scale[0], k.scale[0], k.scale[0]] : k.scale; + return relative ? v.map((/** @type {number} */ x, /** @type {number} */ c) => r.s[c] * Number(x)) : v.map(Number); + }); + tracks.push({ node: ni, path: 'scale', times: Float32Array.from(scl.map((/** @type {any} */ k) => k.t)), values: Float32Array.from(values), interp: 'LINEAR' }); + } + duration = Math.max(duration, keys[keys.length - 1].t); + } + if (!tracks.length || duration <= 0) return { tp: null, parts: found.map((f) => f.node.name), missing }; + const fps = 30; + return { + tp: { + name: String(spec.clip || 'Parts').slice(0, 80), + duration, + fps, + frames: Math.round(duration * fps), + loop: spec.loop !== false, + rootMotion: 'moving', + nodes, + tracks, + humanoid: null, + sig: signatureOf(nodes.map((n) => ({ name: n.name, parent: n.parent >= 0 ? nodes[n.parent].name : '' }))), + provenance: { tool: 'animate_parts', version: String(TPANIM_FORMAT), date: new Date().toISOString().slice(0, 10) }, + tpExtras: true + }, + parts: found.map((f) => f.node.name), + missing + }; +} + diff --git a/src/lib/wireValidate.js b/src/lib/wireValidate.js index ee2462ad0..5137ba333 100644 --- a/src/lib/wireValidate.js +++ b/src/lib/wireValidate.js @@ -341,6 +341,18 @@ export const VALIDATORS = { d.clipRefs.length <= 64 && d.clipRefs.every((/** @type {any} */ r) => !!r && typeof r.hash === 'string' && r.hash.length <= 128 && typeof r.name === 'string' && r.name.length <= 200) ); + // 57 M11: an Animation states graph is a plain object with a states ARRAY, or null; a + // parameter is a short name + a number/boolean value + a finite stamp + if (d.parameter === 'animGraph') return isUuid(d.uuid) && (d.animGraph === null || (!!d.animGraph && typeof d.animGraph === 'object' && isArray(d.animGraph.states))); + if (d.parameter === 'animParam') + return isUuid(d.uuid) && typeof d.name === 'string' && d.name.length <= 40 && (typeof d.value === 'boolean' || Number.isFinite(d.value)) && Number.isFinite(d.at); + // 57 M12: a socket attach is {uuid, socket, offset[7]} or null (detached) + if (d.parameter === 'attachTo') + return ( + isUuid(d.uuid) && + (d.attachTo === null || + (!!d.attachTo && isUuid(d.attachTo.uuid) && typeof d.attachTo.socket === 'string' && d.attachTo.socket.length <= 200 && isNumberArray(d.attachTo.offset) && d.attachTo.offset.length === 7)) + ); 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))); diff --git a/tests/e2e/tpanim-motion.test.cjs b/tests/e2e/tpanim-motion.test.cjs new file mode 100644 index 000000000..b3f518d7e --- /dev/null +++ b/tests/e2e/tpanim-motion.test.cjs @@ -0,0 +1,254 @@ +// 57 M3 + M8 — game-ready clips and split/join, host + peer, on the real KayKit knight + clips: +// · CROSSFADE: a clip change with a fade blends on BOTH peers from one stamp — no snap (the +// counterfactual: the same change with no fade snaps); +// · BLEND BY SPEED: a node in the knight's own graph picks idle / walk from the knight's speed on +// A (moving it) and on B (receiving its moves), no wiring; +// · per-clip settings (In place / Moving, loop, speed, rename) are ref fields that replicate; +// · Loop it / Trim / Split / Join write NEW clip files and swap the ref in one undo step; +// · Extract clips turns a model's own clips into files, optionally used instead (bytes untouched); +// · the Animation window shows the clip tools (facts + seam) for the selected model. +const h = require('./helpers.cjs'); + +const animOf = (page, uuid) => + page.evaluate((uuid) => { + let map; + window.__stores.animatedImports.animatedObjects.subscribe((m) => (map = m))(); + const st = map?.[uuid]; + if (!st) return null; + let g; + window.__stores.objectsGroup.subscribe((x) => (g = x))(); + const root = g.getObjectByProperty('uuid', uuid); + return { ...st, info: window.__stores.animatedImports.clipInfo(uuid), refs: window.__stores.animatedImports.clipRefsOf(root) }; + }, uuid); + +/** the largest frame-to-frame turn (degrees) of the knight's left upper arm over `ms`, sampled per animation frame */ +const sampleJumps = (page, uuid, ms) => + page.evaluate( + ({ uuid, ms }) => + new Promise((resolve) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const bone = g.getObjectByProperty('uuid', uuid).getObjectByName(s.THREE.PropertyBinding.sanitizeNodeName('upperarm.l')); + const q = new s.THREE.Quaternion(); + const prev = new s.THREE.Quaternion(); + let have = false; + let worst = 0; + let frames = 0; + const end = performance.now() + ms; + const step = () => { + bone.updateWorldMatrix(true, false); + bone.matrixWorld.decompose(new s.THREE.Vector3(), q, new s.THREE.Vector3()); + if (have) worst = Math.max(worst, (prev.angleTo(q) * 180) / Math.PI); + prev.copy(q); + have = true; + frames++; + if (performance.now() < end) requestAnimationFrame(step); + else resolve({ worst, frames }); + }; + requestAnimationFrame(step); + }), + { uuid, ms } + ); + +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); + + // the knight + three clips from the shared clips file + const uuid = await A.page.evaluate(async () => { + const s = window.__stores; + const knight = await (await fetch('/avatars/knight.glb')).arrayBuffer(); + const id = await s.fileHandler.importFile(new File([knight], 'knight.glb'), 'Knight'); + const clips = await (await fetch('/avatars/clips.glb')).arrayBuffer(); + const items = await s.explorer.importFiles([new File([clips], 'clips.glb')], null); + for (const want of ['Idle', 'Walking_A', 'Running_A']) { + const item = items.find((i) => i.name.endsWith(want + '.tpanim')); + s.clipAssets.assignClipAsset(id, { hash: item.hash, name: want }); + } + return id; + }); + await h.eventually(() => animOf(A.page, uuid), (s) => s?.info?.filter((c) => c.asset).length === 3, 'three clip assets bound on A', 20000); + await h.eventually(() => animOf(B.page, uuid), (s) => s?.info?.filter((c) => c.asset).length === 3, 'and on B', 30000); + + // ---------- M3 crossfade: no snap on either peer ---------- + await A.page.evaluate((uuid) => window.__stores.animatedImports.setAnimationState(uuid, { clip: 'Idle', playing: true }), uuid); + await h.eventually(() => animOf(B.page, uuid), (s) => s?.clip === 'Idle' && s.playing, 'Idle plays on B'); + await A.page.waitForTimeout(800); + const own = await sampleJumps(A.page, uuid, 600); // the idle's own frame-to-frame motion + const [soft, softB] = await Promise.all([ + (async () => { + const p = sampleJumps(A.page, uuid, 1400); + await A.page.waitForTimeout(200); + await A.page.evaluate((uuid) => window.__stores.animatedImports.setAnimationState(uuid, { clip: 'Running_A', playing: true, fade: 0.7 }), uuid); + return p; + })(), + sampleJumps(B.page, uuid, 1600) + ]); + const stA = await animOf(A.page, uuid); + const stB = await animOf(B.page, uuid); + h.check(stB.from === 'Idle' && stB.fade === 0.7 && Math.abs(stB.at - stA.at) < 1e-6, `M3: B crossfades from the same stamp (from ${stB.from}, fade ${stB.fade}, Δat ${(stB.at - stA.at).toFixed(6)})`); + await A.page.waitForTimeout(600); + await A.page.evaluate((uuid) => window.__stores.animatedImports.setAnimationState(uuid, { clip: 'Idle', playing: true }), uuid); + await A.page.waitForTimeout(900); + const hard = await (async () => { + const p = sampleJumps(A.page, uuid, 900); + await A.page.waitForTimeout(200); + await A.page.evaluate((uuid) => window.__stores.animatedImports.setAnimationState(uuid, { clip: 'Running_A', playing: true }), uuid); + return p; + })(); + h.check(hard.worst > Math.max(own.worst * 3, 8), `M3 counterfactual: a change with NO fade snaps (${hard.worst.toFixed(1)}° in one frame, the idle moves ${own.worst.toFixed(1)}°)`); + h.check(soft.worst < hard.worst / 2, `M3: the crossfade does not snap on A (worst ${soft.worst.toFixed(1)}° per frame vs ${hard.worst.toFixed(1)}° hard, ${soft.frames} frames)`); + h.check(softB.worst < hard.worst / 2, `M3: ...nor on B (worst ${softB.worst.toFixed(1)}°)`); + + // ---------- M3 Blend by Speed: idle / walk from the knight's own speed, on A and on B ---------- + await A.page.evaluate((uuid) => { + const s = window.__stores; + s.animatedImports.setAnimationState(uuid, { playing: false }); + s.flowGraphsCtl.createObjectGraph(uuid); + s.nodesHandler.createFlowNode({ id: 'knight-blend', type: 'blendspeed', position: { x: 0, y: 0 }, data: { type: 'blendspeed', idle: '', walk: '', run: '', walkSpeed: 1.4, runSpeed: 3.5, crossfade: 0.25 } }, uuid); + }, uuid); + const weightsOf = (page) => page.evaluate((uuid) => window.__stores.animatedImports.poseDriverOf(uuid)?.weights ?? null, uuid); + await h.eventually(() => weightsOf(A.page), (w) => w && w.Idle > 0.95, 'M3: standing still, the blend is all Idle on A'); + await h.eventually(() => weightsOf(B.page), (w) => w && w.Idle > 0.95, 'M3: ...and on B (its graph replicated)', 15000); + // walk the knight at 1.4 m/s along x for 3 s, broadcasting moves at ~10 Hz like a drag does + const walking = A.page.evaluate( + (uuid) => + new Promise((resolve) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const o = g.getObjectByProperty('uuid', uuid); + let peer; + s.peers.subscribe((p) => (peer = p))(); + const t0 = performance.now(); + const x0 = o.position.x; + let lastSend = 0; + const step = () => { + const t = (performance.now() - t0) / 1000; + o.position.x = x0 + 1.4 * t; + if (performance.now() - lastSend > 100) { + lastSend = performance.now(); + peer.send({ type: 'move', uuid, pos: o.position.toArray(), rot: o.rotation.toArray(), scale: o.scale.toArray() }); + } + if (t < 3) requestAnimationFrame(step); + else resolve(true); + }; + requestAnimationFrame(step); + }), + uuid + ); + await A.page.waitForTimeout(1500); + const wA = await weightsOf(A.page); + const wB = await weightsOf(B.page); + h.check(wA && wA.Walking_A > 0.7, `M3: walking at 1.4 m/s the blend is Walk on A (${JSON.stringify(wA)})`); + h.check(wB && wB.Walking_A > 0.6, `M3: ...and on B from the replicated moves (${JSON.stringify(wB)})`); + await walking; + await h.eventually(() => weightsOf(A.page), (w) => w && w.Idle > 0.9, 'M3: stopped, it eases back to Idle', 5000); + await A.page.evaluate((uuid) => window.__stores.nodesHandler.deleteFlowNodes(['knight-blend'], uuid), uuid); + + // ---------- per-clip settings: ref fields, replicated ---------- + await A.page.evaluate((uuid) => window.__stores.clipAssets.updateClipRef(uuid, 'Walking_A', { mode: 'inplace', speed: 1.25, loop: true }), uuid); + await h.eventually(() => animOf(B.page, uuid), (s) => s?.refs?.some((r) => r.name === 'Walking_A' && r.mode === 'inplace' && r.speed === 1.25), 'M3: In place + speed replicate as ref settings'); + await A.page.evaluate((uuid) => window.__stores.clipAssets.updateClipRef(uuid, 'Walking_A', { name: 'Walk' }), uuid); + await h.eventually(() => animOf(B.page, uuid), (s) => s?.info?.some((c) => c.name === 'Walk' && c.asset) && !s.info.some((c) => c.name === 'Walking_A'), 'M8: a rename replicates (and rebinds under the new name)'); + + // ---------- M3 Loop it + the seam check ---------- + const seam = await A.page.evaluate(async (uuid) => { + const s = window.__stores; + const edit = s.clipEdit; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const ref = s.animatedImports.clipRefsOf(g.getObjectByProperty('uuid', uuid)).find((r) => r.name === 'Walk'); + const before = await edit.clipFactsFor(ref.hash); + await edit.loopClipAsset(uuid, 'Walk'); + const ref2 = s.animatedImports.clipRefsOf(g.getObjectByProperty('uuid', uuid)).find((r) => r.name === 'Walk'); + const after = await edit.clipFactsFor(ref2.hash); + return { before: before.seam, after: after.seam, swapped: ref2.hash !== ref.hash, kept: ref2.mode === 'inplace' && ref2.speed === 1.25, fps: after.fps, frames: after.frames }; + }, uuid); + h.check(seam.swapped && seam.kept, 'M3: Loop it writes a new file and the ref keeps its settings'); + h.check(seam.after.ok && seam.after.angle <= seam.before.angle, `M3: the seam closes (${seam.before.angle.toFixed(2)}° → ${seam.after.angle.toFixed(2)}°, ${seam.fps} fps, ${seam.frames} frames)`); + + // ---------- M8 trim / split / join ---------- + const ops = await A.page.evaluate(async (uuid) => { + const s = window.__stores; + const edit = s.clipEdit; + const info = () => s.animatedImports.clipInfo(uuid); + const run = info().find((c) => c.name === 'Running_A'); + await edit.trimClipAsset(uuid, 'Running_A', 0.1, 0.5); + const trimmed = info().length && (await new Promise((r) => setTimeout(r, 300)), info().find((c) => c.name === 'Running_A')); + await edit.splitClipAsset(uuid, 'Idle', 0.5, ['Idle start', 'Idle end']); + await new Promise((r) => setTimeout(r, 400)); + const split = info().filter((c) => c.name.startsWith('Idle ')); + await edit.joinClipAssets(uuid, ['Idle start', 'Running_A'], 0.2, 'Idle to run'); + await new Promise((r) => setTimeout(r, 400)); + const joined = info().find((c) => c.name === 'Idle to run'); + return { run: run?.duration, trimmed: trimmed?.duration, split: split.map((c) => c.duration), joined: joined?.duration }; + }, uuid); + h.check(Math.abs(ops.trimmed - 0.4) < 0.04, `M8: trim keeps the in–out part (${ops.run?.toFixed(2)} s → ${ops.trimmed?.toFixed(2)} s)`); + h.check(ops.split.length === 2 && ops.split[0] > 0.4 && ops.split[0] < 0.6, `M8: split at the playhead makes two clips (${ops.split.map((d) => d.toFixed(2)).join(' + ')} s)`); + h.check(ops.joined && Math.abs(ops.joined - (ops.split[0] + ops.trimmed - 0.2)) < 0.05, `M8: join overlaps by the crossfade (${ops.joined?.toFixed(2)} s)`); + await h.eventually(() => animOf(B.page, uuid), (s) => s?.info?.some((c) => c.name === 'Idle to run' && c.asset), 'M8: the joined clip reached B (a new file, pulled once)', 20000); + // one undo takes the join back off, everywhere + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => animOf(B.page, uuid), (s) => s && !s.info.some((c) => c.name === 'Idle to run'), 'M8: undo removes the joined clip on B'); + + // ---------- M8 Extract clips from a model with its own clip (bytes untouched) ---------- + const mover = await A.page.evaluate( + () => + new Promise((resolve) => { + const s = window.__stores; + const { GLTFExporter } = s.GLTFExporterModule; + const root = new s.THREE.Group(); + const mesh = new s.THREE.Mesh(new s.THREE.BoxGeometry(1, 1, 1), new s.THREE.MeshStandardMaterial()); + mesh.name = 'mover'; + root.add(mesh); + const clip = new s.THREE.AnimationClip('slide', 2, [new s.THREE.VectorKeyframeTrack('mover.position', [0, 1, 2], [0, 0, 0, 2, 0, 0, 0, 0, 0])]); + new GLTFExporter().parse( + root, + async (buffer) => { + const id = await s.fileHandler.importFile(new File([buffer], 'mover.glb'), 'Mover'); + resolve(id); + }, + () => resolve(null), + { binary: true, animations: [clip] } + ); + }) + ); + await h.eventually(() => animOf(A.page, mover), (s) => s?.info?.some((c) => c.name === 'slide'), 'a model with its own clip'); + const bytesBefore = await A.page.evaluate((id) => window.__stores.explorer.hashBytes(window.__stores.animatedImports.animatedImportPayload(id)), mover); + await A.page.evaluate((id) => window.__stores.clipEdit.extractClips(id, { useFiles: true }), mover); + await h.eventually(() => animOf(A.page, mover), (s) => s?.info?.length === 1 && s.info[0].name === 'slide' && !!s.info[0].asset, 'M8: Extract ▸ use the files: the clip now comes from a .tpanim (same name)'); + await h.eventually(() => animOf(B.page, mover), (s) => s?.info?.length === 1 && !!s.info[0].asset, 'M8: ...on B too', 20000); + const moves = await A.page.evaluate(async (id) => { + const s = window.__stores; + s.animatedImports.setAnimationState(id, { clip: 'slide', playing: true }); + const rec = s.animatedImports.animatedRecordOf(id); + rec.mixer.setTime(1); + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + return g.getObjectByProperty('uuid', id).getObjectByName('mover').position.x; + }, mover); + h.check(Math.abs(moves - 2) < 0.01, `M8: the extracted clip drives the node by name (x at 1 s = ${moves.toFixed(3)})`); + const bytesAfter = await A.page.evaluate((id) => window.__stores.explorer.hashBytes(window.__stores.animatedImports.animatedImportPayload(id)), mover); + h.check(bytesAfter === bytesBefore, 'M8: the model bytes did not change'); + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => animOf(B.page, mover), (s) => s?.info?.some((c) => c.name === 'slide' && !c.asset), 'M8: one undo brings the embedded clip back on B'); + + // ---------- the Animation window shows the clip tools ---------- + await A.page.evaluate((uuid) => { + const s = window.__stores; + s.objectActions.selectObject(uuid); + s.animationClose.set(false); + s.animatedImports.setAnimationState(uuid, { clip: 'Walk', playing: true }); + }, uuid); + await A.page.locator('#clip-tools').waitFor({ timeout: 10000 }); + await h.eventually(() => A.page.locator('#clip-facts').innerText().catch(() => ''), (t) => /\d+ fps · \d+ f/.test(t), 'M3: the window shows the clip’s fps · frames · length'); + h.check(/seamless loop|loop seam/.test(await A.page.locator('#clip-seam').innerText()), 'M3: ...and its loop seam'); + await A.page.locator('#clip-mode-moving').click(); + await h.eventually(() => animOf(B.page, uuid), (s) => s?.refs?.some((r) => r.name === 'Walk' && r.mode === 'moving'), 'M3: the Moving button sets the ref on every peer'); + + await h.finish(browser); +}); diff --git a/tests/e2e/tpanim-states.test.cjs b/tests/e2e/tpanim-states.test.cjs new file mode 100644 index 000000000..6296ac88b --- /dev/null +++ b/tests/e2e/tpanim-states.test.cjs @@ -0,0 +1,233 @@ +// 57 M11 + M12 — the Animation states window + Locomotion preset under the Player controller +// (possess) on host AND peer; Set Animation Parameter / On Animation State nodes; animate_parts +// (the agent tool, through executeAiTool) on a plain model's named parts; Attach to Socket. +const h = require('./helpers.cjs'); + +const readingOf = (page, uuid) => + page.evaluate((uuid) => { + const s = window.__stores; + const r = s.animGraphRuntime.animGraphReading(uuid); + const w = s.animatedImports.poseDriverOf(uuid)?.weights ?? null; + return r ? { state: r.state, params: r.params, weights: w } : null; + }, uuid); + +/** record every state the machine enters on a page, from now on */ +const watchStates = (page, uuid) => + page.evaluate((uuid) => { + window.__seen57 = []; + window.__stores.animGraphRuntime.onAnimStateEnter((u, id) => u === uuid && window.__seen57.push(id)); + }, uuid); +const seenStates = (page) => page.evaluate(() => window.__seen57 ?? []); + +/** a vertical hop on A, broadcasting moves the way a drag does (vy +4 m/s, then -4 back down) */ +const hop = (page, uuid) => + page.evaluate( + (uuid) => + new Promise((resolve) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const o = g.getObjectByProperty('uuid', uuid); + let peer; + s.peers.subscribe((p) => (peer = p))(); + const y0 = o.position.y; + const t0 = performance.now(); + let last = 0; + const step = () => { + const t = (performance.now() - t0) / 1000; + o.position.y = y0 + (t < 0.4 ? 4 * t : Math.max(0, 1.6 - 4 * (t - 0.4))); + if (performance.now() - last > 100) { + last = performance.now(); + peer.send({ type: 'move', uuid, pos: o.position.toArray(), rot: o.rotation.toArray(), scale: o.scale.toArray() }); + } + if (t < 1.6) requestAnimationFrame(step); + else { + o.position.y = y0; + peer.send({ type: 'move', uuid, pos: o.position.toArray(), rot: o.rotation.toArray(), scale: o.scale.toArray() }); + resolve(true); + } + }; + requestAnimationFrame(step); + }), + 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 uuid = await A.page.evaluate(async () => { + const s = window.__stores; + const knight = await (await fetch('/avatars/knight.glb')).arrayBuffer(); + const id = await s.fileHandler.importFile(new File([knight], 'knight.glb'), 'Knight'); + const clips = await (await fetch('/avatars/clips.glb')).arrayBuffer(); + const items = await s.explorer.importFiles([new File([clips], 'clips.glb')], null); + for (const want of ['Idle', 'Walking_A', 'Running_A', 'Jump_Idle']) { + const item = items.find((i) => i.name.endsWith(want + '.tpanim')); + s.clipAssets.assignClipAsset(id, { hash: item.hash, name: want }); + } + return id; + }); + const bound = (page) => page.evaluate((uuid) => window.__stores.animatedImports.clipInfo(uuid).filter((c) => c.asset).length, uuid); + await h.eventually(() => bound(A.page), (n) => n === 4, 'four clips bound on A', 20000); + await h.eventually(() => bound(B.page), (n) => n === 4, '...and on B', 30000); + + // ---------- the window: one click builds the Locomotion preset ---------- + await A.page.evaluate((uuid) => { + const s = window.__stores; + s.objectActions.selectObject(uuid); + s.animationClose.set(false); + }, uuid); + await A.page.locator('#anim-states-open').click({ timeout: 15000 }); + await A.page.locator('#anim-states-preset').click({ timeout: 10000 }); + const graphOn = (page) => + page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((x) => (g = x))(); + return g.getObjectByProperty('uuid', uuid)?.userData?.animGraph ?? null; + }, uuid); + await h.eventually(() => graphOn(B.page), (g) => g?.states?.length === 3 && g.driver === 'motion', 'M11: the Locomotion preset replicated (Move / Jump / Fall, motion driver)'); + const g = await graphOn(A.page); + h.check(g.states[0].blend?.walk === 'Walking_A' && g.states.find((s) => s.id === 'fall')?.clip === 'Jump_Idle', 'M11: the preset picked the character’s own clips'); + await A.page.locator('#anim-states [data-state="move"].as-live').waitFor({ timeout: 10000 }).then( + () => h.check(true, 'M11: the window shows the live state (Move)'), + () => h.check(false, 'M11: the window shows the live state (Move)') + ); + await h.eventually(() => readingOf(A.page, uuid), (r) => r?.state === 'move' && r.weights?.Idle > 0.9, 'M11: standing, it idles on A'); + await h.eventually(() => readingOf(B.page, uuid), (r) => r?.state === 'move' && r.weights?.Idle > 0.9, 'M11: ...and on B'); + + // ---------- walk + run under the Player controller (possess), host and peer ---------- + const drive = async (speed, label) => { + await A.page.evaluate(({ uuid, speed }) => window.__stores.possess.possess(uuid, { speed, camera: 'none' }), { uuid, speed }); + await A.page.keyboard.down('W'); + await A.page.waitForTimeout(1600); + const a = await readingOf(A.page, uuid); + const b = await readingOf(B.page, uuid); + await A.page.keyboard.up('W'); + await A.page.evaluate(() => window.__stores.possess.release()); + return { a, b }; + }; + const walk = await drive(1.4, 'walk'); + h.check(walk.a?.weights?.Walking_A > 0.7, `M11: possessed at 1.4 m/s it WALKS on A (speed ${walk.a?.params?.speed?.toFixed(2)}, ${JSON.stringify(walk.a?.weights)})`); + h.check(walk.b?.weights?.Walking_A > 0.5, `M11: ...and on B from the replicated moves (${JSON.stringify(walk.b?.weights)})`); + await A.page.waitForTimeout(800); + const run = await drive(4, 'run'); + h.check(run.a?.weights?.Running_A > 0.7, `M11: at 4 m/s it RUNS on A (${JSON.stringify(run.a?.weights)})`); + h.check(run.b?.weights?.Running_A > 0.5, `M11: ...and on B (${JSON.stringify(run.b?.weights)})`); + + // ---------- jump: a hop goes Jump -> Fall -> Move, on both peers ---------- + await A.page.waitForTimeout(800); + await watchStates(A.page, uuid); + await watchStates(B.page, uuid); + await hop(A.page, uuid); + await A.page.waitForTimeout(900); + const seenA = await seenStates(A.page); + const seenB = await seenStates(B.page); + h.check(seenA.join() === 'jump,fall,move', `M11: a hop goes Jump → Fall → Move on A (${seenA.join(' → ')})`); + h.check(seenB.join() === 'jump,fall,move', `M11: ...and on B (${seenB.join(' → ')})`); + + // ---------- the nodes: On Click -> Set Animation Parameter (jump trigger); On Animation State ---------- + await A.page.evaluate((uuid) => { + const s = window.__stores; + s.flowGraphsCtl.createObjectGraph(uuid); + const nh = s.nodesHandler; + nh.createFlowNode({ id: 'k-click', type: 'onclick', position: { x: 0, y: 0 }, data: { type: 'onclick', pulse: 0.3 } }, uuid); + nh.createFlowNode({ id: 'k-jump', type: 'setanimparam', position: { x: 220, y: 0 }, data: { type: 'setanimparam', name: 'jump', mode: 'trigger', value: 0 } }, uuid); + nh.createFlowEdge({ id: 'e-k', source: 'k-click', target: 'k-jump', targetHandle: 'trigger' }, uuid); + nh.createFlowNode({ id: 'k-onjump', type: 'onanimstate', position: { x: 0, y: 160 }, data: { type: 'onanimstate', state: 'Jump', pulse: 0.3 } }, uuid); + }, uuid); + await B.page.waitForTimeout(1500); + await watchStates(A.page, uuid); + await watchStates(B.page, uuid); + await A.page.evaluate((uuid) => window.__stores.flowRuntime.fireObjectClick(uuid), uuid); + await A.page.waitForTimeout(600); + h.check((await seenStates(A.page)).includes('jump'), 'M11: Set Animation Parameter fires the jump trigger on A'); + await h.eventually(() => seenStates(B.page), (l) => l.includes('jump'), 'M11: ...and on B (from the replicated click stamp)'); + const pulsed = await A.page.evaluate(() => { + let t; + window.__stores.flowTriggers.subscribe((v) => (t = v))(); + return !!t?.['k-onjump']; + }); + h.check(pulsed, 'M11: On Animation State pulsed when the machine entered Jump'); + + // ---------- M12: animate_parts (the agent tool) on a plain model's named parts ---------- + const heli = await A.page.evaluate( + () => + new Promise((resolve) => { + const s = window.__stores; + const T = s.THREE; + const root = new T.Group(); + const body = new T.Mesh(new T.BoxGeometry(2, 1, 1), new T.MeshStandardMaterial()); + body.name = 'body'; + root.add(body); + const rotor = new T.Mesh(new T.BoxGeometry(4, 0.05, 0.2), new T.MeshStandardMaterial()); + rotor.name = 'rotor'; + rotor.position.set(0, 0.8, 0); + body.add(rotor); + new s.GLTFExporterModule.GLTFExporter().parse(root, async (buf) => resolve(await s.fileHandler.importFile(new File([buf], 'heli.glb'), 'Heli')), () => resolve(null), { binary: true }); + }) + ); + h.check(!!heli && !(await A.page.evaluate((id) => window.__stores.animatedImports.hasAnimatedImport(id), heli)), 'M12: a plain model (no rig, no clips) placed'); + const res = await A.page.evaluate( + (heli) => window.__stores.aiTools.executeAiTool('animate_parts', { object: heli, clip: 'Spin', tracks: [{ part: 'rotor', keys: [{ t: 0, rot: [0, 0, 0] }, { t: 1, rot: [0, 360, 0] }] }], loop: true }), + heli + ); + h.check(res?.ok && res.parts?.[0] === 'rotor', `M12: animate_parts writes one clip on the named part (${JSON.stringify(res).slice(0, 120)})`); + const rotorYaw = (page) => + page.evaluate((heli) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const r = g.getObjectByProperty('uuid', heli)?.getObjectByName('rotor'); + return r ? new s.THREE.Euler().setFromQuaternion(r.quaternion, 'YXZ').y : null; + }, heli); + const turns = async (page) => { + const a = await rotorYaw(page); + await page.waitForTimeout(170); + const b = await rotorYaw(page); + return a !== null && b !== null && Math.abs(a - b) > 0.2; + }; + h.check(await turns(A.page), 'M12: the rotor spins on A'); + await h.eventually(() => turns(B.page), (v) => v, 'M12: ...and on B (the clip pulled, bound to its own copy by part name)', 20000); + const clipOnHeli = (page) => page.evaluate((heli) => window.__stores.animatedImports.clipInfo(heli).map((c) => c.name), heli); + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => clipOnHeli(B.page), (l) => !l.includes('Spin'), 'M12: one undo takes the clip off on B'); + await A.page.evaluate(() => window.__stores.history.redo()); + await h.eventually(() => clipOnHeli(B.page), (l) => l.includes('Spin'), 'M12: redo puts it back'); + + // ---------- M12: Attach to Socket — a box rides the rotor tip ---------- + const box = await A.page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create Box 0.2 0.2 0.2'); + const group = await new Promise((r) => s.objectsGroup.subscribe(r)()); + const b = group.children[group.children.length - 1]; + b.position.set(1.8, 0.8, 0); + return b.uuid; + }); + await B.page.waitForTimeout(1200); + await A.page.evaluate(({ box, heli }) => window.__stores.animatedImports.attachToSocket(box, heli, 'rotor', { snap: false }), { box, heli }); + const boxAt = (page) => + page.evaluate((box) => { + let g; + window.__stores.objectsGroup.subscribe((x) => (g = x))(); + const o = g.getObjectByProperty('uuid', box); + return o ? [o.position.x, o.position.z] : null; + }, box); + const circles = async (page) => { + const a = await boxAt(page); + await page.waitForTimeout(200); + const b = await boxAt(page); + return a && b && Math.hypot(a[0] - b[0], a[1] - b[1]) > 0.2 && Math.abs(Math.hypot(b[0], b[1]) - 1.8) < 0.15; + }; + h.check(await circles(A.page), 'M12: the attached box follows the spinning rotor on A'); + await h.eventually(() => circles(B.page), (v) => v, 'M12: ...and on B (the attach replicated; each peer follows its own socket)', 15000); + await A.page.evaluate((box) => window.__stores.animatedImports.attachToSocket(box, null, null), box); + const s1 = await boxAt(A.page); + await A.page.waitForTimeout(300); + const s2 = await boxAt(A.page); + h.check(s1 && s2 && Math.hypot(s1[0] - s2[0], s1[1] - s2[1]) < 1e-6, 'M12: detached, it stays where it was let go'); + + await h.finish(browser); +}); diff --git a/tests/unit/animGraph.test.js b/tests/unit/animGraph.test.js new file mode 100644 index 000000000..82d25cba0 --- /dev/null +++ b/tests/unit/animGraph.test.js @@ -0,0 +1,129 @@ +// @ts-nocheck — fixture-heavy unit suite (graphs as literals), the checkJs convention for tests +// 57 M11 — the Animation states graph: normalize, transitions with conditions, crossfades between +// states, triggers used once, play-once states, the motion params and the Locomotion preset. +import { describe, it, expect } from 'vitest'; +import { normalizeAnimGraph, newRun, stepGraph, motionParams, locomotionPreset } from '../../src/lib/tpanim/animGraph.js'; + +const KAYKIT = ['Cheer', 'Idle', 'Interact', 'Jump_Idle', 'Running_A', 'Walking_A', 'Walking_Backwards']; +const DUR = { Idle: 2, Walking_A: 1, Running_A: 0.8, Jump_Idle: 0.6, Cheer: 1.5 }; + +/** run the graph at 60 fps through a list of [seconds, params] phases; returns every frame */ +function drive(graph, phases) { + const run = newRun(graph, 0); + const frames = []; + let t = 0; + for (const [secs, params] of phases) + for (let i = 0; i < Math.round(secs * 60); i++) { + t += 1 / 60; + frames.push({ t, ...stepGraph(graph, run, typeof params === 'function' ? params(t) : params, t, KAYKIT, DUR) }); + } + return frames; +} + +describe('normalizeAnimGraph', () => { + it('keeps a valid graph and drops what does not resolve', () => { + const g = normalizeAnimGraph({ + states: [{ id: 'a', clip: 'Idle' }, { id: 'a', clip: 'dupe' }, { id: 'b', blend: { param: 'speed' } }], + transitions: [{ from: 'a', to: 'b', fade: 9, when: [{ param: 'speed', op: '>', value: '1' }, { param: 'x', op: 'nope' }] }, { from: 'a', to: 'zzz' }], + params: [{ name: 'speed' }, { name: 'speed' }], + entry: 'nope', + evil: 1 + }); + expect(g.states.map((s) => s.id)).toEqual(['a', 'b']); + expect(g.entry).toBe('a'); + expect(g.transitions).toEqual([{ from: 'a', to: 'b', fade: 3, when: [{ param: 'speed', op: '>', value: 1 }] }]); + expect(g.params).toEqual([{ name: 'speed', type: 'float' }]); + expect(g.evil).toBeUndefined(); + expect(normalizeAnimGraph({ states: [] })).toBe(null); + expect(normalizeAnimGraph(null)).toBe(null); + }); +}); + +describe('stepGraph', () => { + const g = normalizeAnimGraph({ + entry: 'idle', + params: [{ name: 'go', type: 'trigger' }], + states: [{ id: 'idle', clip: 'Idle' }, { id: 'cheer', clip: 'Cheer', loop: false }], + transitions: [ + { from: 'idle', to: 'cheer', fade: 0.5, when: [{ param: 'go', op: 'trigger' }] }, + { from: 'cheer', to: 'idle', fade: 0.25, when: [], exitTime: 1 } + ] + }); + + it('crossfades between states with weights that always sum to one and never jump', () => { + const frames = drive(g, [[0.5, { go: -1 }], [3, { go: 0.6 }]]); + const at = frames.findIndex((f) => f.entered); + expect(frames[at].state).toBe('cheer'); + let prev = frames[at - 1].weights; + for (const f of frames.slice(at, at + 40)) { + const sum = Object.values(f.weights).reduce((a, b) => a + b, 0); + expect(sum).toBeCloseTo(1, 9); + expect(Math.abs((f.weights.Cheer ?? 0) - (prev.Cheer ?? 0))).toBeLessThan(0.1); + prev = f.weights; + } + // half way through the 0.5 s fade both clips play + const mid = frames[at + 15].weights; + expect(mid.Idle).toBeGreaterThan(0.2); + expect(mid.Cheer).toBeGreaterThan(0.2); + }); + + it('uses a trigger once, and a play-once state holds then leaves at its exit time', () => { + const frames = drive(g, [[0.2, { go: -1 }], [4, { go: 0.25 }]]); + const entries = frames.filter((f) => f.entered).map((f) => f.state); + expect(entries).toEqual(['cheer', 'idle']); // the same stamp does not fire twice + const inCheer = frames.filter((f) => f.state === 'cheer'); + expect(inCheer.at(-1).times.Cheer).toBeCloseTo(1.5 - 1e-4, 2); // pinned at its end before leaving + expect(inCheer.length / 60).toBeGreaterThan(1.45); // exitTime 1 = the whole 1.5 s clip + }); + + it('an old trigger does not replay when a state is entered later', () => { + const frames = drive(g, [[1, { go: 0.1 }]]); + // fired at 0.1 s while in idle (entered at 0): it counts, once + expect(frames.filter((f) => f.entered && f.state === 'cheer').length).toBe(1); + }); +}); + +describe('motionParams', () => { + it('reads speed, vertical speed and grounded (not at a jump apex)', () => { + const memo = { groundedFor: 1 }; + expect(motionParams([3, 0, 4], memo, 1 / 60)).toEqual({ speed: 5, vy: 0, grounded: true }); + expect(motionParams([0, 4, 0], memo, 1 / 60).grounded).toBe(false); + // apex: |vy| small for a few frames only + let p; + for (let i = 0; i < 4; i++) p = motionParams([0, 0.1, 0], memo, 1 / 60); + expect(p.grounded).toBe(false); + for (let i = 0; i < 10; i++) p = motionParams([0, 0, 0], memo, 1 / 60); + expect(p.grounded).toBe(true); + }); +}); + +describe('the Locomotion preset', () => { + const g = locomotionPreset(KAYKIT); + it('builds Move (idle/walk/run blend), Jump and Fall from the model’s clips', () => { + expect(g.driver).toBe('motion'); + const move = g.states.find((s) => s.id === 'move'); + expect(move.blend).toMatchObject({ idle: 'Idle', walk: 'Walking_A', run: 'Running_A' }); + expect(g.states.find((s) => s.id === 'fall').clip).toBe('Jump_Idle'); + }); + + it('walks, runs, jumps, falls and lands', () => { + const memo = { groundedFor: 1 }; + const phase = (/** @type {number[]} */ vel) => () => motionParams(vel, memo, 1 / 60); + const frames = drive(g, [ + [1, phase([0, 0, 0])], + [1, phase([1.4, 0, 0])], + [1, phase([4, 0, 0])], + [0.3, phase([4, 4, 0])], + [0.4, phase([4, -3, 0])], + [0.6, phase([2, 0, 0])] + ]); + const at = (/** @type {number} */ s) => frames[Math.round(s * 60) - 1]; + expect(at(0.9).weights.Idle).toBeCloseTo(1, 5); + expect(at(1.9).weights.Walking_A).toBeGreaterThan(0.95); + expect(at(2.9).weights.Running_A).toBeGreaterThan(0.95); + expect(at(3.25).state).toBe('jump'); + expect(at(3.65).state).toBe('fall'); + expect(at(4.3).state).toBe('move'); + expect(frames.filter((f) => f.entered).map((f) => f.state)).toEqual(['jump', 'fall', 'move']); + }); +}); diff --git a/tests/unit/blendSpeed.test.js b/tests/unit/blendSpeed.test.js new file mode 100644 index 000000000..4bd6f775a --- /dev/null +++ b/tests/unit/blendSpeed.test.js @@ -0,0 +1,77 @@ +// 57 M3 — Blend by Speed: the 1D blend space, the easing, and the auto clip pick. +import { describe, it, expect } from 'vitest'; +import { blendSpeedWeights, smoothWeights, clipForSlot, IDLE_SPEED } from '../../src/lib/tpanim/blendSpeed.js'; + +const sum = (/** @type {any} */ w) => w.idle + w.walk + w.run; + +describe('blendSpeedWeights', () => { + it('stands still, walks, runs — and always sums to one', () => { + expect(blendSpeedWeights(0).weights).toEqual({ idle: 1, walk: 0, run: 0 }); + expect(blendSpeedWeights(IDLE_SPEED / 2).weights.idle).toBe(1); + const mid = blendSpeedWeights(0.8).weights; + expect(mid.idle).toBeGreaterThan(0); + expect(mid.walk).toBeGreaterThan(0); + expect(blendSpeedWeights(1.4).weights.walk).toBe(1); + const jog = blendSpeedWeights(2.4).weights; + expect(jog.walk).toBeGreaterThan(0); + expect(jog.run).toBeGreaterThan(0); + expect(blendSpeedWeights(9).weights.run).toBe(1); + for (const s of [0, 0.3, 1, 1.4, 2, 3, 3.5, 6]) expect(sum(blendSpeedWeights(s).weights)).toBeCloseTo(1, 9); + }); + + it('is CONTINUOUS in speed (no snap anywhere along the ramp)', () => { + let prev = blendSpeedWeights(0).weights; + for (let s = 0.01; s < 5; s += 0.01) { + const w = blendSpeedWeights(s).weights; + expect(Math.abs(w.idle - prev.idle) + Math.abs(w.walk - prev.walk) + Math.abs(w.run - prev.run)).toBeLessThan(0.05); + prev = w; + } + }); + + it('plays walk and run at a pace that matches the speed, within limits', () => { + expect(blendSpeedWeights(1.4).rates.walk).toBeCloseTo(1, 9); + expect(blendSpeedWeights(1.8).rates.walk).toBeGreaterThan(1); + expect(blendSpeedWeights(30).rates.run).toBeLessThanOrEqual(1.6); + expect(blendSpeedWeights(0.2).rates.walk).toBeGreaterThanOrEqual(0.6); + }); + + it('hands a missing slot to its neighbour', () => { + const noRun = blendSpeedWeights(6, { run: false }).weights; + expect(noRun).toEqual({ idle: 0, walk: 1, run: 0 }); + const noWalk = blendSpeedWeights(1.2, { walk: false }).weights; + expect(noWalk.walk).toBe(0); + expect(sum(noWalk)).toBeCloseTo(1, 9); + const noIdle = blendSpeedWeights(0, { idle: false }).weights; + expect(noIdle.idle).toBe(0); + expect(noIdle.walk).toBe(1); + }); +}); + +describe('smoothWeights', () => { + it('eases 95% of the way in the crossfade time, and snaps at 0', () => { + const cur = { idle: 1, walk: 0 }; + const target = { idle: 0, walk: 1 }; + /** @type {Record} */ + let w = cur; + for (let i = 0; i < 25; i++) w = smoothWeights(w, target, 0.01, 0.25); + expect(w.walk).toBeGreaterThan(0.94); + expect(w.walk).toBeLessThan(0.96); + expect(smoothWeights(cur, target, 0.01, 0)).toEqual(target); + // one frame never jumps more than a frame's share + expect(smoothWeights(cur, target, 1 / 60, 0.25).walk).toBeLessThan(0.2); + }); +}); + +describe('clipForSlot', () => { + const kaykit = ['Cheer', 'Idle', 'Running_A', 'Running_Strafe_Left', 'Walking_A', 'Walking_Backwards']; + it('picks by name when the node names none', () => { + expect(clipForSlot(kaykit, 'idle')).toBe('Idle'); + expect(clipForSlot(kaykit, 'walk')).toBe('Walking_A'); + expect(clipForSlot(kaykit, 'run')).toBe('Running_A'); + expect(clipForSlot(['Jump'], 'run')).toBe(''); + }); + it('uses the named clip when the model has it, else none', () => { + expect(clipForSlot(kaykit, 'walk', 'Walking_Backwards')).toBe('Walking_Backwards'); + expect(clipForSlot(kaykit, 'walk', 'Nope')).toBe(''); + }); +}); diff --git a/tests/unit/partsClip.test.js b/tests/unit/partsClip.test.js new file mode 100644 index 000000000..60af0becc --- /dev/null +++ b/tests/unit/partsClip.test.js @@ -0,0 +1,107 @@ +// @ts-nocheck — fixture models built inline +// 57 M12 — animate imported parts: keys on named nodes -> a node-name .tpanim that plays back on +// the model (a spin is a spin, relative keys sit on the part's rest, missing parts are reported). +import { describe, it, expect } from 'vitest'; +import * as THREE from 'three'; +import { buildPartsClip, rotationKeys } from '../../src/lib/tpanim/partsClip.js'; +import { encodeTpanim, decodeTpanim, tpToThreeClip, clipFit } from '../../src/lib/tpanim/format.js'; + +/** a helicopter-ish model: body > mast > rotor, and a door hinged on the body */ +function heli() { + const root = new THREE.Group(); + root.name = 'Heli'; + const body = new THREE.Mesh(new THREE.BoxGeometry(2, 1, 1)); + body.name = 'body'; + root.add(body); + const mast = new THREE.Object3D(); + mast.name = 'mast'; + mast.position.set(0, 0.6, 0); + body.add(mast); + const rotor = new THREE.Mesh(new THREE.BoxGeometry(4, 0.05, 0.2)); + rotor.name = 'rotor'; + rotor.position.set(0, 0.2, 0); + rotor.rotation.y = 0.3; // a rest turn the relative keys must sit on + mast.add(rotor); + const door = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.8, 0.05)); + door.name = 'Door Left'; + door.position.set(0.5, 0, 0.5); + body.add(door); + root.updateMatrixWorld(true); + return { root, rotor, door }; +} + +const play = (root, clip, t) => { + const mixer = new THREE.AnimationMixer(root); + mixer.clipAction(clip).play(); + mixer.setTime(t); + root.updateMatrixWorld(true); + return () => { + mixer.stopAllAction(); + mixer.uncacheRoot(root); + }; +}; + +describe('rotationKeys', () => { + it('splits long Euler segments so a full turn turns', () => { + const keys = rotationKeys([{ t: 0, rot: [0, 0, 0] }, { t: 2, rot: [0, 360, 0] }]); + expect(keys.length).toBe(5); // 0, 90, 180, 270, 360 + expect(keys[2].t).toBeCloseTo(1, 9); + const half = new THREE.Euler().setFromQuaternion(keys[2].q); + expect(Math.abs(half.y) + Math.abs(half.x)).toBeGreaterThan(1); // 180°, not identity + }); +}); + +describe('buildPartsClip', () => { + it('a rotor spin: relative to its rest, continuous, through the file and back', () => { + const { root, rotor } = heli(); + const { tp, parts, missing } = buildPartsClip(root, { clip: 'Spin', tracks: [{ part: 'rotor', keys: [{ t: 0, rot: [0, 0, 0] }, { t: 1, rot: [0, 360, 0] }] }] }); + expect(parts).toEqual(['rotor']); + expect(missing).toEqual([]); + expect(tp.nodes.map((n) => n.name)).toEqual(['body', 'mast', 'rotor']); + const back = decodeTpanim(encodeTpanim(tp)); + expect(back.loop).toBe(true); + expect(back.provenance.tool).toBe('animate_parts'); + expect(clipFit(back, root).fit).not.toBe('none'); + const clip = tpToThreeClip(back); + const yaw = []; + for (const t of [0, 0.25, 0.5, 0.75, 1]) { + const stop = play(root, clip, t); + yaw.push(new THREE.Euler().setFromQuaternion(rotor.quaternion, 'YXZ').y); + stop(); + } + // at t=0 the rotor sits at its rest turn (0.3 rad); a quarter of the way it is 90° further + expect(yaw[0]).toBeCloseTo(0.3, 4); + const d = (a, b) => Math.atan2(Math.sin(a - b), Math.cos(a - b)); + expect(d(yaw[1], yaw[0])).toBeCloseTo(Math.PI / 2, 3); + expect(d(yaw[2], yaw[1])).toBeCloseTo(Math.PI / 2, 3); + // the position never moved + expect(rotor.position.y).toBeCloseTo(0.2, 6); + }); + + it('a door opens by name (case-insensitive), absolute mode, and missing parts are listed', () => { + const { root, door } = heli(); + const { tp, missing } = buildPartsClip(root, { + clip: 'Open', + mode: 'absolute', + loop: false, + tracks: [ + { part: 'door left', keys: [{ t: 0, pos: [0.5, 0, 0.5], rot: [0, 0, 0] }, { t: 0.5, pos: [0.5, 0, 0.9], rot: [0, -80, 0] }] }, + { part: 'nope', keys: [{ t: 0, pos: [0, 0, 0] }] } + ] + }); + expect(missing).toEqual(['nope']); + expect(tp.loop).toBe(false); + expect(tp.duration).toBeCloseTo(0.5, 9); + const stop = play(root, tpToThreeClip(tp, root), 0.5 - 1e-5); // setTime(duration) wraps a repeating action to 0 + expect(door.position.z).toBeCloseTo(0.9, 3); + expect(THREE.MathUtils.radToDeg(new THREE.Euler().setFromQuaternion(door.quaternion).y)).toBeCloseTo(-80, 1); + stop(); + }); + + it('returns no clip when nothing matched', () => { + const { root } = heli(); + const out = buildPartsClip(root, { clip: 'X', tracks: [{ part: 'wing', keys: [{ t: 0, rot: [0, 0, 0] }, { t: 1, rot: [0, 90, 0] }] }] }); + expect(out.tp).toBe(null); + expect(out.missing).toEqual(['wing']); + }); +}); From 27f20aa349d0827a4307c3b9ab8a1b883373278f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 05:49:52 +0300 Subject: [PATCH 14/26] [fix] 57-clips: review fixes + the small pieces the rows still owed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crossfade: AnimationAction.stop() keeps its weight, so a clip interrupted mid-fade replayed half-strength — every transport start resets it to 1 (measured: 0.3 after stop) - ref-only changes (mode / loop / speed) and graph edits poke the clip store, so the panels follow - the Animation states window is a lazy chunk (animGraph.js stays out of the boot bundle) - Locomotion preset: a travelling walk/run tells the blend its own pace and is set In place - BVH: the height lives in the motion (rest hips at the floor) — centimetres detected from the first frame, and toHumanoid stands a floor-rest rig at its first frame (VRMA scales by it) - M12: "Save as a clip file on " bakes keys authored on a model's parts into one node-name .tpanim on the model (clipEdit.partsKeysToClip) - Explorer: clip cards draw their skeleton (a 2D stick figure + trail, lazy); double-click plays a clip on the selected character; .tpanim / .vrma / .bvh dropped on the viewport go to the Explorer and onto the rig under the cursor - unit: tpanimConvert (BVH -> humanoid in metres, animation GLB split, .vrma, model GLB left alone), nodePositionsAt Co-Authored-By: Claude Opus 5.5 (1M context) --- .../editors/AnimStatesWindow.svelte | 24 +++++++++- src/components/editors/AnimationWindow.svelte | 23 +++++++++- src/components/editors/ClipToolsPanel.svelte | 6 ++- src/lib/animatedImports.js | 8 ++-- src/lib/tpanim/clipEdit.js | 46 ++++++++++++++++++- tests/e2e/tpanim-states.test.cjs | 19 ++++++++ 6 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/components/editors/AnimStatesWindow.svelte b/src/components/editors/AnimStatesWindow.svelte index c646d2542..b9f4f62cb 100644 --- a/src/components/editors/AnimStatesWindow.svelte +++ b/src/components/editors/AnimStatesWindow.svelte @@ -6,7 +6,7 @@ // the live reading is local (every peer runs the machine itself). On the shared ToolboxWindow // shell: a floating palette on desktop, a bottom sheet on a phone. import ToolboxWindow from '../ui/ToolboxWindow.svelte'; - import { setAnimGraph, clipInfo, animatedObjects } from '$lib/animatedImports'; + import { setAnimGraph, clipInfo, clipRefsOf, animatedObjects } from '$lib/animatedImports'; import { objectsGroup } from '../../stores/sceneStore'; import { locomotionPreset, normalizeAnimGraph, OPS } from '$lib/tpanim/animGraph.js'; @@ -47,8 +47,28 @@ await setAnimGraph(uuid, next); version++; } + /** + * The preset, made GAME-READY: a walk or run that travels (root motion) tells the blend its own + * pace — so the feet keep up with the controller instead of sliding — and is switched to + * In place, so the controller (not the clip) moves the character. + */ async function preset() { - await setAnimGraph(uuid, locomotionPreset(clips)); + const g = locomotionPreset(clips); + const move = g?.states.find((/** @type {any} */ s) => s.blend); + const refs = clipRefsOf(root); + if (move) { + const { clipFactsFor } = await import('$lib/tpanim/clipEdit.js'); + const { updateClipRef } = await import('$lib/tpanim/clipAssets.js'); + for (const [slot, key] of /** @type {const} */ ([['walk', 'walkSpeed'], ['run', 'runSpeed']])) { + const ref = refs.find((r) => r.name === move.blend[slot]); + const f = ref ? await clipFactsFor(ref.hash) : null; + if (!f || f.travel < 0.1 || !(f.duration > 0)) continue; + move.blend[key] = Math.round((f.travel / f.duration) * 100) / 100; + if (ref && ref.mode !== 'inplace') updateClipRef(uuid, ref.name, { mode: 'inplace' }); + } + if (move.blend.runSpeed <= move.blend.walkSpeed) move.blend.runSpeed = move.blend.walkSpeed * 2; + } + await setAnimGraph(uuid, g); version++; } async function removeGraph() { diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index 8ffe80744..8140094b4 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -14,7 +14,7 @@ // undo entry and ONE broadcast instead of dozens. import { get } from 'svelte/store'; import { untrack } from 'svelte'; - import { selectedObject } from '../../stores/sceneStore'; + import { selectedObject, objectsGroup } from '../../stores/sceneStore'; import { animationClose } from '../../stores/appStore.js'; import { animations, playback, playheads, CHANNELS, EASINGS, STEPPED, channelLabel, isRotChannel, @@ -59,6 +59,13 @@ // live-follow the primary selection (keeps a truthy [] before the first select) const target = $derived($selectedObject && $selectedObject.uuid ? $selectedObject : null); + // 57 M12: the MODEL a selected part belongs to (its top-level object), or null for a top-level object + const partOf = $derived.by(() => { + let o = target; + if (!o || !o.parent || o.parent === $objectsGroup) return null; + while (o.parent && o.parent !== $objectsGroup) o = o.parent; + return o.parent === $objectsGroup ? o : null; + }); // the ACTIVE clip of the object's set, normalized (a v1 save migrates on read) const anim = $derived.by(() => { $animations; // the store is the dependency; activeClip reads it with get() @@ -2135,6 +2142,20 @@ onpointermove={doClipsResize} onpointerup={endClipsResize} >
+ + {#if partOf} + + {/if} {/if} diff --git a/src/components/editors/ClipToolsPanel.svelte b/src/components/editors/ClipToolsPanel.svelte index b4df7359f..fb57693df 100644 --- a/src/components/editors/ClipToolsPanel.svelte +++ b/src/components/editors/ClipToolsPanel.svelte @@ -10,7 +10,6 @@ import { onDestroy, untrack } from 'svelte'; import { clipRefsOf, embeddedClipsOf, setPoseDriver, clearPoseDriver } from '$lib/animatedImports'; import { objectsGroup } from '../../stores/sceneStore'; - import AnimStatesWindow from './AnimStatesWindow.svelte'; /** @type {{uuid: string, clips: {name: string, duration: number, asset?: string}[], clipState: any}} */ let { uuid, clips, clipState } = $props(); @@ -274,7 +273,10 @@ > {#if statesOpen} - (statesOpen = false)} /> + + {#await import('./AnimStatesWindow.svelte') then m} + (statesOpen = false)} /> + {/await} {/if} diff --git a/src/lib/animatedImports.js b/src/lib/animatedImports.js index c7b0e6b84..ef55431b0 100644 --- a/src/lib/animatedImports.js +++ b/src/lib/animatedImports.js @@ -204,7 +204,7 @@ export function addClipAction(uuid, clip, hash, key = hash) { animatedObjects.update((map) => ({ ...map, [uuid]: next })); if (next.playing && next.clip === clip.name) { Object.values(record.actions).forEach((action) => action.stop()); - record.actions[clip.name].play(); + record.actions[clip.name].setEffectiveWeight(1).play(); } } return true; @@ -881,14 +881,16 @@ export function setAnimationState(uuid, next, replicate = true) { Object.entries(record.actions).forEach(([name, action]) => { if (name !== state.from) action.stop(); }); - record.actions[state.clip]?.play(); + // 57 M3: stop() keeps an action's weight, so one left mid-crossfade would play half-strength + record.actions[state.clip]?.setEffectiveWeight(1).play(); } // 33 P2: nothing autoplays at registration any more, so the FIRST play is what schedules // the action — without this a press (or a restored `playing: true`) changed the state and // moved nothing if (state.playing && state.clip && !record.actions[state.clip]?.isScheduled()) { Object.values(record.actions).forEach((action) => action.stop()); - record.actions[state.clip]?.play(); + // 57 M3: stop() keeps an action's weight, so one left mid-crossfade would play half-strength + record.actions[state.clip]?.setEffectiveWeight(1).play(); } if (!state.playing && next.playing === false) record.mixer.setTime(0); if (replicate) { diff --git a/src/lib/tpanim/clipEdit.js b/src/lib/tpanim/clipEdit.js index 4267b28f4..b91a5b07a 100644 --- a/src/lib/tpanim/clipEdit.js +++ b/src/lib/tpanim/clipEdit.js @@ -11,7 +11,7 @@ import { addItemFromBytes, itemByHash } from '../explorer'; import { sendAsset } from '../assetShare'; import { objectsGroup } from '../../stores/sceneStore'; import { showToast } from '../../stores/appStore'; -import { clipRefsOf, setClipRefs, embeddedClipsOf, threeClipOf, restPoseOf, hasAnimatedImport } from '../animatedImports'; +import { clipRefsOf, setClipRefs, embeddedClipsOf, threeClipOf, restPoseOf, hasAnimatedImport, ensureClipModel } from '../animatedImports'; import { encodeTpanim, tpFromThreeClip, toHumanoid, clipFacts } from './format.js'; import { loopSeam, makeLoop, trimClip, splitClip, joinClips, joinProblem, rootTravel } from './clipOps.js'; import { loadClip } from './clipAssets.js'; @@ -197,3 +197,47 @@ export async function extractClips(uuid, opts = {}) { showToast('Extracted ' + written + ' clip' + (written === 1 ? '' : 's') + ' to the Explorer'); return written; } + +/** + * 57 M12: KEYS ON PARTS -> ONE CLIP FILE. The Animation window keys a model's parts (its child + * nodes) as authored clips, one per part; this bakes every part's clip called `clipName` into ONE + * node-name `.tpanim` on the MODEL (the parts' rest nodes + their tracks), saves it to the + * Explorer and assigns it to the model — so the movement travels and plays like any clip file, + * and other copies of the model can use it. The authored keys stay as they were. + * @param {string} modelUuid @param {string} clipName @returns {Promise<{name: string, hash: string, parts: number} | null>} + */ +export async function partsKeysToClip(modelUuid, clipName) { + const root = rootOf(modelUuid); + if (!root) return null; + const THREE = await import('three'); + const ap = await import('../animationPreview'); + /** @type {any[]} */ + const tracks = []; + let duration = 0; + let parts = 0; + root.traverse((/** @type {any} */ o) => { + if (o === root) return; + const set = ap.getAnimSet(o.uuid); + const clip = set ? Object.values(set.clips).find((/** @type {any} */ c) => c.name === clipName) : null; + if (!clip) return; + const baked = ap.clipToThreeClip(o, clip, { name: clipName }); + if (!baked) return; + // a look channel (visible) is not part of a clip file + tracks.push(...baked.tracks.filter((/** @type {any} */ t) => !t.name.endsWith('.visible'))); + duration = Math.max(duration, baked.duration); + parts++; + }); + if (!tracks.length) { + showToast('No part of "' + (root.name || 'this model') + '" has keys in a clip called "' + clipName + '"'); + return null; + } + ensureClipModel(root); + const merged = new THREE.AnimationClip(clipName, duration, tracks); + const tp = tpFromThreeClip(root, merged, { rest: restPoseOf(root.uuid), provenance: { tool: 'animation window', source: root.name || 'model', date: new Date().toISOString().slice(0, 10) } }); + const saved = await saveClip(tp, (root.name ? root.name + ' - ' : '') + clipName); + if (!saved) return null; + const refs = clipRefsOf(root).filter((r) => r.name !== clipName); + setClipRefs(modelUuid, [...refs, { hash: saved.hash, name: clipName }]); + showToast('Saved "' + clipName + '" as a clip file on "' + (root.name || 'the model') + '" (' + parts + ' part' + (parts === 1 ? '' : 's') + ')'); + return { name: clipName, hash: saved.hash, parts }; +} diff --git a/tests/e2e/tpanim-states.test.cjs b/tests/e2e/tpanim-states.test.cjs index 6296ac88b..30139ac36 100644 --- a/tests/e2e/tpanim-states.test.cjs +++ b/tests/e2e/tpanim-states.test.cjs @@ -197,6 +197,25 @@ h.run(async () => { await A.page.evaluate(() => window.__stores.history.redo()); await h.eventually(() => clipOnHeli(B.page), (l) => l.includes('Spin'), 'M12: redo puts it back'); + // ---------- M12: keys on a part in the Animation window -> one clip FILE on the model ---------- + const fromKeys = await A.page.evaluate(async (heli) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const model = g.getObjectByProperty('uuid', heli); + const body = model.getObjectByName('body'); + const ap = s.animationPreview; + ap.createClip(body.uuid, 'Bob'); + const track = ap.addTrack(body.uuid, 'pos.y', body); + ap.updateKey(body.uuid, track, 0, { t: 0, v: 0 }); + ap.updateKey(body.uuid, track, 1, { t: 1, v: 0.5 }); + ap.updateAnim(body.uuid, { duration: 1 }); + const out = await s.clipEdit.partsKeysToClip(heli, 'Bob'); + return { out, clips: s.animatedImports.clipInfo(heli).map((c) => c.name) }; + }, heli); + h.check(fromKeys.out?.parts === 1 && fromKeys.clips.includes('Bob'), `M12: a part's keys become a node-name clip file on the model (${JSON.stringify(fromKeys.out)})`); + await h.eventually(() => clipOnHeli(B.page), (l) => l.includes('Bob'), 'M12: ...and reach B as a clip'); + // ---------- M12: Attach to Socket — a box rides the rotor tip ---------- const box = await A.page.evaluate(async () => { const s = window.__stores; From 0cce6beabe5c26261466f4633f19d9dc0c2ccbf3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 05:55:09 +0300 Subject: [PATCH 15/26] [fix] 57 M12: a parts model whose object a sync REPLACED (same uuid, new instance) re-registers and keeps its transport Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/animatedImports.js | 39 ++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/lib/animatedImports.js b/src/lib/animatedImports.js index ef55431b0..ec326030f 100644 --- a/src/lib/animatedImports.js +++ b/src/lib/animatedImports.js @@ -357,10 +357,14 @@ export function hasClipModel(uuid) { */ export function ensureClipModel(root) { if (!root?.uuid) return false; - if (mixers.has(root.uuid)) return !behaviors.has(root.uuid); + const held = mixers.get(root.uuid); + // a parts model whose object was REPLACED by an object sync (same uuid, new instance) re-registers + if (held?.parts && held.mixer.getRoot() !== root) dropPartsRecord(root.uuid, held, true); + else if (held) return !behaviors.has(root.uuid); const mixer = new THREE.AnimationMixer(root); mixers.set(root.uuid, { mixer, actions: {}, durations: {}, rest: captureRest(root), embedded: [], embeddedOff: false, parts: true }); - animatedObjects.update((map) => ({ ...map, [root.uuid]: { clips: [], clip: '', playing: false, speed: 1 } })); + // a re-registered (replaced) model keeps its transport: its clips rebind and carry on playing + animatedObjects.update((map) => ({ ...map, [root.uuid]: map[root.uuid] ? { ...map[root.uuid], clips: [] } : { clips: [], clip: '', playing: false, speed: 1 } })); return true; } @@ -374,23 +378,30 @@ function sweepPartsModels(time) { for (const child of group.children) { const ud = child.userData; if (ud?.attachTo && !socketFollowers.has(child.uuid)) syncSocketFollower(child); - if (mixers.has(child.uuid) || (!ud?.clipRefs && !ud?.animGraph)) continue; + const held = mixers.get(child.uuid); + if (held?.parts && held.mixer.getRoot() !== child) dropPartsRecord(child.uuid, held, true); + else if (held) continue; + if (!ud?.clipRefs && !ud?.animGraph) continue; if (ensureClipModel(child)) { attachClipRefs(child.uuid); loadGraphRuntime(child); } } - for (const [uuid, record] of mixers) - if (record.parts && !group.getObjectByProperty('uuid', uuid)) { - record.mixer.stopAllAction(); - mixers.delete(uuid); - drivers.delete(uuid); - animatedObjects.update((map) => { - const next = { ...map }; - delete next[uuid]; - return next; - }); - } + for (const [uuid, record] of mixers) if (record.parts && !group.getObjectByProperty('uuid', uuid)) dropPartsRecord(uuid, record); +} + +/** forget a parts model's mixer (its object left, or — `replaced` — a sync swapped the instance) + * @param {string} uuid @param {any} record @param {boolean} [replaced] keep its transport entry */ +function dropPartsRecord(uuid, record, replaced = false) { + record.mixer.stopAllAction(); + mixers.delete(uuid); + drivers.delete(uuid); + if (replaced) return; + animatedObjects.update((map) => { + const next = { ...map }; + delete next[uuid]; + return next; + }); } /** This model's clip transport, for a caller that must not import the store From c2beebc6dd21213abd2fe406d26b80563b566d79 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 09:14:54 +0300 Subject: [PATCH 16/26] 57 clips: the clip tools scroll inside the Animation window's clip pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a short window the tools below the fold (Animation states…, extract) were cut off by the window with no way to reach them; tpanim-states caught it as an off-viewport click. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/editors/ClipToolsPanel.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/editors/ClipToolsPanel.svelte b/src/components/editors/ClipToolsPanel.svelte index fb57693df..8ac130cf5 100644 --- a/src/components/editors/ClipToolsPanel.svelte +++ b/src/components/editors/ClipToolsPanel.svelte @@ -10,6 +10,7 @@ import { onDestroy, untrack } from 'svelte'; import { clipRefsOf, embeddedClipsOf, setPoseDriver, clearPoseDriver } from '$lib/animatedImports'; import { objectsGroup } from '../../stores/sceneStore'; + import { minimalScroll } from '$lib/ui/minimalScroll.js'; /** @type {{uuid: string, clips: {name: string, duration: number, asset?: string}[], clipState: any}} */ let { uuid, clips, clipState } = $props(); @@ -109,7 +110,9 @@ const fmt = (/** @type {number} */ t) => t.toFixed(2) + ' s'; -
+ +
{#if embedded.names.length && !embedded.off}
-
+
{#if !tracks.length}
{clips.length diff --git a/src/components/editors/ClipToolsPanel.svelte b/src/components/editors/ClipToolsPanel.svelte index 8ac130cf5..a9045358b 100644 --- a/src/components/editors/ClipToolsPanel.svelte +++ b/src/components/editors/ClipToolsPanel.svelte @@ -10,7 +10,6 @@ import { onDestroy, untrack } from 'svelte'; import { clipRefsOf, embeddedClipsOf, setPoseDriver, clearPoseDriver } from '$lib/animatedImports'; import { objectsGroup } from '../../stores/sceneStore'; - import { minimalScroll } from '$lib/ui/minimalScroll.js'; /** @type {{uuid: string, clips: {name: string, duration: number, asset?: string}[], clipState: any}} */ let { uuid, clips, clipState } = $props(); @@ -110,9 +109,9 @@ const fmt = (/** @type {number} */ t) => t.toFixed(2) + ' s'; - -
+ +
{#if embedded.names.length && !embedded.off}
{/if}
{#if !clips.length} -

This model has no clips yet — drag animation clips onto it first.

+

This model has no clips yet — the preset fills a humanoid's from the Motion pack; drag clips onto any other model first.

{/if} {#if graph} diff --git a/src/components/editors/AssetBrowser.svelte b/src/components/editors/AssetBrowser.svelte index fb0a52e81..422d30904 100644 --- a/src/components/editors/AssetBrowser.svelte +++ b/src/components/editors/AssetBrowser.svelte @@ -85,7 +85,7 @@ /** 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 listed = $derived(kind.source === 'polyhaven' || kind.source === 'osa' || kind.source === 'motion'); 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); @@ -116,6 +116,13 @@ rows = list.rows; categories = list.categories; noteStale(list); + } else if (kind.source === 'motion') { + // 57 M5: the Motion pack (humanoid .tpanim clips, CC0) + const { motionRows } = await import('$lib/tpanim/motionKit.js'); + const list = await motionRows(fresh); + if (my !== ticket) return; + rows = list.rows; + categories = list.categories; } else { const [page, cats] = await Promise.all([ acgPage({ query: query.trim(), category, fresh }), @@ -259,6 +266,21 @@ if (held) await placeItem(held); } + /** 57 M5: a Motion pack clip goes into the Explorer, a file like any other clip @param {any} row */ + async function addMotion(row) { + const key = rowKey(row); + placing = { ...placing, [key]: true }; + cardError = { ...cardError, [key]: '' }; + try { + const { addMotionClip } = await import('$lib/tpanim/motionKit.js'); + await addMotionClip(row.id); + showToast(row.name + ' added to the Explorer: drag it onto a character to play it'); + } catch (err) { + cardError = { ...cardError, [key]: errorText(err) }; + } finally { + placing = { ...placing, [key]: false }; + } + } /** 57 M5: an avatar goes straight into the scene as a VRM character, its credit kept @type {Record} */ let placing = $state({}); /** @param {any} row */ @@ -481,7 +503,11 @@ {@const key = rowKey(row)} {@const job = $cc0Jobs[key]} {@const item = - row.kind === 'models' || row.kind === 'hdris' ? heldItem(row, $explorerItems) : null} + row.kind === 'models' || row.kind === 'hdris' + ? heldItem(row, $explorerItems) + : row.kind === 'motion' + ? $explorerItems.find((/** @type {any} */ it) => it.kind === 'anim' && it.name === row.name + '.tpanim') + : null} {@const preset = row.kind === 'textures' || row.kind === 'materials' ? heldPreset(row, $materialPresets) @@ -502,6 +528,8 @@ referrerpolicy="no-referrer" onerror={(e) => /** @type {any} */ (e.currentTarget).classList.add('ab-noimg')} /> + {:else if row.kind === 'motion'} +