@series-inc/stowkit-cli 0.1.29 → 0.1.31

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
@@ -38,7 +38,9 @@ checkForUpdate();
38
38
  function printUsage() {
39
39
  console.log(`
40
40
  Usage:
41
- stowkit init [dir] Initialize a StowKit project
41
+ stowkit init [dir] Initialize a StowKit project (interactive menu)
42
+ stowkit init --with-engine Initialize with 3D engine included
43
+ stowkit init --no-engine Initialize without 3D engine (skip prompt)
42
44
  stowkit init --update [dir] Update AI skill files to match installed CLI version
43
45
  stowkit build [dir] Full build: scan + process + pack
44
46
  stowkit scan [dir] Detect new assets, generate .stowmeta defaults
@@ -105,7 +107,11 @@ async function main() {
105
107
  try {
106
108
  switch (command) {
107
109
  case 'init':
108
- await initProject(projectDir, { update: args.includes('--update') });
110
+ await initProject(projectDir, {
111
+ update: args.includes('--update'),
112
+ withEngine: args.includes('--with-engine'),
113
+ noEngine: args.includes('--no-engine'),
114
+ });
109
115
  break;
110
116
  case 'update': {
111
117
  const currentVersion = getVersion();
@@ -226,7 +232,7 @@ async function main() {
226
232
  process.exit(1);
227
233
  }
228
234
  const resolvedProject = path.resolve(projectDir);
229
- await startServer({ port, projectDir: resolvedProject, staticApps: { '/': packerDir } });
235
+ await startServer({ port, projectDir: resolvedProject, force, staticApps: { '/': packerDir } });
230
236
  console.log(`\n Packer: http://localhost:${port}\n`);
231
237
  openBrowser(`http://localhost:${port}`);
232
238
  break;
@@ -240,11 +246,11 @@ async function main() {
240
246
  const resolvedProject = path.resolve(projectDir);
241
247
  const packerPort = port + 1;
242
248
  // Start editor server
243
- await startServer({ port, projectDir: resolvedProject, staticApps: { '/': editorDir } });
249
+ await startServer({ port, projectDir: resolvedProject, force, staticApps: { '/': editorDir } });
244
250
  // Start packer on next port if installed
245
251
  const packerDir = resolveAppDir('@series-inc/stowkit-packer-gui', 'stowkit-packer-gui');
246
252
  if (packerDir) {
247
- await startServer({ port: packerPort, projectDir: resolvedProject, staticApps: { '/': packerDir } });
253
+ await startServer({ port: packerPort, projectDir: resolvedProject, force, staticApps: { '/': packerDir } });
248
254
  console.log(`\n Editor: http://localhost:${port}`);
249
255
  console.log(` Packer: http://localhost:${packerPort}\n`);
250
256
  }
@@ -255,7 +261,7 @@ async function main() {
255
261
  break;
256
262
  }
257
263
  case 'serve':
258
- await startServer({ port, projectDir: projectDir !== '.' ? path.resolve(projectDir) : undefined });
264
+ await startServer({ port, force, projectDir: projectDir !== '.' ? path.resolve(projectDir) : undefined });
259
265
  break;
260
266
  default:
261
267
  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) {
package/dist/init.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export interface InitOptions {
2
2
  update?: boolean;
3
+ withEngine?: boolean;
4
+ noEngine?: boolean;
3
5
  }
4
6
  export declare function initProject(projectDir: string, opts?: InitOptions): Promise<void>;
package/dist/init.js CHANGED
@@ -1,6 +1,36 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
+ import * as readline from 'node:readline';
3
4
  import { fileURLToPath } from 'node:url';
5
+ async function promptMenu(question, choices) {
6
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7
+ return new Promise((resolve) => {
8
+ console.log(`\n${question}\n`);
9
+ for (let i = 0; i < choices.length; i++) {
10
+ console.log(` ${i + 1}) ${choices[i]}`);
11
+ }
12
+ console.log('');
13
+ const ask = () => {
14
+ rl.question('Choose [1]: ', (answer) => {
15
+ const trimmed = answer.trim();
16
+ if (trimmed === '') {
17
+ rl.close();
18
+ resolve(0);
19
+ return;
20
+ }
21
+ const num = parseInt(trimmed, 10);
22
+ if (num >= 1 && num <= choices.length) {
23
+ rl.close();
24
+ resolve(num - 1);
25
+ return;
26
+ }
27
+ console.log(` Please enter 1-${choices.length}`);
28
+ ask();
29
+ });
30
+ };
31
+ ask();
32
+ });
33
+ }
4
34
  export async function initProject(projectDir, opts) {
5
35
  const absDir = path.resolve(projectDir);
6
36
  const configPath = path.join(absDir, '.felicityproject');
@@ -15,6 +45,7 @@ export async function initProject(projectDir, opts) {
15
45
  process.exit(1);
16
46
  }
17
47
  await copySkillFiles(absDir);
48
+ await copyEngineSkillFiles(absDir);
18
49
  console.log('Updated AI skill files:');
19
50
  console.log(' .claude/skills/stowkit/SKILL.md');
20
51
  console.log(' .cursor/rules/stowkit.mdc');
@@ -29,6 +60,16 @@ export async function initProject(projectDir, opts) {
29
60
  catch {
30
61
  // Does not exist — create it
31
62
  }
63
+ // Prompt for engine setup unless explicitly set via flag
64
+ let withEngine = opts?.withEngine ?? false;
65
+ const noEngine = opts?.noEngine ?? false;
66
+ if (!withEngine && !noEngine && process.stdin.isTTY) {
67
+ const choice = await promptMenu('What would you like to set up?', [
68
+ 'StowKit (asset pipeline only)',
69
+ 'StowKit + 3D Engine (includes @series-inc/rundot-3d-engine)',
70
+ ]);
71
+ withEngine = choice === 1;
72
+ }
32
73
  // Create srcArtDir with .gitignore for cache files
33
74
  const srcArtDir = 'assets';
34
75
  await fs.mkdir(path.join(absDir, srcArtDir), { recursive: true });
@@ -72,10 +113,36 @@ export async function initProject(projectDir, opts) {
72
113
  console.log(` Output dir: public/cdn-assets/`);
73
114
  console.log(` Config: .felicityproject`);
74
115
  console.log(` AI skills: .claude/skills/stowkit/SKILL.md, .cursor/rules/stowkit.mdc`);
116
+ // Install engine if selected
117
+ if (withEngine) {
118
+ await installEngine(absDir);
119
+ }
75
120
  console.log('');
76
121
  console.log('Drop your assets (PNG, JPG, FBX, WAV, etc.) into assets/');
77
122
  console.log('Then run: stowkit build');
78
123
  }
124
+ async function installEngine(absDir) {
125
+ console.log('');
126
+ console.log('Installing @series-inc/rundot-3d-engine and three...');
127
+ const { execSync } = await import('node:child_process');
128
+ try {
129
+ execSync('npm install @series-inc/rundot-3d-engine three', {
130
+ cwd: absDir,
131
+ stdio: 'inherit',
132
+ });
133
+ }
134
+ catch {
135
+ console.error('Failed to install engine packages. You can install manually:');
136
+ console.error(' npm install @series-inc/rundot-3d-engine three');
137
+ return;
138
+ }
139
+ await copyEngineSkillFiles(absDir);
140
+ console.log('');
141
+ console.log(' 3D Engine installed:');
142
+ console.log(' @series-inc/rundot-3d-engine');
143
+ console.log(' three');
144
+ console.log(' AI skills: .claude/skills/stowkit-engine/SKILL.md, .cursor/rules/stowkit-engine.mdc');
145
+ }
79
146
  async function copySkillFiles(absDir) {
80
147
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
81
148
  const skillSrc = path.resolve(thisDir, '../skill.md');
@@ -92,3 +159,34 @@ async function copySkillFiles(absDir) {
92
159
  // Skill file not found in package — skip silently
93
160
  }
94
161
  }
162
+ async function copyEngineSkillFiles(absDir) {
163
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
164
+ const candidates = [
165
+ // Monorepo dev: sibling folder
166
+ path.resolve(thisDir, '../../stowkit-engine/SKILL.md'),
167
+ // Installed in project's node_modules
168
+ path.join(absDir, 'node_modules/@series-inc/rundot-3d-engine/SKILL.md'),
169
+ // Installed as dep of CLI: nested node_modules
170
+ path.resolve(thisDir, '../node_modules/@series-inc/rundot-3d-engine/SKILL.md'),
171
+ // Hoisted in global node_modules
172
+ path.resolve(thisDir, '../../../@series-inc/rundot-3d-engine/SKILL.md'),
173
+ ];
174
+ let skillContent = null;
175
+ for (const candidate of candidates) {
176
+ try {
177
+ skillContent = await fs.readFile(candidate, 'utf-8');
178
+ break;
179
+ }
180
+ catch {
181
+ // not found, try next
182
+ }
183
+ }
184
+ if (!skillContent)
185
+ return;
186
+ const claudeDir = path.join(absDir, '.claude', 'skills', 'stowkit-engine');
187
+ await fs.mkdir(claudeDir, { recursive: true });
188
+ await fs.writeFile(path.join(claudeDir, 'SKILL.md'), skillContent);
189
+ const cursorDir = path.join(absDir, '.cursor', 'rules');
190
+ await fs.mkdir(cursorDir, { recursive: true });
191
+ await fs.writeFile(path.join(cursorDir, 'stowkit-engine.mdc'), `---\ndescription: Rundot 3D Engine — VenusGame, GameObject, Component, physics, animations, StowKit asset loading\nalwaysApply: true\n---\n\n${skillContent}`);
192
+ }
@@ -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.31",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "stowkit": "./dist/cli.js"
@@ -17,8 +17,8 @@
17
17
  "dev": "tsc --watch"
18
18
  },
19
19
  "dependencies": {
20
- "@series-inc/stowkit-packer-gui": "^0.1.16",
21
- "@series-inc/stowkit-editor": "^0.1.2",
20
+ "@series-inc/stowkit-packer-gui": "^0.1.17",
21
+ "@series-inc/stowkit-editor": "^0.1.3",
22
22
  "draco3d": "^1.5.7",
23
23
  "fbx-parser": "^2.1.3",
24
24
  "@strangeape/ffmpeg-audio-wasm": "^0.1.0",
package/skill.md CHANGED
@@ -36,7 +36,8 @@ A StowKit project has a `.felicityproject` JSON file at its root:
36
36
  ## CLI Commands
37
37
 
38
38
  ```bash
39
- stowkit init [dir] # Scaffold a new project (creates .felicityproject, assets/, public/cdn-assets/)
39
+ stowkit init [dir] # Scaffold a new project (interactive menu: pipeline only or with 3D engine)
40
+ stowkit init --with-engine # Scaffold with 3D engine pre-installed
40
41
  stowkit init --update [dir] # Update AI skill files to match installed CLI version
41
42
  stowkit update # Update CLI to latest version and refresh skill files
42
43
  stowkit version # Show installed version