@forgeax/engine-gltf 0.1.23 → 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 (43) hide show
  1. package/README.md +56 -3
  2. package/dist/__tests__/physical-clearcoat.unit.test.d.ts +2 -0
  3. package/dist/__tests__/physical-clearcoat.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/physical-material-import.integration.test.d.ts +2 -0
  5. package/dist/__tests__/physical-material-import.integration.test.d.ts.map +1 -0
  6. package/dist/bridge.d.ts +1 -0
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/check-extensions.d.ts +1 -1
  9. package/dist/check-extensions.d.ts.map +1 -1
  10. package/dist/cli-gltf.mjs +181 -8
  11. package/dist/cli-gltf.mjs.map +1 -1
  12. package/dist/errors.d.ts +7 -0
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/gltf-importer.d.ts.map +1 -1
  15. package/dist/image-color-space.d.ts +12 -0
  16. package/dist/image-color-space.d.ts.map +1 -1
  17. package/dist/importer-entry.mjs +373 -24
  18. package/dist/importer-entry.mjs.map +1 -1
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.mjs +374 -25
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/material/parse-material.d.ts +66 -0
  24. package/dist/material/parse-material.d.ts.map +1 -1
  25. package/dist/node-file-entry.mjs +181 -8
  26. package/dist/node-file-entry.mjs.map +1 -1
  27. package/package.json +12 -12
  28. package/src/__tests__/bridge-material-values.unit.test.ts +84 -1
  29. package/src/__tests__/bridge-skin-stride.unit.test.ts +22 -0
  30. package/src/__tests__/gltf-error-derived-views.test-d.ts +4 -0
  31. package/src/__tests__/gltf-error-migration.test.ts +3 -3
  32. package/src/__tests__/gltf.unit.test.ts +52 -6
  33. package/src/__tests__/material-pack-refs.integration.test.ts +53 -0
  34. package/src/__tests__/parse-gltf.unit.test.ts +29 -1
  35. package/src/__tests__/physical-clearcoat.unit.test.ts +110 -0
  36. package/src/__tests__/physical-material-import.integration.test.ts +239 -0
  37. package/src/bridge.ts +258 -13
  38. package/src/check-extensions.ts +16 -10
  39. package/src/errors.ts +21 -2
  40. package/src/gltf-importer.ts +35 -0
  41. package/src/image-color-space.ts +29 -4
  42. package/src/index.ts +8 -1
  43. package/src/material/parse-material.ts +307 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-gltf",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -38,21 +38,21 @@
38
38
  "LICENSE"
39
39
  ],
40
40
  "dependencies": {
41
- "@forgeax/engine-animation": "0.1.23",
42
- "@forgeax/engine-geometry": "0.1.23",
43
- "@forgeax/engine-import": "0.1.23",
44
- "@forgeax/engine-math": "0.1.23",
45
- "@forgeax/engine-pack": "0.1.23",
46
- "@forgeax/engine-scene": "0.1.23",
47
- "@forgeax/engine-types": "0.1.23",
41
+ "@forgeax/engine-animation": "0.1.24",
42
+ "@forgeax/engine-geometry": "0.1.24",
43
+ "@forgeax/engine-import": "0.1.24",
44
+ "@forgeax/engine-math": "0.1.24",
45
+ "@forgeax/engine-pack": "0.1.24",
46
+ "@forgeax/engine-scene": "0.1.24",
47
+ "@forgeax/engine-types": "0.1.24",
48
48
  "@webgpu/types": "^0.1.71",
49
49
  "meshoptimizer": "^0.20.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@forgeax/engine-assets-runtime": "0.1.23",
53
- "@forgeax/engine-picking": "0.1.23",
54
- "@forgeax/engine-render": "0.1.23",
55
- "@forgeax/engine-runtime": "0.1.23",
52
+ "@forgeax/engine-assets-runtime": "0.1.24",
53
+ "@forgeax/engine-picking": "0.1.24",
54
+ "@forgeax/engine-render": "0.1.24",
55
+ "@forgeax/engine-runtime": "0.1.24",
56
56
  "@types/node": "^20.14.0"
57
57
  },
58
58
  "forgeax": {
@@ -1,4 +1,4 @@
1
- import type { Handle } from '@forgeax/engine-types';
1
+ import type { AssetGuid, Handle } from '@forgeax/engine-types';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import { toMaterialAsset } from '../bridge.js';
4
4
  import type { GltfMaterialIr } from '../parse-gltf.js';
@@ -149,4 +149,87 @@ describe('glTF material bridge values', () => {
149
149
  },
150
150
  });
