@series-inc/stowkit-cli 0.1.27 → 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;
@@ -2,6 +2,7 @@ import * as THREE from 'three';
2
2
  import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
3
3
  import { PropertyBinding } from 'three';
4
4
  import { parseBinary as fbxParseBinary, parseText as fbxParseText, FBXReader } from 'fbx-parser';
5
+ import { mergeByMaterial, } from './interfaces.js';
5
6
  // ─── Minimal DOM shim for Three.js FBXLoader in Node ────────────────────────
6
7
  function ensureDomShim() {
7
8
  const g = globalThis;
@@ -62,6 +63,14 @@ export class NodeFbxImporter {
62
63
  return mesh;
63
64
  }
64
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
+ }
65
74
  console.error('[NodeFbxImporter] Failed:', err);
66
75
  return emptyMesh();
67
76
  }
@@ -188,54 +197,57 @@ function extractMeshFromGroup(group) {
188
197
  if (!posAttr)
189
198
  continue;
190
199
  const vertCount = posAttr.count;
191
- 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);
192
203
  {
193
- const worldMatrix = mesh.matrixWorld;
194
204
  const v = new THREE.Vector3();
195
205
  for (let vi = 0; vi < vertCount; vi++) {
196
206
  v.fromBufferAttribute(posAttr, vi);
197
207
  v.applyMatrix4(worldMatrix);
198
- positions[vi * 3] = v.x;
199
- positions[vi * 3 + 1] = v.y;
200
- 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;
201
211
  }
202
212
  }
203
- let normals = null;
213
+ let fullNormals = null;
204
214
  const normAttr = geometry.getAttribute('normal');
205
215
  if (normAttr && normAttr.count === vertCount) {
206
- normals = new Float32Array(vertCount * 3);
216
+ fullNormals = new Float32Array(vertCount * 3);
207
217
  const normalMatrix = new THREE.Matrix3().getNormalMatrix(mesh.matrixWorld);
208
218
  const n = new THREE.Vector3();
209
219
  for (let vi = 0; vi < vertCount; vi++) {
210
220
  n.fromBufferAttribute(normAttr, vi);
211
221
  n.applyMatrix3(normalMatrix).normalize();
212
- normals[vi * 3] = n.x;
213
- normals[vi * 3 + 1] = n.y;
214
- 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;
215
225
  }
216
226
  }
217
- let uvs = null;
227
+ let fullUvs = null;
218
228
  const uvAttr = geometry.getAttribute('uv');
219
229
  if (uvAttr && uvAttr.count === vertCount) {
220
- uvs = new Float32Array(vertCount * 2);
230
+ fullUvs = new Float32Array(vertCount * 2);
221
231
  for (let vi = 0; vi < vertCount; vi++) {
222
- uvs[vi * 2] = uvAttr.getX(vi);
223
- uvs[vi * 2 + 1] = uvAttr.getY(vi);
232
+ fullUvs[vi * 2] = uvAttr.getX(vi);
233
+ fullUvs[vi * 2 + 1] = uvAttr.getY(vi);
224
234
  }
225
235
  }
226
236
  const indexAttr = geometry.getIndex();
227
- let indices;
237
+ let fullIndices;
228
238
  if (indexAttr) {
229
- indices = new Uint32Array(indexAttr.count);
239
+ fullIndices = new Uint32Array(indexAttr.count);
230
240
  for (let ii = 0; ii < indexAttr.count; ii++)
231
- indices[ii] = indexAttr.getX(ii);
241
+ fullIndices[ii] = indexAttr.getX(ii);
232
242
  }
233
243
  else {
234
- indices = new Uint32Array(vertCount);
244
+ fullIndices = new Uint32Array(vertCount);
235
245
  for (let ii = 0; ii < vertCount; ii++)
236
- indices[ii] = ii;
246
+ fullIndices[ii] = ii;
237
247
  }
