@forgeax/engine-geometry 0.1.31 → 0.1.32

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.
@@ -0,0 +1,13 @@
1
+ import type { AssetError, MeshAsset, Result } from '@forgeax/engine-types';
2
+ export interface Vec2Point {
3
+ readonly x: number;
4
+ readonly y: number;
5
+ }
6
+ export type Vec3Point = readonly [number, number, number];
7
+ /** Extrude a simple 2D contour along +Z/-Z, including caps and side walls. */
8
+ export declare function createExtrusionGeometry(contour: readonly Vec2Point[], depth: number): Result<MeshAsset, AssetError>;
9
+ /** Sweep a circular profile along a 3D path. */
10
+ export declare function createSweepGeometry(path: readonly Vec3Point[], radius: number, radialSegments?: number): Result<MeshAsset, AssetError>;
11
+ /** Revolve a (radius, height) profile around the Y axis. */
12
+ export declare function createRevolutionGeometry(profile: readonly Vec2Point[], radialSegments?: number): Result<MeshAsset, AssetError>;
13
+ //# sourceMappingURL=procedural.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"procedural.d.ts","sourceRoot":"","sources":["../src/procedural.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAI3E,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,SAAS,GAAG,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AAsK1D,8EAA8E;AAC9E,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,SAAS,SAAS,EAAE,EAC7B,KAAK,EAAE,MAAM,GACZ,MAAM,CAAC,SAAS,EAAE,UAAU,CAAC,CAyF/B;AAED,gDAAgD;AAChD,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,SAAS,SAAS,EAAE,EAC1B,MAAM,EAAE,MAAM,EACd,cAAc,SAAK,GAClB,MAAM,CAAC,SAAS,EAAE,UAAU,CAAC,CA0G/B;AAED,4DAA4D;AAC5D,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,SAAS,SAAS,EAAE,EAC7B,cAAc,SAAK,GAClB,MAAM,CAAC,SAAS,EAAE,UAAU,CAAC,CAqD/B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-geometry",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
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.31",
26
- "@forgeax/engine-math": "0.1.31",
27
- "@forgeax/engine-pack": "0.1.31",
28
- "@forgeax/engine-types": "0.1.31"
25
+ "@forgeax/engine-ecs": "0.1.32",
26
+ "@forgeax/engine-math": "0.1.32",
27
+ "@forgeax/engine-pack": "0.1.32",
28
+ "@forgeax/engine-types": "0.1.32"
29
29
  },