151
151
  });
152
+
153
+ it('owns a complete five-extension Standard root and admits only authored physical textures', () => {
154
+ const material = toMaterialAsset(
155
+ {
156
+ name: 'physical-root',
157
+ baseColorFactor: [0.8, 0.7, 0.6, 1],
158
+ metallicFactor: 0.1,
159
+ roughnessFactor: 0.35,
160
+ clearcoatFactor: 0.75,
161
+ clearcoatRoughnessFactor: 0.2,
162
+ clearcoatNormalTexture: { texture: 0 },
163
+ anisotropyStrength: 0.5,
164
+ anisotropyRotation: 0.25,
165
+ anisotropyTexture: { texture: 1 },
166
+ sheenColorFactor: [0.2, 0.1, 0.05],
167
+ sheenRoughnessFactor: 0.3,
168
+ sheenColorTexture: { texture: 2 },
169
+ iridescenceFactor: 0.6,
170
+ iridescenceIor: 1.4,
171
+ iridescenceThicknessMinimum: 120,
172
+ iridescenceThicknessMaximum: 380,
173
+ iridescenceTexture: { texture: 3 },
174
+ specularFactor: 0.8,
175
+ specularColorFactor: [0.9, 0.85, 0.75],
176
+ specularColorTexture: { texture: 4 },
177
+ } as unknown as GltfMaterialIr,
178
+ {
179
+ standardRootGuid: 'standard-root' as unknown as AssetGuid,
180
+ textureHandles: new Map([
181
+ [0, 100],
182
+ [1, 101],
183
+ [2, 102],
184
+ [3, 103],
185
+ [4, 104],
186
+ ]) as unknown as ReadonlyMap<number, Handle<'TextureAsset', 'shared'>>,
187
+ },
188
+ );
189
+
190
+ expect(material.parent).toBeUndefined();
191
+ const names = new Set((material.parameters ?? []).map((parameter) => parameter.name));
192
+ for (const name of [
193
+ 'clearcoat',
194
+ 'clearcoatRoughness',
195
+ 'clearcoatNormalTexture',
196
+ 'anisotropyStrength',
197
+ 'anisotropyRotation',
198
+ 'anisotropyTexture',
199
+ 'sheenColor',
200
+ 'sheenRoughness',
201
+ 'sheenColorTexture',
202
+ 'iridescence',
203
+ 'iridescenceIor',
204
+ 'iridescenceThicknessMinimum',
205
+ 'iridescenceThicknessMaximum',
206
+ 'iridescenceTexture',
207
+ 'specular',
208
+ 'specularColor',
209
+ 'specularColorTexture',
210
+ ]) {
211
+ expect(names.has(name)).toBe(true);
212
+ }
213
+ for (const name of [
214
+ 'clearcoatTexture',
215
+ 'clearcoatRoughnessTexture',
216
+ 'sheenRoughnessTexture',
217
+ 'iridescenceThicknessTexture',
218
+ 'specularTexture',
219
+ ]) {
220
+ expect(names.has(name)).toBe(false);
221
+ }
222
+ expect(material.values).toMatchObject({
223
+ clearcoat: 0.75,
224
+ anisotropyStrength: 0.5,
225
+ sheenColor: [0.2, 0.1, 0.05],
226
+ iridescenceIor: 1.4,
227
+ specularColor: [0.9, 0.85, 0.75],
228
+ clearcoatNormalTexture: { texture: 100 },
229
+ anisotropyTexture: { texture: 101 },
230
+ sheenColorTexture: { texture: 102 },
231
+ iridescenceTexture: { texture: 103 },
232
+ specularColorTexture: { texture: 104 },
233
+ });
234
+ });
152
235
  });
@@ -102,6 +102,28 @@ describe('meshIrToMeshAsset pure-unskinned path (AC-04 / AC-08 / OOS-9)', () =>
102
102
  });
103
103
 
