@forgeax/engine-gltf 0.0.0-dev.8d955ade1c79

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 (220) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +204 -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__/bridge-material-samplers.unit.test.d.ts +2 -0
  7. package/dist/__tests__/bridge-material-samplers.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/bridge-material-values.unit.test.d.ts +2 -0
  9. package/dist/__tests__/bridge-material-values.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/bridge-mixed-color.unit.test.d.ts +2 -0
  11. package/dist/__tests__/bridge-mixed-color.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/bridge-sampler-required.test-d.d.ts +2 -0
  13. package/dist/__tests__/bridge-sampler-required.test-d.d.ts.map +1 -0
  14. package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts +2 -0
  15. package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/bridge-skin-stride.unit.test.d.ts +2 -0
  17. package/dist/__tests__/bridge-skin-stride.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/bridge-test-helpers.d.ts +5 -0
  19. package/dist/__tests__/bridge-test-helpers.d.ts.map +1 -0
  20. package/dist/__tests__/bridge.unit.test.d.ts +2 -0
  21. package/dist/__tests__/bridge.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/cli-gltf.integration.test.d.ts +2 -0
  23. package/dist/__tests__/cli-gltf.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/color-0-accessor.unit.test.d.ts +2 -0
  25. package/dist/__tests__/color-0-accessor.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/data-uri-surface.unit.test.d.ts +2 -0
  27. package/dist/__tests__/data-uri-surface.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/extension-admission.unit.test.d.ts +2 -0
  29. package/dist/__tests__/extension-admission.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-diagnostics.integration.test.d.ts +2 -0
  79. package/dist/__tests__/source-key-diagnostics.integration.test.d.ts.map +1 -0
  80. package/dist/__tests__/source-key-producer.integration.test.d.ts +2 -0
  81. package/dist/__tests__/source-key-producer.integration.test.d.ts.map +1 -0
  82. package/dist/__tests__/source-key.unit.test.d.ts +2 -0
  83. package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
  84. package/dist/accessor/decode-accessor.d.ts +75 -0
  85. package/dist/accessor/decode-accessor.d.ts.map +1 -0
  86. package/dist/accessor/decode-color.d.ts +15 -0
  87. package/dist/accessor/decode-color.d.ts.map +1 -0
  88. package/dist/accessor/index.d.ts +3 -0
  89. package/dist/accessor/index.d.ts.map +1 -0
  90. package/dist/bridge.d.ts +103 -0
  91. package/dist/bridge.d.ts.map +1 -0
  92. package/dist/check-extensions.d.ts +29 -0
  93. package/dist/check-extensions.d.ts.map +1 -0
  94. package/dist/cli-gltf.d.ts +8 -0
  95. package/dist/cli-gltf.d.ts.map +1 -0
  96. package/dist/cli-gltf.mjs +2435 -0
  97. package/dist/cli-gltf.mjs.map +1 -0
  98. package/dist/data-uri.d.ts +6 -0
  99. package/dist/data-uri.d.ts.map +1 -0
  100. package/dist/errors.d.ts +201 -0
  101. package/dist/errors.d.ts.map +1 -0
  102. package/dist/extract-image-bytes.d.ts +42 -0
  103. package/dist/extract-image-bytes.d.ts.map +1 -0
  104. package/dist/gltf-importer.d.ts +21 -0
  105. package/dist/gltf-importer.d.ts.map +1 -0
  106. package/dist/image-color-space.d.ts +35 -0
  107. package/dist/image-color-space.d.ts.map +1 -0
  108. package/dist/importer-entry.d.ts +10 -0
  109. package/dist/importer-entry.d.ts.map +1 -0
  110. package/dist/importer-entry.mjs +3378 -0
  111. package/dist/importer-entry.mjs.map +1 -0
  112. package/dist/index.d.ts +25 -0
  113. package/dist/index.d.ts.map +1 -0
  114. package/dist/index.mjs +3673 -0
  115. package/dist/index.mjs.map +1 -0
  116. package/dist/meshopt-decode.d.ts +39 -0
  117. package/dist/meshopt-decode.d.ts.map +1 -0
  118. package/dist/node-file-entry.d.ts +14 -0
  119. package/dist/node-file-entry.d.ts.map +1 -0
  120. package/dist/node-file-entry.mjs +1946 -0
  121. package/dist/node-file-entry.mjs.map +1 -0
  122. package/dist/node-path.d.ts +14 -0
  123. package/dist/node-path.d.ts.map +1 -0
  124. package/dist/parse-animation.d.ts +70 -0
  125. package/dist/parse-animation.d.ts.map +1 -0
  126. package/dist/parse-glb-chunks.d.ts +25 -0
  127. package/dist/parse-glb-chunks.d.ts.map +1 -0
  128. package/dist/parse-glb.d.ts +3 -0
  129. package/dist/parse-glb.d.ts.map +1 -0
  130. package/dist/parse-gltf-header.d.ts +18 -0
  131. package/dist/parse-gltf-header.d.ts.map +1 -0
  132. package/dist/parse-gltf.d.ts +263 -0
  133. package/dist/parse-gltf.d.ts.map +1 -0
  134. package/dist/parse-skin.d.ts +70 -0
  135. package/dist/parse-skin.d.ts.map +1 -0
  136. package/dist/reimport-reuse-meta.d.ts +48 -0
  137. package/dist/reimport-reuse-meta.d.ts.map +1 -0
  138. package/dist/serialize-meta.d.ts +2 -0
  139. package/dist/serialize-meta.d.ts.map +1 -0
  140. package/dist/source-key.d.ts +44 -0
  141. package/dist/source-key.d.ts.map +1 -0
  142. package/dist/sub-asset-key.d.ts +12 -0
  143. package/dist/sub-asset-key.d.ts.map +1 -0
  144. package/dist/to-asset-pack.d.ts +2 -0
  145. package/dist/to-asset-pack.d.ts.map +1 -0
  146. package/dist/transform.d.ts +37 -0
  147. package/dist/transform.d.ts.map +1 -0
  148. package/package.json +92 -0
  149. package/src/__tests__/animation-target-errors.unit.test.ts +51 -0
  150. package/src/__tests__/bridge-material-samplers.unit.test.ts +67 -0
  151. package/src/__tests__/bridge-material-values.unit.test.ts +106 -0
  152. package/src/__tests__/bridge-mixed-color.unit.test.ts +100 -0
  153. package/src/__tests__/bridge-sampler-required.test-d.ts +59 -0
  154. package/src/__tests__/bridge-skin-shader-route.unit.test.ts +42 -0
  155. package/src/__tests__/bridge-skin-stride.unit.test.ts +115 -0
  156. package/src/__tests__/bridge-test-helpers.ts +9 -0
  157. package/src/__tests__/bridge.unit.test.ts +1227 -0
  158. package/src/__tests__/cli-gltf.integration.test.ts +122 -0
  159. package/src/__tests__/color-0-accessor.unit.test.ts +174 -0
  160. package/src/__tests__/data-uri-surface.unit.test.ts +26 -0
  161. package/src/__tests__/extension-admission.unit.test.ts +19 -0
  162. package/src/__tests__/fixtures/color-0/float-vec3.gltf +13 -0
  163. package/src/__tests__/fixtures/color-0/interleaved-vec3.gltf +6 -0
  164. package/src/__tests__/fixtures/color-0/normalized-ubyte.gltf +6 -0
  165. package/src/__tests__/fixtures/color-0/normalized-ushort.gltf +6 -0
  166. package/src/__tests__/gltf-error-derived-views.test-d.ts +152 -0
  167. package/src/__tests__/gltf-error-migration.test.ts +147 -0
  168. package/src/__tests__/gltf-error-policy-owner.unit.test.ts +185 -0
  169. package/src/__tests__/gltf-importer.test.ts +12 -0
  170. package/src/__tests__/gltf-local-artifacts.test.ts +11 -0
  171. package/src/__tests__/gltf.unit.test.ts +2975 -0
  172. package/src/__tests__/malformed-buffer-data-uri.integration.test.ts +140 -0
  173. package/src/__tests__/material-missing-uv.unit.test.ts +29 -0
  174. package/src/__tests__/material-pack-refs.integration.test.ts +58 -0
  175. package/src/__tests__/material-per-slot-ir.unit.test.ts +90 -0
  176. package/src/__tests__/material-texture-transform.unit.test.ts +105 -0
  177. package/src/__tests__/material-unused-uv.unit.test.ts +44 -0
  178. package/src/__tests__/meshopt-decode.unit.test.ts +87 -0
  179. package/src/__tests__/meshopt-falsifier.unit.test.ts +92 -0
  180. package/src/__tests__/meshopt-mode-filter-owner.test-d.ts +79 -0
  181. package/src/__tests__/meshopt-real-matrix.integration.test.ts +370 -0
  182. package/src/__tests__/morph-import.integration.test.ts +115 -0
  183. package/src/__tests__/morph-import.unit.test.ts +62 -0
  184. package/src/__tests__/parse-decode.unit.test.ts +919 -0
  185. package/src/__tests__/parse-gltf-multi-uv.test.ts +238 -0
  186. package/src/__tests__/parse-gltf-skin-attrs.unit.test.ts +191 -0
  187. package/src/__tests__/parse-gltf.unit.test.ts +133 -0
  188. package/src/__tests__/pick-e2e.integration.test.ts +238 -0
  189. package/src/__tests__/public-surface.unit.test.ts +10 -0
  190. package/src/__tests__/source-key-diagnostics.integration.test.ts +140 -0
  191. package/src/__tests__/source-key-producer.integration.test.ts +171 -0
  192. package/src/__tests__/source-key.unit.test.ts +159 -0
  193. package/src/accessor/decode-accessor.ts +309 -0
  194. package/src/accessor/decode-color.ts +128 -0
  195. package/src/accessor/index.ts +15 -0
  196. package/src/bridge.ts +928 -0
  197. package/src/check-extensions.ts +85 -0
  198. package/src/cli-gltf.ts +339 -0
  199. package/src/data-uri.ts +29 -0
  200. package/src/errors.ts +386 -0
  201. package/src/extract-image-bytes.ts +343 -0
  202. package/src/gltf-importer.ts +976 -0
  203. package/src/image-color-space.ts +104 -0
  204. package/src/importer-entry.ts +24 -0
  205. package/src/index.ts +148 -0
  206. package/src/meshopt-decode.ts +200 -0
  207. package/src/node-file-entry.ts +98 -0
  208. package/src/node-path.ts +41 -0
  209. package/src/parse-animation.ts +286 -0
  210. package/src/parse-glb-chunks.ts +167 -0
  211. package/src/parse-glb.ts +16 -0
  212. package/src/parse-gltf-header.ts +58 -0
  213. package/src/parse-gltf.ts +1731 -0
  214. package/src/parse-skin.ts +238 -0
  215. package/src/reimport-reuse-meta.ts +134 -0
  216. package/src/serialize-meta.ts +24 -0
  217. package/src/source-key.ts +106 -0
  218. package/src/sub-asset-key.ts +61 -0
  219. package/src/to-asset-pack.ts +8 -0
  220. package/src/transform.ts +117 -0
