@homie-rocks/studio 0.24.5 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/optimise.mjs CHANGED
@@ -134,13 +134,31 @@ export async function measureDoc(doc) {
134
134
  return { name: t.getName() || t.getURI() || '', mimeType: t.getMimeType(), px: px ? [px[0], px[1]] : null, bytes: t.getImage()?.byteLength ?? 0, gpuBytes: px ? Math.round(px[0] * px[1] * 4 * (4 / 3)) : 0, slots };
135
135
  });
136
136
  let box = null;
137
+ const skins = root.listSkins();
137
138
  if (scene) {
138
- const b = fn.getBounds(scene);
139
+ // A skinned model is where its bones put it (its mesh node's own transform does nothing): measured skinned.
140
+ const b = skins.length ? skinnedBounds(doc) ?? fn.getBounds(scene) : fn.getBounds(scene);
139
141
  if (Number.isFinite(b.min[0]) && Number.isFinite(b.max[0])) box = { min: b.min.map((v) => round(v)), max: b.max.map((v) => round(v)), size: b.max.map((v, i) => round(v - b.min[i])) };
140
142
  }
141
- const skins = root.listSkins();
143
+ // Skinning: vertices a skin moves, and the most joints one vertex reads (three.js reads JOINTS_0 only: 4).
144
+ let skinnedVertices = 0; let influences = 0;
145
+ for (const node of root.listNodes()) {
146
+ if (!node.getSkin() || !node.getMesh()) continue;
147
+ for (const prim of node.getMesh().listPrimitives()) {
148
+ skinnedVertices += prim.getAttribute('POSITION')?.getCount() ?? 0;
149
+ influences = Math.max(influences, prim.getAttribute('JOINTS_1') ? 8 : prim.getAttribute('JOINTS_0') ? 4 : 0);
150
+ }
151
+ }
152
+ const anims = root.listAnimations();
153
+ const keys = anims.reduce((n, a) => n + a.listSamplers().reduce((m, x) => m + x.getInput().getCount(), 0), 0);
154
+ const clipSeconds = anims.reduce((n, a) => n + a.listSamplers().reduce((m, x) => Math.max(m, x.getInput().getMax([])[0] ?? 0), 0), 0);
155
+ const channels = anims.reduce((n, a) => n + a.listChannels().length, 0);
142
156
  return {
143
157
  triangles, vertices, drawCalls,
158
+ skinnedVertices, influences,
159
+ clipKeys: keys, clipSeconds: round(clipSeconds, 2), clipChannels: channels,
160
+ // Keys a second per channel: 30 is the house rate (resampled clips usually sit well under it).
161
+ clipRate: channels && clipSeconds ? round(keys / channels / (clipSeconds / Math.max(1, anims.length)), 1) : 0,
144
162
  materials: seenMaterials.size,
145
163
  textures,
146
164
  textureGpuBytes: textures.reduce((s, t) => s + t.gpuBytes, 0),
@@ -155,6 +173,52 @@ export async function measureDoc(doc) {
155
173
  };
156
174
  }
157
175
 
176
+ /**
177
+ * The rest-pose bounds of a model's skinned meshes, as a renderer draws them: every vertex through its joints
178
+ * (joint world matrix x inverse bind matrix, weighted), never through the mesh node's own transform (glTF ignores it
179
+ * for a skinned mesh). A rig in centimetres under a 0.01 armature, or one scaled by a wrapper node, measures true.
180
+ * Returns { min, max } or null when nothing is skinned. Up to 40,000 vertices are read (evenly spread).
181
+ */
182
+ export function skinnedBounds(doc) {
183
+ const min = [Infinity, Infinity, Infinity]; const max = [-Infinity, -Infinity, -Infinity];
184
+ const v = [0, 0, 0]; const j = [0, 0, 0, 0]; const w = [0, 0, 0, 0];
185
+ let any = false;
186
+ for (const node of doc.getRoot().listNodes()) {
187
+ const skin = node.getSkin(); const mesh = node.getMesh();
188
+ if (!skin || !mesh) continue;
189
+ const joints = skin.listJoints();
190
+ const ibm = skin.getInverseBindMatrices()?.getArray() ?? null;
191
+ const mats = joints.map((jn, k) => mul4(jn.getWorldMatrix(), ibm ? Array.from(ibm.subarray(k * 16, k * 16 + 16)) : IDENTITY));
192
+ for (const prim of mesh.listPrimitives()) {
193
+ const P = prim.getAttribute('POSITION'); const J = prim.getAttribute('JOINTS_0'); const W = prim.getAttribute('WEIGHTS_0');
194
+ if (!P || !J || !W) continue;
195
+ const step = Math.max(1, Math.floor(P.getCount() / 40000));
196
+ for (let i = 0; i < P.getCount(); i += step) {
197
+ P.getElement(i, v); J.getElement(i, j); W.getElement(i, w);
198
+ let x = 0; let y = 0; let z = 0; let sum = 0;
199
+ for (let k = 0; k < 4; k++) {
200
+ const m = mats[j[k]]; const wk = w[k];
201
+ if (!m || !wk) continue;
202
+ x += wk * (m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12]); y += wk * (m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13]); z += wk * (m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]);
203
+ sum += wk;
204
+ }
205
+ if (sum <= 0) continue;
206
+ x /= sum; y /= sum; z /= sum;
207
+ if (x < min[0]) min[0] = x; if (y < min[1]) min[1] = y; if (z < min[2]) min[2] = z;
208
+ if (x > max[0]) max[0] = x; if (y > max[1]) max[1] = y; if (z > max[2]) max[2] = z;
209
+ any = true;
210
+ }
211
+ }
212
+ }
213
+ return any ? { min, max } : null;
214
+ }
215
+ const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
216
+ function mul4(a, b) {
217
+ const o = new Array(16);
218
+ for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) { let t = 0; for (let k = 0; k < 4; k++) t += a[k * 4 + r] * b[c * 4 + k]; o[c * 4 + r] = t; }
219
+ return o;
220
+ }
221
+
158
222
  /** The Khronos glTF-Validator's verdict: { errors, warnings, messages: [first few] } (null when it is not installed). */
