@series-inc/stowkit-cli 0.1.14 → 0.1.16

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.
@@ -0,0 +1,4 @@
1
+ export declare function renameAsset(projectDir: string, assetPath: string, newName: string): Promise<void>;
2
+ export declare function moveAsset(projectDir: string, assetPath: string, targetFolder: string): Promise<void>;
3
+ export declare function deleteAsset(projectDir: string, assetPath: string): Promise<void>;
4
+ export declare function setStringId(projectDir: string, assetPath: string, newStringId: string): Promise<void>;
@@ -0,0 +1,122 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { readProjectConfig, renameFile, deleteFile, readFile, } from './node-fs.js';
4
+ import { readStowmeta, writeStowmeta, } from './app/stowmeta-io.js';
5
+ // ─── Rename ──────────────────────────────────────────────────────────────────
6
+ export async function renameAsset(projectDir, assetPath, newName) {
7
+ const config = await readProjectConfig(projectDir);
8
+ // Build new ID: same folder, new filename (preserve original extension)
9
+ const folder = assetPath.includes('/') ? assetPath.slice(0, assetPath.lastIndexOf('/') + 1) : '';
10
+ const oldBase = assetPath.split('/').pop() ?? assetPath;
11
+ const extMatch = oldBase.match(/\.[^.]+$/);
12
+ const ext = extMatch ? extMatch[0] : '';
13
+ const fullNewName = ext && !newName.endsWith(ext) ? newName + ext : newName;
14
+ const newId = folder + fullNewName;
15
+ if (newId === assetPath) {
16
+ console.log('Nothing to rename — same name.');
17
+ return;
18
+ }
19
+ // Verify source exists
20
+ const sourceData = await readFile(config.srcArtDir, assetPath);
21
+ if (!sourceData) {
22
+ console.error(`Asset not found: ${assetPath}`);
23
+ process.exit(1);
24
+ }
25
+ // Rename source, stowmeta, stowcache
26
+ await renameFile(config.srcArtDir, assetPath, newId);
27
+ await renameFile(config.srcArtDir, `${assetPath}.stowmeta`, `${newId}.stowmeta`);
28
+ await renameFile(config.srcArtDir, `${assetPath}.stowcache`, `${newId}.stowcache`);
29
+ // For GLB containers, rename the .children cache directory
30
+ const meta = await readStowmeta(config.srcArtDir, newId);
31
+ if (meta && meta.type === 'glbContainer') {
32
+ await renameFile(config.srcArtDir, `${assetPath}.children`, `${newId}.children`);
33
+ }
34
+ console.log(`Renamed: ${assetPath} → ${newId}`);
35
+ }
36
+ // ─── Move ────────────────────────────────────────────────────────────────────
37
+ export async function moveAsset(projectDir, assetPath, targetFolder) {
38
+ const config = await readProjectConfig(projectDir);
39
+ const fileName = assetPath.split('/').pop() ?? assetPath;
40
+ const newId = targetFolder ? `${targetFolder}/${fileName}` : fileName;
41
+ if (newId === assetPath) {
42
+ console.log('Nothing to move — already in target folder.');
43
+ return;
44
+ }
45
+ // Verify source exists
46
+ const sourceData = await readFile(config.srcArtDir, assetPath);
47
+ if (!sourceData) {
48
+ console.error(`Asset not found: ${assetPath}`);
49
+ process.exit(1);
50
+ }
51
+ // Move source, stowmeta, stowcache
52
+ await renameFile(config.srcArtDir, assetPath, newId);
53
+ await renameFile(config.srcArtDir, `${assetPath}.stowmeta`, `${newId}.stowmeta`);
54
+ await renameFile(config.srcArtDir, `${assetPath}.stowcache`, `${newId}.stowcache`);
55
+ // For GLB containers, move the .children cache directory and update child references
56
+ const meta = await readStowmeta(config.srcArtDir, newId);
57
+ if (meta && meta.type === 'glbContainer') {
58
+ await renameFile(config.srcArtDir, `${assetPath}.children`, `${newId}.children`);
59
+ // Update texture references in material configs
60
+ const glbMeta = meta;
61
+ const oldPrefix = assetPath + '/';
62
+ const newPrefix = newId + '/';
63
+ let updated = false;
64
+ for (const child of glbMeta.children ?? []) {
65
+ if (child.materialConfig) {
66
+ for (const prop of child.materialConfig.properties) {
67
+ if (prop.textureAsset?.startsWith(oldPrefix)) {
68
+ prop.textureAsset = newPrefix + prop.textureAsset.slice(oldPrefix.length);
69
+ updated = true;
70
+ }
71
+ }
72
+ }
73
+ if (child.materialOverrides) {
74
+ for (const [k, v] of Object.entries(child.materialOverrides)) {
75
+ if (v?.startsWith(oldPrefix)) {
76
+ child.materialOverrides[k] = newPrefix + v.slice(oldPrefix.length);
77
+ updated = true;
78
+ }
79
+ }
80
+ }
81
+ }
82
+ if (updated) {
83
+ await writeStowmeta(config.srcArtDir, newId, glbMeta);
84
+ }
85
+ }
86
+ console.log(`Moved: ${assetPath} → ${newId}`);
87
+ }
88
+ // ─── Delete ──────────────────────────────────────────────────────────────────
89
+ export async function deleteAsset(projectDir, assetPath) {
90
+ const config = await readProjectConfig(projectDir);
91
+ // Verify source exists
92
+ const sourceData = await readFile(config.srcArtDir, assetPath);
93
+ if (!sourceData) {
94
+ console.error(`Asset not found: ${assetPath}`);
95
+ process.exit(1);
96
+ }
97
+ // For GLB containers, also delete the .children cache directory
98
+ const meta = await readStowmeta(config.srcArtDir, assetPath);
99
+ if (meta && meta.type === 'glbContainer') {
100
+ const childrenDir = path.join(config.srcArtDir, `${assetPath}.children`);
101
+ await fs.rm(childrenDir, { recursive: true, force: true }).catch(() => { });
102
+ }
103
+ // Delete source, stowmeta, stowcache
104
+ await deleteFile(config.srcArtDir, assetPath);
105
+ await deleteFile(config.srcArtDir, `${assetPath}.stowmeta`);
106
+ await deleteFile(config.srcArtDir, `${assetPath}.stowcache`);
107
+ console.log(`Deleted: ${assetPath}`);
108
+ }
109
+ // ─── Set stringId ────────────────────────────────────────────────────────────
110
+ export async function setStringId(projectDir, assetPath, newStringId) {
111
+ const config = await readProjectConfig(projectDir);
112
+ const meta = await readStowmeta(config.srcArtDir, assetPath);
113
+ if (!meta) {
114
+ console.error(`No .stowmeta found for: ${assetPath}`);
115
+ console.error('Run `stowkit build` or `stowkit scan` first to generate it.');
116
+ process.exit(1);
117
+ }
118
+ const oldStringId = meta.stringId;
119
+ meta.stringId = newStringId;
120
+ await writeStowmeta(config.srcArtDir, assetPath, meta);
121
+ console.log(`Updated stringId: "${oldStringId}" → "${newStringId}" (${assetPath})`);
122
+ }
package/dist/cli.js CHANGED
@@ -6,6 +6,8 @@ import { fullBuild, scanProject, showStatus } from './orchestrator.js';
6
6
  import { startServer } from './server.js';
