@series-inc/stowkit-cli 0.1.29 → 0.1.30

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.
@@ -39,4 +39,6 @@ export interface ProjectAsset {
39
39
  processedSize: number;
40
40
  parentId?: string;
41
41
  locked?: boolean;
42
+ /** Processing warnings (e.g. animation bone mismatches). */
43
+ warnings?: string[];
42
44
  }
package/dist/cli.js CHANGED
@@ -226,7 +226,7 @@ async function main() {
226
226
  process.exit(1);
227
227
  }
228
228
  const resolvedProject = path.resolve(projectDir);
229
- await startServer({ port, projectDir: resolvedProject, staticApps: { '/': packerDir } });
229
+ await startServer({ port, projectDir: resolvedProject, force, staticApps: { '/': packerDir } });
230
230
  console.log(`\n Packer: http://localhost:${port}\n`);
231
231
  openBrowser(`http://localhost:${port}`);
232
232
  break;
@@ -240,11 +240,11 @@ async function main() {
240
240
  const resolvedProject = path.resolve(projectDir);
241
241
  const packerPort = port + 1;
242
242
  // Start editor server
243
- await startServer({ port, projectDir: resolvedProject, staticApps: { '/': editorDir } });
243
+ await startServer({ port, projectDir: resolvedProject, force, staticApps: { '/': editorDir } });
244
244
  // Start packer on next port if installed
245
245
  const packerDir = resolveAppDir('@series-inc/stowkit-packer-gui', 'stowkit-packer-gui');
246
246
  if (packerDir) {
247
- await startServer({ port: packerPort, projectDir: resolvedProject, staticApps: { '/': packerDir } });
247
+ await startServer({ port: packerPort, projectDir: resolvedProject, force, staticApps: { '/': packerDir } });
248
248
  console.log(`\n Editor: http://localhost:${port}`);
249
249
  console.log(` Packer: http://localhost:${packerPort}\n`);
250
250
  }
@@ -255,7 +255,7 @@ async function main() {
255
255
  break;
256
256
  }
257
257
  case 'serve':
258
- await startServer({ port, projectDir: projectDir !== '.' ? path.resolve(projectDir) : undefined });
258
+ await startServer({ port, force, projectDir: projectDir !== '.' ? path.resolve(projectDir) : undefined });
259
259
  break;
260
260
  default:
261
261
  console.error(`Unknown command: ${command}`);
@@ -50,10 +50,15 @@ export declare const SKINNED_MESH_GEOMETRY_INFO_SIZE = 72;
50
50
  export declare const SKINNED_MESH_METADATA_FIXED_SIZE = 144;
51
51
  /** Size of Bone on disk (bytes): name[64] + parent_index(4) + offset_matrix[64] */
52
52
  export declare const BONE_SIZE = 132;
53
- /** Size of VertexWeights on disk (bytes): bone_indices[16] + weights[16] */
53
+ /** Size of VertexWeights on disk (bytes) — legacy: bone_indices[4*u32] + weights[4*f32] */
54
54
  export declare const VERTEX_WEIGHTS_SIZE = 32;
55
+ /** Size of compact VertexWeights on disk: bone_indices[4*u16] + weights[4*u8] = 12 bytes */
56
+ export declare const COMPACT_VERTEX_WEIGHTS_SIZE = 12;
55
57
  /** Interleaved vertex size for skinned meshes: pos[3] + normal[3] + uv[2] = 8 floats */
56
58
  export declare const SKINNED_VERTEX_STRIDE = 32;
59
+ /** SkinnedMeshGeometryInfo flags */
60
+ export declare const SKINNED_INDEX16_FLAG = 1;
61
+ export declare const SKINNED_COMPACT_WEIGHTS_FLAG = 2;
57
62
  /** Size of AnimationTrackDescriptor on disk (bytes) */
58
63
  export declare const ANIMATION_TRACK_DESCRIPTOR_SIZE = 88;
59
64
  /** Size of AnimationClipMetadata fixed portion on disk (bytes) */
@@ -50,10 +50,15 @@ export const SKINNED_MESH_GEOMETRY_INFO_SIZE = 72;
50
50
  export const SKINNED_MESH_METADATA_FIXED_SIZE = 144;
51
51
  /** Size of Bone on disk (bytes): name[64] + parent_index(4) + offset_matrix[64] */
52
52
  export const BONE_SIZE = 132;
53
- /** Size of VertexWeights on disk (bytes): bone_indices[16] + weights[16] */
53
+ /** Size of VertexWeights on disk (bytes) — legacy: bone_indices[4*u32] + weights[4*f32] */
54
54
  export const VERTEX_WEIGHTS_SIZE = 32;
55
+ /** Size of compact VertexWeights on disk: bone_indices[4*u16] + weights[4*u8] = 12 bytes */
56
+ export const COMPACT_VERTEX_WEIGHTS_SIZE = 12;
55
57
  /** Interleaved vertex size for skinned meshes: pos[3] + normal[3] + uv[2] = 8 floats */
56
58
  export const SKINNED_VERTEX_STRIDE = 32;
59
+ /** SkinnedMeshGeometryInfo flags */
60
+ export const SKINNED_INDEX16_FLAG = 0x1;
61
+ export const SKINNED_COMPACT_WEIGHTS_FLAG = 0x2;
57
62
  // ─── Animation V2 Constants ─────────────────────────────────────────────────
58
63
  /** Size of AnimationTrackDescriptor on disk (bytes) */
59
64
  export const ANIMATION_TRACK_DESCRIPTOR_SIZE = 88;
@@ -159,7 +159,8 @@ export interface SkinnedMeshGeometryInfo {
159
159
  weightsOffset: number;
160
160
  weightsSize: number;
161
161
  materialIndex: number;
162
- _padding: number;
162
+ /** Bit flags: 0x1 = index16, 0x2 = compact weights (u16 bone indices + u8 weights) */
163
+ flags: number;
163
164
  }
164
165
  export interface Bone {
165
166
  name: string;
@@ -63,6 +63,14 @@ export class NodeFbxImporter {
63
63
  return mesh;
64
64
  }
65
65
  catch (err) {
66
+ // Animation-only FBX files often crash Three.js's FBXLoader (missing skeleton connections).
67
+ // Fall back to extracting animations directly via fbx-parser.
68
+ const animations = parseFbxAnimations(data);
69
+ if (animations.length > 0) {
70
+ const mesh = emptyMesh();
71
+ mesh.animations = animations;
72
+ return mesh;
73
+ }
66
74
  console.error('[NodeFbxImporter] Failed:', err);
67
75
  return emptyMesh();
68
76
  }
@@ -189,54 +197,57 @@ function extractMeshFromGroup(group) {
189
197
  if (!posAttr)
190
198
  continue;
191
199
  const vertCount = posAttr.count;
192
- const positions = new Float32Array(vertCount * 3);
200
+ // Pre-compute full-mesh vertex data (shared across groups)
201
+ const worldMatrix = mesh.matrixWorld;
202
+ const fullPositions = new Float32Array(vertCount * 3);
193
203
  {
194
- const worldMatrix = mesh.matrixWorld;
195
204
  const v = new THREE.Vector3();
196
205
  for (let vi = 0; vi < vertCount; vi++) {
197
206
  v.fromBufferAttribute(posAttr, vi);
198
207
  v.applyMatrix4(worldMatrix);
199
- positions[vi * 3] = v.x;
200
- positions[vi * 3 + 1] = v.y;
201
- positions[vi * 3 + 2] = v.z;
208
+ fullPositions[vi * 3] = v.x;
209
+ fullPositions[vi * 3 + 1] = v.y;
210
+ fullPositions[vi * 3 + 2] = v.z;
202
211
  }
203
212
  }
204
- let normals = null;
213
+ let fullNormals = null;
205
214
  const normAttr = geometry.getAttribute('normal');
206
215
  if (normAttr && normAttr.count === vertCount) {
207
- normals = new Float32Array(vertCount * 3);
216
+ fullNormals = new Float32Array(vertCount * 3);
208
217
  const normalMatrix = new THREE.Matrix3().getNormalMatrix(mesh.matrixWorld);
209
218
  const n = new THREE.Vector3();
210
219
  for (let vi = 0; vi < vertCount; vi++) {
211
220
  n.fromBufferAttribute(normAttr, vi);
212
221
  n.applyMatrix3(normalMatrix).normalize();
213
- normals[vi * 3] = n.x;
214
- normals[vi * 3 + 1] = n.y;
215
- normals[vi * 3 + 2] = n.z;
222
+ fullNormals[vi * 3] = n.x;
223
+ fullNormals[vi * 3 + 1] = n.y;
224
+ fullNormals[vi * 3 + 2] = n.z;
216
225
  }
217
226
  }
218
- let uvs = null;
227
+ let fullUvs = null;
219
228
  const uvAttr = geometry.getAttribute('uv');
220
229
  if (uvAttr && uvAttr.count === vertCount) {
221
- uvs = new Float32Array(vertCount * 2);
230
+ fullUvs = new Float32Array(vertCount * 2);
222
231
  for (let vi = 0; vi < vertCount; vi++) {
223
- uvs[vi * 2] = uvAttr.getX(vi);
224
- uvs[vi * 2 + 1] = uvAttr.getY(vi);
232
+ fullUvs[vi * 2] = uvAttr.getX(vi);
233
+ fullUvs[vi * 2 + 1] = uvAttr.getY(vi);
225
234
  }
226
235
  }
227
236
  const indexAttr = geometry.getIndex();
228
- let indices;
237
+ let fullIndices;
229
238
  if (indexAttr) {
230
- indices = new Uint32Array(indexAttr.count);
239
+ fullIndices = new Uint32Array(indexAttr.count);
231
240
  for (let ii = 0; ii < indexAttr.count; ii++)
232
- indices[ii] = indexAttr.getX(ii);
241
+ fullIndices[ii] = indexAttr.getX(ii);
233
242
  }
234
243
  else {
235
- indices = new Uint32Array(vertCount);
244
+ fullIndices = new Uint32Array(vertCount);
236
245
  for (let ii = 0; ii < vertCount; ii++)
237
- indices[ii] = ii;
246
+ fullIndices[ii] = ii;
238
247
  }
239
- let skinData;
248
+ // Pre-compute full skin data
249
+ let fullSkinBoneIndices;
250
+ let fullSkinBoneWeights;
240
251
  if (mesh.isSkinnedMesh) {
241
252
  const skinIndexAttr = geometry.getAttribute('skinIndex');
242
253
  const skinWeightAttr = geometry.getAttribute('skinWeight');
@@ -250,44 +261,125 @@ function extractMeshFromGroup(group) {
250
261
  remap[si] = idx >= 0 ? idx : 0;
251
262
  }
252
263
  }
253
- const boneIndices = new Uint32Array(vertCount * 4);
254
- const boneWeights = new Float32Array(vertCount * 4);
264
+ fullSkinBoneIndices = new Uint32Array(vertCount * 4);
265
+ fullSkinBoneWeights = new Float32Array(vertCount * 4);
255
266
  for (let vi = 0; vi < vertCount; vi++) {
256
267
  const si0 = skinIndexAttr.getX(vi);
257
268
  const si1 = skinIndexAttr.getY(vi);
258
269
  const si2 = skinIndexAttr.getZ(vi);
259
270
  const si3 = skinIndexAttr.getW(vi);
260
- boneIndices[vi * 4] = remap && si0 < remap.length ? remap[si0] : si0;
261
- boneIndices[vi * 4 + 1] = remap && si1 < remap.length ? remap[si1] : si1;
262
- boneIndices[vi * 4 + 2] = remap && si2 < remap.length ? remap[si2] : si2;
263
- boneIndices[vi * 4 + 3] = remap && si3 < remap.length ? remap[si3] : si3;
264
- boneWeights[vi * 4] = skinWeightAttr.getX(vi);
265
- boneWeights[vi * 4 + 1] = skinWeightAttr.getY(vi);
266
- boneWeights[vi * 4 + 2] = skinWeightAttr.getZ(vi);
267
- boneWeights[vi * 4 + 3] = skinWeightAttr.getW(vi);
271
+ fullSkinBoneIndices[vi * 4] = remap && si0 < remap.length ? remap[si0] : si0;
272
+ fullSkinBoneIndices[vi * 4 + 1] = remap && si1 < remap.length ? remap[si1] : si1;
273
+ fullSkinBoneIndices[vi * 4 + 2] = remap && si2 < remap.length ? remap[si2] : si2;
274
+ fullSkinBoneIndices[vi * 4 + 3] = remap && si3 < remap.length ? remap[si3] : si3;
275
+ fullSkinBoneWeights[vi * 4] = skinWeightAttr.getX(vi);
276
+ fullSkinBoneWeights[vi * 4 + 1] = skinWeightAttr.getY(vi);
277
+ fullSkinBoneWeights[vi * 4 + 2] = skinWeightAttr.getZ(vi);
278
+ fullSkinBoneWeights[vi * 4 + 3] = skinWeightAttr.getW(vi);
268
279
  }
269
- skinData = { boneIndices, boneWeights };
270
280
  }
271
281
  }
272
- const meshMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
273
- const matName = meshMaterial?.name || 'default';
274
- let matIndex;
275
- if (materialSet.has(matName)) {
276
- matIndex = materialSet.get(matName);
282
+ // Determine groups: multi-material meshes have geometry.groups
283
+ const matArray = Array.isArray(mesh.material) ? mesh.material : null;
284
+ const groups = geometry.groups.length > 0 && matArray && matArray.length > 1
285
+ ? geometry.groups
286
+ : null;
287
+ const meshSubMeshIndices = [];
288
+ if (groups) {
289
+ // Multi-material: split into one submesh per group
290
+ for (const group of groups) {
291
+ const groupMat = matArray[group.materialIndex ?? 0] ?? matArray[0];
292
+ const matName = groupMat?.name || 'default';
293
+ let matIndex;
294
+ if (materialSet.has(matName)) {
295
+ matIndex = materialSet.get(matName);
296
+ }
297
+ else {
298
+ matIndex = materials.length;
299
+ materialSet.set(matName, matIndex);
300
+ materials.push({ name: matName });
301
+ }
302
+ // Extract the index slice for this group and remap vertices
303
+ const groupIndexStart = group.start;
304
+ const groupIndexCount = group.count;
305
+ const usedVerts = new Map(); // old vertex index → new vertex index
306
+ const groupIndicesRaw = [];
307
+ for (let ii = 0; ii < groupIndexCount; ii++) {
308
+ const oldVi = fullIndices[groupIndexStart + ii];
309
+ if (!usedVerts.has(oldVi)) {
310
+ usedVerts.set(oldVi, usedVerts.size);
311
+ }
312
+ groupIndicesRaw.push(usedVerts.get(oldVi));
313
+ }
314
+ const gVertCount = usedVerts.size;
315
+ const positions = new Float32Array(gVertCount * 3);
316
+ const normals = fullNormals ? new Float32Array(gVertCount * 3) : null;
317
+ const uvs = fullUvs ? new Float32Array(gVertCount * 2) : null;
318
+ let skinData;
319
+ let gBoneIndices;
320
+ let gBoneWeights;
321
+ if (fullSkinBoneIndices && fullSkinBoneWeights) {
322
+ gBoneIndices = new Uint32Array(gVertCount * 4);
323
+ gBoneWeights = new Float32Array(gVertCount * 4);
324
+ }
325
+ for (const [oldVi, newVi] of usedVerts) {
326
+ positions[newVi * 3] = fullPositions[oldVi * 3];
327
+ positions[newVi * 3 + 1] = fullPositions[oldVi * 3 + 1];
328
+ positions[newVi * 3 + 2] = fullPositions[oldVi * 3 + 2];
329
+ if (normals && fullNormals) {
330
+ normals[newVi * 3] = fullNormals[oldVi * 3];
331
+ normals[newVi * 3 + 1] = fullNormals[oldVi * 3 + 1];
332
+ normals[newVi * 3 + 2] = fullNormals[oldVi * 3 + 2];
333
+ }
334
+ if (uvs && fullUvs) {
335
+ uvs[newVi * 2] = fullUvs[oldVi * 2];
336
+ uvs[newVi * 2 + 1] = fullUvs[oldVi * 2 + 1];
337
+ }
338
+ if (gBoneIndices && gBoneWeights && fullSkinBoneIndices && fullSkinBoneWeights) {
339
+ gBoneIndices[newVi * 4] = fullSkinBoneIndices[oldVi * 4];
340
+ gBoneIndices[newVi * 4 + 1] = fullSkinBoneIndices[oldVi * 4 + 1];
341
+ gBoneIndices[newVi * 4 + 2] = fullSkinBoneIndices[oldVi * 4 + 2];
342
+ gBoneIndices[newVi * 4 + 3] = fullSkinBoneIndices[oldVi * 4 + 3];
343
+ gBoneWeights[newVi * 4] = fullSkinBoneWeights[oldVi * 4];
344
+ gBoneWeights[newVi * 4 + 1] = fullSkinBoneWeights[oldVi * 4 + 1];
345
+ gBoneWeights[newVi * 4 + 2] = fullSkinBoneWeights[oldVi * 4 + 2];
346
+ gBoneWeights[newVi * 4 + 3] = fullSkinBoneWeights[oldVi * 4 + 3];
347
+ }
348
+ }
349
+ if (gBoneIndices && gBoneWeights) {
350
+ skinData = { boneIndices: gBoneIndices, boneWeights: gBoneWeights };
351
+ }
352
+ meshSubMeshIndices.push(subMeshes.length);
353
+ subMeshes.push({ skinData, positions, normals, uvs, indices: new Uint32Array(groupIndicesRaw), materialIndex: matIndex });
354
+ }
277
355
  }
278
356
  else {
279
- matIndex = materials.length;
280
- materialSet.set(matName, matIndex);
281
- materials.push({ name: matName });
357
+ // Single material
358
+ const meshMaterial = matArray ? matArray[0] : mesh.material;
359
+ const matName = meshMaterial?.name || 'default';
360
+ let matIndex;
361
+ if (materialSet.has(matName)) {
362
+ matIndex = materialSet.get(matName);
363
+ }
364
+ else {
365
+ matIndex = materials.length;
366
+ materialSet.set(matName, matIndex);
367
+ materials.push({ name: matName });
368
+ }
369
+ let skinData;
370
+ if (fullSkinBoneIndices && fullSkinBoneWeights) {
371
+ skinData = { boneIndices: fullSkinBoneIndices, boneWeights: fullSkinBoneWeights };
372
+ }
373
+ meshSubMeshIndices.push(subMeshes.length);
374
+ subMeshes.push({ skinData, positions: fullPositions, normals: fullNormals, uvs: fullUvs, indices: fullIndices, materialIndex: matIndex });
282
375
  }
283
- subMeshes.push({ skinData, positions, normals, uvs, indices, materialIndex: matIndex });
284
376
  nodes.push({
285
377
  name: mesh.name || `mesh_${mi}`,
286
378
  parentIndex: -1,
287
379
  position: [0, 0, 0],
288
380
  rotation: [0, 0, 0, 1],
289
381
  scale: [1, 1, 1],
290
- meshIndices: [mi],
382
+ meshIndices: meshSubMeshIndices,
291
383
  });
292
384
  }
293
385
  if (materials.length === 0)
@@ -1,12 +1,16 @@
1
- import { SKINNED_VERTEX_STRIDE, VERTEX_WEIGHTS_SIZE } from '../core/constants.js';
1
+ import { SKINNED_VERTEX_STRIDE, COMPACT_VERTEX_WEIGHTS_SIZE, SKINNED_INDEX16_FLAG, SKINNED_COMPACT_WEIGHTS_FLAG, } from '../core/constants.js';
2
2
  export function buildSkinnedMeshData(imported, scaleFactor = 1.0) {
3
3
  const geometries = [];
4
4
  let totalDataSize = 0;
5
5
  for (const sub of imported.subMeshes) {
6
6
  const vertCount = sub.positions.length / 3;
7
+ const useIndex16 = vertCount < 65536;
7
8
  const vertexBufSize = vertCount * SKINNED_VERTEX_STRIDE;
8
- const indexBufSize = sub.indices.length * 4;
9
- const weightsBufSize = vertCount * VERTEX_WEIGHTS_SIZE;
9
+ const indexBufSize = sub.indices.length * (useIndex16 ? 2 : 4);
10
+ const weightsBufSize = vertCount * COMPACT_VERTEX_WEIGHTS_SIZE;
11
+ let flags = SKINNED_COMPACT_WEIGHTS_FLAG;
12
+ if (useIndex16)
13
+ flags |= SKINNED_INDEX16_FLAG;
10
14
  geometries.push({
11
15
  vertexCount: vertCount,
12
16
  indexCount: sub.indices.length,
@@ -19,15 +23,19 @@ export function buildSkinnedMeshData(imported, scaleFactor = 1.0) {
19
23
  weightsOffset: totalDataSize + vertexBufSize + indexBufSize,
20
24
  weightsSize: weightsBufSize,
21
25
  materialIndex: sub.materialIndex,
22
- _padding: 0,
26
+ flags,
23
27
  });
24
28
  totalDataSize += vertexBufSize + indexBufSize + weightsBufSize;
25
29
  }
26
30
  const data = new Uint8Array(totalDataSize);
27
31
  const view = new DataView(data.buffer);
28
32
  let offset = 0;
29
- for (const sub of imported.subMeshes) {
33
+ for (let si = 0; si < imported.subMeshes.length; si++) {
34
+ const sub = imported.subMeshes[si];
35
+ const geo = geometries[si];
30
36
  const vertCount = sub.positions.length / 3;
37
+ const useIndex16 = (geo.flags & SKINNED_INDEX16_FLAG) !== 0;
38
+ // Vertex buffer (32 bytes per vert: pos[3] + normal[3] + uv[2])
31
39
  for (let v = 0; v < vertCount; v++) {
32
40
  view.setFloat32(offset, sub.positions[v * 3] * scaleFactor, true);
33
41
  offset += 4;
@@ -64,25 +72,37 @@ export function buildSkinnedMeshData(imported, scaleFactor = 1.0) {
64
72
  offset += 4;
65
73
  }
66
74
  }
67
- for (let i = 0; i < sub.indices.length; i++) {
68
- view.setUint32(offset, sub.indices[i], true);
69
- offset += 4;
75
+ // Index buffer uint16 or uint32
76
+ if (useIndex16) {
77
+ for (let i = 0; i < sub.indices.length; i++) {
78
+ view.setUint16(offset, sub.indices[i], true);
79
+ offset += 2;
80
+ }
81
+ }
82
+ else {
83
+ for (let i = 0; i < sub.indices.length; i++) {
84
+ view.setUint32(offset, sub.indices[i], true);
85
+ offset += 4;
86
+ }
70
87
  }
88
+ // Compact weights: bone_indices[4*u16] + weights[4*u8] = 12 bytes per vert
71
89
  for (let v = 0; v < vertCount; v++) {
72
90
  if (sub.skinData) {
73
91
  for (let w = 0; w < 4; w++) {
74
- view.setUint32(offset, sub.skinData.boneIndices[v * 4 + w], true);
75
- offset += 4;
92
+ view.setUint16(offset, sub.skinData.boneIndices[v * 4 + w], true);
93
+ offset += 2;
76
94
  }
77
95
  for (let w = 0; w < 4; w++) {
78
- view.setFloat32(offset, sub.skinData.boneWeights[v * 4 + w], true);
79
- offset += 4;
96
+ // Quantize float weight [0,1] to uint8 [0,255]
97
+ view.setUint8(offset, Math.round(sub.skinData.boneWeights[v * 4 + w] * 255));
98
+ offset += 1;
80
99
  }
81
100
  }
82
101
  else {
83
- for (let w = 0; w < 8; w++) {
84
- view.setUint32(offset, 0, true);
85
- offset += 4;
102
+ // 8 bytes zero indices + 4 bytes zero weights
103
+ for (let w = 0; w < 12; w++) {
104
+ view.setUint8(offset, 0);
105
+ offset += 1;
86
106
  }
87
107
  }
88
108
  }
@@ -262,7 +262,7 @@ function serializeSkinnedGeometryInfo(w, g) {
262
262
  w.writeUint64FromNumber(g.weightsOffset);
263
263
  w.writeUint64FromNumber(g.weightsSize);
264
264
  w.writeUint32(g.materialIndex);
265
- w.writeUint32(g._padding);
265
+ w.writeUint32(g.flags);
266
266
  }
267
267
  function deserializeSkinnedGeometryInfo(r) {
268
268
  return {
@@ -277,7 +277,7 @@ function deserializeSkinnedGeometryInfo(r) {
277
277
  weightsOffset: r.readUint64AsNumber(),
278
278
  weightsSize: r.readUint64AsNumber(),
279
279
  materialIndex: r.readUint32(),
280
- _padding: r.readUint32(),
280
+ flags: r.readUint32(),
281
281
  };
282
282
  }
283
283
  function serializeBone(w, bone) {
@@ -263,10 +263,10 @@ export async function fullBuild(projectDir, opts) {
263
263
  for (let si = 0; si < mesh.imported.subMeshes.length; si++) {
264
264
  const matIdx = mesh.imported.subMeshes[si].materialIndex;
265
265
  const matName = mesh.imported.materials[matIdx]?.name;
266
- if (matName) {
266
+ if (matName && overrides[matIdx] === undefined) {
267
267
  const matChildId = `${container.id}/${matName}.stowmat`;
268
268
  if (assetsById.has(matChildId)) {
269
- overrides[si] = matChildId;
269
+ overrides[matIdx] = matChildId;
270
270
  }
271
271
  }
272
272
  }
@@ -22,6 +22,7 @@ export interface ProcessResult {
22
22
  processedSize: number;
23
23
  glbChildren?: GlbChildDescriptor[];
24
24
  glbExtract?: GlbExtractResult;
25
+ warnings?: string[];
25
26
  }
26
27
  export declare function processAsset(id: string, sourceData: Uint8Array, type: AssetType, stringId: string, settings: AssetSettings, ctx: ProcessingContext): Promise<ProcessResult>;
27
28
  export declare function processExtractedMesh(childId: string, imported: ImportedMesh, hasSkeleton: boolean, stringId: string, settings: AssetSettings, ctx: ProcessingContext): Promise<ProcessResult>;
@@ -40,3 +41,4 @@ export declare function buildAnimationDataBlobsV2(imported: ImportedMesh): {
40
41
  data: Uint8Array;
41
42
  metadata: AnimationClipMetadata;
42
43
  }[];
44
+ export declare function generateAnimationWarnings(imported: ImportedMesh): string[];
package/dist/pipeline.js CHANGED
@@ -99,6 +99,7 @@ export async function processAsset(id, sourceData, type, stringId, settings, ctx
99
99
  log('importing FBX...');
100
100
  const imported = await ctx.meshImporter.import(sourceData, id);
101
101
  const animCount = imported.animations?.length ?? 0;
102
+ const animWarnings = generateAnimationWarnings(imported);
102
103
  if (animCount === 0)
103
104
  throw new Error('No animation clips found in FBX file');
104
105
  const animBlobs = buildAnimationDataBlobsV2(imported);
@@ -117,7 +118,9 @@ export async function processAsset(id, sourceData, type, stringId, settings, ctx
117
118
  BlobStore.setProcessed(`${id}:animCount`, new Uint8Array([animBlobs.length]));
118
119
  const totalAnimBytes = animBlobs.reduce((s, a) => s + a.data.length, 0);
119
120
  log(`animation serialized (${(totalAnimBytes / 1024).toFixed(0)} KB, ${animBlobs.length} clips)`);
120
- return { metadata: primary.metadata, processedSize: totalAnimBytes };
121
+ if (animWarnings.length > 0)
122
+ log(`warnings: ${animWarnings.join('; ')}`);
123
+ return { metadata: primary.metadata, processedSize: totalAnimBytes, warnings: animWarnings.length > 0 ? animWarnings : undefined };
121
124
  }
122
125
  if (type === AssetType.Audio) {
123
126
  log('decoding audio...');
@@ -494,3 +497,51 @@ export function buildAnimationDataBlobsV2(imported) {
494
497
  }
495
498
  return results;
496
499
  }
500
+ // ─── Animation Diagnostics ──────────────────────────────────────────────────
501
+ export function generateAnimationWarnings(imported) {
502
+ const warnings = [];
503
+ if (!imported.animations || imported.animations.length === 0) {
504
+ warnings.push('No animation clips found in FBX');
505
+ return warnings;
506
+ }
507
+ const boneNames = new Set((imported.bones ?? []).map(b => b.name));
508
+ for (const clip of imported.animations) {
509
+ const prefix = imported.animations.length > 1 ? `Clip "${clip.name}": ` : '';
510
+ if (!clip.tracks || clip.tracks.length === 0) {
511
+ warnings.push(`${prefix}No animation tracks`);
512
+ continue;
513
+ }
514
+ if (clip.duration <= 0) {
515
+ warnings.push(`${prefix}Duration is 0`);
516
+ }
517
+ // Check for bone name mismatches
518
+ const trackBoneNames = new Set();
519
+ let singleKeyframeTracks = 0;
520
+ for (const track of clip.tracks) {
521
+ if (track.name.endsWith('.scale'))
522
+ continue;
523
+ const boneName = track.name.split('.')[0];
524
+ trackBoneNames.add(boneName);
525
+ if (track.times.length <= 1)
526
+ singleKeyframeTracks++;
527
+ }
528
+ if (boneNames.size > 0 && trackBoneNames.size > 0) {
529
+ const unmatched = [];
530
+ for (const tn of trackBoneNames) {
531
+ if (!boneNames.has(tn))
532
+ unmatched.push(tn);
533
+ }
534
+ if (unmatched.length > 0 && unmatched.length === trackBoneNames.size) {
535
+ warnings.push(`${prefix}No track bone names match skeleton (${unmatched.length} unmatched: ${unmatched.slice(0, 3).join(', ')}${unmatched.length > 3 ? '...' : ''})`);
536
+ }
537
+ else if (unmatched.length > 0) {
538
+ warnings.push(`${prefix}${unmatched.length}/${trackBoneNames.size} track bones not in skeleton: ${unmatched.slice(0, 3).join(', ')}${unmatched.length > 3 ? '...' : ''}`);
539
+ }
540
+ }
541
+ const nonScaleTracks = clip.tracks.filter(t => !t.name.endsWith('.scale'));
542
+ if (singleKeyframeTracks > 0 && singleKeyframeTracks === nonScaleTracks.length) {
543
+ warnings.push(`${prefix}All ${singleKeyframeTracks} tracks have only 1 keyframe (static pose)`);
544
+ }
545
+ }
546
+ return warnings;
547
+ }
package/dist/server.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface ServerOptions {
3
3
  port?: number;
4
4
  projectDir?: string;
5
5
  wasmDir?: string;
6
+ /** Force reprocess all assets on startup, ignoring cache. */
7
+ force?: boolean;
6
8
  /** Static apps to serve. Key = URL prefix (e.g. '/packer'), value = directory path. Use '/' for root. */
7
9
  staticApps?: Record<string, string>;
8
10
  }
package/dist/server.js CHANGED
@@ -327,10 +327,10 @@ async function processGlbContainer(containerId) {
327
327
  for (let si = 0; si < mesh.imported.subMeshes.length; si++) {
328
328
  const matIdx = mesh.imported.subMeshes[si].materialIndex;
329
329
  const matName = mesh.imported.materials[matIdx]?.name;
330
- if (matName) {
330
+ if (matName && overrides[matIdx] === undefined) {
331
331
  const matChildId = `${containerId}/${matName}.stowmat`;
332
332
  if (childAssets.some(a => a.id === matChildId)) {
333
- overrides[si] = matChildId;
333
+ overrides[matIdx] = matChildId;
334
334
  }
335
335
  }
336
336
  }
@@ -735,6 +735,7 @@ async function processOneAsset(id) {
735
735
  asset.metadata = result.metadata;
736
736
  asset.processedSize = result.processedSize;
737
737
  asset.sourceSize = sourceData.length;
738
+ asset.warnings = result.warnings;
738
739
  broadcast({
739
740
  type: 'asset-update', id,
740
741
  updates: {
@@ -742,6 +743,7 @@ async function processOneAsset(id) {
742
743
  metadata: result.metadata,
743
744
  processedSize: result.processedSize,
744
745
  sourceSize: sourceData.length,
746
+ warnings: result.warnings,
745
747
  },
746
748
  });
747
749
  }
@@ -1576,7 +1578,7 @@ export async function startServer(opts = {}) {
1576
1578
  // Open project if specified (skip if already loaded — e.g. stowkit editor starts two servers)
1577
1579
  if (opts.projectDir && (!projectConfig || projectConfig.projectDir !== opts.projectDir)) {
1578
1580
  await openProject(opts.projectDir);
1579
- queueProcessing();
1581
+ queueProcessing({ force: opts.force });
1580
1582
  }
1581
1583
  return new Promise((resolve) => {
1582
1584
  server.listen(port, () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "stowkit": "./dist/cli.js"