@forgeax/engine-geometry 0.1.7 → 0.1.19
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/README.md +51 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/edges.unit.test.d.ts +2 -0
- package/dist/__tests__/edges.unit.test.d.ts.map +1 -0
- package/dist/edges.d.ts +6 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +390 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/edges.unit.test.ts +320 -0
- package/src/edges.ts +492 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/engine-geometry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@forgeax/engine-ecs": "0.1.
|
|
26
|
-
"@forgeax/engine-math": "0.1.
|
|
27
|
-
"@forgeax/engine-pack": "0.1.
|
|
28
|
-
"@forgeax/engine-types": "0.1.
|
|
25
|
+
"@forgeax/engine-ecs": "0.1.19",
|
|
26
|
+
"@forgeax/engine-math": "0.1.19",
|
|
27
|
+
"@forgeax/engine-pack": "0.1.19",
|
|
28
|
+
"@forgeax/engine-types": "0.1.19"
|
|
29
29
|
},
|
|
30
30
|
"forgeax": {
|
|
31
31
|
"metrics": {
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { AssetError, type MeshAsset } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
createBoxGeometry,
|
|
5
|
+
createEdgesGeometry,
|
|
6
|
+
createPlaneGeometry,
|
|
7
|
+
createWireframeGeometry,
|
|
8
|
+
} from '../index.js';
|
|
9
|
+
|
|
10
|
+
function triangleMesh(): MeshAsset {
|
|
11
|
+
const position = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
12
|
+
return {
|
|
13
|
+
kind: 'mesh',
|
|
14
|
+
vertices: position,
|
|
15
|
+
attributes: { position },
|
|
16
|
+
submeshes: [
|
|
17
|
+
{
|
|
18
|
+
indexOffset: 0,
|
|
19
|
+
indexCount: 0,
|
|
20
|
+
vertexCount: 3,
|
|
21
|
+
topology: 'triangle-list',
|
|
22
|
+
materialSlot: 0,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
materialSlots: [{ slotName: 'source' }],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function firstSubmesh(mesh: MeshAsset) {
|
|
30
|
+
const submesh = mesh.submeshes[0];
|
|
31
|
+
if (submesh === undefined) throw new Error('test mesh must have a submesh');
|
|
32
|
+
return submesh;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expectParseFailure(result: ReturnType<typeof createWireframeGeometry>, field: string) {
|
|
36
|
+
expect(result.ok).toBe(false);
|
|
37
|
+
if (result.ok) return;
|
|
38
|
+
expect(result.error).toBeInstanceOf(AssetError);
|
|
39
|
+
expect(result.error.code).toBe('asset-parse-failed');
|
|
40
|
+
expect(result.error.detail).toMatchObject({ field });
|
|
41
|
+
expect(result.error.detail).toHaveProperty('value');
|
|
42
|
+
expect(result.error.detail).toHaveProperty('reason');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('Geometry edge factories input contract', () => {
|
|
46
|
+
it('exposes both factories with valid indexed and non-indexed inputs', () => {
|
|
47
|
+
const nonIndexed = triangleMesh();
|
|
48
|
+
const indexed: MeshAsset = {
|
|
49
|
+
...nonIndexed,
|
|
50
|
+
vertices: new Float32Array(0),
|
|
51
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
52
|
+
submeshes: [{ ...firstSubmesh(nonIndexed), indexCount: 3 }],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
expect(createWireframeGeometry(nonIndexed).ok).toBe(true);
|
|
56
|
+
expect(createEdgesGeometry(nonIndexed).ok).toBe(true);
|
|
57
|
+
expect(createWireframeGeometry(indexed).ok).toBe(true);
|
|
58
|
+
expect(createEdgesGeometry(indexed, 1).ok).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it.each([
|
|
62
|
+
Number.NaN,
|
|
63
|
+
Number.POSITIVE_INFINITY,
|
|
64
|
+
-1,
|
|
65
|
+
181,
|
|
66
|
+
])('rejects threshold %s with structured detail', (threshold) => {
|
|
67
|
+
const result = createEdgesGeometry(triangleMesh(), threshold);
|
|
68
|
+
expectParseFailure(result, 'thresholdAngleDegrees');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects malformed position storage, cardinality, and finite values', () => {
|
|
72
|
+
const wrongStorage = {
|
|
73
|
+
...triangleMesh(),
|
|
74
|
+
attributes: { position: new Uint16Array([0, 0, 0]) },
|
|
75
|
+
} as unknown as MeshAsset;
|
|
76
|
+
expectParseFailure(createWireframeGeometry(wrongStorage), 'attributes.position');
|
|
77
|
+
|
|
78
|
+
const wrongCardinality = {
|
|
79
|
+
...triangleMesh(),
|
|
80
|
+
attributes: { position: new Float32Array([0, 0]) },
|
|
81
|
+
} as unknown as MeshAsset;
|
|
82
|
+
expectParseFailure(createWireframeGeometry(wrongCardinality), 'attributes.position');
|
|
83
|
+
|
|
84
|
+
const nonFinite = {
|
|
85
|
+
...triangleMesh(),
|
|
86
|
+
attributes: { position: new Float32Array([0, 0, 0, 1, 0, 0, Number.NaN, 1, 0]) },
|
|
87
|
+
} as unknown as MeshAsset;
|
|
88
|
+
expectParseFailure(createWireframeGeometry(nonFinite), 'attributes.position');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects non-triangle topology, invalid ranges, indices, and non-indexed shape', () => {
|
|
92
|
+
const source = triangleMesh();
|
|
93
|
+
expectParseFailure(
|
|
94
|
+
createWireframeGeometry({
|
|
95
|
+
...source,
|
|
96
|
+
submeshes: [{ ...firstSubmesh(source), topology: 'line-list' }],
|
|
97
|
+
}),
|
|
98
|
+
'submeshes[0].topology',
|
|
99
|
+
);
|
|
100
|
+
expectParseFailure(
|
|
101
|
+
createWireframeGeometry({
|
|
102
|
+
...source,
|
|
103
|
+
submeshes: [{ ...firstSubmesh(source), vertexCount: 2 }],
|
|
104
|
+
}),
|
|
105
|
+
'submeshes[0].vertexCount',
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const indexed: MeshAsset = {
|
|
109
|
+
...source,
|
|
110
|
+
indices: new Uint16Array([0, 1, 3]),
|
|
111
|
+
submeshes: [{ ...firstSubmesh(source), indexCount: 3 }],
|
|
112
|
+
};
|
|
113
|
+
expectParseFailure(createWireframeGeometry(indexed), 'indices[2]');
|
|
114
|
+
|
|
115
|
+
const nonIndexedMultiple: MeshAsset = {
|
|
116
|
+
...source,
|
|
117
|
+
submeshes: [firstSubmesh(source), firstSubmesh(source)],
|
|
118
|
+
};
|
|
119
|
+
expectParseFailure(createWireframeGeometry(nonIndexedMultiple), 'submeshes');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
function unwrap(result: ReturnType<typeof createWireframeGeometry>): MeshAsset {
|
|
124
|
+
expect(result.ok).toBe(true);
|
|
125
|
+
if (!result.ok) throw result.error;
|
|
126
|
+
return result.value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function indexedMesh(positions: number[], indices: number[]): MeshAsset {
|
|
130
|
+
const position = new Float32Array(positions);
|
|
131
|
+
return {
|
|
132
|
+
kind: 'mesh',
|
|
133
|
+
vertices: position,
|
|
134
|
+
attributes: { position },
|
|
135
|
+
indices: new Uint32Array(indices),
|
|
136
|
+
submeshes: [
|
|
137
|
+
{
|
|
138
|
+
indexOffset: 0,
|
|
139
|
+
indexCount: indices.length,
|
|
140
|
+
vertexCount: position.length / 3,
|
|
141
|
+
topology: 'triangle-list',
|
|
142
|
+
materialSlot: 0,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
materialSlots: [{ slotName: 'source' }],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function squareMesh(): MeshAsset {
|
|
150
|
+
return indexedMesh([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], [0, 1, 2, 0, 2, 3]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function edgeCount(mesh: MeshAsset): number {
|
|
154
|
+
return mesh.submeshes[0]?.vertexCount === undefined ? 0 : mesh.submeshes[0].vertexCount / 2;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function vertexBytes(mesh: MeshAsset): number[] {
|
|
158
|
+
return Array.from(
|
|
159
|
+
new Uint8Array(mesh.vertices.buffer, mesh.vertices.byteOffset, mesh.vertices.byteLength),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function emittedPositions(mesh: MeshAsset): number[] {
|
|
164
|
+
const position = mesh.attributes.position;
|
|
165
|
+
if (!(position instanceof Float32Array)) throw new Error('expected float32 output positions');
|
|
166
|
+
return Array.from(position);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function expectLineCarrier(mesh: MeshAsset, expectedEdges: number): void {
|
|
170
|
+
expect(mesh.kind).toBe('mesh');
|
|
171
|
+
expect(mesh.indices).toBeUndefined();
|
|
172
|
+
expect(mesh.materialSlots).toEqual([{ slotName: 'Default' }]);
|
|
173
|
+
expect(mesh.submeshes).toEqual([
|
|
174
|
+
{
|
|
175
|
+
indexOffset: 0,
|
|
176
|
+
indexCount: 0,
|
|
177
|
+
vertexCount: expectedEdges * 2,
|
|
178
|
+
topology: 'line-list',
|
|
179
|
+
materialSlot: 0,
|
|
180
|
+
},
|
|
181
|
+
]);
|
|
182
|
+
expect(mesh.vertices.length).toBe(expectedEdges * 2 * 12);
|
|
183
|
+
expect(mesh.attributes.position).toBeInstanceOf(Float32Array);
|
|
184
|
+
expect(mesh.attributes.normal).toBeInstanceOf(Float32Array);
|
|
185
|
+
expect(mesh.attributes.uv).toBeInstanceOf(Float32Array);
|
|
186
|
+
expect(mesh.attributes.tangent).toBeInstanceOf(Float32Array);
|
|
187
|
+
expect(mesh.attributes.normal).toHaveLength(expectedEdges * 2 * 3);
|
|
188
|
+
expect(mesh.attributes.uv).toHaveLength(expectedEdges * 2 * 2);
|
|
189
|
+
expect(mesh.attributes.tangent).toHaveLength(expectedEdges * 2 * 4);
|
|
190
|
+
expect(Array.from(mesh.attributes.normal as Float32Array).every((value) => value === 0)).toBe(
|
|
191
|
+
true,
|
|
192
|
+
);
|
|
193
|
+
expect(Array.from(mesh.attributes.uv as Float32Array).every((value) => value === 0)).toBe(true);
|
|
194
|
+
expect(Array.from(mesh.attributes.tangent as Float32Array).every((value) => value === 0)).toBe(
|
|
195
|
+
true,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
describe('Geometry edge factories deterministic carriers', () => {
|
|
200
|
+
it('emits all unique box edges and keeps subdivided plane diagonals', () => {
|
|
201
|
+
const box = unwrap(createWireframeGeometry(unwrap(createBoxGeometry(1, 1, 1))));
|
|
202
|
+
expectLineCarrier(box, 18);
|
|
203
|
+
const boxSource = unwrap(createBoxGeometry(1, 1, 1));
|
|
204
|
+
expect(edgeCount(unwrap(createEdgesGeometry(boxSource)))).toBe(12);
|
|
205
|
+
|
|
206
|
+
const plane = unwrap(createWireframeGeometry(unwrap(createPlaneGeometry(2, 2, 2, 2))));
|
|
207
|
+
expectLineCarrier(plane, 16);
|
|
208
|
+
const planeSource = unwrap(createPlaneGeometry(2, 2, 2, 2));
|
|
209
|
+
expect(edgeCount(unwrap(createEdgesGeometry(planeSource)))).toBe(8);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('uses the edge threshold to include or exclude a coplanar diagonal', () => {
|
|
213
|
+
const source = squareMesh();
|
|
214
|
+
expect(edgeCount(unwrap(createWireframeGeometry(source)))).toBe(5);
|
|
215
|
+
expect(edgeCount(unwrap(createEdgesGeometry(source)))).toBe(4);
|
|
216
|
+
expect(edgeCount(unwrap(createEdgesGeometry(source, 0)))).toBe(5);
|
|
217
|
+
expect(edgeCount(unwrap(createEdgesGeometry(source, 180)))).toBe(4);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('keeps a ninety-degree crease at threshold 90 but not at 91 degrees', () => {
|
|
221
|
+
const source = indexedMesh([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1], [0, 1, 2, 1, 0, 3]);
|
|
222
|
+
expect(edgeCount(unwrap(createEdgesGeometry(source, 90)))).toBe(5);
|
|
223
|
+
expect(edgeCount(unwrap(createEdgesGeometry(source, 91)))).toBe(4);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('welds duplicate seams and chooses the minimum exact representative', () => {
|
|
227
|
+
const source = indexedMesh(
|
|
228
|
+
[0, 0, 0, 1, 0, 0, 1, 1, 0, 0.00004, 0, 0, 1.00004, 1, 0, 0, 1, 0],
|
|
229
|
+
[0, 1, 2, 3, 4, 5],
|
|
230
|
+
);
|
|
231
|
+
const result = unwrap(createEdgesGeometry(source));
|
|
232
|
+
expect(edgeCount(result)).toBe(4);
|
|
233
|
+
expect(emittedPositions(result)).not.toContain(0.00004);
|
|
234
|
+
expect(emittedPositions(result)).not.toContain(1.00004);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('always keeps non-manifold edges', () => {
|
|
238
|
+
const source = indexedMesh(
|
|
239
|
+
[0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0],
|
|
240
|
+
[0, 1, 2, 1, 0, 3, 0, 1, 4],
|
|
241
|
+
);
|
|
242
|
+
expect(edgeCount(unwrap(createEdgesGeometry(source)))).toBe(7);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('skips zero-area faces and zero-length edges without NaN or output fallback', () => {
|
|
246
|
+
const mixed = indexedMesh([0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 1, 1, 2, 3]);
|
|
247
|
+
const output = unwrap(createEdgesGeometry(mixed));
|
|
248
|
+
expect(edgeCount(output)).toBe(3);
|
|
249
|
+
expect(emittedPositions(output).every(Number.isFinite)).toBe(true);
|
|
250
|
+
|
|
251
|
+
const empty = unwrap(createEdgesGeometry(indexedMesh([0, 0, 0], [0, 0, 0])));
|
|
252
|
+
expectLineCarrier(empty, 0);
|
|
253
|
+
expect(Array.from(empty.aabb ?? [])).toEqual([
|
|
254
|
+
Number.POSITIVE_INFINITY,
|
|
255
|
+
Number.POSITIVE_INFINITY,
|
|
256
|
+
Number.POSITIVE_INFINITY,
|
|
257
|
+
Number.NEGATIVE_INFINITY,
|
|
258
|
+
Number.NEGATIVE_INFINITY,
|
|
259
|
+
Number.NEGATIVE_INFINITY,
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('packs canonical bytes, derives AABB from emitted endpoints, and preserves source data', () => {
|
|
264
|
+
const source = squareMesh();
|
|
265
|
+
const sourcePositions = Array.from(source.attributes.position as Float32Array);
|
|
266
|
+
const sourceIndices = Array.from(source.indices ?? []);
|
|
267
|
+
const output = unwrap(createWireframeGeometry(source));
|
|
268
|
+
expectLineCarrier(output, 5);
|
|
269
|
+
expect(Array.from(source.attributes.position as Float32Array)).toEqual(sourcePositions);
|
|
270
|
+
expect(Array.from(source.indices ?? [])).toEqual(sourceIndices);
|
|
271
|
+
|
|
272
|
+
const position = output.attributes.position as Float32Array;
|
|
273
|
+
expect(Array.from(output.aabb ?? [])).toEqual([0, 0, 0, 1, 1, 0]);
|
|
274
|
+
for (let vertex = 0; vertex < position.length / 3; vertex += 1) {
|
|
275
|
+
const base = vertex * 12;
|
|
276
|
+
expect(Array.from(output.vertices.slice(base, base + 3))).toEqual(
|
|
277
|
+
Array.from(position.slice(vertex * 3, vertex * 3 + 3)),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('is byte-stable across repeated/default-explicit/indexed-non-indexed/permuted inputs', () => {
|
|
283
|
+
const indexed = squareMesh();
|
|
284
|
+
const nonIndexed: MeshAsset = {
|
|
285
|
+
kind: 'mesh',
|
|
286
|
+
vertices: new Float32Array(0),
|
|
287
|
+
attributes: {
|
|
288
|
+
position: new Float32Array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0]),
|
|
289
|
+
},
|
|
290
|
+
submeshes: [
|
|
291
|
+
{
|
|
292
|
+
indexOffset: 0,
|
|
293
|
+
indexCount: 0,
|
|
294
|
+
vertexCount: 6,
|
|
295
|
+
topology: 'triangle-list',
|
|
296
|
+
materialSlot: 0,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
materialSlots: [{ slotName: 'source' }],
|
|
300
|
+
};
|
|
301
|
+
const permuted = indexedMesh(
|
|
302
|
+
Array.from(indexed.attributes.position as Float32Array),
|
|
303
|
+
[0, 2, 3, 0, 1, 2],
|
|
304
|
+
);
|
|
305
|
+
const first = unwrap(createWireframeGeometry(indexed));
|
|
306
|
+
const repeated = unwrap(createWireframeGeometry(indexed));
|
|
307
|
+
const firstEdges = unwrap(createEdgesGeometry(indexed));
|
|
308
|
+
const repeatedEdges = unwrap(createEdgesGeometry(indexed));
|
|
309
|
+
const explicitEdges = unwrap(createEdgesGeometry(indexed, 1));
|
|
310
|
+
const nonIndexedOutput = unwrap(createWireframeGeometry(nonIndexed));
|
|
311
|
+
const permutedOutput = unwrap(createWireframeGeometry(permuted));
|
|
312
|
+
expect(vertexBytes(repeated)).toEqual(vertexBytes(first));
|
|
313
|
+
expect(vertexBytes(repeatedEdges)).toEqual(vertexBytes(firstEdges));
|
|
314
|
+
expect(vertexBytes(explicitEdges)).toEqual(vertexBytes(firstEdges));
|
|
315
|
+
expect(vertexBytes(nonIndexedOutput)).toEqual(vertexBytes(first));
|
|
316
|
+
expect(vertexBytes(permutedOutput)).toEqual(vertexBytes(first));
|
|
317
|
+
expect(emittedPositions(repeated)).toEqual(emittedPositions(first));
|
|
318
|
+
expect(Array.from(repeated.aabb ?? [])).toEqual(Array.from(first.aabb ?? []));
|
|
319
|
+
});
|
|
320
|
+
});
|