@series-inc/stowkit-cli 0.1.11 → 0.1.13

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 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import { parentPort } from 'node:worker_threads';
2
+ import { processAsset, processExtractedMesh } from '../pipeline.js';
3
+ import { BlobStore } from '../app/blob-store.js';
4
+ import { NodeBasisEncoder } from '../encoders/basis-encoder.js';
5
+ import { NodeDracoEncoder } from '../encoders/draco-encoder.js';
6
+ import { NodeAacEncoder, NodeAudioDecoder } from '../encoders/aac-encoder.js';
7
+ import { NodeFbxImporter } from '../encoders/fbx-loader.js';
8
+ import { SharpImageDecoder } from '../encoders/image-decoder.js';
9
+ // Lazy-init encoder context
10
+ let ctx = null;
11
+ let initPromise = null;
12
+ function getCtx(wasmDir) {
13
+ if (ctx)
14
+ return Promise.resolve(ctx);
15
+ if (initPromise)
16
+ return initPromise;
17
+ initPromise = (async () => {
18
+ const textureEncoder = new NodeBasisEncoder(wasmDir);
19
+ const meshEncoder = new NodeDracoEncoder();
20
+ const aacEncoder = new NodeAacEncoder();
21
+ const audioDecoder = new NodeAudioDecoder();
22
+ const meshImporter = new NodeFbxImporter();
23
+ const imageDecoder = new SharpImageDecoder();
24
+ await Promise.all([
25
+ textureEncoder.initialize(),
26
+ meshEncoder.initialize(),
27
+ aacEncoder.initialize(),
28
+ audioDecoder.initialize(),
29
+ ]);
30
+ ctx = {
31
+ textureEncoder,
32
+ meshEncoder,
33
+ meshImporter,
34
+ imageDecoder,
35
+ audioDecoder,
36
+ aacEncoder,
37
+ onProgress: (id, msg) => {
38
+ parentPort.postMessage({ type: 'progress', id, msg });
39
+ },
40
+ };
41
+ return ctx;
42
+ })();
43
+ return initPromise;
44
+ }
45
+ function harvestBlobs() {
46
+ const blobs = BlobStore.getAllProcessed();
47
+ const result = [];
48
+ for (const [key, data] of blobs) {
49
+ result.push([key, data]);
50
+ }
51
+ return result;
52
+ }
53
+ parentPort.on('message', async (msg) => {
54
+ const { type, id, wasmDir } = msg;
55
+ try {
56
+ const processingCtx = await getCtx(wasmDir);
57
+ if (type === 'processAsset') {
58
+ const { sourceData, assetType, stringId, settings } = msg;
59
+ // Clear blobs from previous task
60
+ BlobStore.clearProcessed();
61
+ // Set source so pipeline can find it
62
+ BlobStore.setSource(id, sourceData);
63
+ const result = await processAsset(id, sourceData, assetType, stringId, settings, processingCtx);
64
+ const blobs = harvestBlobs();
65
+ parentPort.postMessage({ type: 'result', id, result, blobs });
66
+ }
67
+ else if (type === 'processExtractedMesh') {
68
+ const { childId, imported, hasSkeleton, stringId, settings } = msg;
69
+ BlobStore.clearProcessed();
70
+ const result = await processExtractedMesh(childId, imported, hasSkeleton, stringId, settings, processingCtx);
71
+ const blobs = harvestBlobs();
72
+ parentPort.postMessage({ type: 'result', id: childId, result, blobs });
73
+ }
74
+ }
75
+ catch (err) {
76
+ const errorId = type === 'processExtractedMesh' ? msg.childId : id;
77
+ parentPort.postMessage({
78
+ type: 'error',
79
+ id: errorId,
80
+ error: err instanceof Error ? err.message : String(err),
81
+ });
82
+ }
83
+ });
@@ -0,0 +1,41 @@
1
+ import type { AssetType } from '../core/types.js';
2
+ import type { AssetSettings } from '../app/state.js';
3
+ import type { ProcessResult } from '../pipeline.js';
4
+ import type { ImportedMesh } from '../encoders/interfaces.js';
5
+ export declare class WorkerPool {
6
+ private workers;
7
+ private idle;
8
+ private pending;
9
+ private taskMap;
10
+ private wasmDir?;
11
+ private terminated;
12
+ constructor(opts?: {
13
+ poolSize?: number;
14
+ wasmDir?: string;
15
+ });
16
+ private handleMessage;
17
+ private handleError;
18
+ private releaseWorker;
19
+ private acquireWorker;
20
+ processAsset(task: {
21
+ id: string;
22
+ sourceData: Uint8Array;
23
+ type: AssetType;
24
+ stringId: string;
25
+ settings: AssetSettings;
26
+ }, onProgress?: (id: string, msg: string) => void): Promise<{
27
+ result: ProcessResult;
28
+ blobs: [string, Uint8Array][];
29
+ }>;
30
+ processExtractedMesh(task: {
31
+ childId: string;
32
+ imported: ImportedMesh;
33
+ hasSkeleton: boolean;
34
+ stringId: string;
35
+ settings: AssetSettings;
36
+ }, onProgress?: (id: string, msg: string) => void): Promise<{
37
+ result: ProcessResult;
38
+ blobs: [string, Uint8Array][];
39
+ }>;
40
+ terminate(): Promise<void>;
41
+ }
@@ -0,0 +1,130 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import * as os from 'node:os';
3
+ export class WorkerPool {
4
+ workers = [];
5
+ idle = [];
6
+ pending = [];
7
+ taskMap = new Map();
8
+ wasmDir;
9
+ terminated = false;
10
+ constructor(opts) {
11
+ this.wasmDir = opts?.wasmDir;
12
+ const poolSize = opts?.poolSize ?? Math.min(4, Math.max(1, os.cpus().length - 1));
13
+ for (let i = 0; i < poolSize; i++) {
14
+ const worker = new Worker(new URL('./process-worker.js', import.meta.url));
15
+ worker.on('message', (msg) => this.handleMessage(worker, msg));
16
+ worker.on('error', (err) => this.handleError(worker, err));
17
+ this.workers.push(worker);
18
+ this.idle.push(worker);
19
+ }
20
+ }
21
+ handleMessage(worker, msg) {
22
+ const task = this.taskMap.get(worker);
23
+ if (!task)
24
+ return;
25
+ if (msg.type === 'progress') {
26
+ task.onProgress?.(msg.id, msg.msg);
27
+ return;
28
+ }
29
+ if (msg.type === 'result') {
30
+ this.taskMap.delete(worker);
31
+ this.releaseWorker(worker);
32
+ task.resolve({ result: msg.result, blobs: msg.blobs ?? [] });
33
+ return;
34
+ }
35
+ if (msg.type === 'error') {
36
+ this.taskMap.delete(worker);
37
+ this.releaseWorker(worker);
38
+ task.reject(new Error(msg.error ?? 'Unknown worker error'));
39
+ return;
40
+ }
41
+ }
42
+ handleError(worker, err) {
43
+ const task = this.taskMap.get(worker);
44
+ if (task) {
45
+ this.taskMap.delete(worker);
46
+ task.reject(err);
47
+ }
48
+ // Replace dead worker
49
+ const idx = this.workers.indexOf(worker);
50
+ if (idx !== -1 && !this.terminated) {
51
+ const replacement = new Worker(new URL('./process-worker.js', import.meta.url));
52
+ replacement.on('message', (msg) => this.handleMessage(replacement, msg));
53
+ replacement.on('error', (e) => this.handleError(replacement, e));
54
+ this.workers[idx] = replacement;
55
+ this.releaseWorker(replacement);
56
+ }
57
+ }
58
+ releaseWorker(worker) {
59
+ if (this.pending.length > 0) {
60
+ const next = this.pending.shift();
61
+ // Worker stays busy — the pending task will claim it
62
+ this.idle.push(worker);
63
+ next();
64
+ }
65
+ else {
66
+ this.idle.push(worker);
67
+ }
68
+ }
69
+ acquireWorker() {
70
+ const worker = this.idle.pop();
71
+ if (worker)
72
+ return Promise.resolve(worker);
73
+ return new Promise((resolve) => {
74
+ this.pending.push(() => {
75
+ const w = this.idle.pop();
76
+ resolve(w);
77
+ });
78
+ });
79
+ }
80
+ async processAsset(task, onProgress) {
81
+ if (this.terminated)
82
+ throw new Error('WorkerPool is terminated');
83
+ const worker = await this.acquireWorker();
84
+ return new Promise((resolve, reject) => {
85
+ this.taskMap.set(worker, { resolve, reject, onProgress });
86
+ const msg = {
87
+ type: 'processAsset',
88
+ id: task.id,
89
+ sourceData: task.sourceData,
90
+ assetType: task.type,
91
+ stringId: task.stringId,
92
+ settings: task.settings,
93
+ wasmDir: this.wasmDir,
94
+ };
95
+ worker.postMessage(msg);
96
+ });
97
+ }
98
+ async processExtractedMesh(task, onProgress) {
99
+ if (this.terminated)
100
+ throw new Error('WorkerPool is terminated');
101
+ const worker = await this.acquireWorker();
102
+ return new Promise((resolve, reject) => {
103
+ this.taskMap.set(worker, { resolve, reject, onProgress });
104
+ const msg = {
105
+ type: 'processExtractedMesh',
106
+ id: task.childId,
107
+ childId: task.childId,
108
+ imported: task.imported,
109
+ hasSkeleton: task.hasSkeleton,
110
+ stringId: task.stringId,
111
+ settings: task.settings,
112
+ wasmDir: this.wasmDir,
113
+ };
114
+ // Use structured clone (no transfer list) — mesh typed arrays may share
115
+ // underlying ArrayBuffers from the GLB binary chunk, making transfer unsafe.
116
+ worker.postMessage(msg);
117
+ });
118
+ }
119
+ async terminate() {
120
+ this.terminated = true;
121
+ // Reject any pending tasks
122
+ for (const cb of this.pending) {
123
+ // Tasks waiting for a worker — they'll never get one
124
+ }
125
+ this.pending = [];
126
+ await Promise.all(this.workers.map(w => w.terminate()));
127
+ this.workers = [];
128
+ this.idle = [];
129
+ }
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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.1",
21
- "@series-inc/stowkit-editor": "^0.1.1",
20
+ "@series-inc/stowkit-packer-gui": "^0.1.6",
21
+ "@series-inc/stowkit-editor": "^0.1.2",
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
@@ -11,41 +11,54 @@ A StowKit project has a `.felicityproject` JSON file at its root:
11
11
  "srcArtDir": "assets",
12
12
  "name": "My Game",
13
13
  "cdnAssetsPath": "public/cdn-assets",
14
+ "prefabsPath": "prefabs",
14
15
  "packs": [{ "name": "default" }]
15
16
  }
