@forgeax/engine-geometry 0.1.7 → 0.1.20

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/src/edges.ts ADDED
@@ -0,0 +1,492 @@
1
+ import { box3 } from '@forgeax/engine-math';
2
+ import {
3
+ ASSET_ERROR_HINTS,
4
+ AssetError,
5
+ err,
6
+ type MeshAsset,
7
+ ok,
8
+ type Result,
9
+ type VertexAttributeMap,
10
+ } from '@forgeax/engine-types';
11
+ import { packInterleavedVertexAttributes } from './vertex-attribute-layout';
12
+
13
+ const WELD_EPSILON = 1e-4;
14
+ const DEFAULT_THRESHOLD_DEGREES = 1;
15
+
16
+ type Vec3 = readonly [number, number, number];
17
+
18
+ interface Endpoint {
19
+ readonly value: Vec3;
20
+ readonly exactKey: string;
21
+ readonly weldKey: string;
22
+ }
23
+
24
+ interface Triangle {
25
+ readonly a: Endpoint;
26
+ readonly b: Endpoint;
27
+ readonly c: Endpoint;
28
+ readonly normal: Vec3;
29
+ }
30
+
31
+ interface PreparedMesh {
32
+ readonly triangles: readonly Triangle[];
33
+ }
34
+
35
+ interface WireEdge {
36
+ readonly a: Endpoint;
37
+ readonly b: Endpoint;
38
+ }
39
+
40
+ interface SurfaceEdge {
41
+ readonly aWeldKey: string;
42
+ readonly bWeldKey: string;
43
+ a: Endpoint;
44
+ b: Endpoint;
45
+ readonly normals: Vec3[];
46
+ }
47
+
48
+ function parseFailure(field: string, value: unknown, reason: string): AssetError {
49
+ return new AssetError({
50
+ code: 'asset-parse-failed',
51
+ expected: `a valid triangle-list MeshAsset: ${reason}`,
52
+ hint: ASSET_ERROR_HINTS['asset-parse-failed'],
53
+ detail: { field, value, reason },
54
+ });
55
+ }
56
+
57
+ function f32Bits(value: number): number {
58
+ const buffer = new ArrayBuffer(4);
59
+ new Float32Array(buffer)[0] = value;
60
+ return new Uint32Array(buffer)[0] as number;
61
+ }
62
+
63
+ function normalizedValue(value: number): number {
64
+ return value === 0 ? 0 : value;
65
+ }
66
+
67
+ function exactKey(value: Vec3): string {
68
+ return value
69
+ .map((component) => f32Bits(normalizedValue(component)).toString(16).padStart(8, '0'))
70
+ .join(':');
71
+ }
72
+
73
+ function weldComponent(value: number): string {
74
+ const scaled = normalizedValue(value) / WELD_EPSILON;
75
+ return String(Math.round(scaled));
76
+ }
77
+
78
+ function weldKey(value: Vec3): string {
79
+ return value.map(weldComponent).join(':');
80
+ }
81
+
82
+ function endpoint(position: Vec3): Endpoint {
83
+ const value: Vec3 = [
84
+ normalizedValue(position[0]),
85
+ normalizedValue(position[1]),
86
+ normalizedValue(position[2]),
87
+ ];
88
+ return { value, exactKey: exactKey(value), weldKey: weldKey(value) };
89
+ }
90
+
91
+ function compareText(left: string, right: string): number {
92
+ return left < right ? -1 : left > right ? 1 : 0;
93
+ }
94
+
95
+ function compareEndpoint(left: Endpoint, right: Endpoint): number {
96
+ return compareText(left.exactKey, right.exactKey);
97
+ }
98
+
99
+ function compareWireEdge(left: WireEdge, right: WireEdge): number {
100
+ return compareEndpoint(left.a, right.a) || compareEndpoint(left.b, right.b);
101
+ }
102
+
103
+ function compareSurfaceEdge(left: SurfaceEdge, right: SurfaceEdge): number {
104
+ return compareText(left.aWeldKey, right.aWeldKey) || compareText(left.bWeldKey, right.bWeldKey);
105
+ }
106
+
107
+ function readPosition(raw: Float32Array | ArrayBuffer): Float32Array {
108
+ return raw instanceof Float32Array ? raw : new Float32Array(raw);
109
+ }
110
+
111
+ function validatePosition(source: MeshAsset): Result<Float32Array, AssetError> {
112
+ if (source === null || typeof source !== 'object' || source.kind !== 'mesh') {
113
+ return err(parseFailure('kind', source?.kind, 'kind must be mesh'));
114
+ }
115
+ const attributes = source.attributes;
116
+ const raw = attributes?.position;
117
+ if (raw === undefined) {
118
+ return err(parseFailure('attributes.position', undefined, 'position is required'));
119
+ }
120
+ if (raw instanceof ArrayBuffer) {
121
+ if (raw.byteLength % 4 !== 0) {
122
+ return err(
123
+ parseFailure(
124
+ 'attributes.position',
125
+ raw.byteLength,
126
+ 'ArrayBuffer byte length must align to f32',
127
+ ),
128
+ );
129
+ }
130
+ } else if (!(raw instanceof Float32Array)) {
131
+ return err(
132
+ parseFailure(
133
+ 'attributes.position',
134
+ raw === null ? null : typeof raw,
135
+ 'position storage must be Float32Array or ArrayBuffer',
136
+ ),
137
+ );
138
+ }
139
+ const position = readPosition(raw);
140
+ if (position.length % 3 !== 0) {
141
+ return err(
142
+ parseFailure(
143
+ 'attributes.position',
144
+ position.length,
145
+ 'position length must be divisible by 3',
146
+ ),
147
+ );
148
+ }
149
+ for (let index = 0; index < position.length; index += 1) {
150
+ const value = position[index] as number;
151
+ if (!Number.isFinite(value)) {
152
+ return err(parseFailure('attributes.position', value, `position[${index}] must be finite`));
153
+ }
154
+ }
155
+ return ok(position);
156
+ }
157
+
158
+ function validateThreshold(threshold: number): Result<number, AssetError> {
159
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 180) {
160
+ return err(
161
+ parseFailure('thresholdAngleDegrees', threshold, 'threshold must be finite and in [0, 180]'),
162
+ );
163
+ }
164
+ return ok(threshold);
165
+ }
166
+
167
+ function validateSubmeshShape(source: MeshAsset, positionCount: number): Result<void, AssetError> {
168
+ if (!Array.isArray(source.submeshes) || source.submeshes.length === 0) {
169
+ return err(parseFailure('submeshes', source.submeshes, 'at least one submesh is required'));
170
+ }
171
+ const indexed = source.indices !== undefined;
172
+ if (!indexed && source.submeshes.length !== 1) {
173
+ return err(
174
+ parseFailure('submeshes', source.submeshes.length, 'non-indexed meshes require one submesh'),
175
+ );
176
+ }
177
+ if (!indexed && positionCount % 3 !== 0) {
178
+ return err(
179
+ parseFailure(
180
+ 'attributes.position',
181
+ positionCount,
182
+ 'non-indexed positions require triangle cardinality',
183
+ ),
184
+ );
185
+ }
186
+ for (let index = 0; index < source.submeshes.length; index += 1) {
187
+ const submesh = source.submeshes[index];
188
+ if (submesh === undefined || submesh === null || submesh.topology !== 'triangle-list') {
189
+ return err(
190
+ parseFailure(
191
+ `submeshes[${index}].topology`,
192
+ submesh?.topology,
193
+ 'topology must be triangle-list',
194
+ ),
195
+ );
196
+ }
197
+ if (!Number.isInteger(submesh.indexOffset) || submesh.indexOffset < 0) {
198
+ return err(
199
+ parseFailure(
200
+ `submeshes[${index}].indexOffset`,
201
+ submesh.indexOffset,
202
+ 'index offset must be a non-negative integer',
203
+ ),
204
+ );
205
+ }
206
+ if (!Number.isInteger(submesh.indexCount) || submesh.indexCount < 0) {
207
+ return err(
208
+ parseFailure(
209
+ `submeshes[${index}].indexCount`,
210
+ submesh.indexCount,
211
+ 'index count must be a non-negative integer',
212
+ ),
213
+ );
214
+ }
215
+ if (
216
+ !Number.isInteger(submesh.vertexCount) ||
217
+ submesh.vertexCount < 0 ||
218
+ submesh.vertexCount > positionCount
219
+ ) {
220
+ return err(
221
+ parseFailure(
222
+ `submeshes[${index}].vertexCount`,
223
+ submesh.vertexCount,
224
+ 'vertex count must be an integer within position count',
225
+ ),
226
+ );
227
+ }
228
+ if (submesh.indexCount % 3 !== 0) {
229
+ return err(
230
+ parseFailure(
231
+ `submeshes[${index}].indexCount`,
232
+ submesh.indexCount,
233
+ 'index count must be divisible by 3',
234
+ ),
235
+ );
236
+ }
237
+ if (!indexed && submesh.indexOffset !== 0) {
238
+ return err(
239
+ parseFailure(
240
+ `submeshes[${index}].indexOffset`,
241
+ submesh.indexOffset,
242
+ 'non-indexed submesh index offset must be zero',
243
+ ),
244
+ );
245
+ }
246
+ if (!indexed && submesh.indexCount !== 0) {
247
+ return err(
248
+ parseFailure(
249
+ `submeshes[${index}].indexCount`,
250
+ submesh.indexCount,
251
+ 'non-indexed submesh index count must be zero',
252
+ ),
253
+ );
254
+ }
255
+ if (!indexed && submesh.vertexCount !== positionCount) {
256
+ return err(
257
+ parseFailure(
258
+ `submeshes[${index}].vertexCount`,
259
+ submesh.vertexCount,
260
+ 'non-indexed submesh must span all positions',
261
+ ),
262
+ );
263
+ }
264
+ }
265
+ return ok(undefined);
266
+ }
267
+
268
+ function validateIndices(source: MeshAsset, positionCount: number): Result<void, AssetError> {
269
+ const indices = source.indices;
270
+ if (indices === undefined) return ok(undefined);
271
+ if (!(indices instanceof Uint16Array) && !(indices instanceof Uint32Array)) {
272
+ return err(
273
+ parseFailure('indices', indices, 'indices storage must be Uint16Array or Uint32Array'),
274
+ );
275
+ }
276
+ for (let index = 0; index < source.submeshes.length; index += 1) {
277
+ const submesh = source.submeshes[index];
278
+ if (submesh === undefined) continue;
279
+ if (submesh.indexOffset + submesh.indexCount > indices.length) {
280
+ return err(
281
+ parseFailure(`submeshes[${index}]`, submesh, 'submesh index range exceeds indices length'),
282
+ );
283
+ }
284
+ for (
285
+ let offset = submesh.indexOffset;
286
+ offset < submesh.indexOffset + submesh.indexCount;
287
+ offset += 1
288
+ ) {
289
+ const value = indices[offset] as number;
290
+ if (!Number.isInteger(value) || value < 0 || value >= positionCount) {
291
+ return err(
292
+ parseFailure(`indices[${offset}]`, value, 'index must address a position vertex'),
293
+ );
294
+ }
295
+ }
296
+ }
297
+ return ok(undefined);
298
+ }
299
+
300
+ function cross(a: Vec3, b: Vec3): Vec3 {
301
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
302
+ }
303
+
304
+ function subtract(a: Vec3, b: Vec3): Vec3 {
305
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
306
+ }
307
+
308
+ function triangleNormal(a: Vec3, b: Vec3, c: Vec3): Vec3 | undefined {
309
+ const normal = cross(subtract(b, a), subtract(c, a));
310
+ const length = Math.hypot(normal[0], normal[1], normal[2]);
311
+ if (!Number.isFinite(length) || length === 0) return undefined;
312
+ return [normal[0] / length, normal[1] / length, normal[2] / length];
313
+ }
314
+
315
+ function positionAt(position: Float32Array, index: number): Vec3 {
316
+ const base = index * 3;
317
+ return [position[base] as number, position[base + 1] as number, position[base + 2] as number];
318
+ }
319
+
320
+ function makeTriangle(
321
+ position: Float32Array,
322
+ indices: readonly [number, number, number],
323
+ ): Triangle | undefined {
324
+ const a = endpoint(positionAt(position, indices[0]));
325
+ const b = endpoint(positionAt(position, indices[1]));
326
+ const c = endpoint(positionAt(position, indices[2]));
327
+ const normal = triangleNormal(a.value, b.value, c.value);
328
+ return normal === undefined ? undefined : { a, b, c, normal };
329
+ }
330
+
331
+ function collectTriangles(source: MeshAsset, position: Float32Array): Triangle[] {
332
+ const triangles: Triangle[] = [];
333
+ for (const submesh of source.submeshes) {
334
+ if (source.indices === undefined) {
335
+ for (let offset = 0; offset < submesh.vertexCount; offset += 3) {
336
+ const triangle = makeTriangle(position, [offset, offset + 1, offset + 2]);
337
+ if (triangle !== undefined) triangles.push(triangle);
338
+ }
339
+ continue;
340
+ }
341
+ for (
342
+ let offset = submesh.indexOffset;
343
+ offset < submesh.indexOffset + submesh.indexCount;
344
+ offset += 3
345
+ ) {
346
+ const a = source.indices[offset] as number;
347
+ const b = source.indices[offset + 1] as number;
348
+ const c = source.indices[offset + 2] as number;
349
+ const triangle = makeTriangle(position, [a, b, c]);
350
+ if (triangle !== undefined) triangles.push(triangle);
351
+ }
352
+ }
353
+ return triangles;
354
+ }
355
+
356
+ function prepare(source: MeshAsset, threshold?: number): Result<PreparedMesh, AssetError> {
357
+ const positionResult = validatePosition(source);
358
+ if (!positionResult.ok) return positionResult;
359
+ if (threshold !== undefined) {
360
+ const thresholdResult = validateThreshold(threshold);
361
+ if (!thresholdResult.ok) return thresholdResult;
362
+ }
363
+ const submeshResult = validateSubmeshShape(source, positionResult.value.length / 3);
364
+ if (!submeshResult.ok) return submeshResult;
365
+ const indexResult = validateIndices(source, positionResult.value.length / 3);
366
+ if (!indexResult.ok) return indexResult;
367
+ return ok({ triangles: collectTriangles(source, positionResult.value) });
368
+ }
369
+
370
+ function addWireEdge(edges: Map<string, WireEdge>, left: Endpoint, right: Endpoint): void {
371
+ if (left.exactKey === right.exactKey) return;
372
+ const [a, b] = compareEndpoint(left, right) < 0 ? [left, right] : [right, left];
373
+ const key = `${a.exactKey}|${b.exactKey}`;
374
+ if (!edges.has(key)) edges.set(key, { a, b });
375
+ }
376
+
377
+ function collectWireEdges(triangles: readonly Triangle[]): WireEdge[] {
378
+ const edges = new Map<string, WireEdge>();
379
+ for (const triangle of triangles) {
380
+ addWireEdge(edges, triangle.a, triangle.b);
381
+ addWireEdge(edges, triangle.b, triangle.c);
382
+ addWireEdge(edges, triangle.c, triangle.a);
383
+ }
384
+ return Array.from(edges.values()).sort(compareWireEdge);
385
+ }
386
+
387
+ function updateRepresentative(current: Endpoint, candidate: Endpoint): Endpoint {
388
+ return compareEndpoint(candidate, current) < 0 ? candidate : current;
389
+ }
390
+
391
+ function addSurfaceEdge(
392
+ edges: Map<string, SurfaceEdge>,
393
+ left: Endpoint,
394
+ right: Endpoint,
395
+ normal: Vec3,
396
+ ): void {
397
+ if (left.weldKey === right.weldKey) return;
398
+ const leftFirst = compareText(left.weldKey, right.weldKey) < 0;
399
+ const a = leftFirst ? left : right;
400
+ const b = leftFirst ? right : left;
401
+ const key = `${a.weldKey}|${b.weldKey}`;
402
+ const current = edges.get(key);
403
+ if (current === undefined) {
404
+ edges.set(key, {
405
+ aWeldKey: a.weldKey,
406
+ bWeldKey: b.weldKey,
407
+ a,
408
+ b,
409
+ normals: [normal],
410
+ });
411
+ return;
412
+ }
413
+ current.a = updateRepresentative(current.a, a);
414
+ current.b = updateRepresentative(current.b, b);
415
+ current.normals.push(normal);
416
+ }
417
+
418
+ function collectSurfaceEdges(triangles: readonly Triangle[]): SurfaceEdge[] {
419
+ const edges = new Map<string, SurfaceEdge>();
420
+ for (const triangle of triangles) {
421
+ addSurfaceEdge(edges, triangle.a, triangle.b, triangle.normal);
422
+ addSurfaceEdge(edges, triangle.b, triangle.c, triangle.normal);
423
+ addSurfaceEdge(edges, triangle.c, triangle.a, triangle.normal);
424
+ }
425
+ return Array.from(edges.values()).sort(compareSurfaceEdge);
426
+ }
427
+
428
+ function keepSurfaceEdge(edge: SurfaceEdge, thresholdDegrees: number): boolean {
429
+ if (edge.normals.length !== 2) return true;
430
+ const first = edge.normals[0];
431
+ const second = edge.normals[1];
432
+ if (first === undefined || second === undefined) return true;
433
+ const dot = Math.min(
434
+ 1,
435
+ Math.max(-1, first[0] * second[0] + first[1] * second[1] + first[2] * second[2]),
436
+ );
437
+ return dot <= Math.cos((thresholdDegrees * Math.PI) / 180);
438
+ }
439
+
440
+ function lineMesh(edges: readonly WireEdge[]): Result<MeshAsset, AssetError> {
441
+ const positions = new Float32Array(edges.length * 6);
442
+ for (let edgeIndex = 0; edgeIndex < edges.length; edgeIndex += 1) {
443
+ const edge = edges[edgeIndex] as WireEdge;
444
+ positions.set(edge.a.value, edgeIndex * 6);
445
+ positions.set(edge.b.value, edgeIndex * 6 + 3);
446
+ }
447
+ const vertexCount = positions.length / 3;
448
+ const attributes: VertexAttributeMap = {
449
+ position: positions,
450
+ normal: new Float32Array(vertexCount * 3),
451
+ uv: new Float32Array(vertexCount * 2),
452
+ tangent: new Float32Array(vertexCount * 4),
453
+ };
454
+ const packed = packInterleavedVertexAttributes(attributes, vertexCount);
455
+ if (!packed.ok) return err(packed.error);
456
+ return ok({
457
+ kind: 'mesh',
458
+ vertices: packed.value.vertices,
459
+ attributes,
460
+ submeshes: [
461
+ {
462
+ indexOffset: 0,
463
+ indexCount: 0,
464
+ vertexCount,
465
+ topology: 'line-list',
466
+ materialSlot: 0,
467
+ },
468
+ ],
469
+ materialSlots: [{ slotName: 'Default' }],
470
+ aabb: box3.fromPositions(box3.create(), positions),
471
+ });
472
+ }
473
+
474
+ /** Convert every unique exact triangle edge into a sorted non-indexed line carrier. */
475
+ export function createWireframeGeometry(source: MeshAsset): Result<MeshAsset, AssetError> {
476
+ const prepared = prepare(source);
477
+ if (!prepared.ok) return prepared;
478
+ return lineMesh(collectWireEdges(prepared.value.triangles));
479
+ }
480
+
481
+ /** Convert boundary, non-manifold, and threshold-surviving surface edges into a line carrier. */
482
+ export function createEdgesGeometry(
483
+ source: MeshAsset,
484
+ thresholdAngleDegrees: number = DEFAULT_THRESHOLD_DEGREES,
485
+ ): Result<MeshAsset, AssetError> {
486
+ const prepared = prepare(source, thresholdAngleDegrees);
487
+ if (!prepared.ok) return prepared;
488
+ const edges = collectSurfaceEdges(prepared.value.triangles).filter((edge) =>
489
+ keepSurfaceEdge(edge, thresholdAngleDegrees),
490
+ );
491
+ return lineMesh(edges.map(({ a, b }) => ({ a, b })));
492
+ }
package/src/index.ts CHANGED
@@ -34,9 +34,16 @@ export {
34
34
  type Shape2dPose,
35
35
  type Vec2,
36
36
  } from './dim2';
37
+ // Edge factories return Result<MeshAsset, AssetError>; threshold units are degrees.
38
+ export { createEdgesGeometry, createWireframeGeometry } from './edges';
37
39
  export { createPlaneGeometry } from './plane';
38
40
  export { createSphereGeometry } from './sphere';
39
41
  export { computeTangentVec4 } from './tangent';
42
+ export {
43
+ createTeapotGeometry,
44
+ type TeapotMeshAsset,
45
+ type TeapotProvenance,
46
+ } from './teapot';
40
47
  export { createTorusGeometry } from './torus';
41
48
  export {
42
49
  buildMeshAttributeMapForUvSets,