238
- let skinData;
248
+ // Pre-compute full skin data
249
+ let fullSkinBoneIndices;
250
+ let fullSkinBoneWeights;
239
251
  if (mesh.isSkinnedMesh) {
240
252
  const skinIndexAttr = geometry.getAttribute('skinIndex');
241
253
  const skinWeightAttr = geometry.getAttribute('skinWeight');
@@ -249,56 +261,137 @@ function extractMeshFromGroup(group) {
249
261
  remap[si] = idx >= 0 ? idx : 0;
250
262
  }
251
263
  }
252
- const boneIndices = new Uint32Array(vertCount * 4);
253
- const boneWeights = new Float32Array(vertCount * 4);
264
+ fullSkinBoneIndices = new Uint32Array(vertCount * 4);
265
+ fullSkinBoneWeights = new Float32Array(vertCount * 4);
254
266
  for (let vi = 0; vi < vertCount; vi++) {
255
267
  const si0 = skinIndexAttr.getX(vi);
256
268
  const si1 = skinIndexAttr.getY(vi);
257
269
  const si2 = skinIndexAttr.getZ(vi);
258
270
  const si3 = skinIndexAttr.getW(vi);
259
- boneIndices[vi * 4] = remap && si0 < remap.length ? remap[si0] : si0;
260
- boneIndices[vi * 4 + 1] = remap && si1 < remap.length ? remap[si1] : si1;
261
- boneIndices[vi * 4 + 2] = remap && si2 < remap.length ? remap[si2] : si2;
262
- boneIndices[vi * 4 + 3] = remap && si3 < remap.length ? remap[si3] : si3;
263
- boneWeights[vi * 4] = skinWeightAttr.getX(vi);
264
- boneWeights[vi * 4 + 1] = skinWeightAttr.getY(vi);
265
- boneWeights[vi * 4 + 2] = skinWeightAttr.getZ(vi);
266
- 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);
267
279
  }
268
- skinData = { boneIndices, boneWeights };
269
280
  }
270
281
  }
271
- const meshMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
272
- const matName = meshMaterial?.name || 'default';
273
- let matIndex;
274
- if (materialSet.has(matName)) {
275
- 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
+ }
276
355
  }
277
356
  else {
278
- matIndex = materials.length;
279
- materialSet.set(matName, matIndex);
280
- 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 });
281
375
  }
282
- subMeshes.push({ skinData, positions, normals, uvs, indices, materialIndex: matIndex });
283
376
  nodes.push({
284
377
  name: mesh.name || `mesh_${mi}`,
285
378
  parentIndex: -1,
286
379
  position: [0, 0, 0],
287
380
  rotation: [0, 0, 0, 1],
288
381
  scale: [1, 1, 1],
289
- meshIndices: [mi],
382
+ meshIndices: meshSubMeshIndices,
290
383
  });
291
384
  }
292
385
  if (materials.length === 0)
293
386
  materials.push({ name: 'default' });
294
- return {
387
+ return mergeByMaterial({
295
388
  subMeshes,
296
389
  materials,
297
390
  nodes,
298
391
  hasSkeleton,
299
392
  bones: allBones,
300
393
  animations: [],
301
- };
394
+ });
302
395
  }
303
396
  // ─── Hierarchical Group → ImportedMesh extraction ────────────────────────────
304
397
  function extractMeshFromGroupHierarchical(group) {
@@ -1,5 +1,6 @@
1
1
  import * as THREE from 'three';
2
2
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
3
+ import { mergeByMaterial } from './interfaces.js';
3
4
  import { PropertyBinding } from 'three';
4
5
  import { MaterialFieldType, PreviewPropertyFlag } from '../core/types.js';
5
6
  // ─── DOM shim (shared pattern from fbx-loader) ─────────────────────────────
@@ -333,14 +334,14 @@ function extractMeshes(gltf) {
333
334
  }
334
335
  if (materials.length === 0)
335
336
  materials.push({ name: 'default' });
336
- const imported = {
337
+ const imported = mergeByMaterial({
337
338
  subMeshes,
338
339
  materials,
339
340
  nodes,
340
341
  hasSkeleton,
341
342
  bones: allBones,
342
343
  animations: [],
343
- };
344
+ });
344
345
  const meshName = meshObjects[0]?.name || 'mesh';
345
346
  return [{ name: meshName, imported, hasSkeleton }];
346
347
  }