16
17
  ```
17
18
 
18
- - `srcArtDir` — directory containing source art files (PNG, JPG, FBX, WAV, etc.)
19
+ - `srcArtDir` — directory containing source art files (PNG, JPG, FBX, GLB, WAV, etc.)
19
20
  - `cdnAssetsPath` — output directory for built `.stow` packs
21
+ - `prefabsPath` — directory for prefab definitions (optional)
20
22
  - `packs` — named packs to split assets into
21
23
 
22
24
  ## CLI Commands
23
25
 
24
26
  ```bash
25
- npx stowkit init # Scaffold a new project (creates .felicityproject, assets/, public/cdn-assets/)
26
- npx stowkit build # Full build: scan + compress + pack (reads from cwd)
27
- npx stowkit scan # Detect new assets and generate .stowmeta defaults
28
- npx stowkit status # Show project info and how many assets need processing
29
- npx stowkit packer # Open the visual packer GUI in browser
30
- npx stowkit build --force # Reprocess everything, ignore cache
27
+ npx stowkit init [dir] # Scaffold a new project (creates .felicityproject, assets/, public/cdn-assets/)
28
+ npx stowkit build [dir] # Full build: scan + process + pack
29
+ npx stowkit scan [dir] # Detect new assets and generate .stowmeta defaults
30
+ npx stowkit process [dir] # Compress assets (respects cache)
31
+ npx stowkit status [dir] # Show project summary, stale asset count
32
+ npx stowkit clean [dir] # Delete orphaned .stowcache and .stowmeta files
33
+ npx stowkit packer [dir] # Open the packer GUI in browser
34
+ npx stowkit editor [dir] # Open the level editor in browser
35
+ npx stowkit serve [dir] # Start API server only (no GUI)
31
36
  ```
32
37
 
33
- All commands default to the current directory. Pass a path as second argument to target a different directory.
38
+ All commands default to the current directory.
39
+
40
+ **Options:**
41
+ - `--force` — Ignore cache and reprocess everything
42
+ - `--verbose` / `-v` — Detailed output
43
+ - `--port <number>` — Server port (default 3210)
34
44
 
35
45
  ## Supported Asset Types
36
46
 
37
47
  | Type | Extensions | Compression |
38
48
  |------|-----------|-------------|
49
+ | **GlbContainer** | **gltf, glb** | **Container — auto-extracts textures, meshes, materials, and animations** |
39
50
  | Texture2D | png, jpg, jpeg, bmp, tga, webp, gif | KTX2 (Basis Universal) |
40
51
  | Audio | wav, mp3, ogg, flac, aac, m4a | AAC (M4A container) |
41
- | StaticMesh | fbx, obj, gltf, glb | Draco |
52
+ | StaticMesh | fbx, obj | Draco |
42
53
  | SkinnedMesh | fbx | Uncompressed interleaved vertex data |
43
54
  | AnimationClip | fbx | v2 format (Three.js-native tracks) |
44
55
  | MaterialSchema | .stowmat | Metadata only (no data blob) |
45
56
 
57
+ **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
+
46
59
  ## .stowmeta Files
47
60
 
48
- Every source asset gets a `.stowmeta` sidecar file (JSON) that controls processing settings:
61
+ Every source asset gets a `.stowmeta` sidecar file (JSON) that controls processing settings.
49
62
 
50
63
  **Texture example:**
51
64
  ```json
