@forgeax/engine-gltf 0.1.2

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 (217) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +197 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/animation-target-errors.unit.test.d.ts +2 -0
  5. package/dist/__tests__/animation-target-errors.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/asset-runtime-fixture.d.ts +14 -0
  7. package/dist/__tests__/asset-runtime-fixture.d.ts.map +1 -0
  8. package/dist/__tests__/bridge-material-samplers.unit.test.d.ts +2 -0
  9. package/dist/__tests__/bridge-material-samplers.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/bridge-material-values.unit.test.d.ts +2 -0
  11. package/dist/__tests__/bridge-material-values.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/bridge-mixed-color.unit.test.d.ts +2 -0
  13. package/dist/__tests__/bridge-mixed-color.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/bridge-sampler-required.test-d.d.ts +2 -0
  15. package/dist/__tests__/bridge-sampler-required.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts +2 -0
  17. package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/bridge-skin-stride.unit.test.d.ts +2 -0
  19. package/dist/__tests__/bridge-skin-stride.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/bridge-test-helpers.d.ts +5 -0
  21. package/dist/__tests__/bridge-test-helpers.d.ts.map +1 -0
  22. package/dist/__tests__/bridge.unit.test.d.ts +2 -0
  23. package/dist/__tests__/bridge.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/cli-gltf.integration.test.d.ts +2 -0
  25. package/dist/__tests__/cli-gltf.integration.test.d.ts.map +1 -0
  26. package/dist/__tests__/color-0-accessor.unit.test.d.ts +2 -0
  27. package/dist/__tests__/color-0-accessor.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/data-uri-surface.unit.test.d.ts +2 -0
  29. package/dist/__tests__/data-uri-surface.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/gltf-error-derived-views.test-d.d.ts +2 -0
  31. package/dist/__tests__/gltf-error-derived-views.test-d.d.ts.map +1 -0
  32. package/dist/__tests__/gltf-error-migration.test.d.ts +2 -0
  33. package/dist/__tests__/gltf-error-migration.test.d.ts.map +1 -0
  34. package/dist/__tests__/gltf-error-policy-owner.unit.test.d.ts +2 -0
  35. package/dist/__tests__/gltf-error-policy-owner.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/gltf-importer.test.d.ts +2 -0
  37. package/dist/__tests__/gltf-importer.test.d.ts.map +1 -0
  38. package/dist/__tests__/gltf-local-artifacts.test.d.ts +2 -0
  39. package/dist/__tests__/gltf-local-artifacts.test.d.ts.map +1 -0
  40. package/dist/__tests__/gltf.unit.test.d.ts +2 -0
  41. package/dist/__tests__/gltf.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/malformed-buffer-data-uri.integration.test.d.ts +2 -0
  43. package/dist/__tests__/malformed-buffer-data-uri.integration.test.d.ts.map +1 -0
  44. package/dist/__tests__/material-missing-uv.unit.test.d.ts +2 -0
  45. package/dist/__tests__/material-missing-uv.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/material-pack-refs.integration.test.d.ts +2 -0
  47. package/dist/__tests__/material-pack-refs.integration.test.d.ts.map +1 -0
  48. package/dist/__tests__/material-per-slot-ir.unit.test.d.ts +2 -0
  49. package/dist/__tests__/material-per-slot-ir.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/material-texture-transform.unit.test.d.ts +2 -0
  51. package/dist/__tests__/material-texture-transform.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/material-unused-uv.unit.test.d.ts +2 -0
  53. package/dist/__tests__/material-unused-uv.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/meshopt-decode.unit.test.d.ts +2 -0
  55. package/dist/__tests__/meshopt-decode.unit.test.d.ts.map +1 -0
  56. package/dist/__tests__/meshopt-falsifier.unit.test.d.ts +2 -0
  57. package/dist/__tests__/meshopt-falsifier.unit.test.d.ts.map +1 -0
  58. package/dist/__tests__/meshopt-mode-filter-owner.test-d.d.ts +2 -0
  59. package/dist/__tests__/meshopt-mode-filter-owner.test-d.d.ts.map +1 -0
  60. package/dist/__tests__/meshopt-real-matrix.integration.test.d.ts +2 -0
  61. package/dist/__tests__/meshopt-real-matrix.integration.test.d.ts.map +1 -0
  62. package/dist/__tests__/morph-import.integration.test.d.ts +2 -0
  63. package/dist/__tests__/morph-import.integration.test.d.ts.map +1 -0
  64. package/dist/__tests__/morph-import.unit.test.d.ts +2 -0
  65. package/dist/__tests__/morph-import.unit.test.d.ts.map +1 -0
  66. package/dist/__tests__/parse-decode.unit.test.d.ts +2 -0
  67. package/dist/__tests__/parse-decode.unit.test.d.ts.map +1 -0
  68. package/dist/__tests__/parse-gltf-multi-uv.test.d.ts +2 -0
  69. package/dist/__tests__/parse-gltf-multi-uv.test.d.ts.map +1 -0
  70. package/dist/__tests__/parse-gltf-skin-attrs.unit.test.d.ts +2 -0
  71. package/dist/__tests__/parse-gltf-skin-attrs.unit.test.d.ts.map +1 -0
  72. package/dist/__tests__/parse-gltf.unit.test.d.ts +2 -0
  73. package/dist/__tests__/parse-gltf.unit.test.d.ts.map +1 -0
  74. package/dist/__tests__/pick-e2e.integration.test.d.ts +2 -0
  75. package/dist/__tests__/pick-e2e.integration.test.d.ts.map +1 -0
  76. package/dist/__tests__/public-surface.unit.test.d.ts +2 -0
  77. package/dist/__tests__/public-surface.unit.test.d.ts.map +1 -0
  78. package/dist/__tests__/source-key-producer.integration.test.d.ts +2 -0
  79. package/dist/__tests__/source-key-producer.integration.test.d.ts.map +1 -0
  80. package/dist/__tests__/source-key.unit.test.d.ts +2 -0
  81. package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
  82. package/dist/accessor/decode-accessor.d.ts +75 -0
  83. package/dist/accessor/decode-accessor.d.ts.map +1 -0
  84. package/dist/accessor/decode-color.d.ts +15 -0
  85. package/dist/accessor/decode-color.d.ts.map +1 -0
  86. package/dist/accessor/index.d.ts +3 -0
  87. package/dist/accessor/index.d.ts.map +1 -0
  88. package/dist/bridge.d.ts +104 -0
  89. package/dist/bridge.d.ts.map +1 -0
  90. package/dist/check-extensions.d.ts +29 -0
  91. package/dist/check-extensions.d.ts.map +1 -0
  92. package/dist/cli-gltf.d.ts +8 -0
  93. package/dist/cli-gltf.d.ts.map +1 -0
  94. package/dist/cli-gltf.mjs +2419 -0
  95. package/dist/cli-gltf.mjs.map +1 -0
  96. package/dist/data-uri.d.ts +6 -0
  97. package/dist/data-uri.d.ts.map +1 -0
  98. package/dist/errors.d.ts +201 -0
  99. package/dist/errors.d.ts.map +1 -0
  100. package/dist/extract-image-bytes.d.ts +42 -0
  101. package/dist/extract-image-bytes.d.ts.map +1 -0
  102. package/dist/gltf-importer.d.ts +21 -0
  103. package/dist/gltf-importer.d.ts.map +1 -0
  104. package/dist/image-color-space.d.ts +35 -0
  105. package/dist/image-color-space.d.ts.map +1 -0
  106. package/dist/importer-entry.d.ts +10 -0
  107. package/dist/importer-entry.d.ts.map +1 -0
  108. package/dist/importer-entry.mjs +3367 -0
  109. package/dist/importer-entry.mjs.map +1 -0
  110. package/dist/index.d.ts +25 -0
  111. package/dist/index.d.ts.map +1 -0
  112. package/dist/index.mjs +3645 -0
  113. package/dist/index.mjs.map +1 -0
  114. package/dist/meshopt-decode.d.ts +39 -0
  115. package/dist/meshopt-decode.d.ts.map +1 -0
  116. package/dist/node-file-entry.d.ts +14 -0
  117. package/dist/node-file-entry.d.ts.map +1 -0
  118. package/dist/node-file-entry.mjs +1942 -0
  119. package/dist/node-file-entry.mjs.map +1 -0
  120. package/dist/node-path.d.ts +14 -0
  121. package/dist/node-path.d.ts.map +1 -0
  122. package/dist/parse-animation.d.ts +70 -0
  123. package/dist/parse-animation.d.ts.map +1 -0
  124. package/dist/parse-glb-chunks.d.ts +25 -0
  125. package/dist/parse-glb-chunks.d.ts.map +1 -0
  126. package/dist/parse-glb.d.ts +3 -0
  127. package/dist/parse-glb.d.ts.map +1 -0
  128. package/dist/parse-gltf-header.d.ts +18 -0
  129. package/dist/parse-gltf-header.d.ts.map +1 -0
  130. package/dist/parse-gltf.d.ts +263 -0
  131. package/dist/parse-gltf.d.ts.map +1 -0
  132. package/dist/parse-skin.d.ts +70 -0
  133. package/dist/parse-skin.d.ts.map +1 -0
  134. package/dist/reimport-reuse-meta.d.ts +48 -0
  135. package/dist/reimport-reuse-meta.d.ts.map +1 -0
  136. package/dist/serialize-meta.d.ts +2 -0
  137. package/dist/serialize-meta.d.ts.map +1 -0
  138. package/dist/source-key.d.ts +22 -0
  139. package/dist/source-key.d.ts.map +1 -0
  140. package/dist/sub-asset-key.d.ts +12 -0
  141. package/dist/sub-asset-key.d.ts.map +1 -0
  142. package/dist/to-asset-pack.d.ts +2 -0
  143. package/dist/to-asset-pack.d.ts.map +1 -0
  144. package/dist/transform.d.ts +37 -0
  145. package/dist/transform.d.ts.map +1 -0
  146. package/package.json +94 -0
  147. package/src/__tests__/animation-target-errors.unit.test.ts +51 -0
  148. package/src/__tests__/asset-runtime-fixture.ts +278 -0
  149. package/src/__tests__/bridge-material-samplers.unit.test.ts +67 -0
  150. package/src/__tests__/bridge-material-values.unit.test.ts +106 -0
  151. package/src/__tests__/bridge-mixed-color.unit.test.ts +100 -0
  152. package/src/__tests__/bridge-sampler-required.test-d.ts +59 -0
  153. package/src/__tests__/bridge-skin-shader-route.unit.test.ts +42 -0
  154. package/src/__tests__/bridge-skin-stride.unit.test.ts +115 -0
  155. package/src/__tests__/bridge-test-helpers.ts +9 -0
  156. package/src/__tests__/bridge.unit.test.ts +1227 -0
  157. package/src/__tests__/cli-gltf.integration.test.ts +122 -0
  158. package/src/__tests__/color-0-accessor.unit.test.ts +174 -0
  159. package/src/__tests__/data-uri-surface.unit.test.ts +26 -0
  160. package/src/__tests__/fixtures/color-0/float-vec3.gltf +13 -0
  161. package/src/__tests__/fixtures/color-0/interleaved-vec3.gltf +6 -0
  162. package/src/__tests__/fixtures/color-0/normalized-ubyte.gltf +6 -0
  163. package/src/__tests__/fixtures/color-0/normalized-ushort.gltf +6 -0
  164. package/src/__tests__/gltf-error-derived-views.test-d.ts +152 -0
  165. package/src/__tests__/gltf-error-migration.test.ts +147 -0
  166. package/src/__tests__/gltf-error-policy-owner.unit.test.ts +185 -0
  167. package/src/__tests__/gltf-importer.test.ts +12 -0
  168. package/src/__tests__/gltf-local-artifacts.test.ts +11 -0
  169. package/src/__tests__/gltf.unit.test.ts +2975 -0
  170. package/src/__tests__/malformed-buffer-data-uri.integration.test.ts +140 -0
  171. package/src/__tests__/material-missing-uv.unit.test.ts +29 -0
  172. package/src/__tests__/material-pack-refs.integration.test.ts +58 -0
  173. package/src/__tests__/material-per-slot-ir.unit.test.ts +90 -0
  174. package/src/__tests__/material-texture-transform.unit.test.ts +75 -0
  175. package/src/__tests__/material-unused-uv.unit.test.ts +44 -0
  176. package/src/__tests__/meshopt-decode.unit.test.ts +87 -0
  177. package/src/__tests__/meshopt-falsifier.unit.test.ts +92 -0
  178. package/src/__tests__/meshopt-mode-filter-owner.test-d.ts +79 -0
  179. package/src/__tests__/meshopt-real-matrix.integration.test.ts +286 -0
  180. package/src/__tests__/morph-import.integration.test.ts +128 -0
  181. package/src/__tests__/morph-import.unit.test.ts +62 -0
  182. package/src/__tests__/parse-decode.unit.test.ts +919 -0
  183. package/src/__tests__/parse-gltf-multi-uv.test.ts +238 -0
  184. package/src/__tests__/parse-gltf-skin-attrs.unit.test.ts +191 -0
  185. package/src/__tests__/parse-gltf.unit.test.ts +133 -0
  186. package/src/__tests__/pick-e2e.integration.test.ts +238 -0
  187. package/src/__tests__/public-surface.unit.test.ts +10 -0
  188. package/src/__tests__/source-key-producer.integration.test.ts +163 -0
  189. package/src/__tests__/source-key.unit.test.ts +159 -0
  190. package/src/accessor/decode-accessor.ts +309 -0
  191. package/src/accessor/decode-color.ts +128 -0
  192. package/src/accessor/index.ts +15 -0
  193. package/src/bridge.ts +929 -0
  194. package/src/check-extensions.ts +81 -0
  195. package/src/cli-gltf.ts +344 -0
  196. package/src/data-uri.ts +29 -0
  197. package/src/errors.ts +386 -0
  198. package/src/extract-image-bytes.ts +343 -0
  199. package/src/gltf-importer.ts +967 -0
  200. package/src/image-color-space.ts +104 -0
  201. package/src/importer-entry.ts +24 -0
  202. package/src/index.ts +147 -0
  203. package/src/meshopt-decode.ts +200 -0
  204. package/src/node-file-entry.ts +98 -0
  205. package/src/node-path.ts +41 -0
  206. package/src/parse-animation.ts +286 -0
  207. package/src/parse-glb-chunks.ts +167 -0
  208. package/src/parse-glb.ts +16 -0
  209. package/src/parse-gltf-header.ts +58 -0
  210. package/src/parse-gltf.ts +1729 -0
  211. package/src/parse-skin.ts +238 -0
  212. package/src/reimport-reuse-meta.ts +134 -0
  213. package/src/serialize-meta.ts +24 -0
  214. package/src/source-key.ts +72 -0
  215. package/src/sub-asset-key.ts +61 -0
  216. package/src/to-asset-pack.ts +8 -0
  217. package/src/transform.ts +117 -0