@@ -83,6 +83,13 @@ export interface ImportedSkinData {
83
83
  boneIndices: Uint32Array;
84
84
  boneWeights: Float32Array;
85
85
  }
86
+ /**
87
+ * Merge submeshes that share the same material into a single submesh.
88
+ * Only used when preserveHierarchy is OFF — vertices are already in world space.
89
+ * Skinned submeshes are left unmerged.
90
+ * Returns a new ImportedMesh with merged submeshes and a single root node.
91
+ */
92
+ export declare function mergeByMaterial(mesh: ImportedMesh): ImportedMesh;
86
93
  export interface DecodedPcm {
87
94
  channels: Float32Array[];
88
95
  sampleRate: number;
@@ -1 +1,107 @@
1
- export {};
1
+ // ─── Mesh Merging (flat mode) ────────────────────────────────────────────────
2
+ /**
3
+ * Merge submeshes that share the same material into a single submesh.
4
+ * Only used when preserveHierarchy is OFF — vertices are already in world space.
5
+ * Skinned submeshes are left unmerged.
6
+ * Returns a new ImportedMesh with merged submeshes and a single root node.
7
+ */
8
+ export function mergeByMaterial(mesh) {
9
+ // Group non-skinned submeshes by materialIndex
10
+ const groups = new Map(); // materialIndex → submesh indices
11
+ const skinnedIndices = [];
12
+ for (let i = 0; i < mesh.subMeshes.length; i++) {
13
+ const sm = mesh.subMeshes[i];
14
+ if (sm.skinData) {
15
+ skinnedIndices.push(i);
16
+ continue;
17
+ }
18
+ let list = groups.get(sm.materialIndex);
19
+ if (!list) {
20
+ list = [];
21
+ groups.set(sm.materialIndex, list);
22
+ }
23
+ list.push(i);
24
+ }
25
+ // Nothing to merge
26
+ if (groups.size === mesh.subMeshes.length - skinnedIndices.length && skinnedIndices.length === 0) {
27
+ // Already one submesh per material, just collapse to single node
28
+ return collapseToSingleNode(mesh);
29
+ }
30
+ const mergedSubMeshes = [];
31
+ // Merge each material group
32
+ for (const [matIdx, smIndices] of groups) {
33
+ if (smIndices.length === 1) {
34
+ mergedSubMeshes.push(mesh.subMeshes[smIndices[0]]);
35
+ continue;
36
+ }
37
+ // Calculate total sizes
38
+ let totalVerts = 0;
39
+ let totalIndices = 0;
40
+ let hasNormals = true;
41
+ let hasUvs = true;
42
+ for (const si of smIndices) {
43
+ const sm = mesh.subMeshes[si];
44
+ totalVerts += sm.positions.length / 3;
45
+ totalIndices += sm.indices.length;
46
+ if (!sm.normals)
47
+ hasNormals = false;
48
+ if (!sm.uvs)
49
+ hasUvs = false;
50
+ }
51
+ const positions = new Float32Array(totalVerts * 3);
52
+ const normals = hasNormals ? new Float32Array(totalVerts * 3) : null;
53
+ const uvs = hasUvs ? new Float32Array(totalVerts * 2) : null;
54
+ const indices = new Uint32Array(totalIndices);
55
+ let vertOffset = 0;
56
+ let idxOffset = 0;
57
+ for (const si of smIndices) {
58
+ const sm = mesh.subMeshes[si];
59
+ const vc = sm.positions.length / 3;
60
+ positions.set(sm.positions, vertOffset * 3);
61
+ if (normals && sm.normals)
62
+ normals.set(sm.normals, vertOffset * 3);
63
+ if (uvs && sm.uvs)
64
+ uvs.set(sm.uvs, vertOffset * 2);
65
+ // Offset indices to account for merged vertex buffer
66
+ for (let j = 0; j < sm.indices.length; j++) {
67
+ indices[idxOffset + j] = sm.indices[j] + vertOffset;
68
+ }
69
+ vertOffset += vc;
70
+ idxOffset += sm.indices.length;
71
+ }
72
+ mergedSubMeshes.push({ positions, normals, uvs, indices, materialIndex: matIdx });
73
+ }
74
+ // Append skinned submeshes unchanged
75
+ for (const si of skinnedIndices) {
76
+ mergedSubMeshes.push(mesh.subMeshes[si]);
77
+ }
78
+ // Single node referencing all submeshes — the user sees one mesh
79
+ const node = {
80
+ name: mesh.nodes[0]?.name || 'mesh',
81
+ parentIndex: -1,
82
+ position: [0, 0, 0],
83
+ rotation: [0, 0, 0, 1],
84
+ scale: [1, 1, 1],
85
+ meshIndices: mergedSubMeshes.map((_, i) => i),
86
+ };
87
+ return {
88
+ subMeshes: mergedSubMeshes,
89
+ materials: mesh.materials,
90
+ nodes: [node],
91
+ hasSkeleton: mesh.hasSkeleton,
92
+ bones: mesh.bones,
93
+ animations: mesh.animations,
94
+ };
95
+ }
96
+ /** Collapse N flat nodes into a single node referencing all submeshes */
97
+ function collapseToSingleNode(mesh) {
98
+ const node = {
99
+ name: mesh.nodes[0]?.name || 'mesh',
100
+ parentIndex: -1,
101
+ position: [0, 0, 0],
102
+ rotation: [0, 0, 0, 1],
103
+ scale: [1, 1, 1],
104
+ meshIndices: mesh.subMeshes.map((_, i) => i),
105
+ };
106
+ return { ...mesh, nodes: [node] };
107
+ }
@@ -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...');
@@ -367,8 +370,14 @@ function resolveAssignedMaterial(meshAsset, subMeshIndex, sourceMaterialName, as
367
370
  }
368
371
  function toMaterialPropertyValue(prop, assetsById) {
369
372
  const textureAsset = prop.textureAssetId ? assetsById.get(prop.textureAssetId) : undefined;
373
+ // Use previewFlag name as fallback when fieldName is empty (e.g. alphaTest)
374
+ const flagNames = {
375
+ 1: 'mainTex', 2: 'tint', 3: 'alphaTest',
376
+ 4: 'metalness', 5: 'roughness', 6: 'normalMap', 7: 'emissiveMap', 8: 'emissive',
377
+ };
378
+ const fieldName = prop.fieldName.trim() || flagNames[prop.previewFlag] || '';
370
379
  return {
371
- fieldName: prop.fieldName,
380
+ fieldName,
372
381
  value: [prop.value[0], prop.value[1], prop.value[2], prop.value[3]],
373
382
  textureId: textureAsset?.stringId ?? '',
374
383
  };
@@ -390,7 +399,7 @@ function applyMaterialAssignments(meshAsset, metadata, assetsById, materialsBySt
390
399
  continue;
391
400
  const config = assigned.settings.materialConfig;
392
401
  const propertyValues = config.properties
393
- .filter((prop) => prop.fieldName.trim().length > 0)
402
+ .filter((prop) => prop.fieldName.trim().length > 0 || prop.previewFlag > 0)
394
403
  .map((prop) => toMaterialPropertyValue(prop, assetsById));
395
404
  materials[i].schemaId = config.schemaId || assigned.stringId;
396
405
  materials[i].properties = propertyValues;
@@ -488,3 +497,51 @@ export function buildAnimationDataBlobsV2(imported) {
488
497
  }
489
498
  return results;
490
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.27",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "stowkit": "./dist/cli.js"
@@ -17,7 +17,7 @@
17
17
  "dev": "tsc --watch"
18
18
  },
19
19
  "dependencies": {
20
- "@series-inc/stowkit-packer-gui": "^0.1.12",
20
+ "@series-inc/stowkit-packer-gui": "^0.1.16",
21
21
  "@series-inc/stowkit-editor": "^0.1.2",
22
22
  "draco3d": "^1.5.7",
23
23
  "fbx-parser": "^2.1.3",