@@ -119,6 +132,114 @@ Every source asset gets a `.stowmeta` sidecar file (JSON) that controls processi
119
132
  }
120
133
  ```
121
134
 
135
+ ## GLB Container Assets
136
+
137
+ When a `.glb` or `.gltf` file is added, it gets a `glbContainer` stowmeta. On the first build, the pipeline parses the GLB and automatically extracts all embedded content as **inline children** stored directly in the container's `.stowmeta`.
138
+
139
+ **GLB container stowmeta example:**
140
+ ```json
141
+ {
142
+ "version": 1,
143
+ "type": "glbContainer",
144
+ "stringId": "hero",
145
+ "tags": [],
146
+ "pack": "default",
147
+ "preserveHierarchy": false,
148
+ "children": [
149
+ {
150
+ "name": "Hero_Diffuse.png",
151
+ "childType": "texture",
152
+ "stringId": "hero_diffuse",
153
+ "quality": "fastest",
154
+ "resize": "full",
155
+ "generateMipmaps": false
156
+ },
157
+ {
158
+ "name": "Hero",
159
+ "childType": "skinnedMesh",
160
+ "stringId": "hero",
161
+ "dracoQuality": "balanced",
162
+ "materialOverrides": {
163
+ "0": "models/hero.glb/HeroSkin.stowmat"
164
+ }
165
+ },
166
+ {
167
+ "name": "HeroSkin.stowmat",
168
+ "childType": "materialSchema",
169
+ "stringId": "HeroSkin",
170
+ "materialConfig": {
171
+ "schemaId": "",
172
+ "properties": [
173
+ {
174
+ "fieldName": "BaseColor",
175
+ "fieldType": "texture",
176
+ "previewFlag": "mainTex",
177
+ "value": [1, 1, 1, 1],
178
+ "textureAsset": "models/hero.glb/Hero_Diffuse.png"
179
+ },
180
+ {
181
+ "fieldName": "Tint",
182
+ "fieldType": "color",
183
+ "previewFlag": "tint",
184
+ "value": [1, 1, 1, 1],
185
+ "textureAsset": null
186
+ }
187
+ ]
188
+ }
189
+ },
190
+ {
191
+ "name": "Idle",
192
+ "childType": "animationClip",
193
+ "stringId": "idle",
194
+ "targetMeshId": null
195
+ }
196
+ ]
197
+ }
198
+ ```
199
+
200
+ ### GLB child types
201
+
202
+ Children extracted from a GLB container can be any of:
203
+
204
+ - **texture** — Embedded images (PNG/JPG) extracted from GLB binary chunk. Processed through the normal KTX2 pipeline.
205
+ - **staticMesh** — Meshes with no skeleton. Draco-compressed. Positions stored in local space when `preserveHierarchy` is true, otherwise baked to world space.
206
+ - **skinnedMesh** — Meshes with a skeleton and bone weights. Uncompressed interleaved vertex data.
207
+ - **animationClip** — Animations from the GLB. Processed into v2 format.
208
+ - **materialSchema** — PBR materials extracted from the GLB. Automatically converted from glTF PBR metallic-roughness to StowKit material configs with texture references pointing to sibling child textures.
209
+
210
+ ### GLB child IDs
211
+
212
+ Child asset IDs follow the pattern `{containerId}/{childName}`. For example, if the container is `models/hero.glb`, its children have IDs like:
213
+ - `models/hero.glb/Hero_Diffuse.png` (texture)
214
+ - `models/hero.glb/Hero` (mesh)
215
+ - `models/hero.glb/HeroSkin.stowmat` (material)
216
+ - `models/hero.glb/Idle` (animation)
217
+
218
+ Material texture references within a GLB also use these child IDs (e.g. `"textureAsset": "models/hero.glb/Hero_Diffuse.png"`).
219
+
220
+ ### GLB auto-assignment
221
+
222
+ The build pipeline automatically:
223
+ 1. Parses the GLB and discovers all textures, meshes, materials, and animations
224
+ 2. Creates a `children` manifest in the container's `.stowmeta`
225
+ 3. Converts glTF PBR materials into `materialConfig` entries with correct texture references
226
+ 4. Auto-assigns `materialOverrides` on mesh children based on GLB sub-mesh→material mappings
227
+ 5. Processes each child through the appropriate encoder (KTX2 for textures, Draco for static meshes, etc.)
228
+
229
+ ### preserveHierarchy
230
+
231
+ 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.
232
+
233
+ ### GLB child settings
234
+
235
+ Each child in the `children` array supports the same settings as its corresponding standalone asset type:
236
+ - Texture children: `quality`, `resize`, `generateMipmaps`
237
+ - Mesh children: `dracoQuality`, `materialOverrides`
238
+ - Animation children: `targetMeshId`
239
+ - All children: `excluded`, `tags`, `pack`, `stringId`
240
+
241
+ Edit these fields in the container's `.stowmeta` to customize per-child processing, then run `npx stowkit build`.
242
+
122
243
  ## .stowmat Files (Material Schemas)
123
244
 
124
245
  Materials are defined as `.stowmat` JSON files placed in the source art directory:
@@ -150,6 +271,8 @@ Materials are defined as `.stowmat` JSON files placed in the source art director
150
271
  **Preview flags:** none, mainTex, tint, alphaTest
151
272
  **textureAsset:** relative path to a texture in the project (e.g. "textures/hero.png")
152
273
 
274
+ GLB containers also produce inline material children with a `materialConfig` field — these replace separate `.stowmat` files for GLB-extracted materials.
275
+
153
276
  ## Material Overrides on Meshes
154
277
 
155
278
  To assign materials to mesh sub-meshes, set `materialOverrides` in the mesh's `.stowmeta`:
@@ -163,7 +286,9 @@ To assign materials to mesh sub-meshes, set `materialOverrides` in the mesh's `.
163
286
  }
