@forgeax/engine-geometry 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 (57) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +225 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/dim2.test.d.ts +2 -0
  5. package/dist/__tests__/dim2.test.d.ts.map +1 -0
  6. package/dist/__tests__/geometry.unit.test.d.ts +2 -0
  7. package/dist/__tests__/geometry.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/vertex-attribute-layout-owner.unit.test.d.ts +2 -0
  9. package/dist/__tests__/vertex-attribute-layout-owner.unit.test.d.ts.map +1 -0
  10. package/dist/assets/mesh-binary.d.ts +4 -0
  11. package/dist/assets/mesh-binary.d.ts.map +1 -0
  12. package/dist/assets/mesh-decoder.d.ts +6 -0
  13. package/dist/assets/mesh-decoder.d.ts.map +1 -0
  14. package/dist/assets/primitive-mesh.d.ts +5 -0
  15. package/dist/assets/primitive-mesh.d.ts.map +1 -0
  16. package/dist/box.d.ts +41 -0
  17. package/dist/box.d.ts.map +1 -0
  18. package/dist/capsule.d.ts +14 -0
  19. package/dist/capsule.d.ts.map +1 -0
  20. package/dist/cone.d.ts +4 -0
  21. package/dist/cone.d.ts.map +1 -0
  22. package/dist/cylinder.d.ts +4 -0
  23. package/dist/cylinder.d.ts.map +1 -0
  24. package/dist/dim2.d.ts +73 -0
  25. package/dist/dim2.d.ts.map +1 -0
  26. package/dist/index.d.ts +14 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.mjs +2000 -0
  29. package/dist/index.mjs.map +1 -0
  30. package/dist/plane.d.ts +4 -0
  31. package/dist/plane.d.ts.map +1 -0
  32. package/dist/sphere.d.ts +4 -0
  33. package/dist/sphere.d.ts.map +1 -0
  34. package/dist/tangent.d.ts +34 -0
  35. package/dist/tangent.d.ts.map +1 -0
  36. package/dist/torus.d.ts +4 -0
  37. package/dist/torus.d.ts.map +1 -0
  38. package/dist/vertex-attribute-layout.d.ts +90 -0
  39. package/dist/vertex-attribute-layout.d.ts.map +1 -0
  40. package/package.json +59 -0
  41. package/src/__tests__/dim2.test.ts +132 -0
  42. package/src/__tests__/geometry.unit.test.ts +1223 -0
  43. package/src/__tests__/vertex-attribute-layout-owner.unit.test.ts +181 -0
  44. package/src/assets/mesh-binary.ts +421 -0
  45. package/src/assets/mesh-decoder.ts +97 -0
  46. package/src/assets/primitive-mesh.ts +36 -0
  47. package/src/box.ts +437 -0
  48. package/src/capsule.ts +145 -0
  49. package/src/cone.ts +26 -0
  50. package/src/cylinder.ts +180 -0
  51. package/src/dim2.ts +505 -0
  52. package/src/index.ts +54 -0
  53. package/src/plane.ts +78 -0
  54. package/src/sphere.ts +82 -0
  55. package/src/tangent.ts +320 -0
  56. package/src/torus.ts +83 -0
  57. package/src/vertex-attribute-layout.ts +503 -0
