@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.
- package/LICENSE +202 -0
- package/README.md +225 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/dim2.test.d.ts +2 -0
- package/dist/__tests__/dim2.test.d.ts.map +1 -0
- package/dist/__tests__/geometry.unit.test.d.ts +2 -0
- package/dist/__tests__/geometry.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vertex-attribute-layout-owner.unit.test.d.ts +2 -0
- package/dist/__tests__/vertex-attribute-layout-owner.unit.test.d.ts.map +1 -0
- package/dist/assets/mesh-binary.d.ts +4 -0
- package/dist/assets/mesh-binary.d.ts.map +1 -0
- package/dist/assets/mesh-decoder.d.ts +6 -0
- package/dist/assets/mesh-decoder.d.ts.map +1 -0
- package/dist/assets/primitive-mesh.d.ts +5 -0
- package/dist/assets/primitive-mesh.d.ts.map +1 -0
- package/dist/box.d.ts +41 -0
- package/dist/box.d.ts.map +1 -0
- package/dist/capsule.d.ts +14 -0
- package/dist/capsule.d.ts.map +1 -0
- package/dist/cone.d.ts +4 -0
- package/dist/cone.d.ts.map +1 -0
- package/dist/cylinder.d.ts +4 -0
- package/dist/cylinder.d.ts.map +1 -0
- package/dist/dim2.d.ts +73 -0
- package/dist/dim2.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +2000 -0
- package/dist/index.mjs.map +1 -0
- package/dist/plane.d.ts +4 -0
- package/dist/plane.d.ts.map +1 -0
- package/dist/sphere.d.ts +4 -0
- package/dist/sphere.d.ts.map +1 -0
- package/dist/tangent.d.ts +34 -0
- package/dist/tangent.d.ts.map +1 -0
- package/dist/torus.d.ts +4 -0
- package/dist/torus.d.ts.map +1 -0
- package/dist/vertex-attribute-layout.d.ts +90 -0
- package/dist/vertex-attribute-layout.d.ts.map +1 -0
- package/package.json +59 -0
- package/src/__tests__/dim2.test.ts +132 -0
- package/src/__tests__/geometry.unit.test.ts +1223 -0
- package/src/__tests__/vertex-attribute-layout-owner.unit.test.ts +181 -0
- package/src/assets/mesh-binary.ts +421 -0
- package/src/assets/mesh-decoder.ts +97 -0
- package/src/assets/primitive-mesh.ts +36 -0
- package/src/box.ts +437 -0
- package/src/capsule.ts +145 -0
- package/src/cone.ts +26 -0
- package/src/cylinder.ts +180 -0
- package/src/dim2.ts +505 -0
- package/src/index.ts +54 -0
- package/src/plane.ts +78 -0
- package/src/sphere.ts +82 -0
- package/src/tangent.ts +320 -0
- package/src/torus.ts +83 -0
- 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
|
+
}
|