164
287
  ```
165
288
 
166
- Keys are sub-mesh indices (as strings), values are relative paths to `.stowmat` files.
289
+ Keys are sub-mesh indices (as strings), values are relative paths to `.stowmat` files or GLB child material IDs (e.g. `"models/hero.glb/HeroSkin.stowmat"`).
290
+
291
+ For GLB mesh children, `materialOverrides` are auto-assigned from the GLB's sub-mesh→material mappings. You can override them by editing the child entry in the container's `.stowmeta`.
167
292
 
168
293
  ## Setting Up a New Project
169
294
 
@@ -178,6 +303,8 @@ Edit the `.stowmeta` file for any asset, then run `npx stowkit build`.
178
303
  The build respects cache — only assets whose settings or source files changed get reprocessed.
179
304
  Use `--force` to reprocess everything.
180
305
 
306
+ For GLB child assets, edit the child's entry inside the container's `.stowmeta` `children` array.
307
+
181
308
  ## Multi-Pack Setup
182
309
 
183
310
  Split assets into multiple packs by editing `.felicityproject`:
@@ -193,19 +320,45 @@ Split assets into multiple packs by editing `.felicityproject`:
193
320
  ```
194
321
 
195
322
  Then set `"pack": "level1"` in each asset's `.stowmeta` to assign it to a pack.