package/package.json ADDED
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "@forgeax/engine-gltf",
3
+ "version": "0.1.2",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "sideEffects": false,
8
+ "description": "Runtime glTF 2.0 importer (Tier-B subset: scenes + nodes + meshes (POSITION + INDICES) + UnlitMaterial.baseColor + Camera.perspective). Pure-function pipeline parseGlb / parseGltf / toAssetPack consumed by build-time CLI (forgeax-engine-remote-gltf import) writing <source>.meta.json (external-asset-package; assetType=gltf) for typed assets.load(guid, sceneAssetKind) and World scene ownership.",
9
+ "bin": {
10
+ "forgeax-engine-remote-gltf": "./dist/cli-gltf.mjs"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.mjs"
16
+ },
17
+ "./node": {
18
+ "types": "./dist/node-file-entry.d.ts",
19
+ "import": "./dist/node-file-entry.mjs"
20
+ },
21
+ "./importer": {
22
+ "types": "./dist/importer-entry.d.ts",
23
+ "import": "./dist/importer-entry.mjs"
24
+ },
25
+ "./cli-gltf": {
26
+ "types": "./dist/cli-gltf.d.ts",
27
+ "node": "./dist/cli-gltf.mjs",
28
+ "default": null
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "main": "./dist/index.mjs",
33
+ "types": "./dist/index.d.ts",
34
+ "files": [
35
+ "dist",
36
+ "src",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "dependencies": {
41
+ "@forgeax/engine-animation": "0.1.2",
42
+ "@forgeax/engine-geometry": "0.1.2",
43
+ "@forgeax/engine-import": "0.1.2",
44
+ "@forgeax/engine-math": "0.1.2",
45
+ "@forgeax/engine-pack": "0.1.2",
46
+ "@forgeax/engine-scene": "0.1.2",
47
+ "@forgeax/engine-types": "0.1.2",
48
+ "@webgpu/types": "^0.1.71",
49
+ "meshoptimizer": "^0.20.0"
50
+ },
51
+ "devDependencies": {
52
+ "@forgeax/engine-assets-runtime": "0.1.2",
53
+ "@forgeax/engine-ecs": "0.1.2",
54
+ "@forgeax/engine-geometry": "0.1.2",
55
+ "@forgeax/engine-picking": "0.1.2",
56
+ "@forgeax/engine-render": "0.1.2",
57
+ "@forgeax/engine-runtime": "0.1.2",
58
+ "@types/node": "^20.14.0"
59
+ },
60
+ "forgeax": {
61
+ "metrics": {
62
+ "bundle-size": {
63
+ "enabled": true,
64
+ "path": "dist/index.mjs",
65
+ "compression": "gzip",
66
+ "expected": 5865,
67
+ "baseline": {
68
+ "source": ".forgeax-harness/forgeax-loop/feat-20260515-gltf-loader-via-asset-system/plan-strategy.md#R3",
69
+ "threshold": 102400
70
+ }
71
+ },
72
+ "fps": {
73
+ "enabled": false,
74
+ "reason": "build-time importer pure-function pipeline; no runtime canvas. fps reported by hello-gltf app (M5)."
75
+ },
76
+ "bench": {
77
+ "enabled": false,
78
+ "reason": "Tier-B importer is one-shot build-time work (not a hot path); bench focus stays on @forgeax/engine-math."
79
+ },
80
+ "gate": {
81
+ "enabled": false,
82
+ "reason": "no per-package binary gate; runtime parsing is consumed by @forgeax/engine-runtime + cli-gltf only, no separate enforcement scope."
83
+ },
84
+ "spike-report": {
85
+ "enabled": false,
86
+ "reason": "not a spike package."
87
+ }
88
+ }
89
+ },
90
+ "scripts": {
91
+ "build": "tsup",
92
+ "test": "vitest run"
93
+ }
94
+ }
@@ -0,0 +1,51 @@
1
+ import { deriveAnimationTargetId } from '@forgeax/engine-animation/target-id';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { gltfErr } from '../errors';
4
+ import { buildNodeParentMap, resolveNamedNodePath } from '../node-path';
5
+
6
+ describe('glTF animation target paths', () => {
7
+ const nodes = [
8
+ { name: 'Root', children: [1] },
9
+ { name: 'Hip', children: [] },
10
+ ] as const;
11
+
12
+ it('derives a stable full-path ID from a source node index', () => {
13
+ const parents = buildNodeParentMap(nodes);
14
+ const path = resolveNamedNodePath(nodes, parents, 1);
15
+ expect(path.ok).toBe(true);
16
+ if (!path.ok) return;
17
+ expect(path.value).toEqual(['Root', 'Hip']);
18
+ expect(deriveAnimationTargetId(path.value)).toBe('a95da0ec669189f98273e8f86d8ad9f2');
19
+ });
20
+
21
+ it('uses one stable code with a reason discriminator', () => {
22
+ const error = gltfErr('gltf-animation-target-invalid', {
23
+ reason: 'name-missing',
24
+ animationIndex: 0,
25
+ channelIndex: 1,
26
+ nodeIndex: 1,
27
+ });
28
+ expect(error.code).toBe('gltf-animation-target-invalid');
29
+ expect(error.detail.reason).toBe('name-missing');
30
+ expect(error.hint.length).toBeGreaterThan(0);
31
+ });
32
+
33
+ it('rejects cyclic node ancestry instead of looping', () => {
34
+ const cyclicNodes = [
35
+ { name: 'A', children: [1] },
36
+ { name: 'B', children: [0] },
37
+ ] as const;
38
+ const path = resolveNamedNodePath(cyclicNodes, buildNodeParentMap(cyclicNodes), 0);
39
+
40
+ expect(path).toEqual({
41
+ ok: false,
42
+ reason: 'hierarchy-cycle',
43
+ nodeIndex: 0,
44
+ });
45
+ });
46
+
47
+ it('does not reject unnamed nodes that are not animation targets', () => {
48
+ const parents = buildNodeParentMap([{ children: [] }]);
49
+ expect(parents.size).toBe(0);
50
+ });
51
+ });
@@ -0,0 +1,278 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ type AssetRegistry,
4
+ createAssetRegistry,
5
+ createCatalogSource,
6
+ } from '@forgeax/engine-assets-runtime';
7
+ import { meshAssetDecoder, meshAssetKind } from '@forgeax/engine-geometry';
8
+ import { type DdcPack, normaliseForPack } from '@forgeax/engine-import';
9
+ import type {
10
+ AssetDecoder,
11
+ AssetKind,
12
+ AssetLoadError,
13
+ MeshAsset,
14
+ MorphTarget,
15
+ Result,
16
+ VertexAttributeMap,
17
+ } from '@forgeax/engine-types';
18
+ import { err, ok } from '@forgeax/engine-types';
19
+
20
+ const SCOPE_ID = 'gltf-test-scope';
21
+ const GENERATION = 1;
22
+ const PACK_DIGEST = `sha256:${'1'.repeat(64)}`;
23
+ const OUTPUT_SET_DIGEST = `sha256:${'2'.repeat(64)}`;
24
+ const OUTPUT_DIGEST = `sha256:${'3'.repeat(64)}`;
25
+ const ATTRIBUTE_KEYS = [
26
+ 'position',
27
+ 'normal',
28
+ 'uv',
29
+ 'tangent',
30
+ 'skinIndex',
31
+ 'skinWeight',
32
+ 'uv1',
33
+ 'uv2',
34
+ 'uv3',
35
+ 'uv4',
36
+ 'uv5',
37
+ 'uv6',
38
+ 'uv7',
39
+ ] as const;
40
+
41
+ type FixtureAsset = DdcPack['assets'][number];
42
+
43
+ export interface AssetRuntimeFixtureOptions {
44
+ readonly packageUrl?: string;
45
+ readonly artifactOverrides?: ReadonlyMap<string, Uint8Array>;
46
+ }
47
+
48
+ function sha256(bytes: Uint8Array): string {
49
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
50
+ }
51
+
52
+ function row(asset: FixtureAsset, packageUrl: string) {
53
+ const sourcePath = `fixtures/${asset.guid}`;
54
+ return {
55
+ guid: asset.guid,
56
+ packageUrl,
57
+ kind: asset.kind,
58
+ sourcePath,
59
+ revision: { rootId: 'gltf-fixture', digest: 'sha256:catalog', observedAt: 1 },
60
+ publication: {
61
+ schemaVersion: 'asset-publication/1' as const,
62
+ sourcePath,
63
+ sourceRevision: 'gltf-fixture-source',
64
+ generation: GENERATION,
65
+ digest: PACK_DIGEST,
66
+ outputSetDigest: OUTPUT_SET_DIGEST,
67
+ outputs: [
68
+ {
69
+ guid: asset.guid,
70
+ sourceKey: asset.sourceKey ?? asset.guid,
71
+ kind: asset.kind,
72
+ digest: OUTPUT_DIGEST,
73
+ refs: asset.refs,
74
+ },
75
+ ],
76
+ receipt: {
77
+ schemaVersion: 'asset-publication-receipt/1' as const,
78
+ sourcePath,
79
+ sourceRevision: 'gltf-fixture-source',
80
+ inputFingerprint: 'gltf-fixture-input',
81
+ outputDigest: OUTPUT_DIGEST,
82
+ outputSetDigest: OUTPUT_SET_DIGEST,
83
+ externalEvidence: [],
84
+ },
85
+ externalEvidence: [],
86
+ },
87
+ };
88
+ }
89
+
90
+ function floatArray(value: unknown): Float32Array | undefined {
91
+ if (value instanceof Float32Array) return value;
92
+ if (value instanceof ArrayBuffer) return new Float32Array(value);
93
+ if (ArrayBuffer.isView(value))
94
+ return new Float32Array(Array.from(value as unknown as ArrayLike<number>));
95
+ if (Array.isArray(value)) return new Float32Array(value as number[]);
96
+ return undefined;
97
+ }
98
+
99
+ function indexArray(value: unknown): Uint16Array | Uint32Array | undefined {
100
+ if (value instanceof Uint16Array || value instanceof Uint32Array) return value;
101
+ if (ArrayBuffer.isView(value) || Array.isArray(value)) {
102
+ const values = Array.from(value as unknown as ArrayLike<number>);
103
+ return values.some((entry) => entry > 0xffff)
104
+ ? new Uint32Array(values)
105
+ : new Uint16Array(values);
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ function integerAttribute(value: unknown): Uint16Array | undefined {
111
+ if (value instanceof Uint16Array) return value;
112
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) {
113
+ return new Uint16Array(Array.from(value as unknown as ArrayLike<number>));
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ function morphTarget(value: unknown): MorphTarget | undefined {
119
+ if (value === null || typeof value !== 'object') return undefined;
120
+ const source = value as Record<string, unknown>;
121
+ const position = floatArray(source.position);
122
+ const normal = floatArray(source.normal);
123
+ const tangent = floatArray(source.tangent);
124
+ return {
125
+ ...(position === undefined ? {} : { position }),
126
+ ...(normal === undefined ? {} : { normal }),
127
+ ...(tangent === undefined ? {} : { tangent }),
128
+ };
129
+ }
130
+
131
+ function meshPayload(value: unknown): MeshAsset | undefined {
132
+ if (value === null || typeof value !== 'object') return undefined;
133
+ const source = value as Record<string, unknown>;
134
+ const vertices = floatArray(source.vertices);
135
+ const rawAttributes = source.attributes;
136
+ const rawSubmeshes = source.submeshes;
137
+ const rawMaterialSlots = source.materialSlots;
138
+ if (
139
+ source.kind !== 'mesh' ||
140
+ vertices === undefined ||
141
+ rawAttributes === null ||
142
+ typeof rawAttributes !== 'object' ||
143
+ !Array.isArray(rawSubmeshes) ||
144
+ !Array.isArray(rawMaterialSlots)
145
+ ) {
146
+ return undefined;
147
+ }
148
+ const attributes: VertexAttributeMap = {};
149
+ const sourceAttributes = rawAttributes as Record<string, unknown>;
150
+ for (const key of ATTRIBUTE_KEYS) {
151
+ const raw = sourceAttributes[key];
152
+ if (raw === undefined) continue;
153
+ const converted = key === 'skinIndex' ? integerAttribute(raw) : floatArray(raw);
154
+ if (converted !== undefined) attributes[key] = converted;
155
+ }
156
+ const indices = indexArray(source.indices);
157
+ const aabb = floatArray(source.aabb);
158
+ const morphWeights = floatArray(source.morphWeights);
159
+ const morphTargets = Array.isArray(source.morphTargets)
160
+ ? source.morphTargets.flatMap((target) => {
161
+ const converted = morphTarget(target);
162
+ return converted === undefined ? [] : [converted];
163
+ })
164
+ : undefined;
165
+ return {
166
+ kind: 'mesh',
167
+ vertices,
168
+ ...(indices === undefined ? {} : { indices }),
169
+ attributes,
170
+ ...(aabb === undefined ? {} : { aabb }),
171
+ submeshes: rawSubmeshes as MeshAsset['submeshes'],
172
+ materialSlots: rawMaterialSlots as MeshAsset['materialSlots'],
173
+ ...(morphTargets === undefined ? {} : { morphTargets }),
174
+ ...(morphWeights === undefined ? {} : { morphWeights }),
175
+ };
176
+ }
177
+
178
+ export const normalizedMeshAssetDecoder: AssetDecoder<MeshAsset> = {
179
+ async decode(input): Promise<Result<MeshAsset, AssetLoadError>> {
180
+ const body = input.envelope.artifacts.body;
181
+ if (body !== undefined) {
182
+ const bytes = await input.artifacts.read(body);
183
+ if (!bytes.ok) return bytes;
184
+ }
185
+ const payload = meshPayload(input.envelope.payload);
186
+ if (payload === undefined) {
187
+ return err({
188
+ code: 'asset-package-invalid',
189
+ expected: 'a JSON-safe mesh payload that can be owned by Geometry',
190
+ hint: 'recook the mesh payload with typed vertex and attribute fields',
191
+ detail: { guid: input.envelope.guid, reason: 'mesh payload normalization failed' },
192
+ });
193
+ }
194
+ return meshAssetDecoder.decode({
195
+ ...input,
196
+ envelope: { ...input.envelope, payload },
197
+ });
198
+ },
199
+ };
200
+
201
+ export function installMeshDecoder(assets: AssetRegistry) {
202
+ return assets.installDecoder(meshAssetKind, normalizedMeshAssetDecoder);
203
+ }
204
+
205
+ export function installPassthroughDecoder(assets: AssetRegistry, kind: string) {
206
+ const token: AssetKind<Record<string, unknown>, string> = {
207
+ kind,
208
+ } as AssetKind<Record<string, unknown>, string>;
209
+ return assets.installDecoder(token, {
210
+ async decode({ envelope }) {
211
+ return ok(envelope.payload);
212
+ },
213
+ });
214
+ }
215
+
216
+ export function createAssetRuntimeFixture(
217
+ sourceAssets: readonly FixtureAsset[],
218
+ options: AssetRuntimeFixtureOptions = {},
219
+ ): AssetRegistry {
220
+ const packageUrl = options.packageUrl ?? 'https://assets.test/gltf-fixture.pack.json';
221
+ const artifactBytes = new Map<string, Uint8Array>();
222
+ const runtimeAssets = sourceAssets.map((asset) => {
223
+ const artifacts = Object.fromEntries(
224
+ Object.entries(asset.artifacts).map(([key, body]) => {
225
+ const path = `${asset.guid}/${key}.bin`;
226
+ const url = new URL(path, packageUrl).toString();
227
+ artifactBytes.set(
228
+ url,
229
+ options.artifactOverrides?.get(`${asset.guid}:${key}`) ?? body.bytes,
230
+ );
231
+ return [
232
+ key,
233
+ {
234
+ path,
235
+ mediaType: body.mediaType,
236
+ contentEncoding: 'identity' as const,
237
+ byteLength: body.bytes.byteLength,
238
+ integrity: { algorithm: 'sha256' as const, digest: sha256(body.bytes) },
239
+ ...(body.assetCodec === undefined ? {} : { assetCodec: body.assetCodec }),
240
+ },
241
+ ];
242
+ }),
243
+ );
244
+ return {
245
+ guid: asset.guid,
246
+ kind: asset.kind,
247
+ ...(asset.name === undefined ? {} : { name: asset.name }),
248
+ payload: normaliseForPack(asset.payload) as Record<string, unknown>,
249
+ refs: asset.refs,
250
+ artifacts,
251
+ };
252
+ });
253
+ const pack = {
254
+ schemaVersion: '2.0.0' as const,
255
+ kind: 'internal-text-package' as const,
256
+ scopeId: SCOPE_ID,
257
+ generation: GENERATION,
258
+ digest: PACK_DIGEST,
259
+ outputSetDigest: OUTPUT_SET_DIGEST,
260
+ assets: runtimeAssets,
261
+ };
262
+ const fetcher: typeof globalThis.fetch = async (input) => {
263
+ const url = String(input);
264
+ if (url === packageUrl) return new Response(JSON.stringify(pack));
265
+ const bytes = artifactBytes.get(url);
266
+ return bytes === undefined
267
+ ? new Response('not found', { status: 404 })
268
+ : new Response(bytes as unknown as BodyInit);
269
+ };
270
+ return createAssetRegistry({
271
+ catalog: createCatalogSource({
272
+ entries: sourceAssets.map((asset) => row(asset, packageUrl)),
273
+ }),
274
+ fetcher,
275
+ scopeId: SCOPE_ID,
276
+ generation: GENERATION,
277
+ });
278
+ }
@@ -0,0 +1,67 @@
1
+ import type { Handle } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { toMaterialAsset } from '../bridge.js';
4
+ import type { GltfMaterialIr } from '../parse-gltf.js';
5
+
6
+ describe('glTF material sampler mapping', () => {
7
+ it('maps BLEND to straight-alpha state without depth writes', () => {
8
+ const asset = toMaterialAsset({
9
+ baseColorFactor: [1, 1, 1, 0.4],
10
+ metallicFactor: 0,
11
+ roughnessFactor: 1,
12
+ alphaMode: 'BLEND',
13
+ } as unknown as GltfMaterialIr);
14
+ const state = asset.passes?.[0]?.renderState as {
15
+ blend?: {
16
+ color?: { srcFactor?: string; dstFactor?: string };
17
+ alpha?: { srcFactor?: string; dstFactor?: string };
18
+ };
19
+ depthWriteEnabled?: boolean;
20
+ queue?: number;
21
+ };
22
+
23
+ expect(state.queue).toBe(3000);
24
+ expect(state.depthWriteEnabled).toBe(false);
25
+ expect(state.blend).toMatchObject({
26
+ color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' },
27
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
28
+ });
29
+ });
30
+
31
+ it('maps each texture slot to its own sampler GUID', () => {
32
+ const asset = toMaterialAsset(
33
+ {
34
+ baseColorFactor: [1, 1, 1, 1],
35
+ metallicFactor: 1,
36
+ roughnessFactor: 1,
37
+ baseColorTexture: { texture: 0, sampler: 10 },
38
+ metallicRoughnessTexture: { texture: 1, sampler: 11 },
39
+ normalTexture: { texture: 2, sampler: 12 },
40
+ occlusionTexture: { texture: 3, sampler: 13 },
41
+ emissiveTexture: { texture: 4, sampler: 14 },
42
+ } as unknown as GltfMaterialIr,
43
+ {
44
+ textureHandles: new Map([
45
+ [0, 100],
46
+ [1, 101],
47
+ [2, 102],
48
+ [3, 103],
49
+ [4, 104],
50
+ ]) as unknown as ReadonlyMap<number, Handle<'TextureAsset', 'shared'>>,
51
+ samplerHandles: new Map([
52
+ [10, 200],
53
+ [11, 201],
54
+ [12, 202],
55
+ [13, 203],
56
+ [14, 204],
57
+ ]) as unknown as ReadonlyMap<number, Handle<'SamplerAsset', 'shared'>>,
58
+ },
59
+ );
60
+ const values = asset.values as Record<string, { sampler?: unknown }>;
61
+ expect(values.baseColorTexture?.sampler).toBe(200);
62
+ expect(values.metallicRoughnessTexture?.sampler).toBe(201);
63
+ expect(values.normalTexture?.sampler).toBe(202);
64
+ expect(values.occlusionTexture?.sampler).toBe(203);
65
+ expect(values.emissiveTexture?.sampler).toBe(204);
66
+ });
67
+ });
@@ -0,0 +1,106 @@
1
+ import type { Handle } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { toMaterialAsset } from '../bridge.js';
4
+ import type { GltfMaterialIr } from '../parse-gltf.js';
5
+
6
+ describe('glTF material bridge values', () => {
7
+ it('preserves all four base-color channels for factor and texture composition', () => {
8
+ const factor = [0.5, 0.75, 0.25, 0.4] as const;
9
+ const material = toMaterialAsset({
10
+ baseColorFactor: factor,
11
+ metallicFactor: 0.5,
12
+ roughnessFactor: 0.7,
13
+ baseColorTexture: { texture: 0 },
14
+ } as unknown as GltfMaterialIr);
15
+
16
+ expect(material.values).toMatchObject({ baseColor: factor });
17
+ expect(material.values).not.toHaveProperty('baseColorAlpha');
18
+ expect(material.values).not.toHaveProperty('textureAlphaFactor');
19
+ });
20
+
21
+ it.each([
22
+ ['default', undefined, 0.5],
23
+ ['explicit', 0.73, 0.73],
24
+ ['zero', 0, 0],
25
+ ['one', 1, 1],
26
+ ] as const)('projects MASK %s cutoff into the material contract', (_name, authored, expected) => {
27
+ const material = toMaterialAsset({
28
+ baseColorFactor: [1, 1, 1, 0.5],
29
+ metallicFactor: 0,
30
+ roughnessFactor: 1,
31
+ alphaMode: 'MASK',
32
+ ...(authored === undefined ? {} : { alphaCutoff: authored }),
33
+ } as unknown as GltfMaterialIr);
34
+
35
+ expect(material.values?.alphaCutoff).toBe(expected);
36
+ });
37
+
38
+ it('projects five structured texture slots into standard-root values', () => {
39
+ const material = toMaterialAsset(
40
+ {
41
+ baseColorFactor: [0.8, 0.2, 0.1, 1],
42
+ metallicFactor: 0.5,
43
+ roughnessFactor: 0.7,
44
+ baseColorTexture: { texture: 0, sampler: 10, texCoord: 1 },
45
+ metallicRoughnessTexture: { texture: 1, sampler: 11, texCoord: 2 },
46
+ normalTexture: { texture: 2, sampler: 12, texCoord: 3, scale: 0.6 },
47
+ occlusionTexture: { texture: 3, sampler: 13, texCoord: 4, strength: 0.8 },
48
+ emissiveTexture: { texture: 4, sampler: 14, texCoord: 5 },
49
+ } as unknown as GltfMaterialIr,
50
+ {
51
+ textureHandles: new Map([
52
+ [0, 100],
53
+ [1, 101],
54
+ [2, 102],
55
+ [3, 103],
56
+ [4, 104],
57
+ ]) as unknown as ReadonlyMap<number, Handle<'TextureAsset', 'shared'>>,
58
+ samplerHandles: new Map([
59
+ [10, 200],
60
+ [11, 201],
61
+ [12, 202],
62
+ [13, 203],
63
+ [14, 204],
64
+ ]) as unknown as ReadonlyMap<number, Handle<'SamplerAsset', 'shared'>>,
65
+ },
66
+ );
67
+
68
+ expect(material.passes?.[0]?.program.module).toBe('forgeax::default-standard-pbr');
69
+ expect(material.colorSpace).toBe('linear');
70
+ expect(material.values).toMatchObject({
71
+ baseColor: [0.8, 0.2, 0.1, 1],
72
+ metallic: 0.5,
73
+ roughness: 0.7,
74
+ baseColorTexture: { texture: 100, sampler: 200, coordinates: { set: 1 } },
75
+ metallicRoughnessTexture: { texture: 101, sampler: 201, coordinates: { set: 2 } },
76
+ normalTexture: { texture: 102, sampler: 202, coordinates: { set: 3 }, normalScale: 0.6 },
77
+ occlusionTexture: {
78
+ texture: 103,
79
+ sampler: 203,
80
+ coordinates: { set: 4 },
81
+ occlusionStrength: 0.8,
82
+ },
83
+ emissiveTexture: { texture: 104, sampler: 204, coordinates: { set: 5 } },
84
+ });
85
+ });
86
+
87
+ it('keeps identity texture authoring compact', () => {
88
+ const material = toMaterialAsset(
89
+ {
90
+ baseColorTexture: { texture: 0 },
91
+ metallicFactor: 0.5,
92
+ roughnessFactor: 0.5,
93
+ } as unknown as GltfMaterialIr,
94
+ {
95
+ textureHandles: new Map([[0, 100]]) as unknown as ReadonlyMap<
96
+ number,
97
+ Handle<'TextureAsset', 'shared'>
98
+ >,
99
+ samplerHandles: new Map(),
100
+ },
101
+ );
102
+
103
+ expect(material.values?.baseColorTexture).toEqual({ texture: 100 });
104
+ expect(material.values?.baseColorTexture).not.toHaveProperty('coordinates');
105
+ });
106
+ });
@@ -0,0 +1,100 @@
1
+ import type { MeshAsset } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { meshIrToMeshAsset as bridgeMeshIrToMeshAsset } from '../bridge.js';
4
+ import type { GltfMeshIr } from '../parse-gltf.js';
5
+ import { unwrapMeshAsset as meshIrToMeshAsset } from './bridge-test-helpers.js';
6
+
7
+ type ColorMeshIr = GltfMeshIr & { readonly colors0?: Float32Array };
8
+
9
+ function primitive(vertexCount: number, colors0?: Float32Array): ColorMeshIr {
10
+ const positions = new Float32Array(vertexCount * 3);
11
+ for (let vertex = 0; vertex < vertexCount; vertex++) {
12
+ positions[vertex * 3 + 0] = vertex;
13
+ }
14
+ return {
15
+ positions,
16
+ indices: new Uint16Array(vertexCount === 3 ? [0, 1, 2] : [0, 1, 2, 0, 2, 3]),
17
+ materialIndex: 0,
18
+ meshIndex: 0,
19
+ ...(colors0 === undefined ? {} : { colors0 }),
20
+ };
21
+ }
22
+
23
+ function color(asset: MeshAsset): Float32Array | undefined {
24
+ return asset.attributes.color as Float32Array | undefined;
25
+ }
26
+
27
+ describe('glTF bridge COLOR_0 mixed primitive contract', () => {
28
+ it('takes the attributes union and fills absent primitive vertices with white', () => {
29
+ const colored = primitive(3, new Float32Array([1, 0.5, 0, 1, 0, 1, 0.5, 1, 0.25, 0, 1, 1]));
30
+ const plain = primitive(3);
31
+ const asset = meshIrToMeshAsset([colored, plain]);
32
+ expect(color(asset)).toEqual(
33
+ new Float32Array([
34
+ 1, 0.5, 0, 1, 0, 1, 0.5, 1, 0.25, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
35
+ ]),
36
+ );
37
+ });
38
+
39
+ it.each([
40
+ ['all colored', [primitive(3, new Float32Array(12)), primitive(4, new Float32Array(16))]],
41
+ ['all plain', [primitive(3), primitive(4)]],
42
+ [
43
+ 'skin and UV combination',
44
+ [
45
+ {
46
+ ...primitive(3, new Float32Array(12)),
47
+ joints0: new Uint16Array(12),
48
+ weights0: new Float32Array(12),
49
+ texcoord0: new Float32Array(6),
50
+ texcoord1: new Float32Array(6),
51
+ },
52
+ ],
53
+ ],
54
+ ] as const)('uses one canonical packer for %s', (_name, prims) => {
55
+ const asset = meshIrToMeshAsset(prims);
56
+ const attributes = asset.attributes as Record<string, unknown>;
57
+ expect(attributes.position).toBeInstanceOf(Float32Array);
58
+ expect(attributes.color === undefined || attributes.color instanceof Float32Array).toBe(true);
59
+ });
60
+
61
+ it('keeps a missing color stream unallocated for a plain mesh', () => {
62
+ const asset = meshIrToMeshAsset([primitive(3)]);
63
+ expect(color(asset)).toBeUndefined();
64
+ });
65
+
66
+ it('returns closed, typed bridge failures before publishing malformed meshes', () => {
67
+ const empty = bridgeMeshIrToMeshAsset([]);
68
+ expect(empty.ok).toBe(false);
69
+ if (!empty.ok) expect(empty.error.detail).toEqual({ reason: 'empty-input', primitiveCount: 0 });
70
+
71
+ const morphMismatch = bridgeMeshIrToMeshAsset([
72
+ { ...primitive(3), morphTargets: [] },
73
+ { ...primitive(3), morphTargets: [{ position: new Float32Array(9) }] },
74
+ ]);
75
+ expect(morphMismatch.ok).toBe(false);
76
+ if (!morphMismatch.ok)
77
+ expect(morphMismatch.error.detail).toMatchObject({
78
+ reason: 'morph-count-mismatch',
79
+ expectedTargetCount: 0,
80
+ actualTargetCount: 1,
81
+ });
82
+
83
+ const colorMismatch = bridgeMeshIrToMeshAsset([primitive(3, new Float32Array(4))]);
84
+ expect(colorMismatch.ok).toBe(false);
85
+ if (!colorMismatch.ok)
86
+ expect(colorMismatch.error.detail).toMatchObject({
87
+ reason: 'color-cardinality',
88
+ semantic: 'COLOR_0',
89
+ expectedLength: 12,
90
+ actualLength: 4,
91
+ });
92
+
93
+ const layoutMismatch = bridgeMeshIrToMeshAsset([
94
+ { ...primitive(3), positions: new Float32Array(4) },
95
+ ]);
96
+ expect(layoutMismatch.ok).toBe(false);
97
+ if (!layoutMismatch.ok)
98
+ expect(layoutMismatch.error.detail).toMatchObject({ reason: 'layout-invalid' });
99
+ });
100
+ });