@forgeax/engine-gltf 0.1.6 → 0.1.19

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 (39) hide show
  1. package/README.md +9 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/transmission-pack-carrier.integration.test.d.ts +2 -0
  4. package/dist/__tests__/transmission-pack-carrier.integration.test.d.ts.map +1 -0
  5. package/dist/bridge.d.ts.map +1 -1
  6. package/dist/check-extensions.d.ts +3 -3
  7. package/dist/check-extensions.d.ts.map +1 -1
  8. package/dist/cli-gltf.mjs +161 -69
  9. package/dist/cli-gltf.mjs.map +1 -1
  10. package/dist/errors.d.ts +7 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/gltf-importer.d.ts.map +1 -1
  13. package/dist/importer-entry.mjs +177 -72
  14. package/dist/importer-entry.mjs.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.mjs +177 -72
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/material/parse-material.d.ts +118 -0
  20. package/dist/material/parse-material.d.ts.map +1 -0
  21. package/dist/node-file-entry.mjs +161 -69
  22. package/dist/node-file-entry.mjs.map +1 -1
  23. package/dist/parse-gltf.d.ts +17 -103
  24. package/dist/parse-gltf.d.ts.map +1 -1
  25. package/package.json +12 -12
  26. package/src/__tests__/bridge-material-values.unit.test.ts +46 -0
  27. package/src/__tests__/gltf-error-derived-views.test-d.ts +4 -0
  28. package/src/__tests__/gltf-error-policy-owner.unit.test.ts +26 -178
  29. package/src/__tests__/gltf.unit.test.ts +28 -4
  30. package/src/__tests__/material-pack-refs.integration.test.ts +12 -0
  31. package/src/__tests__/parse-gltf.unit.test.ts +121 -0
  32. package/src/__tests__/transmission-pack-carrier.integration.test.ts +82 -0
  33. package/src/bridge.ts +12 -1
  34. package/src/check-extensions.ts +14 -9
  35. package/src/errors.ts +12 -0
  36. package/src/gltf-importer.ts +6 -0
  37. package/src/index.ts +1 -0
  38. package/src/material/parse-material.ts +342 -0
  39. package/src/parse-gltf.ts +50 -277