323
+ GLB children inherit the container's pack by default, but each child can override it with its own `"pack"` field.
196
324
 
197
325
  ## Cache
198
326
 
199
327
  Processed assets are cached in `.stowcache` sidecar files next to the source.
200
328
  The `.stowmeta` file stores a cache stamp (source size, modified time, settings hash).
201
329
  Cache is automatically invalidated when source files or settings change.
330
+ GLB children have their own cache entries stored alongside the container.
202
331
  Add `*.stowcache` to `.gitignore`.
203
332
 
204
333
  ## Common Tasks for AI Agents
205
334
 
335
+ ### Adding a GLB model (recommended 3D workflow)
336
+
337
+ 1. Place the `.glb` file into the `srcArtDir` (e.g. `assets/models/hero.glb`)
338
+ 2. Run `npx stowkit build`
339
+ 3. The pipeline automatically:
340
+ - Creates a `glbContainer` `.stowmeta` for the file
341
+ - Parses the GLB and extracts all textures, meshes, materials, and animations
342
+ - Populates the `children` array in the `.stowmeta` with one entry per extracted asset
343
+ - Converts glTF PBR materials to StowKit `materialConfig` with correct texture references
344
+ - Auto-assigns `materialOverrides` on mesh children from the GLB's sub-mesh→material mapping
345
+ - Processes each child (KTX2 for textures, Draco for static meshes, etc.)
346
+ 4. To customize child settings after the first build, edit the `children` array entries in the container's `.stowmeta`
347
+ 5. To exclude a child from packing, set `"excluded": true` on that child entry
348
+ 6. To preserve the scene graph hierarchy in static meshes, set `"preserveHierarchy": true` on the container
349
+
350
+ ### When to use preserveHierarchy
351
+
352
+ - **Default (false):** All mesh geometry is baked to world space and flattened into a single mesh. Use this for simple props and environment pieces.
353
+ - **true:** The original scene graph node hierarchy is preserved with local transforms (position, rotation, scale) per node. Use this for complex multi-part models where you need individual nodes at runtime (e.g. a vehicle with doors, a character with attachable accessories).
354
+ - Skinned meshes are always excluded from hierarchy preservation regardless of this setting.
355
+
356
+ ### Other common tasks
357
+
206
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`
207
359
  - **Change compression quality:** Edit the `.stowmeta` file's quality/resize fields, then `npx stowkit build`
208
360
  - **Create a material:** Create a `.stowmat` JSON file in `assets/`, run `npx stowkit scan`
209
361
  - **Assign material to mesh:** Edit the mesh's `.stowmeta` to add `materialOverrides`
210
362
  - **Check project health:** Run `npx stowkit status`
211
363
  - **Full rebuild:** `npx stowkit build --force`
364
+ - **Clean orphaned files:** `npx stowkit clean`