@series-inc/stowkit-cli 0.1.15 → 0.1.17

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.
@@ -72,6 +72,7 @@ export interface StowMetaGlbChild {
72
72
  sampleRate?: string;
73
73
  dracoQuality?: string;
74
74
  materialOverrides?: Record<string, string | null>;
75
+ sceneNodeNames?: string[];
75
76
  targetMeshId?: string | null;
76
77
  materialConfig?: {
77
78
  schemaId: string;
@@ -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);
@@ -200,7 +149,15 @@ export async function fullBuild(projectDir, opts) {
200
149
  }
201
150
  for (const mesh of extract.meshes) {
202
151
  const typeName = mesh.hasSkeleton ? 'skinnedMesh' : 'staticMesh';
203
- childrenManifest.push(existingChildren.get(mesh.name) ?? generateDefaultGlbChild(mesh.name, typeName));
152
+ const meshChild = existingChildren.get(mesh.name) ?? generateDefaultGlbChild(mesh.name, typeName);
153
+ // Store scene node names so AI agents can see the hierarchy in the stowmeta
154
+ if (mesh.imported.nodes.length > 1) {
155
+ meshChild.sceneNodeNames = mesh.imported.nodes.map(n => n.name);
156
+ }
157
+ else {
158
+ delete meshChild.sceneNodeNames;
159
+ }
160
+ childrenManifest.push(meshChild);
204
161
  }
205
162
  for (const mat of extract.materials) {
206
163
  const matName = `${mat.name}.stowmat`;
@@ -237,8 +194,6 @@ export async function fullBuild(projectDir, opts) {
237
194
  // Create child assets from inline entries
238
195
  for (const child of childrenManifest) {
239
196
  const childId = `${container.id}/${child.name}`;
240
- if (assetsById.has(childId))
241
- continue;
242
197
  const baseName = child.name.replace(/\.[^.]+$/, '');
243
198
  const { type: cType, settings: cSettings } = glbChildToAssetSettings(child);
244
199
  const childAsset = {
@@ -274,6 +229,27 @@ export async function fullBuild(projectDir, opts) {
274
229
  childAsset.processedSize = result.processedSize;
275
230
  }
276
231
  }
232
+ // Check cache for texture/mesh children (skip if --force)
233
+ if (childAsset.status === 'pending' && !force && child.cache) {
234
+ const cached = await readCacheBlobs(config.srcArtDir, childId);
235
+ if (cached) {
236
+ for (const [key, data] of cached) {
237
+ if (key === `${childId}:__metadata__`) {
238
+ try {
239
+ childAsset.metadata = JSON.parse(new TextDecoder().decode(data));
240
+ }
241
+ catch { /* skip */ }
242
+ }
243
+ else {
244
+ BlobStore.setProcessed(key, data);
245
+ }
246
+ }
247
+ childAsset.status = 'ready';
248
+ childAsset.processedSize = BlobStore.getProcessed(childId)?.length ?? 0;
249
+ if (verbose)
250
+ console.log(` [cached] ${childId}`);
251
+ }
252
+ }
277
253
  assets.push(childAsset);
278
254
  assetsById.set(childId, childAsset);
279
255
  }
package/dist/server.js CHANGED
@@ -240,8 +240,15 @@ async function processGlbContainer(containerId) {
240
240
  // Process meshes
241
241
  for (const mesh of extract.meshes) {
242
242
  const typeName = mesh.hasSkeleton ? 'skinnedMesh' : 'staticMesh';
243
- const existing = existingChildren.get(mesh.name);
244
- childrenManifest.push(existing ?? generateDefaultGlbChild(mesh.name, typeName));
243
+ const meshChild = existingChildren.get(mesh.name) ?? generateDefaultGlbChild(mesh.name, typeName);
244
+ // Store scene node names so AI agents can see the hierarchy in the stowmeta
245
+ if (mesh.imported.nodes.length > 1) {
246
+ meshChild.sceneNodeNames = mesh.imported.nodes.map(n => n.name);
247
+ }
248
+ else {
249
+ delete meshChild.sceneNodeNames;
250
+ }
251
+ childrenManifest.push(meshChild);
245
252
  }
246
253
  // Process materials
247
254
  for (const mat of extract.materials) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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.8",
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
@@ -12,7 +12,7 @@ StowKit is a game asset pipeline that compresses and packs assets into `.stow` b
12
12
 
13
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
14
 
15
- The only file you should manually create is `.stowmat` (material schema) files these are user-authored material definitions, not generated metadata.
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
16
 
17
17
  ## Project Structure
18
18
 
@@ -42,6 +42,11 @@ npx stowkit scan [dir] # Detect new assets and generate .stowmeta d
42
42
  npx stowkit process [dir] # Compress assets (respects cache)
43
43
  npx stowkit status [dir] # Show project summary, stale asset count
44
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
45
50
  npx stowkit packer [dir] # Open the packer GUI in browser
46
51
  npx stowkit editor [dir] # Open the level editor in browser
47
52
  npx stowkit serve [dir] # Start API server only (no GUI)
@@ -53,6 +58,7 @@ All commands default to the current directory.
53
58
  - `--force` — Ignore cache and reprocess everything
54
59
  - `--verbose` / `-v` — Detailed output
55
60
  - `--port <number>` — Server port (default 3210)
61
+ - `--schema <name>` — Material schema template for `create-material` (default: `pbr`)
56
62
 
57
63
  ## Supported Asset Types
58
64
 
@@ -242,6 +248,19 @@ The build pipeline automatically:
242
248
 
243
249
  Set `"preserveHierarchy": true` on a GLB container to preserve the scene graph node hierarchy in extracted static meshes. When false (default), all mesh geometry is baked to world space and flattened. Skinned meshes are always excluded from hierarchy preservation.
244
250
 
251
+ When preserve hierarchy is enabled, mesh children in the `.stowmeta` will include a `sceneNodeNames` array listing all scene graph nodes in that mesh:
252
+
253
+ ```json
254
+ {
255
+ "name": "Environment",
256
+ "childType": "staticMesh",
257
+ "stringId": "environment",
258
+ "sceneNodeNames": ["Root", "Floor", "Wall_North", "Wall_South", "Ceiling", "Door_Frame"]
259
+ }
260
+ ```
261
+
262
+ This lets you see which nodes are in the hierarchy without reading the binary processed data. The array is automatically populated during extraction and only appears when the mesh has more than one node.
263
+
245
264
  ### GLB child settings
246
265
 
247
266
  Each child in the `children` array supports the same settings as its corresponding standalone asset type:
@@ -359,6 +378,18 @@ Add `*.stowcache` to `.gitignore`.
359
378
  5. To exclude a child from packing, set `"excluded": true` on that child entry
360
379
  6. To preserve the scene graph hierarchy in static meshes, set `"preserveHierarchy": true` on the container
361
380
 
381
+ ### Enabling preserveHierarchy from CLI
382
+
383
+ To enable hierarchy preservation on a GLB, edit its `.stowmeta` and set `"preserveHierarchy": true`, then rebuild:
384
+
385
+ ```bash
386
+ # After the first build has generated the .stowmeta, edit it:
387
+ # Set "preserveHierarchy": true in assets/models/hero.glb.stowmeta
388
+ npx stowkit build
389
+ ```
390
+
391
+ The CLI always re-extracts GLB containers on every build, so changes to `preserveHierarchy` take effect immediately — no `--force` needed.
392
+
362
393
  ### When to use preserveHierarchy
363
394
 
364
395
  - **Default (false):** All mesh geometry is baked to world space and flattened into a single mesh. Use this for simple props and environment pieces.
@@ -370,9 +401,14 @@ Add `*.stowcache` to `.gitignore`.
370
401
  - **Add a texture:** Place PNG/JPG into `assets/`, run `npx stowkit build`. The CLI auto-generates the `.stowmeta`. Do NOT create it yourself.
371
402
  - **Add audio:** Place WAV/MP3/OGG into `assets/`, run `npx stowkit build`. Same rule — never manually create `.stowmeta`.
372
403
  - **Add an FBX mesh:** Place FBX into `assets/`, run `npx stowkit build`.
404
+ - **Enable preserve hierarchy on a GLB:** Edit the `.stowmeta` for the GLB container, set `"preserveHierarchy": true`, then `npx stowkit build`
373
405
  - **Change compression quality:** Edit the **existing** `.stowmeta` file's quality/resize fields (after it was generated by a build/scan), then `npx stowkit build`
374
- - **Create a material:** Create a `.stowmat` JSON file in `assets/` (this is the one file type you DO create manually), then run `npx stowkit build`
406
+ - **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`.
375
407
  - **Assign material to mesh:** Edit the mesh's **existing** `.stowmeta` to add `materialOverrides`
408
+ - **Rename an asset:** `npx stowkit rename textures/old_name.png new_name` (extension preserved automatically, sidecars renamed too)
409
+ - **Move an asset:** `npx stowkit move textures/hero.png characters` (moves to `characters/hero.png`, updates GLB child refs if container)
410
+ - **Delete an asset:** `npx stowkit delete textures/unused.png` (removes source + .stowmeta + .stowcache, cascades for GLB containers)
411
+ - **Change an asset's stringId:** `npx stowkit set-id textures/hero.png hero_diffuse`
376
412
  - **Check project health:** Run `npx stowkit status`
377
413
  - **Full rebuild:** `npx stowkit build --force`
378
414
  - **Clean orphaned files:** `npx stowkit clean`