@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
@@ -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,163 @@
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 type { GltfDoc } from '../parse-gltf.js';
9
+ import { toAssetPack } from '../parse-gltf.js';
10
+ import { serializeMetaJson } from '../serialize-meta.js';
11
+
12
+ function duplicateNamedMeshDoc(): GltfDoc {
13
+ return {
14
+ meshes: [
15
+ { name: 'Hero', positions: new Float32Array(), materialIndex: null, meshIndex: 0 },
16
+ { name: 'Hero', positions: new Float32Array(), materialIndex: null, meshIndex: 1 },
17
+ ],
18
+ materials: [],
19
+ nodes: [],
20
+ scenes: [],
21
+ textures: undefined,
22
+ images: undefined,
23
+ samplers: undefined,
24
+ skeletons: [],
25
+ animationClips: [],
26
+ defaultSceneIndex: 0,
27
+ diagnostics: { nodeNames: [], unsupportedExtensions: [], matrixTrsCoexistNodes: [] },
28
+ };
29
+ }
30
+
31
+ function uniqueNamedMeshDoc(): GltfDoc {
32
+ const doc = duplicateNamedMeshDoc();
33
+ return {
34
+ ...doc,
35
+ meshes: doc.meshes.map((mesh, index) => ({ ...mesh, name: `Hero${index}` })),
36
+ };
37
+ }
38
+
39
+ function duplicateNamedMaterialDoc(): GltfDoc {
40
+ const material = {
41
+ name: 'Window',
42
+ baseColorFactor: [1, 1, 1, 1] as const,
43
+ metallicFactor: 0,
44
+ roughnessFactor: 1,
45
+ };
46
+ return {
47
+ ...duplicateNamedMeshDoc(),
48
+ meshes: [],
49
+ materials: [material, material],
50
+ };
51
+ }
52
+
53
+ function duplicateNamedMeshGltfJson(): Record<string, unknown> {
54
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
55
+ const bufferUri = `data:application/octet-stream;base64,${Buffer.from(positions.buffer).toString('base64')}`;
56
+ return {
57
+ asset: { version: '2.0' },
58
+ scene: 0,
59
+ scenes: [{ name: 'Scene', nodes: [0, 1] }],
60
+ nodes: [
61
+ { name: 'A', mesh: 0 },
62
+ { name: 'B', mesh: 1 },
63
+ ],
64
+ meshes: [
65
+ { name: 'Hero', primitives: [{ attributes: { POSITION: 0 } }] },
66
+ { name: 'Hero', primitives: [{ attributes: { POSITION: 0 } }] },
67
+ ],
68
+ accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }],
69
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: positions.byteLength }],
70
+ buffers: [{ byteLength: positions.byteLength, uri: bufferUri }],
71
+ };
72
+ }
73
+
74
+ describe('glTF producer source-key boundary', () => {
75
+ it('returns a structured conflict before publishing duplicate semantic names', () => {
76
+ const result = toAssetPack(duplicateNamedMeshDoc(), undefined, 'duplicate.gltf');
77
+
78
+ expect(result.ok).toBe(false);
79
+ if (result.ok) return;
80
+ expect(result.error.code).toBe('duplicate-source-key');
81
+ expect(result.error.sourceIndices).toEqual([0, 1]);
82
+ });
83
+
84
+ it('applies the same preflight to duplicate material names', () => {
85
+ const result = toAssetPack(duplicateNamedMaterialDoc(), undefined, 'duplicate.gltf');
86
+
87
+ expect(result.ok).toBe(false);
88
+ if (result.ok) return;
89
+ expect(result.error.code).toBe('duplicate-source-key');
90
+ expect(result.error.sourceIndices).toEqual([0, 1]);
91
+ });
92
+
93
+ it('publishes producer-valid output when semantic identity is unique', () => {
94
+ const result = toAssetPack(uniqueNamedMeshDoc(), undefined, 'unique.gltf');
95
+
96
+ expect(result.ok).toBe(true);
97
+ if (!result.ok) return;
98
+ expect(validateProducerOutputs(result.value.subAssets).ok).toBe(true);
99
+ });
100
+
101
+ it('serializes a valid output that the pack scanner accepts immediately', async () => {
102
+ const tempDir = await mkdtemp(join(tmpdir(), 'gltf-source-key-'));
103
+ try {
104
+ const result = toAssetPack(uniqueNamedMeshDoc(), undefined, 'unique.gltf');
105
+ expect(result.ok).toBe(true);
106
+ if (!result.ok) return;
107
+ await writeFile(join(tempDir, 'unique.gltf'), '{}', 'utf-8');
108
+ await writeFile(
109
+ join(tempDir, 'unique.gltf.meta.json'),
110
+ serializeMetaJson(result.value.meta),
111
+ 'utf-8',
112
+ );
113
+
114
+ const scanResult = await scan([tempDir]);
115
+ expect(scanResult.ok).toBe(true);
116
+ } finally {
117
+ await rm(tempDir, { recursive: true, force: true });
118
+ }
119
+ });
120
+
121
+ it('fails before writing and preserves the previous sidecar', async () => {
122
+ const tempDir = await mkdtemp(join(tmpdir(), 'gltf-cli-source-key-'));
123
+ try {
124
+ const sourcePath = join(tempDir, 'duplicate.gltf');
125
+ const metaPath = `${sourcePath}.meta.json`;
126
+ const previous = serializeMetaJson({
127
+ schemaVersion: 1,
128
+ kind: 'external-asset-package',
129
+ importer: 'gltf',
130
+ source: 'duplicate.gltf',
131
+ subAssets: [
132
+ {
133
+ guid: '01928000-7c00-7000-8000-000000000001',
134
+ sourceIndex: 0,
135
+ kind: 'mesh',
136
+ sourceKey: 'mesh:previous',
137
+ },
138
+ ],
139
+ importSettings: {
140
+ defaultSceneIndex: 0,
141
+ diagnostics: { nodeNames: [], unsupportedExtensions: [], matrixTrsCoexistNodes: [] },
142
+ },
143
+ });
144
+ await writeFile(sourcePath, JSON.stringify(duplicateNamedMeshGltfJson()), 'utf-8');
145
+ await writeFile(metaPath, previous, 'utf-8');
146
+ const stderr: string[] = [];
147
+
148
+ const code = await runCliGltf(['import', sourcePath], {
149
+ stdoutWrite: () => {},
150
+ stderrWrite: (line) => stderr.push(line),
151
+ });
152
+
153
+ expect(code).toBe(1);
154
+ expect(JSON.parse(stderr[0] as string)).toMatchObject({
155
+ code: 'duplicate-source-key',
156
+ detail: { sourceIndices: [0, 1] },
157
+ });
158
+ expect(await readFile(metaPath, 'utf-8')).toBe(previous);
159
+ } finally {
160
+ await rm(tempDir, { recursive: true, force: true });
161
+ }
162
+ });
163
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest';
2
+ import { type GltfMetaJson, reimportReuseMeta } from '../reimport-reuse-meta.js';
3
+ import {
4
+ deriveGltfSourceKeys,
5
+ type GltfSourceKeyError,
6
+ type GltfSourceKeyErrorCode,
7
+ } from '../source-key.js';
8
+
9
+ const META_BASE: GltfMetaJson = {
10
+ schemaVersion: 1,
11
+ kind: 'external-asset-package',
12
+ importer: 'gltf',
13
+ source: 'scene.gltf',
14
+ subAssets: [],
15
+ importSettings: {
16
+ defaultSceneIndex: 0,
17
+ diagnostics: { nodeNames: [], unsupportedExtensions: [], matrixTrsCoexistNodes: [] },
18
+ },
19
+ };
20
+
21
+ describe('glTF producer sourceKey', () => {
22
+ it('derives the public error-code alias from its error owner', () => {
23
+ expectTypeOf<GltfSourceKeyErrorCode>().toEqualTypeOf<GltfSourceKeyError['code']>();
24
+ });
25
+
26
+ it('derives stable semantic keys without using sourceIndex or locator', () => {
27
+ const result = deriveGltfSourceKeys([
28
+ { kind: 'mesh', sourceIndex: 4, name: 'Hero' },
29
+ { kind: 'material', sourceIndex: 0, name: 'Skin' },
30
+ ]);
31
+ expect(result).toEqual({ ok: true, keys: ['mesh:Hero', 'material:Skin'], conflicts: [] });
32
+ });
33
+
34
+ it('reports duplicate semantic names as a structured conflict', () => {
35
+ const result = deriveGltfSourceKeys([
36
+ { kind: 'mesh', sourceIndex: 0, name: 'Hero' },
37
+ { kind: 'mesh', sourceIndex: 1, name: 'Hero' },
38
+ ]);
39
+ expect(result.ok).toBe(false);
40
+ if (!result.ok) expect(result.error.code).toBe('duplicate-source-key');
41
+ });
42
+
43
+ it('uses the semantic kind for one anonymous output, never sourceIndex', () => {
44
+ const result = deriveGltfSourceKeys([{ kind: 'mesh', sourceIndex: 7 }]);
45
+ expect(result).toEqual({ ok: true, keys: ['mesh'], conflicts: [] });
46
+ });
47
+
48
+ it('persists an output display name in the generated sub-asset entry', () => {
49
+ const result = reimportReuseMeta([{ kind: 'mesh', sourceIndex: 0, name: 'Hero' }], undefined);
50
+ expect(result.ok).toBe(true);
51
+ if (!result.ok) return;
52
+ expect(result.value.subAssets[0]).toMatchObject({
53
+ kind: 'mesh',
54
+ sourceKey: 'mesh:Hero',
55
+ name: 'Hero',
56
+ });
57
+ });
58
+
59
+ it('rejects two anonymous outputs of one kind as ambiguous', () => {
60
+ const result = deriveGltfSourceKeys([
61
+ { kind: 'mesh', sourceIndex: 7 },
62
+ { kind: 'mesh', sourceIndex: 9 },
63
+ ]);
64
+ expect(result.ok).toBe(false);
65
+ if (!result.ok) expect(result.error.code).toBe('ambiguous-source-key');
66
+ });
67
+
68
+ it('preserves keyed identity when output order changes', () => {
69
+ const existing: GltfMetaJson = {
70
+ ...META_BASE,
71
+ subAssets: [
72
+ {
73
+ guid: '01928000-7c00-7000-8000-000000000001',
74
+ sourceIndex: 0,
75
+ kind: 'mesh',
76
+ sourceKey: 'mesh:Hero',
77
+ },
78
+ {
79
+ guid: '01928000-7c00-7000-8000-000000000002',
80
+ sourceIndex: 1,
81
+ kind: 'mesh',
82
+ sourceKey: 'mesh:Body',
83
+ },
84
+ ],
85
+ };
86
+ const result = reimportReuseMeta(
87
+ [
88
+ { kind: 'mesh', sourceIndex: 0, name: 'Body' },
89
+ { kind: 'mesh', sourceIndex: 1, name: 'Hero' },
90
+ ],
91
+ existing,
92
+ );
93
+ expect(result.ok).toBe(true);
94
+ if (!result.ok) return;
95
+ expect(result.value.subAssets).toMatchObject([
96
+ { guid: '01928000-7c00-7000-8000-000000000002', sourceIndex: 0, sourceKey: 'mesh:Body' },
97
+ { guid: '01928000-7c00-7000-8000-000000000001', sourceIndex: 1, sourceKey: 'mesh:Hero' },
98
+ ]);
99
+ });
100
+
101
+ it('preserves keyed identity when a new output is inserted', () => {
102
+ const existing: GltfMetaJson = {
103
+ ...META_BASE,
104
+ subAssets: [
105
+ {
106
+ guid: '01928000-7c00-7000-8000-000000000004',
107
+ sourceIndex: 0,
108
+ kind: 'mesh',
109
+ sourceKey: 'mesh:Hero',
110
+ },
111
+ {
112
+ guid: '01928000-7c00-7000-8000-000000000005',
113
+ sourceIndex: 1,
114
+ kind: 'mesh',
115
+ sourceKey: 'mesh:Body',
116
+ },
117
+ ],
118
+ };
119
+ const result = reimportReuseMeta(
120
+ [
121
+ { kind: 'mesh', sourceIndex: 0, name: 'New' },
122
+ { kind: 'mesh', sourceIndex: 1, name: 'Hero' },
123
+ { kind: 'mesh', sourceIndex: 2, name: 'Body' },
124
+ ],
125
+ existing,
126
+ );
127
+ expect(result.ok).toBe(true);
128
+ if (!result.ok) return;
129
+ expect(result.value.subAssets.slice(1)).toMatchObject([
130
+ { guid: '01928000-7c00-7000-8000-000000000004', sourceKey: 'mesh:Hero' },
131
+ { guid: '01928000-7c00-7000-8000-000000000005', sourceKey: 'mesh:Body' },
132
+ ]);
133
+ });
134
+
135
+ it('reuses an anonymous semantic kind key when its sourceIndex changes', () => {
136
+ const existing: GltfMetaJson = {
137
+ ...META_BASE,
138
+ subAssets: [
139
+ {
140
+ guid: '01928000-7c00-7000-8000-000000000003',
141
+ sourceIndex: 0,
142
+ kind: 'scene',
143
+ sourceKey: 'scene',
144
+ },
145
+ ],
146
+ };
147
+ const result = reimportReuseMeta([{ kind: 'scene', sourceIndex: 9 }], existing);
148
+ expect(result.ok).toBe(true);
149
+ if (!result.ok) return;
150
+ expect(result.value.subAssets).toEqual([
151
+ {
152
+ guid: '01928000-7c00-7000-8000-000000000003',
153
+ sourceIndex: 9,
154
+ kind: 'scene',
155
+ sourceKey: 'scene',
156
+ },
157
+ ]);
158
+ });
159
+ });