@@ -14,6 +14,8 @@ describe('glTF material Pack refs', () => {
14
14
  normalTexture: { texture: 2, sampler: 2 },
15
15
  occlusionTexture: { texture: 3, sampler: 3 },
16
16
  emissiveTexture: { texture: 4, sampler: 4 },
17
+ transmissionTexture: { texture: 5, sampler: 5 },
18
+ thicknessTexture: { texture: 6, sampler: 6 },
17
19
  } as unknown as GltfMaterialIr;
18
20
  const doc = {
19
21
  textures: [
@@ -22,6 +24,8 @@ describe('glTF material Pack refs', () => {
22
24
  { source: 2, sampler: 2 },
23
25
  { source: 3, sampler: 3 },
24
26
  { source: 4, sampler: 4 },
27
+ { source: 5, sampler: 5 },
28
+ { source: 6, sampler: 6 },
25
29
  ],
26
30
  } as unknown as GltfDoc;
27
31
  const refs = materialRefsForPack(
@@ -33,6 +37,8 @@ describe('glTF material Pack refs', () => {
33
37
  [2, 'texture-2'],
34
38
  [3, 'texture-3'],
35
39
  [4, 'texture-4'],
40
+ [5, 'texture-5'],
41
+ [6, 'texture-6'],
36
42
  ]),
37
43
  new Map([
38
44
  [0, 'sampler-0'],
@@ -40,6 +46,8 @@ describe('glTF material Pack refs', () => {
40
46
  [2, 'sampler-2'],
41
47
  [3, 'sampler-3'],
42
48
  [4, 'sampler-4'],
49
+ [5, 'sampler-5'],
50
+ [6, 'sampler-6'],
43
51
  ]),
44
52
  );
45
53
  expect(refs.map((ref) => ref.guid)).toEqual([
@@ -53,6 +61,10 @@ describe('glTF material Pack refs', () => {
53
61
  'sampler-3',
54
62
  'texture-4',
55
63
  'sampler-4',
64
+ 'texture-5',
65
+ 'sampler-5',
66
+ 'texture-6',
67
+ 'sampler-6',
56
68
  ]);
57
69
  });
58
70
  });
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import { dataUriBase64Payload, decodeBase64 } from '../data-uri.js';
4
+ import { parseMaterial as parseMaterialIr } from '../material/parse-material.js';
4
5
  import { parseGltf } from '../parse-gltf.js';
5
6
 
6
7
  const noopLoader = async (_uri: string) => new ArrayBuffer(0);
@@ -41,6 +42,126 @@ describe('glTF MASK alpha cutoff parsing', () => {
41
42
  });
42
43
  });
43
44
 
45
+ describe('glTF transmission material IR', () => {
46
+ it('rejects BLEND materials with effective transmission as a structured error', () => {
47
+ const result = parseMaterialIr(
48
+ {
49
+ alphaMode: 'BLEND',
50
+ extensions: { KHR_materials_transmission: { transmissionFactor: 1 } },
51
+ },
52
+ [],
53
+ );
54
+
55
+ expect(result.ok).toBe(false);
56
+ if (result.ok) return;
57
+ expect(result.error.code).toBe('gltf-material-transmission-invalid');
58
+ expect(result.error.detail).toEqual({
59
+ extension: 'KHR_materials_transmission',
60
+ field: 'alphaMode',
61
+ reason: 'blend',
62
+ actual: 'BLEND',
63
+ });
64
+ });
65
+
66
+ it('projects transmission, IOR, volume, channel, and texture transform facts', async () => {
67
+ const result = await parseGltf(
68
+ {
69
+ asset: { version: '2.0' },
70
+ extensionsUsed: ['KHR_materials_transmission', 'KHR_materials_ior', 'KHR_materials_volume'],
71
+ materials: [
72
+ {
73
+ pbrMetallicRoughness: {
74
+ baseColorTexture: {
75
+ index: 0,
76
+ texCoord: 1,
77
+ extensions: {
78
+ KHR_texture_transform: {
79
+ offset: [0.25, 0.5],
80
+ rotation: 0.25,
81
+ scale: [2, 3],
82
+ },
83
+ },
84
+ },
85
+ },
86
+ extensions: {
87
+ KHR_materials_transmission: {
88
+ transmissionFactor: 0.75,
89
+ transmissionTexture: { index: 1, texCoord: 2 },
90
+ },
91
+ KHR_materials_ior: { ior: 1.33 },
92
+ KHR_materials_volume: {
93
+ thicknessFactor: 0.04,
94
+ thicknessTexture: { index: 2, texCoord: 3 },
95
+ attenuationColor: [0.2, 0.4, 0.8],
96
+ attenuationDistance: Number.POSITIVE_INFINITY,
97
+ },
98
+ },
99
+ },
100
+ ],
101
+ textures: [
102
+ { sampler: 7, source: 0 },
103
+ { sampler: 8, source: 1 },
104
+ { sampler: 9, source: 2 },
105
+ ],
106
+ },
107
+ noopLoader,
108
+ '/material-transmission.gltf',
109
+ );
110
+
111
+ expect(result.ok).toBe(true);
112
+ if (!result.ok) return;
113
+ expect(result.value.materials[0]).toMatchObject({
114
+ transmissionFactor: 0.75,
115
+ transmissionTexture: { texture: 1, sampler: 8, texCoord: 2 },
116
+ ior: 1.33,
117
+ thicknessFactor: 0.04,
118
+ thicknessTexture: { texture: 2, sampler: 9, texCoord: 3 },
119
+ attenuationColor: [0.2, 0.4, 0.8],
120
+ });
121
+ expect(result.value.materials[0]).not.toHaveProperty('attenuationDistance');
122
+ expect(result.value.materials[0]?.baseColorTexture).toMatchObject({
123
+ transform: { offset: [0.25, 0.5], rotation: 0.25, scale: [2, 3] },
124
+ });
125
+ });
126
+
127
+ it('rejects an IOR at the non-positive boundary', async () => {
128
+ const result = await parseGltf(
129
+ {
130
+ asset: { version: '2.0' },
131
+ extensionsUsed: ['KHR_materials_ior'],
132
+ materials: [{ extensions: { KHR_materials_ior: { ior: 0 } } }],
133
+ },
134
+ noopLoader,
135
+ '/material-invalid-ior.gltf',
136
+ );
137
+
138
+ expect(result.ok).toBe(false);
139
+ if (result.ok) return;
140
+ expect(result.error.code).toBe('gltf-material-transmission-invalid');
141
+ expect(result.error.detail).toMatchObject({ extension: 'KHR_materials_ior', field: 'ior' });
142
+ });
143
+
144
+ it('accepts all transmission material required extensions through the parse path', async () => {
145
+ const result = await parseGltf(
146
+ {
147
+ asset: { version: '2.0' },
148
+ extensionsUsed: ['KHR_materials_transmission', 'KHR_materials_ior', 'KHR_materials_volume'],
149
+ extensionsRequired: [
150
+ 'KHR_materials_transmission',
151
+ 'KHR_materials_ior',
152
+ 'KHR_materials_volume',
153
+ ],
154
+ },
155
+ noopLoader,
156
+ '/material-required-transmission.gltf',
157
+ );
158
+
159
+ expect(result.ok).toBe(true);
160
+ if (!result.ok) return;
161
+ expect(result.value.diagnostics.unsupportedExtensions).toEqual([]);
162
+ });
163
+ });
164
+
44
165
  describe('glTF COLOR_0 importer carrier', () => {
45
166
  it('publishes normalized VEC3 FLOAT colors as importer-owned RGBA', async () => {
46
167
  const fixture = JSON.parse(
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { toMaterialAsset } from '../bridge.js';
3
+ import { parseGltf, toAssetPack } from '../parse-gltf.js';
4
+
5
+ const noopLoader = async (_uri: string) => new ArrayBuffer(0);
6
+
7
+ describe('glTF transmission carrier route', () => {
8
+ it('keeps required transmission extensions through parse, pack identity, and Standard bridge', async () => {
9
+ const source = {
10
+ asset: { version: '2.0' },
11
+ extensionsRequired: [
12
+ 'KHR_materials_transmission',
13
+ 'KHR_materials_ior',
14
+ 'KHR_materials_volume',
15
+ ],
16
+ extensionsUsed: ['KHR_materials_transmission', 'KHR_materials_ior', 'KHR_materials_volume'],
17
+ materials: [
18
+ {
19
+ name: 'TransmissionSphere',
20
+ pbrMetallicRoughness: {
21
+ baseColorFactor: [0.76, 0.9, 1, 1],
22
+ metallicFactor: 0,
23
+ roughnessFactor: 0.06,
24
+ },
25
+ extensions: {
26
+ KHR_materials_transmission: { transmissionFactor: 1 },
27
+ KHR_materials_ior: { ior: 1.45 },
28
+ KHR_materials_volume: {
29
+ thicknessFactor: 0.12,
30
+ attenuationColor: [0.9, 0.97, 1],
31
+ attenuationDistance: 3,
32
+ },
33
+ },
34
+ },
35
+ ],
36
+ };
37
+
38
+ const parsed = await parseGltf(source, noopLoader, 'carrier/transmission.gltf');
39
+ expect(parsed.ok).toBe(true);
40
+ if (!parsed.ok) return;
41
+
42
+ const material = parsed.value.materials[0];
43
+ if (material === undefined) throw new Error('transmission carrier did not parse a material');
44
+ expect(material).toMatchObject({
45
+ name: 'TransmissionSphere',
46
+ transmissionFactor: 1,
47
+ ior: 1.45,
48
+ thicknessFactor: 0.12,
49
+ attenuationColor: [0.9, 0.97, 1],
50
+ attenuationDistance: 3,
51
+ });
52
+
53
+ const pack = toAssetPack(parsed.value, undefined, 'transmission.gltf');
54
+ expect(pack.ok).toBe(true);
55
+ if (!pack.ok) return;
56
+ expect(pack.value.subAssets).toEqual([
57
+ expect.objectContaining({
58
+ kind: 'material',
59
+ name: 'TransmissionSphere',
60
+ sourceKey: 'material:TransmissionSphere',
61
+ }),
62
+ ]);
63
+
64
+ const bridged = toMaterialAsset(material);
65
+ const pass = bridged.passes?.[0];
66
+ if (pass === undefined) throw new Error('transmission bridge did not emit a Forward pass');
67
+ if (pass.renderState === undefined)
68
+ throw new Error('transmission bridge pass has no render state');
69
+ expect(pass.program.module).toBe('forgeax::default-standard-pbr');
70
+ expect(pass.renderState.queue).toBe(2000);
71
+ expect(bridged.values).toMatchObject({
72
+ baseColor: [0.76, 0.9, 1, 1],
73
+ metallic: 0,
74
+ roughness: 0.06,
75
+ transmission: 1,
76
+ ior: 1.45,
77
+ thickness: 0.12,
78
+ attenuationColor: [0.9, 0.97, 1],
79
+ attenuationDistance: 3,
80
+ });
81
+ });
82
+ });
package/src/bridge.ts CHANGED
@@ -776,7 +776,9 @@ type MaterialTextureSlot =
776
776
  | 'metallicRoughnessTexture'
777
777
  | 'normalTexture'
778
778
  | 'occlusionTexture'
779
- | 'emissiveTexture';
779
+ | 'emissiveTexture'
780
+ | 'transmissionTexture'
781
+ | 'thicknessTexture';
780
782
 
781
783
  function textureValue(
782
784
  info: GltfTextureInfoIr | number | undefined,
@@ -830,6 +832,8 @@ export function validateMaterialUvSets(
830
832
  ['normalTexture', mat.normalTexture],
831
833
  ['occlusionTexture', mat.occlusionTexture],
832
834
  ['emissiveTexture', mat.emissiveTexture],
835
+ ['transmissionTexture', mat.transmissionTexture],
836
+ ['thicknessTexture', mat.thicknessTexture],
833
837
  ];
834
838
  for (const [slot, rawBinding] of slots) {
835
839
  const binding = textureInfo(rawBinding);
@@ -867,6 +871,8 @@ export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext
867
871
  ['normalTexture', mat.normalTexture],
868
872
  ['occlusionTexture', mat.occlusionTexture],
869
873
  ['emissiveTexture', mat.emissiveTexture],
874
+ ['transmissionTexture', mat.transmissionTexture],
875
+ ['thicknessTexture', mat.thicknessTexture],
870
876
  ];
871
877
  for (const [slot, info] of textureSlots) {
872
878
  const value = textureValue(info, slot, ctx);
@@ -875,6 +881,11 @@ export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext
875
881
  if (mat.occlusionTexture !== undefined && values.occlusionStrength === undefined) {
876
882
  values.occlusionStrength = 1;
877
883
  }
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;
878
889
 
879
890
  const module = ctx?.skinned === true ? 'forgeax::pbr-skin' : 'forgeax::default-standard-pbr';
880
891
 
@@ -1,13 +1,15 @@
1
1
  // check-extensions.ts - KHR / vendor extension gate.
2
2
  //
3
- // v1 required-extension support contains EXT_mesh_gpu_instancing,
4
- // KHR_lights_punctual, and KHR_texture_transform. The exported legacy list remains the original mesh
5
- // extension list for callers that display the v1 mesh-only surface.
3
+ // Required-extension support contains EXT_mesh_gpu_instancing,
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.
6
8
  // (feat-20260518-gltf-instancing-and-name-component plan-strategy section
7
9
  // 2 D-1 / D-3). Any extension listed in `extensionsRequired[]` outside
8
- // this allowlist triggers `gltf-extension-unsupported` (hard fail).
10
+ // the supported extension set triggers `gltf-extension-unsupported` (hard fail).
9
11
  // Extensions listed in `extensionsUsed[]` (but not required and not in
10
- // allowlist) are recorded in `importSettings.diagnostics
12
+ // supported extension set) are recorded in `importSettings.diagnostics
11
13
  // .unsupportedExtensions` so AI users can observe them downstream — and
12
14
  // nothing else: `extensionsUsed` is purely informational per the glTF spec
13
15
  // (only `extensionsRequired` is binding), and exporters routinely
@@ -16,12 +18,12 @@
16
18
  // diagnostics list is the single channel (no `console.error`).
17
19
  //
18
20
  // Future expansion (KHR_materials_unlit, ...) extends
19
- // `EXTENSION_ALLOWLIST` in place; each addition lands under its own feat-*
21
+ // `SUPPORTED_EXTENSIONS` in place; each addition lands under its own feat-*
20
22
  // loop with breaking-change registry entry.
21
23
 
22
24
  import { err, type GltfError, gltfErr, ok, type Result } from './errors.js';
23
25
 
24
- /** Hard-coded v1 allowlist (plan-strategy decision section 2 D-1 / D-3). */
26
+ /** Legacy v1 mesh-only list kept for callers that display that surface. */
25
27
  export const EXTENSION_ALLOWLIST: readonly string[] = [
26
28
  'EXT_mesh_gpu_instancing',
27
29
  'EXT_meshopt_compression',
@@ -30,10 +32,13 @@ const SUPPORTED_EXTENSIONS: readonly string[] = [
30
32
  ...EXTENSION_ALLOWLIST,
31
33
  'KHR_lights_punctual',
32
34
  'KHR_texture_transform',
35
+ 'KHR_materials_transmission',
36
+ 'KHR_materials_ior',
37
+ 'KHR_materials_volume',
33
38
  ];
34
39
 
35
40
  export interface ExtensionsCheckResult {
36
- /** Names listed in extensionsUsed but not in the allowlist. */
41
+ /** Names listed in extensionsUsed but not in the supported extension set. */
37
42
  readonly unsupportedUsed: readonly string[];
38
43
  }
39
44
 
@@ -43,7 +48,7 @@ export interface GltfExtensionsJson {
43
48
  }
44
49
 
45
50
  /**
46
- * Validate the glTF JSON's extension declarations against the v1 allowlist.
51
+ * Validate the glTF JSON's extension declarations against the supported set.
47
52
  *
48
53
  * Returns:
49
54
  * - `Result.err(gltf-extension-unsupported)` for the FIRST entry of
package/src/errors.ts CHANGED
@@ -60,6 +60,13 @@ export interface GltfExtensionUnsupportedDetail {
60
60
  readonly source: 'extensionsRequired' | 'extensionsUsed';
61
61
  }
62
62
 
63
+ export interface GltfMaterialTransmissionInvalidDetail {
64
+ readonly extension: 'KHR_materials_transmission' | 'KHR_materials_ior' | 'KHR_materials_volume';
65
+ readonly field: string;
66
+ readonly reason: 'type' | 'range' | 'non-finite' | 'blend';
67
+ readonly actual?: unknown;
68
+ }
69
+
63
70
  /** `gltf-accessor-type-mismatch` payload: 4-member closed reason discriminator. */
64
71
  export interface GltfAccessorTypeMismatchDetail {
65
72
  readonly accessorIndex: number;
@@ -256,6 +263,10 @@ const gltfErrorPolicy = {
256
263
  expected: 'extension listed in v1 allowlist (see EXTENSION_ALLOWLIST in @forgeax/engine-gltf)',
257
264
  hint: 'see feat-future-gltf-extensions-allowlist; remove this extension or wait for the allowlist to expand',
258
265
  },
266
+ 'gltf-material-transmission-invalid': {
267
+ expected: 'KHR transmission, IOR, and volume values are finite and within their glTF ranges',
268
+ hint: 'repair the named glTF material extension value and re-import the source',
269
+ },
259
270
  'gltf-accessor-type-mismatch': {
260
271
  expected: 'dense fixed-stride accessor with supported componentType',
261
272
  hint: 'sparse: see feat-future-gltf-sparse-accessor; morph: see feat-future-gltf-morph; interleaved: see feat-future-gltf-mesh-multi-section',
@@ -364,6 +375,7 @@ interface DetailFor {
364
375
  readonly 'gltf-color-accessor-unsupported': GltfColorAccessorUnsupportedDetail;
365
376
  readonly 'gltf-color-accessor-malformed': GltfColorAccessorMalformedDetail;
366
377
  readonly 'gltf-mesh-bridge-invalid': GltfMeshBridgeInvalidDetail;
378
+ readonly 'gltf-material-transmission-invalid': GltfMaterialTransmissionInvalidDetail;
367
379
  }
368
380
 
369
381
  /**
@@ -412,6 +412,8 @@ export function materialRefsForPack(
412
412
  pushRefsForSlot(mat.normalTexture, 'normalTexture');
413
413
  pushRefsForSlot(mat.occlusionTexture, 'occlusionTexture');
414
414
  pushRefsForSlot(mat.emissiveTexture, 'emissiveTexture');
415
+ pushRefsForSlot(mat.transmissionTexture, 'transmissionTexture');
416
+ pushRefsForSlot(mat.thicknessTexture, 'thicknessTexture');
415
417
  return refs;
416
418
  }
417
419
 
@@ -439,6 +441,8 @@ function rewriteMaterialAssetRefs(
439
441
  | 'normalTexture'
440
442
  | 'occlusionTexture'
441
443
  | 'emissiveTexture'
444
+ | 'transmissionTexture'
445
+ | 'thicknessTexture'
442
446
  ),
443
447
  GltfTextureInfoIr | number | undefined,
444
448
  ][] = [
@@ -447,6 +451,8 @@ function rewriteMaterialAssetRefs(
447
451
  ['normalTexture', mat.normalTexture],
448
452
  ['occlusionTexture', mat.occlusionTexture],
449
453
  ['emissiveTexture', mat.emissiveTexture],
454
+ ['transmissionTexture', mat.transmissionTexture],
455
+ ['thicknessTexture', mat.thicknessTexture],
450
456
  ];
451
457
  let cursor = 0;
452
458
  for (const [slot, rawBinding] of slots) {
package/src/index.ts CHANGED
@@ -65,6 +65,7 @@ export type {
65
65
  GltfImageMimeUnsupportedDetail,
66
66
  GltfInstancingCountMismatchDetail,
67
67
  GltfMalformedHeaderDetail,
68
+ GltfMaterialTransmissionInvalidDetail,
68
69
  GltfMetaMissingDetail,
69
70
  GltfMorphInvalidDetail,
70
71
  GltfMorphUnsupportedDetail,