159
223
  export async function validateModel(bytes) {
160
224
  const { validator } = await modelTools();
@@ -289,7 +353,19 @@ export async function optimiseModel(input, { out = null, triangles = 1500, textu
289
353
  ops.push(`scale ${round(s, 4)} (to ${height} m)`);
290
354
  }
291
355
  }
292
- await doc.transform(fn.center({ pivot: 'below' }));
356
+ if (hasRig && doc.getRoot().listSkins().length) {
357
+ // A skinned model's pivot is set on a wrapper over everything (its bones carry its mesh): gltf-transform's center
358
+ // measures a skinned mesh by its node, which a rig in centimetres gets wrong a hundredfold.
359
+ const b = skinnedBounds(doc);
360
+ if (b) {
361
+ const top = scene.listChildren();
362
+ const wrap = top.length === 1 && top[0].getName() === 'homie-scale' ? top[0] : null;
363
+ const holder = wrap ?? doc.createNode('homie-pivot');
364
+ if (!wrap) { for (const child of scene.listChildren()) { scene.removeChild(child); holder.addChild(child); } scene.addChild(holder); }
365
+ const t = holder.getTranslation();
366
+ holder.setTranslation([t[0] - (b.min[0] + b.max[0]) / 2, t[1] - b.min[1], t[2] - (b.min[2] + b.max[2]) / 2]);
367
+ }
368
+ } else await doc.transform(fn.center({ pivot: 'below' }));
293
369
  ops.push('center bottom');
294
370
 
295
371
  // Simplify to the triangle budget, loosening the error until it fits (a closed silhouette first).
package/lib/render3d.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  * const jpg = await r.swatch(tokens, models, { title }) // a data: URL
8
8
  * const l = await r.lineup(models, tokens) // { front, quarter, silhouettes, rows }
9
9
  * const t = await r.thumb({ id, glb }) // { image, size }
10
+ * const f = await r.frames({ id, glb, anims }, tokens, { verbs: ['idle', 'run'], count: 12 }) // a character's clips, frame by frame
10
11
  * });
11
12
  *
12
13
  * Models go in as { id, glb: <base64>, label }. Nothing is fetched but Google Fonts (the swatch's title font).
@@ -72,6 +73,7 @@ export async function withRenderer(fn, { log = () => {} } = {}) {
72
73
  swatch: (tokens, models = [], opts = {}) => call('swatch', tokens, models, opts),
73
74
  lineup: (models, tokens, opts = {}) => call('lineup', models, tokens, opts),
74
75
  thumb: (model, opts = {}) => call('thumb', model, opts),
76
+ frames: (model, tokens, opts = {}) => call('frames', model, tokens, opts),
75
77
  });