7
7
  import { initProject } from './init.js';
8
8
  import { cleanupProject } from './cleanup.js';
9
+ import { createMaterial } from './create-material.js';
10
+ import { renameAsset, moveAsset, deleteAsset, setStringId } from './asset-commands.js';
9
11
  const args = process.argv.slice(2);
10
12
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
11
13
  function printUsage() {
@@ -17,6 +19,11 @@ Usage:
17
19
  stowkit process [dir] Compress assets (respects cache)
18
20
  stowkit status [dir] Show project summary, stale asset count
19
21
  stowkit clean [dir] Delete orphaned .stowcache and .stowmeta files
22
+ stowkit create-material <path> Create a .stowmat material file
23
+ stowkit rename <path> <name> Rename an asset file
24
+ stowkit move <path> <folder> Move an asset to a different folder
25
+ stowkit delete <path> Delete an asset and its sidecar files
26
+ stowkit set-id <path> <id> Change an asset's stringId
20
27
  stowkit packer [dir] Open the packer GUI
21
28
  stowkit editor [dir] Open the level editor
22
29
  stowkit serve [dir] Start API server only (no GUI)
@@ -25,6 +32,7 @@ Options:
25
32
  --force Ignore cache and reprocess everything
26
33
  --verbose Detailed output
27
34
  --port Server port (default 3210)
35
+ --schema Material schema template: pbr (default), unlit, or custom name
28
36
  --help Show this help message
29
37
  `.trim());
30
38
  }
@@ -94,6 +102,54 @@ async function main() {
94
102
  case 'clean':
95
103
  await cleanupProject(projectDir, { verbose });
96
104
  break;
105
+ case 'create-material': {
106
+ // For create-material, the positional arg is the material path, not project dir
107
+ const matPath = args.find(a => !a.startsWith('-') && a !== command);
108
+ if (!matPath) {
109
+ console.error('Usage: stowkit create-material <path> [--schema pbr|unlit|<name>]');
110
+ process.exit(1);
111
+ }
112
+ const schemaIdx = args.indexOf('--schema');
113
+ const schema = schemaIdx >= 0 ? args[schemaIdx + 1] : undefined;
114
+ await createMaterial('.', matPath, { schema });
115
+ break;
116
+ }
117
+ case 'rename': {
118
+ const positional = args.filter(a => !a.startsWith('-') && a !== command);
119
+ if (positional.length < 2) {
120
+ console.error('Usage: stowkit rename <asset-path> <new-name>');
121
+ process.exit(1);
122
+ }
123
+ await renameAsset('.', positional[0], positional[1]);
124
+ break;
125
+ }
126
+ case 'move': {
127
+ const positional = args.filter(a => !a.startsWith('-') && a !== command);
128
+ if (positional.length < 2) {
129
+ console.error('Usage: stowkit move <asset-path> <target-folder>');
130
+ process.exit(1);
131
+ }
132
+ await moveAsset('.', positional[0], positional[1]);
133
+ break;
134
+ }
135
+ case 'delete': {
136
+ const assetPath = args.find(a => !a.startsWith('-') && a !== command);
137
+ if (!assetPath) {
138
+ console.error('Usage: stowkit delete <asset-path>');
139
+ process.exit(1);
140
+ }
141
+ await deleteAsset('.', assetPath);
142
+ break;
143
+ }
144
+ case 'set-id': {
145
+ const positional = args.filter(a => !a.startsWith('-') && a !== command);
146
+ if (positional.length < 2) {
147
+ console.error('Usage: stowkit set-id <asset-path> <new-string-id>');
148
+ process.exit(1);
149
+ }
150
+ await setStringId('.', positional[0], positional[1]);
151
+ break;
152
+ }
97
153
  case 'packer': {
98
154
  const packerDir = resolveAppDir('@series-inc/stowkit-packer-gui', 'stowkit-packer-gui');
99
155
  if (!packerDir) {
@@ -0,0 +1,3 @@
1
+ export declare function createMaterial(projectDir: string, relativePath: string, opts?: {
2
+ schema?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,47 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { readProjectConfig } from './node-fs.js';
4
+ const TEMPLATES = {
5
+ pbr: [
6
+ { fieldName: 'BaseColor', fieldType: 'texture', previewFlag: 'mainTex', value: [1, 1, 1, 1], textureAsset: null },
7
+ { fieldName: 'Normal', fieldType: 'texture', previewFlag: 'none', value: [0, 0, 1, 0], textureAsset: null },
8
+ { fieldName: 'Tint', fieldType: 'color', previewFlag: 'tint', value: [1, 1, 1, 1], textureAsset: null },
9
+ { fieldName: 'AlphaTest', fieldType: 'float', previewFlag: 'alphaTest', value: [0.5, 0, 0, 0], textureAsset: null },
10
+ ],
11
+ unlit: [
12
+ { fieldName: 'BaseColor', fieldType: 'texture', previewFlag: 'mainTex', value: [1, 1, 1, 1], textureAsset: null },
13
+ { fieldName: 'Tint', fieldType: 'color', previewFlag: 'tint', value: [1, 1, 1, 1], textureAsset: null },
14
+ ],
15
+ };
16
+ export async function createMaterial(projectDir, relativePath, opts) {
17
+ const config = await readProjectConfig(projectDir);
18
+ // Ensure .stowmat extension
19
+ if (!relativePath.endsWith('.stowmat')) {
20
+ relativePath += '.stowmat';
21
+ }
22
+ const fullPath = path.join(config.srcArtDir, relativePath);
23
+ // Don't overwrite existing
24
+ try {
25
+ await fs.access(fullPath);
26
+ console.error(`Material already exists: ${relativePath}`);
27
+ process.exit(1);
28
+ }
29
+ catch {
30
+ // Does not exist — good
31
+ }
32
+ const schemaName = opts?.schema ?? 'pbr';
33
+ const properties = TEMPLATES[schemaName] ?? [];
34
+ const mat = {
35
+ version: 1,
36
+ schemaName,
37
+ properties,
38
+ };
39
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
40
+ await fs.writeFile(fullPath, JSON.stringify(mat, null, 2) + '\n');
41
+ console.log(`Created material: ${relativePath}`);
42
+ console.log(` Schema: ${schemaName}`);
43
+ console.log(` Properties: ${properties.map(p => p.fieldName).join(', ') || '(none)'}`);
44
+ if (!TEMPLATES[schemaName]) {
45
+ console.log(` (Custom schema — no template properties. Edit the file to add properties.)`);
46
+ }
47
+ }
@@ -98,58 +98,8 @@ export async function fullBuild(projectDir, opts) {
98
98
  }
99
99
  assets.push(asset);
100
100
  assetsById.set(id, asset);
101
- // Expand GlbContainer children
102
- if (type === AssetType.GlbContainer && meta.type === 'glbContainer') {
103
- const glbMeta = meta;
104
- if (glbMeta.children && glbMeta.children.length > 0) {
105
- asset.status = 'ready'; // Container itself is always "ready"
106
- for (const child of glbMeta.children) {
107
- const childId = `${id}/${child.name}`;
108
- const baseName = child.name.replace(/\.[^.]+$/, '');
109
- // Read settings from inline child entry
110
- const { type: cType, settings: cSettings } = glbChildToAssetSettings(child);
111
- const childAsset = {
112
- id: childId,
113
- fileName: child.name,
114
- stringId: child.stringId || baseName,
115
- type: cType,
116
- status: 'pending',
117
- settings: cSettings,
118
- sourceSize: 0,
119
- processedSize: 0,
120
- parentId: id,
121
- locked: true,
122
- };
123
- // Check cache
124
- if (!force && child.cache) {
125
- const cached = await readCacheBlobs(config.srcArtDir, childId);
126
- if (cached) {
127
- for (const [key, data] of cached) {
128
- if (key === `${childId}:__metadata__`) {
129
- try {
130
- childAsset.metadata = JSON.parse(new TextDecoder().decode(data));
131
- }
132
- catch { /* skip */ }
133
- }
134
- else {
135
- BlobStore.setProcessed(key, data);
136
- }
137
- }
138
- childAsset.status = 'ready';
139
- childAsset.processedSize = BlobStore.getProcessed(childId)?.length ?? 0;
140
- if (verbose)
141
- console.log(` [cached] ${childId}`);
142
- }
143
- }
144
- assets.push(childAsset);
145
- assetsById.set(childId, childAsset);
146
- }
147
- }
148
- else {
149
- // No children manifest yet — need to parse GLB to discover them
150
- // This will be handled during processing
151
- }
152
- }
101
+ // GlbContainers are always extracted in section 2b (even if children exist in manifest)
102
+ // so that preserveHierarchy changes, re-exports, etc. are always reflected.
153
103
  }
154
104
  // Materials from .stowmat files
155
105
  for (const matFile of scan.matFiles) {
@@ -178,10 +128,9 @@ export async function fullBuild(projectDir, opts) {
178
128
  assets.push(asset);
179
129
  assetsById.set(id, asset);
180
130
  }
181
- // 2b. Process GLB containers that need extraction (no children yet)
182
- // Store extract results so mesh/animation children can be processed after encoder init
131
+ // 2b. Extract all GLB containers (always re-parse so preserveHierarchy etc. are reflected)
183
132
  const glbExtracts = new Map();
184
- const glbContainers = assets.filter(a => a.type === AssetType.GlbContainer && a.status === 'pending');
133
+ const glbContainers = assets.filter(a => a.type === AssetType.GlbContainer);
185
134
  for (const container of glbContainers) {
186
135
  try {
187
136
  const sourceData = await readFile(config.srcArtDir, container.id);
@@ -237,8 +186,6 @@ export async function fullBuild(projectDir, opts) {
237
186
  // Create child assets from inline entries
238
187
  for (const child of childrenManifest) {
239
188
  const childId = `${container.id}/${child.name}`;
240
- if (assetsById.has(childId))
241
- continue;
242
189
  const baseName = child.name.replace(/\.[^.]+$/, '');
243
190
  const { type: cType, settings: cSettings } = glbChildToAssetSettings(child);
244
191
  const childAsset = {
@@ -274,6 +221,27 @@ export async function fullBuild(projectDir, opts) {
274
221
  childAsset.processedSize = result.processedSize;
275
222
  }
276
223
  }
224
+ // Check cache for texture/mesh children (skip if --force)
225
+ if (childAsset.status === 'pending' && !force && child.cache) {
226
+ const cached = await readCacheBlobs(config.srcArtDir, childId);
227
+ if (cached) {
228
+ for (const [key, data] of cached) {
229
+ if (key === `${childId}:__metadata__`) {
230
+ try {
231
+ childAsset.metadata = JSON.parse(new TextDecoder().decode(data));
232
+ }
233
+ catch { /* skip */ }
234
+ }
235
+ else {
236
+ BlobStore.setProcessed(key, data);
237
+ }
238
+ }
239
+ childAsset.status = 'ready';
240
+ childAsset.processedSize = BlobStore.getProcessed(childId)?.length ?? 0;
241
+ if (verbose)
242
+ console.log(` [cached] ${childId}`);
243
+ }
244
+ }
277
245
  assets.push(childAsset);
278
246
  assetsById.set(childId, childAsset);
279
247
  }
@@ -318,10 +286,9 @@ export async function fullBuild(projectDir, opts) {
318
286
  console.error(` [glb] ${container.id} FAILED: ${container.error}`);
319
287
  }
320
288
  }
321
- // 2c. Process GLB mesh children + stale assets via worker pool
322
- const glbMeshChildren = assets.filter(a => a.status === 'pending' && a.parentId && (a.type === AssetType.StaticMesh || a.type === AssetType.SkinnedMesh));
323
- const pending = assets.filter(a => a.status === 'pending' && !a.parentId);
324
- const totalWork = glbMeshChildren.length + pending.length;
289
+ // 2c. Process all pending assets via worker pool (one queue, no split)
290
+ const pending = assets.filter(a => a.status === 'pending');
291
+ const totalWork = pending.length;
325
292
  if (totalWork === 0) {
326
293
  if (verbose)
327
294
  console.log('All assets cached, nothing to process.');
@@ -330,39 +297,34 @@ export async function fullBuild(projectDir, opts) {
330
297
  console.log(`Processing ${totalWork} asset(s)...`);
331
298
  const pool = new WorkerPool({ wasmDir: opts?.wasmDir });
332
299
  let processed = 0;
333
- // Process GLB mesh children
334
- for (const child of glbMeshChildren) {
335
- const extract = glbExtracts.get(child.parentId);
336
- if (!extract)
337
- continue;
338
- const mesh = extract.meshes.find(m => `${child.parentId}/${m.name}` === child.id);
339
- if (!mesh)
340
- continue;
341
- try {
342
- const { result, blobs } = await pool.processExtractedMesh({ childId: child.id, imported: mesh.imported, hasSkeleton: mesh.hasSkeleton, stringId: child.stringId, settings: child.settings });
343
- for (const [key, data] of blobs)
344
- BlobStore.setProcessed(key, data);
345
- child.status = 'ready';
346
- child.metadata = result.metadata;
347
- child.processedSize = result.processedSize;
348
- processed++;
349
- if (verbose)
350
- console.log(` [${processed}/${totalWork}] ${child.id} (glb-mesh)`);
351
- }
352
- catch (err) {
353
- child.status = 'error';
354
- child.error = err instanceof Error ? err.message : String(err);
355
- processed++;
356
- }
357
- }
358
- // Process remaining pending assets with concurrency limit
359
300
  const queue = [...pending];
360
301
  async function processNext() {
361
302
  while (queue.length > 0) {
362
303
  const asset = queue.shift();
363
304
  const id = asset.id;
364
305
  try {
365
- // Load source (GLB texture children have source in BlobStore already)
306
+ // GLB mesh children use processExtractedMesh (needs parsed mesh data)
307
+ const isMeshChild = asset.parentId &&
308
+ (asset.type === AssetType.StaticMesh || asset.type === AssetType.SkinnedMesh);
309
+ if (isMeshChild) {
310
+ const extract = glbExtracts.get(asset.parentId);
311
+ if (!extract)
312
+ throw new Error(`No extract for parent ${asset.parentId}`);
313
+ const mesh = extract.meshes.find(m => `${asset.parentId}/${m.name}` === id);
314
+ if (!mesh)
315
+ throw new Error(`Mesh not found in extract: ${id}`);
316
+ const { result, blobs } = await pool.processExtractedMesh({ childId: id, imported: mesh.imported, hasSkeleton: mesh.hasSkeleton, stringId: asset.stringId, settings: asset.settings });
317
+ for (const [key, data] of blobs)
318
+ BlobStore.setProcessed(key, data);
319
+ asset.status = 'ready';
320
+ asset.metadata = result.metadata;
321
+ asset.processedSize = result.processedSize;
322
+ processed++;
323
+ if (verbose)
324
+ console.log(` [${processed}/${totalWork}] ${id} (glb-mesh)`);
325
+ continue;
326
+ }
327
+ // Everything else uses processAsset (source bytes)
366
328
  let sourceData = BlobStore.getSource(id);
367
329
  if (!sourceData) {
368
330
  const data = await readFile(config.srcArtDir, id);
@@ -381,39 +343,39 @@ export async function fullBuild(projectDir, opts) {
381
343
  asset.processedSize = result.processedSize;
382
344
  processed++;
383
345
  console.log(` [${processed}/${totalWork}] ${id} (${elapsed}ms)`);
384
- // Write cache
385
- const cacheEntries = new Map();
386
- const processedBlob = BlobStore.getProcessed(id);
387
- if (processedBlob)
388
- cacheEntries.set(id, processedBlob);
389
- if (result.metadata) {
390
- cacheEntries.set(`${id}:__metadata__`, new TextEncoder().encode(JSON.stringify(result.metadata)));
391
- }
392
- // Auxiliary blobs
393
- for (const suffix of [':skinnedMeta', ':animMeta', ':animCount']) {
394
- const blob = BlobStore.getProcessed(`${id}${suffix}`);
395
- if (blob)
396
- cacheEntries.set(`${id}${suffix}`, blob);
397
- }
398
- const animCountBlob = BlobStore.getProcessed(`${id}:animCount`);
399
- const clipCount = animCountBlob ? animCountBlob[0] : 0;
400
- for (let ci = 0; ci < clipCount; ci++) {
401
- const animData = BlobStore.getProcessed(`${id}:anim:${ci}`);
402
- if (animData)
403
- cacheEntries.set(`${id}:anim:${ci}`, animData);
404
- const animMeta = BlobStore.getProcessed(`${id}:animMeta:${ci}`);
405
- if (animMeta)
406
- cacheEntries.set(`${id}:animMeta:${ci}`, animMeta);
407
- }
408
- if (cacheEntries.size > 0) {
409
- await writeCacheBlobs(config.srcArtDir, id, cacheEntries);
410
- // Stamp cache in .stowmeta
411
- const snapshot = await getFileSnapshot(config.srcArtDir, id);
412
- if (snapshot) {
413
- const meta = await readStowmeta(config.srcArtDir, id);
414
- if (meta) {
415
- meta.cache = buildCacheStamp(snapshot, asset.type, asset.settings);
416
- await writeStowmeta(config.srcArtDir, id, meta);
346
+ // Write cache (only for top-level assets that have their own file on disk)
347
+ if (!asset.parentId) {
348
+ const cacheEntries = new Map();
349
+ const processedBlob = BlobStore.getProcessed(id);
350
+ if (processedBlob)
351
+ cacheEntries.set(id, processedBlob);
352
+ if (result.metadata) {
353
+ cacheEntries.set(`${id}:__metadata__`, new TextEncoder().encode(JSON.stringify(result.metadata)));
354
+ }
355
+ for (const suffix of [':skinnedMeta', ':animMeta', ':animCount']) {
356
+ const blob = BlobStore.getProcessed(`${id}${suffix}`);
357
+ if (blob)
358
+ cacheEntries.set(`${id}${suffix}`, blob);
359
+ }
360
+ const animCountBlob = BlobStore.getProcessed(`${id}:animCount`);
361
+ const clipCount = animCountBlob ? animCountBlob[0] : 0;
362
+ for (let ci = 0; ci < clipCount; ci++) {
363
+ const animData = BlobStore.getProcessed(`${id}:anim:${ci}`);
364
+ if (animData)
365
+ cacheEntries.set(`${id}:anim:${ci}`, animData);
366
+ const animMeta = BlobStore.getProcessed(`${id}:animMeta:${ci}`);
367
+ if (animMeta)
368
+ cacheEntries.set(`${id}:animMeta:${ci}`, animMeta);
369
+ }
370
+ if (cacheEntries.size > 0) {
371
+ await writeCacheBlobs(config.srcArtDir, id, cacheEntries);
372
+ const snapshot = await getFileSnapshot(config.srcArtDir, id);
373
+ if (snapshot) {
374
+ const meta = await readStowmeta(config.srcArtDir, id);
375
+ if (meta) {
376
+ meta.cache = buildCacheStamp(snapshot, asset.type, asset.settings);
377
+ await writeStowmeta(config.srcArtDir, id, meta);
378
+ }
417
379
  }
418
380
  }
419
381
  }
@@ -427,7 +389,7 @@ export async function fullBuild(projectDir, opts) {
427
389
  }
428
390
  }
429
391
  const workers = [];
430
- for (let i = 0; i < Math.min(maxConcurrent, pending.length); i++) {
392
+ for (let i = 0; i < Math.min(maxConcurrent, queue.length); i++) {
431
393
  workers.push(processNext());
432
394
  }
433
395
  await Promise.all(workers);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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.7",
20
+ "@series-inc/stowkit-packer-gui": "^0.1.9",
21
21
  "@series-inc/stowkit-editor": "^0.1.2",
22
22
  "draco3d": "^1.5.7",
23
23
  "fbx-parser": "^2.1.3",
package/skill.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  StowKit is a game asset pipeline that compresses and packs assets into `.stow` binary files for runtime loading.
4
4
 
5
+ ## CRITICAL: Never manually create .stowmeta files
6
+
7
+ `.stowmeta` files are **auto-generated by the CLI**. To add any asset to the project:
8
+
9
+ 1. Place the source file (GLB, PNG, FBX, WAV, etc.) into the `srcArtDir`
10
+ 2. Run `npx stowkit build` (or `npx stowkit scan`)
11
+ 3. The CLI detects the new file, generates the correct `.stowmeta` with proper defaults, and processes it
12
+
13
+ **Do not write `.stowmeta` files by hand.** Only edit an existing `.stowmeta` after it has been generated by the CLI (e.g. to change quality settings, pack assignment, or stringId). The same applies to GLB children — the `children` array is populated automatically on the first build.
14
+
15
+ To create materials, use `npx stowkit create-material <path>` — this scaffolds a `.stowmat` file with the right structure. You can also create `.stowmat` files manually if needed.
16
+
5
17
  ## Project Structure
6
18
 
7
19
  A StowKit project has a `.felicityproject` JSON file at its root:
@@ -30,6 +42,11 @@ npx stowkit scan [dir] # Detect new assets and generate .stowmeta d
30
42
  npx stowkit process [dir] # Compress assets (respects cache)
31
43
  npx stowkit status [dir] # Show project summary, stale asset count
32
44
  npx stowkit clean [dir] # Delete orphaned .stowcache and .stowmeta files
45
+ npx stowkit create-material <path> # Create a .stowmat material file (--schema pbr|unlit|<name>)
46
+ npx stowkit rename <path> <name> # Rename an asset file (preserves extension, updates sidecars)
47
+ npx stowkit move <path> <folder> # Move an asset to a different folder (updates GLB child refs)
48
+ npx stowkit delete <path> # Delete an asset and its .stowmeta/.stowcache files
49
+ npx stowkit set-id <path> <id> # Change an asset's stringId
33
50
  npx stowkit packer [dir] # Open the packer GUI in browser
34
51
  npx stowkit editor [dir] # Open the level editor in browser
35
52
  npx stowkit serve [dir] # Start API server only (no GUI)
@@ -41,6 +58,7 @@ All commands default to the current directory.
41
58
  - `--force` — Ignore cache and reprocess everything
42
59
  - `--verbose` / `-v` — Detailed output
43
60
  - `--port <number>` — Server port (default 3210)
61
+ - `--schema <name>` — Material schema template for `create-material` (default: `pbr`)
44
62
 
45
63
  ## Supported Asset Types
46
64
 
@@ -56,9 +74,9 @@ All commands default to the current directory.
56
74
 
57
75
  **GLB/GLTF is the recommended format for 3D models.** Dropping a `.glb` file into the project is the easiest way to get meshes, textures, materials, and animations into the pipeline — everything is extracted and processed automatically. FBX and OBJ are still supported as standalone mesh formats but lack the automatic material/texture extraction that GLB provides.
58
76
 
59
- ## .stowmeta Files
77
+ ## .stowmeta Files (auto-generated — do not create manually)
60
78
 
61
- Every source asset gets a `.stowmeta` sidecar file (JSON) that controls processing settings.
79
+ Every source asset gets a `.stowmeta` sidecar file generated by `npx stowkit build` or `npx stowkit scan`. These files control processing settings and should only be **edited** (never created) by hand. The examples below are reference for understanding and editing existing files.
62
80
 
63
81
  **Texture example:**
64
82
  ```json
@@ -347,6 +365,18 @@ Add `*.stowcache` to `.gitignore`.
347
365
  5. To exclude a child from packing, set `"excluded": true` on that child entry
348
366
  6. To preserve the scene graph hierarchy in static meshes, set `"preserveHierarchy": true` on the container
349
367
 
368
+ ### Enabling preserveHierarchy from CLI
369
+
370
+ To enable hierarchy preservation on a GLB, edit its `.stowmeta` and set `"preserveHierarchy": true`, then rebuild:
371
+
372
+ ```bash
373
+ # After the first build has generated the .stowmeta, edit it:
374
+ # Set "preserveHierarchy": true in assets/models/hero.glb.stowmeta
375
+ npx stowkit build
376
+ ```
377
+
378
+ The CLI always re-extracts GLB containers on every build, so changes to `preserveHierarchy` take effect immediately — no `--force` needed.
379
+
350
380
  ### When to use preserveHierarchy
351
381
 
352
382
  - **Default (false):** All mesh geometry is baked to world space and flattened into a single mesh. Use this for simple props and environment pieces.
@@ -355,10 +385,17 @@ Add `*.stowcache` to `.gitignore`.
355
385
 
356
386
  ### Other common tasks
357
387
 
358
- - **Add a texture:** Drop a PNG/JPG into `assets/`, run `npx stowkit scan` to generate its `.stowmeta`, optionally edit settings, then `npx stowkit build`
359
- - **Change compression quality:** Edit the `.stowmeta` file's quality/resize fields, then `npx stowkit build`
360
- - **Create a material:** Create a `.stowmat` JSON file in `assets/`, run `npx stowkit scan`
361
- - **Assign material to mesh:** Edit the mesh's `.stowmeta` to add `materialOverrides`
388
+ - **Add a texture:** Place PNG/JPG into `assets/`, run `npx stowkit build`. The CLI auto-generates the `.stowmeta`. Do NOT create it yourself.
389
+ - **Add audio:** Place WAV/MP3/OGG into `assets/`, run `npx stowkit build`. Same rule — never manually create `.stowmeta`.
390
+ - **Add an FBX mesh:** Place FBX into `assets/`, run `npx stowkit build`.
391
+ - **Enable preserve hierarchy on a GLB:** Edit the `.stowmeta` for the GLB container, set `"preserveHierarchy": true`, then `npx stowkit build`
392
+ - **Change compression quality:** Edit the **existing** `.stowmeta` file's quality/resize fields (after it was generated by a build/scan), then `npx stowkit build`
393
+ - **Create a material:** Run `npx stowkit create-material materials/MyMat` (creates `assets/materials/MyMat.stowmat` with PBR template). Use `--schema unlit` for unlit materials, or `--schema <name>` for a custom schema. Then run `npx stowkit build`.
394
+ - **Assign material to mesh:** Edit the mesh's **existing** `.stowmeta` to add `materialOverrides`
395
+ - **Rename an asset:** `npx stowkit rename textures/old_name.png new_name` (extension preserved automatically, sidecars renamed too)
396
+ - **Move an asset:** `npx stowkit move textures/hero.png characters` (moves to `characters/hero.png`, updates GLB child refs if container)
397
+ - **Delete an asset:** `npx stowkit delete textures/unused.png` (removes source + .stowmeta + .stowcache, cascades for GLB containers)
398
+ - **Change an asset's stringId:** `npx stowkit set-id textures/hero.png hero_diffuse`
362
399
  - **Check project health:** Run `npx stowkit status`
363
400
  - **Full rebuild:** `npx stowkit build --force`
364
401
  - **Clean orphaned files:** `npx stowkit clean`