@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
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;
@@ -541,7 +564,7 @@ export function gltfDocToSceneAsset(doc: GltfDoc, ctx: GltfBridgeContext): Scene
541
564
 
542
565
  // bug-20260613: SceneAsset entities also emit ChildOf when a glTF node has a
543
566
  // parent (line 547 below). Runtime propagateTransforms then derives
544
- // Transform.world via `parent.world * compose(child.local TRS)`. If we
567
+ // GlobalTransform.world via `parent.world * compose(child.local TRS)`. If we
545
568
  // wrote the *world* TRS into Transform here, every child node would get
546
569
  // baked twice -- once at importer time and again at propagate time --
547
570
  // collapsing the skin so vertices fly to (parent.world)^2 space and the
@@ -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,23 +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
- ];
31
- const SUPPORTED_EXTENSIONS: readonly string[] = [
32
- ...EXTENSION_ALLOWLIST,
33
31
  'KHR_lights_punctual',
34
32
  'KHR_texture_transform',
35
33
  'KHR_materials_transmission',
36
34
  'KHR_materials_ior',
37
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',
38
46
  ];
39
47
 
40
48
  export interface ExtensionsCheckResult {
package/src/errors.ts CHANGED
@@ -60,6 +60,12 @@ export interface GltfExtensionUnsupportedDetail {
60
60
  readonly source: 'extensionsRequired' | 'extensionsUsed';
61
61
  }
62
62
 
63
+ export interface GltfLodInvalidDetail {
64
+ readonly rootNode: number;
65
+ readonly ids: readonly number[];
66
+ readonly reason: 'missing-node' | 'duplicate-node' | 'not-integer' | 'coverage';
67
+ }
68
+
63
69
  export interface GltfMaterialTransmissionInvalidDetail {
64
70
  readonly extension: 'KHR_materials_transmission' | 'KHR_materials_ior' | 'KHR_materials_volume';
65
71
  readonly field: string;
@@ -67,6 +73,18 @@ export interface GltfMaterialTransmissionInvalidDetail {
67
73
  readonly actual?: unknown;
68
74
  }
69
75
 
76
+ export interface GltfMaterialPhysicalInvalidDetail {
77
+ readonly extension:
78
+ | 'KHR_materials_clearcoat'
79
+ | 'KHR_materials_anisotropy'
80
+ | 'KHR_materials_sheen'
81
+ | 'KHR_materials_iridescence'
82
+ | 'KHR_materials_specular';
83
+ readonly field: string;
84
+ readonly reason: 'type' | 'range' | 'non-finite';
85
+ readonly actual?: unknown;
86
+ }
87
+
70
88
  /** `gltf-accessor-type-mismatch` payload: 4-member closed reason discriminator. */
71
89
  export interface GltfAccessorTypeMismatchDetail {
72
90
  readonly accessorIndex: number;
@@ -260,13 +278,23 @@ const gltfErrorPolicy = {
260
278
  hint: 'rebuild .gltf with valid bufferViews; check accessor index; ensure accessor.byteOffset + EFFECTIVE_STRIDE * (count - 1) + element_size <= bufferView.byteLength',
261
279
  },
262
280
  'gltf-extension-unsupported': {
263
- expected: 'extension listed in v1 allowlist (see EXTENSION_ALLOWLIST in @forgeax/engine-gltf)',
264
- hint: 'see feat-future-gltf-extensions-allowlist; remove this extension or wait for the allowlist to expand',
281
+ expected:
282
+ 'extension listed in the supported allowlist (see EXTENSION_ALLOWLIST in @forgeax/engine-gltf)',
283
+ hint: 'remove the unsupported required extension or use a supported glTF extension; extensionsUsed-only entries remain diagnostic',
284
+ },
285
+ 'gltf-lod-invalid': {
286
+ expected: 'MSFT_lod ids to reference unique existing node indices with valid coverage',
287
+ hint: 'repair the MSFT_lod node relation or remove it from extensionsRequired before re-importing',
265
288
  },
266
289
  'gltf-material-transmission-invalid': {
267
290
  expected: 'KHR transmission, IOR, and volume values are finite and within their glTF ranges',
268
291
  hint: 'repair the named glTF material extension value and re-import the source',
269
292
  },
293
+ 'gltf-material-physical-invalid': {
294
+ expected:
295
+ 'KHR clearcoat, anisotropy, sheen, iridescence, and specular values are finite and within their glTF ranges',
296
+ hint: 'repair the named physical material extension value and re-import the source',
297
+ },
270
298
  'gltf-accessor-type-mismatch': {
271
299
  expected: 'dense fixed-stride accessor with supported componentType',
272
300
  hint: 'sparse: see feat-future-gltf-sparse-accessor; morph: see feat-future-gltf-morph; interleaved: see feat-future-gltf-mesh-multi-section',
@@ -357,6 +385,7 @@ interface DetailFor {
357
385
  readonly 'gltf-version-unsupported': GltfVersionUnsupportedDetail;
358
386
  readonly 'gltf-buffer-out-of-bounds': GltfBufferOutOfBoundsDetail;
359
387
  readonly 'gltf-extension-unsupported': GltfExtensionUnsupportedDetail;
388
+ readonly 'gltf-lod-invalid': GltfLodInvalidDetail;
360
389
  readonly 'gltf-accessor-type-mismatch': GltfAccessorTypeMismatchDetail;
361
390
  readonly 'gltf-texture-load-failed': GltfTextureLoadFailedDetail;
362
391
  readonly 'gltf-meta-missing': GltfMetaMissingDetail;
@@ -376,6 +405,7 @@ interface DetailFor {
376
405
  readonly 'gltf-color-accessor-malformed': GltfColorAccessorMalformedDetail;
377
406
  readonly 'gltf-mesh-bridge-invalid': GltfMeshBridgeInvalidDetail;
378
407
  readonly 'gltf-material-transmission-invalid': GltfMaterialTransmissionInvalidDetail;
408
+ readonly 'gltf-material-physical-invalid': GltfMaterialPhysicalInvalidDetail;
379
409
  }
380
410
 
381
411
  /**
@@ -32,6 +32,7 @@
32
32
  // gap as `import-produced-no-assets`. A texture sub-asset that fails byte
33
33
  // extraction surfaces as `gltf-image-extract-failed` (D-6).
34
34
 
35
+ import { deriveDefaultLodScreenCoverages } from '@forgeax/engine-import';
35
36
  import { packMeshBinV4 } from '@forgeax/engine-import/mesh-bin';
36
37
  import { AssetGuid as AssetGuidCodec } from '@forgeax/engine-pack/guid';
37
38
  import type {
@@ -58,6 +59,7 @@ import {
58
59
  gltfDocToSceneAsset,
59
60
  meshIrToMeshAsset,
60
61
  toMaterialAsset,
62
+ validateMaterialTangentInputs,
61
63
  validateMaterialUvSets,
62
64
  } from './bridge.js';
63
65
  import { gltfErr } from './errors.js';
@@ -414,6 +416,16 @@ export function materialRefsForPack(
414
416
  pushRefsForSlot(mat.emissiveTexture, 'emissiveTexture');
415
417
  pushRefsForSlot(mat.transmissionTexture, 'transmissionTexture');
416
418
  pushRefsForSlot(mat.thicknessTexture, 'thicknessTexture');
419
+ pushRefsForSlot(mat.clearcoatTexture, 'clearcoatTexture');
420
+ pushRefsForSlot(mat.clearcoatRoughnessTexture, 'clearcoatRoughnessTexture');
421
+ pushRefsForSlot(mat.clearcoatNormalTexture, 'clearcoatNormalTexture');
422
+ pushRefsForSlot(mat.anisotropyTexture, 'anisotropyTexture');
423
+ pushRefsForSlot(mat.sheenColorTexture, 'sheenColorTexture');
424
+ pushRefsForSlot(mat.sheenRoughnessTexture, 'sheenRoughnessTexture');
425
+ pushRefsForSlot(mat.iridescenceTexture, 'iridescenceTexture');
426
+ pushRefsForSlot(mat.iridescenceThicknessTexture, 'iridescenceThicknessTexture');
427
+ pushRefsForSlot(mat.specularTexture, 'specularTexture');
428
+ pushRefsForSlot(mat.specularColorTexture, 'specularColorTexture');
417
429
  return refs;
418
430
  }
419
431
 
@@ -443,6 +455,16 @@ function rewriteMaterialAssetRefs(
443
455
  | 'emissiveTexture'
444
456
  | 'transmissionTexture'
445
457
  | 'thicknessTexture'
458
+ | 'clearcoatTexture'
459
+ | 'clearcoatRoughnessTexture'
460
+ | 'clearcoatNormalTexture'
461
+ | 'anisotropyTexture'
462
+ | 'sheenColorTexture'
463
+ | 'sheenRoughnessTexture'
464
+ | 'iridescenceTexture'
465
+ | 'iridescenceThicknessTexture'
466
+ | 'specularTexture'
467
+ | 'specularColorTexture'
446
468
  ),
447
469
  GltfTextureInfoIr | number | undefined,
448
470
  ][] = [
@@ -453,6 +475,16 @@ function rewriteMaterialAssetRefs(
453
475
  ['emissiveTexture', mat.emissiveTexture],
454
476
  ['transmissionTexture', mat.transmissionTexture],
455
477
  ['thicknessTexture', mat.thicknessTexture],
478
+ ['clearcoatTexture', mat.clearcoatTexture],
479
+ ['clearcoatRoughnessTexture', mat.clearcoatRoughnessTexture],
480
+ ['clearcoatNormalTexture', mat.clearcoatNormalTexture],
481
+ ['anisotropyTexture', mat.anisotropyTexture],
482
+ ['sheenColorTexture', mat.sheenColorTexture],
483
+ ['sheenRoughnessTexture', mat.sheenRoughnessTexture],
484
+ ['iridescenceTexture', mat.iridescenceTexture],
485
+ ['iridescenceThicknessTexture', mat.iridescenceThicknessTexture],
486
+ ['specularTexture', mat.specularTexture],
487
+ ['specularColorTexture', mat.specularColorTexture],
456
488
  ];
457
489
  let cursor = 0;
458
490
  for (const [slot, rawBinding] of slots) {
@@ -591,7 +623,83 @@ async function importGltf(
591
623
  }),
592
624
  };
593
625
  }
594
- const meshPayload = stabilizeMeshMaterialSlots(bridged.value, ctx, sub.guid, sub.sourceKey);
626
+ const stabilizedMesh = stabilizeMeshMaterialSlots(
627
+ bridged.value,
628
+ ctx,
629
+ sub.guid,
630
+ sub.sourceKey,
631
+ );
632
+ const lodGroup =
633
+ doc.lod?.groups?.find(
634
+ (group) => doc.nodes[group.rootNode]?.meshIndex === sub.sourceIndex,
635
+ ) ??
636
+ (doc.lod?.rootNode !== undefined &&
637
+ doc.nodes[doc.lod.rootNode]?.meshIndex === sub.sourceIndex
638
+ ? doc.lod
639
+ : undefined);
640
+ const rootMeshIndex = lodGroup === undefined ? undefined : sub.sourceIndex;
641
+ if (lodGroup !== undefined) {
642
+ const referencedMeshIndices = [
643
+ sub.sourceIndex,
644
+ ...lodGroup.lodNodeIds.map((nodeIndex) => doc.nodes[nodeIndex]?.meshIndex),
645
+ ];
646
+ const missingMeshIndex = referencedMeshIndices.find(
647
+ (meshIndex) =>
648
+ !Number.isInteger(meshIndex) ||
649
+ meshIndex === null ||
650
+ meshIndex === undefined ||
651
+ maps.meshGuidByIndex.get(meshIndex as number) === undefined,
652
+ );
653
+ if (missingMeshIndex !== undefined) {
654
+ return {
655
+ ok: false,
656
+ error: new ImportError({
657
+ code: 'import-internal-error',
658
+ expected: 'every MSFT_lod node to resolve a cooked mesh sub-asset',
659
+ actual: String(missingMeshIndex),
660
+ hint: 'repair the referenced glTF mesh primitives and re-run the importer',
661
+ detail: {
662
+ reason: `MSFT_lod group for mesh ${sub.sourceIndex} references an unprojected mesh`,
663
+ },
664
+ }),
665
+ };
666
+ }
667
+ }
668
+ const authoredLods =
669
+ sub.sourceKey === undefined ? undefined : ctx.sourceOverrides?.[sub.sourceKey]?.lods;
670
+ const lodEntries = Array.isArray(authoredLods) ? authoredLods : [];
671
+ const lodLevels =
672
+ rootMeshIndex === sub.sourceIndex && lodGroup !== undefined
673
+ ? lodGroup.lodNodeIds.flatMap((nodeIndex, index) => {
674
+ const meshIndex = doc.nodes[nodeIndex]?.meshIndex;
675
+ const guid =
676
+ meshIndex === undefined || meshIndex === null
677
+ ? undefined
678
+ : maps.meshGuidByIndex.get(meshIndex);
679
+ if (guid === undefined) return [];
680
+ const authored = lodEntries[index];
681
+ const authoredCoverage =
682
+ authored !== null && typeof authored === 'object'
683
+ ? (authored as { readonly screenCoverage?: unknown }).screenCoverage
684
+ : undefined;
685
+ const coverage =
686
+ typeof authoredCoverage === 'number'
687
+ ? authoredCoverage
688
+ : (lodGroup.screenCoverages[index] ??
689
+ deriveDefaultLodScreenCoverages(lodGroup.lodNodeIds.length + 1)[index]);
690
+ const parsed = AssetGuidCodec.parse(guid);
691
+ return parsed.ok && coverage !== undefined
692
+ ? [{ mesh: parsed.value, screenCoverage: coverage, guid }]
693
+ : [];
694
+ })
695
+ : [];
696
+ const meshPayload: MeshAsset =
697
+ lodLevels.length === 0
698
+ ? stabilizedMesh
699
+ : {
700
+ ...stabilizedMesh,
701
+ lods: lodLevels.map(({ mesh, screenCoverage }) => ({ mesh, screenCoverage })),
702
+ };
595
703
  const materialRefs: AssetRef[] = [];
596
704
  const seenMaterialGuids = new Set<string>();
597
705
  for (let slotIndex = 0; slotIndex < meshPayload.materialSlots.length; slotIndex++) {
@@ -606,6 +714,15 @@ async function importGltf(
606
714
  });
607
715
  }
608
716
  }
717
+ for (const [lodIndex, level] of lodLevels.entries()) {
718
+ if (!seenMaterialGuids.has(level.guid.toLowerCase())) {
719
+ seenMaterialGuids.add(level.guid.toLowerCase());
720
+ materialRefs.push({
721
+ guid: level.guid,
722
+ sourceField: { fieldName: 'lods', arrayIndex: lodIndex },
723
+ });
724
+ }
725
+ }
609
726
  out.push({
610
727
  guid: sub.guid,
611
728
  kind: 'mesh',
@@ -664,6 +781,10 @@ async function importGltf(
664
781
  if (!uvResult.ok) {
665
782
  throw Object.assign(new Error(uvResult.error.message), uvResult.error);
666
783
  }
784
+ const tangentResult = validateMaterialTangentInputs(mat, meshIr);
785
+ if (!tangentResult.ok) {
786
+ throw Object.assign(new Error(tangentResult.error.message), tangentResult.error);
787
+ }
667
788
  }
668
789
  const matAsset = toMaterialAsset(mat, {
669
790
  textureHandles: maps.textureHandles,