@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.
@@ -5,5 +5,7 @@ export declare const BlobStore: {
5
5
  getProcessed(id: string): Uint8Array | undefined;
6
6
  remove(id: string): void;
7
7
  renameAll(oldId: string, newId: string): void;
8
+ getAllProcessed(): Map<string, Uint8Array>;
9
+ clearProcessed(): void;
8
10
  clear(): void;
9
11
  };
@@ -35,6 +35,12 @@ export const BlobStore = {
35
35
  }
36
36
  }
37
37
  },
38
+ getAllProcessed() {
39
+ return new Map(processedBlobs);
40
+ },
41
+ clearProcessed() {
42
+ processedBlobs.clear();
43
+ },
38
44
  clear() {
39
45
  sourceBlobs.clear();
40
46
  processedBlobs.clear();
@@ -25,7 +25,9 @@ interface StowMetaBase {
25
25
  stringId: string;
26
26
  tags: string[];
27
27
  pack?: string;
28
+ excluded?: boolean;
28
29
  cache?: StowMetaCache;
30
+ parentId?: string;
29
31
  }
30
32
  export interface StowMetaTexture extends StowMetaBase {
31
33
  type: 'texture';
@@ -42,6 +44,7 @@ export interface StowMetaStaticMesh extends StowMetaBase {
42
44
  type: 'staticMesh';
43
45
  dracoQuality: string;
44
46
  materialOverrides: Record<string, string | null>;
47
+ preserveHierarchy?: boolean;
45
48
  }
46
49
  export interface StowMetaSkinnedMesh extends StowMetaBase {
47
50
  type: 'skinnedMesh';
@@ -54,7 +57,33 @@ export interface StowMetaAnimationClip extends StowMetaBase {
54
57
  export interface StowMetaMaterialSchema extends StowMetaBase {
55
58
  type: 'materialSchema';
56
59
  }
57
- export type StowMeta = StowMetaTexture | StowMetaAudio | StowMetaStaticMesh | StowMetaSkinnedMesh | StowMetaAnimationClip | StowMetaMaterialSchema;
60
+ export interface StowMetaGlbChild {
61
+ name: string;
62
+ childType: string;
63
+ stringId: string;
64
+ excluded?: boolean;
65
+ tags?: string[];
66
+ pack?: string;
67
+ cache?: StowMetaCache;
68
+ quality?: string;
69
+ resize?: string;
70
+ generateMipmaps?: boolean;
71
+ aacQuality?: string;
72
+ sampleRate?: string;
73
+ dracoQuality?: string;
74
+ materialOverrides?: Record<string, string | null>;
75
+ targetMeshId?: string | null;
76
+ materialConfig?: {
77
+ schemaId: string;
78
+ properties: StowMatProperty[];
79
+ };
80
+ }
81
+ export interface StowMetaGlbContainer extends StowMetaBase {
82
+ type: 'glbContainer';
83
+ preserveHierarchy?: boolean;
84
+ children: StowMetaGlbChild[];
85
+ }
86
+ export type StowMeta = StowMetaTexture | StowMetaAudio | StowMetaStaticMesh | StowMetaSkinnedMesh | StowMetaAnimationClip | StowMetaMaterialSchema | StowMetaGlbContainer;
58
87
  export interface StowMatProperty {
59
88
  fieldName: string;
60
89
  fieldType: string;
@@ -1,6 +1,15 @@
1
1
  import { readFile, writeFile, deleteFile } from '../node-fs.js';
2
2
  // ─── Cache File Path ─────────────────────────────────────────────────────────
3
+ const GLB_CONTAINER_EXTS = /\.(glb|gltf)\//i;
3
4
  export function cachePath(relativePath) {
5
+ // For container children, redirect to companion dir
6
+ const match = relativePath.match(GLB_CONTAINER_EXTS);
7
+ if (match) {
8
+ const containerEnd = relativePath.indexOf('/', match.index);
9
+ const containerPath = relativePath.substring(0, containerEnd);
10
+ const childName = relativePath.substring(containerEnd + 1);
11
+ return `${containerPath}.children/${childName}.stowcache`;
12
+ }
4
13
  return `${relativePath}.stowcache`;
5
14
  }
6
15
  // ─── Binary Cache Format ─────────────────────────────────────────────────────
@@ -101,6 +110,9 @@ export function computeSettingsHash(type, settings) {
101
110
  case 6: // AnimationClip
102
111
  parts.push('v2');
103
112
  break;
113
+ case 7: // GlbContainer
114
+ parts.push('v1');
115
+ break;
104
116
  }
105
117
  return parts.join('|');
106
118
  }
@@ -22,6 +22,8 @@ export interface AssetSettings {
22
22
  materialConfig: MaterialConfig;
23
23
  materialOverrides: Record<number, string | null>;
24
24
  pack: string;
25
+ excluded: boolean;
26
+ preserveHierarchy: boolean;
25
27
  }
26
28
  export declare function defaultAssetSettings(): AssetSettings;
27
29
  export interface ProjectAsset {
@@ -35,4 +37,6 @@ export interface ProjectAsset {
35
37
  metadata?: TextureMetadata | AudioMetadata | MeshMetadata | AnimationClipMetadata | SkinnedMeshMetadata;
36
38
  sourceSize: number;
37
39
  processedSize: number;
40
+ parentId?: string;
41
+ locked?: boolean;
38
42
  }
package/dist/app/state.js CHANGED
@@ -12,5 +12,7 @@ export function defaultAssetSettings() {
12
12
  materialConfig: { schemaId: '', properties: [] },
13
13
  materialOverrides: {},
14
14
  pack: 'default',
15
+ excluded: false,
16
+ preserveHierarchy: false,
15
17
  };
16
18
  }
@@ -1,7 +1,7 @@
1
1
  import type { AssetSettings } from './state.js';
2
2
  import { AssetType } from '../core/types.js';
3
3
  import type { ProjectAsset } from './state.js';
4
- import type { StowMeta } from './disk-project.js';
4
+ import type { StowMeta, StowMetaGlbChild } from './disk-project.js';
5
5
  export declare function detectAssetType(fileName: string): AssetType;
6
6
  export declare function stowmetaPath(relativePath: string): string;
7
7
  export declare function readStowmeta(srcArtDir: string, relativePath: string): Promise<StowMeta | null>;
@@ -12,3 +12,20 @@ export declare function stowmetaToAssetSettings(meta: StowMeta): {
12
12
  };
13
13
  export declare function assetSettingsToStowmeta(asset: ProjectAsset): StowMeta;
14
14
  export declare function generateDefaultStowmeta(relativePath: string, type?: AssetType): StowMeta;
15
+ /** Generate a default inline child entry for a GLB container. */
16
+ export declare function generateDefaultGlbChild(name: string, childType: string): StowMetaGlbChild;
17
+ /** Convert an inline child entry to { type, settings } for use in ProjectAsset. */
18
+ export declare function glbChildToAssetSettings(child: StowMetaGlbChild): {
19
+ type: AssetType;
20
+ settings: AssetSettings;
21
+ };
22
+ /** Convert runtime AssetSettings back to inline child fields (merges into existing child). */
23
+ export declare function assetSettingsToGlbChild(name: string, childType: string, stringId: string, settings: AssetSettings, existing?: StowMetaGlbChild): StowMetaGlbChild;
24
+ /** Read a specific child's settings from the container's stowmeta. */
25
+ export declare function readGlbChildSettings(srcArtDir: string, containerId: string, childName: string): Promise<{
26
+ child: StowMetaGlbChild;
27
+ type: AssetType;
28
+ settings: AssetSettings;
29
+ } | null>;
30
+ /** Write/merge a child's settings into the container's stowmeta. */
31
+ export declare function writeGlbChildSettings(srcArtDir: string, containerId: string, childName: string, stringId: string, childType: string, settings: AssetSettings): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { AssetType } from '../core/types.js';
2
- import { KTX2Quality, TextureResize, DracoQualityPreset, AacQuality, AudioSampleRate } from '../core/types.js';
3
- import { KTX2_QUALITY_STRINGS, KTX2_QUALITY_TO_STRING, TEXTURE_RESIZE_STRINGS, TEXTURE_RESIZE_TO_STRING, DRACO_QUALITY_STRINGS, DRACO_QUALITY_TO_STRING, AAC_QUALITY_STRINGS, AAC_QUALITY_TO_STRING, AUDIO_SAMPLE_RATE_STRINGS, AUDIO_SAMPLE_RATE_TO_STRING, } from './disk-project.js';
2
+ import { KTX2Quality, TextureResize, DracoQualityPreset, AacQuality, AudioSampleRate, MaterialFieldType, PreviewPropertyFlag } from '../core/types.js';
3
+ import { KTX2_QUALITY_STRINGS, KTX2_QUALITY_TO_STRING, TEXTURE_RESIZE_STRINGS, TEXTURE_RESIZE_TO_STRING, DRACO_QUALITY_STRINGS, DRACO_QUALITY_TO_STRING, AAC_QUALITY_STRINGS, AAC_QUALITY_TO_STRING, AUDIO_SAMPLE_RATE_STRINGS, AUDIO_SAMPLE_RATE_TO_STRING, MATERIAL_FIELD_TYPE_STRINGS, MATERIAL_FIELD_TYPE_TO_STRING, PREVIEW_FLAG_STRINGS, PREVIEW_FLAG_TO_STRING, } from './disk-project.js';
4
4
  import { readTextFile, writeFile } from '../node-fs.js';
5
5
  import { defaultAssetSettings } from './state.js';
6
6
  // ─── Detect asset type from file extension ──────────────────────────────────
@@ -11,7 +11,10 @@ const AUDIO_EXTENSIONS = new Set([
11
11
  'wav', 'mp3', 'ogg', 'flac', 'aac', 'm4a',
12
12
  ]);
13
13
  const MESH_EXTENSIONS = new Set([
14
- 'fbx', 'obj', 'gltf', 'glb',
14
+ 'fbx', 'obj',
15
+ ]);
16
+ const GLB_EXTENSIONS = new Set([
17
+ 'gltf', 'glb',
15
18
  ]);
16
19
  export function detectAssetType(fileName) {
17
20
  const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
@@ -21,6 +24,8 @@ export function detectAssetType(fileName) {
21
24
  return AssetType.Audio;
22
25
  if (MESH_EXTENSIONS.has(ext))
23
26
  return AssetType.StaticMesh;
27
+ if (GLB_EXTENSIONS.has(ext))
28
+ return AssetType.GlbContainer;
24
29
  return AssetType.Unknown;
25
30
  }
26
31
  // ─── Stowmeta path helper ───────────────────────────────────────────────────
@@ -56,6 +61,7 @@ export function stowmetaToAssetSettings(meta) {
56
61
  const base = defaultAssetSettings();
57
62
  base.tags = meta.tags ?? [];
58
63
  base.pack = meta.pack ?? 'default';
64
+ base.excluded = meta.excluded ?? false;
59
65
  switch (meta.type) {
60
66
  case 'texture':
61
67
  return {
@@ -83,6 +89,7 @@ export function stowmetaToAssetSettings(meta) {
83
89
  ...base,
84
90
  dracoQuality: DRACO_QUALITY_STRINGS[meta.dracoQuality] ?? DracoQualityPreset.Balanced,
85
91
  materialOverrides: parseMaterialOverrides(meta.materialOverrides),
92
+ preserveHierarchy: meta.preserveHierarchy ?? false,
86
93
  },
87
94
  };
88
95
  case 'skinnedMesh':
@@ -106,6 +113,14 @@ export function stowmetaToAssetSettings(meta) {
106
113
  type: AssetType.MaterialSchema,
107
114
  settings: base,
108
115
  };
116
+ case 'glbContainer':
117
+ return {
118
+ type: AssetType.GlbContainer,
119
+ settings: {
120
+ ...base,
121
+ preserveHierarchy: meta.preserveHierarchy ?? false,
122
+ },
123
+ };
109
124
  default:
110
125
  return { type: AssetType.Unknown, settings: base };
111
126
  }
@@ -128,6 +143,7 @@ export function assetSettingsToStowmeta(asset) {
128
143
  stringId: asset.stringId,
129
144
  tags: asset.settings.tags,
130
145
  pack: asset.settings.pack,
146
+ excluded: asset.settings.excluded || undefined,
131
147
  };
132
148
  switch (asset.type) {
133
149
  case AssetType.Texture2D:
@@ -151,6 +167,7 @@ export function assetSettingsToStowmeta(asset) {
151
167
  type: 'staticMesh',
152
168
  dracoQuality: DRACO_QUALITY_TO_STRING[asset.settings.dracoQuality] ?? 'balanced',
153
169
  materialOverrides: serializeMaterialOverrides(asset.settings.materialOverrides),
170
+ preserveHierarchy: asset.settings.preserveHierarchy || undefined,
154
171
  };
155
172
  case AssetType.SkinnedMesh:
156
173
  return {
@@ -166,6 +183,8 @@ export function assetSettingsToStowmeta(asset) {
166
183
  };
167
184
  case AssetType.MaterialSchema:
168
185
  return { ...base, type: 'materialSchema' };
186
+ case AssetType.GlbContainer:
187
+ return { ...base, type: 'glbContainer', preserveHierarchy: asset.settings.preserveHierarchy || undefined, children: [] };
169
188
  default:
170
189
  return { ...base, type: 'materialSchema' };
171
190
  }
@@ -201,7 +220,199 @@ export function generateDefaultStowmeta(relativePath, type) {
201
220
  return { ...base, type: 'animationClip', targetMeshId: null };
202
221
  case AssetType.MaterialSchema:
203
222
  return { ...base, type: 'materialSchema' };
223
+ case AssetType.GlbContainer:
224
+ return { ...base, type: 'glbContainer', children: [] };
204
225
  default:
205
226
  return { ...base, type: 'materialSchema' };
206
227
  }
207
228
  }
229
+ // ─── GLB Inline Child Helpers ───────────────────────────────────────────────
230
+ /** Generate a default inline child entry for a GLB container. */
231
+ export function generateDefaultGlbChild(name, childType) {
232
+ const baseName = name.replace(/\.[^.]+$/, '');
233
+ const stringId = childType === 'materialSchema' ? baseName : baseName.toLowerCase();
234
+ const child = { name, childType, stringId };
235
+ switch (childType) {
236
+ case 'texture':
237
+ child.quality = 'fastest';
238
+ child.resize = 'full';
239
+ child.generateMipmaps = false;
240
+ break;
241
+ case 'audio':
242
+ child.aacQuality = 'medium';
243
+ child.sampleRate = 'auto';
244
+ break;
245
+ case 'staticMesh':
246
+ case 'skinnedMesh':
247
+ child.dracoQuality = 'balanced';
248
+ child.materialOverrides = {};
249
+ break;
250
+ case 'animationClip':
251
+ child.targetMeshId = null;
252
+ break;
253
+ case 'materialSchema':
254
+ break;
255
+ }
256
+ return child;
257
+ }
258
+ /** Convert an inline child entry to { type, settings } for use in ProjectAsset. */
259
+ export function glbChildToAssetSettings(child) {
260
+ const base = defaultAssetSettings();
261
+ base.tags = child.tags ?? [];
262
+ base.pack = child.pack ?? 'default';
263
+ base.excluded = child.excluded ?? false;
264
+ switch (child.childType) {
265
+ case 'texture':
266
+ return {
267
+ type: AssetType.Texture2D,
268
+ settings: {
269
+ ...base,
270
+ quality: KTX2_QUALITY_STRINGS[child.quality ?? 'fastest'] ?? KTX2Quality.Fastest,
271
+ resize: TEXTURE_RESIZE_STRINGS[child.resize ?? 'full'] ?? TextureResize.Full,
272
+ generateMipmaps: child.generateMipmaps ?? false,
273
+ },
274
+ };
275
+ case 'audio':
276
+ return {
277
+ type: AssetType.Audio,
278
+ settings: {
279
+ ...base,
280
+ aacQuality: AAC_QUALITY_STRINGS[child.aacQuality ?? 'medium'] ?? AacQuality.Medium,
281
+ audioSampleRate: AUDIO_SAMPLE_RATE_STRINGS[child.sampleRate ?? 'auto'] ?? AudioSampleRate.Auto,
282
+ },
283
+ };
284
+ case 'staticMesh':
285
+ return {
286
+ type: AssetType.StaticMesh,
287
+ settings: {
288
+ ...base,
289
+ dracoQuality: DRACO_QUALITY_STRINGS[child.dracoQuality ?? 'balanced'] ?? DracoQualityPreset.Balanced,
290
+ materialOverrides: parseMaterialOverrides(child.materialOverrides),
291
+ },
292
+ };
293
+ case 'skinnedMesh':
294
+ return {
295
+ type: AssetType.SkinnedMesh,
296
+ settings: {
297
+ ...base,
298
+ materialOverrides: parseMaterialOverrides(child.materialOverrides),
299
+ },
300
+ };
301
+ case 'animationClip':
302
+ return {
303
+ type: AssetType.AnimationClip,
304
+ settings: {
305
+ ...base,
306
+ targetMeshId: child.targetMeshId ?? null,
307
+ },
308
+ };
309
+ case 'materialSchema':
310
+ if (child.materialConfig) {
311
+ base.materialConfig = glbChildMaterialConfigToRuntime(child.materialConfig);
312
+ }
313
+ return {
314
+ type: AssetType.MaterialSchema,
315
+ settings: base,
316
+ };
317
+ default:
318
+ return { type: AssetType.Unknown, settings: base };
319
+ }
320
+ }
321
+ /** Convert runtime AssetSettings back to inline child fields (merges into existing child). */
322
+ export function assetSettingsToGlbChild(name, childType, stringId, settings, existing) {
323
+ const child = {
324
+ ...(existing ?? {}),
325
+ name,
326
+ childType,
327
+ stringId,
328
+ };
329
+ if (settings.tags?.length)
330
+ child.tags = settings.tags;
331
+ else
332
+ delete child.tags;
333
+ if (settings.pack && settings.pack !== 'default')
334
+ child.pack = settings.pack;
335
+ else
336
+ delete child.pack;
337
+ if (settings.excluded)
338
+ child.excluded = true;
339
+ else
340
+ delete child.excluded;
341
+ switch (childType) {
342
+ case 'texture':
343
+ child.quality = KTX2_QUALITY_TO_STRING[settings.quality] ?? 'fastest';
344
+ child.resize = TEXTURE_RESIZE_TO_STRING[settings.resize] ?? 'full';
345
+ child.generateMipmaps = settings.generateMipmaps;
346
+ break;
347
+ case 'audio':
348
+ child.aacQuality = AAC_QUALITY_TO_STRING[settings.aacQuality] ?? 'medium';
349
+ child.sampleRate = AUDIO_SAMPLE_RATE_TO_STRING[settings.audioSampleRate] ?? 'auto';
350
+ break;
351
+ case 'staticMesh':
352
+ case 'skinnedMesh':
353
+ child.dracoQuality = DRACO_QUALITY_TO_STRING[settings.dracoQuality] ?? 'balanced';
354
+ child.materialOverrides = serializeMaterialOverrides(settings.materialOverrides);
355
+ break;
356
+ case 'animationClip':
357
+ child.targetMeshId = settings.targetMeshId ?? null;
358
+ break;
359
+ case 'materialSchema':
360
+ if (settings.materialConfig && settings.materialConfig.properties.length > 0) {
361
+ child.materialConfig = runtimeMaterialConfigToGlbChild(settings.materialConfig);
362
+ }
363
+ break;
364
+ }
365
+ return child;
366
+ }
367
+ /** Read a specific child's settings from the container's stowmeta. */
368
+ export async function readGlbChildSettings(srcArtDir, containerId, childName) {
369
+ const containerMeta = await readStowmeta(srcArtDir, containerId);
370
+ if (!containerMeta || containerMeta.type !== 'glbContainer')
371
+ return null;
372
+ const child = containerMeta.children.find(c => c.name === childName);
373
+ if (!child)
374
+ return null;
375
+ const { type, settings } = glbChildToAssetSettings(child);
376
+ return { child, type, settings };
377
+ }
378
+ /** Write/merge a child's settings into the container's stowmeta. */
379
+ export async function writeGlbChildSettings(srcArtDir, containerId, childName, stringId, childType, settings) {
380
+ const containerMeta = await readStowmeta(srcArtDir, containerId);
381
+ if (!containerMeta || containerMeta.type !== 'glbContainer')
382
+ return;
383
+ const idx = containerMeta.children.findIndex(c => c.name === childName);
384
+ const existing = idx >= 0 ? containerMeta.children[idx] : undefined;
385
+ const updated = assetSettingsToGlbChild(childName, childType, stringId, settings, existing);
386
+ if (idx >= 0) {
387
+ containerMeta.children[idx] = updated;
388
+ }
389
+ else {
390
+ containerMeta.children.push(updated);
391
+ }
392
+ await writeStowmeta(srcArtDir, containerId, containerMeta);
393
+ }
394
+ // ─── Material config conversion for inline GLB children ─────────────────────
395
+ function glbChildMaterialConfigToRuntime(config) {
396
+ return {
397
+ schemaId: config.schemaId ?? '',
398
+ properties: (config.properties ?? []).map((prop) => ({
399
+ fieldName: prop.fieldName ?? '',
400
+ fieldType: MATERIAL_FIELD_TYPE_STRINGS[prop.fieldType] ?? MaterialFieldType.Color,
401
+ previewFlag: PREVIEW_FLAG_STRINGS[prop.previewFlag] ?? PreviewPropertyFlag.None,
402
+ value: prop.value ?? [1, 1, 1, 1],
403
+ textureAssetId: prop.textureAsset ?? null,
404
+ })),
405
+ };
406
+ }
407
+ function runtimeMaterialConfigToGlbChild(config) {
408
+ return {
409
+ schemaId: config.schemaId,
410
+ properties: config.properties.map((prop) => ({
411
+ fieldName: prop.fieldName,
412
+ fieldType: MATERIAL_FIELD_TYPE_TO_STRING[prop.fieldType] ?? 'color',
413
+ previewFlag: PREVIEW_FLAG_TO_STRING[prop.previewFlag] ?? 'none',
414
+ value: [...prop.value],
415
+ textureAsset: prop.textureAssetId,
416
+ })),
417
+ };
418
+ }
package/dist/cleanup.js CHANGED
@@ -48,6 +48,8 @@ async function walkForOrphans(basePath, prefix, sourceFiles, matFiles, onOrphan)
48
48
  if (entry.isDirectory()) {
49
49
  if (entry.name.startsWith('.'))
50
50
  continue;
51
+ if (entry.name.endsWith('.children'))
52
+ continue;
51
53
  await walkForOrphans(basePath, relativePath, sourceFiles, matFiles, onOrphan);
52
54
  }
53
55
  else if (entry.isFile()) {
@@ -5,7 +5,8 @@ export declare enum AssetType {
5
5
  Audio = 3,
6
6
  MaterialSchema = 4,
7
7
  SkinnedMesh = 5,
8
- AnimationClip = 6
8
+ AnimationClip = 6,
9
+ GlbContainer = 7
9
10
  }
10
11
  export declare enum KTX2Quality {
11
12
  Fastest = 0,
@@ -8,6 +8,7 @@ export var AssetType;
8
8
  AssetType[AssetType["MaterialSchema"] = 4] = "MaterialSchema";
9
9
  AssetType[AssetType["SkinnedMesh"] = 5] = "SkinnedMesh";
10
10
  AssetType[AssetType["AnimationClip"] = 6] = "AnimationClip";
11
+ AssetType[AssetType["GlbContainer"] = 7] = "GlbContainer";
11
12
  })(AssetType || (AssetType = {}));
12
13
  // ─── Texture Enums ──────────────────────────────────────────────────────────
13
14
  export var KTX2Quality;
@@ -1,4 +1,4 @@
1
- import type { IMeshImporter, ImportedMesh } from './interfaces.js';
1
+ import type { IMeshImporter, MeshImportOpts, ImportedMesh } from './interfaces.js';
2
2
  export declare class NodeFbxImporter implements IMeshImporter {
3
- import(data: Uint8Array, _fileName: string): Promise<ImportedMesh>;
3
+ import(data: Uint8Array, _fileName: string, opts?: MeshImportOpts): Promise<ImportedMesh>;
4
4
  }
@@ -49,13 +49,15 @@ function ensureDomShim() {
49
49
  }
50
50
  // ─── FBX Importer ────────────────────────────────────────────────────────────
51
51
  export class NodeFbxImporter {
52
- async import(data, _fileName) {
52
+ async import(data, _fileName, opts) {
53
53
  ensureDomShim();
54
54
  try {
55
55
  const loader = new FBXLoader();
56
56
  const buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
57
57
  const group = loader.parse(buffer, '');
58
- const mesh = extractMeshFromGroup(group);
58
+ const mesh = opts?.preserveHierarchy
59
+ ? extractMeshFromGroupHierarchical(group)
60
+ : extractMeshFromGroup(group);
59
61
  mesh.animations = parseFbxAnimations(data);
60
62
  return mesh;
61
63
  }
@@ -298,6 +300,142 @@ function extractMeshFromGroup(group) {
298
300
  animations: [],
299
301
  };
300
302
  }
303
+ // ─── Hierarchical Group → ImportedMesh extraction ────────────────────────────
304
+ function extractMeshFromGroupHierarchical(group) {
305
+ group.updateMatrixWorld(true);
306
+ // Collect all relevant nodes — skip Bone, Light, Camera, SkinnedMesh
307
+ const nodeList = [];
308
+ const nodeIndexMap = new Map();
309
+ function collectNodes(obj) {
310
+ if (obj.isBone)
311
+ return;
312
+ if (obj.isLight)
313
+ return;
314
+ if (obj.isCamera)
315
+ return;
316
+ if (obj.isSkinnedMesh)
317
+ return;
318
+ nodeIndexMap.set(obj, nodeList.length);
319
+ nodeList.push(obj);
320
+ for (const child of obj.children) {
321
+ collectNodes(child);
322
+ }
323
+ }
324
+ for (const child of group.children) {
325
+ collectNodes(child);
326
+ }
327
+ if (nodeList.length === 0)
328
+ return emptyMesh();
329
+ const subMeshes = [];
330
+ const materials = [];
331
+ const materialSet = new Map();
332
+ const nodes = [];
333
+ for (let ni = 0; ni < nodeList.length; ni++) {
334
+ const obj = nodeList[ni];
335
+ const isMesh = obj.isMesh;
336
+ // Determine parent index
337
+ let parentIndex = -1;
338
+ let ancestor = obj.parent;
339
+ while (ancestor && ancestor !== group) {
340
+ const idx = nodeIndexMap.get(ancestor);
341
+ if (idx !== undefined) {
342
+ parentIndex = idx;
343
+ break;
344
+ }
345
+ ancestor = ancestor.parent;
346
+ }
347
+ // Local transform
348
+ const pos = [obj.position.x, obj.position.y, obj.position.z];
349
+ const rot = [obj.quaternion.x, obj.quaternion.y, obj.quaternion.z, obj.quaternion.w];
350
+ const scl = [obj.scale.x, obj.scale.y, obj.scale.z];
351
+ const meshIndices = [];
352
+ if (isMesh) {
353
+ const mesh = obj;
354
+ const geometry = mesh.geometry;
355
+ const posAttr = geometry.getAttribute('position');
356
+ if (!posAttr) {
357
+ nodes.push({ name: obj.name || `node_${ni}`, parentIndex, position: pos, rotation: rot, scale: scl, meshIndices: [] });
358
+ continue;
359
+ }
360
+ const vertCount = posAttr.count;
361
+ // Store positions in LOCAL space (no matrixWorld bake)
362
+ const positions = new Float32Array(vertCount * 3);
363
+ for (let vi = 0; vi < vertCount; vi++) {
364
+ positions[vi * 3] = posAttr.getX(vi);
365
+ positions[vi * 3 + 1] = posAttr.getY(vi);
366
+ positions[vi * 3 + 2] = posAttr.getZ(vi);
367
+ }
368
+ // Normals in local space
369
+ let normals = null;
370
+ const normAttr = geometry.getAttribute('normal');
371
+ if (normAttr && normAttr.count === vertCount) {
372
+ normals = new Float32Array(vertCount * 3);
373
+ const localNormalMatrix = new THREE.Matrix3().getNormalMatrix(mesh.matrix);
374
+ const n = new THREE.Vector3();
375
+ for (let vi = 0; vi < vertCount; vi++) {
376
+ n.fromBufferAttribute(normAttr, vi);
377
+ n.applyMatrix3(localNormalMatrix).normalize();
378
+ normals[vi * 3] = n.x;
379
+ normals[vi * 3 + 1] = n.y;
380
+ normals[vi * 3 + 2] = n.z;
381
+ }
382
+ }
383
+ // UVs — FBX convention: no V-flip
384
+ let uvs = null;
385
+ const uvAttr = geometry.getAttribute('uv');
386
+ if (uvAttr && uvAttr.count === vertCount) {
387
+ uvs = new Float32Array(vertCount * 2);
388
+ for (let vi = 0; vi < vertCount; vi++) {
389
+ uvs[vi * 2] = uvAttr.getX(vi);
390
+ uvs[vi * 2 + 1] = uvAttr.getY(vi);
391
+ }
392
+ }
393
+ const indexAttr = geometry.getIndex();
394
+ let indices;
395
+ if (indexAttr) {
396
+ indices = new Uint32Array(indexAttr.count);
397
+ for (let ii = 0; ii < indexAttr.count; ii++)
398
+ indices[ii] = indexAttr.getX(ii);
399
+ }
400
+ else {
401
+ indices = new Uint32Array(vertCount);
402
+ for (let ii = 0; ii < vertCount; ii++)
403
+ indices[ii] = ii;
404
+ }
405
+ const meshMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
406
+ const matName = meshMaterial?.name || 'default';
407
+ let matIndex;
408
+ if (materialSet.has(matName)) {
409
+ matIndex = materialSet.get(matName);
410
+ }
411
+ else {
412
+ matIndex = materials.length;
413
+ materialSet.set(matName, matIndex);
414
+ materials.push({ name: matName });
415
+ }
416
+ meshIndices.push(subMeshes.length);
417
+ subMeshes.push({ positions, normals, uvs, indices, materialIndex: matIndex });
418
+ }
419
+ nodes.push({
420
+ name: obj.name || `node_${ni}`,
421
+ parentIndex,
422
+ position: pos,
423
+ rotation: rot,
424
+ scale: scl,
425
+ meshIndices,
426
+ });
427
+ }
428
+ if (materials.length === 0)
429
+ materials.push({ name: 'default' });
430
+ return {
431
+ subMeshes,
432
+ materials,
433
+ nodes,
434
+ hasSkeleton: false,
435
+ bones: [],
436
+ animations: [],
437
+ };
438
+ }
301
439
  function emptyMesh() {
302
440
  return { subMeshes: [], materials: [{ name: 'default' }], nodes: [], hasSkeleton: false, bones: [], animations: [] };
303
441
  }