@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.
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/meshGeometry.d.ts +15 -0
- package/dist/meshGeometry.d.ts.map +1 -0
- package/dist/meshGeometry.js +128 -0
- package/dist/meshGeometry.js.map +1 -0
- package/dist/meshGeometryAttributes.d.ts +28 -0
- package/dist/meshGeometryAttributes.d.ts.map +1 -0
- package/dist/meshGeometryAttributes.js +144 -0
- package/dist/meshGeometryAttributes.js.map +1 -0
- package/dist/meshGeometryBuilders.d.ts +19 -0
- package/dist/meshGeometryBuilders.d.ts.map +1 -0
- package/dist/meshGeometryBuilders.js +772 -0
- package/dist/meshGeometryBuilders.js.map +1 -0
- package/dist/meshGeometryCompute.d.ts +7 -0
- package/dist/meshGeometryCompute.d.ts.map +1 -0
- package/dist/meshGeometryCompute.js +299 -0
- package/dist/meshGeometryCompute.js.map +1 -0
- package/dist/meshGeometryIndex.d.ts +4 -0
- package/dist/meshGeometryIndex.d.ts.map +1 -0
- package/dist/meshGeometryIndex.js +70 -0
- package/dist/meshGeometryIndex.js.map +1 -0
- package/dist/meshGeometryLayout.d.ts +4 -0
- package/dist/meshGeometryLayout.d.ts.map +1 -0
- package/dist/meshGeometryLayout.js +83 -0
- package/dist/meshGeometryLayout.js.map +1 -0
- package/dist/meshGeometryOperations.d.ts +12 -0
- package/dist/meshGeometryOperations.d.ts.map +1 -0
- package/dist/meshGeometryOperations.js +209 -0
- package/dist/meshGeometryOperations.js.map +1 -0
- package/dist/meshGeometrySubset.d.ts +5 -0
- package/dist/meshGeometrySubset.d.ts.map +1 -0
- package/dist/meshGeometrySubset.js +40 -0
- package/dist/meshGeometrySubset.js.map +1 -0
- package/dist/meshGeometryTransforms.d.ts +7 -0
- package/dist/meshGeometryTransforms.d.ts.map +1 -0
- package/dist/meshGeometryTransforms.js +235 -0
- package/dist/meshGeometryTransforms.js.map +1 -0
- package/dist/meshGeometryUvs.d.ts +5 -0
- package/dist/meshGeometryUvs.d.ts.map +1 -0
- package/dist/meshGeometryUvs.js +58 -0
- package/dist/meshGeometryUvs.js.map +1 -0
- package/package.json +39 -0
- package/src/meshGeometry.test.ts +155 -0
- package/src/meshGeometryAttributes.test.ts +195 -0
- package/src/meshGeometryBuilders.test.ts +220 -0
- package/src/meshGeometryCompute.test.ts +218 -0
- package/src/meshGeometryIndex.test.ts +84 -0
- package/src/meshGeometryLayout.test.ts +132 -0
- package/src/meshGeometryOperations.test.ts +154 -0
- package/src/meshGeometrySubset.test.ts +75 -0
- package/src/meshGeometryTransforms.test.ts +169 -0
- package/src/meshGeometryUvs.test.ts +128 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createAabb, createBoundingSphere } from '@flighthq/geometry';
|
|
2
|
+
import type { VertexAttributeLayout } from '@flighthq/types';
|
|
3
|
+
|
|
4
|
+
import { createMeshGeometry } from './meshGeometry';
|
|
5
|
+
import {
|
|
6
|
+
computeMeshGeometryBoundingSphere,
|
|
7
|
+
computeMeshGeometryBounds,
|
|
8
|
+
computeMeshGeometryFlatNormals,
|
|
9
|
+
computeMeshGeometryNormals,
|
|
10
|
+
computeMeshGeometryTangents,
|
|
11
|
+
} from './meshGeometryCompute';
|
|
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
|
+
// One CCW triangle in the XY plane (normal +Z) with a u-along-X, v-along-Y UV mapping.
|
|
24
|
+
function makeTriangle() {
|
|
25
|
+
const vertices = new Float32Array(3 * 12);
|
|
26
|
+
const setVertex = (i: number, px: number, py: number, u: number, v: number): void => {
|
|
27
|
+
const b = i * 12;
|
|
28
|
+
vertices[b] = px;
|
|
29
|
+
vertices[b + 1] = py;
|
|
30
|
+
vertices[b + 2] = 0;
|
|
31
|
+
vertices[b + 10] = u;
|
|
32
|
+
vertices[b + 11] = v;
|
|
33
|
+
};
|
|
34
|
+
setVertex(0, 0, 0, 0, 0);
|
|
35
|
+
setVertex(1, 1, 0, 1, 0);
|
|
36
|
+
setVertex(2, 0, 1, 0, 1);
|
|
37
|
+
const indices = new Uint16Array([0, 1, 2]);
|
|
38
|
+
return createMeshGeometry({ indices: indices, layout: CANONICAL_LAYOUT, vertices: vertices });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('computeMeshGeometryBoundingSphere', () => {
|
|
42
|
+
it('writes the center and radius for a unit-side triangle', () => {
|
|
43
|
+
const geometry = makeTriangle();
|
|
44
|
+
const out = createBoundingSphere();
|
|
45
|
+
computeMeshGeometryBoundingSphere(out, geometry);
|
|
46
|
+
// AABB of the triangle: min=(0,0,0) max=(1,1,0) → center=(0.5,0.5,0).
|
|
47
|
+
expect(out.center.x).toBeCloseTo(0.5);
|
|
48
|
+
expect(out.center.y).toBeCloseTo(0.5);
|
|
49
|
+
expect(out.center.z).toBeCloseTo(0);
|
|
50
|
+
expect(out.radius).toBeGreaterThan(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('radius encloses all vertices', () => {
|
|
54
|
+
const geometry = makeTriangle();
|
|
55
|
+
const out = createBoundingSphere();
|
|
56
|
+
computeMeshGeometryBoundingSphere(out, geometry);
|
|
57
|
+
const cx = out.center.x,
|
|
58
|
+
cy = out.center.y,
|
|
59
|
+
cz = out.center.z;
|
|
60
|
+
const r = out.radius;
|
|
61
|
+
const verts = geometry.vertices;
|
|
62
|
+
for (let i = 0; i < 3; i++) {
|
|
63
|
+
const dx = verts[i * 12] - cx;
|
|
64
|
+
const dy = verts[i * 12 + 1] - cy;
|
|
65
|
+
const dz = verts[i * 12 + 2] - cz;
|
|
66
|
+
expect(Math.sqrt(dx * dx + dy * dy + dz * dz)).toBeLessThanOrEqual(r + 1e-6);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('yields empty sphere (radius -1) for an empty vertex stream', () => {
|
|
71
|
+
const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: new Float32Array(0) });
|
|
72
|
+
const out = createBoundingSphere();
|
|
73
|
+
computeMeshGeometryBoundingSphere(out, geometry);
|
|
74
|
+
expect(out.radius).toBe(-1);
|
|
75
|
+
expect(out.center.x).toBe(0);
|
|
76
|
+
expect(out.center.y).toBe(0);
|
|
77
|
+
expect(out.center.z).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('computeMeshGeometryBounds', () => {
|
|
82
|
+
it('writes the tight AABB of all positions', () => {
|
|
83
|
+
const geometry = makeTriangle();
|
|
84
|
+
const out = createAabb();
|
|
85
|
+
computeMeshGeometryBounds(out, geometry);
|
|
86
|
+
expect(out.min.x).toBe(0);
|
|
87
|
+
expect(out.min.y).toBe(0);
|
|
88
|
+
expect(out.max.x).toBe(1);
|
|
89
|
+
expect(out.max.y).toBe(1);
|
|
90
|
+
expect(out.max.z).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('is safe when out aliases geometry bounds', () => {
|
|
94
|
+
const geometry = makeTriangle();
|
|
95
|
+
geometry.bounds = createAabb();
|
|
96
|
+
computeMeshGeometryBounds(geometry.bounds, geometry);
|
|
97
|
+
expect(geometry.bounds.max.x).toBe(1);
|
|
98
|
+
expect(geometry.bounds.min.y).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('yields an empty box for an empty vertex stream', () => {
|
|
102
|
+
const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: new Float32Array(0) });
|
|
103
|
+
const out = createAabb(1, 2, 3, 4, 5, 6);
|
|
104
|
+
computeMeshGeometryBounds(out, geometry);
|
|
105
|
+
expect(out.min.x).toBe(Number.POSITIVE_INFINITY);
|
|
106
|
+
expect(out.max.x).toBe(Number.NEGATIVE_INFINITY);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('computeMeshGeometryFlatNormals', () => {
|
|
111
|
+
it('writes the face normal to all three vertices of a CCW triangle (indexed)', () => {
|
|
112
|
+
const geometry = makeTriangle();
|
|
113
|
+
computeMeshGeometryFlatNormals(geometry, geometry);
|
|
114
|
+
// All three vertices should have the face normal +Z.
|
|
115
|
+
for (let i = 0; i < 3; i++) {
|
|
116
|
+
expect(geometry.vertices[i * 12 + 3]).toBeCloseTo(0);
|
|
117
|
+
expect(geometry.vertices[i * 12 + 4]).toBeCloseTo(0);
|
|
118
|
+
expect(geometry.vertices[i * 12 + 5]).toBeCloseTo(1);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('writes the face normal to all three vertices of a CCW triangle (non-indexed)', () => {
|
|
123
|
+
const vertices = new Float32Array(3 * 12);
|
|
124
|
+
const setVertex = (i: number, px: number, py: number): void => {
|
|
125
|
+
const b = i * 12;
|
|
126
|
+
vertices[b] = px;
|
|
127
|
+
vertices[b + 1] = py;
|
|
128
|
+
vertices[b + 2] = 0;
|
|
129
|
+
};
|
|
130
|
+
setVertex(0, 0, 0);
|
|
131
|
+
setVertex(1, 1, 0);
|
|
132
|
+
setVertex(2, 0, 1);
|
|
133
|
+
const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: vertices });
|
|
134
|
+
computeMeshGeometryFlatNormals(geometry, geometry);
|
|
135
|
+
for (let i = 0; i < 3; i++) {
|
|
136
|
+
expect(geometry.vertices[i * 12 + 3]).toBeCloseTo(0);
|
|
137
|
+
expect(geometry.vertices[i * 12 + 4]).toBeCloseTo(0);
|
|
138
|
+
expect(geometry.vertices[i * 12 + 5]).toBeCloseTo(1);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('is safe when out aliases geometry (alias-safe)', () => {
|
|
143
|
+
const geometry = makeTriangle();
|
|
144
|
+
const prevVersion = geometry.version;
|
|
145
|
+
computeMeshGeometryFlatNormals(geometry, geometry);
|
|
146
|
+
expect(geometry.version).toBe(prevVersion + 1);
|
|
147
|
+
expect(geometry.vertices[5]).toBeCloseTo(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('bumps version', () => {
|
|
151
|
+
const geometry = makeTriangle();
|
|
152
|
+
const prevVersion = geometry.version;
|
|
153
|
+
computeMeshGeometryFlatNormals(geometry, geometry);
|
|
154
|
+
expect(geometry.version).toBe(prevVersion + 1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('produces unit-length normals', () => {
|
|
158
|
+
const geometry = makeTriangle();
|
|
159
|
+
computeMeshGeometryFlatNormals(geometry, geometry);
|
|
160
|
+
for (let i = 0; i < 3; i++) {
|
|
161
|
+
const nx = geometry.vertices[i * 12 + 3];
|
|
162
|
+
const ny = geometry.vertices[i * 12 + 4];
|
|
163
|
+
const nz = geometry.vertices[i * 12 + 5];
|
|
164
|
+
expect(Math.sqrt(nx * nx + ny * ny + nz * nz)).toBeCloseTo(1);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('computeMeshGeometryNormals', () => {
|
|
170
|
+
it('writes the unit face normal in-place for a CCW triangle', () => {
|
|
171
|
+
const geometry = makeTriangle();
|
|
172
|
+
computeMeshGeometryNormals(geometry, geometry);
|
|
173
|
+
// Face normal of a CCW XY triangle is +Z.
|
|
174
|
+
expect(geometry.vertices[3]).toBeCloseTo(0);
|
|
175
|
+
expect(geometry.vertices[4]).toBeCloseTo(0);
|
|
176
|
+
expect(geometry.vertices[5]).toBeCloseTo(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('writes into a distinct out geometry', () => {
|
|
180
|
+
const source = makeTriangle();
|
|
181
|
+
const out = makeTriangle();
|
|
182
|
+
computeMeshGeometryNormals(out, source);
|
|
183
|
+
expect(out.vertices[5]).toBeCloseTo(1);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('computeMeshGeometryTangents', () => {
|
|
188
|
+
it('writes a unit tangent aligned with +X for the canonical UV mapping', () => {
|
|
189
|
+
const geometry = makeTriangle();
|
|
190
|
+
computeMeshGeometryNormals(geometry, geometry);
|
|
191
|
+
computeMeshGeometryTangents(geometry, geometry);
|
|
192
|
+
// u increases along +X, so tangent.xyz ~ (1,0,0).
|
|
193
|
+
expect(geometry.vertices[6]).toBeCloseTo(1);
|
|
194
|
+
expect(geometry.vertices[7]).toBeCloseTo(0);
|
|
195
|
+
expect(geometry.vertices[8]).toBeCloseTo(0);
|
|
196
|
+
// Right-handed mapping => positive handedness.
|
|
197
|
+
expect(geometry.vertices[9]).toBe(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('writes into a distinct out geometry', () => {
|
|
201
|
+
const source = makeTriangle();
|
|
202
|
+
computeMeshGeometryNormals(source, source);
|
|
203
|
+
const out = makeTriangle();
|
|
204
|
+
computeMeshGeometryNormals(out, out);
|
|
205
|
+
computeMeshGeometryTangents(out, source);
|
|
206
|
+
expect(out.vertices[6]).toBeCloseTo(1);
|
|
207
|
+
expect(out.vertices[9]).toBe(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('produces negative handedness when the v axis is flipped', () => {
|
|
211
|
+
const geometry = makeTriangle();
|
|
212
|
+
// Flip v on vertex 2 so the UV winding reverses relative to geometry.
|
|
213
|
+
geometry.vertices[2 * 12 + 11] = -1;
|
|
214
|
+
computeMeshGeometryNormals(geometry, geometry);
|
|
215
|
+
computeMeshGeometryTangents(geometry, geometry);
|
|
216
|
+
expect(geometry.vertices[9]).toBe(-1);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { VertexAttributeLayout } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import { createMeshGeometry } from './meshGeometry';
|
|
4
|
+
import { computeMeshGeometryWireframeIndices, expandMeshGeometryIndices } from './meshGeometryIndex';
|
|
5
|
+
|
|
6
|
+
const CANONICAL_LAYOUT: VertexAttributeLayout = {
|
|
7
|
+
attributes: [
|
|
8
|
+
{ byteOffset: 0, format: 'float32x3', semantic: 'position' },
|
|
9
|
+
{ byteOffset: 12, format: 'float32x3', semantic: 'normal' },
|
|
10
|
+
{ byteOffset: 24, format: 'float32x4', semantic: 'tangent' },
|
|
11
|
+
{ byteOffset: 40, format: 'float32x2', semantic: 'uv0' },
|
|
12
|
+
],
|
|
13
|
+
stride: 48,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// A quad: 4 vertices, 2 triangles sharing the (1, 2) edge, position packed into slot 0.
|
|
17
|
+
function makeQuad() {
|
|
18
|
+
const vertices = new Float32Array(4 * 12);
|
|
19
|
+
const setPosition = (i: number, x: number, y: number): void => {
|
|
20
|
+
vertices[i * 12] = x;
|
|
21
|
+
vertices[i * 12 + 1] = y;
|
|
22
|
+
};
|
|
23
|
+
setPosition(0, 0, 0);
|
|
24
|
+
setPosition(1, 1, 0);
|
|
25
|
+
setPosition(2, 0, 1);
|
|
26
|
+
setPosition(3, 1, 1);
|
|
27
|
+
const indices = new Uint16Array([0, 1, 2, 2, 1, 3]);
|
|
28
|
+
return createMeshGeometry({ indices: indices, layout: CANONICAL_LAYOUT, vertices: vertices });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('computeMeshGeometryWireframeIndices', () => {
|
|
32
|
+
it('expands each triangle into three edge line segments', () => {
|
|
33
|
+
const geometry = makeQuad();
|
|
34
|
+
const lines = computeMeshGeometryWireframeIndices(geometry);
|
|
35
|
+
// 2 triangles * 3 edges * 2 indices.
|
|
36
|
+
expect(lines.length).toBe(12);
|
|
37
|
+
expect(Array.from(lines.slice(0, 6))).toEqual([0, 1, 1, 2, 2, 0]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('mirrors the source index width (Uint16 stays Uint16)', () => {
|
|
41
|
+
const geometry = makeQuad();
|
|
42
|
+
const lines = computeMeshGeometryWireframeIndices(geometry);
|
|
43
|
+
expect(lines).toBeInstanceOf(Uint16Array);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns an empty buffer for non-triangle topology', () => {
|
|
47
|
+
const vertices = new Float32Array(2 * 12);
|
|
48
|
+
const geometry = createMeshGeometry({
|
|
49
|
+
indices: new Uint16Array([0, 1]),
|
|
50
|
+
layout: CANONICAL_LAYOUT,
|
|
51
|
+
topology: 'line-list',
|
|
52
|
+
vertices: vertices,
|
|
53
|
+
});
|
|
54
|
+
const lines = computeMeshGeometryWireframeIndices(geometry);
|
|
55
|
+
expect(lines.length).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('expandMeshGeometryIndices', () => {
|
|
60
|
+
it('un-welds shared vertices into per-index copies', () => {
|
|
61
|
+
const geometry = makeQuad();
|
|
62
|
+
const expanded = expandMeshGeometryIndices(geometry);
|
|
63
|
+
expect(expanded.indices).toBeNull();
|
|
64
|
+
// 6 indices -> 6 standalone vertices.
|
|
65
|
+
expect(expanded.vertices.length).toBe(6 * 12);
|
|
66
|
+
// Index 2 (position 0,1) appears at flat positions 2 and 3.
|
|
67
|
+
expect(expanded.vertices[2 * 12]).toBe(0);
|
|
68
|
+
expect(expanded.vertices[2 * 12 + 1]).toBe(1);
|
|
69
|
+
expect(expanded.vertices[3 * 12]).toBe(0);
|
|
70
|
+
expect(expanded.vertices[3 * 12 + 1]).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('deep-copies non-indexed geometry as-is', () => {
|
|
74
|
+
const vertices = new Float32Array(3 * 12);
|
|
75
|
+
vertices[0] = 7;
|
|
76
|
+
const geometry = createMeshGeometry({ indices: null, layout: CANONICAL_LAYOUT, vertices: vertices });
|
|
77
|
+
const expanded = expandMeshGeometryIndices(geometry);
|
|
78
|
+
expect(expanded.indices).toBeNull();
|
|
79
|
+
expect(expanded.vertices.length).toBe(3 * 12);
|
|
80
|
+
expect(expanded.vertices[0]).toBe(7);
|
|
81
|
+
// Distinct backing array.
|
|
82
|
+
expect(expanded.vertices).not.toBe(geometry.vertices);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { VertexAttributeLayout } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import { createMeshGeometry } from './meshGeometry';
|
|
4
|
+
import { CANONICAL_MESH_GEOMETRY_LAYOUT, convertMeshGeometryLayout } from './meshGeometryLayout';
|
|
5
|
+
|
|
6
|
+
// Minimal position-only layout: 3 floats / 12 bytes.
|
|
7
|
+
const POSITION_ONLY_LAYOUT: VertexAttributeLayout = {
|
|
8
|
+
attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
|
|
9
|
+
stride: 12,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// position + uv0 layout: 5 floats / 20 bytes.
|
|
13
|
+
const POSITION_UV0_LAYOUT: VertexAttributeLayout = {
|
|
14
|
+
attributes: [
|
|
15
|
+
{ byteOffset: 0, format: 'float32x3', semantic: 'position' },
|
|
16
|
+
{ byteOffset: 12, format: 'float32x2', semantic: 'uv0' },
|
|
17
|
+
],
|
|
18
|
+
stride: 20,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function makeCanonicalGeometry() {
|
|
22
|
+
const vertices = new Float32Array(12); // one vertex, canonical layout
|
|
23
|
+
// position = (1, 2, 3), normal = (0, 1, 0), tangent = (1, 0, 0, 1), uv0 = (0.5, 0.25)
|
|
24
|
+
vertices[0] = 1;
|
|
25
|
+
vertices[1] = 2;
|
|
26
|
+
vertices[2] = 3;
|
|
27
|
+
vertices[3] = 0;
|
|
28
|
+
vertices[4] = 1;
|
|
29
|
+
vertices[5] = 0;
|
|
30
|
+
vertices[6] = 1;
|
|
31
|
+
vertices[7] = 0;
|
|
32
|
+
vertices[8] = 0;
|
|
33
|
+
vertices[9] = 1;
|
|
34
|
+
vertices[10] = 0.5;
|
|
35
|
+
vertices[11] = 0.25;
|
|
36
|
+
return createMeshGeometry({ layout: CANONICAL_MESH_GEOMETRY_LAYOUT, vertices: vertices });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('CANONICAL_MESH_GEOMETRY_LAYOUT', () => {
|
|
40
|
+
it('has stride 48', () => {
|
|
41
|
+
expect(CANONICAL_MESH_GEOMETRY_LAYOUT.stride).toBe(48);
|
|
42
|
+
});
|
|
43
|
+
it('has four attributes: position, normal, tangent, uv0', () => {
|
|
44
|
+
const semantics = CANONICAL_MESH_GEOMETRY_LAYOUT.attributes.map((a) => a.semantic);
|
|
45
|
+
expect(semantics).toContain('position');
|
|
46
|
+
expect(semantics).toContain('normal');
|
|
47
|
+
expect(semantics).toContain('tangent');
|
|
48
|
+
expect(semantics).toContain('uv0');
|
|
49
|
+
});
|
|
50
|
+
it('position attribute is at byteOffset 0 with format float32x3', () => {
|
|
51
|
+
const pos = CANONICAL_MESH_GEOMETRY_LAYOUT.attributes.find((a) => a.semantic === 'position');
|
|
52
|
+
expect(pos?.byteOffset).toBe(0);
|
|
53
|
+
expect(pos?.format).toBe('float32x3');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('convertMeshGeometryLayout', () => {
|
|
58
|
+
it('strips to position-only layout', () => {
|
|
59
|
+
const source = makeCanonicalGeometry();
|
|
60
|
+
const converted = convertMeshGeometryLayout(source, POSITION_ONLY_LAYOUT);
|
|
61
|
+
expect(converted.layout.stride).toBe(12);
|
|
62
|
+
expect(converted.vertices.length).toBe(3);
|
|
63
|
+
expect(converted.vertices[0]).toBeCloseTo(1);
|
|
64
|
+
expect(converted.vertices[1]).toBeCloseTo(2);
|
|
65
|
+
expect(converted.vertices[2]).toBeCloseTo(3);
|
|
66
|
+
});
|
|
67
|
+
it('strips to position + uv0 layout', () => {
|
|
68
|
+
const source = makeCanonicalGeometry();
|
|
69
|
+
const converted = convertMeshGeometryLayout(source, POSITION_UV0_LAYOUT);
|
|
70
|
+
expect(converted.layout.stride).toBe(20);
|
|
71
|
+
expect(converted.vertices.length).toBe(5);
|
|
72
|
+
expect(converted.vertices[0]).toBeCloseTo(1);
|
|
73
|
+
expect(converted.vertices[1]).toBeCloseTo(2);
|
|
74
|
+
expect(converted.vertices[2]).toBeCloseTo(3);
|
|
75
|
+
expect(converted.vertices[3]).toBeCloseTo(0.5);
|
|
76
|
+
expect(converted.vertices[4]).toBeCloseTo(0.25);
|
|
77
|
+
});
|
|
78
|
+
it('zero-fills attributes absent in the source', () => {
|
|
79
|
+
// Source has position only; target requests position + normal.
|
|
80
|
+
const posOnlySource = createMeshGeometry({
|
|
81
|
+
layout: POSITION_ONLY_LAYOUT,
|
|
82
|
+
vertices: new Float32Array([1, 2, 3]),
|
|
83
|
+
});
|
|
84
|
+
const targetLayout: VertexAttributeLayout = {
|
|
85
|
+
attributes: [
|
|
86
|
+
{ byteOffset: 0, format: 'float32x3', semantic: 'position' },
|
|
87
|
+
{ byteOffset: 12, format: 'float32x3', semantic: 'normal' },
|
|
88
|
+
],
|
|
89
|
+
stride: 24,
|
|
90
|
+
};
|
|
91
|
+
const converted = convertMeshGeometryLayout(posOnlySource, targetLayout);
|
|
92
|
+
expect(converted.vertices[0]).toBeCloseTo(1);
|
|
93
|
+
expect(converted.vertices[1]).toBeCloseTo(2);
|
|
94
|
+
expect(converted.vertices[2]).toBeCloseTo(3);
|
|
95
|
+
// Normal was not in source → zero-filled.
|
|
96
|
+
expect(converted.vertices[3]).toBe(0);
|
|
97
|
+
expect(converted.vertices[4]).toBe(0);
|
|
98
|
+
expect(converted.vertices[5]).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
it('preserves vertex count', () => {
|
|
101
|
+
const vertices = new Float32Array(3 * 12); // 3 canonical vertices
|
|
102
|
+
vertices[0] = 1;
|
|
103
|
+
vertices[12] = 2;
|
|
104
|
+
vertices[24] = 3;
|
|
105
|
+
const source = createMeshGeometry({ layout: CANONICAL_MESH_GEOMETRY_LAYOUT, vertices: vertices });
|
|
106
|
+
const converted = convertMeshGeometryLayout(source, POSITION_ONLY_LAYOUT);
|
|
107
|
+
expect(converted.vertices.length).toBe(9); // 3 vertices × 3 floats
|
|
108
|
+
expect(converted.vertices[0]).toBeCloseTo(1);
|
|
109
|
+
expect(converted.vertices[3]).toBeCloseTo(2);
|
|
110
|
+
expect(converted.vertices[6]).toBeCloseTo(3);
|
|
111
|
+
});
|
|
112
|
+
it('preserves index buffer and topology', () => {
|
|
113
|
+
const vertices = new Float32Array(3 * 12);
|
|
114
|
+
const indices = new Uint16Array([0, 1, 2]);
|
|
115
|
+
const source = createMeshGeometry({
|
|
116
|
+
indices: indices,
|
|
117
|
+
layout: CANONICAL_MESH_GEOMETRY_LAYOUT,
|
|
118
|
+
topology: 'triangle-list',
|
|
119
|
+
vertices: vertices,
|
|
120
|
+
});
|
|
121
|
+
const converted = convertMeshGeometryLayout(source, POSITION_ONLY_LAYOUT);
|
|
122
|
+
expect(converted.topology).toBe('triangle-list');
|
|
123
|
+
expect(converted.indices).not.toBeNull();
|
|
124
|
+
expect(converted.indices?.length).toBe(3);
|
|
125
|
+
});
|
|
126
|
+
it('does not modify the source geometry', () => {
|
|
127
|
+
const source = makeCanonicalGeometry();
|
|
128
|
+
const originalData = Array.from(source.vertices);
|
|
129
|
+
convertMeshGeometryLayout(source, POSITION_ONLY_LAYOUT);
|
|
130
|
+
expect(Array.from(source.vertices)).toEqual(originalData);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { VertexAttributeLayout } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import { createMeshGeometry, getMeshGeometryIndexCount, getMeshGeometryVertexCount } from './meshGeometry';
|
|
4
|
+
import { createBoxMeshGeometry, createQuadMeshGeometry } from './meshGeometryBuilders';
|
|
5
|
+
import {
|
|
6
|
+
createMeshGeometryFromAttributes,
|
|
7
|
+
getMeshGeometryTriangleCount,
|
|
8
|
+
mergeMeshGeometries,
|
|
9
|
+
validateMeshGeometry,
|
|
10
|
+
} from './meshGeometryOperations';
|
|
11
|
+
|
|
12
|
+
const CANONICAL_LAYOUT: VertexAttributeLayout = {
|
|
13
|
+
attributes: [
|
|
14
|
+
{ byteOffset: 0, format: 'float32x3', semantic: 'position' },
|
|
15
|
+
{ byteOffset: 12, format: 'float32x3', semantic: 'normal' },
|
|
16
|
+
{ byteOffset: 24, format: 'float32x4', semantic: 'tangent' },
|
|
17
|
+
{ byteOffset: 40, format: 'float32x2', semantic: 'uv0' },
|
|
18
|
+
],
|
|
19
|
+
stride: 48,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const POSITION_ONLY_LAYOUT: VertexAttributeLayout = {
|
|
23
|
+
attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
|
|
24
|
+
stride: 12,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('createMeshGeometryFromAttributes', () => {
|
|
28
|
+
it('builds a triangle from positions only, computing normals', () => {
|
|
29
|
+
const positions = [0, 0, 0, 1, 0, 0, 0, 1, 0];
|
|
30
|
+
const indices = [0, 1, 2];
|
|
31
|
+
const geo = createMeshGeometryFromAttributes({ indices, positions });
|
|
32
|
+
expect(getMeshGeometryVertexCount(geo)).toBe(3);
|
|
33
|
+
expect(getMeshGeometryIndexCount(geo)).toBe(3);
|
|
34
|
+
// Normal should be +Z for this CCW XY triangle.
|
|
35
|
+
expect(geo.vertices[5]).toBeCloseTo(1, 3);
|
|
36
|
+
});
|
|
37
|
+
it('uses supplied normals without recomputing', () => {
|
|
38
|
+
const positions = [0, 0, 0, 1, 0, 0, 0, 1, 0];
|
|
39
|
+
const normals = [0, 0, -1, 0, 0, -1, 0, 0, -1]; // explicit -Z normals
|
|
40
|
+
const indices = [0, 1, 2];
|
|
41
|
+
const geo = createMeshGeometryFromAttributes({ indices, normals, positions });
|
|
42
|
+
expect(geo.vertices[5]).toBeCloseTo(-1, 3); // supplied -Z preserved
|
|
43
|
+
});
|
|
44
|
+
it('computes bounds', () => {
|
|
45
|
+
const positions = [0, 0, 0, 2, 0, 0, 0, 2, 0];
|
|
46
|
+
const geo = createMeshGeometryFromAttributes({ positions });
|
|
47
|
+
expect(geo.bounds!.max.x).toBeCloseTo(2);
|
|
48
|
+
expect(geo.bounds!.max.y).toBeCloseTo(2);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('getMeshGeometryTriangleCount', () => {
|
|
53
|
+
it('returns index/3 for triangle-list', () => {
|
|
54
|
+
const geo = createBoxMeshGeometry();
|
|
55
|
+
expect(getMeshGeometryTriangleCount(geo)).toBe(getMeshGeometryIndexCount(geo) / 3);
|
|
56
|
+
});
|
|
57
|
+
it('returns 0 for point-list', () => {
|
|
58
|
+
const geo = createMeshGeometry({
|
|
59
|
+
layout: CANONICAL_LAYOUT,
|
|
60
|
+
topology: 'point-list',
|
|
61
|
+
vertices: new Float32Array(12),
|
|
62
|
+
});
|
|
63
|
+
expect(getMeshGeometryTriangleCount(geo)).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
it('returns indexCount - 2 for triangle-strip', () => {
|
|
66
|
+
const verts = new Float32Array(4 * 12);
|
|
67
|
+
const geo = createMeshGeometry({
|
|
68
|
+
indices: new Uint16Array([0, 1, 2, 3]),
|
|
69
|
+
layout: CANONICAL_LAYOUT,
|
|
70
|
+
topology: 'triangle-strip',
|
|
71
|
+
vertices: verts,
|
|
72
|
+
});
|
|
73
|
+
expect(getMeshGeometryTriangleCount(geo)).toBe(2); // 4 - 2
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('mergeMeshGeometries', () => {
|
|
78
|
+
it('returns null for empty array', () => {
|
|
79
|
+
expect(mergeMeshGeometries([])).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
it('returns null on layout mismatch', () => {
|
|
82
|
+
const a = createQuadMeshGeometry();
|
|
83
|
+
const b = createMeshGeometry({ layout: POSITION_ONLY_LAYOUT, vertices: new Float32Array(9) });
|
|
84
|
+
expect(mergeMeshGeometries([a, b])).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
it('concatenates vertices from two geometries', () => {
|
|
87
|
+
const a = createQuadMeshGeometry();
|
|
88
|
+
const b = createQuadMeshGeometry();
|
|
89
|
+
const merged = mergeMeshGeometries([a, b]);
|
|
90
|
+
expect(merged).not.toBeNull();
|
|
91
|
+
expect(getMeshGeometryVertexCount(merged!)).toBe(getMeshGeometryVertexCount(a) + getMeshGeometryVertexCount(b));
|
|
92
|
+
});
|
|
93
|
+
it('offsets indices correctly', () => {
|
|
94
|
+
const a = createQuadMeshGeometry(); // 4 verts, indices 0..3
|
|
95
|
+
const b = createQuadMeshGeometry();
|
|
96
|
+
const merged = mergeMeshGeometries([a, b]);
|
|
97
|
+
const aVertCount = getMeshGeometryVertexCount(a);
|
|
98
|
+
// All indices from `b` should be >= aVertCount in the merged geometry.
|
|
99
|
+
const bIndexStart = a.indices ? a.indices.length : getMeshGeometryVertexCount(a);
|
|
100
|
+
const bIndexCount = b.indices ? b.indices.length : getMeshGeometryVertexCount(b);
|
|
101
|
+
for (let i = bIndexStart; i < bIndexStart + bIndexCount; i++) {
|
|
102
|
+
expect(merged!.indices![i]).toBeGreaterThanOrEqual(aVertCount);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
it('recomputes bounds', () => {
|
|
106
|
+
const a = createQuadMeshGeometry();
|
|
107
|
+
const merged = mergeMeshGeometries([a, a]);
|
|
108
|
+
expect(merged!.bounds).not.toBeNull();
|
|
109
|
+
});
|
|
110
|
+
it('carries per-source subsets', () => {
|
|
111
|
+
const a = createQuadMeshGeometry();
|
|
112
|
+
const b = createQuadMeshGeometry();
|
|
113
|
+
const merged = mergeMeshGeometries([a, b]);
|
|
114
|
+
expect(merged!.subsets.length).toBe(2);
|
|
115
|
+
expect(merged!.subsets[0].indexOffset).toBe(0);
|
|
116
|
+
expect(merged!.subsets[1].indexOffset).toBe(a.indices ? a.indices.length : getMeshGeometryVertexCount(a));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('validateMeshGeometry', () => {
|
|
121
|
+
it('returns true for a valid geometry', () => {
|
|
122
|
+
expect(validateMeshGeometry(createBoxMeshGeometry())).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
it('returns false when indices reference out-of-range vertices', () => {
|
|
125
|
+
const geo = createMeshGeometry({
|
|
126
|
+
indices: new Uint16Array([0, 1, 99]),
|
|
127
|
+
layout: CANONICAL_LAYOUT,
|
|
128
|
+
vertices: new Float32Array(3 * 12),
|
|
129
|
+
});
|
|
130
|
+
expect(validateMeshGeometry(geo)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
it('returns false when vertex stream is not stride-aligned', () => {
|
|
133
|
+
const geo = createMeshGeometry({
|
|
134
|
+
layout: CANONICAL_LAYOUT,
|
|
135
|
+
// 13 floats is not a multiple of 12 (the stride in floats).
|
|
136
|
+
vertices: new Float32Array(13),
|
|
137
|
+
});
|
|
138
|
+
// Override to force non-aligned stream.
|
|
139
|
+
geo.vertices = new Float32Array(13);
|
|
140
|
+
expect(validateMeshGeometry(geo)).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
it('returns false when positions contain NaN', () => {
|
|
143
|
+
const verts = new Float32Array(12);
|
|
144
|
+
verts[0] = NaN;
|
|
145
|
+
const geo = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: verts });
|
|
146
|
+
expect(validateMeshGeometry(geo)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
it('returns false when positions contain Infinity', () => {
|
|
149
|
+
const verts = new Float32Array(12);
|
|
150
|
+
verts[2] = Infinity;
|
|
151
|
+
const geo = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: verts });
|
|
152
|
+
expect(validateMeshGeometry(geo)).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { VertexAttributeLayout } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import { createMeshGeometry } from './meshGeometry';
|
|
4
|
+
import {
|
|
5
|
+
addMeshGeometrySubset,
|
|
6
|
+
getMeshGeometrySubsetTriangleCount,
|
|
7
|
+
setMeshGeometrySubsets,
|
|
8
|
+
} from './meshGeometrySubset';
|
|
9
|
+
|
|
10
|
+
const CANONICAL_LAYOUT: VertexAttributeLayout = {
|
|
11
|
+
attributes: [
|
|
12
|
+
{ byteOffset: 0, format: 'float32x3', semantic: 'position' },
|
|
13
|
+
{ byteOffset: 12, format: 'float32x3', semantic: 'normal' },
|
|
14
|
+
{ byteOffset: 24, format: 'float32x4', semantic: 'tangent' },
|
|
15
|
+
{ byteOffset: 40, format: 'float32x2', semantic: 'uv0' },
|
|
16
|
+
],
|
|
17
|
+
stride: 48,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// A two-triangle quad so the default subset spans 6 indices.
|
|
21
|
+
function makeQuad() {
|
|
22
|
+
const vertices = new Float32Array(4 * 12);
|
|
23
|
+
const indices = new Uint16Array([0, 1, 2, 2, 1, 3]);
|
|
24
|
+
return createMeshGeometry({ indices: indices, layout: CANONICAL_LAYOUT, vertices: vertices });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('addMeshGeometrySubset', () => {
|
|
28
|
+
it('appends a subset, replacing the array reference', () => {
|
|
29
|
+
const geometry = makeQuad();
|
|
30
|
+
const previous = geometry.subsets;
|
|
31
|
+
addMeshGeometrySubset(geometry, { indexCount: 3, indexOffset: 3 });
|
|
32
|
+
expect(geometry.subsets.length).toBe(2);
|
|
33
|
+
expect(geometry.subsets[1].indexOffset).toBe(3);
|
|
34
|
+
expect(geometry.subsets).not.toBe(previous);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('getMeshGeometrySubsetTriangleCount', () => {
|
|
39
|
+
it('divides the index count by three for triangle-list topology', () => {
|
|
40
|
+
const geometry = makeQuad();
|
|
41
|
+
expect(getMeshGeometrySubsetTriangleCount(geometry, 0)).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns the sentinel 0 for an out-of-range subset', () => {
|
|
45
|
+
const geometry = makeQuad();
|
|
46
|
+
expect(getMeshGeometrySubsetTriangleCount(geometry, 5)).toBe(0);
|
|
47
|
+
expect(getMeshGeometrySubsetTriangleCount(geometry, -1)).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('counts strip triangles as indexCount - 2', () => {
|
|
51
|
+
const vertices = new Float32Array(4 * 12);
|
|
52
|
+
const geometry = createMeshGeometry({
|
|
53
|
+
indices: new Uint16Array([0, 1, 2, 3]),
|
|
54
|
+
layout: CANONICAL_LAYOUT,
|
|
55
|
+
topology: 'triangle-strip',
|
|
56
|
+
vertices: vertices,
|
|
57
|
+
});
|
|
58
|
+
expect(getMeshGeometrySubsetTriangleCount(geometry, 0)).toBe(2);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('setMeshGeometrySubsets', () => {
|
|
63
|
+
it('replaces the entire subset list with a fresh copy', () => {
|
|
64
|
+
const geometry = makeQuad();
|
|
65
|
+
const input = [
|
|
66
|
+
{ indexCount: 3, indexOffset: 0 },
|
|
67
|
+
{ indexCount: 3, indexOffset: 3 },
|
|
68
|
+
];
|
|
69
|
+
setMeshGeometrySubsets(geometry, input);
|
|
70
|
+
expect(geometry.subsets.length).toBe(2);
|
|
71
|
+
// Copied, not aliased to the input objects.
|
|
72
|
+
expect(geometry.subsets[0]).not.toBe(input[0]);
|
|
73
|
+
expect(geometry.subsets[1].indexOffset).toBe(3);
|
|
74
|
+
});
|
|
75
|
+
});
|