@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
@@ -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
+ });
package/src/bridge.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  // (B3) child world pos accumulates parent transform
20
20
  // (B6) camera detection via GltfNodeIr.camera field (not legacy nodes[1] heuristic)
21
21
 
22
- import { packInterleavedVertexAttributes } from '@forgeax/engine-geometry';
22
+ import { computeTangentVec4, packInterleavedVertexAttributes } from '@forgeax/engine-geometry';
23
23
  import type { Mat4 } from '@forgeax/engine-math';
24
24
  import { box3, mat4, quat, vec3 } from '@forgeax/engine-math';
25
25
  import { AssetGuid as AssetGuidCodec } from '@forgeax/engine-pack/guid';
@@ -40,6 +40,13 @@ import type {
40
40
  Submesh,
41
41
  VertexAttributeMap,
42
42
  } from '@forgeax/engine-types';
43
+ import {
44
+ STANDARD_LAYER_PARAMETER_GROUPS,
45
+ STANDARD_MATERIAL_PARAM_SCHEMA,
46
+ STANDARD_PHYSICAL_PARAMETER_NAMES,
47
+ STANDARD_TRANSMISSION_PARAMETER_NAMES,
48
+ standardMaterialParameters,
49
+ } from '@forgeax/engine-types';
43
50
  import { createMaterialError, err, type GltfError, gltfErr, ok } from './errors.js';
