@flighthq/mesh 0.1.0

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 (55) hide show
  1. package/dist/index.d.ts +11 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +11 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/meshGeometry.d.ts +15 -0
  6. package/dist/meshGeometry.d.ts.map +1 -0
  7. package/dist/meshGeometry.js +128 -0
  8. package/dist/meshGeometry.js.map +1 -0
  9. package/dist/meshGeometryAttributes.d.ts +28 -0
  10. package/dist/meshGeometryAttributes.d.ts.map +1 -0
  11. package/dist/meshGeometryAttributes.js +144 -0
  12. package/dist/meshGeometryAttributes.js.map +1 -0
  13. package/dist/meshGeometryBuilders.d.ts +19 -0
  14. package/dist/meshGeometryBuilders.d.ts.map +1 -0
  15. package/dist/meshGeometryBuilders.js +772 -0
  16. package/dist/meshGeometryBuilders.js.map +1 -0
  17. package/dist/meshGeometryCompute.d.ts +7 -0
  18. package/dist/meshGeometryCompute.d.ts.map +1 -0
  19. package/dist/meshGeometryCompute.js +299 -0
  20. package/dist/meshGeometryCompute.js.map +1 -0
  21. package/dist/meshGeometryIndex.d.ts +4 -0
  22. package/dist/meshGeometryIndex.d.ts.map +1 -0
  23. package/dist/meshGeometryIndex.js +70 -0
  24. package/dist/meshGeometryIndex.js.map +1 -0
  25. package/dist/meshGeometryLayout.d.ts +4 -0
  26. package/dist/meshGeometryLayout.d.ts.map +1 -0
  27. package/dist/meshGeometryLayout.js +83 -0
  28. package/dist/meshGeometryLayout.js.map +1 -0
  29. package/dist/meshGeometryOperations.d.ts +12 -0
  30. package/dist/meshGeometryOperations.d.ts.map +1 -0
  31. package/dist/meshGeometryOperations.js +209 -0
  32. package/dist/meshGeometryOperations.js.map +1 -0
  33. package/dist/meshGeometrySubset.d.ts +5 -0
  34. package/dist/meshGeometrySubset.d.ts.map +1 -0
  35. package/dist/meshGeometrySubset.js +40 -0
  36. package/dist/meshGeometrySubset.js.map +1 -0
  37. package/dist/meshGeometryTransforms.d.ts +7 -0
  38. package/dist/meshGeometryTransforms.d.ts.map +1 -0
  39. package/dist/meshGeometryTransforms.js +235 -0
  40. package/dist/meshGeometryTransforms.js.map +1 -0
  41. package/dist/meshGeometryUvs.d.ts +5 -0
  42. package/dist/meshGeometryUvs.d.ts.map +1 -0
  43. package/dist/meshGeometryUvs.js +58 -0
  44. package/dist/meshGeometryUvs.js.map +1 -0
  45. package/package.json +39 -0
  46. package/src/meshGeometry.test.ts +155 -0
  47. package/src/meshGeometryAttributes.test.ts +195 -0
  48. package/src/meshGeometryBuilders.test.ts +220 -0
  49. package/src/meshGeometryCompute.test.ts +218 -0
  50. package/src/meshGeometryIndex.test.ts +84 -0
  51. package/src/meshGeometryLayout.test.ts +132 -0
  52. package/src/meshGeometryOperations.test.ts +154 -0
  53. package/src/meshGeometrySubset.test.ts +75 -0
  54. package/src/meshGeometryTransforms.test.ts +169 -0
  55. package/src/meshGeometryUvs.test.ts +128 -0
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@flighthq/mesh",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "dependencies": {
30
+ "@flighthq/entity": "0.1.0",
31
+ "@flighthq/geometry": "0.1.0",
32
+ "@flighthq/types": "0.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.3.0"
36
+ },
37
+ "description": "Mesh geometry: vertex layouts, primitive builders, and normals/tangents/bounds",
38
+ "sideEffects": false
39
+ }
@@ -0,0 +1,155 @@
1
+ import type { MeshGeometryRuntime, VertexAttributeLayout } from '@flighthq/types';
2
+ import { EntityRuntimeKey } from '@flighthq/types';
3
+
4
+ import {
5
+ cloneMeshGeometry,
6
+ createMeshGeometry,
7
+ destroyMeshGeometryGlData,
8
+ destroyMeshGeometryWgpuData,
9
+ getMeshGeometryIndexCount,
10
+ getMeshGeometryVertexCount,
11
+ } from './meshGeometry';
12
+
13
+ const CANONICAL_LAYOUT: VertexAttributeLayout = {
14
+ attributes: [
15
+ { byteOffset: 0, format: 'float32x3', semantic: 'position' },
16
+ { byteOffset: 12, format: 'float32x3', semantic: 'normal' },
17
+ { byteOffset: 24, format: 'float32x4', semantic: 'tangent' },
18
+ { byteOffset: 40, format: 'float32x2', semantic: 'uv0' },
19
+ ],
20
+ stride: 48,
21
+ };
22
+
23
+ function makeVertices(count: number): Float32Array<ArrayBuffer> {
24
+ return new Float32Array(count * 12);
25
+ }
26
+
27
+ describe('cloneMeshGeometry', () => {
28
+ it('deep-copies vertices and indices independently', () => {
29
+ const vertices = makeVertices(3);
30
+ vertices[0] = 7;
31
+ const indices = new Uint16Array([0, 1, 2]);
32
+ const source = createMeshGeometry({ indices: indices, layout: CANONICAL_LAYOUT, vertices: vertices });
33
+
34
+ const clone = cloneMeshGeometry(source);
35
+ expect(clone).not.toBe(source);
36
+ expect(clone.vertices).not.toBe(source.vertices);
37
+ expect(clone.vertices[0]).toBe(7);
38
+ expect(clone.indices).not.toBe(source.indices);
39
+
40
+ clone.vertices[0] = 99;
41
+ expect(source.vertices[0]).toBe(7);
42
+ });
43
+
44
+ it('resets the upload version and carries a fresh runtime', () => {
45
+ const source = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(1) });
46
+ source.version = 5;
47
+ const clone = cloneMeshGeometry(source);
48
+ expect(clone.version).toBe(0);
49
+ expect(clone[EntityRuntimeKey]).not.toBe(source[EntityRuntimeKey]);
50
+ });
51
+
52
+ it('clones bounds when present without sharing the AABB instance', () => {
53
+ const source = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(1) });
54
+ source.bounds = { max: { x: 1, y: 1, z: 1 }, min: { x: -1, y: -1, z: -1 } } as NonNullable<typeof source.bounds>;
55
+ const clone = cloneMeshGeometry(source);
56
+ expect(clone.bounds).not.toBe(source.bounds);
57
+ expect(clone.bounds!.min.x).toBe(-1);
58
+ expect(clone.bounds!.max.z).toBe(1);
59
+ });
60
+ });
61
+
62
+ describe('createMeshGeometry', () => {
63
+ it('defaults topology to triangle-list and a single full subset', () => {
64
+ const indices = new Uint16Array([0, 1, 2]);
65
+ const geometry = createMeshGeometry({ indices: indices, layout: CANONICAL_LAYOUT, vertices: makeVertices(3) });
66
+ expect(geometry.topology).toBe('triangle-list');
67
+ expect(geometry.subsets).toHaveLength(1);
68
+ expect(geometry.subsets[0]).toEqual({ indexCount: 3, indexOffset: 0 });
69
+ });
70
+
71
+ it('keeps Uint16 indices below the 65k ceiling', () => {
72
+ const geometry = createMeshGeometry({
73
+ indices: new Uint16Array([0, 1, 2]),
74
+ layout: CANONICAL_LAYOUT,
75
+ vertices: makeVertices(3),
76
+ });
77
+ expect(geometry.indices).toBeInstanceOf(Uint16Array);
78
+ });
79
+
80
+ it('auto-promotes indices to Uint32 past 65535 vertices', () => {
81
+ const vertexCount = 70000;
82
+ const geometry = createMeshGeometry({
83
+ indices: new Uint16Array([0, 1, 2]),
84
+ layout: CANONICAL_LAYOUT,
85
+ vertices: makeVertices(vertexCount),
86
+ });
87
+ expect(geometry.indices).toBeInstanceOf(Uint32Array);
88
+ expect(getMeshGeometryVertexCount(geometry)).toBe(vertexCount);
89
+ });
90
+
91
+ it('keeps already-Uint32 indices as Uint32', () => {
92
+ const geometry = createMeshGeometry({
93
+ indices: new Uint32Array([0, 1, 2]),
94
+ layout: CANONICAL_LAYOUT,
95
+ vertices: makeVertices(3),
96
+ });
97
+ expect(geometry.indices).toBeInstanceOf(Uint32Array);
98
+ });
99
+
100
+ it('allows non-indexed geometry with a vertex-count subset', () => {
101
+ const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(6) });
102
+ expect(geometry.indices).toBeNull();
103
+ expect(geometry.subsets[0].indexCount).toBe(6);
104
+ });
105
+
106
+ it('initializes null GPU upload slots on the runtime', () => {
107
+ const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(1) });
108
+ const runtime = geometry[EntityRuntimeKey] as MeshGeometryRuntime;
109
+ expect(runtime.webglData).toBeNull();
110
+ expect(runtime.webgpuData).toBeNull();
111
+ });
112
+ });
113
+
114
+ describe('destroyMeshGeometryGlData', () => {
115
+ it('clears the webgl upload slot', () => {
116
+ const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(1) });
117
+ const runtime = geometry[EntityRuntimeKey] as MeshGeometryRuntime;
118
+ runtime.webglData = {} as MeshGeometryRuntime['webglData'];
119
+ destroyMeshGeometryGlData(geometry);
120
+ expect(runtime.webglData).toBeNull();
121
+ });
122
+ });
123
+
124
+ describe('destroyMeshGeometryWgpuData', () => {
125
+ it('clears the webgpu upload slot', () => {
126
+ const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(1) });
127
+ const runtime = geometry[EntityRuntimeKey] as MeshGeometryRuntime;
128
+ runtime.webgpuData = {} as MeshGeometryRuntime['webgpuData'];
129
+ destroyMeshGeometryWgpuData(geometry);
130
+ expect(runtime.webgpuData).toBeNull();
131
+ });
132
+ });
133
+
134
+ describe('getMeshGeometryIndexCount', () => {
135
+ it('returns the index length', () => {
136
+ const geometry = createMeshGeometry({
137
+ indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
138
+ layout: CANONICAL_LAYOUT,
139
+ vertices: makeVertices(4),
140
+ });
141
+ expect(getMeshGeometryIndexCount(geometry)).toBe(6);
142
+ });
143
+
144
+ it('returns 0 for non-indexed geometry', () => {
145
+ const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(3) });
146
+ expect(getMeshGeometryIndexCount(geometry)).toBe(0);
147
+ });
148
+ });
149
+
150
+ describe('getMeshGeometryVertexCount', () => {
151
+ it('derives the vertex count from stride', () => {
152
+ const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(5) });
153
+ expect(getMeshGeometryVertexCount(geometry)).toBe(5);
154
+ });
155
+ });
@@ -0,0 +1,195 @@
1
+ import type { VertexAttributeLayout } from '@flighthq/types';
2
+
3
+ import { createMeshGeometry } from './meshGeometry';
4
+ import {
5
+ getMeshGeometryVertexNormal,
6
+ getMeshGeometryVertexPosition,
7
+ getMeshGeometryVertexTangent,
8
+ getMeshGeometryVertexUv0,
9
+ getVertexAttribute,
10
+ getVertexAttributeFloatOffset,
11
+ setMeshGeometryVertexNormal,
12
+ setMeshGeometryVertexPosition,
13
+ setMeshGeometryVertexTangent,
14
+ setMeshGeometryVertexUv0,
15
+ } from './meshGeometryAttributes';
16
+
17
+ const CANONICAL_LAYOUT: VertexAttributeLayout = {
18
+ attributes: [
19
+ { byteOffset: 0, format: 'float32x3', semantic: 'position' },
20
+ { byteOffset: 12, format: 'float32x3', semantic: 'normal' },
21
+ { byteOffset: 24, format: 'float32x4', semantic: 'tangent' },
22
+ { byteOffset: 40, format: 'float32x2', semantic: 'uv0' },
23
+ ],
24
+ stride: 48,
25
+ };
26
+
27
+ // One vertex with known values: pos=(1,2,3) normal=(0,1,0) tangent=(1,0,0,1) uv=(0.5,0.75).
28
+ function makeOneVertex() {
29
+ const vertices = new Float32Array(12);
30
+ vertices[0] = 1;
31
+ vertices[1] = 2;
32
+ vertices[2] = 3; // position
33
+ vertices[3] = 0;
34
+ vertices[4] = 1;
35
+ vertices[5] = 0; // normal
36
+ vertices[6] = 1;
37
+ vertices[7] = 0;
38
+ vertices[8] = 0;
39
+ vertices[9] = 1; // tangent
40
+ vertices[10] = 0.5;
41
+ vertices[11] = 0.75; // uv0
42
+ return createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices });
43
+ }
44
+
45
+ describe('getMeshGeometryVertexNormal', () => {
46
+ it('reads the normal of the given vertex', () => {
47
+ const geo = makeOneVertex();
48
+ const out = { x: 0, y: 0, z: 0 };
49
+ expect(getMeshGeometryVertexNormal(out, geo, 0)).toBe(true);
50
+ expect(out.x).toBe(0);
51
+ expect(out.y).toBe(1);
52
+ expect(out.z).toBe(0);
53
+ });
54
+ it('returns false for out-of-range index', () => {
55
+ const out = { x: 0, y: 0, z: 0 };
56
+ expect(getMeshGeometryVertexNormal(out, makeOneVertex(), 1)).toBe(false);
57
+ });
58
+ it('returns false when layout has no normal', () => {
59
+ const layout: VertexAttributeLayout = {
60
+ attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
61
+ stride: 12,
62
+ };
63
+ const geo = createMeshGeometry({ layout, vertices: new Float32Array(3) });
64
+ const out = { x: 0, y: 0, z: 0 };
65
+ expect(getMeshGeometryVertexNormal(out, geo, 0)).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe('getMeshGeometryVertexPosition', () => {
70
+ it('reads the position of the given vertex', () => {
71
+ const geo = makeOneVertex();
72
+ const out = { x: 0, y: 0, z: 0 };
73
+ expect(getMeshGeometryVertexPosition(out, geo, 0)).toBe(true);
74
+ expect(out.x).toBe(1);
75
+ expect(out.y).toBe(2);
76
+ expect(out.z).toBe(3);
77
+ });
78
+ it('returns false for out-of-range index', () => {
79
+ const out = { x: 0, y: 0, z: 0 };
80
+ expect(getMeshGeometryVertexPosition(out, makeOneVertex(), -1)).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe('getMeshGeometryVertexTangent', () => {
85
+ it('reads the tangent including w', () => {
86
+ const geo = makeOneVertex();
87
+ const out = { w: 0, x: 0, y: 0, z: 0 };
88
+ expect(getMeshGeometryVertexTangent(out, geo, 0)).toBe(true);
89
+ expect(out.x).toBe(1);
90
+ expect(out.y).toBe(0);
91
+ expect(out.z).toBe(0);
92
+ expect(out.w).toBe(1);
93
+ });
94
+ });
95
+
96
+ describe('getMeshGeometryVertexUv0', () => {
97
+ it('reads the uv0 coordinates', () => {
98
+ const geo = makeOneVertex();
99
+ const out = { x: 0, y: 0 };
100
+ expect(getMeshGeometryVertexUv0(out, geo, 0)).toBe(true);
101
+ expect(out.x).toBeCloseTo(0.5);
102
+ expect(out.y).toBeCloseTo(0.75);
103
+ });
104
+ });
105
+
106
+ describe('getVertexAttribute', () => {
107
+ it('returns the attribute for a present semantic', () => {
108
+ const attr = getVertexAttribute(CANONICAL_LAYOUT, 'normal');
109
+ expect(attr).not.toBeNull();
110
+ expect(attr!.byteOffset).toBe(12);
111
+ expect(attr!.format).toBe('float32x3');
112
+ });
113
+ it('returns null for an absent semantic', () => {
114
+ expect(getVertexAttribute(CANONICAL_LAYOUT, 'color0')).toBeNull();
115
+ });
116
+ });
117
+
118
+ describe('getVertexAttributeFloatOffset', () => {
119
+ it('returns the float offset for a float32 semantic', () => {
120
+ expect(getVertexAttributeFloatOffset(CANONICAL_LAYOUT, 'position')).toBe(0);
121
+ expect(getVertexAttributeFloatOffset(CANONICAL_LAYOUT, 'normal')).toBe(3);
122
+ expect(getVertexAttributeFloatOffset(CANONICAL_LAYOUT, 'tangent')).toBe(6);
123
+ expect(getVertexAttributeFloatOffset(CANONICAL_LAYOUT, 'uv0')).toBe(10);
124
+ });
125
+ it('returns -1 for an absent semantic', () => {
126
+ expect(getVertexAttributeFloatOffset(CANONICAL_LAYOUT, 'color0')).toBe(-1);
127
+ });
128
+ it('returns -1 for a non-float32 attribute', () => {
129
+ const layout: VertexAttributeLayout = {
130
+ attributes: [{ byteOffset: 0, format: 'uint8x4', semantic: 'color0' }],
131
+ stride: 4,
132
+ };
133
+ expect(getVertexAttributeFloatOffset(layout, 'color0')).toBe(-1);
134
+ });
135
+ });
136
+
137
+ describe('setMeshGeometryVertexNormal', () => {
138
+ it('writes the normal and bumps version', () => {
139
+ const geo = makeOneVertex();
140
+ const v0 = geo.version;
141
+ expect(setMeshGeometryVertexNormal(geo, 0, 0, 0, 1)).toBe(true);
142
+ expect(geo.vertices[3]).toBe(0);
143
+ expect(geo.vertices[4]).toBe(0);
144
+ expect(geo.vertices[5]).toBe(1);
145
+ expect(geo.version).toBe(v0 + 1);
146
+ });
147
+ it('returns false for out-of-range index', () => {
148
+ const geo = makeOneVertex();
149
+ expect(setMeshGeometryVertexNormal(geo, 5, 0, 0, 1)).toBe(false);
150
+ });
151
+ });
152
+
153
+ describe('setMeshGeometryVertexPosition', () => {
154
+ it('writes the position and bumps version', () => {
155
+ const geo = makeOneVertex();
156
+ const v0 = geo.version;
157
+ expect(setMeshGeometryVertexPosition(geo, 0, 10, 20, 30)).toBe(true);
158
+ expect(geo.vertices[0]).toBe(10);
159
+ expect(geo.vertices[1]).toBe(20);
160
+ expect(geo.vertices[2]).toBe(30);
161
+ expect(geo.version).toBe(v0 + 1);
162
+ });
163
+ });
164
+
165
+ describe('setMeshGeometryVertexTangent', () => {
166
+ it('writes the tangent including w and bumps version', () => {
167
+ const geo = makeOneVertex();
168
+ const v0 = geo.version;
169
+ expect(setMeshGeometryVertexTangent(geo, 0, 0, 1, 0, -1)).toBe(true);
170
+ expect(geo.vertices[6]).toBe(0);
171
+ expect(geo.vertices[7]).toBe(1);
172
+ expect(geo.vertices[8]).toBe(0);
173
+ expect(geo.vertices[9]).toBe(-1);
174
+ expect(geo.version).toBe(v0 + 1);
175
+ });
176
+ });
177
+
178
+ describe('setMeshGeometryVertexUv0', () => {
179
+ it('writes the uv0 and bumps version', () => {
180
+ const geo = makeOneVertex();
181
+ const v0 = geo.version;
182
+ expect(setMeshGeometryVertexUv0(geo, 0, 0.1, 0.9)).toBe(true);
183
+ expect(geo.vertices[10]).toBeCloseTo(0.1);
184
+ expect(geo.vertices[11]).toBeCloseTo(0.9);
185
+ expect(geo.version).toBe(v0 + 1);
186
+ });
187
+ it('returns false for absent semantic', () => {
188
+ const layout: VertexAttributeLayout = {
189
+ attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
190
+ stride: 12,
191
+ };
192
+ const geo = createMeshGeometry({ layout, vertices: new Float32Array(3) });
193
+ expect(setMeshGeometryVertexUv0(geo, 0, 0.5, 0.5)).toBe(false);
194
+ });
195
+ });
@@ -0,0 +1,220 @@
1
+ import type { MeshGeometry } from '@flighthq/types';
2
+
3
+ import { getMeshGeometryIndexCount, getMeshGeometryVertexCount } from './meshGeometry';
4
+ import {
5
+ createBoxMeshGeometry,
6
+ createCapsuleMeshGeometry,
7
+ createCircleMeshGeometry,
8
+ createConeMeshGeometry,
9
+ createCylinderMeshGeometry,
10
+ createDodecahedronMeshGeometry,
11
+ createIcosahedronMeshGeometry,
12
+ createIcosphereMeshGeometry,
13
+ createOctahedronMeshGeometry,
14
+ createPlaneMeshGeometry,
15
+ createPolyhedronMeshGeometry,
16
+ createQuadMeshGeometry,
17
+ createRingMeshGeometry,
18
+ createSphereMeshGeometry,
19
+ createTetrahedronMeshGeometry,
20
+ createTorusKnotMeshGeometry,
21
+ createTorusMeshGeometry,
22
+ } from './meshGeometryBuilders';
23
+
24
+ function expectUnitNormals(geometry: Readonly<MeshGeometry>): void {
25
+ const count = getMeshGeometryVertexCount(geometry);
26
+ const stride = 12;
27
+ for (let i = 0; i < count; i++) {
28
+ const b = i * stride + 3;
29
+ const len = Math.hypot(geometry.vertices[b], geometry.vertices[b + 1], geometry.vertices[b + 2]);
30
+ expect(len).toBeCloseTo(1, 4);
31
+ }
32
+ }
33
+
34
+ function expectUnitTangents(geometry: Readonly<MeshGeometry>): void {
35
+ const count = getMeshGeometryVertexCount(geometry);
36
+ const stride = 12;
37
+ for (let i = 0; i < count; i++) {
38
+ const b = i * stride + 6;
39
+ const len = Math.hypot(geometry.vertices[b], geometry.vertices[b + 1], geometry.vertices[b + 2]);
40
+ expect(len).toBeCloseTo(1, 4);
41
+ expect(Math.abs(geometry.vertices[b + 3])).toBe(1);
42
+ }
43
+ }
44
+
45
+ describe('createBoxMeshGeometry', () => {
46
+ it('builds 24 vertices, 36 indices, and bounds at the half-extents', () => {
47
+ const geometry = createBoxMeshGeometry(2, 4, 6);
48
+ expect(getMeshGeometryVertexCount(geometry)).toBe(24);
49
+ expect(getMeshGeometryIndexCount(geometry)).toBe(36);
50
+ expect(geometry.bounds!.min.x).toBeCloseTo(-1);
51
+ expect(geometry.bounds!.max.y).toBeCloseTo(2);
52
+ expect(geometry.bounds!.max.z).toBeCloseTo(3);
53
+ expectUnitNormals(geometry);
54
+ expectUnitTangents(geometry);
55
+ });
56
+ });
57
+
58
+ describe('createCapsuleMeshGeometry', () => {
59
+ it('builds a capsule that spans -(height/2+radius)..(height/2+radius) on Y', () => {
60
+ const geometry = createCapsuleMeshGeometry(0.5, 1, 8, 4);
61
+ expect(getMeshGeometryVertexCount(geometry)).toBeGreaterThan(0);
62
+ expect(geometry.bounds!.min.y).toBeCloseTo(-1, 1);
63
+ expect(geometry.bounds!.max.y).toBeCloseTo(1, 1);
64
+ expectUnitNormals(geometry);
65
+ });
66
+ });
67
+
68
+ describe('createCircleMeshGeometry', () => {
69
+ it('builds a filled disc with segments triangles', () => {
70
+ const geometry = createCircleMeshGeometry(0.5, 16);
71
+ // 1 center + 17 rim (segments+1) = 18 vertices; 16 triangles = 48 indices.
72
+ expect(getMeshGeometryIndexCount(geometry)).toBe(48);
73
+ expect(geometry.bounds!.max.x).toBeCloseTo(0.5, 2);
74
+ expectUnitNormals(geometry);
75
+ });
76
+ });
77
+
78
+ describe('createConeMeshGeometry', () => {
79
+ it('builds a capped cone with an apex at +Y', () => {
80
+ const geometry = createConeMeshGeometry(0.5, 2, 16);
81
+ expect(getMeshGeometryVertexCount(geometry)).toBeGreaterThan(0);
82
+ expect(geometry.bounds!.max.y).toBeCloseTo(1);
83
+ expect(geometry.bounds!.min.y).toBeCloseTo(-1);
84
+ expectUnitNormals(geometry);
85
+ });
86
+ });
87
+
88
+ describe('createCylinderMeshGeometry', () => {
89
+ it('builds a capped cylinder bounded by its radius and height', () => {
90
+ const geometry = createCylinderMeshGeometry(0.5, 0.5, 2, 16);
91
+ expect(geometry.bounds!.max.y).toBeCloseTo(1);
92
+ expect(geometry.bounds!.max.x).toBeCloseTo(0.5, 2);
93
+ expectUnitNormals(geometry);
94
+ expectUnitTangents(geometry);
95
+ });
96
+ });
97
+
98
+ describe('createDodecahedronMeshGeometry', () => {
99
+ it('produces a non-empty geometry with positive vertex count', () => {
100
+ const geometry = createDodecahedronMeshGeometry(0.5);
101
+ expect(getMeshGeometryVertexCount(geometry)).toBeGreaterThan(0);
102
+ expectUnitNormals(geometry);
103
+ });
104
+ });
105
+
106
+ describe('createIcosahedronMeshGeometry', () => {
107
+ it('builds 20 triangles at detail=0', () => {
108
+ const geometry = createIcosahedronMeshGeometry(0.5);
109
+ // 20 faces × 3 independent verts = 60 vertices.
110
+ expect(getMeshGeometryVertexCount(geometry)).toBe(60);
111
+ expectUnitNormals(geometry);
112
+ });
113
+ });
114
+
115
+ describe('createIcosphereMeshGeometry', () => {
116
+ it('builds a sphere with evenly distributed vertices', () => {
117
+ const geometry = createIcosphereMeshGeometry(1, 1);
118
+ // Each icosahedron face is subdivided into 4, giving 20×4 = 80 faces × 3 = 240 verts.
119
+ expect(getMeshGeometryVertexCount(geometry)).toBe(240);
120
+ const count = getMeshGeometryVertexCount(geometry);
121
+ // All vertices should lie on the unit sphere.
122
+ for (let i = 0; i < count; i++) {
123
+ const b = i * 12;
124
+ const r = Math.hypot(geometry.vertices[b], geometry.vertices[b + 1], geometry.vertices[b + 2]);
125
+ expect(r).toBeCloseTo(1, 3);
126
+ }
127
+ expectUnitNormals(geometry);
128
+ });
129
+ });
130
+
131
+ describe('createOctahedronMeshGeometry', () => {
132
+ it('builds 8 triangles at detail=0', () => {
133
+ const geometry = createOctahedronMeshGeometry(0.5);
134
+ // 8 faces × 3 verts = 24 vertices.
135
+ expect(getMeshGeometryVertexCount(geometry)).toBe(24);
136
+ expectUnitNormals(geometry);
137
+ });
138
+ });
139
+
140
+ describe('createPlaneMeshGeometry', () => {
141
+ it('builds a subdivided plane in the XZ plane with +Y normals', () => {
142
+ const geometry = createPlaneMeshGeometry(2, 2, 2, 2);
143
+ expect(getMeshGeometryVertexCount(geometry)).toBe(9);
144
+ expect(getMeshGeometryIndexCount(geometry)).toBe(24);
145
+ expect(geometry.vertices[4]).toBeCloseTo(1);
146
+ expect(geometry.bounds!.min.x).toBeCloseTo(-1);
147
+ expect(geometry.bounds!.max.z).toBeCloseTo(1);
148
+ });
149
+ });
150
+
151
+ describe('createPolyhedronMeshGeometry', () => {
152
+ it('accepts custom vertex/face data', () => {
153
+ const verts: ReadonlyArray<readonly [number, number, number]> = [
154
+ [0, 1, 0],
155
+ [-1, -1, 0],
156
+ [1, -1, 0],
157
+ ];
158
+ const faces: ReadonlyArray<readonly [number, number, number]> = [[0, 1, 2]];
159
+ const geometry = createPolyhedronMeshGeometry(verts, faces, 1, 0);
160
+ expect(getMeshGeometryVertexCount(geometry)).toBe(3);
161
+ });
162
+ });
163
+
164
+ describe('createQuadMeshGeometry', () => {
165
+ it('builds a unit quad in the XY plane with +Z normals', () => {
166
+ const geometry = createQuadMeshGeometry();
167
+ expect(getMeshGeometryVertexCount(geometry)).toBe(4);
168
+ expect(getMeshGeometryIndexCount(geometry)).toBe(6);
169
+ expect(geometry.vertices[5]).toBeCloseTo(1);
170
+ expectUnitTangents(geometry);
171
+ });
172
+ });
173
+
174
+ describe('createRingMeshGeometry', () => {
175
+ it('builds a ring bounded by outerRadius', () => {
176
+ const geometry = createRingMeshGeometry(0.25, 0.5, 16);
177
+ expect(getMeshGeometryVertexCount(geometry)).toBeGreaterThan(0);
178
+ expect(geometry.bounds!.max.x).toBeCloseTo(0.5, 2);
179
+ expectUnitNormals(geometry);
180
+ });
181
+ });
182
+
183
+ describe('createSphereMeshGeometry', () => {
184
+ it('builds a sphere whose vertices lie on the radius', () => {
185
+ const geometry = createSphereMeshGeometry(1, 16, 8);
186
+ const count = getMeshGeometryVertexCount(geometry);
187
+ for (let i = 0; i < count; i++) {
188
+ const b = i * 12;
189
+ const r = Math.hypot(geometry.vertices[b], geometry.vertices[b + 1], geometry.vertices[b + 2]);
190
+ expect(r).toBeCloseTo(1, 4);
191
+ }
192
+ expectUnitNormals(geometry);
193
+ });
194
+ });
195
+
196
+ describe('createTetrahedronMeshGeometry', () => {
197
+ it('builds 4 triangles at detail=0', () => {
198
+ const geometry = createTetrahedronMeshGeometry(0.5);
199
+ // 4 faces × 3 verts = 12 vertices.
200
+ expect(getMeshGeometryVertexCount(geometry)).toBe(12);
201
+ expectUnitNormals(geometry);
202
+ });
203
+ });
204
+
205
+ describe('createTorusKnotMeshGeometry', () => {
206
+ it('produces a non-empty geometry', () => {
207
+ const geometry = createTorusKnotMeshGeometry(0.5, 0.15, 32, 8);
208
+ expect(getMeshGeometryVertexCount(geometry)).toBeGreaterThan(0);
209
+ expect(getMeshGeometryIndexCount(geometry)).toBeGreaterThan(0);
210
+ });
211
+ });
212
+
213
+ describe('createTorusMeshGeometry', () => {
214
+ it('builds a torus bounded by radius + tube', () => {
215
+ const geometry = createTorusMeshGeometry(0.5, 0.2, 12, 24);
216
+ expect(geometry.bounds!.max.x).toBeCloseTo(0.7, 2);
217
+ expect(geometry.bounds!.max.z).toBeCloseTo(0.2, 2);
218
+ expectUnitNormals(geometry);
219
+ });
220
+ });