30
30
  "forgeax": {
31
31
  "metrics": {
@@ -0,0 +1,310 @@
1
+ import type { MeshAsset } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createExtrusionGeometry, createRevolutionGeometry, createSweepGeometry } from '../index';
4
+
5
+ function expectTriangleWinding(mesh: MeshAsset): void {
6
+ const position = mesh.attributes.position;
7
+ const normal = mesh.attributes.normal;
8
+ const indices = mesh.indices;
9
+ expect(position).toBeDefined();
10
+ expect(normal).toBeDefined();
11
+ expect(indices).toBeDefined();
12
+ if (
13
+ !(position instanceof Float32Array) ||
14
+ !(normal instanceof Float32Array) ||
15
+ indices === undefined
16
+ )
17
+ throw new Error('procedural mesh lacks position, normal, or index arrays');
18
+ for (let i = 0; i < indices.length; i += 3) {
19
+ const a = (indices[i] as number) * 3;
20
+ const b = (indices[i + 1] as number) * 3;
21
+ const c = (indices[i + 2] as number) * 3;
22
+ const abx = (position[b] as number) - (position[a] as number);
23
+ const aby = (position[b + 1] as number) - (position[a + 1] as number);
24
+ const abz = (position[b + 2] as number) - (position[a + 2] as number);
25
+ const acx = (position[c] as number) - (position[a] as number);
26
+ const acy = (position[c + 1] as number) - (position[a + 1] as number);
27
+ const acz = (position[c + 2] as number) - (position[a + 2] as number);
28
+ const crossX = aby * acz - abz * acy;
29
+ const crossY = abz * acx - abx * acz;
30
+ const crossZ = abx * acy - aby * acx;
31
+ const dot =
32
+ crossX * (normal[a] as number) +
33
+ crossY * (normal[a + 1] as number) +
34
+ crossZ * (normal[a + 2] as number);
35
+ expect(dot).toBeGreaterThan(1e-8);
36
+ }
37
+ }
38
+
39
+ function meshSurfaceAreaAndSignedVolume(mesh: MeshAsset): {
40
+ readonly area: number;
41
+ readonly signedVolume: number;
42
+ } {
43
+ const position = mesh.attributes.position;
44
+ const indices = mesh.indices;
45
+ if (!(position instanceof Float32Array) || indices === undefined) {
46
+ throw new Error('procedural mesh lacks indexed position data');
47
+ }
48
+ const pointAt = (offset: number): readonly [number, number, number] => [
49
+ position[offset] as number,
50
+ position[offset + 1] as number,
51
+ position[offset + 2] as number,
52
+ ];
53
+ let area = 0;
54
+ let signedVolume = 0;
55
+ for (let offset = 0; offset < indices.length; offset += 3) {
56
+ const a = pointAt((indices[offset] as number) * 3);
57
+ const b = pointAt((indices[offset + 1] as number) * 3);
58
+ const c = pointAt((indices[offset + 2] as number) * 3);
59
+ const abx = b[0] - a[0];
60
+ const aby = b[1] - a[1];
61
+ const abz = b[2] - a[2];
62
+ const acx = c[0] - a[0];
63
+ const acy = c[1] - a[1];
64
+ const acz = c[2] - a[2];
65
+ const crossX = aby * acz - abz * acy;
66
+ const crossY = abz * acx - abx * acz;
67
+ const crossZ = abx * acy - aby * acx;
68
+ area += Math.hypot(crossX, crossY, crossZ) / 2;
69
+ signedVolume +=
70
+ (a[0] * (b[1] * c[2] - b[2] * c[1]) -
71
+ a[1] * (b[0] * c[2] - b[2] * c[0]) +
72
+ a[2] * (b[0] * c[1] - b[1] * c[0])) /
73
+ 6;
74
+ }
75
+ return { area, signedVolume };
76
+ }
77
+
78
+ describe('reusable procedural geometry', () => {
79
+ it('triangulates and extrudes a concave contour with normals, UVs, and bounds', () => {
80
+ const result = createExtrusionGeometry(
81
+ [
82
+ { x: -1, y: -1 },
83
+ { x: 1, y: -1 },
84
+ { x: 1, y: 0 },
85
+ { x: 0, y: -0.25 },
86
+ { x: -1, y: 0 },
87
+ ],
88
+ 2,
89
+ );
90
+ expect(result.ok).toBe(true);
91
+ if (!result.ok) return;
92
+ expect(result.value.indices?.length).toBeGreaterThan(0);
93
+ const position = result.value.attributes.position;
94
+ const normal = result.value.attributes.normal;
95
+ const uv = result.value.attributes.uv;
96
+ expect(position).toBeInstanceOf(Float32Array);
97
+ expect(normal).toBeInstanceOf(Float32Array);
98
+ expect(uv).toBeInstanceOf(Float32Array);
99
+ if (
100
+ !(position instanceof Float32Array) ||
101
+ !(normal instanceof Float32Array) ||
102
+ !(uv instanceof Float32Array)
103
+ )
104
+ return;
105
+ expect(normal.length).toBe(position.length);
106
+ expect(uv.length).toBe((result.value.vertices.length / 12) * 2);
107
+ expect(Array.from(result.value.aabb ?? [])).toEqual([-1, -1, -1, 1, 0, 1]);
108
+ });
109
+
110
+ it('builds a curved path sweep and a surface of revolution', () => {
111
+ const sweep = createSweepGeometry(
112
+ [
113
+ [0, 0, 0],
114
+ [0, 1, 0],
115
+ [1, 2, 0],
116
+ ],
117
+ 0.2,
118
+ 8,
119
+ );
120
+ const revolution = createRevolutionGeometry(
121
+ [
122
+ { x: 0.5, y: -1 },
123
+ { x: 0.75, y: 0 },
124
+ { x: 0.5, y: 1 },
125
+ ],
126
+ 8,
127
+ );
128
+ expect(sweep.ok).toBe(true);
129
+ expect(revolution.ok).toBe(true);
130
+ if (sweep.ok) expect(sweep.value.indices?.length).toBe(2 * 8 * 6);
131
+ if (revolution.ok) expect(revolution.value.indices?.length).toBe(2 * 8 * 6);
132
+ });
133
+
134
+ it('keeps extrusion and sweep winding aligned with emitted normals', () => {
135
+ const counterClockwise = [
136
+ { x: -1, y: -1 },
137
+ { x: 1, y: -1 },
138
+ { x: 1, y: 1 },
139
+ { x: -1, y: 1 },
140
+ ];
141
+ const clockwise = [...counterClockwise].reverse();
142
+ for (const contour of [counterClockwise, clockwise]) {
143
+ const extrusion = createExtrusionGeometry(contour, 1);
144
+ expect(extrusion.ok).toBe(true);
145
+ if (extrusion.ok) expectTriangleWinding(extrusion.value);
146
+ }
147
+ const sweep = createSweepGeometry(
148
+ [
149
+ [0, 0, 0],
150
+ [0, 1, 0],
151
+ ],
152
+ 1,
153
+ 8,
154
+ );
155
+ expect(sweep.ok).toBe(true);
156
+ if (sweep.ok) expectTriangleWinding(sweep.value);
157
+ });
158
+
159
+ it('matches independent area, volume, and radial-normal expectations', () => {
160
+ const extrusion = createExtrusionGeometry(
161
+ [
162
+ { x: -1, y: -1 },
163
+ { x: 1, y: -1 },
164
+ { x: 1, y: 1 },
165
+ { x: -1, y: 1 },
166
+ ],
167
+ 2,
168
+ );
169
+ expect(extrusion.ok).toBe(true);
170
+ if (!extrusion.ok) return;
171
+ const extrusionMetrics = meshSurfaceAreaAndSignedVolume(extrusion.value);
172
+ // Square area * depth gives the closed volume; caps + four sides give the
173
+ // independently derived surface area.
174
+ expect(Math.abs(extrusionMetrics.signedVolume)).toBeCloseTo(8, 5);
175
+ expect(extrusionMetrics.area).toBeCloseTo(24, 5);
176
+
177
+ const segments = 8;
178
+ const radius = 1;
179
+ const height = 2;
180
+ const sweep = createSweepGeometry(
181
+ [
182
+ [0, 0, 0],
183
+ [0, height, 0],
184
+ ],
185
+ radius,
186
+ segments,
187
+ );
188
+ expect(sweep.ok).toBe(true);
189
+ if (!sweep.ok) return;
190
+ const sweepMetrics = meshSurfaceAreaAndSignedVolume(sweep.value);
191
+ const expectedLateralArea = 2 * segments * radius * Math.sin(Math.PI / segments) * height;
192
+ expect(sweepMetrics.area).toBeCloseTo(expectedLateralArea, 5);
193
+ const position = sweep.value.attributes.position;
194
+ const normal = sweep.value.attributes.normal;
195
+ expect(position).toBeInstanceOf(Float32Array);
196
+ expect(normal).toBeInstanceOf(Float32Array);
197
+ if (!(position instanceof Float32Array) || !(normal instanceof Float32Array)) return;
198
+ for (let vertex = 0; vertex < position.length / 3; vertex += 1) {
199
+ const x = position[vertex * 3] as number;
200
+ const z = position[vertex * 3 + 2] as number;
201
+ const length = Math.hypot(x, z);
202
+ const nx = normal[vertex * 3] as number;
203
+ const ny = normal[vertex * 3 + 1] as number;
204
+ const nz = normal[vertex * 3 + 2] as number;
205
+ expect(Math.hypot(nx, ny, nz)).toBeCloseTo(1, 5);
206
+ expect(nx * (x / length) + nz * (z / length)).toBeGreaterThan(0.999);
207
+ }
208
+
209
+ const revolution = createRevolutionGeometry(
210
+ [
211
+ { x: radius, y: 0 },
212
+ { x: radius, y: height },
213
+ ],
214
+ segments,
215
+ );
216
+ expect(revolution.ok).toBe(true);
217
+ if (!revolution.ok) return;
218
+ expect(meshSurfaceAreaAndSignedVolume(revolution.value).area).toBeCloseTo(
219
+ expectedLateralArea,
220
+ 5,
221
+ );
222
+ });
223
+
224
+ it('rejects malformed contours and degenerate paths before mesh creation', () => {
225
+ const tooShort = createExtrusionGeometry(
226
+ [
227
+ { x: 0, y: 0 },
228
+ { x: 1, y: 0 },
229
+ ],
230
+ 1,
231
+ );
232
+ expect(tooShort.ok).toBe(false);
233
+ if (!tooShort.ok) {
234
+ expect(tooShort.error.detail).toMatchObject({
235
+ field: 'contour',
236
+ value: 'needs at least three finite points',
237
+ reason: 'needs at least three finite points',
238
+ });
239
+ }
240
+ expect(
241
+ createExtrusionGeometry(
242
+ [
243
+ { x: 0, y: 0 },
244
+ { x: 2, y: 2 },
245
+ { x: 0, y: 2 },
246
+ { x: 2, y: 0 },
247
+ ],
248
+ 1,
249
+ ).ok,
250
+ ).toBe(false);
251
+ expect(
252
+ createSweepGeometry(
253
+ [
254
+ [0, 0, 0],
255
+ [0, 0, 0],
256
+ ],
257
+ 1,
258
+ ).ok,
259
+ ).toBe(false);
260
+ const closedSweep = createSweepGeometry(
261
+ [
262
+ [0, 0, 0],
263
+ [0, 1, 0],
264
+ [1, 1, 0],
265
+ [0, 0, 0],
266
+ ],
267
+ 1,
268
+ 8,
269
+ );
270
+ expect(closedSweep.ok).toBe(true);
271
+ const repeatedSweep = createSweepGeometry(
272
+ [
273
+ [0, 0, 0],
274
+ [0, 1, 0],
275
+ [0, 1, 0],
276
+ [0, 2, 0],
277
+ ],
278
+ 1,
279
+ 8,
280
+ );
281
+ expect(repeatedSweep.ok).toBe(false);
282
+ if (!repeatedSweep.ok) {
283
+ expect(repeatedSweep.error.detail).toMatchObject({
284
+ field: 'path',
285
+ value: 'must not contain repeated points',
286
+ reason: 'must not contain repeated points',
287
+ });
288
+ }
289
+ const repeatedRevolution = createRevolutionGeometry([
290
+ { x: 1, y: 0 },
291
+ { x: 1, y: 1 },
292
+ { x: 1, y: 1 },
293
+ { x: 1, y: 2 },
294
+ ]);
295
+ expect(repeatedRevolution.ok).toBe(false);
296
+ if (!repeatedRevolution.ok) {
297
+ expect(repeatedRevolution.error.detail).toMatchObject({
298
+ field: 'profile',
299
+ value: 'must not contain repeated points',
300
+ reason: 'must not contain repeated points',
301
+ });
302
+ }
303
+ expect(
304
+ createRevolutionGeometry([
305
+ { x: 1, y: 0 },
306
+ { x: 2, y: 0 },
307
+ ]).ok,
308
+ ).toBe(false);
309
+ });
310
+ });
package/src/index.ts CHANGED
@@ -46,6 +46,13 @@ export {
46
46
  type MeshBuilderSubmesh,
47
47
  } from './mesh-builder';
48
48
  export { createPlaneGeometry } from './plane';
49
+ export {
50
+ createExtrusionGeometry,
51
+ createRevolutionGeometry,
52
+ createSweepGeometry,
53
+ type Vec2Point,
54
+ type Vec3Point,
55
+ } from './procedural';
49
56
  export { createSphereGeometry } from './sphere';
50
57
  export { computeTangentVec4 } from './tangent';
51
58
  export {