@forgeax/engine-gltf 0.1.21 → 0.1.24

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.
Files changed (68) hide show
  1. package/README.md +115 -3
  2. package/dist/__tests__/lod-import.integration.test.d.ts +2 -0
  3. package/dist/__tests__/lod-import.integration.test.d.ts.map +1 -0
  4. package/dist/__tests__/lod-meta-publication.integration.test.d.ts +2 -0
  5. package/dist/__tests__/lod-meta-publication.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/physical-clearcoat.unit.test.d.ts +2 -0
  7. package/dist/__tests__/physical-clearcoat.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/physical-material-import.integration.test.d.ts +2 -0
  9. package/dist/__tests__/physical-material-import.integration.test.d.ts.map +1 -0
  10. package/dist/bridge.d.ts +1 -0
  11. package/dist/bridge.d.ts.map +1 -1
  12. package/dist/check-extensions.d.ts +1 -1
  13. package/dist/check-extensions.d.ts.map +1 -1
  14. package/dist/cli-gltf.mjs +362 -14
  15. package/dist/cli-gltf.mjs.map +1 -1
  16. package/dist/errors.d.ts +13 -0
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/gltf-importer.d.ts.map +1 -1
  19. package/dist/image-color-space.d.ts +12 -0
  20. package/dist/image-color-space.d.ts.map +1 -1
  21. package/dist/importer-entry.mjs +516 -25
  22. package/dist/importer-entry.mjs.map +1 -1
  23. package/dist/index.d.ts +4 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.mjs +609 -28
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/lod/parse-lod.d.ts +15 -0
  28. package/dist/lod/parse-lod.d.ts.map +1 -0
  29. package/dist/lod/project-meta.d.ts +23 -0
  30. package/dist/lod/project-meta.d.ts.map +1 -0
  31. package/dist/material/parse-material.d.ts +66 -0
  32. package/dist/material/parse-material.d.ts.map +1 -1
  33. package/dist/node-file-entry.mjs +265 -8
  34. package/dist/node-file-entry.mjs.map +1 -1
  35. package/dist/parse-gltf.d.ts +3 -1
  36. package/dist/parse-gltf.d.ts.map +1 -1
  37. package/dist/reimport-reuse-meta.d.ts +1 -1
  38. package/dist/reimport-reuse-meta.d.ts.map +1 -1
  39. package/package.json +12 -12
  40. package/src/__tests__/bridge-material-values.unit.test.ts +84 -1
  41. package/src/__tests__/bridge-skin-stride.unit.test.ts +22 -0
  42. package/src/__tests__/fixtures/lod/core-only.gltf +1 -0
  43. package/src/__tests__/fixtures/lod/msft-lod-malformed.gltf +1 -0
  44. package/src/__tests__/fixtures/lod/msft-lod-multi-root.gltf +1 -0
  45. package/src/__tests__/fixtures/lod/msft-lod-node-extras.gltf +1 -0
  46. package/src/__tests__/fixtures/lod/msft-lod.gltf +1 -0
  47. package/src/__tests__/gltf-error-derived-views.test-d.ts +8 -0
  48. package/src/__tests__/gltf-error-migration.test.ts +3 -3
  49. package/src/__tests__/gltf.unit.test.ts +60 -5
  50. package/src/__tests__/lod-import.integration.test.ts +106 -0
  51. package/src/__tests__/lod-meta-publication.integration.test.ts +24 -0
  52. package/src/__tests__/material-pack-refs.integration.test.ts +53 -0
  53. package/src/__tests__/parse-gltf.unit.test.ts +50 -0
  54. package/src/__tests__/physical-clearcoat.unit.test.ts +110 -0
  55. package/src/__tests__/physical-material-import.integration.test.ts +239 -0
  56. package/src/__tests__/source-key-producer.integration.test.ts +6 -2
  57. package/src/bridge.ts +259 -14
  58. package/src/check-extensions.ts +16 -8
  59. package/src/errors.ts +32 -2
  60. package/src/gltf-importer.ts +122 -1
  61. package/src/image-color-space.ts +29 -4
  62. package/src/index.ts +10 -1
  63. package/src/lod/parse-lod.ts +129 -0
  64. package/src/lod/project-meta.ts +48 -0
  65. package/src/material/parse-material.ts +307 -0
  66. package/src/parse-gltf.ts +97 -1
  67. package/src/reimport-reuse-meta.ts +3 -1
  68. package/dist/.tsbuildinfo +0 -1
@@ -162,6 +162,56 @@ describe('glTF transmission material IR', () => {
162
162
  });