76
78
  } finally {
77
79
  await browser.close().catch(() => {});
package/lib/rig.mjs ADDED
@@ -0,0 +1,431 @@
1
+ /**
2
+ * THE SKELETON STANDARD: one bone vocabulary for every character a studio makes, so clips, sockets, look-at and foot
3
+ * planting work on any of them (free, on this computer).
4
+ *
5
+ * Families
6
+ * humanoid people: the VRM 1.0 humanoid bone names (hips, spine, chest, neck, head, leftUpperArm, leftLowerArm,
7
+ * leftHand, leftUpperLeg, leftLowerLeg, leftFoot, leftToes, and the right side), 4 influences a vertex.
8
+ * In: Mixamo names (mixamorig:LeftArm, the de facto standard: Tripo's mixamo spec, Meshy, Rokoko),
9
+ * KayKit (upperarm.l), Blender (upper_arm.L, thigh.L), Unreal (upperarm_l, thigh_l, calf_l).
10
+ * mini chibi people of one piece per limb (Kenney's mini and blocky characters): hips, spine, head, and an
11
+ * upper arm and upper leg a side. Humanoid clips retarget onto it (a whole leg follows the thigh).
12
+ * quadruped four legs (Kenney's cube pets): body, head, neck, tail, frontLeftLeg, frontRightLeg, backLeftLeg,
13
+ * backRightLeg. Clips come from its own species; humanoid clips never fit it.
14
+ * parts anything else that moves by its nodes: its own clips only.
15
+ * A rig is skinned (bones bend a mesh) or rigid (each part a node that moves: Kenney's blocky characters and pets);
16
+ * both play clips through three.js's AnimationMixer the same way.
17
+ *
18
+ * skeletonOf(doc) the family, the map from the file's names to the standard, what is missing, the helper
19
+ * bones that move nothing, and a fingerprint: characters with one fingerprint share one
20
+ * clip library (public/anims/<skeleton>.glb)
21
+ * normaliseRig(doc, opts) the shipped character: joints renamed to the standard, helper bones that move nothing
22
+ * removed, every skinned part and every held thing (a sword under the hand) merged into ONE
23
+ * skinned mesh (one draw call; `keep` picks which held things stay: part swaps), clips taken out
24
+ *
25
+ * Bone names are matched after lower-casing and dropping separators, so "mixamorig:LeftUpLeg", "LeftUpLeg" and
26
+ * "left_up_leg" are one name. Fingers and anything unmatched keep their own names.
27
+ */
28
+ import { createHash } from 'node:crypto';
29
+
30
+ /** The VRM 1.0 humanoid bones Homie uses (fingers keep their own names; three.js reads 4 influences, so do we). */
31
+ export const HUMANOID = Object.freeze([
32
+ 'hips', 'spine', 'chest', 'upperChest', 'neck', 'head', 'jaw', 'leftEye', 'rightEye',
33
+ 'leftShoulder', 'leftUpperArm', 'leftLowerArm', 'leftHand', 'rightShoulder', 'rightUpperArm', 'rightLowerArm', 'rightHand',
34
+ 'leftUpperLeg', 'leftLowerLeg', 'leftFoot', 'leftToes', 'rightUpperLeg', 'rightLowerLeg', 'rightFoot', 'rightToes',
35
+ ]);
36
+ /** VRM's required fifteen: a rig missing one of these is not a humanoid. */
37
+ export const HUMANOID_REQUIRED = Object.freeze(['hips', 'spine', 'head', 'leftUpperArm', 'leftLowerArm', 'leftHand', 'rightUpperArm', 'rightLowerArm', 'rightHand', 'leftUpperLeg', 'leftLowerLeg', 'leftFoot', 'rightUpperLeg', 'rightLowerLeg', 'rightFoot']);
38
+ export const MINI = Object.freeze(['hips', 'spine', 'head', 'leftUpperArm', 'rightUpperArm', 'leftUpperLeg', 'rightUpperLeg']);
39
+ export const QUADRUPED = Object.freeze(['body', 'neck', 'head', 'tail', 'frontLeftLeg', 'frontRightLeg', 'backLeftLeg', 'backRightLeg']);
40
+ /** Where held things go: the standard socket names and the bones that carry them when a rig has no slot bone. */
41
+ export const SOCKETS = Object.freeze({ rightHand: ['rightHandSlot', 'rightHand'], leftHand: ['leftHandSlot', 'leftHand'], head: ['headSlot', 'head'], back: ['upperChest', 'chest', 'spine'] });
42
+
43
+ export const FAMILIES = Object.freeze({
44
+ humanoid: { label: 'Homie humanoid (VRM names)', bones: HUMANOID, required: HUMANOID_REQUIRED, note: 'people: one vocabulary, so one clip set retargets onto any of them' },
45
+ mini: { label: 'Mini humanoid (one piece per limb)', bones: MINI, required: ['hips', 'head', 'leftUpperArm', 'rightUpperArm', 'leftUpperLeg', 'rightUpperLeg'], note: 'chibi people: humanoid clips retarget onto them, a whole limb following its upper bone' },
46
+ quadruped: { label: 'Quadruped (four legs)', bones: QUADRUPED, required: ['body', 'frontLeftLeg', 'frontRightLeg', 'backLeftLeg', 'backRightLeg'], note: 'animals: their own species\' clips' },
47
+ parts: { label: 'Moving parts', bones: [], required: [], note: 'its own clips only' },
48
+ });
49
+
50
+ // Prefixes rigs put on every bone ("mixamorig:", "Armature|", "DEF-"); "rig" only with a separator, or "rightHand" loses it.
51
+ const key = (s) => String(s ?? '').toLowerCase().replace(/^(mixamorig\d*[:_]?|(armature|rig|bip0?1|def|org|mch)[:_. |-]+)/, '').replace(/[^a-z0-9]/g, '');
52
+
53
+ /*
54
+ * Name tables, by the key above. Order matters only inside a table: a rig names each bone once.
55
+ * Sources: Mixamo's mixamorig set (three-vrm's mixamoVRMRigMap, MIT), KayKit's rig (its CC0 packs), Blender's
56
+ * metarig and Rigify DEF bones, Unreal's mannequin, Kenney's mini and blocky characters and cube pets.
57
+ */
58
+ const HUMANOID_NAMES = {
59
+ hips: ['hips', 'hip', 'pelvis', 'root_pelvis'],
60
+ spine: ['spine', 'spine01', 'spine1', 'spine001', 'torso', 'abdomen'],
61
+ chest: ['spine1', 'spine02', 'spine2', 'chest', 'spine002', 'spine003'],
62
+ upperChest: ['spine2', 'spine03', 'upperchest', 'spine004'],
63
+ neck: ['neck', 'neck01', 'neck1'],
64
+ head: ['head'],
65
+ jaw: ['jaw'],
66
+ leftEye: ['lefteye', 'eyel', 'eyeleft'],
67
+ rightEye: ['righteye', 'eyer', 'eyeright'],
68
+ leftShoulder: ['leftshoulder', 'shoulderl', 'claviclel', 'leftclavicle', 'lshoulder'],
69
+ leftUpperArm: ['leftarm', 'upperarml', 'leftupperarm', 'lupperarm', 'larm', 'armleft'],
70
+ leftLowerArm: ['leftforearm', 'lowerarml', 'forearml', 'leftlowerarm', 'lforearm'],
71
+ leftHand: ['lefthand', 'wristl', 'handl', 'lhand'],
72
+ rightShoulder: ['rightshoulder', 'shoulderr', 'clavicler', 'rightclavicle', 'rshoulder'],
73
+ rightUpperArm: ['rightarm', 'upperarmr', 'rightupperarm', 'rupperarm', 'rarm', 'armright'],
74
+ rightLowerArm: ['rightforearm', 'lowerarmr', 'forearmr', 'rightlowerarm', 'rforearm'],
75
+ rightHand: ['righthand', 'wristr', 'handr', 'rhand'],
76
+ leftUpperLeg: ['leftupleg', 'upperlegl', 'thighl', 'leftthigh', 'leftupperleg', 'lthigh', 'legleft'],
77
+ leftLowerLeg: ['leftleg', 'lowerlegl', 'shinl', 'calfl', 'leftshin', 'leftlowerleg', 'lcalf', 'leftcalf'],
78
+ leftFoot: ['leftfoot', 'footl', 'lfoot'],
79
+ leftToes: ['lefttoebase', 'toesl', 'toel', 'balll', 'lefttoe', 'ltoe'],
80
+ rightUpperLeg: ['rightupleg', 'upperlegr', 'thighr', 'rightthigh', 'rightupperleg', 'rthigh', 'legright'],
81
+ rightLowerLeg: ['rightleg', 'lowerlegr', 'shinr', 'calfr', 'rightshin', 'rightlowerleg', 'rcalf', 'rightcalf'],
82
+ rightFoot: ['rightfoot', 'footr', 'rfoot'],
83
+ rightToes: ['righttoebase', 'toesr', 'toer', 'ballr', 'righttoe', 'rtoe'],
84
+ leftHandSlot: ['handslotl', 'lefthandslot', 'weaponl', 'propl'],
85
+ rightHandSlot: ['handslotr', 'righthandslot', 'weaponr', 'propr'],
86
+ };
87
+ /** KayKit and Unreal suffixes (".l", "_l") collide with Mixamo's prefixes after keying; these settle it. */
88
+ const SUFFIXED = { upperarm: 'UpperArm', lowerarm: 'LowerArm', forearm: 'LowerArm', upperleg: 'UpperLeg', lowerleg: 'LowerLeg', thigh: 'UpperLeg', calf: 'LowerLeg', shin: 'LowerLeg', foot: 'Foot', toes: 'Toes', toe: 'Toes', ball: 'Toes', wrist: 'Hand', hand: 'Hand', clavicle: 'Shoulder', shoulder: 'Shoulder', upperarmtwist: null };
89
+
90
+ const MINI_NAMES = { hips: ['root', 'hips', 'body'], spine: ['torso', 'spine', 'chest'], head: ['head'], leftUpperArm: ['armleft', 'leftarm'], rightUpperArm: ['armright', 'rightarm'], leftUpperLeg: ['legleft', 'leftleg'], rightUpperLeg: ['legright', 'rightleg'] };
91
+ const QUAD_NAMES = { body: ['body', 'spine', 'torso'], neck: ['neck'], head: ['head'], tail: ['tail', 'tail01', 'tail1'], frontLeftLeg: ['legfrontleft', 'frontleftleg', 'legfl', 'frontlegl', 'leftfrontleg'], frontRightLeg: ['legfrontright', 'frontrightleg', 'legfr', 'frontlegr', 'rightfrontleg'], backLeftLeg: ['legbackleft', 'backleftleg', 'legbl', 'backlegl', 'hindlegl', 'leftbackleg', 'leftbackleg'], backRightLeg: ['legbackright', 'backrightleg', 'legbr', 'backlegr', 'hindlegr', 'rightbackleg'] };
92
+
93
+ /** A name read with a side suffix (KayKit "upperarm.l", Unreal "thigh_l", Blender "thigh.L"): its standard name. */
94
+ function suffixed(raw) {
95
+ const m = /^(?:.*?[:_. -])?([a-z]+?)(?:[_. -]?0*\d*)?[_. -](l|r|left|right)$/i.exec(String(raw).toLowerCase().replace(/^(mixamorig\d*|def|org|mch)[:_. -]*/, ''));
96
+ if (!m) return null;
97
+ const base = m[1].replace(/[^a-z]/g, '');
98
+ const side = m[2].startsWith('l') ? 'left' : 'right';
99
+ if (base === 'handslot' || base === 'weapon' || base === 'prop') return `${side}HandSlot`;
100
+ const part = SUFFIXED[base];
101
+ return part ? `${side}${part}` : null;
102
+ }
103
+
104
+ /** Map a list of joint names to one family's standard names: { map: { std: name }, score }. */
105
+ function mapNames(names, table, { useSuffix = false } = {}) {
106
+ const map = {};
107
+ const taken = new Set();
108
+ // Exact keys first, in the table's order (so Mixamo's Spine/Spine1/Spine2 land on spine/chest/upperChest).
109
+ const keyed = names.map((n) => ({ n, k: key(n) }));
110
+ for (const [std, alts] of Object.entries(table)) {
111
+ // A rig already normalised names its bones by the standard itself.
112
+ for (const alt of [std.toLowerCase(), ...alts]) {
113
+ const hit = keyed.find((x) => x.k === alt && !taken.has(x.n));
114
+ if (hit) { map[std] = hit.n; taken.add(hit.n); break; }
115
+ }
116
+ }
117
+ if (useSuffix) {
118
+ for (const n of names) {
119
+ if (taken.has(n)) continue;
120
+ const std = suffixed(n);
121
+ if (std && !map[std] && (table[std] || /HandSlot$/.test(std))) { map[std] = n; taken.add(n); }
122
+ }
123
+ }
124
+ return map;
125
+ }
126
+
127
+ /**
128
+ * The family and the standard-name map of a list of joint names (or node names of a rigid rig).
129
+ * Returns { family, map: { std: original }, missing: [std], extra: [original] }.
130
+ */
131
+ export function mapSkeleton(names, parentOf = null) {
132
+ const hum = mapNames(names, HUMANOID_NAMES, { useSuffix: true });
133
+ // The spine chain by the tree, not by its numbers: Mixamo counts Spine, Spine1, Spine2 upward, Meshy counts Spine02,
134
+ // Spine01, Spine upward. Whatever they are called, the joints between the hips and the neck (or head) are spine,
135
+ // chest and upper chest, in that order from the hips.
136
+ if (parentOf && hum.hips && (hum.neck || hum.head)) {
137
+ const chain = [];
138
+ let at = parentOf[hum.neck ?? hum.head];
139
+ while (at && at !== hum.hips && chain.length < 8) { chain.unshift(at); at = parentOf[at]; }
140
+ if (at === hum.hips && chain.length) {
141
+ for (const k of ['spine', 'chest', 'upperChest']) delete hum[k];
142
+ ['spine', 'chest', 'upperChest'].forEach((k, i) => { if (chain[i]) hum[k] = chain[i]; });
143
+ }
144
+ }
145
+ // Mixamo's chain is Spine, Spine1, Spine2: spine, chest, upperChest. KayKit's is spine, chest.
146
+ const humMissing = HUMANOID_REQUIRED.filter((b) => !hum[b]);
147
+ if (humMissing.length <= 1 && hum.hips && hum.leftUpperLeg && hum.leftLowerLeg) {
148
+ return { family: 'humanoid', map: hum, missing: humMissing, extra: names.filter((n) => !Object.values(hum).includes(n)) };
149
+ }
150
+ const quad = mapNames(names, QUAD_NAMES);
151
+ if (FAMILIES.quadruped.required.every((b) => quad[b])) return { family: 'quadruped', map: quad, missing: QUADRUPED.filter((b) => !quad[b]), extra: names.filter((n) => !Object.values(quad).includes(n)) };
152
+ const mini = mapNames(names, MINI_NAMES);
153
+ if (FAMILIES.mini.required.every((b) => mini[b])) return { family: 'mini', map: mini, missing: MINI.filter((b) => !mini[b]), extra: names.filter((n) => !Object.values(mini).includes(n)) };
154
+ return { family: names.length ? 'parts' : 'none', map: {}, missing: [], extra: names };
155
+ }
156
+
157
+ /* ------------------------------------------------------------------ reading a rig from a glTF Document */
158
+
159
+ const parentsOf = (root) => { const p = new Map(); for (const n of root.listNodes()) for (const c of n.listChildren()) p.set(c, n); return p; };
160
+
161
+ /**
162
+ * The joints of a Document's character: a skin's joints (skinned), else the nodes its clips move (rigid parts).
163
+ * Returns { skinned, joints: Node[], skin } (joints in hierarchy order, parents first).
164
+ */
165
+ export function jointsOf(doc) {
166
+ const root = doc.getRoot();
167
+ const skins = root.listSkins();
168
+ if (skins.length) {
169
+ // Characters with several skins (rare) share one skeleton in practice: take the biggest.
170
+ const skin = skins.reduce((a, b) => (b.listJoints().length > a.listJoints().length ? b : a));
171
+ return { skinned: true, skin, joints: depthOrder(root, skin.listJoints()) };
172
+ }
173
+ const moved = new Set();
174
+ for (const a of root.listAnimations()) for (const c of a.listChannels()) { const n = c.getTargetNode(); if (n) moved.add(n); }
175
+ // Every part under a moving part is a part too (a head no clip happens to turn is still the head).
176
+ for (const n of [...moved]) { const walk = (x) => { for (const c of x.listChildren()) { if (!moved.has(c) && (c.getMesh() || c.listChildren().length)) moved.add(c); walk(c); } }; walk(n); }
177
+ // A rigid rig whose clips went to its clip library: normaliseRig marked its moving parts.
178
+ if (!moved.size) for (const n of root.listNodes()) if (n.getExtras()?.homie?.joint) moved.add(n);
179
+ return { skinned: false, skin: null, joints: depthOrder(root, [...moved]) };
180
+ }
181
+
182
+ function depthOrder(root, nodes) {
183
+ const parents = parentsOf(root);
184
+ const depth = (n) => { let d = 0; let p = parents.get(n); while (p) { d += 1; p = parents.get(p); } return d; };
185
+ return [...nodes].sort((a, b) => depth(a) - depth(b));
186
+ }
187
+
188
+ /** Total vertex weight per joint index of a skin (a joint that moves nothing has 0). */
189
+ export function jointWeights(doc, skin) {
190
+ const joints = skin.listJoints();
191
+ const used = new Float64Array(joints.length);
192
+ const j = [0, 0, 0, 0]; const w = [0, 0, 0, 0];
193
+ let influences = 4;
194
+ for (const node of doc.getRoot().listNodes()) {
195
+ if (node.getSkin() !== skin || !node.getMesh()) continue;
196
+ for (const p of node.getMesh().listPrimitives()) {
197
+ if (p.getAttribute('JOINTS_1')) influences = 8;
198
+ const J = p.getAttribute('JOINTS_0'); const W = p.getAttribute('WEIGHTS_0');
199
+ if (!J || !W) continue;
200
+ for (let i = 0; i < J.getCount(); i++) { J.getElement(i, j); W.getElement(i, w); for (let k = 0; k < 4; k++) if (j[k] < used.length) used[j[k]] += w[k]; }
201
+ }
202
+ }
203
+ return { used, influences };
204
+ }
205
+
206
+ /**
207
+ * skeletonOf(doc): what kind of rig a character has. { family, skinned, bones (joints), deform (joints that move a
208
+ * vertex, or every joint of a rigid rig), map: { std: original }, missing, helpers (joints that move nothing and lead
209
+ * to nothing that does), influences, fingerprint, id }.
210
+ */
211
+ export function skeletonOf(doc) {
212
+ const { skinned, skin, joints } = jointsOf(doc);
213
+ if (!joints.length) return { family: 'none', skinned: false, bones: 0, deform: 0, map: {}, missing: [], helpers: [], influences: 0, fingerprint: null, id: null, joints: [] };
214
+ const names = joints.map((n) => n.getName());
215
+ const tree = parentsOf(doc.getRoot());
216
+ const parentOf = Object.fromEntries(joints.map((n) => [n.getName(), tree.get(n)?.getName() ?? null]));
217
+ const { family, map, missing } = mapSkeleton(names, parentOf);
218
+ let helpers = [];
219
+ let deform = joints.length;
220
+ let influences = 4;
221
+ if (skinned) {
222
+ const w = jointWeights(doc, skin);
223
+ influences = w.influences;
224
+ const all = skin.listJoints();
225
+ const weighted = new Set(all.filter((_, i) => w.used[i] > 1e-6));
226
+ deform = weighted.size;
227
+ // A helper moves no vertex, has no weighted joint under it, is not a mapped bone or a socket: IK targets, poles.
228
+ const parents = parentsOf(doc.getRoot());
229
+ const keepers = new Set([...weighted, ...Object.values(map).map((n) => all.find((x) => x.getName() === n)).filter(Boolean)]);
230
+ for (const n of [...keepers]) { let p = parents.get(n); while (p && all.includes(p)) { keepers.add(p); p = parents.get(p); } }
231
+ helpers = all.filter((n) => !keepers.has(n)).map((n) => n.getName());
232
+ }
233
+ const std = Object.fromEntries(Object.entries(map).map(([s, o]) => [o, s]));
234
+ const parents = parentsOf(doc.getRoot());
235
+ const rows = joints.filter((n) => !helpers.includes(n.getName())).map((n) => {
236
+ const r = n.getRotation();
237
+ return `${std[n.getName()] ?? n.getName()}<${std[parents.get(n)?.getName()] ?? parents.get(n)?.getName() ?? ''}:${r.map((v) => (Math.round(v * 50) / 50).toFixed(2)).join(',')}`;
238
+ });
239
+ const fingerprint = createHash('sha256').update(`${family}|${skinned ? 's' : 'r'}|${rows.join(';')}`).digest('hex').slice(0, 10);
240
+ return { family, skinned, bones: joints.length, deform, map, missing, helpers, influences, fingerprint, id: `${family}-${fingerprint.slice(0, 6)}`, joints: names };
241
+ }
242
+
243
+ /* ------------------------------------------------------------------ the shipped character */
244
+
245
+ const mat4 = () => new Float64Array(16);
246
+ function mul(a, b) {
247
+ const o = mat4();
248
+ for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]; o[c * 4 + r] = s; }
249
+ return o;
250
+ }
251
+ function invert(m) {
252
+ const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m;
253
+ const b00 = a00 * a11 - a01 * a10; const b01 = a00 * a12 - a02 * a10; const b02 = a00 * a13 - a03 * a10; const b03 = a01 * a12 - a02 * a11;
254
+ const b04 = a01 * a13 - a03 * a11; const b05 = a02 * a13 - a03 * a12; const b06 = a20 * a31 - a21 * a30; const b07 = a20 * a32 - a22 * a30;
255
+ const b08 = a20 * a33 - a23 * a30; const b09 = a21 * a32 - a22 * a31; const b10 = a21 * a33 - a23 * a31; const b11 = a22 * a33 - a23 * a32;
256
+ const det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
257
+ if (!det) return null;
258
+ const d = 1 / det;
259
+ return Float64Array.from([
260
+ (a11 * b11 - a12 * b10 + a13 * b09) * d, (a02 * b10 - a01 * b11 - a03 * b09) * d, (a31 * b05 - a32 * b04 + a33 * b03) * d, (a22 * b04 - a21 * b05 - a23 * b03) * d,
261
+ (a12 * b08 - a10 * b11 - a13 * b07) * d, (a00 * b11 - a02 * b08 + a03 * b07) * d, (a32 * b02 - a30 * b05 - a33 * b01) * d, (a20 * b05 - a22 * b02 + a23 * b01) * d,
262
+ (a10 * b10 - a11 * b08 + a13 * b06) * d, (a01 * b08 - a00 * b10 - a03 * b06) * d, (a30 * b04 - a31 * b02 + a33 * b00) * d, (a21 * b02 - a20 * b04 - a23 * b00) * d,
263
+ (a11 * b07 - a10 * b09 - a12 * b06) * d, (a00 * b09 - a01 * b07 + a02 * b06) * d, (a31 * b01 - a30 * b03 - a32 * b00) * d, (a20 * b03 - a21 * b01 + a22 * b00) * d,
264
+ ]);
265
+ }
266
+ const apply = (m, v) => [m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]];
267
+ const applyDir = (m, v) => { const o = [m[0] * v[0] + m[4] * v[1] + m[8] * v[2], m[1] * v[0] + m[5] * v[1] + m[9] * v[2], m[2] * v[0] + m[6] * v[1] + m[10] * v[2]]; const l = Math.hypot(...o) || 1; return o.map((x) => x / l); };
268
+
269
+ /** A node's world matrix from its own and its parents' rest transforms. */
270
+ const worldOf = (node) => Float64Array.from(node.getWorldMatrix());
271
+
272
+ /**
273
+ * The character a game ships, from a raw rigged model (a Document, changed in place):
274
+ * rename joints to the standard names (the map from skeletonOf), so clips, sockets and look-at find them
275
+ * helpers joints that move nothing (IK targets, poles) removed from the skin and the tree
276
+ * merge every skinned part and every held thing under a joint (a sword in the hand, a hat on the head) into ONE
277
+ * skinned primitive per material: one draw call. `keep`: the held things to keep (names, or a test); the
278
+ * rest are dropped (part swaps); null keeps them all
279
+ * clips every clip taken out and returned (they go to the skeleton's clip library)
280
+ * Returns { skeleton (after), renamed, removedHelpers, merged, dropped, kept, clips: Animation[] (detached copies are
281
+ * not made: the caller reads them before disposing) }.
282
+ */
283
+ export function normaliseRig(doc, { keep = null, merge = true, rename = true, helpers = true, takeClips = true } = {}) {
284
+ const root = doc.getRoot();
285
+ const before = skeletonOf(doc);
286
+ const { skinned, skin } = jointsOf(doc);
287
+ const out = { renamed: 0, removedHelpers: [], merged: 0, dropped: [], kept: [] };
288
+ if (rename) {
289
+ for (const [std, orig] of Object.entries(before.map)) {
290
+ const n = root.listNodes().find((x) => x.getName() === orig);
291
+ if (n && n.getName() !== std) { n.setName(std); out.renamed += 1; }
292
+ }
293
+ }
294
+ if (skinned && helpers && before.helpers.length) {
295
+ out.removedHelpers = removeJoints(doc, skin, before.helpers);
296
+ }
297
+ if (skinned && merge) Object.assign(out, mergeCharacter(doc, skin, keep));
298
+ // A rigid rig (parts that move by their nodes) keeps knowing its joints once its clips are gone.
299
+ if (!skinned) for (const n of jointsOf(doc).joints) n.setExtras({ ...(n.getExtras() ?? {}), homie: { ...(n.getExtras()?.homie ?? {}), joint: true } });
300
+ // The clips go to the skeleton's clip library (lib/clips.mjs bakes them before this runs); the model ships without.
301
+ if (takeClips) for (const a of root.listAnimations()) a.dispose();
302
+ return { ...out, before, after: skeletonOf(doc) };
303
+ }
304
+
305
+ /** Remove joints from a skin (and the tree) and re-index every vertex's JOINTS_0. Returns the removed names. */
306
+ function removeJoints(doc, skin, names) {
307
+ const all = skin.listJoints();
308
+ const gone = all.filter((n) => names.includes(n.getName()));
309
+ if (!gone.length) return [];
310
+ const keepIdx = all.map((n, i) => (gone.includes(n) ? -1 : i)).filter((i) => i >= 0);
311
+ const remap = new Int32Array(all.length).fill(0);
312
+ keepIdx.forEach((old, neu) => { remap[old] = neu; });
313
+ const ibm = skin.getInverseBindMatrices();
314
+ for (const node of doc.getRoot().listNodes()) {
315
+ if (node.getSkin() !== skin || !node.getMesh()) continue;
316
+ for (const p of node.getMesh().listPrimitives()) {
317
+ const J = p.getAttribute('JOINTS_0'); const W = p.getAttribute('WEIGHTS_0');
318
+ if (!J) continue;
319
+ const j = [0, 0, 0, 0]; const w = [0, 0, 0, 0];
320
+ for (let i = 0; i < J.getCount(); i++) {
321
+ J.getElement(i, j); W.getElement(i, w);
322
+ // A removed joint carried no weight: its slot is zeroed and points at joint 0.
323
+ for (let k = 0; k < 4; k++) { if (remap[j[k]] === undefined || gone.includes(all[j[k]])) { j[k] = 0; w[k] = 0; } else j[k] = remap[j[k]]; }
324
+ J.setElement(i, j); W.setElement(i, w);
325
+ }
326
+ }
327
+ }
328
+ if (ibm) {
329
+ const arr = ibm.getArray();
330
+ const next = new Float32Array(keepIdx.length * 16);
331
+ keepIdx.forEach((old, neu) => next.set(arr.subarray(old * 16, old * 16 + 16), neu * 16));
332
+ ibm.setArray(next);
333
+ }
334
+ const removed = gone.map((n) => n.getName());
335
+ for (const n of gone) skin.removeJoint(n);
336
+ // Out of the tree too (a helper's children are helpers as well).
337
+ for (const n of gone) n.dispose();
338
+ return removed;
339
+ }
340
+
341
+ /**
342
+ * Merge a character into one skinned primitive per material. Skinned parts are already in bind space; a held thing
343
+ * (a rigid mesh under a joint) becomes skinned to that joint with full weight: its vertices are carried into bind
344
+ * space through the joint's bind matrix. Returns { merged, dropped, kept }.
345
+ */
346
+ function mergeCharacter(doc, skin, keep) {
347
+ const root = doc.getRoot();
348
+ const joints = skin.listJoints();
349
+ const parents = parentsOf(root);
350
+ const ibmArr = skin.getInverseBindMatrices()?.getArray() ?? null;
351
+ const bindOf = (ji) => (ibmArr ? invert(Float64Array.from(ibmArr.subarray(ji * 16, ji * 16 + 16))) : worldOf(joints[ji]));
352
+ const wants = (name) => (keep === null || keep === undefined ? true : typeof keep === 'function' ? keep(name) : keep.some((k) => k.toLowerCase() === String(name).toLowerCase()));
353
+ const parts = []; // { node, prims, rigid: jointIndex | null }
354
+ const dropped = []; const kept = [];
355
+ for (const node of root.listNodes()) {
356
+ const mesh = node.getMesh();
357
+ if (!mesh) continue;
358
+ if (node.getSkin() === skin) { parts.push({ node, rigid: null }); continue; }
359
+ if (node.getSkin()) continue;
360
+ // A rigid mesh: under which joint?
361
+ let p = parents.get(node); let chain = [node];
362
+ while (p && !joints.includes(p)) { chain.unshift(p); p = parents.get(p); }
363
+ if (!p) continue;
364
+ if (!wants(node.getName())) { dropped.push(node.getName()); node.setMesh(null); continue; }
365
+ kept.push(node.getName());
366
+ parts.push({ node, rigid: joints.indexOf(p), chain });
367
+ }
368
+ if (parts.length < 2) return { merged: 0, dropped, kept };
369
+ // Group primitives by material and by the attributes they can share.
370
+ const groups = new Map();
371
+ for (const part of parts) {
372
+ for (const prim of part.node.getMesh().listPrimitives()) {
373
+ if (prim.getMode() !== 4 || !prim.getIndices()) return { merged: 0, dropped, kept };
374
+ const k = `${prim.getMaterial()?.getName() ?? ''}#${root.listMaterials().indexOf(prim.getMaterial())}`;
375
+ if (!groups.has(k)) groups.set(k, []);
376
+ groups.get(k).push({ part, prim });
377
+ }
378
+ }
379
+ const buffer = root.listBuffers()[0] ?? doc.createBuffer();
380
+ const target = parts.find((p) => p.rigid === null)?.node;
381
+ if (!target) return { merged: 0, dropped, kept };
382
+ const mesh = doc.createMesh(`${target.getName() || 'character'}`);
383
+ let merged = 0;
384
+ for (const items of groups.values()) {
385
+ const semantics = ['POSITION', 'NORMAL', 'TEXCOORD_0'].filter((s) => items.every(({ prim }) => prim.getAttribute(s)));
386
+ const pos = []; const nor = []; const uv = []; const jo = []; const we = []; const idx = [];
387
+ let base = 0;
388
+ for (const { part, prim } of items) {
389
+ const P = prim.getAttribute('POSITION'); const N = prim.getAttribute('NORMAL'); const T = prim.getAttribute('TEXCOORD_0');
390
+ const J = prim.getAttribute('JOINTS_0'); const W = prim.getAttribute('WEIGHTS_0');
391
+ let m = null;
392
+ if (part.rigid !== null) {
393
+ // Rigid: its world rest transform, carried into the bind space of the joint it hangs from.
394
+ const world = worldOf(part.node);
395
+ const jointWorld = worldOf(joints[part.rigid]);
396
+ m = mul(bindOf(part.rigid), mul(invert(jointWorld), world));
397
+ }
398
+ const v = [0, 0, 0]; const n = [0, 0, 0]; const t = [0, 0]; const j = [0, 0, 0, 0]; const w = [0, 0, 0, 0];
399
+ for (let i = 0; i < P.getCount(); i++) {
400
+ P.getElement(i, v);
401
+ pos.push(...(m ? apply(m, v) : v));
402
+ if (semantics.includes('NORMAL')) { N.getElement(i, n); nor.push(...(m ? applyDir(m, n) : n)); }
403
+ if (semantics.includes('TEXCOORD_0')) { T.getElement(i, t); uv.push(t[0], t[1]); }
404
+ if (part.rigid !== null) { jo.push(part.rigid, 0, 0, 0); we.push(1, 0, 0, 0); }
405
+ else { J.getElement(i, j); W.getElement(i, w); jo.push(...j); we.push(...w); }
406
+ }
407
+ const I = prim.getIndices();
408
+ for (let i = 0; i < I.getCount(); i++) idx.push(I.getScalar(i) + base);
409
+ base += P.getCount();
410
+ }
411
+ const prim = doc.createPrimitive().setMaterial(items[0].prim.getMaterial());
412
+ prim.setAttribute('POSITION', doc.createAccessor().setType('VEC3').setArray(new Float32Array(pos)).setBuffer(buffer));
413
+ if (nor.length) prim.setAttribute('NORMAL', doc.createAccessor().setType('VEC3').setArray(new Float32Array(nor)).setBuffer(buffer));
414
+ if (uv.length) prim.setAttribute('TEXCOORD_0', doc.createAccessor().setType('VEC2').setArray(new Float32Array(uv)).setBuffer(buffer));
415
+ prim.setAttribute('JOINTS_0', doc.createAccessor().setType('VEC4').setArray(joints.length > 255 ? new Uint16Array(jo) : new Uint8Array(jo)).setBuffer(buffer));
416
+ prim.setAttribute('WEIGHTS_0', doc.createAccessor().setType('VEC4').setArray(new Float32Array(we)).setBuffer(buffer));
417
+ prim.setIndices(doc.createAccessor().setType('SCALAR').setArray(base > 65535 ? new Uint32Array(idx) : new Uint16Array(idx)).setBuffer(buffer));
418
+ mesh.addPrimitive(prim);
419
+ merged += items.length;
420
+ }
421
+ // The merged mesh hangs where the first skinned part did; every part's own node loses its mesh.
422
+ for (const part of parts) { const old = part.node.getMesh(); part.node.setMesh(null); if (part.node !== target) part.node.setSkin(null); if (old && !old.listParents().some((x) => x.propertyType === 'Node')) old.dispose(); }
423
+ target.setMesh(mesh).setSkin(skin).setName(target.getName());
424
+ return { merged, dropped, kept };
425
+ }
426
+
427
+ /** The standard name of a socket on a rig (`rightHand` -> its slot bone, else the hand), from a list of joint names. */
428
+ export function socketBone(names, socket) {
429
+ for (const n of SOCKETS[socket] ?? [socket]) if (names.includes(n)) return n;
430
+ return null;
431
+ }
package/lib/scaffold.mjs CHANGED
@@ -289,6 +289,13 @@ studio's pinned copy, never a registry lookup of the bare name.
289
289
  with its licence; \`RIGHTS.md\` and the credits page follow. \`assets check <id>\` holds a game to the phone
290
290
  budgets; \`assets lineup <id>\` shows everything at true scale. A three.js game loads models through
291
291
  \`@homie-rocks/studio/assets\` (\`createModels\`); \`game new <id> --from gem-rush-3d\` starts one.
292
+ - **Characters and clips**: an animated library character (\`assets find "knight" --kind character\`) comes in
293
+ phone-sized, one draw call, its bones named by the skeleton standard, its clips in one shared clip library per
294
+ skeleton (\`public/anims/<skeleton>.glb\`); \`anim plan <id>\` lists each one's clips against the verbs the game
295
+ needs, \`anim add <id> <asset> --verbs jump,attack\` retargets more onto it (free), \`anim preview <id>\` draws
296
+ them looping. A game plays them through \`@homie-rocks/studio/animate\` (\`loadCharacter\`: blends, hits, jumps,
297
+ lean, look-at, crowds; every number a Game Lab tunable); \`game new <id> --from hero-rush-3d\` starts one. The
298
+ plugin's \`animate\` skill has the rest.
292
299
  - **Licences**: a public game ships only assets whose licence allows it (CC0, CC BY with credit, the studio's own,
293
300
  generated); \`publish\` refuses an asset with no licence record. A remix gets each redistributable model,
294
301
  checked by SHA-256, and a grey placeholder for the rest.
package/lib/studio.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * current directory, and its games.
4
4
  */
5
5
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
6
- import { dirname, join, resolve, sep } from 'node:path';
6
+ import { basename, dirname, join, resolve, sep } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { httpsPage, licenseOf, pageOfSource, remixAllowed, remixCredit, remixRow } from '../worker/license.mjs';
9
9
 
@@ -208,7 +208,10 @@ async function fetchStarterModels(dest, only = null) {
208
208
  }
209
209
  const missing = [];
210
210
  let fetched = 0;
211
- for (const a of wanted) {
211
+ // A character the starter names with a `rig` recipe goes through the character pipeline (lib/characters.mjs): made
212
+ // phone-sized, its held things picked, its clips baked into its skeleton's clip library. Everything else is copied.
213
+ const rigged = wanted.filter((a) => a.rig && ['character', 'creature'].includes(a.kind));
214
+ for (const a of wanted.filter((x) => !rigged.includes(x))) {
212
215
  const item = index.items.find((x) => x.id === a.from.item);
213
216
  const f = a.files.find((x) => x.role === 'model');
214
217
  if (!item) { missing.push({ asset: a.id, item: a.from.item, why: `the library has no ${a.from.item}` }); continue; }
@@ -222,7 +225,19 @@ async function fetchStarterModels(dest, only = null) {
222
225
  } catch (error) { missing.push({ asset: a.id, item: a.from.item, why: error.message }); }
223
226
  }
224
227
  writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
225
- return { fetched, missing, from: lib.base };
228
+ let baked = 0;
229
+ if (rigged.length) {
230
+ const { addCharacter } = await import('./characters.mjs');
231
+ const root = join(dest, '..', '..');
232
+ const game = basename(dest);
233
+ for (const a of rigged) {
234
+ try {
235
+ await addCharacter(root, game, { item: a.from.item, as: a.id, kind: a.kind, card: a.card ?? null, height: a.rig.heightM ?? null, keep: a.rig.keep ?? null, verbs: a.rig.verbs ?? [], lib, index });
236
+ fetched += 1; baked += 1;
237
+ } catch (error) { missing.push({ asset: a.id, item: a.from.item, why: error.message }); }
238
+ }
239
+ }
240
+ return { fetched, missing, from: lib.base, ...(baked ? { characters: baked } : {}) };
226
241
  }
227
242
 
228
243
  /**
@@ -170,7 +170,9 @@ export async function assetsLineup(root, id, { log = () => {} } = {}) {
170
170
  // `inGame`: what the game's code does to it at runtime (drawn at another height, repainted from style.json): the
171
171
  // lineup draws it the same way (the repaint as the board's re-tint, the nearest the game's own swap can be shown).
172
172
  const scale = a.inGame?.heightM && a.measured?.heightM ? a.inGame.heightM / a.measured.heightM : null;
173
- models.push({ a, input: modelIn(a.id, readFileSync(abs), { label: a.card ? String(a.card).split('/').pop() : a.id, ...(scale ? { scale } : {}), ...(a.inGame?.tint ? { tint: lightColours({ sky: a.inGame.tint, ground: a.inGame.tint }, tokens.palette).sky } : a.inGame?.repaint ? { retint: true } : Number(a.inGame?.pull) > 0 ? { pull: Number(a.inGame.pull) } : {}) }), abs });
173
+ // A character stands in its idle pose from its skeleton's clip library (not its bind pose: arms out in a T).
174
+ const anims = a.rig?.anims && existsSync(join(root, 'games', id, a.rig.anims)) ? readFileSync(join(root, 'games', id, a.rig.anims)).toString('base64') : null;
175
+ models.push({ a, input: modelIn(a.id, readFileSync(abs), { label: a.card ? String(a.card).split('/').pop() : a.id, ...(scale ? { scale } : {}), ...(anims ? { anims, pose: 'idle', poseAt: 0.3 } : {}), ...(a.inGame?.tint ? { tint: lightColours({ sky: a.inGame.tint, ground: a.inGame.tint }, tokens.palette).sky } : a.inGame?.repaint ? { retint: true } : Number(a.inGame?.pull) > 0 ? { pull: Number(a.inGame.pull) } : {}) }), abs });
174
176
  }
175
177
  if (!models.length) return { ok: false, command: 'assets lineup', id, why: `games/${id} has no recorded models yet (assets add, or the models skill)` };
176
178
  const dir = join(root, '.studio', 'art', id);