@@ -0,0 +1,238 @@
1
+ // pick-e2e.integration.test.ts — glTF end-to-end pick probe over pack JSON roundtrip
2
+ // (bug-20260706-import-path-meshasset-aabb-undefined-blocks-pick M5 / m5-1).
3
+ //
4
+ // Verifies the full chain: GltfMeshIr → meshIrToMeshAsset (producer) →
5
+ // packMeshBin (JSON tail encode) → explicit JSON.stringify → JSON.parse
6
+ // roundtrip (simulating dev-server pack transport) → aabb reconstruction
7
+ // (Float32Array from plain array, the loader dual-contract path) →
8
+ // World.allocSharedRef + spawn (MeshFilter / MeshRenderer / Transform) →
9
+ // pick() hit assertion + negative-control miss.
10
+ //
11
+ // Falsification drill (plan-strategy section 5.4) EXECUTED:
12
+ // (1) Commented out `aabb: box3.fromPositions(box3.create(), positionsCat)`
13
+ // in packages/gltf/src/bridge.ts:329.
14
+ // (2) Ran this test → FAIL: pick() returned undefined (mesh entity had
15
+ // aabb === undefined, broad-phase `continue` skipped it).
16
+ // Confirmed the probe is sensitive to the aabb variable — not
17
+ // unconditionally green.
18
+ // (3) Restored the aabb emit line.
19
+ // (4) Re-ran → PASS.
20
+ // Drill result: probe correctly detects aabb absence. (Recorded inline
21
+ // in this file header as required by plan subsection 5.4.)
22
+ //
23
+ // AC-03 hard constraint: dawn-node smoke does NOT count — this vitest
24
+ // integration test explicitly exercises the pack JSON roundtrip path that
25
+ // dawn smoke skips. Reviewer can grep for `JSON.stringify` /
26
+ // `JSON.parse` in this file to confirm.
27
+ //
28
+ // References: plan-strategy D-3 / R-6; pick-tile.test.ts (no-GPU pick
29
+ // test paradigm); cli-gltf.integration.test.ts (gltf integration test
30
+ // fixture pattern).
31
+
32
+ import { World } from '@forgeax/engine-ecs';
33
+ import { packMeshBinV4 } from '@forgeax/engine-import';
34
+ import { decodeMeshBinHeader } from '@forgeax/engine-pack';
35
+ import { pick } from '@forgeax/engine-picking';
36
+ import { Camera, Materials, MeshFilter, MeshRenderer, perspective } from '@forgeax/engine-render';
37
+ import { Transform } from '@forgeax/engine-scene';
38
+ import type { Handle, MaterialAsset, MeshAsset } from '@forgeax/engine-types';
39
+ import { describe, expect, it } from 'vitest';
40
+ import type { GltfMeshIr } from '../parse-gltf.js';
41
+ import { unwrapMeshAsset as meshIrToMeshAsset } from './bridge-test-helpers.js';
42
+
43
+ // ---- helpers ----
44
+
45
+ /** Convert a branded handle to the raw u32 used by allocSharedRef return. */
46
+ function asHandle<T extends string>(n: number): Handle<T, 'shared'> {
47
+ return n as unknown as Handle<T, 'shared'>;
48
+ }
49
+
50
+ // ---- test ----
51
+
52
+ describe('gltf e2e pick probe over pack JSON roundtrip (m5-1)', () => {
53
+ it('meshIrToMeshAsset → packMeshBin → JSON roundtrip → pick hit + negative-control miss', () => {
54
+ // --- 1. GltfMeshIr fixture: single triangle at Z=-3, XY symmetric ---
55
+ const prim: GltfMeshIr = {
56
+ positions: new Float32Array([-1, -1, -3, 1, -1, -3, 0, 1, -3]),
57
+ indices: new Uint16Array([0, 1, 2]),
58
+ materialIndex: 0,
59
+ meshIndex: 0,
60
+ };
61
+
62
+ // --- 2. Producer: meshIrToMeshAsset emits aabb (M4) ---
63
+ const meshAsset = meshIrToMeshAsset([prim]);
64
+ expect(meshAsset.aabb).toBeInstanceOf(Float32Array);
65
+ expect(meshAsset.aabb).toHaveLength(6);
66
+ // Min of positions: (-1, -1, -3); max: (1, 1, -3)
67
+ expect(meshAsset.aabb?.[0]).toBe(-1);
68
+ expect(meshAsset.aabb?.[1]).toBe(-1);
69
+ expect(meshAsset.aabb?.[2]).toBe(-3);
70
+ expect(meshAsset.aabb?.[3]).toBe(1);
71
+ expect(meshAsset.aabb?.[4]).toBe(1);
72
+ expect(meshAsset.aabb?.[5]).toBe(-3);
73
+
74
+ // --- 3. Encode via packMeshBin: converts aabb Float32Array to plain
75
+ // array in the JSON tail (Array.from at import/src/mesh-bin.ts:153) ---
76
+ const packed = packMeshBinV4(
77
+ {
78
+ vertices: meshAsset.vertices,
79
+ indices: meshAsset.indices,
80
+ submeshes: meshAsset.submeshes,
81
+ aabb: meshAsset.aabb,
82
+ attributes: meshAsset.attributes,
83
+ },
84
+ 'gltf://pick',
85
+ );
86
+ expect(packed.ok).toBe(true);
87
+ if (!packed.ok) return;
88
+ const bin = packed.value;
89
+
90
+ const header = decodeMeshBinHeader(bin, 'gltf://pick');
91
+ expect(header.ok).toBe(true);
92
+ if (!header.ok) return;
93
+ const jsonOffset = 80 + header.value.vertexBytes + header.value.indexBytes;
94
+ const jsonBytes = bin.slice(jsonOffset, jsonOffset + header.value.jsonBytes);
95
+ const metaRaw = new TextDecoder().decode(jsonBytes);
96
+
97
+ // Verify aabb is encoded as a plain number array in the JSON tail.
98
+ const metaParsed = JSON.parse(metaRaw) as { aabb?: number[] };
99
+ expect(Array.isArray(metaParsed.aabb)).toBe(true);
100
+ expect(metaParsed.aabb).toHaveLength(6);
101
+
102
+ // --- 4. Explicit JSON roundtrip: simulate dev-server pack transport ---
103
+ // AC-03 / D-3: the test MUST contain explicit JSON.stringify → JSON.parse
104
+ // that mirrors the fetch-based transport path dawn smoke skips.
105
+ const transportStr = JSON.stringify(metaParsed);
106
+ const transportParsed = JSON.parse(transportStr) as { aabb?: number[] };
107
+ expect(Array.isArray(transportParsed.aabb)).toBe(true);
108
+ // Values survive the roundtrip intact.
109
+ expect(transportParsed.aabb?.[0]).toBe(-1);
110
+ expect(transportParsed.aabb?.[1]).toBe(-1);
111
+ expect(transportParsed.aabb?.[2]).toBe(-3);
112
+ expect(transportParsed.aabb?.[3]).toBe(1);
113
+ expect(transportParsed.aabb?.[4]).toBe(1);
114
+ expect(transportParsed.aabb?.[5]).toBe(-3);
115
+
116
+ // --- 5. Reconstruct aabb as the runtime loader would ---
117
+ // loader dual-contract: plain array → new Float32Array(array)
118
+ // (runtime/src/mesh-bin.ts:116: `aabb = new Float32Array(parsed.aabb)`)
119
+ const roundtrippedAabb = new Float32Array(transportParsed.aabb as number[]);
120
+ expect(roundtrippedAabb).toHaveLength(6);
121
+ expect(roundtrippedAabb[0]).toBe(-1);
122
+ expect(roundtrippedAabb[5]).toBe(-3);
123
+
124
+ // --- 6. World setup ---
125
+ const world = new World();
126
+
127
+ // Material for MeshRenderer (any visible material; pick only needs
128
+ // the MeshFilter + MeshRenderer presence on the archetype).
129
+ const unlitMat = Materials.unlit([1, 1, 1, 1]) as unknown as MaterialAsset;
130
+ const unlitHandle = asHandle<'MaterialAsset'>(world.allocSharedRef('MaterialAsset', unlitMat));
131
+
132
+ // Reconstruct the MeshAsset payload with the roundtripped aabb and
133
+ // original typed arrays. Use a cast since exactOptionalPropertyTypes
134
+ // forbids spreading optional fields that resolve to undefined.
135
+ const meshPayload = {
136
+ kind: 'mesh' as const,
137
+ vertices: meshAsset.vertices,
138
+ ...(meshAsset.indices !== undefined ? { indices: meshAsset.indices } : {}),
139
+ submeshes: meshAsset.submeshes,
140
+ aabb: roundtrippedAabb,
141
+ attributes: meshAsset.attributes,
142
+ } as MeshAsset;
143
+
144
+ const meshHandle = asHandle<'MeshAsset'>(world.allocSharedRef('MeshAsset', meshPayload));
145
+
146
+ // Mesh entity at origin. Identity Transform → identity world mat4.
147
+ const meshEntity = world
148
+ .spawn(
149
+ { component: Transform, data: {} },
150
+ { component: MeshFilter, data: { assetHandle: meshHandle } },
151
+ { component: MeshRenderer, data: { materials: [unlitHandle] } },
152
+ )
153
+ .unwrap();
154
+
155
+ // Camera entity at origin, looking down -Z (perspective).
156
+ // Identity Transform → identity world mat4 → view = identity.
157
+ const cameraEntity = world
158
+ .spawn(
159
+ { component: Transform, data: {} },
160
+ {
161
+ component: Camera,
162
+ data: perspective({ fov: Math.PI / 4, aspect: 1 }),
163
+ },
164
+ )
165
+ .unwrap();
166
+
167
+ // --- 7. pick hit: screen center (50, 50) on 100x100 → ndc (0, 0) → ray
168
+ // straight down -Z → enters AABB at z=-3 → hit ---
169
+ const hit = pick(world, cameraEntity, 50, 50, 100, 100);
170
+ expect(hit).toBeDefined();
171
+ if (hit) {
172
+ expect(hit.entity).toBe(meshEntity);
173
+ // The entry point should be near the AABB front face at z=-3.
174
+ expect(Math.abs((hit.point[2] as number) - -3)).toBeLessThan(0.1);
175
+ }
176
+
177
+ // --- 8. Negative control: pick bottom-right corner (100, 100) on
178
+ // 100x100 → ndc (1, -1) → ray at Z=-3 lands at (x≈1.24, y≈-1.24)
179
+ // which is outside the AABB x-range [-1, 1] → miss ---
180
+ const miss = pick(world, cameraEntity, 100, 100, 100, 100);
181
+ expect(miss).toBeUndefined();
182
+ });
183
+
184
+ it('mesh without aabb: pick() returns undefined (falsification witness)', () => {
185
+ // Create a MeshAsset payload that explicitly omits aabb, verifying
186
+ // that pick() skips it at the broad-phase check (pick.ts:184).
187
+ // This is a secondary in-code falsification — it repeats the
188
+ // comment-out-drill semantics without modifying M4 production code.
189
+ const world = new World();
190
+
191
+ const unlitMat = Materials.unlit([1, 1, 1, 1]) as unknown as MaterialAsset;
192
+ const unlitHandle = asHandle<'MaterialAsset'>(world.allocSharedRef('MaterialAsset', unlitMat));
193
+
194
+ const noAabbMesh = {
195
+ kind: 'mesh' as const,
196
+ vertices: new Float32Array([-1, -1, -3, 1, -1, -3, 0, 1, -3]),
197
+ indices: new Uint16Array([0, 1, 2]),
198
+ submeshes: [
199
+ {
200
+ indexOffset: 0,
201
+ indexCount: 3,
202
+ vertexCount: 3,
203
+ topology: 'triangle-list' as const,
204
+ materialSlot: 0,
205
+ },
206
+ ],
207
+ materialSlots: [{ slotName: 'Default' }],
208
+ attributes: {},
209
+ } satisfies MeshAsset;
210
+
211
+ const noAabbHandle = asHandle<'MeshAsset'>(world.allocSharedRef('MeshAsset', noAabbMesh));
212
+
213
+ world
214
+ .spawn(
215
+ { component: Transform, data: {} },
216
+ { component: MeshFilter, data: { assetHandle: noAabbHandle } },
217
+ {
218
+ component: MeshRenderer,
219
+ data: { materials: [unlitHandle] },
220
+ },
221
+ )
222
+ .unwrap();
223
+
224
+ const cameraEntity = world
225
+ .spawn(
226
+ { component: Transform, data: {} },
227
+ {
228
+ component: Camera,
229
+ data: perspective({ fov: Math.PI / 4, aspect: 1 }),
230
+ },
231
+ )
232
+ .unwrap();
233
+
234
+ // Same center ray that would hit if aabb were present.
235
+ const miss = pick(world, cameraEntity, 50, 50, 100, 100);
236
+ expect(miss).toBeUndefined();
237
+ });
238
+ });
@@ -0,0 +1,10 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import * as publicSurface from '../index';
3
+
4
+ describe('gltf public surface', () => {
5
+ test('keeps parser/importer front doors without a version mirror', () => {
6
+ expect(typeof publicSurface.parseGltf).toBe('function');
7
+ expect(typeof publicSurface.gltfImporter).toBe('object');
8
+ expect('GLTF_PACKAGE_VERSION' in publicSurface).toBe(false);
9
+ });
10
+ });
@@ -0,0 +1,140 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { describe, expect, it } from 'vitest';
6
+ import { runCliGltf } from '../cli-gltf.js';
7
+ import { parseGlb, toAssetPack } from '../parse-gltf.js';
8
+
9
+ const GLB_MAGIC = 0x46546c67;
10
+ const GLB_JSON_CHUNK = 0x4e4f534a;
11
+
12
+ function meshGlb(names: readonly (string | null)[]): Uint8Array {
13
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
14
+ const json = {
15
+ asset: { version: '2.0' },
16
+ scene: 0,
17
+ scenes: [{ name: 'Scene', nodes: names.map((_, index) => index) }],
18
+ nodes: names.map((_, index) => ({ name: `Node${index}`, mesh: index })),
19
+ meshes: names.map((name) => ({
20
+ ...(name === null ? {} : { name }),
21
+ primitives: [{ attributes: { POSITION: 0 } }],
22
+ })),
23
+ accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }],
24
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: positions.byteLength }],
25
+ buffers: [{ byteLength: positions.byteLength }],
26
+ };
27
+ const encodedJson = new TextEncoder().encode(JSON.stringify(json));
28
+ const jsonLength = Math.ceil(encodedJson.byteLength / 4) * 4;
29
+ const binLength = Math.ceil(positions.byteLength / 4) * 4;
30
+ const bytes = new Uint8Array(12 + 8 + jsonLength + 8 + binLength);
31
+ const view = new DataView(bytes.buffer);
32
+ view.setUint32(0, GLB_MAGIC, true);
33
+ view.setUint32(4, 2, true);
34
+ view.setUint32(8, bytes.byteLength, true);
35
+ view.setUint32(12, jsonLength, true);
36
+ view.setUint32(16, GLB_JSON_CHUNK, true);
37
+ bytes.fill(0x20, 20, 20 + jsonLength);
38
+ bytes.set(encodedJson, 20);
39
+ const binHeader = 20 + jsonLength;
40
+ view.setUint32(binHeader, binLength, true);
41
+ view.setUint32(binHeader + 4, 0x004e4942, true);
42
+ bytes.set(new Uint8Array(positions.buffer), binHeader + 8);
43
+ return bytes;
44
+ }
45
+
46
+ function digest(bytes: Uint8Array): string {
47
+ return createHash('sha256').update(bytes).digest('hex');
48
+ }
49
+
50
+ async function directError(bytes: Uint8Array, source: string): Promise<unknown> {
51
+ const parsed = await parseGlb(bytes.buffer as ArrayBuffer, source);
52
+ expect(parsed.ok).toBe(true);
53
+ if (!parsed.ok) return undefined;
54
+ const packed = toAssetPack(parsed.value, undefined, source);
55
+ expect(packed.ok).toBe(false);
56
+ return packed.ok ? undefined : packed.error;
57
+ }
58
+
59
+ describe('glTF source-key diagnostics', () => {
60
+ it.each([
61
+ {
62
+ label: 'named duplicate',
63
+ names: ['Hero', 'Hero'],
64
+ expected: {
65
+ code: 'duplicate-source-key',
66
+ detail: {
67
+ key: 'mesh:Hero',
68
+ sourceIndices: [0, 1],
69
+ entries: [
70
+ { kind: 'mesh', name: 'Hero', sourceIndex: 0 },
71
+ { kind: 'mesh', name: 'Hero', sourceIndex: 1 },
72
+ ],
73
+ },
74
+ },
75
+ },
76
+ {
77
+ label: 'anonymous ambiguity',
78
+ names: [null, null],
79
+ expected: {
80
+ code: 'ambiguous-source-key',
81
+ detail: {
82
+ key: 'mesh',
83
+ sourceIndices: [0, 1],
84
+ entries: [
85
+ { kind: 'mesh', name: null, sourceIndex: 0 },
86
+ { kind: 'mesh', name: null, sourceIndex: 1 },
87
+ ],
88
+ },
89
+ },
90
+ },
91
+ ])('keeps direct and CLI $label errors identical without mutating the GLB', async (fixture) => {
92
+ const tempDir = await mkdtemp(join(tmpdir(), 'gltf-source-key-detail-'));
93
+ try {
94
+ const sourcePath = join(tempDir, `${fixture.label.replaceAll(' ', '-')}.glb`);
95
+ const bytes = meshGlb(fixture.names);
96
+ const before = digest(bytes);
97
+ await writeFile(sourcePath, bytes);
98
+
99
+ const direct = await directError(bytes, sourcePath);
100
+ expect(direct).toMatchObject(fixture.expected);
101
+
102
+ const stderr: string[] = [];
103
+ const exitCode = await runCliGltf(['import', sourcePath], {
104
+ stdoutWrite: () => {},
105
+ stderrWrite: (line) => stderr.push(line),
106
+ });
107
+ expect(exitCode).toBe(1);
108
+ const cli = JSON.parse(stderr[0] as string) as unknown;
109
+ expect(cli).toEqual(direct);
110
+ await expect(access(`${sourcePath}.meta.json`)).rejects.toThrow();
111
+ expect(digest(await readFile(sourcePath))).toBe(before);
112
+ } finally {
113
+ await rm(tempDir, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ it('continues to write a sibling meta for a valid GLB without mutating its source', async () => {
118
+ const tempDir = await mkdtemp(join(tmpdir(), 'gltf-source-key-success-'));
119
+ try {
120
+ const sourcePath = join(tempDir, 'unique.glb');
121
+ const bytes = meshGlb(['HeroA', 'HeroB']);
122
+ const before = digest(bytes);
123
+ await writeFile(sourcePath, bytes);
124
+
125
+ const exitCode = await runCliGltf(['import', sourcePath], {
126
+ stdoutWrite: () => {},
127
+ stderrWrite: () => {},
128
+ });
129
+
130
+ expect(exitCode).toBe(0);
131
+ expect(JSON.parse(await readFile(`${sourcePath}.meta.json`, 'utf-8'))).toMatchObject({
132
+ importer: 'gltf',
133
+ source: 'unique.glb',
134
+ });
135
+ expect(digest(await readFile(sourcePath))).toBe(before);
136
+ } finally {
137
+ await rm(tempDir, { recursive: true, force: true });
138
+ }
139
+ });
140
+ });
@@ -0,0 +1,171 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { validateProducerOutputs } from '@forgeax/engine-pack';
5
+ import { scan } from '@forgeax/engine-pack/scanner';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { runCliGltf } from '../cli-gltf.js';
8
+ import { createGltfImporter } from '../gltf-importer.js';
9
+ import type { GltfDoc } from '../parse-gltf.js';
10
+ import { toAssetPack } from '../parse-gltf.js';
11
+ import { serializeMetaJson } from '../serialize-meta.js';
12
+
13
+ function duplicateNamedMeshDoc(): GltfDoc {
14
+ return {
15
+ meshes: [
16
+ { name: 'Hero', positions: new Float32Array(), materialIndex: null, meshIndex: 0 },
17
+ { name: 'Hero', positions: new Float32Array(), materialIndex: null, meshIndex: 1 },
18
+ ],
19
+ materials: [],
20
+ nodes: [],
21
+ scenes: [],
22
+ textures: undefined,
23
+ images: undefined,
24
+ samplers: undefined,
25
+ skeletons: [],
26
+ animationClips: [],
27
+ defaultSceneIndex: 0,
28
+ diagnostics: { nodeNames: [], unsupportedExtensions: [], matrixTrsCoexistNodes: [] },
29
+ };
30
+ }
31
+
32
+ function uniqueNamedMeshDoc(): GltfDoc {
33
+ const doc = duplicateNamedMeshDoc();
34
+ return {
35
+ ...doc,
36
+ meshes: doc.meshes.map((mesh, index) => ({ ...mesh, name: `Hero${index}` })),
37
+ };
38
+ }
39
+
40
+ function duplicateNamedMaterialDoc(): GltfDoc {
41
+ const material = {
42
+ name: 'Window',
43
+ baseColorFactor: [1, 1, 1, 1] as const,
44
+ metallicFactor: 0,
45
+ roughnessFactor: 1,
46
+ };
47
+ return {
48
+ ...duplicateNamedMeshDoc(),
49
+ meshes: [],
50
+ materials: [material, material],
51
+ };
52
+ }
53
+
54
+ function duplicateNamedMeshGltfJson(): Record<string, unknown> {
55
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
56
+ const bufferUri = `data:application/octet-stream;base64,${Buffer.from(positions.buffer).toString('base64')}`;
57
+ return {
58
+ asset: { version: '2.0' },
59
+ scene: 0,
60
+ scenes: [{ name: 'Scene', nodes: [0, 1] }],
61
+ nodes: [
62
+ { name: 'A', mesh: 0 },
63
+ { name: 'B', mesh: 1 },
64
+ ],
65
+ meshes: [
66
+ { name: 'Hero', primitives: [{ attributes: { POSITION: 0 } }] },
67
+ { name: 'Hero', primitives: [{ attributes: { POSITION: 0 } }] },
68
+ ],
69
+ accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }],
70
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: positions.byteLength }],
71
+ buffers: [{ byteLength: positions.byteLength, uri: bufferUri }],
72
+ };
73
+ }
74
+
75
+ describe('glTF producer source-key boundary', () => {
76
+ it('keeps procedural runtime stubs out of the Catalog projection', () => {
77
+ const publish = createGltfImporter().capabilities?.catalog?.publish;
78
+ expect(publish).toBeTypeOf('function');
79
+ expect(publish?.({ importSettings: { geometry: 'procedural' }, subAssets: [] })).toBe(false);
80
+ expect(publish?.({ importSettings: {}, subAssets: [] })).toBe(true);
81
+ });
82
+
83
+ it('returns a structured conflict before publishing duplicate semantic names', () => {
84
+ const result = toAssetPack(duplicateNamedMeshDoc(), undefined, 'duplicate.gltf');
85
+
86
+ expect(result.ok).toBe(false);
87
+ if (result.ok) return;
88
+ expect(result.error.code).toBe('duplicate-source-key');
89
+ expect(result.error.detail.sourceIndices).toEqual([0, 1]);
90
+ });
91
+
92
+ it('applies the same preflight to duplicate material names', () => {
93
+ const result = toAssetPack(duplicateNamedMaterialDoc(), undefined, 'duplicate.gltf');
94
+
95
+ expect(result.ok).toBe(false);
96
+ if (result.ok) return;
97
+ expect(result.error.code).toBe('duplicate-source-key');
98
+ expect(result.error.detail.sourceIndices).toEqual([0, 1]);
99
+ });
100
+
101
+ it('publishes producer-valid output when semantic identity is unique', () => {
102
+ const result = toAssetPack(uniqueNamedMeshDoc(), undefined, 'unique.gltf');
103
+
104
+ expect(result.ok).toBe(true);
105
+ if (!result.ok) return;
106
+ expect(validateProducerOutputs(result.value.subAssets).ok).toBe(true);
107
+ });
108
+
109
+ it('serializes a valid output that the pack scanner accepts immediately', async () => {
110
+ const tempDir = await mkdtemp(join(tmpdir(), 'gltf-source-key-'));
111
+ try {
112
+ const result = toAssetPack(uniqueNamedMeshDoc(), undefined, 'unique.gltf');
113
+ expect(result.ok).toBe(true);
114
+ if (!result.ok) return;
115
+ await writeFile(join(tempDir, 'unique.gltf'), '{}', 'utf-8');
116
+ await writeFile(
117
+ join(tempDir, 'unique.gltf.meta.json'),
118
+ serializeMetaJson(result.value.meta),
119
+ 'utf-8',
120
+ );
121
+
122
+ const scanResult = await scan([tempDir]);
123
+ expect(scanResult.ok).toBe(true);
124
+ } finally {
125
+ await rm(tempDir, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ it('fails before writing and preserves the previous sidecar', async () => {
130
+ const tempDir = await mkdtemp(join(tmpdir(), 'gltf-cli-source-key-'));
131
+ try {
132
+ const sourcePath = join(tempDir, 'duplicate.gltf');
133
+ const metaPath = `${sourcePath}.meta.json`;
134
+ const previous = serializeMetaJson({
135
+ schemaVersion: 1,
136
+ kind: 'external-asset-package',
137
+ importer: 'gltf',
138
+ source: 'duplicate.gltf',
139
+ subAssets: [
140
+ {
141
+ guid: '01928000-7c00-7000-8000-000000000001',
142
+ sourceIndex: 0,
143
+ kind: 'mesh',
144
+ sourceKey: 'mesh:previous',
145
+ },
146
+ ],
147
+ importSettings: {
148
+ defaultSceneIndex: 0,
149
+ diagnostics: { nodeNames: [], unsupportedExtensions: [], matrixTrsCoexistNodes: [] },
150
+ },
151
+ });
152
+ await writeFile(sourcePath, JSON.stringify(duplicateNamedMeshGltfJson()), 'utf-8');
153
+ await writeFile(metaPath, previous, 'utf-8');
154
+ const stderr: string[] = [];
155
+
156
+ const code = await runCliGltf(['import', sourcePath], {
157
+ stdoutWrite: () => {},
158
+ stderrWrite: (line) => stderr.push(line),
159
+ });
160
+
161
+ expect(code).toBe(1);
162
+ expect(JSON.parse(stderr[0] as string)).toMatchObject({
163
+ code: 'duplicate-source-key',
164
+ detail: { sourceIndices: [0, 1] },
165
+ });
166
+ expect(await readFile(metaPath, 'utf-8')).toBe(previous);
167
+ } finally {
168
+ await rm(tempDir, { recursive: true, force: true });
169
+ }
170
+ });
171
+ });