163
163
  });
164
164
 
165
+ describe('glTF specular material IR', () => {
166
+ it('projects specularTexture through the alpha channel', () => {
167
+ const result = parseMaterialIr(
168
+ {
169
+ extensions: {
170
+ KHR_materials_specular: {
171
+ specularFactor: 0.75,
172
+ specularTexture: { index: 0 },
173
+ specularColorFactor: [0.8, 0.7, 0.6],
174
+ specularColorTexture: { index: 1 },
175
+ },
176
+ },
177
+ },
178
+ [{ source: 0 }, { source: 1 }],
179
+ );
180
+
181
+ expect(result.ok).toBe(true);
182
+ if (!result.ok) return;
183
+ expect(result.value).toMatchObject({
184
+ specularFactor: 0.75,
185
+ specularChannel: 3,
186
+ specularTexture: { texture: 0 },
187
+ specularColorFactor: [0.8, 0.7, 0.6],
188
+ specularColorChannel: [0, 1, 2],
189
+ specularColorTexture: { texture: 1 },
190
+ });
191
+ });
192
+ });
193
+
194
+ describe('glTF required extension admission', () => {
195
+ it.each([
196
+ 'KHR_materials_transmission',
197
+ 'KHR_materials_ior',
198
+ 'KHR_materials_volume',
199
+ ])('accepts %s through the parse path', async (extension) => {
200
+ const result = await parseGltf(
201
+ {
202
+ asset: { version: '2.0' },
203
+ extensionsUsed: [extension],
204
+ extensionsRequired: [extension],
205
+ },
206
+ noopLoader,
207
+ `/required-${extension}.gltf`,
208
+ );
209
+
210
+ expect(result.ok).toBe(true);
211
+ if (!result.ok) return;
212
+ expect(result.value.diagnostics.unsupportedExtensions).toEqual([]);
213
+ });
214
+ });
165
215
  describe('glTF COLOR_0 importer carrier', () => {
166
216
  it('publishes normalized VEC3 FLOAT colors as importer-owned RGBA', async () => {
167
217
  const fixture = JSON.parse(
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateMaterialTangentInputs } from '../bridge.js';
3
+ import { checkExtensions } from '../check-extensions.js';
4
+ import { parseMaterial } from '../material/parse-material.js';
5
+
6
+ describe('KHR_materials_clearcoat projection', () => {
7
+ it('projects factor, R/G textures, coat normal RG, scale, and transform facts', () => {
8
+ const result = parseMaterial(
9
+ {
10
+ extensions: {
11
+ KHR_materials_clearcoat: {
12
+ clearcoatFactor: 0.75,
13
+ clearcoatTexture: {
14
+ index: 0,
15
+ texCoord: 2,
16
+ extensions: { KHR_texture_transform: { offset: [0.25, 0.5] } },
17
+ },
18
+ clearcoatRoughnessFactor: 0.25,
19
+ clearcoatRoughnessTexture: { index: 1, texCoord: 3 },
20
+ clearcoatNormalTexture: { index: 2, texCoord: 4, scale: 0.5 },
21
+ },
22
+ },
23
+ } as never,
24
+ [{ source: 0 }, { source: 1 }, { source: 2 }],
25
+ );
26
+ expect(result.ok).toBe(true);
27
+ if (result.ok) {
28
+ expect(result.value).toMatchObject({
29
+ clearcoatFactor: 0.75,
30
+ clearcoatChannel: 0,
31
+ clearcoatRoughnessFactor: 0.25,
32
+ clearcoatRoughnessChannel: 1,
33
+ clearcoatNormalTexture: { texture: 2, texCoord: 4, scale: 0.5 },
34
+ clearcoatNormalChannel: [0, 1],
35
+ });
36
+ expect((result.value as unknown as Record<string, unknown>).clearcoatTexture).toMatchObject({
37
+ texture: 0,
38
+ texCoord: 2,
39
+ transform: { offset: [0.25, 0.5] },
40
+ });
41
+ }
42
+ });
43
+
44
+ it('admits the clearcoat extension as a required extension', () => {
45
+ const result = checkExtensions({ extensionsRequired: ['KHR_materials_clearcoat'] });
46
+ expect(result.ok).toBe(true);
47
+ });
48
+
49
+ it('reports tangent admission with material, mesh, layer, UV, and attributes', () => {
50
+ const result = validateMaterialTangentInputs(
51
+ {
52
+ name: 'coat',
53
+ baseColorFactor: [1, 1, 1, 1],
54
+ metallicFactor: 0,
55
+ roughnessFactor: 0.5,
56
+ clearcoatNormalTexture: { texture: 0 },
57
+ } as never,
58
+ {
59
+ name: 'mesh',
60
+ positions: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
61
+ texcoord0: new Float32Array([0, 0, 0, 0, 0, 0]),
62
+ materialIndex: 0,
63
+ meshIndex: 0,
64
+ indices: new Uint32Array([0, 1, 2]),
65
+ },
66
+ );
67
+ expect(result.ok).toBe(false);
68
+ if (!result.ok) {
69
+ expect(result.error.code).toBe('material-tangent-required');
70
+ expect(result.error.detail).toMatchObject({
71
+ material: 'coat',
72
+ mesh: 'mesh',
73
+ layer: 'clearcoat',
74
+ uv: 'TEXCOORD_0',
75
+ attributes: ['NORMAL', 'TEXCOORD_0', 'TANGENT'],
76
+ });
77
+ }
78
+ });
79
+
80
+ it('requires a tangent frame for scalar-only anisotropy', () => {
81
+ const result = validateMaterialTangentInputs(
82
+ {
83
+ name: 'anisotropic-scalar',
84
+ baseColorFactor: [1, 1, 1, 1],
85
+ metallicFactor: 0,
86
+ roughnessFactor: 0.5,
87
+ anisotropyStrength: 0.4,
88
+ anisotropyRotation: 0,
89
+ } as never,
90
+ {
91
+ name: 'mesh-without-frame',
92
+ positions: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
93
+ materialIndex: 0,
94
+ meshIndex: 0,
95
+ indices: new Uint32Array([0, 1, 2]),
96
+ },
97
+ );
98
+ expect(result.ok).toBe(false);
99
+ if (!result.ok) {
100
+ expect(result.error.code).toBe('material-tangent-required');
101
+ expect(result.error.detail).toMatchObject({
102
+ material: 'anisotropic-scalar',
103
+ mesh: 'mesh-without-frame',
104
+ layer: 'anisotropy',
105
+ uv: 'TEXCOORD_0',
106
+ attributes: ['NORMAL', 'TEXCOORD_0', 'TANGENT'],
107
+ });
108
+ }
109
+ });
110
+ });
@@ -0,0 +1,239 @@
1
+ import { AssetRegistry } from '@forgeax/engine-assets-runtime';
2
+ import {
3
+ ImporterRegistry,
4
+ type ImportRunnerFs,
5
+ type RunImportMeta,
6
+ runImport,
7
+ } from '@forgeax/engine-import';
8
+ import type { TextureAsset } from '@forgeax/engine-types';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { gltfImporter } from '../gltf-importer.js';
11
+
12
+ const SOURCE = 'physical-material.gltf';
13
+ const TINY_PNG_BASE64 =
14
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC';
15
+
16
+ const MATERIAL_GUID = '019f0000-0000-7000-8000-000000000100';
17
+ const TEXTURE_GUIDS = [
18
+ '019f0000-0000-7000-8000-000000000101',
19
+ '019f0000-0000-7000-8000-000000000102',
20
+ '019f0000-0000-7000-8000-000000000103',
21
+ '019f0000-0000-7000-8000-000000000104',
22
+ '019f0000-0000-7000-8000-000000000105',
23
+ ] as const;
24
+ const SAMPLER_GUIDS = [
25
+ '019f0000-0000-7000-8000-000000000106',
26
+ '019f0000-0000-7000-8000-000000000107',
27
+ '019f0000-0000-7000-8000-000000000108',
28
+ '019f0000-0000-7000-8000-000000000109',
29
+ '019f0000-0000-7000-8000-00000000010a',
30
+ ] as const;
31
+
32
+ function dataUri(): string {
33
+ return `data:image/png;base64,${TINY_PNG_BASE64}`;
34
+ }
35
+
36
+ function sourceBytes(): Uint8Array {
37
+ const textures = TEXTURE_GUIDS.map((_guid, index) => ({ source: index, sampler: index }));
38
+ return new TextEncoder().encode(
39
+ JSON.stringify({
40
+ asset: { version: '2.0' },
41
+ extensionsUsed: [
42
+ 'KHR_materials_clearcoat',
43
+ 'KHR_materials_anisotropy',
44
+ 'KHR_materials_sheen',
45
+ 'KHR_materials_iridescence',
46
+ 'KHR_materials_specular',
47
+ ],
48
+ materials: [
49
+ {
50
+ name: 'PhysicalSurface',
51
+ pbrMetallicRoughness: {
52
+ baseColorFactor: [0.8, 0.7, 0.6, 1],
53
+ metallicFactor: 0.1,
54
+ roughnessFactor: 0.35,
55
+ },
56
+ extensions: {
57
+ KHR_materials_clearcoat: {
58
+ clearcoatFactor: 0.75,
59
+ clearcoatRoughnessFactor: 0.2,
60
+ clearcoatNormalTexture: { index: 0 },
61
+ },
62
+ KHR_materials_anisotropy: {
63
+ anisotropyStrength: 0.5,
64
+ anisotropyRotation: 0.25,
65
+ anisotropyTexture: { index: 1 },
66
+ },
67
+ KHR_materials_sheen: {
68
+ sheenColorFactor: [0.2, 0.1, 0.05],
69
+ sheenRoughnessFactor: 0.3,
70
+ sheenColorTexture: { index: 2 },
71
+ },
72
+ KHR_materials_iridescence: {
73
+ iridescenceFactor: 0.6,
74
+ iridescenceIor: 1.4,
75
+ iridescenceThicknessMinimum: 120,
76
+ iridescenceThicknessMaximum: 380,
77
+ iridescenceTexture: { index: 3 },
78
+ },
79
+ KHR_materials_specular: {
80
+ specularFactor: 0.8,
81
+ specularColorFactor: [0.9, 0.85, 0.75],
82
+ specularColorTexture: { index: 4 },
83
+ },
84
+ },
85
+ },
86
+ ],
87
+ textures,
88
+ samplers: [{}, {}, {}, {}, {}],
89
+ images: TEXTURE_GUIDS.map(() => ({ uri: dataUri(), mimeType: 'image/png' })),
90
+ scenes: [],
91
+ nodes: [],
92
+ meshes: [],
93
+ }),
94
+ );
95
+ }
96
+
97
+ function meta(): RunImportMeta {
98
+ return {
99
+ importer: 'gltf',
100
+ source: SOURCE,
101
+ subAssets: [
102
+ {
103
+ guid: MATERIAL_GUID,
104
+ sourceIndex: 0,
105
+ sourceKey: 'material/physical-surface',
106
+ kind: 'material',
107
+ },
108
+ ...TEXTURE_GUIDS.map((guid, sourceIndex) => ({
109
+ guid,
110
+ sourceIndex,
111
+ sourceKey: `texture/${sourceIndex}`,
112
+ kind: 'texture' as const,
113
+ })),
114
+ ...SAMPLER_GUIDS.map((guid, sourceIndex) => ({
115
+ guid,
116
+ sourceIndex,
117
+ sourceKey: `sampler/${sourceIndex}`,
118
+ kind: 'sampler' as const,
119
+ })),
120
+ ],
121
+ };
122
+ }
123
+
124
+ function decodeBase64(value: string): Uint8Array {
125
+ const binary = atob(value);
126
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
127
+ }
128
+
129
+ function fs(): ImportRunnerFs {
130
+ return {
131
+ readSource: async (sourcePath) =>
132
+ sourcePath === SOURCE
133
+ ? { ok: true, value: sourceBytes() }
134
+ : { ok: false, error: new Error(`unexpected source path ${sourcePath}`) },
135
+ decodeImage: async (_bytes, _mimeType, settings) => {
136
+ const colorSpace = settings.colorSpace === 'srgb' ? 'srgb' : 'linear';
137
+ const texture: TextureAsset = {
138
+ kind: 'texture',
139
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
140
+ format: colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm',
141
+ data: new Uint8Array([128, 96, 64, 255]),
142
+ colorSpace,
143
+ mips: { kind: 'generate' },
144
+ };
145
+ return {
146
+ ok: true,
147
+ value: {
148
+ texture,
149
+ bytes: decodeBase64(TINY_PNG_BASE64),
150
+ mediaType: 'image/png',
151
+ assetCodec: { name: 'rgba8', version: '1' },
152
+ },
153
+ };
154
+ },
155
+ };
156
+ }
157
+
158
+ describe('glTF physical material importer route', () => {
159
+ it('carries all five KHR layers from importer through pack and runtime material parsing', async () => {
160
+ const importers = new ImporterRegistry();
161
+ importers.register(gltfImporter);
162
+
163
+ const result = await runImport(meta(), importers, fs());
164
+ expect(result.ok).toBe(true);
165
+ if (!result.ok || 'skipped' in result.value)
166
+ throw new Error('physical import did not publish a pack');
167
+
168
+ expect(result.value.pack.assets).toHaveLength(11);
169
+ expect(result.value.cookProducts).toHaveLength(11);
170
+ expect(result.value.cookProducts.every((product) => product.receipt !== undefined)).toBe(true);
171
+
172
+ const material = result.value.pack.assets.find((asset) => asset.guid === MATERIAL_GUID);
173
+ if (material === undefined) throw new Error('physical material pack row missing');
174
+ expect(material.refs).toEqual([
175
+ TEXTURE_GUIDS[0],
176
+ SAMPLER_GUIDS[0],
177
+ TEXTURE_GUIDS[1],
178
+ SAMPLER_GUIDS[1],
179
+ TEXTURE_GUIDS[2],
180
+ SAMPLER_GUIDS[2],
181
+ TEXTURE_GUIDS[3],
182
+ SAMPLER_GUIDS[3],
183
+ TEXTURE_GUIDS[4],
184
+ SAMPLER_GUIDS[4],
185
+ ]);
186
+
187
+ const payload = material.payload;
188
+ const parameterNames = new Set(
189
+ (payload.parameters as readonly { name: string }[]).map((parameter) => parameter.name),
190
+ );
191
+ for (const name of [
192
+ 'clearcoat',
193
+ 'anisotropyStrength',
194
+ 'sheenColor',
195
+ 'iridescence',
196
+ 'specular',
197
+ 'clearcoatNormalTexture',
198
+ 'anisotropyTexture',
199
+ 'sheenColorTexture',
200
+ 'iridescenceTexture',
201
+ 'specularColorTexture',
202
+ ]) {
203
+ expect(parameterNames.has(name)).toBe(true);
204
+ }
205
+
206
+ const runtime = new AssetRegistry({
207
+ findMaterialArtifact: () => ({ ok: false, error: new Error('shader registry not wired') }),
208
+ } as never);
209
+ const parsed = runtime.parseAndReturnAsset({
210
+ kind: material.kind,
211
+ payload,
212
+ refs: [...material.refs],
213
+ });
214
+ expect(parsed.ok).toBe(true);
215
+ if (!parsed.ok) return;
216
+ expect(parsed.value.asset.kind).toBe('material');
217
+ const values = (parsed.value.asset as { values?: Record<string, unknown> }).values ?? {};
218
+ expect(values.clearcoatNormalTexture).toMatchObject({
219
+ texture: TEXTURE_GUIDS[0],
220
+ sampler: SAMPLER_GUIDS[0],
221
+ });
222
+ expect(values.anisotropyTexture).toMatchObject({
223
+ texture: TEXTURE_GUIDS[1],
224
+ sampler: SAMPLER_GUIDS[1],
225
+ });
226
+ expect(values.sheenColorTexture).toMatchObject({
227
+ texture: TEXTURE_GUIDS[2],
228
+ sampler: SAMPLER_GUIDS[2],
229
+ });
230
+ expect(values.iridescenceTexture).toMatchObject({
231
+ texture: TEXTURE_GUIDS[3],
232
+ sampler: SAMPLER_GUIDS[3],
233
+ });
234
+ expect(values.specularColorTexture).toMatchObject({
235
+ texture: TEXTURE_GUIDS[4],
236
+ sampler: SAMPLER_GUIDS[4],
237
+ });
238
+ });
239
+ });
@@ -86,7 +86,9 @@ describe('glTF producer source-key boundary', () => {
86
86
  expect(result.ok).toBe(false);
87
87
  if (result.ok) return;
88
88
  expect(result.error.code).toBe('duplicate-source-key');
89
- expect(result.error.detail.sourceIndices).toEqual([0, 1]);
89
+ expect((result.error.detail as { sourceIndices: readonly number[] }).sourceIndices).toEqual([
90
+ 0, 1,
91
+ ]);
90
92
  });
91
93
 
92
94
  it('applies the same preflight to duplicate material names', () => {
@@ -95,7 +97,9 @@ describe('glTF producer source-key boundary', () => {
95
97
  expect(result.ok).toBe(false);
96
98
  if (result.ok) return;
97
99
  expect(result.error.code).toBe('duplicate-source-key');
98
- expect(result.error.detail.sourceIndices).toEqual([0, 1]);
100
+ expect((result.error.detail as { sourceIndices: readonly number[] }).sourceIndices).toEqual([
101
+ 0, 1,
102
+ ]);
99
103
  });
100
104
 
101
105
  it('publishes producer-valid output when semantic identity is unique', () => {