@@ -0,0 +1,1223 @@
1
+ // @forgeax/engine-geometry unit tests.
2
+ //
3
+ // Extracted from the runtime integration mega-test (feat-20260704-runtime-tier1
4
+ // -decomposition, w6): the geometry-pure describe blocks (procedural factories,
5
+ // computeTangentVec4 helper, winding, UV orientation, VertexAttributeMap
6
+ // narrowing, deriveVertexBufferLayout) that depend only on ecs + types +
7
+ // @forgeax/engine-geometry itself. Assertion logic is copied verbatim (AC-09);
8
+ // only the import sources changed (leaf package must not import runtime -- D-2).
9
+
10
+ import {
11
+ computeTangentVec4,
12
+ createBoxGeometry,
13
+ createCapsuleGeometry,
14
+ createConeGeometry,
15
+ createCylinderGeometry,
16
+ createPlaneGeometry,
17
+ createSphereGeometry,
18
+ createTorusGeometry,
19
+ deriveVertexBufferLayout,
20
+ meshFromInterleaved,
21
+ PROCEDURAL_FLOATS_PER_VERTEX,
22
+ } from '@forgeax/engine-geometry';
23
+ import type { MeshAsset, Result, VertexAttributeMap } from '@forgeax/engine-types';
24
+ import { AssetError } from '@forgeax/engine-types';
25
+ import { describe, expect, it } from 'vitest';
26
+
27
+ {
28
+ // --- from geometry-tangent.test.ts ---
29
+ // Procedural geometry tangent emit tests (M4 / w19).
30
+ //
31
+ // Covers feat-20260518-pbr-direct-lighting-mvp AC-10:
32
+ // 1. The `computeTangentVec4` helper (geometry/tangent.ts) implements the
33
+ // path A formula (UV-derivative + face-area-weighted average +
34
+ // Gram-Schmidt re-orthogonalisation + handedness sign) and outputs
35
+ // vec4 per vertex (.xyz tangent, .w in {+1, -1}).
36
+ // 2. The 6 procedural geometry factories (box / cone / cylinder / plane
37
+ // / sphere / torus) emit `tangent` in their `attributes` map with
38
+ // `length === vertexCount * 4` and per-vertex assertions:
39
+ // (a) tangent attribute exists and is a Float32Array view of length
40
+ // vertexCount * 4
41
+ // (b) tangent.xyz numerical assertion at known-vertex positions
42
+ // (path A predicted value, eps <= 1e-4)
43
+ // (c) handedness .w in {+1, -1} (path A sign(det(deltaUV)))
44
+ // (d) dot(T.xyz, N) eps <= 1e-3 after Gram-Schmidt re-ortho
45
+ //
46
+ // Plan-strategy anchors: section 2 D-2 (path A); D-7 (single-file helper);
47
+ // D-10 (procedural 12 floats vs BUILTIN 6 floats). Requirements anchor:
48
+ // AC-10. Risk anchor: R-4 (path A vec4 forward-compatible with future
49
+ // MikkTSpace baker via `B = cross(N, T.xyz) * T.w`).
50
+
51
+ function unwrap(r: { ok: true; value: MeshAsset } | { ok: false; error: AssetError }): MeshAsset {
52
+ if (!r.ok) throw new Error(`unexpected err: ${r.error.code}`);
53
+ return r.value;
54
+ }
55
+
56
+ function asF32(v: ArrayBuffer | Float32Array | Uint16Array | undefined): Float32Array {
57
+ if (v === undefined) throw new Error('attribute missing');
58
+ if (v instanceof Float32Array) return v;
59
+ if (v instanceof Uint16Array) throw new Error('expected Float32Array, got Uint16Array');
60
+ return new Float32Array(v);
61
+ }
62
+
63
+ function readVec3(arr: Float32Array, vertexIdx: number): [number, number, number] {
64
+ const b = vertexIdx * 3;
65
+ return [arr[b] ?? 0, arr[b + 1] ?? 0, arr[b + 2] ?? 0];
66
+ }
67
+
68
+ function readVec4(arr: Float32Array, vertexIdx: number): [number, number, number, number] {
69
+ const b = vertexIdx * 4;
70
+ return [arr[b] ?? 0, arr[b + 1] ?? 0, arr[b + 2] ?? 0, arr[b + 3] ?? 0];
71
+ }
72
+
73
+ function unwrapTangent(r: Result<Float32Array, AssetError>): Float32Array {
74
+ if (!r.ok) throw new Error(`unexpected tangent error: ${r.error.code}`);
75
+ return r.value;
76
+ }
77
+
78
+ function dot3(a: [number, number, number], b: [number, number, number]): number {
79
+ return (a[0] ?? 0) * (b[0] ?? 0) + (a[1] ?? 0) * (b[1] ?? 0) + (a[2] ?? 0) * (b[2] ?? 0);
80
+ }
81
+
82
+ function len3(a: [number, number, number]): number {
83
+ return Math.sqrt(dot3(a, a));
84
+ }
85
+
86
+ describe('computeTangentVec4 helper (M4 / w20)', () => {
87
+ it('malformed normal cardinality returns a structured failure instead of throwing (M72 red gate)', () => {
88
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
89
+ const malformedNormals = new Float32Array([0, 0, 1, 0, 0, 1]);
90
+ const uvs = new Float32Array([0, 0, 1, 0, 0, 1]);
91
+ const indices = new Uint32Array([0, 1, 2]);
92
+
93
+ expect(() => computeTangentVec4(positions, malformedNormals, uvs, indices)).not.toThrow();
94
+ const result = computeTangentVec4(positions, malformedNormals, uvs, indices);
95
+ expect(result.ok).toBe(false);
96
+ });
97
+
98
+ it('preflights every public tangent topology boundary with the existing AssetError', () => {
99
+ const validPositions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
100
+ const validNormals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
101
+ const validUvs = new Float32Array([0, 0, 1, 0, 0, 1]);
102
+ const cases = [
103
+ {
104
+ field: 'positions',
105
+ positions: new Float32Array(8),
106
+ normals: validNormals,
107
+ uvs: validUvs,
108
+ indices: new Uint32Array([0, 1, 2]),
109
+ },
110
+ {
111
+ field: 'normals',
112
+ positions: validPositions,
113
+ normals: new Float32Array(6),
114
+ uvs: validUvs,
115
+ indices: new Uint32Array([0, 1, 2]),
116
+ },
117
+ {
118
+ field: 'uvs',
119
+ positions: validPositions,
120
+ normals: validNormals,
121
+ uvs: new Float32Array(4),
122
+ indices: new Uint32Array([0, 1, 2]),
123
+ },
124
+ {
125
+ field: 'positions',
126
+ positions: new Float32Array([0, 0, 0, 1, 0, 0]),
127
+ normals: new Float32Array([0, 0, 1, 0, 0, 1]),
128
+ uvs: new Float32Array([0, 0, 1, 0]),
129
+ indices: undefined,
130
+ },
131
+ {
132
+ field: 'indices',
133
+ positions: validPositions,
134
+ normals: validNormals,
135
+ uvs: validUvs,
136
+ indices: new Uint32Array([0, 1]),
137
+ },
138
+ {
139
+ field: 'indices',
140
+ positions: validPositions,
141
+ normals: validNormals,
142
+ uvs: validUvs,
143
+ indices: new Uint32Array([0, 1, 3]),
144
+ },
145
+ ];
146
+
147
+ for (const testCase of cases) {
148
+ const result = computeTangentVec4(
149
+ testCase.positions,
150
+ testCase.normals,
151
+ testCase.uvs,
152
+ testCase.indices,
153
+ );
154
+ expect(result.ok).toBe(false);
155
+ if (!result.ok) {
156
+ expect(result.error).toBeInstanceOf(AssetError);
157
+ expect(result.error.code).toBe('asset-parse-failed');
158
+ expect(result.error.detail).toMatchObject({ field: testCase.field });
159
+ }
160
+ }
161
+ });
162
+
163
+ it('corrected arrays recover on the next call with the established vec4 output', () => {
164
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
165
+ const malformedNormals = new Float32Array([0, 0, 1, 0, 0, 1]);
166
+ const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
167
+ const uvs = new Float32Array([0, 0, 1, 0, 0, 1]);
168
+ const indices = new Uint32Array([0, 1, 2]);
169
+
170
+ const failed = computeTangentVec4(positions, malformedNormals, uvs, indices);
171
+ expect(failed.ok).toBe(false);
172
+ const recovered = computeTangentVec4(positions, normals, uvs, indices);
173
+ expect(recovered.ok).toBe(true);
174
+ if (recovered.ok) {
175
+ expect([...recovered.value]).toEqual([1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1]);
176
+ }
177
+ });
178
+
179
+ it('preflights interleaved vertex and index topology before allocation', () => {
180
+ const malformedVertices = meshFromInterleaved(
181
+ new Float32Array(9),
182
+ new Uint32Array([0, 1, 2]),
183
+ );
184
+ expect(malformedVertices.ok).toBe(false);
185
+ if (!malformedVertices.ok) {
186
+ expect(malformedVertices.error).toBeInstanceOf(AssetError);
187
+ expect(malformedVertices.error.code).toBe('asset-parse-failed');
188
+ expect(malformedVertices.error.detail).toMatchObject({ field: 'vertices' });
189
+ }
190
+
191
+ const validInterleaved = new Float32Array(3 * 8);
192
+ const malformedIndices = meshFromInterleaved(validInterleaved, new Uint32Array([0, 1, 3]));
193
+ expect(malformedIndices.ok).toBe(false);
194
+ if (!malformedIndices.ok) {
195
+ expect(malformedIndices.error).toBeInstanceOf(AssetError);
196
+ expect(malformedIndices.error.code).toBe('asset-parse-failed');
197
+ expect(malformedIndices.error.detail).toMatchObject({ field: 'indices' });
198
+ }
199
+ });
200
+
201
+ it('single UV-aligned triangle yields tangent (1,0,0,1) (normal)', () => {
202
+ // Triangle on the XY plane with +Z normal; UV maps so that u runs
203
+ // along +X (E2), v runs along +Y (E1). Path A predicts tangent (1,0,0)
204
+ // and handedness +1.
205
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
206
+ const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
207
+ const uvs = new Float32Array([0, 0, 1, 0, 0, 1]);
208
+ const indices = new Uint32Array([0, 1, 2]);
209
+ const out = unwrapTangent(computeTangentVec4(positions, normals, uvs, indices));
210
+ expect(out.length).toBe(3 * 4);
211
+ for (let i = 0; i < 3; i++) {
212
+ const [tx, ty, tz, tw] = readVec4(out, i);
213
+ expect(tx).toBeCloseTo(1, 4);
214
+ expect(ty).toBeCloseTo(0, 4);
215
+ expect(tz).toBeCloseTo(0, 4);
216
+ expect(tw).toBe(1);
217
+ }
218
+ });
219
+
220
+ it('flipped UV winding yields handedness -1 (boundary)', () => {
221
+ // Same triangle but with U winding reversed -> det(deltaUV) < 0.
222
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
223
+ const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
224
+ // Swap u for vertices 1 and 2: u=0 at v1, u=1 at v2 -> deltaU's flip sign
225
+ const uvs = new Float32Array([1, 0, 0, 0, 1, 1]);
226
+ const indices = new Uint32Array([0, 1, 2]);
227
+ const out = unwrapTangent(computeTangentVec4(positions, normals, uvs, indices));
228
+ for (let i = 0; i < 3; i++) {
229
+ const [, , , tw] = readVec4(out, i);
230
+ expect(Math.abs(tw)).toBe(1);
231
+ expect(tw).toBe(-1);
232
+ }
233
+ });
234
+
235
+ it('Gram-Schmidt re-ortho keeps tangent perpendicular to normal (boundary)', () => {
236
+ // Construct a triangle where the raw face tangent is not perpendicular
237
+ // to the supplied vertex normal (normal tilted off +Z). After
238
+ // Gram-Schmidt the output tangent must satisfy dot(T, N) ~ 0.
239
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
240
+ // Tilt all three normals to (1,0,1)/sqrt(2) so Gram-Schmidt has work.
241
+ const k = 1 / Math.sqrt(2);
242
+ const normals = new Float32Array([k, 0, k, k, 0, k, k, 0, k]);
243
+ const uvs = new Float32Array([0, 0, 1, 0, 0, 1]);
244
+ const indices = new Uint32Array([0, 1, 2]);
245
+ const out = unwrapTangent(computeTangentVec4(positions, normals, uvs, indices));
246
+ for (let i = 0; i < 3; i++) {
247
+ const t = readVec3(out.subarray(i * 4, i * 4 + 3), 0);
248
+ const n = readVec3(normals, i);
249
+ expect(Math.abs(dot3(t, n))).toBeLessThan(1e-3);
250
+ expect(len3(t)).toBeCloseTo(1, 4);
251
+ }
252
+ });
253
+
254
+ it('non-indexed input is supported (degenerate-input shape)', () => {
255
+ // indices undefined -> assume sequential (0,1,2,3,4,5,...)
256
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
257
+ const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
258
+ const uvs = new Float32Array([0, 0, 1, 0, 0, 1]);
259
+ const out = unwrapTangent(computeTangentVec4(positions, normals, uvs));
260
+ expect(out.length).toBe(3 * 4);
261
+ const [tx, , , tw] = readVec4(out, 0);
262
+ expect(tx).toBeCloseTo(1, 4);
263
+ expect(tw).toBe(1);
264
+ });
265
+ });
266
+
267
+ describe('createPlaneGeometry tangent emit (M4 / w21)', () => {
268
+ // Plane lies on XY with +Z normal. Under the WebGPU top-left UV
269
+ // convention (uv.v = iy/hs), vertex 0 (iy=0) carries uv=(0,0).
270
+ // The representative triangle (0,2,1) has uv walk (0,0)->(0,1)->(1,0):
271
+ // dU1=0 dV1=1 dU2=1 dV2=0 => det=-1 => handedness w=-1.
272
+ // Path A thus predicts tangent (1,0,0,-1) at every vertex.
273
+ it('emits tangent attribute with length === vertexCount * 4 (normal)', () => {
274
+ const m = unwrap(createPlaneGeometry(2, 2));
275
+ const tangent = asF32(m.attributes.tangent);
276
+ const position = asF32(m.attributes.position);
277
+ const vertexCount = position.length / 3;
278
+ expect(tangent.length).toBe(vertexCount * 4);
279
+ });
280
+
281
+ it('plane v0 tangent equals (1,0,0,-1) (boundary)', () => {
282
+ const m = unwrap(createPlaneGeometry(2, 2));
283
+ const tangent = asF32(m.attributes.tangent);
284
+ const [tx, ty, tz, tw] = readVec4(tangent, 0);
285
+ expect(tx).toBeCloseTo(1, 4);
286
+ expect(ty).toBeCloseTo(0, 4);
287
+ expect(tz).toBeCloseTo(0, 4);
288
+ expect(tw).toBe(-1);
289
+ });
290
+
291
+ it('every vertex tangent is perpendicular to its normal (boundary)', () => {
292
+ const m = unwrap(createPlaneGeometry(2, 2, 2, 2));
293
+ const tangent = asF32(m.attributes.tangent);
294
+ const normal = asF32(m.attributes.normal);
295
+ const vertexCount = normal.length / 3;
296
+ for (let i = 0; i < vertexCount; i++) {
297
+ const t = readVec3(tangent.subarray(i * 4, i * 4 + 3), 0);
298
+ const n = readVec3(normal, i);
299
+ expect(Math.abs(dot3(t, n))).toBeLessThan(1e-3);
300
+ expect(Math.abs(tangent[i * 4 + 3] ?? 0)).toBe(1);
301
+ }
302
+ });
303
+ });
304
+
305
+ describe('createBoxGeometry tangent emit (M4 / w21)', () => {
306
+ it('emits tangent attribute of length vertexCount * 4 (normal)', () => {
307
+ const m = unwrap(createBoxGeometry(1, 1, 1));
308
+ const tangent = asF32(m.attributes.tangent);
309
+ const position = asF32(m.attributes.position);
310
+ expect(tangent.length).toBe((position.length / 3) * 4);
311
+ });
312
+
313
+ it('every vertex tangent is unit length and perpendicular to normal (boundary)', () => {
314
+ const m = unwrap(createBoxGeometry(2, 3, 4));
315
+ const tangent = asF32(m.attributes.tangent);
316
+ const normal = asF32(m.attributes.normal);
317
+ const vertexCount = normal.length / 3;
318
+ for (let i = 0; i < vertexCount; i++) {
319
+ const t = readVec3(tangent.subarray(i * 4, i * 4 + 3), 0);
320
+ const n = readVec3(normal, i);
321
+ expect(len3(t)).toBeCloseTo(1, 3);
322
+ expect(Math.abs(dot3(t, n))).toBeLessThan(1e-3);
323
+ expect(Math.abs(tangent[i * 4 + 3] ?? 0)).toBe(1);
324
+ }
325
+ });
326
+
327
+ it('+Z face vertex 0 tangent equals (1,0,0,+1) (boundary)', () => {
328
+ // Box face order: [+X, -X, +Y, -Y, +Z, -Z]. Each face has 4 vertices
329
+ // for ws=hs=1. The +Z face starts at vertex 4*4=16; its first vertex
330
+ // (j=0,i=0) is at (-hw,-hh,hd) with normal (0,0,+1).
331
+ //
332
+ // Under the WebGPU top-left UV convention (uv.v = j/vSegs), vertex 16
333
+ // carries uv=(0,0). The CCW-from-outside triangle (a,b,d) = (16,17,19)
334
+ // walks BL(0,0) -> BR(1,0) -> TR(1,1): dU1=1 dV1=0 dU2=1 dV2=1 =>
335
+ // det=+1 => handedness +1 (T=+X, B=+Y, N=+Z; T x B = +Z = +N). Path A
336
+ // predicts (1,0,0,+1).
337
+ const m = unwrap(createBoxGeometry(2, 2, 2));
338
+ const tangent = asF32(m.attributes.tangent);
339
+ const v = 16; // start of +Z face
340
+ const [tx, ty, tz, tw] = readVec4(tangent, v);
341
+ expect(tx).toBeCloseTo(1, 4);
342
+ expect(ty).toBeCloseTo(0, 4);
343
+ expect(tz).toBeCloseTo(0, 4);
344
+ expect(tw).toBe(1);
345
+ });
346
+ });
347
+
348
+ describe('createSphereGeometry tangent emit (M4 / w21)', () => {
349
+ it('emits tangent attribute of length vertexCount * 4 (normal)', () => {
350
+ const m = unwrap(createSphereGeometry(1));
351
+ const tangent = asF32(m.attributes.tangent);
352
+ const position = asF32(m.attributes.position);
353
+ expect(tangent.length).toBe((position.length / 3) * 4);
354
+ });
355
+
356
+ it('equator (mid-latitude) vertices have unit tangent perpendicular to normal (boundary)', () => {
357
+ const m = unwrap(createSphereGeometry(1, 16, 12));
358
+ const tangent = asF32(m.attributes.tangent);
359
+ const normal = asF32(m.attributes.normal);
360
+ // Equator row in sphere.ts: iy = hs/2 = 6, ix scans 0..16. Stride
361
+ // = ws + 1 = 17. Skip ix=0 (seam) and ix=ws (seam wrap-around).
362
+ const stride = 17;
363
+ const equatorRow = 6;
364
+ for (let ix = 1; ix < 16; ix++) {
365
+ const v = equatorRow * stride + ix;
366
+ const t = readVec3(tangent.subarray(v * 4, v * 4 + 3), 0);
367
+ const n = readVec3(normal, v);
368
+ expect(len3(t)).toBeCloseTo(1, 2);
369
+ expect(Math.abs(dot3(t, n))).toBeLessThan(5e-3);
370
+ expect(Math.abs(tangent[v * 4 + 3] ?? 0)).toBe(1);
371
+ }
372
+ });
373
+ });
374
+
375
+ describe('createCylinderGeometry tangent emit (M4 / w21)', () => {
376
+ it('emits tangent attribute of length vertexCount * 4 (normal)', () => {
377
+ const m = unwrap(createCylinderGeometry(1, 1, 2));
378
+ const tangent = asF32(m.attributes.tangent);
379
+ const position = asF32(m.attributes.position);
380
+ expect(tangent.length).toBe((position.length / 3) * 4);
381
+ });
382
+
383
+ it('every side vertex tangent is unit length and perpendicular to normal (boundary)', () => {
384
+ const m = unwrap(createCylinderGeometry(1, 1, 2, 16, 1));
385
+ const tangent = asF32(m.attributes.tangent);
386
+ const normal = asF32(m.attributes.normal);
387
+ const sideVertexCount = (16 + 1) * (1 + 1);
388
+ for (let v = 1; v < sideVertexCount - 1; v++) {
389
+ const t = readVec3(tangent.subarray(v * 4, v * 4 + 3), 0);
390
+ const n = readVec3(normal, v);
391
+ expect(len3(t)).toBeCloseTo(1, 2);
392
+ expect(Math.abs(dot3(t, n))).toBeLessThan(5e-3);
393
+ expect(Math.abs(tangent[v * 4 + 3] ?? 0)).toBe(1);
394
+ }
395
+ });
396
+ });
397
+
398
+ describe('createConeGeometry tangent emit (M4 / w21)', () => {
399
+ it('emits tangent attribute of length vertexCount * 4 (normal)', () => {
400
+ const m = unwrap(createConeGeometry(1, 2));
401
+ const tangent = asF32(m.attributes.tangent);
402
+ const position = asF32(m.attributes.position);
403
+ expect(tangent.length).toBe((position.length / 3) * 4);
404
+ });
405
+
406
+ it('cone bottom-cap centre vertex has unit tangent (boundary)', () => {
407
+ // Cone delegates to cylinder with radiusTop=0; bottom-cap exists.
408
+ // Side vertices come first; bottom-cap centre is the first vertex
409
+ // of the cap section. Verify tangent is unit length and .w in {+1,-1}.
410
+ const m = unwrap(createConeGeometry(1, 2, 16, 1));
411
+ const tangent = asF32(m.attributes.tangent);
412
+ const normal = asF32(m.attributes.normal);
413
+ const sideVertexCount = (16 + 1) * (1 + 1);
414
+ // Sample a side mid-vertex (skip the seam at ix=0).
415
+ const v = sideVertexCount / 2 + 1;
416
+ const t = readVec3(tangent.subarray(v * 4, v * 4 + 3), 0);
417
+ const n = readVec3(normal, v);
418
+ expect(len3(t)).toBeCloseTo(1, 2);
419
+ expect(Math.abs(dot3(t, n))).toBeLessThan(5e-3);
420
+ expect(Math.abs(tangent[v * 4 + 3] ?? 0)).toBe(1);
421
+ });
422
+ });
423
+
424
+ describe('createCapsuleGeometry tangent emit', () => {
425
+ it('emits tangent attribute of length vertexCount * 4 (normal)', () => {
426
+ const m = unwrap(createCapsuleGeometry(0.5, 1));
427
+ const tangent = asF32(m.attributes.tangent);
428
+ const position = asF32(m.attributes.position);
429
+ expect(tangent.length).toBe((position.length / 3) * 4);
430
+ });
431
+
432
+ it('sampled vertices have unit tangent perpendicular to normal (boundary)', () => {
433
+ const m = unwrap(createCapsuleGeometry(0.5, 1, 6, 16));
434
+ const tangent = asF32(m.attributes.tangent);
435
+ const normal = asF32(m.attributes.normal);
436
+ const vertexCount = normal.length / 3;
437
+ // Skip pole + seam vertices (row 0 north pole, ix=0 seam) by striding
438
+ // from an interior offset.
439
+ let sampled = 0;
440
+ for (let v = 20; v < vertexCount - 20; v += 5) {
441
+ const t = readVec3(tangent.subarray(v * 4, v * 4 + 3), 0);
442
+ const n = readVec3(normal, v);
443
+ expect(len3(t)).toBeCloseTo(1, 2);
444
+ expect(Math.abs(dot3(t, n))).toBeLessThan(5e-3);
445
+ expect(Math.abs(tangent[v * 4 + 3] ?? 0)).toBe(1);
446
+ sampled++;
447
+ }
448
+ expect(sampled).toBeGreaterThan(0);
449
+ });
450
+ });
451
+
452
+ describe('createTorusGeometry tangent emit (M4 / w21)', () => {
453
+ it('emits tangent attribute of length vertexCount * 4 (normal)', () => {
454
+ const m = unwrap(createTorusGeometry(1, 0.4));
455
+ const tangent = asF32(m.attributes.tangent);
456
+ const position = asF32(m.attributes.position);
457
+ expect(tangent.length).toBe((position.length / 3) * 4);
458
+ });
459
+
460
+ it('every vertex tangent is unit length and perpendicular to normal (boundary)', () => {
461
+ const m = unwrap(createTorusGeometry(2, 0.5, 8, 24));
462
+ const tangent = asF32(m.attributes.tangent);
463
+ const normal = asF32(m.attributes.normal);
464
+ const vertexCount = normal.length / 3;
465
+ // Sample a strided subset; skip seam vertices (j=0 and i=0/i=ts).
466
+ let sampled = 0;
467
+ for (let v = 26; v < vertexCount - 26; v += 7) {
468
+ const t = readVec3(tangent.subarray(v * 4, v * 4 + 3), 0);
469
+ const n = readVec3(normal, v);
470
+ expect(len3(t)).toBeCloseTo(1, 2);
471
+ expect(Math.abs(dot3(t, n))).toBeLessThan(5e-3);
472
+ expect(Math.abs(tangent[v * 4 + 3] ?? 0)).toBe(1);
473
+ sampled++;
474
+ }
475
+ expect(sampled).toBeGreaterThan(0);
476
+ });
477
+ });
478
+ }
479
+
480
+ {
481
+ // --- from geometry-winding.test.ts ---
482
+ // Procedural geometry winding-faces-outward invariant.
483
+ //
484
+ // bug-20260519: `createBoxGeometry` emitted CW-from-outside triangles
485
+ // while `createPlaneGeometry` / `createSphereGeometry` emitted CCW.
486
+ // Combined with the unlit / standard pipelines'
487
+ // `frontFace: 'ccw' + cullMode: 'back'` setup, the box geometry's front
488
+ // face was culled and the user saw the cube's back / left / right faces
489
+ // from the inside instead. This test pins the invariant: every triangle
490
+ // of every procedural factory must wind CCW when viewed from outside the
491
+ // geometry.
492
+ //
493
+ // Method (works for every closed and open shape):
494
+ // - Read each triangle's per-vertex normals from the factory output;
495
+ // average them to get the surface normal at the triangle's centroid.
496
+ // - Compute the geometric normal `(b - a) x (c - a)`.
497
+ // - The triangle is CCW from outside iff the geometric normal agrees
498
+ // with the per-vertex normal direction (`dot > 0`).
499
+ //
500
+ // Per-vertex normals are authored by every factory in this codebase as
501
+ // the surface outward normal (sphere: position; box: face normal; torus:
502
+ // position - tube center; plane: +Z; etc.). They are the right "outward"
503
+ // reference because the test's purpose is exactly to assert that the
504
+ // triangle winding agrees with the authored normal direction — anything
505
+ // else (origin-relative, factory-specific) only adds approximation noise.
506
+
507
+ function unwrap(r: { ok: true; value: MeshAsset } | { ok: false; error: AssetError }): MeshAsset {
508
+ if (!r.ok) throw new Error(`unexpected err: ${r.error.code}`);
509
+ return r.value;
510
+ }
511
+
512
+ interface TriangleVerdict {
513
+ readonly triangleIndex: number;
514
+ readonly indices: readonly [number, number, number];
515
+ readonly dot: number;
516
+ }
517
+
518
+ type V3 = readonly [number, number, number];
519
+
520
+ function readPos(vertices: Float32Array, idx: number): V3 {
521
+ const base = idx * PROCEDURAL_FLOATS_PER_VERTEX;
522
+ return [vertices[base] ?? 0, vertices[base + 1] ?? 0, vertices[base + 2] ?? 0];
523
+ }
524
+
525
+ function readNormal(vertices: Float32Array, idx: number): V3 {
526
+ const base = idx * PROCEDURAL_FLOATS_PER_VERTEX;
527
+ return [vertices[base + 3] ?? 0, vertices[base + 4] ?? 0, vertices[base + 5] ?? 0];
528
+ }
529
+
530
+ function sub(a: V3, b: V3): V3 {
531
+ return [(a[0] ?? 0) - (b[0] ?? 0), (a[1] ?? 0) - (b[1] ?? 0), (a[2] ?? 0) - (b[2] ?? 0)];
532
+ }
533
+
534
+ function add3(a: V3, b: V3, c: V3): V3 {
535
+ return [
536
+ (a[0] ?? 0) + (b[0] ?? 0) + (c[0] ?? 0),
537
+ (a[1] ?? 0) + (b[1] ?? 0) + (c[1] ?? 0),
538
+ (a[2] ?? 0) + (b[2] ?? 0) + (c[2] ?? 0),
539
+ ];
540
+ }
541
+
542
+ function cross(a: V3, b: V3): V3 {
543
+ return [
544
+ (a[1] ?? 0) * (b[2] ?? 0) - (a[2] ?? 0) * (b[1] ?? 0),
545
+ (a[2] ?? 0) * (b[0] ?? 0) - (a[0] ?? 0) * (b[2] ?? 0),
546
+ (a[0] ?? 0) * (b[1] ?? 0) - (a[1] ?? 0) * (b[0] ?? 0),
547
+ ];
548
+ }
549
+
550
+ function dot(a: V3, b: V3): number {
551
+ return (a[0] ?? 0) * (b[0] ?? 0) + (a[1] ?? 0) * (b[1] ?? 0) + (a[2] ?? 0) * (b[2] ?? 0);
552
+ }
553
+
554
+ /**
555
+ * Collect every triangle whose geometric winding disagrees with the
556
+ * average of its three per-vertex normals. Degenerate triangles (zero-
557
+ * length geometric normal — collapsed quads at sphere poles, etc.) are
558
+ * silently skipped: a zero geometric normal cannot be inverted, and
559
+ * winding for collapsed triangles is irrelevant to back-face cull
560
+ * (they project to zero pixels).
561
+ */
562
+ function findInvertedTriangles(mesh: MeshAsset): TriangleVerdict[] {
563
+ const inverted: TriangleVerdict[] = [];
564
+ const indices = mesh.indices;
565
+ if (indices === undefined) return inverted;
566
+ const indexCount = indices.length;
567
+ for (let t = 0; t < indexCount; t += 3) {
568
+ const i0 = indices[t] ?? 0;
569
+ const i1 = indices[t + 1] ?? 0;
570
+ const i2 = indices[t + 2] ?? 0;
571
+ const p0 = readPos(mesh.vertices, i0);
572
+ const p1 = readPos(mesh.vertices, i1);
573
+ const p2 = readPos(mesh.vertices, i2);
574
+ const geomN = cross(sub(p1, p0), sub(p2, p0));
575
+ const geomLen2 = dot(geomN, geomN);
576
+ if (geomLen2 < 1e-12) continue;
577
+ const refN = add3(
578
+ readNormal(mesh.vertices, i0),
579
+ readNormal(mesh.vertices, i1),
580
+ readNormal(mesh.vertices, i2),
581
+ );
582
+ const d = dot(geomN, refN);
583
+ if (d < 0) {
584
+ inverted.push({ triangleIndex: t / 3, indices: [i0, i1, i2], dot: d });
585
+ }
586
+ }
587
+ return inverted;
588
+ }
589
+
590
+ function expectAllOutward(name: string, mesh: MeshAsset): void {
591
+ const inverted = findInvertedTriangles(mesh);
592
+ const totalTriangles = (mesh.indices?.length ?? 0) / 3;
593
+ expect(
594
+ inverted,
595
+ `${inverted.length}/${totalTriangles} ${name} triangles wind opposite to their authored normal; first offender: ${JSON.stringify(inverted[0])}`,
596
+ ).toHaveLength(0);
597
+ }
598
+
599
+ describe('procedural geometry winding faces outward (bug-20260519)', () => {
600
+ it('createBoxGeometry: every triangle CCW from outside', () => {
601
+ expectAllOutward('box', unwrap(createBoxGeometry(2, 1.5, 0.8)));
602
+ });
603
+
604
+ it('createSphereGeometry: every triangle CCW from outside', () => {
605
+ expectAllOutward('sphere', unwrap(createSphereGeometry(1)));
606
+ });
607
+
608
+ it('createCylinderGeometry: every triangle CCW from outside', () => {
609
+ expectAllOutward('cylinder', unwrap(createCylinderGeometry(1, 1, 2)));
610
+ });
611
+
612
+ it('createConeGeometry: every triangle CCW from outside', () => {
613
+ expectAllOutward('cone', unwrap(createConeGeometry(1, 2)));
614
+ });
615
+
616
+ it('createTorusGeometry: every triangle CCW from outside', () => {
617
+ expectAllOutward('torus', unwrap(createTorusGeometry(1, 0.4)));
618
+ });
619
+
620
+ it('createCapsuleGeometry: every triangle CCW from outside', () => {
621
+ expectAllOutward('capsule', unwrap(createCapsuleGeometry(0.5, 1)));
622
+ });
623
+
624
+ it('createCapsuleGeometry: length=0 (sphere degenerate) keeps every triangle CCW outward', () => {
625
+ expectAllOutward('capsule (sphere)', unwrap(createCapsuleGeometry(0.75, 0)));
626
+ });
627
+
628
+ it('createPlaneGeometry: every triangle agrees with authored normal', () => {
629
+ expectAllOutward('plane', unwrap(createPlaneGeometry(2, 1)));
630
+ });
631
+
632
+ it('createBoxGeometry: subdivided box (3x2x4 segs) keeps every triangle CCW outward', () => {
633
+ expectAllOutward('subdivided box', unwrap(createBoxGeometry(1, 1, 1, 3, 2, 4)));
634
+ });
635
+ });
636
+ }
637
+
638
+ {
639
+ // --- from geometry.test.ts ---
640
+ // Procedural geometry factory tests (M3 / w8).
641
+ //
642
+ // Covers 6 factories: box / sphere / plane / cylinder / cone / torus.
643
+ // Each factory is tested for:
644
+ // - idempotency: same inputs -> byte-identical vertex buffers
645
+ // - degenerate parameters -> Result.err(AssetError({ code: 'asset-parse-failed' }))
646
+ // - basic vertex / index count sanity
647
+ //
648
+ // AC-15 narrowing is exercised by the factory implementations themselves
649
+ // (Object.entries(attributes) loops inside each factory body); this test file
650
+ // additionally verifies at runtime that every factory populates the
651
+ // `position` attribute with a Float32Array view, which matches the 6-key
652
+ // VertexAttributeMap closed set.
653
+ //
654
+ // Related: requirements §AC-06 / §AC-14 / §AC-15 / §AC-16;
655
+ // plan-strategy D-P5 6 procedural geometries;
656
+ // plan-tasks.json w8 acceptanceCheck.
657
+
658
+ function unwrapMesh(
659
+ r: { ok: true; value: MeshAsset } | { ok: false; error: AssetError },
660
+ ): MeshAsset {
661
+ if (!r.ok) throw new Error(`unexpected err: ${r.error.code}`);
662
+ return r.value;
663
+ }
664
+
665
+ function arraysEqual(a: ArrayBufferView, b: ArrayBufferView): boolean {
666
+ const va = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
667
+ const vb = new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
668
+ if (va.length !== vb.length) return false;
669
+ for (let i = 0; i < va.length; i++) if (va[i] !== vb[i]) return false;
670
+ return true;
671
+ }
672
+
673
+ function at(a: Float32Array, i: number): number {
674
+ const v = a[i];
675
+ if (v === undefined) throw new Error(`index ${i} out of bounds (length ${a.length})`);
676
+ return v;
677
+ }
678
+
679
+ describe('createBoxGeometry', () => {
680
+ it('idempotent: same inputs -> byte-identical vertex buffers (normal)', () => {
681
+ const a = unwrapMesh(createBoxGeometry(1, 1, 1));
682
+ const b = unwrapMesh(createBoxGeometry(1, 1, 1));
683
+ expect(arraysEqual(a.vertices, b.vertices)).toBe(true);
684
+ });
685
+
686
+ it('default and explicit segments produce identical buffers (boundary)', () => {
687
+ const a = unwrapMesh(createBoxGeometry(2, 3, 4, 1, 1, 1));
688
+ const b = unwrapMesh(createBoxGeometry(2, 3, 4));
689
+ expect(arraysEqual(a.vertices, b.vertices)).toBe(true);
690
+ // Geometry factories always emit indices; assert non-null after indices
691
+ // became optional on MeshAsset (feat-20260604 M2).
692
+ expect(arraysEqual(a.indices as ArrayBufferView, b.indices as ArrayBufferView)).toBe(true);
693
+ });
694
+
695
+ it('degenerate (zero dim) -> asset-parse-failed (degenerate)', () => {
696
+ const r = createBoxGeometry(0, 1, 1);
697
+ expect(r.ok).toBe(false);
698
+ if (!r.ok) {
699
+ expect(r.error).toBeInstanceOf(AssetError);
700
+ expect(r.error.code).toBe('asset-parse-failed');
701
+ }
702
+ });
703
+
704
+ it('degenerate (segments < 1) -> asset-parse-failed (degenerate)', () => {
705
+ const r = createBoxGeometry(1, 1, 1, 0, 1, 1);
706
+ expect(r.ok).toBe(false);
707
+ });
708
+
709
+ it('populates position attribute with Float32Array view (normal)', () => {
710
+ const m = unwrapMesh(createBoxGeometry(1, 1, 1));
711
+ expect(m.attributes.position).toBeInstanceOf(Float32Array);
712
+ expect(m.attributes.normal).toBeInstanceOf(Float32Array);
713
+ expect(m.attributes.uv).toBeInstanceOf(Float32Array);
714
+ });
715
+ });
716
+
717
+ describe('createSphereGeometry', () => {
718
+ it('idempotent: same inputs -> byte-identical vertex buffers (normal)', () => {
719
+ const a = unwrapMesh(createSphereGeometry(1, 8, 6));
720
+ const b = unwrapMesh(createSphereGeometry(1, 8, 6));
721
+ expect(arraysEqual(a.vertices, b.vertices)).toBe(true);
722
+ });
723
+
724
+ it('larger segments yields more vertices (boundary)', () => {
725
+ const a = unwrapMesh(createSphereGeometry(1, 8, 6));
726
+ const b = unwrapMesh(createSphereGeometry(1, 16, 12));
727
+ expect(b.vertices.length).toBeGreaterThan(a.vertices.length);
728
+ });
729
+
730
+ it('degenerate (radius <= 0) -> asset-parse-failed (degenerate)', () => {
731
+ const r = createSphereGeometry(0, 8, 6);
732
+ expect(r.ok).toBe(false);
733
+ if (!r.ok) expect(r.error.code).toBe('asset-parse-failed');
734
+ });
735
+
736
+ it('degenerate (widthSegments < 3) -> asset-parse-failed (degenerate)', () => {
737
+ const r = createSphereGeometry(1, 2, 6);
738
+ expect(r.ok).toBe(false);
739
+ });
740
+
741
+ it('triangle count > 0 (normal)', () => {
742
+ const m = unwrapMesh(createSphereGeometry(1, 16, 12));
743
+ expect(m.indices?.length ?? 0).toBeGreaterThan(0);
744
+ });
745
+
746
+ it('vertices lie on unit sphere: |hypot(pos) - 1| < 1e-6 (normal)', () => {
747
+ const m = unwrapMesh(createSphereGeometry(1, 16, 12));
748
+ const pos = m.attributes.position;
749
+ expect(pos).toBeInstanceOf(Float32Array);
750
+ const f32 = pos as Float32Array;
751
+ const count = f32.length;
752
+ for (let i = 0; i + 2 < count; i += 3) {
753
+ const h = Math.hypot(f32[i] ?? 0, f32[i + 1] ?? 0, f32[i + 2] ?? 0);
754
+ expect(Math.abs(h - 1)).toBeLessThan(1e-6);
755
+ }
756
+ });
757
+ });
758
+
759
+ describe('createPlaneGeometry', () => {
760
+ it('idempotent: same inputs -> byte-identical vertex buffers (normal)', () => {
761
+ const a = unwrapMesh(createPlaneGeometry(2, 2));
762
+ const b = unwrapMesh(createPlaneGeometry(2, 2));
763
+ expect(arraysEqual(a.vertices, b.vertices)).toBe(true);
764
+ });
765
+
766
+ it('unit plane has 4 vertices and 6 indices with default 1x1 segments (boundary)', () => {
767
+ const m = unwrapMesh(createPlaneGeometry(1, 1));
768
+ // 4 vertices * 12 floats (pos3 + normal3 + uv2 + tangent4) = 48 floats
769
+ // (feat-20260518 M4 / w21: stride upgraded from 8 to 12 to carry
770
+ // tangent for the standard / pbr.wgsl pipeline; see
771
+ // geometry/box.ts PROCEDURAL_FLOATS_PER_VERTEX = 12).
772
+ expect(m.vertices.length).toBe(48);
773
+ expect(m.indices?.length ?? 0).toBe(6);
774
+ });
775
+
776
+ it('degenerate (width <= 0) -> asset-parse-failed (degenerate)', () => {
777
+ const r = createPlaneGeometry(-1, 1);
778
+ expect(r.ok).toBe(false);
779
+ if (!r.ok) expect(r.error.code).toBe('asset-parse-failed');
780
+ });
781
+ });
782
+
783
+ describe('createCylinderGeometry', () => {
784
+ it('idempotent: same inputs -> byte-identical vertex buffers (normal)', () => {
785
+ const a = unwrapMesh(createCylinderGeometry(1, 1, 2, 8));
786
+ const b = unwrapMesh(createCylinderGeometry(1, 1, 2, 8));
787
+ expect(arraysEqual(a.vertices, b.vertices)).toBe(true);
788
+ });
789
+
790
+ it('zero top radius is allowed (cone-like degenerate end) (boundary)', () => {
791
+ const r = createCylinderGeometry(0, 1, 2, 8);
792
+ expect(r.ok).toBe(true);
793
+ });
794
+
795
+ it('degenerate (both radii <= 0) -> asset-parse-failed (degenerate)', () => {
796
+ const r = createCylinderGeometry(0, 0, 2, 8);
797
+ expect(r.ok).toBe(false);
798
+ if (!r.ok) expect(r.error.code).toBe('asset-parse-failed');
799
+ });
800
+
801
+ it('degenerate (radialSegments < 3) -> asset-parse-failed (degenerate)', () => {
802
+ const r = createCylinderGeometry(1, 1, 2, 2);
803
+ expect(r.ok).toBe(false);
804
+ });
805
+ });
806
+
807
+ describe('createConeGeometry', () => {
808
+ it('idempotent: same inputs -> byte-identical vertex buffers (normal)', () => {
809
+ const a = unwrapMesh(createConeGeometry(1, 2, 8));
810
+ const b = unwrapMesh(createConeGeometry(1, 2, 8));
811
+ expect(arraysEqual(a.vertices, b.vertices)).toBe(true);
812
+ });
813
+
814
+ it('cone equals cylinder with topRadius=0 (boundary)', () => {
815
+ const cone = unwrapMesh(createConeGeometry(1, 2, 8));
816
+ const cyl = unwrapMesh(createCylinderGeometry(0, 1, 2, 8));
817
+ expect(arraysEqual(cone.vertices, cyl.vertices)).toBe(true);
818
+ expect(arraysEqual(cone.indices as ArrayBufferView, cyl.indices as ArrayBufferView)).toBe(
819
+ true,
820
+ );
821
+ });
822
+
823
+ it('degenerate (radius <= 0) -> asset-parse-failed (degenerate)', () => {
824
+ const r = createConeGeometry(0, 2, 8);
825
+ expect(r.ok).toBe(false);
826
+ if (!r.ok) expect(r.error.code).toBe('asset-parse-failed');
827
+ });
828
+ });
829
+
830
+ describe('createTorusGeometry', () => {
831
+ it('idempotent: same inputs -> byte-identical vertex buffers (normal)', () => {
832
+ const a = unwrapMesh(createTorusGeometry(1, 0.3, 8, 6));
833
+ const b = unwrapMesh(createTorusGeometry(1, 0.3, 8, 6));
834
+ expect(arraysEqual(a.vertices, b.vertices)).toBe(true);
835
+ });
836
+
837
+ it('tube radius smaller than ring produces finite positive vertex count (boundary)', () => {
838
+ const m = unwrapMesh(createTorusGeometry(1, 0.3, 8, 6));
839
+ expect(m.vertices.length).toBeGreaterThan(0);
840
+ });
841
+
842
+ it('degenerate (ring radius <= 0) -> asset-parse-failed (degenerate)', () => {
843
+ const r = createTorusGeometry(0, 0.3, 8, 6);
844
+ expect(r.ok).toBe(false);
845
+ if (!r.ok) expect(r.error.code).toBe('asset-parse-failed');
846
+ });
847
+
848
+ it('degenerate (tubularSegments < 3) -> asset-parse-failed (degenerate)', () => {
849
+ const r = createTorusGeometry(1, 0.3, 2, 6);
850
+ expect(r.ok).toBe(false);
851
+ });
852
+ });
853
+
854
+ describe('createCapsuleGeometry', () => {
855
+ it('idempotent: same inputs -> byte-identical vertex buffers (normal)', () => {
856
+ const a = unwrapMesh(createCapsuleGeometry(0.5, 1, 4, 8));
857
+ const b = unwrapMesh(createCapsuleGeometry(0.5, 1, 4, 8));
858
+ expect(arraysEqual(a.vertices, b.vertices)).toBe(true);
859
+ });
860
+
861
+ it('default segments produce a finite positive vertex + index count (normal)', () => {
862
+ const m = unwrapMesh(createCapsuleGeometry(0.5, 1));
863
+ expect(m.vertices.length).toBeGreaterThan(0);
864
+ expect(m.indices?.length ?? 0).toBeGreaterThan(0);
865
+ });
866
+
867
+ it('total height = length + 2*radius; extreme Y at +-(length/2 + radius) (boundary)', () => {
868
+ const radius = 0.5;
869
+ const length = 2;
870
+ const m = unwrapMesh(createCapsuleGeometry(radius, length, 6, 12));
871
+ const pos = m.attributes.position as Float32Array;
872
+ let maxY = -Infinity;
873
+ let minY = Infinity;
874
+ for (let i = 0; i + 2 < pos.length; i += 3) {
875
+ const y = pos[i + 1] ?? 0;
876
+ if (y > maxY) maxY = y;
877
+ if (y < minY) minY = y;
878
+ }
879
+ const expected = length / 2 + radius;
880
+ expect(Math.abs(maxY - expected)).toBeLessThan(1e-5);
881
+ expect(Math.abs(minY + expected)).toBeLessThan(1e-5);
882
+ });
883
+
884
+ it('length=0 collapses to a sphere of the given radius: all verts on |r| (boundary)', () => {
885
+ const radius = 0.75;
886
+ const m = unwrapMesh(createCapsuleGeometry(radius, 0, 6, 12));
887
+ const pos = m.attributes.position as Float32Array;
888
+ for (let i = 0; i + 2 < pos.length; i += 3) {
889
+ const h = Math.hypot(pos[i] ?? 0, pos[i + 1] ?? 0, pos[i + 2] ?? 0);
890
+ expect(Math.abs(h - radius)).toBeLessThan(1e-5);
891
+ }
892
+ });
893
+
894
+ it('degenerate (radius <= 0) -> asset-parse-failed (degenerate)', () => {
895
+ const r = createCapsuleGeometry(0, 1);
896
+ expect(r.ok).toBe(false);
897
+ if (!r.ok) expect(r.error.code).toBe('asset-parse-failed');
898
+ });
899
+
900
+ it('degenerate (negative length) -> asset-parse-failed (degenerate)', () => {
901
+ const r = createCapsuleGeometry(0.5, -1);
902
+ expect(r.ok).toBe(false);
903
+ });
904
+
905
+ it('degenerate (radialSegments < 3) -> asset-parse-failed (degenerate)', () => {
906
+ const r = createCapsuleGeometry(0.5, 1, 4, 2);
907
+ expect(r.ok).toBe(false);
908
+ });
909
+
910
+ it('degenerate (capSegments < 1) -> asset-parse-failed (degenerate)', () => {
911
+ const r = createCapsuleGeometry(0.5, 1, 0, 8);
912
+ expect(r.ok).toBe(false);
913
+ });
914
+ });
915
+
916
+ describe('VertexAttributeMap narrowing (AC-15)', () => {
917
+ it('every factory returns a mesh whose attributes keys are a subset of the 6-key closed set', () => {
918
+ const meshes = [
919
+ unwrapMesh(createBoxGeometry(1, 1, 1)),
920
+ unwrapMesh(createSphereGeometry(1, 8, 6)),
921
+ unwrapMesh(createPlaneGeometry(1, 1)),
922
+ unwrapMesh(createCylinderGeometry(1, 1, 2, 8)),
923
+ unwrapMesh(createConeGeometry(1, 2, 8)),
924
+ unwrapMesh(createTorusGeometry(1, 0.3, 8, 6)),
925
+ unwrapMesh(createCapsuleGeometry(0.5, 1, 4, 8)),
926
+ ];
927
+ const allowed = new Set(['position', 'normal', 'uv', 'tangent', 'skinIndex', 'skinWeight']);
928
+ for (const m of meshes) {
929
+ for (const key of Object.keys(m.attributes)) {
930
+ expect(allowed.has(key)).toBe(true);
931
+ }
932
+ // every factory at least populates position
933
+ expect(m.attributes.position).toBeInstanceOf(Float32Array);
934
+ }
935
+ });
936
+ });
937
+
938
+ // VAIU-F1: the @forgeax/engine-geometry barrel is the single AI-user-facing
939
+ // import for the 6 geometry factories. Guard it so a future accidental
940
+ // barrel-drop breaks the test instead of the AI-user-facing import.
941
+ // bug-20260601: procedural geometry UV.v uses the WebGPU top-left
942
+ // convention (V=0 = image top). The torus already authored j/rs (angular
943
+ // wrap-around-the-tube coordinate, direction-agnostic) and is asserted
944
+ // unchanged. Factory interleaved buffer layout: position(3) + normal(3) +
945
+ // uv(2) = 8 floats per vertex before meshFromInterleaved expands to 12.
946
+ describe('procedural geometry UV.v orientation (bug-20260601)', () => {
947
+ it('plane: top row (iy=0) carries v=0, bottom row carries v=1', () => {
948
+ const m = unwrapMesh(createPlaneGeometry(2, 4, 1, 2));
949
+ // 1x2 segments = (1+1) * (2+1) = 6 vertices. Row iy=0 vertices are
950
+ // indices 0..1 (v is at stride offset 1 within uv2).
951
+ const uv = m.attributes.uv;
952
+ expect(uv).toBeInstanceOf(Float32Array);
953
+ const uvs = uv as Float32Array;
954
+ // Top row (iy=0, v=0): vertices 0,1
955
+ expect(Math.abs(at(uvs, 0 * 2 + 1) - 0)).toBeLessThan(1e-6);
956
+ expect(Math.abs(at(uvs, 1 * 2 + 1) - 0)).toBeLessThan(1e-6);
957
+ // Bottom row (iy=2, v=1): vertices 4,5
958
+ expect(Math.abs(at(uvs, 4 * 2 + 1) - 1)).toBeLessThan(1e-6);
959
+ expect(Math.abs(at(uvs, 5 * 2 + 1) - 1)).toBeLessThan(1e-6);
960
+ });
961
+
962
+ it('box: +Y face top edge (j=0) carries v=0, bottom edge (j=vSegs) carries v=1', () => {
963
+ // 1x1x1 box, 1 segment per face. The +Y face has 4 vertices; j=0 yields v=0.
964
+ const m = unwrapMesh(createBoxGeometry(1, 1, 1, 1, 1, 1));
965
+ const uvs = m.attributes.uv as Float32Array;
966
+ // Box has 6 faces * 4 vertices = 24 vertices. The +Y face is face index 2
967
+ // (0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z, 5=-Z), so its 4 vertices start at
968
+ // offset 8 within the per-face vertex sequence (2 faces * 4 verts).
969
+ const yFaceStart = 2 * 4;
970
+ // j=0 (top edge): vertices yFaceStart+0 and yFaceStart+1
971
+ expect(Math.abs(at(uvs, (yFaceStart + 0) * 2 + 1) - 0)).toBeLessThan(1e-6);
972
+ expect(Math.abs(at(uvs, (yFaceStart + 1) * 2 + 1) - 0)).toBeLessThan(1e-6);
973
+ // j=1 (bottom edge): vertices yFaceStart+2 and yFaceStart+3
974
+ expect(Math.abs(at(uvs, (yFaceStart + 2) * 2 + 1) - 1)).toBeLessThan(1e-6);
975
+ expect(Math.abs(at(uvs, (yFaceStart + 3) * 2 + 1) - 1)).toBeLessThan(1e-6);
976
+ });
977
+
978
+ it('sphere: north pole (v=0, phi=0) carries v=0', () => {
979
+ const m = unwrapMesh(createSphereGeometry(1, 8, 4));
980
+ const uvs = m.attributes.uv as Float32Array;
981
+ // 8+1=9 vertices per row, iy=0 (north pole) row occupies first 9 vertices.
982
+ // All north-pole-row vertices carry v=0.
983
+ for (let i = 0; i < 9; i++) {
984
+ expect(Math.abs(at(uvs, i * 2 + 1) - 0)).toBeLessThan(1e-6);
985
+ }
986
+ });
987
+
988
+ it('cylinder: top edge (v=0) carries v=0', () => {
989
+ const m = unwrapMesh(createCylinderGeometry(1, 1, 2, 8, 2));
990
+ const uvs = m.attributes.uv as Float32Array;
991
+ // Side face: (8+1) * (2+1) = 27 vertices. Row iy=0 occupies first 9 vertices.
992
+ // Top row carries v=0.
993
+ for (let i = 0; i < 9; i++) {
994
+ expect(Math.abs(at(uvs, i * 2 + 1) - 0)).toBeLessThan(1e-6);
995
+ }
996
+ });
997
+
998
+ it('cone delegates to cylinder and inherits the corrected UV', () => {
999
+ const cone = unwrapMesh(createConeGeometry(1, 2, 8, 2));
1000
+ const cyl = unwrapMesh(createCylinderGeometry(0, 1, 2, 8, 2));
1001
+ const coneUV = cone.attributes.uv as Float32Array;
1002
+ const cylUV = cyl.attributes.uv as Float32Array;
1003
+ // Byte-identical UVs (same as the existing cone-equals-cylinder test for buffers).
1004
+ for (let i = 0; i < coneUV.length; i++) {
1005
+ expect(Math.abs(at(coneUV, i) - at(cylUV, i))).toBeLessThan(1e-6);
1006
+ }
1007
+ // Top row v=0
1008
+ for (let i = 0; i < 9; i++) {
1009
+ expect(Math.abs(at(coneUV, i * 2 + 1) - 0)).toBeLessThan(1e-6);
1010
+ }
1011
+ });
1012
+
1013
+ it('torus V is already top-left (j/rs, angular coordinate) — unchanged', () => {
1014
+ const m = unwrapMesh(createTorusGeometry(1, 0.4, 4, 6));
1015
+ const uvs = m.attributes.uv as Float32Array;
1016
+ // (4+1) * (6+1) = 35 vertices. j=0 (rs=0) row carries v=0.
1017
+ const stride = 6 + 1; // tubularSegments+1
1018
+ for (let i = 0; i < stride; i++) {
1019
+ expect(Math.abs(at(uvs, i * 2 + 1) - 0)).toBeLessThan(1e-6);
1020
+ }
1021
+ });
1022
+ });
1023
+
1024
+ describe('geometry barrel re-exports (VAIU-F1)', () => {
1025
+ it('@forgeax/engine-geometry exposes all 7 factories', async () => {
1026
+ const mod = await import('@forgeax/engine-geometry');
1027
+ expect(typeof mod.createBoxGeometry).toBe('function');
1028
+ expect(typeof mod.createCapsuleGeometry).toBe('function');
1029
+ expect(typeof mod.createConeGeometry).toBe('function');
1030
+ expect(typeof mod.createCylinderGeometry).toBe('function');
1031
+ expect(typeof mod.createPlaneGeometry).toBe('function');
1032
+ expect(typeof mod.createSphereGeometry).toBe('function');
1033
+ expect(typeof mod.createTorusGeometry).toBe('function');
1034
+ });
1035
+ });
1036
+ }
1037
+
1038
+ {
1039
+ // --- from vertex-attribute-layout.test.ts ---
1040
+ // @forgeax/engine-runtime - vertex-attribute-layout unit tests (M2 / T-23).
1041
+ //
1042
+ // Tests deriveVertexBufferLayout against each of the 6 closed-set vertex
1043
+ // attribute keys plus multi-key combination scenarios.
1044
+ // plan-strategy D-7: vertex-attribute-layout.ts is the SSOT for @location(N)
1045
+ // -> GPUVertexFormat mapping consumed by shader WGSL and geometry factories.
1046
+
1047
+ function makeBuffer(len: number): Float32Array {
1048
+ return new Float32Array(len);
1049
+ }
1050
+
1051
+ describe('deriveVertexBufferLayout', () => {
1052
+ it('position-only produces one layout entry with float32x3 at location 0', () => {
1053
+ const map: VertexAttributeMap = { position: makeBuffer(3) };
1054
+ const layout = deriveVertexBufferLayout(map);
1055
+ expect(layout).toHaveLength(1);
1056
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1057
+ const entry = layout[0]!;
1058
+ expect(entry.arrayStride).toBe(12);
1059
+ expect(entry.attributes).toHaveLength(1);
1060
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1061
+ expect(entry.attributes[0]!.shaderLocation).toBe(0);
1062
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1063
+ expect(entry.attributes[0]!.format).toBe('float32x3');
1064
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1065
+ expect(entry.attributes[0]!.offset).toBe(0);
1066
+ });
1067
+
1068
+ it('normal-only produces one layout entry with float32x3 at location 1', () => {
1069
+ const map: VertexAttributeMap = { normal: makeBuffer(3) };
1070
+ const layout = deriveVertexBufferLayout(map);
1071
+ expect(layout).toHaveLength(1);
1072
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1073
+ expect(layout[0]!.attributes).toHaveLength(1);
1074
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1075
+ expect(layout[0]!.attributes[0]!.shaderLocation).toBe(1);
1076
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1077
+ expect(layout[0]!.attributes[0]!.format).toBe('float32x3');
1078
+ });
1079
+
1080
+ it('uv-only produces one layout entry with float32x2 at location 2', () => {
1081
+ const map: VertexAttributeMap = { uv: makeBuffer(2) };
1082
+ const layout = deriveVertexBufferLayout(map);
1083
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1084
+ expect(layout[0]!.attributes[0]!.shaderLocation).toBe(2);
1085
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1086
+ expect(layout[0]!.attributes[0]!.format).toBe('float32x2');
1087
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1088
+ expect(layout[0]!.attributes[0]!.offset).toBe(0);
1089
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1090
+ expect(layout[0]!.arrayStride).toBe(8);
1091
+ });
1092
+
1093
+ it('tangent-only produces one layout entry with float32x4 at location 3', () => {
1094
+ const map: VertexAttributeMap = { tangent: makeBuffer(4) };
1095
+ const layout = deriveVertexBufferLayout(map);
1096
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1097
+ expect(layout[0]!.attributes[0]!.shaderLocation).toBe(3);
1098
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1099
+ expect(layout[0]!.attributes[0]!.format).toBe('float32x4');
1100
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1101
+ expect(layout[0]!.arrayStride).toBe(16);
1102
+ });
1103
+
1104
+ it('skinIndex-only produces uint16x4 at location 4', () => {
1105
+ const map: VertexAttributeMap = { skinIndex: new Uint16Array(4).buffer };
1106
+ const layout = deriveVertexBufferLayout(map);
1107
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1108
+ expect(layout[0]!.attributes[0]!.shaderLocation).toBe(4);
1109
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1110
+ expect(layout[0]!.attributes[0]!.format).toBe('uint16x4');
1111
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1112
+ expect(layout[0]!.arrayStride).toBe(8);
1113
+ });
1114
+
1115
+ it('skinWeight-only produces float32x4 at location 5', () => {
1116
+ const map: VertexAttributeMap = { skinWeight: makeBuffer(4) };
1117
+ const layout = deriveVertexBufferLayout(map);
1118
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1119
+ expect(layout[0]!.attributes[0]!.shaderLocation).toBe(5);
1120
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1121
+ expect(layout[0]!.attributes[0]!.format).toBe('float32x4');
1122
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1123
+ expect(layout[0]!.arrayStride).toBe(16);
1124
+ });
1125
+
1126
+ it('all 6 keys produce correct sequential locations and offsets', () => {
1127
+ const map: VertexAttributeMap = {
1128
+ position: makeBuffer(3),
1129
+ normal: makeBuffer(3),
1130
+ uv: makeBuffer(2),
1131
+ tangent: makeBuffer(4),
1132
+ skinIndex: new Uint16Array(4).buffer,
1133
+ skinWeight: makeBuffer(4),
1134
+ };
1135
+ const layout = deriveVertexBufferLayout(map);
1136
+ expect(layout).toHaveLength(1);
1137
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1138
+ const attrs = layout[0]!.attributes;
1139
+ expect(attrs).toHaveLength(6);
1140
+
1141
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1142
+ expect(attrs[0]!.shaderLocation).toBe(0);
1143
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1144
+ expect(attrs[0]!.format).toBe('float32x3');
1145
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1146
+ expect(attrs[0]!.offset).toBe(0);
1147
+
1148
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1149
+ expect(attrs[1]!.shaderLocation).toBe(1);
1150
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1151
+ expect(attrs[1]!.format).toBe('float32x3');
1152
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1153
+ expect(attrs[1]!.offset).toBe(12);
1154
+
1155
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1156
+ expect(attrs[2]!.shaderLocation).toBe(2);
1157
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1158
+ expect(attrs[2]!.format).toBe('float32x2');
1159
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1160
+ expect(attrs[2]!.offset).toBe(24);
1161
+
1162
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1163
+ expect(attrs[3]!.shaderLocation).toBe(3);
1164
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1165
+ expect(attrs[3]!.format).toBe('float32x4');
1166
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1167
+ expect(attrs[3]!.offset).toBe(32);
1168
+
1169
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1170
+ expect(attrs[4]!.shaderLocation).toBe(4);
1171
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1172
+ expect(attrs[4]!.format).toBe('uint16x4');
1173
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1174
+ expect(attrs[4]!.offset).toBe(48);
1175
+
1176
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1177
+ expect(attrs[5]!.shaderLocation).toBe(5);
1178
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1179
+ expect(attrs[5]!.format).toBe('float32x4');
1180
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1181
+ expect(attrs[5]!.offset).toBe(56);
1182
+
1183
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1184
+ expect(layout[0]!.arrayStride).toBe(72); // 12+12+8+16+8+16 = 72
1185
+ });
1186
+
1187
+ it('produces contiguous offsets when some keys are missing', () => {
1188
+ const map: VertexAttributeMap = {
1189
+ position: makeBuffer(3),
1190
+ uv: makeBuffer(2),
1191
+ skinWeight: makeBuffer(4),
1192
+ };
1193
+ const layout = deriveVertexBufferLayout(map);
1194
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1195
+ const attrs = layout[0]!.attributes;
1196
+ expect(attrs).toHaveLength(3);
1197
+
1198
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1199
+ expect(attrs[0]!.shaderLocation).toBe(0); // position
1200
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1201
+ expect(attrs[0]!.offset).toBe(0);
1202
+
1203
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1204
+ expect(attrs[1]!.shaderLocation).toBe(2); // uv (normal skipped)
1205
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1206
+ expect(attrs[1]!.offset).toBe(12); // after position stride
1207
+
1208
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1209
+ expect(attrs[2]!.shaderLocation).toBe(5); // skinWeight
1210
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1211
+ expect(attrs[2]!.offset).toBe(20); // 12 + 8
1212
+
1213
+ // biome-ignore lint/style/noNonNullAssertion: controlled test context
1214
+ expect(layout[0]!.arrayStride).toBe(36); // 12 + 8 + 16
1215
+ });
1216
+
1217
+ it('empty map produces empty layout', () => {
1218
+ const map: VertexAttributeMap = {};
1219
+ const layout = deriveVertexBufferLayout(map);
1220
+ expect(layout).toHaveLength(0);
1221
+ });
1222
+ });
1223
+ }