104
104
  describe('glTF bridge uses geometry-owned packing', () => {
105
+ it('persists a generated tangent frame when source TANGENT is absent', () => {
106
+ const asset = meshIrToMeshAsset([
107
+ {
108
+ meshIndex: 0,
109
+ materialIndex: null,
110
+ positions: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
111
+ normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]),
112
+ texcoord0: new Float32Array([0, 0, 1, 0, 0, 1]),
113
+ indices: new Uint16Array([0, 1, 2]),
114
+ },
115
+ ]);
116
+
117
+ const tangent = asset.attributes.tangent;
118
+ expect(tangent).toBeInstanceOf(Float32Array);
119
+ if (!(tangent instanceof Float32Array)) throw new Error('generated tangent stream missing');
120
+ expect(tangent?.length).toBe(12);
121
+ expect(tangent?.[0]).toBeCloseTo(1, 5);
122
+ expect(tangent?.[1]).toBeCloseTo(0, 5);
123
+ expect(tangent?.[2]).toBeCloseTo(0, 5);
124
+ expect(Math.abs(tangent?.[3] ?? 0)).toBeCloseTo(1, 5);
125
+ });
126
+
105
127
  it('does not own a parallel 12F/18F stride or uv count calculation', () => {
106
128
  const asset = meshIrToMeshAsset([makeUnskinnedPrim(0, 4)]);
107
129
  expect(asset.vertices.length / 12).toBe(4);
@@ -15,6 +15,7 @@ import {
15
15
  type GltfInstancingCountMismatchDetail,
16
16
  type GltfLodInvalidDetail,
17
17
  type GltfMalformedHeaderDetail,
18
+ type GltfMaterialPhysicalInvalidDetail,
18
19
  type GltfMaterialTransmissionInvalidDetail,
19
20
  type GltfMeshBridgeInvalidDetail,
20
21
  type GltfMeshoptDecodeFailedDetail,
@@ -55,6 +56,7 @@ type ExpectedDetails = {
55
56
  readonly 'gltf-mesh-bridge-invalid': GltfMeshBridgeInvalidDetail;
56
57
  readonly 'gltf-lod-invalid': GltfLodInvalidDetail;
57
58
  readonly 'gltf-material-transmission-invalid': GltfMaterialTransmissionInvalidDetail;
59
+ readonly 'gltf-material-physical-invalid': GltfMaterialPhysicalInvalidDetail;
58
60
  };
59
61
 
60
62
  type ExpectedCodes = keyof ExpectedDetails;
@@ -117,6 +119,8 @@ function exhaustive(error: GltfError): string {
117
119
  return error.detail.reason;
118
120
  case 'gltf-material-transmission-invalid':
119
121
  return `${error.detail.extension}:${error.detail.field}:${error.detail.reason}`;
122
+ case 'gltf-material-physical-invalid':
123
+ return `${error.detail.extension}:${error.detail.field}:${error.detail.reason}`;
120
124
  }
121
125
  return error;
122
126
  }
@@ -17,7 +17,7 @@ import { resolve } from 'node:path';
17
17
  import { describe, expect, it } from 'vitest';
18
18
 
19
19
  const REPO_ROOT = resolve(import.meta.dirname ?? '.', '..', '..', '..', '..');
20
- const TYPES_INDEX = resolve(REPO_ROOT, 'packages', 'types', 'src', 'index.ts');
20
+ const TYPES_INDEX = resolve(REPO_ROOT, 'packages', 'types', 'src', 'core-contracts.ts');
21
21
  const GLTF_ERRORS = resolve(REPO_ROOT, 'packages', 'gltf', 'src', 'errors.ts');
22
22
 
23
23
  function grepCount(pattern: string, file: string): number {
@@ -125,12 +125,12 @@ function grepCountImportGltfErrorFromTypes(): number {
125
125
  }
126
126
 
127
127
  describe('M1 GltfErrorCode migration grep gate (AC-28)', () => {
128
- it("(a) types/src/index.ts contains zero 'gltf-' string literals", () => {
128
+ it("(a) types/src/core-contracts.ts contains zero 'gltf-' string literals", () => {
129
129
  const count = grepCount("'gltf-", TYPES_INDEX);
130
130
  expect(count).toBe(0);
131
131
  });
132
132
 
133
- it('(b) types/src/index.ts contains zero export type GltfError* exports', () => {
133
+ it('(b) types/src/core-contracts.ts contains zero export type GltfError* exports', () => {
134
134
  const count = grepCount('export type GltfError', TYPES_INDEX);
135
135
  expect(count).toBe(0);
136
136
  });
@@ -278,7 +278,9 @@ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
278
278
  'gltf-version-unsupported',
279
279
  'gltf-buffer-out-of-bounds',
280
280
  'gltf-extension-unsupported',
281
+ 'gltf-lod-invalid',
281
282
  'gltf-material-transmission-invalid',
283
+ 'gltf-material-physical-invalid',
282
284
  'gltf-accessor-type-mismatch',
283
285
  'gltf-texture-load-failed',
284
286
  'gltf-meta-missing',
@@ -300,7 +302,8 @@ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
300
302
  ];
301
303
 
302
304
  function classifyByExhaustiveSwitch(err: GltfError): string {
303
- switch (err.code) {
305
+ const code = err.code;
306
+ switch (code) {
304
307
  case 'gltf-malformed-header':
305
308
  return 'malformed';
306
309
  case 'gltf-version-unsupported':
@@ -313,6 +316,8 @@ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
313
316
  return 'lod';
314
317
  case 'gltf-material-transmission-invalid':
315
318
  return 'material-transmission-invalid';
319
+ case 'gltf-material-physical-invalid':
320
+ return 'material-physical-invalid';
316
321
  case 'gltf-accessor-type-mismatch':
317
322
  return 'accessor';
318
323
  case 'gltf-texture-load-failed':
@@ -350,6 +355,7 @@ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
350
355
  case 'gltf-mesh-bridge-invalid':
351
356
  return 'mesh-bridge-invalid';
352
357
  }
358
+ throw new Error(`unhandled glTF error code: ${code}`);
353
359
  }
354
360
 
355
361
  function buildErrSample(code: GltfErrorCode): GltfError {
@@ -371,6 +377,13 @@ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
371
377
  reason: 'range',
372
378
  actual: 2,
373
379
  });
380
+ case 'gltf-material-physical-invalid':
381
+ return gltfErr(code, {
382
+ extension: 'KHR_materials_clearcoat',
383
+ field: 'clearcoatFactor',
384
+ reason: 'range',
385
+ actual: 2,
386
+ });
374
387
  case 'gltf-accessor-type-mismatch':
375
388
  return gltfErr(code, { accessorIndex: 0, reason: 'sparse' });
376
389
  case 'gltf-texture-load-failed':
@@ -452,6 +465,7 @@ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
452
465
  case 'gltf-mesh-bridge-invalid':
453
466
  return gltfErr(code, { reason: 'empty-input', primitiveCount: 0 });
454
467
  }
468
+ throw new Error(`unhandled glTF error code: ${code}`);
455
469
  }
456
470
 
457
471
  describe('errors.test.ts', () => {
@@ -460,8 +474,8 @@ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
460
474
  });
461
475
 
462
476
  describe('GltfErrorCode roster', () => {
463
- it('GLTF_ERROR_HINTS exposes exactly 24 keys', () => {
464
- expect(Object.keys(GLTF_ERROR_HINTS).length).toBe(24);
477
+ it('GLTF_ERROR_HINTS exposes exactly 25 keys', () => {
478
+ expect(Object.keys(GLTF_ERROR_HINTS).length).toBe(25);
465
479
  });
466
480
 
467
481
  it.each(ALL_CODES)('hint for %s is a non-empty string', (code) => {
@@ -1934,6 +1948,25 @@ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
1934
1948
  expect(map.get(0)).toBe('srgb');
1935
1949
  });
1936
1950
 
1951
+ it('physical extension texture slots keep their glTF color domains', () => {
1952
+ const map = deriveTextureColorSpace({
1953
+ imageCount: 4,
1954
+ textures: [{ source: 0 }, { source: 1 }, { source: 2 }, { source: 3 }],
1955
+ materials: [
1956
+ {
1957
+ sheenColorTexture: 0,
1958
+ specularColorTexture: 1,
1959
+ anisotropyTexture: 2,
1960
+ thicknessTexture: 3,
1961
+ },
1962
+ ],
1963
+ });
1964
+ expect(map.get(0)).toBe('srgb');
1965
+ expect(map.get(1)).toBe('srgb');
1966
+ expect(map.get(2)).toBe('linear');
1967
+ expect(map.get(3)).toBe('linear');
1968
+ });
1969
+
1937
1970
  it('occlusionTexture image classifies as linear', () => {
1938
1971
  const map = deriveTextureColorSpace({
1939
1972
  imageCount: 1,
@@ -2023,12 +2056,20 @@ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
2023
2056
  stderrSpy.mockRestore();
2024
2057
  });
2025
2058
 
2026
- it('v1 allowlist contains EXT_mesh_gpu_instancing (literal)', () => {
2059
+ it('public allowlist contains every currently supported extension (literal)', () => {
2027
2060
  expect(EXTENSION_ALLOWLIST).toEqual([
2028
2061
  'EXT_mesh_gpu_instancing',
2029
2062
  'EXT_meshopt_compression',
2030
- 'MSFT_lod',
2031
- 'MSFT_screencoverage',
2063
+ 'KHR_lights_punctual',
2064
+ 'KHR_texture_transform',
2065
+ 'KHR_materials_transmission',
2066
+ 'KHR_materials_ior',
2067
+ 'KHR_materials_volume',
2068
+ 'KHR_materials_clearcoat',
2069
+ 'KHR_materials_anisotropy',
2070
+ 'KHR_materials_sheen',
2071
+ 'KHR_materials_iridescence',
2072
+ 'KHR_materials_specular',
2032
2073
  ]);
2033
2074
  });
2034
2075
 
@@ -2095,6 +2136,11 @@ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
2095
2136
  'KHR_materials_transmission',
2096
2137
  'KHR_materials_ior',
2097
2138
  'KHR_materials_volume',
2139
+ 'KHR_materials_clearcoat',
2140
+ 'KHR_materials_anisotropy',
2141
+ 'KHR_materials_sheen',
2142
+ 'KHR_materials_iridescence',
2143
+ 'KHR_materials_specular',
2098
2144
  ])('%s in extensionsRequired routes ok (supported)', (extension) => {
2099
2145
  const result = checkExtensions({ extensionsRequired: [extension] });
2100
2146
  expect(result.ok).toBe(true);
@@ -67,4 +67,57 @@ describe('glTF material Pack refs', () => {
67
67
  'sampler-6',
68
68
  ]);
69
69
  });
70
+
71
+ it('keeps the five physical extension texture edges in producer slot order', () => {
72
+ const material = {
73
+ name: 'PhysicalPackMaterial',
74
+ baseColorFactor: [1, 1, 1, 1],
75
+ metallicFactor: 0,
76
+ roughnessFactor: 0.5,
77
+ clearcoatNormalTexture: { texture: 0, sampler: 0 },
78
+ anisotropyTexture: { texture: 1, sampler: 1 },
79
+ sheenColorTexture: { texture: 2, sampler: 2 },
80
+ iridescenceTexture: { texture: 3, sampler: 3 },
81
+ specularColorTexture: { texture: 4, sampler: 4 },
82
+ } as unknown as GltfMaterialIr;
83
+ const doc = {
84
+ textures: [
85
+ { source: 0, sampler: 0 },
86
+ { source: 1, sampler: 1 },
87
+ { source: 2, sampler: 2 },
88
+ { source: 3, sampler: 3 },
89
+ { source: 4, sampler: 4 },
90
+ ],
91
+ } as unknown as GltfDoc;
92
+ const refs = materialRefsForPack(
93
+ material,
94
+ doc,
95
+ new Map([
96
+ [0, 'clearcoat-normal-texture'],
97
+ [1, 'anisotropy-texture'],
98
+ [2, 'sheen-color-texture'],
99
+ [3, 'iridescence-texture'],
100
+ [4, 'specular-color-texture'],
101
+ ]),
102
+ new Map([
103
+ [0, 'clearcoat-normal-sampler'],
104
+ [1, 'anisotropy-sampler'],
105
+ [2, 'sheen-color-sampler'],
106
+ [3, 'iridescence-sampler'],
107
+ [4, 'specular-color-sampler'],
108
+ ]),
109
+ );
110
+ expect(refs.map((ref) => ref.guid)).toEqual([
111
+ 'clearcoat-normal-texture',
112
+ 'clearcoat-normal-sampler',
113
+ 'anisotropy-texture',
114
+ 'anisotropy-sampler',
115
+ 'sheen-color-texture',
116
+ 'sheen-color-sampler',
117
+ 'iridescence-texture',
118
+ 'iridescence-sampler',
119
+ 'specular-color-texture',
120
+ 'specular-color-sampler',
121
+ ]);
122
+ });
70
123
  });
@@ -162,6 +162,35 @@ 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
+
165
194
  describe('glTF required extension admission', () => {
166
195
  it.each([
167
196
  'KHR_materials_transmission',
@@ -183,7 +212,6 @@ describe('glTF required extension admission', () => {
183
212
  expect(result.value.diagnostics.unsupportedExtensions).toEqual([]);
184
213
  });
185
214
  });
186
-
187
215
  describe('glTF COLOR_0 importer carrier', () => {
188
216
  it('publishes normalized VEC3 FLOAT colors as importer-owned RGBA', async () => {
189
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
+ });