@forgeax/engine-geometry 0.1.2

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 (60) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +215 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/asset-owner.unit.test.d.ts +2 -0
  5. package/dist/__tests__/asset-owner.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/dim2.test.d.ts +2 -0
  7. package/dist/__tests__/dim2.test.d.ts.map +1 -0
  8. package/dist/__tests__/geometry.unit.test.d.ts +2 -0
  9. package/dist/__tests__/geometry.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/vertex-attribute-layout-owner.unit.test.d.ts +2 -0
  11. package/dist/__tests__/vertex-attribute-layout-owner.unit.test.d.ts.map +1 -0
  12. package/dist/assets/mesh-binary.d.ts +4 -0
  13. package/dist/assets/mesh-binary.d.ts.map +1 -0
  14. package/dist/assets/mesh-decoder.d.ts +6 -0
  15. package/dist/assets/mesh-decoder.d.ts.map +1 -0
  16. package/dist/assets/primitive-mesh.d.ts +5 -0
  17. package/dist/assets/primitive-mesh.d.ts.map +1 -0
  18. package/dist/box.d.ts +41 -0
  19. package/dist/box.d.ts.map +1 -0
  20. package/dist/capsule.d.ts +14 -0
  21. package/dist/capsule.d.ts.map +1 -0
  22. package/dist/cone.d.ts +4 -0
  23. package/dist/cone.d.ts.map +1 -0
  24. package/dist/cylinder.d.ts +4 -0
  25. package/dist/cylinder.d.ts.map +1 -0
  26. package/dist/dim2.d.ts +73 -0
  27. package/dist/dim2.d.ts.map +1 -0
  28. package/dist/index.d.ts +14 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.mjs +2000 -0
  31. package/dist/index.mjs.map +1 -0
  32. package/dist/plane.d.ts +4 -0
  33. package/dist/plane.d.ts.map +1 -0
  34. package/dist/sphere.d.ts +4 -0
  35. package/dist/sphere.d.ts.map +1 -0
  36. package/dist/tangent.d.ts +34 -0
  37. package/dist/tangent.d.ts.map +1 -0
  38. package/dist/torus.d.ts +4 -0
  39. package/dist/torus.d.ts.map +1 -0
  40. package/dist/vertex-attribute-layout.d.ts +90 -0
  41. package/dist/vertex-attribute-layout.d.ts.map +1 -0
  42. package/package.json +59 -0
  43. package/src/__tests__/asset-owner.unit.test.ts +97 -0
  44. package/src/__tests__/dim2.test.ts +132 -0
  45. package/src/__tests__/geometry.unit.test.ts +1223 -0
  46. package/src/__tests__/vertex-attribute-layout-owner.unit.test.ts +181 -0
  47. package/src/assets/mesh-binary.ts +421 -0
  48. package/src/assets/mesh-decoder.ts +97 -0
  49. package/src/assets/primitive-mesh.ts +36 -0
  50. package/src/box.ts +437 -0
  51. package/src/capsule.ts +145 -0
  52. package/src/cone.ts +26 -0
  53. package/src/cylinder.ts +180 -0
  54. package/src/dim2.ts +505 -0
  55. package/src/index.ts +52 -0
  56. package/src/plane.ts +78 -0
  57. package/src/sphere.ts +82 -0
  58. package/src/tangent.ts +320 -0
  59. package/src/torus.ts +83 -0
  60. package/src/vertex-attribute-layout.ts +503 -0
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ // @forgeax/engine-geometry - single-import barrel.
2
+ //
3
+ // AI users get the procedural geometry factories under one namespace import,
4
+ // each returning `Result<MeshAsset, AssetError>` (charter F1 single-entry
5
+ // indexability; P3 explicit failure). Alongside them: the vertex attribute
6
+ // layout SSOT (deriveVertexBufferLayout / buildMeshAttributeMapForUvSets /
7
+ // GpuVertexBufferLayoutEntry) and the tangent helper (computeTangentVec4)
8
+ // consumed by the runtime pipeline + material layers.
9
+ //
10
+ // Attribute layout (all factories): position (3 floats) + normal (3 floats) +
11
+ // uv (2 floats), expanded to the 12-float runtime layout (adds tangent vec4)
12
+ // by meshFromInterleaved / PROCEDURAL_FLOATS_PER_VERTEX.
13
+
14
+ export type { VertexAttributePackDetail } from '@forgeax/engine-types';
15
+ export { meshAssetContribution, meshAssetDecoder, meshAssetKind } from './assets/mesh-decoder';
16
+ export { createPrimitiveMesh, type PrimitiveMeshKind } from './assets/primitive-mesh';
17
+ export {
18
+ createBoxGeometry,
19
+ meshFromInterleaved,
20
+ PROCEDURAL_FLOATS_PER_VERTEX,
21
+ } from './box';
22
+ export { createCapsuleGeometry } from './capsule';
23
+ export { createConeGeometry } from './cone';
24
+ export { createCylinderGeometry } from './cylinder';
25
+ export {
26
+ compute2dBounds,
27
+ create2dGeometry,
28
+ create2dRingGeometry,
29
+ type Shape2d,
30
+ type Shape2dBounds,
31
+ type Shape2dMeshOptions,
32
+ type Shape2dPose,
33
+ type Vec2,
34
+ } from './dim2';
35
+ export { createPlaneGeometry } from './plane';
36
+ export { createSphereGeometry } from './sphere';
37
+ export { computeTangentVec4 } from './tangent';
38
+ export { createTorusGeometry } from './torus';
39
+ export {
40
+ buildMeshAttributeMapForUvSets,
41
+ deriveVertexBufferLayout,
42
+ deriveVertexBufferLayoutFromProjection,
43
+ deriveVertexLayoutProjection,
44
+ deriveVertexLayoutProjectionFromMask,
45
+ type GpuVertexBufferLayoutEntry,
46
+ type PackedVertexAttributes,
47
+ packInterleavedVertexAttributes,
48
+ VertexAttributePackError,
49
+ type VertexLayoutProjection,
50
+ type VertexLayoutProjectionAttribute,
51
+ type VertexLayoutProjectionMaskError,
52
+ } from './vertex-attribute-layout';
package/src/plane.ts ADDED
@@ -0,0 +1,78 @@
1
+ // @forgeax/engine-runtime - Procedural Plane geometry (M3 / w8).
2
+ //
3
+ // Mirrors Three.js r184 PlaneGeometry: createPlaneGeometry(width, height,
4
+ // widthSegments?, heightSegments?) -> Result<MeshAsset, AssetError>. The plane
5
+ // lies on the XY plane with +Z normal (standard three.js orientation).
6
+ // Degenerate parameters fail-fast with AssetError('asset-parse-failed').
7
+ //
8
+ // Related: requirements §AC-06 / §AC-14; plan-strategy §M3 + D-P5;
9
+ // plan-tasks.json w8 acceptanceCheck.
10
+
11
+ import type { AssetError, MeshAsset } from '@forgeax/engine-types';
12
+ import { err, type Result } from '@forgeax/engine-types';
13
+ import { degenerate, FACTORY_FLOATS_PER_VERTEX, meshFromInterleaved } from './box';
14
+
15
+ export function createPlaneGeometry(
16
+ width: number,
17
+ height: number,
18
+ widthSegments: number = 1,
19
+ heightSegments: number = 1,
20
+ ): Result<MeshAsset, AssetError> {
21
+ if (width <= 0 || height <= 0) {
22
+ return err(degenerate(`width=${width}, height=${height}`));
23
+ }
24
+ const ws = widthSegments | 0;
25
+ const hs = heightSegments | 0;
26
+ if (ws < 1 || hs < 1) {
27
+ return err(degenerate(`widthSegments=${ws}, heightSegments=${hs}`));
28
+ }
29
+
30
+ const halfW = width / 2;
31
+ const halfH = height / 2;
32
+ const gridX1 = ws + 1;
33
+ const gridY1 = hs + 1;
34
+ const segW = width / ws;
35
+ const segH = height / hs;
36
+
37
+ const vertexCount = gridX1 * gridY1;
38
+ const indexCount = ws * hs * 6;
39
+ const vertices = new Float32Array(vertexCount * FACTORY_FLOATS_PER_VERTEX);
40
+ const indices = new Uint32Array(indexCount);
41
+
42
+ let vIdx = 0;
43
+ for (let iy = 0; iy < gridY1; iy++) {
44
+ const y = iy * segH - halfH;
45
+ for (let ix = 0; ix < gridX1; ix++) {
46
+ const x = ix * segW - halfW;
47
+ const base = vIdx * FACTORY_FLOATS_PER_VERTEX;
48
+ vertices[base + 0] = x;
49
+ vertices[base + 1] = -y;
50
+ vertices[base + 2] = 0;
51
+ vertices[base + 3] = 0;
52
+ vertices[base + 4] = 0;
53
+ vertices[base + 5] = 1;
54
+ vertices[base + 6] = ix / ws;
55
+ // UV.v uses the WebGPU top-left convention (V=0 = image top).
56
+ vertices[base + 7] = iy / hs;
57
+ vIdx++;
58
+ }
59
+ }
60
+
61
+ let iIdx = 0;
62
+ for (let iy = 0; iy < hs; iy++) {
63
+ for (let ix = 0; ix < ws; ix++) {
64
+ const a = ix + gridX1 * iy;
65
+ const b = ix + gridX1 * (iy + 1);
66
+ const c = ix + 1 + gridX1 * (iy + 1);
67
+ const d = ix + 1 + gridX1 * iy;
68
+ indices[iIdx++] = a;
69
+ indices[iIdx++] = b;
70
+ indices[iIdx++] = d;
71
+ indices[iIdx++] = b;
72
+ indices[iIdx++] = c;
73
+ indices[iIdx++] = d;
74
+ }
75
+ }
76
+
77
+ return meshFromInterleaved(vertices, indices);
78
+ }
package/src/sphere.ts ADDED
@@ -0,0 +1,82 @@
1
+ // @forgeax/engine-runtime - Procedural Sphere geometry (M3 / w8).
2
+ //
3
+ // Mirrors Three.js r184 SphereGeometry signature:
4
+ // createSphereGeometry(radius, widthSegments?, heightSegments?) ->
5
+ // Result<MeshAsset, AssetError>. Degenerate parameters (radius <= 0, widthSegments
6
+ // < 3, heightSegments < 2) fail-fast with AssetError('asset-parse-failed').
7
+ //
8
+ // Related: requirements §AC-06 / §AC-14; plan-strategy §M3 + D-P5;
9
+ // plan-tasks.json w8 acceptanceCheck.
10
+
11
+ import type { AssetError, MeshAsset } from '@forgeax/engine-types';
12
+ import { err, type Result } from '@forgeax/engine-types';
13
+ import { degenerate, FACTORY_FLOATS_PER_VERTEX, meshFromInterleaved } from './box';
14
+
15
+ export function createSphereGeometry(
16
+ radius: number,
17
+ widthSegments: number = 16,
18
+ heightSegments: number = 12,
19
+ ): Result<MeshAsset, AssetError> {
20
+ if (radius <= 0) return err(degenerate(`radius=${radius}`));
21
+ const ws = widthSegments | 0;
22
+ const hs = heightSegments | 0;
23
+ if (ws < 3) return err(degenerate(`widthSegments=${ws}; minimum 3`));
24
+ if (hs < 2) return err(degenerate(`heightSegments=${hs}; minimum 2`));
25
+
26
+ const vertexCount = (ws + 1) * (hs + 1);
27
+ const indexCount = ws * hs * 6;
28
+ const vertices = new Float32Array(vertexCount * FACTORY_FLOATS_PER_VERTEX);
29
+ const indices = new Uint32Array(indexCount);
30
+
31
+ let vIdx = 0;
32
+ for (let iy = 0; iy <= hs; iy++) {
33
+ const v = iy / hs;
34
+ const phi = v * Math.PI;
35
+ for (let ix = 0; ix <= ws; ix++) {
36
+ const u = ix / ws;
37
+ const theta = u * Math.PI * 2;
38
+ const x = -radius * Math.cos(theta) * Math.sin(phi);
39
+ const y = radius * Math.cos(phi);
40
+ const z = radius * Math.sin(theta) * Math.sin(phi);
41
+ const nx = x / radius;
42
+ const ny = y / radius;
43
+ const nz = z / radius;
44
+ const base = vIdx * FACTORY_FLOATS_PER_VERTEX;
45
+ vertices[base + 0] = x;
46
+ vertices[base + 1] = y;
47
+ vertices[base + 2] = z;
48
+ vertices[base + 3] = nx;
49
+ vertices[base + 4] = ny;
50
+ vertices[base + 5] = nz;
51
+ vertices[base + 6] = u;
52
+ // UV.v uses the WebGPU top-left convention (V=0 = image top).
53
+ vertices[base + 7] = v;
54
+ vIdx++;
55
+ }
56
+ }
57
+
58
+ let iIdx = 0;
59
+ const stride = ws + 1;
60
+ for (let iy = 0; iy < hs; iy++) {
61
+ for (let ix = 0; ix < ws; ix++) {
62
+ const a = iy * stride + ix + 1;
63
+ const b = iy * stride + ix;
64
+ const c = (iy + 1) * stride + ix;
65
+ const d = (iy + 1) * stride + ix + 1;
66
+ if (iy !== 0) {
67
+ indices[iIdx++] = a;
68
+ indices[iIdx++] = b;
69
+ indices[iIdx++] = d;
70
+ }
71
+ if (iy !== hs - 1) {
72
+ indices[iIdx++] = b;
73
+ indices[iIdx++] = c;
74
+ indices[iIdx++] = d;
75
+ }
76
+ }
77
+ }
78
+
79
+ // trim unused tail (pole rows contribute fewer triangles than the prealloc estimate)
80
+ const trimmed = indices.slice(0, iIdx);
81
+ return meshFromInterleaved(vertices, trimmed);
82
+ }
package/src/tangent.ts ADDED
@@ -0,0 +1,320 @@
1
+ // @forgeax/engine-runtime - per-vertex tangent (vec4) helper (M4 / w20).
2
+ //
3
+ // Implements the path A formula (LearnOpenGL section 5.4 + glTF 2.0 vec4
4
+ // shape) for procedural geometry: per-triangle UV-derivative tangent ->
5
+ // face-area-weighted average -> Gram-Schmidt re-orthogonalisation against
6
+ // the supplied per-vertex normal -> handedness sign(det(deltaUV)) packed
7
+ // into the .w channel.
8
+ //
9
+ // Output shape: Float32Array (vertexCount * 4) where each vertex is
10
+ // [tx, ty, tz, w] with `.w` in {+1, -1}. The .w sign is forward-compatible
11
+ // with glTF 2.0 spec / MikkTSpace baker (`B = cross(N, T.xyz) * T.w`),
12
+ // satisfying feat-20260518 risk R-4 mitigation (path A vec4 -> path B
13
+ // shader consumption is a no-op switch).
14
+ //
15
+ // Plan-strategy anchors: section 2 D-2 (path A); D-7 (single-file helper to
16
+ // avoid 30-line per-geometry re-implementation -- SSOT #1 + DRY #2).
17
+ // Knowledge-base anchor: .forgeax-harness/knowledge-base/wiki/normal-mapping-and-tbn.md
18
+ // section 2 (math skeleton) and section 2.2 (per-vertex average +
19
+ // handedness). Risk anchor: R-4 (vec4 + .w forward compatibility).
20
+ //
21
+ // Pure function with shared Result/AssetError vocabulary and no mutable state
22
+ // or side effects. Indices may be Uint16Array, Uint32Array, or
23
+ // undefined (sequential 0..vertexCount-1 -- triangle list). Degenerate
24
+ // triangles (zero area UV; det ~ 0) are skipped from the accumulation;
25
+ // vertices touched only by degenerate triangles fall back to a stable
26
+ // frame: tangent perpendicular to the supplied normal with .w = +1.
27
+
28
+ import { ASSET_ERROR_HINTS, AssetError, err, ok, type Result } from '@forgeax/engine-types';
29
+
30
+ const EPSILON = 1e-8;
31
+
32
+ function tangentInputError(field: string, value: number, reason: string): AssetError {
33
+ return new AssetError({
34
+ code: 'asset-parse-failed',
35
+ expected: `valid tangent topology: ${reason}`,
36
+ hint: ASSET_ERROR_HINTS['asset-parse-failed'],
37
+ detail: { field, value, reason },
38
+ });
39
+ }
40
+
41
+ function preflightTangentInput(
42
+ positions: Float32Array,
43
+ normals: Float32Array,
44
+ uvs: Float32Array,
45
+ indices: Uint16Array | Uint32Array | undefined,
46
+ ): Result<number, AssetError> {
47
+ if (positions.length % 3 !== 0) {
48
+ return err(
49
+ tangentInputError(
50
+ 'positions',
51
+ positions.length,
52
+ 'positions.length must be divisible by the position stride of 3',
53
+ ),
54
+ );
55
+ }
56
+ const vertexCount = positions.length / 3;
57
+ if (normals.length !== vertexCount * 3) {
58
+ return err(
59
+ tangentInputError(
60
+ 'normals',
61
+ normals.length,
62
+ `normals.length must equal vertexCount * 3 (${vertexCount * 3})`,
63
+ ),
64
+ );
65
+ }
66
+ if (uvs.length !== vertexCount * 2) {
67
+ return err(
68
+ tangentInputError(
69
+ 'uvs',
70
+ uvs.length,
71
+ `uvs.length must equal vertexCount * 2 (${vertexCount * 2})`,
72
+ ),
73
+ );
74
+ }
75
+ if (indices === undefined) {
76
+ if (vertexCount % 3 !== 0) {
77
+ return err(
78
+ tangentInputError(
79
+ 'positions',
80
+ vertexCount,
81
+ 'non-indexed vertexCount must be divisible by the triangle size of 3',
82
+ ),
83
+ );
84
+ }
85
+ return ok(vertexCount);
86
+ }
87
+ if (indices.length % 3 !== 0) {
88
+ return err(
89
+ tangentInputError(
90
+ 'indices',
91
+ indices.length,
92
+ 'indices.length must be divisible by the triangle size of 3',
93
+ ),
94
+ );
95
+ }
96
+ for (let indexPosition = 0; indexPosition < indices.length; indexPosition++) {
97
+ const index = indices[indexPosition];
98
+ if (index === undefined || !Number.isInteger(index) || index < 0 || index >= vertexCount) {
99
+ return err(
100
+ tangentInputError(
101
+ 'indices',
102
+ index ?? -1,
103
+ `indices[${indexPosition}] must be an integer in [0, ${vertexCount})`,
104
+ ),
105
+ );
106
+ }
107
+ }
108
+ return ok(vertexCount);
109
+ }
110
+
111
+ /**
112
+ * Compute per-vertex tangent (vec4) for a procedural mesh using path A
113
+ * (UV-derivative + face-area-weighted average + Gram-Schmidt + handedness).
114
+ *
115
+ * @param positions Float32Array, vertexCount * 3 (xyz interleaved).
116
+ * @param normals Float32Array, vertexCount * 3 (xyz interleaved); must be
117
+ * pre-normalised by the caller (procedural factories already produce
118
+ * unit-length normals at vertex emit time).
119
+ * @param uvs Float32Array, vertexCount * 2 (uv interleaved).
120
+ * @param indices optional Uint16Array | Uint32Array triangle list; when
121
+ * undefined, sequential triangulation 0,1,2,3,4,5,... is assumed.
122
+ * @returns `Result<Float32Array, AssetError>` with a Float32Array of
123
+ * vertexCount * 4 on success. `.xyz` is the tangent and `.w` is the
124
+ * handedness sign in {+1, -1}; malformed topology returns the existing
125
+ * `asset-parse-failed` AssetError before working or output buffers exist.
126
+ *
127
+ * @remarks
128
+ * Path A formula (per triangle):
129
+ * ```
130
+ * dP1 = p2 - p1; dP2 = p3 - p1
131
+ * dUV1 = uv2 - uv1; dUV2 = uv3 - uv1
132
+ * det = dUV1.u * dUV2.v - dUV2.u * dUV1.v
133
+ * T_face = (dUV2.v * dP1 - dUV1.v * dP2) / det
134
+ * sign = det >= 0 ? +1 : -1
135
+ * ```
136
+ * Per-vertex aggregation: accumulate `T_face * faceArea` into the three
137
+ * vertices of the triangle; accumulate `sign` (the dominant sign per
138
+ * vertex wins). After accumulation: Gram-Schmidt against the supplied
139
+ * normal `T' = normalize(T - dot(T, N) * N)`; repack as vec4 with the
140
+ * dominant handedness sign.
141
+ */
142
+ export function computeTangentVec4(
143
+ positions: Float32Array,
144
+ normals: Float32Array,
145
+ uvs: Float32Array,
146
+ indices?: Uint16Array | Uint32Array,
147
+ ): Result<Float32Array, AssetError> {
148
+ const preflight = preflightTangentInput(positions, normals, uvs, indices);
149
+ if (!preflight.ok) return preflight;
150
+ const vertexCount = preflight.value;
151
+
152
+ // Working buffers: per-vertex accumulated tangent + accumulated handedness
153
+ // signed weight (sum of (face area * sign(det))). The dominant sign of
154
+ // the running sum is the per-vertex .w handedness.
155
+ const accumT = new Float32Array(vertexCount * 3);
156
+ const accumSign = new Float32Array(vertexCount);
157
+
158
+ const triangleCount = indices !== undefined ? indices.length / 3 : vertexCount / 3;
159
+
160
+ for (let tri = 0; tri < triangleCount; tri++) {
161
+ const i0 = indices !== undefined ? (indices[tri * 3] ?? 0) : tri * 3;
162
+ const i1 = indices !== undefined ? (indices[tri * 3 + 1] ?? 0) : tri * 3 + 1;
163
+ const i2 = indices !== undefined ? (indices[tri * 3 + 2] ?? 0) : tri * 3 + 2;
164
+
165
+ const p0x = positions[i0 * 3] ?? 0;
166
+ const p0y = positions[i0 * 3 + 1] ?? 0;
167
+ const p0z = positions[i0 * 3 + 2] ?? 0;
168
+ const p1x = positions[i1 * 3] ?? 0;
169
+ const p1y = positions[i1 * 3 + 1] ?? 0;
170
+ const p1z = positions[i1 * 3 + 2] ?? 0;
171
+ const p2x = positions[i2 * 3] ?? 0;
172
+ const p2y = positions[i2 * 3 + 1] ?? 0;
173
+ const p2z = positions[i2 * 3 + 2] ?? 0;
174
+
175
+ const u0 = uvs[i0 * 2] ?? 0;
176
+ const v0 = uvs[i0 * 2 + 1] ?? 0;
177
+ const u1 = uvs[i1 * 2] ?? 0;
178
+ const v1 = uvs[i1 * 2 + 1] ?? 0;
179
+ const u2 = uvs[i2 * 2] ?? 0;
180
+ const v2 = uvs[i2 * 2 + 1] ?? 0;
181
+
182
+ const dP1x = p1x - p0x;
183
+ const dP1y = p1y - p0y;
184
+ const dP1z = p1z - p0z;
185
+ const dP2x = p2x - p0x;
186
+ const dP2y = p2y - p0y;
187
+ const dP2z = p2z - p0z;
188
+
189
+ const dU1 = u1 - u0;
190
+ const dV1 = v1 - v0;
191
+ const dU2 = u2 - u0;
192
+ const dV2 = v2 - v0;
193
+
194
+ const det = dU1 * dV2 - dU2 * dV1;
195
+ if (Math.abs(det) < EPSILON) {
196
+ // degenerate UV (collinear / zero-area in UV space); skip
197
+ continue;
198
+ }
199
+ const invDet = 1 / det;
200
+ const tx = invDet * (dV2 * dP1x - dV1 * dP2x);
201
+ const ty = invDet * (dV2 * dP1y - dV1 * dP2y);
202
+ const tz = invDet * (dV2 * dP1z - dV1 * dP2z);
203
+
204
+ // Face area (cross product magnitude / 2). Used as accumulation weight.
205
+ const cx = dP1y * dP2z - dP1z * dP2y;
206
+ const cy = dP1z * dP2x - dP1x * dP2z;
207
+ const cz = dP1x * dP2y - dP1y * dP2x;
208
+ const faceArea = 0.5 * Math.sqrt(cx * cx + cy * cy + cz * cz);
209
+ if (faceArea < EPSILON) continue;
210
+
211
+ const signDet = det >= 0 ? 1 : -1;
212
+ const signedWeight = faceArea * signDet;
213
+
214
+ for (const vi of [i0, i1, i2]) {
215
+ accumT[vi * 3] = (accumT[vi * 3] ?? 0) + tx * faceArea;
216
+ accumT[vi * 3 + 1] = (accumT[vi * 3 + 1] ?? 0) + ty * faceArea;
217
+ accumT[vi * 3 + 2] = (accumT[vi * 3 + 2] ?? 0) + tz * faceArea;
218
+ accumSign[vi] = (accumSign[vi] ?? 0) + signedWeight;
219
+ }
220
+ }
221
+
222
+ const out = new Float32Array(vertexCount * 4);
223
+ for (let v = 0; v < vertexCount; v++) {
224
+ let tx = accumT[v * 3] ?? 0;
225
+ let ty = accumT[v * 3 + 1] ?? 0;
226
+ let tz = accumT[v * 3 + 2] ?? 0;
227
+ const nx = normals[v * 3] ?? 0;
228
+ const ny = normals[v * 3 + 1] ?? 0;
229
+ const nz = normals[v * 3 + 2] ?? 0;
230
+
231
+ const tLen = Math.sqrt(tx * tx + ty * ty + tz * tz);
232
+ if (tLen < EPSILON) {
233
+ // Fallback: pick any direction perpendicular to N. Use the axis
234
+ // with the smallest |normal component| to maximise numerical
235
+ // stability of the cross product.
236
+ const ax = Math.abs(nx);
237
+ const ay = Math.abs(ny);
238
+ const az = Math.abs(nz);
239
+ let rx = 1;
240
+ let ry = 0;
241
+ let rz = 0;
242
+ if (ax <= ay && ax <= az) {
243
+ rx = 1;
244
+ ry = 0;
245
+ rz = 0;
246
+ } else if (ay <= ax && ay <= az) {
247
+ rx = 0;
248
+ ry = 1;
249
+ rz = 0;
250
+ } else {
251
+ rx = 0;
252
+ ry = 0;
253
+ rz = 1;
254
+ }
255
+ // T = normalize(cross(N, R))
256
+ tx = ny * rz - nz * ry;
257
+ ty = nz * rx - nx * rz;
258
+ tz = nx * ry - ny * rx;
259
+ const fLen = Math.sqrt(tx * tx + ty * ty + tz * tz) || 1;
260
+ tx /= fLen;
261
+ ty /= fLen;
262
+ tz /= fLen;
263
+ } else {
264
+ tx /= tLen;
265
+ ty /= tLen;
266
+ tz /= tLen;
267
+ }
268
+
269
+ // Gram-Schmidt: T' = normalize(T - dot(T, N) * N)
270
+ const dotTN = tx * nx + ty * ny + tz * nz;
271
+ let gx = tx - dotTN * nx;
272
+ let gy = ty - dotTN * ny;
273
+ let gz = tz - dotTN * nz;
274
+ const gLen = Math.sqrt(gx * gx + gy * gy + gz * gz);
275
+ if (gLen < EPSILON) {
276
+ // tangent collinear with normal after accumulation; reuse fallback
277
+ // basis. Pick perpendicular via the smallest-axis trick again.
278
+ const ax = Math.abs(nx);
279
+ const ay = Math.abs(ny);
280
+ const az = Math.abs(nz);
281
+ let rx = 1;
282
+ let ry = 0;
283
+ let rz = 0;
284
+ if (ax <= ay && ax <= az) {
285
+ rx = 1;
286
+ ry = 0;
287
+ rz = 0;
288
+ } else if (ay <= ax && ay <= az) {
289
+ rx = 0;
290
+ ry = 1;
291
+ rz = 0;
292
+ } else {
293
+ rx = 0;
294
+ ry = 0;
295
+ rz = 1;
296
+ }
297
+ gx = ny * rz - nz * ry;
298
+ gy = nz * rx - nx * rz;
299
+ gz = nx * ry - ny * rx;
300
+ const fLen = Math.sqrt(gx * gx + gy * gy + gz * gz) || 1;
301
+ gx /= fLen;
302
+ gy /= fLen;
303
+ gz /= fLen;
304
+ } else {
305
+ gx /= gLen;
306
+ gy /= gLen;
307
+ gz /= gLen;
308
+ }
309
+
310
+ const accSign = accumSign[v] ?? 0;
311
+ const w = accSign >= 0 ? 1 : -1;
312
+
313
+ out[v * 4] = gx;
314
+ out[v * 4 + 1] = gy;
315
+ out[v * 4 + 2] = gz;
316
+ out[v * 4 + 3] = w;
317
+ }
318
+
319
+ return ok(out);
320
+ }
package/src/torus.ts ADDED
@@ -0,0 +1,83 @@
1
+ // @forgeax/engine-runtime - Procedural Torus geometry (M3 / w8).
2
+ //
3
+ // Mirrors Three.js r184 TorusGeometry: createTorusGeometry(radius, tube,
4
+ // radialSegments?, tubularSegments?) -> Result<MeshAsset, AssetError>.
5
+ // `radius` is the distance from the torus center to the tube center (ring
6
+ // radius); `tube` is the cross-section (tube) radius. Degenerate parameters
7
+ // fail-fast with AssetError('asset-parse-failed').
8
+ //
9
+ // Related: requirements §AC-06 / §AC-14; plan-strategy §M3 + D-P5;
10
+ // plan-tasks.json w8 acceptanceCheck.
11
+
12
+ import type { AssetError, MeshAsset } from '@forgeax/engine-types';
13
+ import { err, type Result } from '@forgeax/engine-types';
14
+ import { degenerate, FACTORY_FLOATS_PER_VERTEX, meshFromInterleaved } from './box';
15
+
16
+ export function createTorusGeometry(
17
+ radius: number,
18
+ tube: number,
19
+ radialSegments: number = 8,
20
+ tubularSegments: number = 24,
21
+ ): Result<MeshAsset, AssetError> {
22
+ if (radius <= 0) return err(degenerate(`radius=${radius}`));
23
+ if (tube <= 0) return err(degenerate(`tube=${tube}`));
24
+ const rs = radialSegments | 0;
25
+ const ts = tubularSegments | 0;
26
+ if (rs < 3) return err(degenerate(`radialSegments=${rs}; minimum 3`));
27
+ if (ts < 3) return err(degenerate(`tubularSegments=${ts}; minimum 3`));
28
+
29
+ const vertexCount = (rs + 1) * (ts + 1);
30
+ const indexCount = rs * ts * 6;
31
+ const vertices = new Float32Array(vertexCount * FACTORY_FLOATS_PER_VERTEX);
32
+ const indices = new Uint32Array(indexCount);
33
+
34
+ let vIdx = 0;
35
+ for (let j = 0; j <= rs; j++) {
36
+ const v = (j / rs) * Math.PI * 2;
37
+ const cosV = Math.cos(v);
38
+ const sinV = Math.sin(v);
39
+ for (let i = 0; i <= ts; i++) {
40
+ const u = (i / ts) * Math.PI * 2;
41
+ const cosU = Math.cos(u);
42
+ const sinU = Math.sin(u);
43
+ const x = (radius + tube * cosV) * cosU;
44
+ const y = (radius + tube * cosV) * sinU;
45
+ const z = tube * sinV;
46
+ // normal = normalize(position - center_of_tube_at_u)
47
+ const centerX = radius * cosU;
48
+ const centerY = radius * sinU;
49
+ const nx = x - centerX;
50
+ const ny = y - centerY;
51
+ const nz = z;
52
+ const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
53
+ const base = vIdx * FACTORY_FLOATS_PER_VERTEX;
54
+ vertices[base + 0] = x;
55
+ vertices[base + 1] = y;
56
+ vertices[base + 2] = z;
57
+ vertices[base + 3] = nx / nlen;
58
+ vertices[base + 4] = ny / nlen;
59
+ vertices[base + 5] = nz / nlen;
60
+ vertices[base + 6] = i / ts;
61
+ vertices[base + 7] = j / rs;
62
+ vIdx++;
63
+ }
64
+ }
65
+
66
+ let iIdx = 0;
67
+ for (let j = 1; j <= rs; j++) {
68
+ for (let i = 1; i <= ts; i++) {
69
+ const a = (ts + 1) * j + i - 1;
70
+ const b = (ts + 1) * (j - 1) + i - 1;
71
+ const c = (ts + 1) * (j - 1) + i;
72
+ const d = (ts + 1) * j + i;
73
+ indices[iIdx++] = a;
74
+ indices[iIdx++] = b;
75
+ indices[iIdx++] = d;
76
+ indices[iIdx++] = b;
77
+ indices[iIdx++] = c;
78
+ indices[iIdx++] = d;
79
+ }
80
+ }
81
+
82
+ return meshFromInterleaved(vertices, indices);
83
+ }