@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
@@ -0,0 +1,36 @@
1
+ import type { AssetError, MeshAsset, Result } from '@forgeax/engine-types';
2
+ import { createBoxGeometry, meshFromInterleaved } from '../box';
3
+ import { createCylinderGeometry } from '../cylinder';
4
+ import { createPlaneGeometry } from '../plane';
5
+ import { createSphereGeometry } from '../sphere';
6
+
7
+ export type PrimitiveMeshKind =
8
+ | 'cube'
9
+ | 'triangle'
10
+ | 'quad'
11
+ | 'sphere'
12
+ | 'cylinder'
13
+ | 'nine-slice-quad';
14
+
15
+ /** Create one ordinary mesh payload for allocation or interning by an owning World. */
16
+ export function createPrimitiveMesh(kind: PrimitiveMeshKind): Result<MeshAsset, AssetError> {
17
+ switch (kind) {
18
+ case 'cube':
19
+ return createBoxGeometry(1, 1, 1);
20
+ case 'triangle':
21
+ return meshFromInterleaved(
22
+ new Float32Array([
23
+ 0, 0.7, 0, 0, 0, 1, 0.5, 1, -0.7, -0.6, 0, 0, 0, 1, 0, 0, 0.7, -0.6, 0, 0, 0, 1, 1, 0,
24
+ ]),
25
+ new Uint16Array([0, 1, 2]),
26
+ );
27
+ case 'quad':
28
+ return createPlaneGeometry(1, 1);
29
+ case 'sphere':
30
+ return createSphereGeometry(1, 16, 12);
31
+ case 'cylinder':
32
+ return createCylinderGeometry(0.5, 0.5, 1, 16, 1);
33
+ case 'nine-slice-quad':
34
+ return createPlaneGeometry(1, 1, 3, 3);
35
+ }
36
+ }
package/src/box.ts ADDED
@@ -0,0 +1,437 @@
1
+ // @forgeax/engine-runtime - Procedural Box geometry (M3 / w8).
2
+ //
3
+ // Mirrors Three.js r184 BoxGeometry signature: createBoxGeometry(width, height,
4
+ // depth, widthSegments?, heightSegments?, depthSegments?) -> Result<MeshAsset, AssetError>.
5
+ // Degenerate parameters (any dim <= 0 or segment < 1) fail-fast with
6
+ // AssetError({ code: 'asset-parse-failed' }) — charter proposition 4 explicit
7
+ // failure red line (requirements §9 geometry double-semantics for
8
+ // 'asset-parse-failed').
9
+ //
10
+ // Attributes populated: position / normal / uv (Float32Array views of the
11
+ // interleaved `vertices` buffer; 8 floats per vertex). This is the AC-15
12
+ // narrowing anchor: each factory includes a
13
+ // `for (const [key] of Object.entries(attrs))` loop that TypeScript infers as
14
+ // the VertexAttributeMap key union (no `as` cast).
15
+ //
16
+ // Related: requirements §AC-06 / §AC-14 / §AC-15;
17
+ // plan-strategy §M3 + D-P5 (6 procedural geometries lowercase keys);
18
+ // plan-tasks.json w8 acceptanceCheck;
19
+ // research Finding 4 (Three.js r184 BufferGeometry mental migration).
20
+
21
+ import { box3 } from '@forgeax/engine-math';
22
+ import {
23
+ ASSET_ERROR_HINTS,
24
+ AssetError,
25
+ err,
26
+ type MeshAsset,
27
+ ok,
28
+ type Result,
29
+ type VertexAttributeMap,
30
+ } from '@forgeax/engine-types';
31
+ import { computeTangentVec4 } from './tangent';
32
+ import { deriveVertexBufferLayout } from './vertex-attribute-layout';
33
+
34
+ /**
35
+ * Floats per vertex for the procedural-geometry interleaved buffer that the
36
+ * factory bodies fill in (position(3) + normal(3) + uv(2)). The
37
+ * `meshFromInterleaved` helper expands this into the runtime 12-float
38
+ * (position + normal + uv + tangent) layout consumed by the standard
39
+ * pipeline (feat-20260518 M4 D-10).
40
+ */
41
+ export const FACTORY_FLOATS_PER_VERTEX = 8;
42
+
43
+ /**
44
+ * Floats per vertex emitted by the factories' final MeshAsset
45
+ * `vertices` Float32Array buffer: position(3) + normal(3) + uv(2) +
46
+ * tangent(4) = 12. Procedural meshes feed both the standard and unlit
47
+ * pipelines (D-10); BUILTIN_CUBE / TRIANGLE keep their 6-floats inline
48
+ * shape (D-2 lock).
49
+ */
50
+ export const PROCEDURAL_FLOATS_PER_VERTEX = 12;
51
+
52
+ function interleavedInputError(field: string, value: number, reason: string): AssetError {
53
+ return new AssetError({
54
+ code: 'asset-parse-failed',
55
+ expected: `valid interleaved triangle topology: ${reason}`,
56
+ hint: ASSET_ERROR_HINTS['asset-parse-failed'],
57
+ detail: { field, value, reason },
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Build the VertexAttributeMap by binding `position` / `normal` / `uv`
63
+ * Float32Array views over the interleaved `vertices` buffer.
64
+ *
65
+ * AC-15 narrowing anchor: the `for (const [key] of Object.entries(attrs))`
66
+ * loop below sees `key` typed as `'position' | 'normal' | 'uv' | 'tangent' |
67
+ * 'skinIndex' | 'skinWeight' | 'color'` (the VertexAttributeMap key closed
68
+ * set) — no `as` cast anywhere. Any typo (e.g. `'POSITION'`) would be a
69
+ * tsc strict compile-time error (requirements §AC-15 narrowing evidence).
70
+ */
71
+ function buildAttributes(vertices: Float32Array, vertexCount: number): VertexAttributeMap {
72
+ const positions = new Float32Array(vertexCount * 3);
73
+ const normals = new Float32Array(vertexCount * 3);
74
+ const uvs = new Float32Array(vertexCount * 2);
75
+ const tangents = new Float32Array(vertexCount * 4);
76
+ for (let i = 0; i < vertexCount; i++) {
77
+ const base = i * PROCEDURAL_FLOATS_PER_VERTEX;
78
+ positions[i * 3 + 0] = vertices[base + 0] as number;
79
+ positions[i * 3 + 1] = vertices[base + 1] as number;
80
+ positions[i * 3 + 2] = vertices[base + 2] as number;
81
+ normals[i * 3 + 0] = vertices[base + 3] as number;
82
+ normals[i * 3 + 1] = vertices[base + 4] as number;
83
+ normals[i * 3 + 2] = vertices[base + 5] as number;
84
+ uvs[i * 2 + 0] = vertices[base + 6] as number;
85
+ uvs[i * 2 + 1] = vertices[base + 7] as number;
86
+ tangents[i * 4 + 0] = vertices[base + 8] as number;
87
+ tangents[i * 4 + 1] = vertices[base + 9] as number;
88
+ tangents[i * 4 + 2] = vertices[base + 10] as number;
89
+ tangents[i * 4 + 3] = vertices[base + 11] as number;
90
+ }
91
+ const attrs: VertexAttributeMap = {
92
+ position: positions,
93
+ normal: normals,
94
+ uv: uvs,
95
+ tangent: tangents,
96
+ };
97
+ // AC-15 narrowing evidence: deriveVertexBufferLayout is the SSOT
98
+ // for the VertexAttributeMap -> GPU vertex layout translation.
99
+ // The call validates that attrs conforms to the closed key set.
100
+ deriveVertexBufferLayout(attrs);
101
+ return attrs;
102
+ }
103
+
104
+ /**
105
+ * Shared helper: build MeshAsset POD from the factory-emitted 8-floats
106
+ * interleaved buffer (position + normal + uv) plus index list. Computes
107
+ * per-vertex tangent (vec4) via `computeTangentVec4` (M4 / D-2 path A) and
108
+ * expands the buffer to the runtime 12-floats stride (position + normal +
109
+ * uv + tangent). All six procedural factories funnel through this helper
110
+ * so the tangent SSOT lives in `geometry/tangent.ts` (D-7).
111
+ */
112
+ export function meshFromInterleaved(
113
+ vertices: Float32Array,
114
+ indices: Uint16Array | Uint32Array,
115
+ ): Result<MeshAsset, AssetError> {
116
+ if (vertices.length % FACTORY_FLOATS_PER_VERTEX !== 0) {
117
+ return err(
118
+ interleavedInputError(
119
+ 'vertices',
120
+ vertices.length,
121
+ `vertices.length must be divisible by the interleaved stride of ${FACTORY_FLOATS_PER_VERTEX}`,
122
+ ),
123
+ );
124
+ }
125
+ const vertexCount = vertices.length / FACTORY_FLOATS_PER_VERTEX;
126
+ if (indices.length % 3 !== 0) {
127
+ return err(
128
+ interleavedInputError(
129
+ 'indices',
130
+ indices.length,
131
+ 'indices.length must be divisible by the triangle size of 3',
132
+ ),
133
+ );
134
+ }
135
+ for (let indexPosition = 0; indexPosition < indices.length; indexPosition++) {
136
+ const index = indices[indexPosition];
137
+ if (index === undefined || !Number.isInteger(index) || index < 0 || index >= vertexCount) {
138
+ return err(
139
+ interleavedInputError(
140
+ 'indices',
141
+ index ?? -1,
142
+ `indices[${indexPosition}] must be an integer in [0, ${vertexCount})`,
143
+ ),
144
+ );
145
+ }
146
+ }
147
+ // Slice positions / normals / uvs out of the 8-floats interleaved
148
+ // buffer for the tangent computation. The helper requires them in
149
+ // tight-packed Float32Array form per attribute.
150
+ const positions = new Float32Array(vertexCount * 3);
151
+ const normals = new Float32Array(vertexCount * 3);
152
+ const uvs = new Float32Array(vertexCount * 2);
153
+ for (let i = 0; i < vertexCount; i++) {
154
+ const base = i * FACTORY_FLOATS_PER_VERTEX;
155
+ positions[i * 3 + 0] = vertices[base + 0] as number;
156
+ positions[i * 3 + 1] = vertices[base + 1] as number;
157
+ positions[i * 3 + 2] = vertices[base + 2] as number;
158
+ normals[i * 3 + 0] = vertices[base + 3] as number;
159
+ normals[i * 3 + 1] = vertices[base + 4] as number;
160
+ normals[i * 3 + 2] = vertices[base + 5] as number;
161
+ uvs[i * 2 + 0] = vertices[base + 6] as number;
162
+ uvs[i * 2 + 1] = vertices[base + 7] as number;
163
+ }
164
+ const tangentResult = computeTangentVec4(positions, normals, uvs, indices);
165
+ if (!tangentResult.ok) return tangentResult;
166
+ const tangents = tangentResult.value;
167
+ const expanded = new Float32Array(vertexCount * PROCEDURAL_FLOATS_PER_VERTEX);
168
+ for (let i = 0; i < vertexCount; i++) {
169
+ const dst = i * PROCEDURAL_FLOATS_PER_VERTEX;
170
+ const src = i * FACTORY_FLOATS_PER_VERTEX;
171
+ expanded[dst + 0] = vertices[src + 0] as number;
172
+ expanded[dst + 1] = vertices[src + 1] as number;
173
+ expanded[dst + 2] = vertices[src + 2] as number;
174
+ expanded[dst + 3] = vertices[src + 3] as number;
175
+ expanded[dst + 4] = vertices[src + 4] as number;
176
+ expanded[dst + 5] = vertices[src + 5] as number;
177
+ expanded[dst + 6] = vertices[src + 6] as number;
178
+ expanded[dst + 7] = vertices[src + 7] as number;
179
+ expanded[dst + 8] = tangents[i * 4] as number;
180
+ expanded[dst + 9] = tangents[i * 4 + 1] as number;
181
+ expanded[dst + 10] = tangents[i * 4 + 2] as number;
182
+ expanded[dst + 11] = tangents[i * 4 + 3] as number;
183
+ }
184
+ return ok({
185
+ kind: 'mesh',
186
+ vertices: expanded,
187
+ indices,
188
+ attributes: buildAttributes(expanded, vertexCount),
189
+ submeshes: [
190
+ {
191
+ indexOffset: 0,
192
+ indexCount: indices.length,
193
+ vertexCount,
194
+ topology: 'triangle-list',
195
+ materialSlot: 0,
196
+ },
197
+ ],
198
+ materialSlots: [{ slotName: 'Default' }],
199
+ // Procedural meshes carry their own local-space AABB: after feat-20260614
200
+ // (D-15) `allocSharedRef` stores the payload verbatim -- there is no
201
+ // `withMeshAabb` pass like the old `register`/`catalog` path -- so the cull
202
+ // + pick path can only read an AABB the POD already holds.
203
+ aabb: box3.fromPositions(box3.create(), positions),
204
+ });
205
+ }
206
+
207
+ /** Shared helper: AssetError for degenerate geometry parameters. */
208
+ export function degenerate(detail: string): AssetError {
209
+ return new AssetError({
210
+ code: 'asset-parse-failed',
211
+ expected: `all dimensions > 0; segments >= 1 (${detail})`,
212
+ hint: ASSET_ERROR_HINTS['asset-parse-failed'],
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Build a procedural box geometry aligned with Three.js r184 BoxGeometry.
218
+ *
219
+ * @param width positive X-axis extent
220
+ * @param height positive Y-axis extent
221
+ * @param depth positive Z-axis extent
222
+ * @param widthSegments >= 1 subdivisions along X
223
+ * @param heightSegments >= 1 subdivisions along Y
224
+ * @param depthSegments >= 1 subdivisions along Z
225
+ * @returns `Result<MeshAsset, AssetError>` with attributes populated
226
+ */
227
+ export function createBoxGeometry(
228
+ width: number,
229
+ height: number,
230
+ depth: number,
231
+ widthSegments: number = 1,
232
+ heightSegments: number = 1,
233
+ depthSegments: number = 1,
234
+ ): Result<MeshAsset, AssetError> {
235
+ if (width <= 0 || height <= 0 || depth <= 0) {
236
+ return err(degenerate(`width=${width}, height=${height}, depth=${depth}`));
237
+ }
238
+ const ws = widthSegments | 0;
239
+ const hs = heightSegments | 0;
240
+ const ds = depthSegments | 0;
241
+ if (ws < 1 || hs < 1 || ds < 1) {
242
+ return err(degenerate(`widthSegments=${ws}, heightSegments=${hs}, depthSegments=${ds}`));
243
+ }
244
+
245
+ type FaceSpec = {
246
+ readonly uAxis: 0 | 1 | 2;
247
+ readonly vAxis: 0 | 1 | 2;
248
+ readonly wAxis: 0 | 1 | 2;
249
+ readonly uSign: 1 | -1;
250
+ readonly vSign: 1 | -1;
251
+ readonly wSign: 1 | -1;
252
+ readonly uSegs: number;
253
+ readonly vSegs: number;
254
+ readonly uSize: number;
255
+ readonly vSize: number;
256
+ readonly wSize: number;
257
+ };
258
+
259
+ const hw = width / 2;
260
+ const hh = height / 2;
261
+ const hd = depth / 2;
262
+
263
+ const faces: readonly FaceSpec[] = [
264
+ // +X face
265
+ {
266
+ uAxis: 2,
267
+ vAxis: 1,
268
+ wAxis: 0,
269
+ uSign: -1,
270
+ vSign: 1,
271
+ wSign: 1,
272
+ uSegs: ds,
273
+ vSegs: hs,
274
+ uSize: depth,
275
+ vSize: height,
276
+ wSize: width,
277
+ },
278
+ // -X face
279
+ {
280
+ uAxis: 2,
281
+ vAxis: 1,
282
+ wAxis: 0,
283
+ uSign: 1,
284
+ vSign: 1,
285
+ wSign: -1,
286
+ uSegs: ds,
287
+ vSegs: hs,
288
+ uSize: depth,
289
+ vSize: height,
290
+ wSize: width,
291
+ },
292
+ // +Y face
293
+ {
294
+ uAxis: 0,
295
+ vAxis: 2,
296
+ wAxis: 1,
297
+ uSign: 1,
298
+ vSign: 1,
299
+ wSign: 1,
300
+ uSegs: ws,
301
+ vSegs: ds,
302
+ uSize: width,
303
+ vSize: depth,
304
+ wSize: height,
305
+ },
306
+ // -Y face
307
+ {
308
+ uAxis: 0,
309
+ vAxis: 2,
310
+ wAxis: 1,
311
+ uSign: 1,
312
+ vSign: -1,
313
+ wSign: -1,
314
+ uSegs: ws,
315
+ vSegs: ds,
316
+ uSize: width,
317
+ vSize: depth,
318
+ wSize: height,
319
+ },
320
+ // +Z face
321
+ {
322
+ uAxis: 0,
323
+ vAxis: 1,
324
+ wAxis: 2,
325
+ uSign: 1,
326
+ vSign: 1,
327
+ wSign: 1,
328
+ uSegs: ws,
329
+ vSegs: hs,
330
+ uSize: width,
331
+ vSize: height,
332
+ wSize: depth,
333
+ },
334
+ // -Z face
335
+ {
336
+ uAxis: 0,
337
+ vAxis: 1,
338
+ wAxis: 2,
339
+ uSign: -1,
340
+ vSign: 1,
341
+ wSign: -1,
342
+ uSegs: ws,
343
+ vSegs: hs,
344
+ uSize: width,
345
+ vSize: height,
346
+ wSize: depth,
347
+ },
348
+ ];
349
+
350
+ let vertexCount = 0;
351
+ let indexCount = 0;
352
+ for (const f of faces) {
353
+ vertexCount += (f.uSegs + 1) * (f.vSegs + 1);
354
+ indexCount += f.uSegs * f.vSegs * 6;
355
+ }
356
+
357
+ const vertices = new Float32Array(vertexCount * FACTORY_FLOATS_PER_VERTEX);
358
+ const indices = new Uint32Array(indexCount);
359
+ let vIdx = 0;
360
+ let iIdx = 0;
361
+
362
+ const halves: readonly [number, number, number] = [hw, hh, hd];
363
+
364
+ for (const f of faces) {
365
+ const vStart = vIdx;
366
+ const halfU = halves[f.uAxis] as number;
367
+ const halfV = halves[f.vAxis] as number;
368
+ const halfW = halves[f.wAxis] as number;
369
+ for (let j = 0; j <= f.vSegs; j++) {
370
+ for (let i = 0; i <= f.uSegs; i++) {
371
+ const uCoord = ((i / f.uSegs) * f.uSize - f.uSize / 2) * f.uSign;
372
+ const vCoord = ((j / f.vSegs) * f.vSize - f.vSize / 2) * f.vSign;
373
+ const pos: [number, number, number] = [0, 0, 0];
374
+ pos[f.uAxis] = (uCoord / f.uSize) * halfU * 2;
375
+ pos[f.vAxis] = (vCoord / f.vSize) * halfV * 2;
376
+ pos[f.wAxis] = halfW * f.wSign;
377
+ const normal: [number, number, number] = [0, 0, 0];
378
+ normal[f.wAxis] = f.wSign;
379
+ const base = vIdx * FACTORY_FLOATS_PER_VERTEX;
380
+ vertices[base + 0] = pos[0];
381
+ vertices[base + 1] = pos[1];
382
+ vertices[base + 2] = pos[2];
383
+ vertices[base + 3] = normal[0];
384
+ vertices[base + 4] = normal[1];
385
+ vertices[base + 5] = normal[2];
386
+ vertices[base + 6] = i / f.uSegs;
387
+ // UV.v uses the WebGPU top-left convention (V=0 = image top).
388
+ vertices[base + 7] = j / f.vSegs;
389
+ vIdx++;
390
+ }
391
+ }
392
+ // bug-20260519: per-face winding correction.
393
+ //
394
+ // The natural quad `(a, b, d) + (a, d, c)` (with a/b/c/d at quad corners
395
+ // bottom-left / bottom-right / top-left / top-right of the (u, v) grid)
396
+ // winds CCW around the direction `(uHat x vHat) * uSign * vSign`. We
397
+ // want CCW around the outward normal `wSign * wHat`. The two agree iff
398
+ // `(uHat x vHat) . wHat * uSign * vSign * wSign == +1`.
399
+ // The first factor is the Levi-Civita symbol of (uAxis, vAxis, wAxis):
400
+ // +1 for cyclic permutations of (0, 1, 2), -1 for anti-cyclic. The
401
+ // 6-face spec at the top of this function lands as:
402
+ // +X / -X: (Z, Y, X) anti-cyclic -> levi = -1
403
+ // +Y / -Y: (X, Z, Y) anti-cyclic -> levi = -1
404
+ // +Z / -Z: (X, Y, Z) cyclic -> levi = +1
405
+ // Combined with the per-face signs the product comes out to +1 for
406
+ // ±X, ±Z (no swap needed) and -1 for ±Y (swap). The swap branch is
407
+ // the diagonally mirrored CCW pair `(a, d, b) + (a, c, d)`.
408
+ const isCyclic = (f.vAxis - f.uAxis + 3) % 3 === 1 && (f.wAxis - f.vAxis + 3) % 3 === 1;
409
+ const levi = isCyclic ? 1 : -1;
410
+ const ccwOutward = levi * f.uSign * f.vSign * f.wSign > 0;
411
+ for (let j = 0; j < f.vSegs; j++) {
412
+ for (let i = 0; i < f.uSegs; i++) {
413
+ const a = vStart + j * (f.uSegs + 1) + i;
414
+ const b = vStart + j * (f.uSegs + 1) + i + 1;
415
+ const c = vStart + (j + 1) * (f.uSegs + 1) + i;
416
+ const d = vStart + (j + 1) * (f.uSegs + 1) + i + 1;
417
+ if (ccwOutward) {
418
+ indices[iIdx++] = a;
419
+ indices[iIdx++] = b;
420
+ indices[iIdx++] = d;
421
+ indices[iIdx++] = a;
422
+ indices[iIdx++] = d;
423
+ indices[iIdx++] = c;
424
+ } else {
425
+ indices[iIdx++] = a;
426
+ indices[iIdx++] = d;
427
+ indices[iIdx++] = b;
428
+ indices[iIdx++] = a;
429
+ indices[iIdx++] = c;
430
+ indices[iIdx++] = d;
431
+ }
432
+ }
433
+ }
434
+ }
435
+
436
+ return meshFromInterleaved(vertices, indices);
437
+ }
package/src/capsule.ts ADDED
@@ -0,0 +1,145 @@
1
+ // @forgeax/engine-runtime - Procedural Capsule geometry.
2
+ //
3
+ // A capsule is a cylinder mid-band of height `length` capped by two radius-
4
+ // `radius` hemispheres, so the total height is `length + 2 * radius`. The
5
+ // convention matches Bevy `Capsule3d::new(radius, height)` (its `height` is
6
+ // the mid-section; the radius is added to each end) and Three.js r184
7
+ // CapsuleGeometry(radius, length, capSegments, radialSegments).
8
+ //
9
+ // Generation mirrors sphere.ts's ring sweep rather than composing separate
10
+ // cylinder + sphere meshes (no mesh-merge primitive exists): a single
11
+ // top-to-bottom latitude sweep emits the top hemisphere (offset +halfLength),
12
+ // the two equator rings that bound the cylinder band, and the bottom
13
+ // hemisphere (offset -halfLength). Consecutive rings are stitched by the same
14
+ // quad connector sphere.ts uses, so the vertical wall between the equator
15
+ // rings is filled with no dedicated cylinder code. Per-vertex normals are
16
+ // `normalize(pos - hemisphereCenter)` (radial on the caps, horizontal on the
17
+ // wall) so the surface is seam-free by construction.
18
+ //
19
+ // Degenerate parameters (radius <= 0, length < 0, capSegments < 1,
20
+ // radialSegments < 3) fail-fast with AssetError('asset-parse-failed'),
21
+ // matching the 6 sibling factories.
22
+
23
+ import type { AssetError, MeshAsset } from '@forgeax/engine-types';
24
+ import { err, type Result } from '@forgeax/engine-types';
25
+ import { degenerate, FACTORY_FLOATS_PER_VERTEX, meshFromInterleaved } from './box';
26
+
27
+ /**
28
+ * Build a procedural capsule geometry aligned with Bevy `Capsule3d` /
29
+ * Three.js r184 CapsuleGeometry.
30
+ *
31
+ * @param radius hemisphere + cylinder radius (> 0)
32
+ * @param length cylinder mid-section height (>= 0; total height = length + 2*radius)
33
+ * @param capSegments latitude bands per hemisphere (>= 1); default 4
34
+ * @param radialSegments longitudes around the axis (>= 3); default 8
35
+ * @returns `Result<MeshAsset, AssetError>` with attributes populated
36
+ */
37
+ export function createCapsuleGeometry(
38
+ radius: number,
39
+ length: number,
40
+ capSegments: number = 4,
41
+ radialSegments: number = 8,
42
+ ): Result<MeshAsset, AssetError> {
43
+ if (radius <= 0) return err(degenerate(`radius=${radius}`));
44
+ if (length < 0) return err(degenerate(`length=${length}`));
45
+ const cs = capSegments | 0;
46
+ const rs = radialSegments | 0;
47
+ if (cs < 1) return err(degenerate(`capSegments=${cs}; minimum 1`));
48
+ if (rs < 3) return err(degenerate(`radialSegments=${rs}; minimum 3`));
49
+
50
+ const halfLength = length / 2;
51
+ // Latitude rows: cs+1 for the top hemisphere (0..pi/2) and cs+1 for the
52
+ // bottom (pi/2..pi). The equator is shared: the top's last row and the
53
+ // bottom's first row are BOTH at the equator radius but at y=+halfLength
54
+ // and y=-halfLength respectively (they coincide only when length===0).
55
+ // Total rows = 2*(cs+1); the middle quad band between the two equator rows
56
+ // becomes the cylinder wall.
57
+ const latRows = 2 * (cs + 1);
58
+ const vertexCount = latRows * (rs + 1);
59
+ const indexCount = (latRows - 1) * rs * 6;
60
+
61
+ const vertices = new Float32Array(vertexCount * FACTORY_FLOATS_PER_VERTEX);
62
+ const indices = new Uint32Array(indexCount);
63
+
64
+ let vIdx = 0;
65
+ // Emit rows top -> bottom. `row` in [0, latRows-1]. The first cs+1 rows are
66
+ // the top hemisphere (phi 0..pi/2, center y=+halfLength); the last cs+1 rows
67
+ // are the bottom hemisphere (phi pi/2..pi, center y=-halfLength).
68
+ for (let row = 0; row < latRows; row++) {
69
+ const topHemi = row <= cs;
70
+ // Ring radius + y from hemisphere-local latitude so the caps are exact
71
+ // hemispheres and the two equator rings sit at y=+-halfLength.
72
+ let ringR: number;
73
+ let y: number;
74
+ let centerY: number;
75
+ if (topHemi) {
76
+ const t = row / cs; // 0 at north pole, 1 at equator
77
+ const a = t * (Math.PI / 2);
78
+ ringR = radius * Math.sin(a);
79
+ centerY = halfLength;
80
+ y = centerY + radius * Math.cos(a);
81
+ } else {
82
+ const t = (row - (cs + 1)) / cs; // 0 at equator (bottom), 1 at south pole
83
+ const a = t * (Math.PI / 2);
84
+ ringR = radius * Math.cos(a);
85
+ centerY = -halfLength;
86
+ y = centerY - radius * Math.sin(a);
87
+ }
88
+ // v texture coord: monotonic 0..1 top->bottom across all rows.
89
+ const v = row / (latRows - 1);
90
+ for (let ix = 0; ix <= rs; ix++) {
91
+ const u = ix / rs;
92
+ const theta = u * Math.PI * 2;
93
+ const sinT = Math.sin(theta);
94
+ const cosT = Math.cos(theta);
95
+ const x = ringR * sinT;
96
+ const z = ringR * cosT;
97
+ // Normal = normalize(pos - hemisphereCenter): radial on caps, horizontal
98
+ // on the equatorial wall (cos component is 0 there).
99
+ const nx = x;
100
+ const ny = y - centerY;
101
+ const nz = z;
102
+ const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
103
+ const base = vIdx * FACTORY_FLOATS_PER_VERTEX;
104
+ vertices[base + 0] = x;
105
+ vertices[base + 1] = y;
106
+ vertices[base + 2] = z;
107
+ vertices[base + 3] = nx / nlen;
108
+ vertices[base + 4] = ny / nlen;
109
+ vertices[base + 5] = nz / nlen;
110
+ vertices[base + 6] = u;
111
+ // UV.v uses the WebGPU top-left convention (V=0 = image top).
112
+ vertices[base + 7] = v;
113
+ vIdx++;
114
+ }
115
+ }
116
+
117
+ let iIdx = 0;
118
+ const stride = rs + 1;
119
+ for (let row = 0; row < latRows - 1; row++) {
120
+ for (let ix = 0; ix < rs; ix++) {
121
+ const a = row * stride + ix + 1;
122
+ const b = row * stride + ix;
123
+ const c = (row + 1) * stride + ix;
124
+ const d = (row + 1) * stride + ix + 1;
125
+ // Skip the collapsed triangles at the two poles (row 0 north, last row
126
+ // south) exactly as sphere.ts does — a degenerate ring has zero-area
127
+ // triangles on one side of each quad.
128
+ const northPoleRow = row === 0;
129
+ const southPoleRow = row === latRows - 2;
130
+ if (!northPoleRow) {
131
+ indices[iIdx++] = a;
132
+ indices[iIdx++] = b;
133
+ indices[iIdx++] = d;
134
+ }
135
+ if (!southPoleRow) {
136
+ indices[iIdx++] = b;
137
+ indices[iIdx++] = c;
138
+ indices[iIdx++] = d;
139
+ }
140
+ }
141
+ }
142
+
143
+ const trimmed = indices.slice(0, iIdx);
144
+ return meshFromInterleaved(vertices, trimmed);
145
+ }
package/src/cone.ts ADDED
@@ -0,0 +1,26 @@
1
+ // @forgeax/engine-runtime - Procedural Cone geometry (M3 / w8).
2
+ //
3
+ // Mirrors Three.js r184 ConeGeometry: createConeGeometry(radius, height,
4
+ // radialSegments?, heightSegments?) -> Result<MeshAsset, AssetError>.
5
+ // Cone is the cylinder-with-top-radius=0 degenerate, delegated directly.
6
+ // Byte-identical output to createCylinderGeometry(0, radius, height, ...)
7
+ // by contract (test in geometry.test.ts enforces the equivalence).
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 } from './box';
15
+ import { createCylinderGeometry } from './cylinder';
16
+
17
+ export function createConeGeometry(
18
+ radius: number,
19
+ height: number,
20
+ radialSegments: number = 16,
21
+ heightSegments: number = 1,
22
+ ): Result<MeshAsset, AssetError> {
23
+ if (radius <= 0) return err(degenerate(`radius=${radius}`));
24
+ if (height <= 0) return err(degenerate(`height=${height}`));
25
+ return createCylinderGeometry(0, radius, height, radialSegments, heightSegments);
26
+ }