44
51
  import type {
45
52
  GltfDoc,
@@ -216,6 +223,21 @@ export function meshIrToMeshAsset(
216
223
  const materialSlot = slotFor(mesh.materialIndex);
217
224
  const primVertexCount = mesh.positions.length / 3;
218
225
  const primIndexCount = mesh.indices === undefined ? 0 : mesh.indices.length;
226
+ // A tangent admission check may already have computed a valid frame for a
227
+ // physical glTF material. Persist that producer result into the canonical
228
+ // MeshAsset instead of silently replacing it with the historical identity
229
+ // tangent. Non-physical meshes keep the identity fallback when the source
230
+ // lacks the inputs required by the tangent producer.
231
+ let generatedTangents: Float32Array | undefined;
232
+ if (mesh.tangents === undefined && mesh.normals !== undefined && mesh.texcoord0 !== undefined) {
233
+ const generated = computeTangentVec4(
234
+ mesh.positions,
235
+ mesh.normals,
236
+ mesh.texcoord0,
237
+ mesh.indices,
238
+ );
239
+ if (generated.ok) generatedTangents = generated.value;
240
+ }
219
241
  if (mesh.colors0 !== undefined && mesh.colors0.length !== primVertexCount * 4) {
220
242
  return err(
221
243
  gltfErr('gltf-mesh-bridge-invalid', {
@@ -247,12 +269,13 @@ export function meshIrToMeshAsset(
247
269
  uvsCat[(vertexCursor + i) * 2 + 0] = mesh.texcoord0[t + 0] as number;
248
270
  uvsCat[(vertexCursor + i) * 2 + 1] = mesh.texcoord0[t + 1] as number;
249
271
  }
250
- if (mesh.tangents !== undefined) {
272
+ const sourceTangents = mesh.tangents ?? generatedTangents;
273
+ if (sourceTangents !== undefined) {
251
274
  const g = i * 4;
252
- tangentsCat[(vertexCursor + i) * 4 + 0] = mesh.tangents[g + 0] as number;
253
- tangentsCat[(vertexCursor + i) * 4 + 1] = mesh.tangents[g + 1] as number;
254
- tangentsCat[(vertexCursor + i) * 4 + 2] = mesh.tangents[g + 2] as number;
255
- tangentsCat[(vertexCursor + i) * 4 + 3] = mesh.tangents[g + 3] as number;
275
+ tangentsCat[(vertexCursor + i) * 4 + 0] = sourceTangents[g + 0] as number;
276
+ tangentsCat[(vertexCursor + i) * 4 + 1] = sourceTangents[g + 1] as number;
277
+ tangentsCat[(vertexCursor + i) * 4 + 2] = sourceTangents[g + 2] as number;
278
+ tangentsCat[(vertexCursor + i) * 4 + 3] = sourceTangents[g + 3] as number;
256
279
  } else {
257
280
  tangentsCat[(vertexCursor + i) * 4 + 0] = 1;
258
281
  tangentsCat[(vertexCursor + i) * 4 + 3] = 1;
@@ -778,7 +801,17 @@ type MaterialTextureSlot =
778
801
  | 'occlusionTexture'
779
802
  | 'emissiveTexture'
780
803
  | 'transmissionTexture'
781
- | 'thicknessTexture';
804
+ | 'thicknessTexture'
805
+ | 'clearcoatTexture'
806
+ | 'clearcoatRoughnessTexture'
807
+ | 'clearcoatNormalTexture'
808
+ | 'anisotropyTexture'
809
+ | 'sheenColorTexture'
810
+ | 'sheenRoughnessTexture'
811
+ | 'iridescenceTexture'
812
+ | 'iridescenceThicknessTexture'
813
+ | 'specularTexture'
814
+ | 'specularColorTexture';
782
815
 
783
816
  function textureValue(
784
817
  info: GltfTextureInfoIr | number | undefined,
@@ -811,6 +844,12 @@ function textureValue(
811
844
  return { ...value, normalScale: normal.scale };
812
845
  }
813
846
  }
847
+ if (slot === 'clearcoatNormalTexture') {
848
+ const normal = info as GltfMaterialIr['clearcoatNormalTexture'];
849
+ if (typeof normal === 'object' && normal?.scale !== undefined) {
850
+ return { ...value, normalScale: normal.scale };
851
+ }
852
+ }
814
853
  if (slot === 'occlusionTexture') {
815
854
  const occlusion = info as GltfMaterialIr['occlusionTexture'];
816
855
  if (typeof occlusion === 'object' && occlusion?.strength !== undefined) {
@@ -820,6 +859,102 @@ function textureValue(
820
859
  return value;
821
860
  }
822
861
 
862
+ /**
863
+ * Select the exact Standard root contract emitted by the glTF producer.
864
+ * Numeric/base fields come from the shared schema; physical texture slots are
865
+ * admitted only when the source actually declares them. A parent root is
866
+ * retained for the legacy base-only path, while an extended glTF material
867
+ * becomes its own root so its extension declarations own cook/ABI identity.
868
+ */
869
+ function standardRootParameterNames(mat: GltfMaterialIr): {
870
+ readonly names: ReadonlySet<string>;
871
+ readonly extended: boolean;
872
+ } {
873
+ const names = new Set(
874
+ STANDARD_MATERIAL_PARAM_SCHEMA.filter(
875
+ (entry) =>
876
+ !STANDARD_PHYSICAL_PARAMETER_NAMES.has(entry.name) &&
877
+ !STANDARD_TRANSMISSION_PARAMETER_NAMES.has(entry.name),
878
+ ).map((entry) => entry.name),
879
+ );
880
+ // IOR is consumed for the base dielectric F0 fallback even when the
881
+ // transmission/volume extension is absent.
882
+ names.add('ior');
883
+
884
+ const addLayer = (layer: keyof typeof STANDARD_LAYER_PARAMETER_GROUPS): void => {
885
+ for (const name of STANDARD_LAYER_PARAMETER_GROUPS[layer]) names.add(name);
886
+ };
887
+ const addTexture = (name: string, info: GltfTextureInfoIr | number | undefined): void => {
888
+ if (info !== undefined) names.add(name);
889
+ };
890
+
891
+ const clearcoat =
892
+ mat.clearcoatFactor !== undefined ||
893
+ mat.clearcoatRoughnessFactor !== undefined ||
894
+ mat.clearcoatNormalTexture !== undefined ||
895
+ mat.clearcoatTexture !== undefined ||
896
+ mat.clearcoatRoughnessTexture !== undefined;
897
+ const anisotropy =
898
+ mat.anisotropyStrength !== undefined ||
899
+ mat.anisotropyRotation !== undefined ||
900
+ mat.anisotropyTexture !== undefined;
901
+ const sheen =
902
+ mat.sheenColorFactor !== undefined ||
903
+ mat.sheenRoughnessFactor !== undefined ||
904
+ mat.sheenColorTexture !== undefined ||
905
+ mat.sheenRoughnessTexture !== undefined;
906
+ const iridescence =
907
+ mat.iridescenceFactor !== undefined ||
908
+ mat.iridescenceIor !== undefined ||
909
+ mat.iridescenceThicknessMinimum !== undefined ||
910
+ mat.iridescenceThicknessMaximum !== undefined ||
911
+ mat.iridescenceTexture !== undefined ||
912
+ mat.iridescenceThicknessTexture !== undefined;
913
+ if (clearcoat) addLayer('clearcoat');
914
+ if (anisotropy) addLayer('anisotropy');
915
+ if (sheen) addLayer('sheen');
916
+ if (iridescence) addLayer('iridescence');
917
+ if (clearcoat && mat.clearcoatNormalTexture !== undefined) names.add('clearcoatNormalScale');
918
+
919
+ addTexture('clearcoatTexture', mat.clearcoatTexture);
920
+ addTexture('clearcoatRoughnessTexture', mat.clearcoatRoughnessTexture);
921
+ addTexture('clearcoatNormalTexture', mat.clearcoatNormalTexture);
922
+ addTexture('anisotropyTexture', mat.anisotropyTexture);
923
+ addTexture('sheenColorTexture', mat.sheenColorTexture);
924
+ addTexture('sheenRoughnessTexture', mat.sheenRoughnessTexture);
925
+ addTexture('iridescenceTexture', mat.iridescenceTexture);
926
+ addTexture('iridescenceThicknessTexture', mat.iridescenceThicknessTexture);
927
+ addTexture('specularTexture', mat.specularTexture);
928
+ addTexture('specularColorTexture', mat.specularColorTexture);
929
+
930
+ const transmission =
931
+ mat.transmissionFactor !== undefined ||
932
+ mat.transmissionTexture !== undefined ||
933
+ mat.ior !== undefined ||
934
+ mat.thicknessFactor !== undefined ||
935
+ mat.thicknessTexture !== undefined ||
936
+ mat.attenuationColor !== undefined ||
937
+ mat.attenuationDistance !== undefined;
938
+ if (transmission) {
939
+ // Transmission/volume keeps its existing paired backdrop ABI. The
940
+ // producer may leave either value at its neutral fallback, but the pair
941
+ // remains part of the pre-existing transmission root contract.
942
+ for (const name of STANDARD_TRANSMISSION_PARAMETER_NAMES) names.add(name);
943
+ }
944
+
945
+ return {
946
+ names,
947
+ extended:
948
+ clearcoat ||
949
+ anisotropy ||
950
+ sheen ||
951
+ iridescence ||
952
+ transmission ||
953
+ mat.specularTexture !== undefined ||
954
+ mat.specularColorTexture !== undefined,
955
+ };
956
+ }
957
+
823
958
  export function validateMaterialUvSets(
824
959
  mat: GltfMaterialIr,
825
960
  primitive: string,
@@ -834,6 +969,16 @@ export function validateMaterialUvSets(
834
969
  ['emissiveTexture', mat.emissiveTexture],
835
970
  ['transmissionTexture', mat.transmissionTexture],
836
971
  ['thicknessTexture', mat.thicknessTexture],
972
+ ['clearcoatTexture', mat.clearcoatTexture],
973
+ ['clearcoatRoughnessTexture', mat.clearcoatRoughnessTexture],
974
+ ['clearcoatNormalTexture', mat.clearcoatNormalTexture],
975
+ ['anisotropyTexture', mat.anisotropyTexture],
976
+ ['sheenColorTexture', mat.sheenColorTexture],
977
+ ['sheenRoughnessTexture', mat.sheenRoughnessTexture],
978
+ ['iridescenceTexture', mat.iridescenceTexture],
979
+ ['iridescenceThicknessTexture', mat.iridescenceThicknessTexture],
980
+ ['specularTexture', mat.specularTexture],
981
+ ['specularColorTexture', mat.specularColorTexture],
837
982
  ];
838
983
  for (const [slot, rawBinding] of slots) {
839
984
  const binding = textureInfo(rawBinding);
@@ -854,8 +999,67 @@ export function validateMaterialUvSets(
854
999
  return ok(undefined);
855
1000
  }
856
1001
 
1002
+ export function validateMaterialTangentInputs(
1003
+ mat: GltfMaterialIr,
1004
+ mesh: GltfMeshIr,
1005
+ layer = 'clearcoat',
1006
+ ): Result<void, MaterialError> {
1007
+ // Anisotropy changes the base GGX lobe even when it has no texture map, so
1008
+ // its tangent direction is part of the scalar layer contract. Select UV 0
1009
+ // for the scalar-only form; a mapped form keeps the map's explicit UV set.
1010
+ const anisotropyDeclared =
1011
+ mat.anisotropyStrength !== undefined ||
1012
+ mat.anisotropyRotation !== undefined ||
1013
+ mat.anisotropyTexture !== undefined;
1014
+ const tangentSlot = anisotropyDeclared
1015
+ ? { layer: 'anisotropy', info: mat.anisotropyTexture }
1016
+ : mat.clearcoatNormalTexture !== undefined
1017
+ ? { layer: 'clearcoat', info: mat.clearcoatNormalTexture }
1018
+ : undefined;
1019
+ if (tangentSlot === undefined) return ok(undefined);
1020
+ const selected = textureInfo(tangentSlot.info);
1021
+ const uvSet = selected?.texCoord ?? 0;
1022
+ const uv = mesh[`texcoord${uvSet === 0 ? '0' : uvSet}` as keyof GltfMeshIr];
1023
+ const attributes = ['NORMAL', `TEXCOORD_${uvSet}`, 'TANGENT'];
1024
+ const fail = (reason: string): Result<void, MaterialError> =>
1025
+ err(
1026
+ createMaterialError('material-tangent-required', {
1027
+ code: 'material-tangent-required',
1028
+ material: mat.name ?? '<unnamed>',
1029
+ mesh: mesh.name ?? '<unnamed>',
1030
+ layer: tangentSlot.layer ?? layer,
1031
+ uv: `TEXCOORD_${uvSet}`,
1032
+ attributes,
1033
+ reason,
1034
+ }),
1035
+ );
1036
+ if (mesh.tangents !== undefined) {
1037
+ if (
1038
+ mesh.tangents.length !== (mesh.positions.length / 3) * 4 ||
1039
+ mesh.tangents.some((value) => !Number.isFinite(value))
1040
+ ) {
1041
+ return fail('imported TANGENT must be finite vec4 per vertex');
1042
+ }
1043
+ return ok(undefined);
1044
+ }
1045
+ if (mesh.normals === undefined) return fail('NORMAL is required to generate tangent');
1046
+ if (!(uv instanceof Float32Array))
1047
+ return fail('the selected UV set is required to generate tangent');
1048
+ const generated = computeTangentVec4(mesh.positions, mesh.normals, uv, mesh.indices);
1049
+ if (!generated.ok) {
1050
+ const detail = generated.error.detail;
1051
+ const reason =
1052
+ detail !== undefined && 'reason' in detail
1053
+ ? String(detail.reason)
1054
+ : 'tangent producer rejected topology';
1055
+ return fail(reason);
1056
+ }
1057
+ return ok(undefined);
1058
+ }
1059
+
857
1060
  /** Convert a parsed GltfMaterialIr into a standard-root derived MaterialAsset. */
858
1061
  export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext): MaterialAsset {
1062
+ const rootContract = standardRootParameterNames(mat);
859
1063
  const values: Record<string, NonNullable<MaterialAsset['values']>[string]> = {
860
1064
  baseColor: mat.baseColorFactor,
861
1065
  metallic: mat.metallicFactor,
@@ -873,6 +1077,16 @@ export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext
873
1077
  ['emissiveTexture', mat.emissiveTexture],
874
1078
  ['transmissionTexture', mat.transmissionTexture],
875
1079
  ['thicknessTexture', mat.thicknessTexture],
1080
+ ['clearcoatTexture', mat.clearcoatTexture],
1081
+ ['clearcoatRoughnessTexture', mat.clearcoatRoughnessTexture],
1082
+ ['clearcoatNormalTexture', mat.clearcoatNormalTexture],
1083
+ ['anisotropyTexture', mat.anisotropyTexture],
1084
+ ['sheenColorTexture', mat.sheenColorTexture],
1085
+ ['sheenRoughnessTexture', mat.sheenRoughnessTexture],
1086
+ ['iridescenceTexture', mat.iridescenceTexture],
1087
+ ['iridescenceThicknessTexture', mat.iridescenceThicknessTexture],
1088
+ ['specularTexture', mat.specularTexture],
1089
+ ['specularColorTexture', mat.specularColorTexture],
876
1090
  ];
877
1091
  for (const [slot, info] of textureSlots) {
878
1092
  const value = textureValue(info, slot, ctx);
@@ -881,11 +1095,37 @@ export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext
881
1095
  if (mat.occlusionTexture !== undefined && values.occlusionStrength === undefined) {
882
1096
  values.occlusionStrength = 1;
883
1097
  }
884
- if (mat.transmissionFactor !== undefined) values.transmission = mat.transmissionFactor;
885
- if (mat.ior !== undefined) values.ior = mat.ior;
886
- if (mat.thicknessFactor !== undefined) values.thickness = mat.thicknessFactor;
887
- if (mat.attenuationColor !== undefined) values.attenuationColor = mat.attenuationColor;
888
- if (mat.attenuationDistance !== undefined) values.attenuationDistance = mat.attenuationDistance;
1098
+ if (rootContract.extended && rootContract.names.has('transmission')) {
1099
+ values.transmission = mat.transmissionFactor ?? 0;
1100
+ values.ior = mat.ior ?? 1.5;
1101
+ values.thickness = mat.thicknessFactor ?? 0;
1102
+ values.attenuationColor = mat.attenuationColor ?? [1, 1, 1];
1103
+ if (mat.attenuationDistance !== undefined) values.attenuationDistance = mat.attenuationDistance;
1104
+ }
1105
+ if (rootContract.names.has('clearcoat')) {
1106
+ values.clearcoat = mat.clearcoatFactor ?? 0;
1107
+ values.clearcoatRoughness = mat.clearcoatRoughnessFactor ?? 0;
1108
+ }
1109
+ const clearcoatNormal = textureInfo(mat.clearcoatNormalTexture) as
1110
+ | (GltfTextureInfoIr & { readonly scale?: number })
1111
+ | undefined;
1112
+ if (clearcoatNormal?.scale !== undefined) values.clearcoatNormalScale = clearcoatNormal.scale;
1113
+ if (rootContract.names.has('anisotropyStrength')) {
1114
+ values.anisotropyStrength = mat.anisotropyStrength ?? 0;
1115
+ values.anisotropyRotation = mat.anisotropyRotation ?? 0;
1116
+ }
1117
+ if (rootContract.names.has('sheenColor')) {
1118
+ values.sheenColor = mat.sheenColorFactor ?? [0, 0, 0];
1119
+ values.sheenRoughness = mat.sheenRoughnessFactor ?? 0;
1120
+ }
1121
+ if (rootContract.names.has('iridescence')) {
1122
+ values.iridescence = mat.iridescenceFactor ?? 0;
1123
+ values.iridescenceIor = mat.iridescenceIor ?? 1.3;
1124
+ values.iridescenceThicknessMinimum = mat.iridescenceThicknessMinimum ?? 100;
1125
+ values.iridescenceThicknessMaximum = mat.iridescenceThicknessMaximum ?? 400;
1126
+ }
1127
+ if (mat.specularFactor !== undefined) values.specular = mat.specularFactor;
1128
+ if (mat.specularColorFactor !== undefined) values.specularColor = mat.specularColorFactor;
889
1129
 
890
1130
  const module = ctx?.skinned === true ? 'forgeax::pbr-skin' : 'forgeax::default-standard-pbr';
891
1131
 
@@ -932,8 +1172,13 @@ export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext
932
1172
  return {
933
1173
  kind: 'material',
934
1174
  colorSpace: 'linear',
935
- ...(ctx?.standardRootGuid === undefined ? {} : { parent: ctx.standardRootGuid }),
1175
+ ...(!rootContract.extended && ctx?.standardRootGuid !== undefined
1176
+ ? { parent: ctx.standardRootGuid }
1177
+ : {}),
936
1178
  passes: [pass],
1179
+ ...(ctx?.standardRootGuid !== undefined && !rootContract.extended
1180
+ ? {}
1181
+ : { parameters: standardMaterialParameters(rootContract.names) }),
937
1182
  values,
938
1183
  };
939
1184
  }
@@ -2,9 +2,10 @@
2
2
  //
3
3
  // Required-extension support contains EXT_mesh_gpu_instancing,
4
4
  // EXT_meshopt_compression, KHR_lights_punctual, KHR_texture_transform, and
5
- // the KHR material transmission/IOR/volume extensions. The exported legacy
6
- // list remains the original mesh extension list for callers that display the
7
- // v1 mesh-only surface.
5
+ // the KHR material transmission/IOR/volume/clearcoat/anisotropy/sheen/
6
+ // iridescence/specular extensions. The exported list is the public supported
7
+ // extension surface consumed by both the required-extension gate and callers
8
+ // that need to discover support.
8
9
  // (feat-20260518-gltf-instancing-and-name-component plan-strategy section
9
10
  // 2 D-1 / D-3). Any extension listed in `extensionsRequired[]` outside
10
11
  // the supported extension set triggers `gltf-extension-unsupported` (hard fail).
@@ -18,25 +19,30 @@
18
19
  // diagnostics list is the single channel (no `console.error`).
19
20
  //
20
21
  // Future expansion (KHR_materials_unlit, ...) extends
21
- // `SUPPORTED_EXTENSIONS` in place; each addition lands under its own feat-*
22
+ // `EXTENSION_ALLOWLIST` in place; each addition lands under its own feat-*
22
23
  // loop with breaking-change registry entry.
23
24
 
24
25
  import { err, type GltfError, gltfErr, ok, type Result } from './errors.js';
25
26
 
26
- /** Legacy v1 mesh-only list kept for callers that display that surface. */
27
+ /** Public supported extension list used by the required/used declaration gate. */
27
28
  export const EXTENSION_ALLOWLIST: readonly string[] = [
28
29
  'EXT_mesh_gpu_instancing',
29
30
  'EXT_meshopt_compression',
30
- 'MSFT_lod',
31
- 'MSFT_screencoverage',
32
- ];
33
- const SUPPORTED_EXTENSIONS: readonly string[] = [
34
- ...EXTENSION_ALLOWLIST,
35
31
  'KHR_lights_punctual',
36
32
  'KHR_texture_transform',
37
33
  'KHR_materials_transmission',
38
34
  'KHR_materials_ior',
39
35
  'KHR_materials_volume',
36
+ 'KHR_materials_clearcoat',
37
+ 'KHR_materials_anisotropy',
38
+ 'KHR_materials_sheen',
39
+ 'KHR_materials_iridescence',
40
+ 'KHR_materials_specular',
41
+ ];
42
+ const SUPPORTED_EXTENSIONS: readonly string[] = [
43
+ ...EXTENSION_ALLOWLIST,
44
+ 'MSFT_lod',
45
+ 'MSFT_screencoverage',
40
46
  ];
41
47
 
42
48
  export interface ExtensionsCheckResult {