@forgeax/engine-geometry 0.1.31 → 0.1.33
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 +15 -5
- package/dist/__tests__/procedural.unit.test.d.ts +2 -0
- package/dist/__tests__/procedural.unit.test.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +360 -10
- package/dist/index.mjs.map +1 -1
- package/dist/procedural.d.ts +13 -0
- package/dist/procedural.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/__tests__/procedural.unit.test.ts +310 -0
- package/src/index.ts +7 -0
- package/src/procedural.ts +447 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// @forgeax/engine-geometry - reusable contour, sweep, and revolution meshes.
|
|
2
|
+
//
|
|
3
|
+
// These factories stay pure and use meshFromInterleaved as the one MeshAsset
|
|
4
|
+
// validation/tangent/AABB path. Invalid authored data fails before a browser or
|
|
5
|
+
// renderer is involved.
|
|
6
|
+
|
|
7
|
+
import type { AssetError, MeshAsset, Result } from '@forgeax/engine-types';
|
|
8
|
+
import { ASSET_ERROR_HINTS, AssetError as AssetErrorValue, err } from '@forgeax/engine-types';
|
|
9
|
+
import { FACTORY_FLOATS_PER_VERTEX, meshFromInterleaved } from './box';
|
|
10
|
+
|
|
11
|
+
export interface Vec2Point {
|
|
12
|
+
readonly x: number;
|
|
13
|
+
readonly y: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type Vec3Point = readonly [number, number, number];
|
|
17
|
+
|
|
18
|
+
function invalid(field: string, detail: string): Result<MeshAsset, AssetError> {
|
|
19
|
+
return err(
|
|
20
|
+
new AssetErrorValue({
|
|
21
|
+
code: 'asset-parse-failed',
|
|
22
|
+
expected: `valid procedural geometry input for ${field}`,
|
|
23
|
+
hint: ASSET_ERROR_HINTS['asset-parse-failed'],
|
|
24
|
+
detail: { field, value: detail, reason: detail },
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function finitePoint(point: Vec2Point): boolean {
|
|
30
|
+
return Number.isFinite(point.x) && Number.isFinite(point.y);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cross(a: Vec2Point, b: Vec2Point, c: Vec2Point): number {
|
|
34
|
+
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function signedArea(points: readonly Vec2Point[]): number {
|
|
38
|
+
let value = 0;
|
|
39
|
+
for (let i = 0; i < points.length; i++) {
|
|
40
|
+
const a = points[i] as Vec2Point;
|
|
41
|
+
const b = points[(i + 1) % points.length] as Vec2Point;
|
|
42
|
+
value += a.x * b.y - b.x * a.y;
|
|
43
|
+
}
|
|
44
|
+
return value / 2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function onSegment(a: Vec2Point, b: Vec2Point, p: Vec2Point): boolean {
|
|
48
|
+
return (
|
|
49
|
+
Math.min(a.x, b.x) <= p.x + 1e-8 &&
|
|
50
|
+
p.x <= Math.max(a.x, b.x) + 1e-8 &&
|
|
51
|
+
Math.min(a.y, b.y) <= p.y + 1e-8 &&
|
|
52
|
+
p.y <= Math.max(a.y, b.y) + 1e-8
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function edgesCross(a: Vec2Point, b: Vec2Point, c: Vec2Point, d: Vec2Point): boolean {
|
|
57
|
+
const ab = cross(a, b, c);
|
|
58
|
+
const abD = cross(a, b, d);
|
|
59
|
+
const cdA = cross(c, d, a);
|
|
60
|
+
const cdB = cross(c, d, b);
|
|
61
|
+
if (Math.abs(ab) < 1e-8 && onSegment(a, b, c)) return true;
|
|
62
|
+
if (Math.abs(abD) < 1e-8 && onSegment(a, b, d)) return true;
|
|
63
|
+
if (Math.abs(cdA) < 1e-8 && onSegment(c, d, a)) return true;
|
|
64
|
+
if (Math.abs(cdB) < 1e-8 && onSegment(c, d, b)) return true;
|
|
65
|
+
return ab > 0 !== abD > 0 && cdA > 0 !== cdB > 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function selfIntersects(points: readonly Vec2Point[]): boolean {
|
|
69
|
+
for (let i = 0; i < points.length; i++) {
|
|
70
|
+
const a = points[i] as Vec2Point;
|
|
71
|
+
const b = points[(i + 1) % points.length] as Vec2Point;
|
|
72
|
+
for (let j = i + 1; j < points.length; j++) {
|
|
73
|
+
if (j === i || (j + 1) % points.length === i || (i + 1) % points.length === j) continue;
|
|
74
|
+
const c = points[j] as Vec2Point;
|
|
75
|
+
const d = points[(j + 1) % points.length] as Vec2Point;
|
|
76
|
+
if (edgesCross(a, b, c, d)) return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function insideTriangle(a: Vec2Point, b: Vec2Point, c: Vec2Point, point: Vec2Point): boolean {
|
|
83
|
+
return cross(a, b, point) >= -1e-8 && cross(b, c, point) >= -1e-8 && cross(c, a, point) >= -1e-8;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Ear-clipping triangulation for a simple contour. */
|
|
87
|
+
function triangulate(points: readonly Vec2Point[]): number[] | undefined {
|
|
88
|
+
const order = points.map((_, index) => index);
|
|
89
|
+
if (signedArea(points) < 0) order.reverse();
|
|
90
|
+
const triangles: number[] = [];
|
|
91
|
+
let guard = 0;
|
|
92
|
+
while (order.length > 3 && guard++ < points.length * points.length) {
|
|
93
|
+
let clipped = false;
|
|
94
|
+
for (let i = 0; i < order.length; i++) {
|
|
95
|
+
const previous = order[(i + order.length - 1) % order.length] as number;
|
|
96
|
+
const current = order[i] as number;
|
|
97
|
+
const next = order[(i + 1) % order.length] as number;
|
|
98
|
+
if (
|
|
99
|
+
cross(
|
|
100
|
+
points[previous] as Vec2Point,
|
|
101
|
+
points[current] as Vec2Point,
|
|
102
|
+
points[next] as Vec2Point,
|
|
103
|
+
) <= 1e-8
|
|
104
|
+
)
|
|
105
|
+
continue;
|
|
106
|
+
let contains = false;
|
|
107
|
+
for (const candidate of order) {
|
|
108
|
+
if (
|
|
109
|
+
candidate !== previous &&
|
|
110
|
+
candidate !== current &&
|
|
111
|
+
candidate !== next &&
|
|
112
|
+
insideTriangle(
|
|
113
|
+
points[previous] as Vec2Point,
|
|
114
|
+
points[current] as Vec2Point,
|
|
115
|
+
points[next] as Vec2Point,
|
|
116
|
+
points[candidate] as Vec2Point,
|
|
117
|
+
)
|
|
118
|
+
) {
|
|
119
|
+
contains = true;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (contains) continue;
|
|
124
|
+
triangles.push(previous, current, next);
|
|
125
|
+
order.splice(i, 1);
|
|
126
|
+
clipped = true;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
if (!clipped) return undefined;
|
|
130
|
+
}
|
|
131
|
+
if (order.length !== 3) return undefined;
|
|
132
|
+
triangles.push(order[0] as number, order[1] as number, order[2] as number);
|
|
133
|
+
return triangles;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pushVertex(
|
|
137
|
+
vertices: number[],
|
|
138
|
+
position: readonly [number, number, number],
|
|
139
|
+
normal: readonly [number, number, number],
|
|
140
|
+
uv: readonly [number, number],
|
|
141
|
+
): number {
|
|
142
|
+
const index = vertices.length / FACTORY_FLOATS_PER_VERTEX;
|
|
143
|
+
vertices.push(
|
|
144
|
+
position[0],
|
|
145
|
+
position[1],
|
|
146
|
+
position[2],
|
|
147
|
+
normal[0],
|
|
148
|
+
normal[1],
|
|
149
|
+
normal[2],
|
|
150
|
+
uv[0],
|
|
151
|
+
uv[1],
|
|
152
|
+
);
|
|
153
|
+
return index;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function pushTriangle(
|
|
157
|
+
vertices: number[],
|
|
158
|
+
indices: number[],
|
|
159
|
+
a: {
|
|
160
|
+
readonly p: [number, number, number];
|
|
161
|
+
readonly n: [number, number, number];
|
|
162
|
+
readonly uv: [number, number];
|
|
163
|
+
},
|
|
164
|
+
b: {
|
|
165
|
+
readonly p: [number, number, number];
|
|
166
|
+
readonly n: [number, number, number];
|
|
167
|
+
readonly uv: [number, number];
|
|
168
|
+
},
|
|
169
|
+
c: {
|
|
170
|
+
readonly p: [number, number, number];
|
|
171
|
+
readonly n: [number, number, number];
|
|
172
|
+
readonly uv: [number, number];
|
|
173
|
+
},
|
|
174
|
+
): void {
|
|
175
|
+
indices.push(
|
|
176
|
+
pushVertex(vertices, a.p, a.n, a.uv),
|
|
177
|
+
pushVertex(vertices, b.p, b.n, b.uv),
|
|
178
|
+
pushVertex(vertices, c.p, c.n, c.uv),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Extrude a simple 2D contour along +Z/-Z, including caps and side walls. */
|
|
183
|
+
export function createExtrusionGeometry(
|
|
184
|
+
contour: readonly Vec2Point[],
|
|
185
|
+
depth: number,
|
|
186
|
+
): Result<MeshAsset, AssetError> {
|
|
187
|
+
if (!Number.isFinite(depth) || depth <= 0) return invalid('depth', 'must be positive and finite');
|
|
188
|
+
const points =
|
|
189
|
+
contour.length > 1 &&
|
|
190
|
+
contour[0]?.x === contour[contour.length - 1]?.x &&
|
|
191
|
+
contour[0]?.y === contour[contour.length - 1]?.y
|
|
192
|
+
? contour.slice(0, -1)
|
|
193
|
+
: [...contour];
|
|
194
|
+
if (points.length < 3 || points.some((point) => !finitePoint(point)))
|
|
195
|
+
return invalid('contour', 'needs at least three finite points');
|
|
196
|
+
const area = signedArea(points);
|
|
197
|
+
if (Math.abs(area) < 1e-8) return invalid('contour', 'area must be non-zero');
|
|
198
|
+
if (selfIntersects(points)) return invalid('contour', 'must not self-intersect');
|
|
199
|
+
const capTriangles = triangulate(points);
|
|
200
|
+
if (capTriangles === undefined) return invalid('contour', 'could not be triangulated');
|
|
201
|
+
const vertices: number[] = [];
|
|
202
|
+
const indices: number[] = [];
|
|
203
|
+
const half = depth / 2;
|
|
204
|
+
for (let i = 0; i < capTriangles.length; i += 3) {
|
|
205
|
+
const a = points[capTriangles[i] as number] as Vec2Point;
|
|
206
|
+
const b = points[capTriangles[i + 1] as number] as Vec2Point;
|
|
207
|
+
const c = points[capTriangles[i + 2] as number] as Vec2Point;
|
|
208
|
+
pushTriangle(
|
|
209
|
+
vertices,
|
|
210
|
+
indices,
|
|
211
|
+
{ p: [a.x, a.y, half], n: [0, 0, 1], uv: [a.x, a.y] },
|
|
212
|
+
{ p: [b.x, b.y, half], n: [0, 0, 1], uv: [b.x, b.y] },
|
|
213
|
+
{ p: [c.x, c.y, half], n: [0, 0, 1], uv: [c.x, c.y] },
|
|
214
|
+
);
|
|
215
|
+
pushTriangle(
|
|
216
|
+
vertices,
|
|
217
|
+
indices,
|
|
218
|
+
{ p: [c.x, c.y, -half], n: [0, 0, -1], uv: [c.x, c.y] },
|
|
219
|
+
{ p: [b.x, b.y, -half], n: [0, 0, -1], uv: [b.x, b.y] },
|
|
220
|
+
{ p: [a.x, a.y, -half], n: [0, 0, -1], uv: [a.x, a.y] },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
let perimeter = 0;
|
|
224
|
+
for (let i = 0; i < points.length; i++) {
|
|
225
|
+
const a = points[i] as Vec2Point;
|
|
226
|
+
const b = points[(i + 1) % points.length] as Vec2Point;
|
|
227
|
+
perimeter += Math.hypot(b.x - a.x, b.y - a.y);
|
|
228
|
+
}
|
|
229
|
+
let distance = 0;
|
|
230
|
+
for (let i = 0; i < points.length; i++) {
|
|
231
|
+
const a = points[i] as Vec2Point;
|
|
232
|
+
const b = points[(i + 1) % points.length] as Vec2Point;
|
|
233
|
+
const edge = Math.hypot(b.x - a.x, b.y - a.y);
|
|
234
|
+
const u0 = perimeter === 0 ? 0 : distance / perimeter;
|
|
235
|
+
const u1 = perimeter === 0 ? 1 : (distance + edge) / perimeter;
|
|
236
|
+
const nx = b.y - a.y;
|
|
237
|
+
const ny = -(b.x - a.x);
|
|
238
|
+
const length = Math.hypot(nx, ny) || 1;
|
|
239
|
+
const normal: [number, number, number] =
|
|
240
|
+
area >= 0 ? [nx / length, ny / length, 0] : [-nx / length, -ny / length, 0];
|
|
241
|
+
if (area >= 0) {
|
|
242
|
+
pushTriangle(
|
|
243
|
+
vertices,
|
|
244
|
+
indices,
|
|
245
|
+
{ p: [a.x, a.y, -half], n: normal, uv: [u0, 0] },
|
|
246
|
+
{ p: [b.x, b.y, -half], n: normal, uv: [u1, 0] },
|
|
247
|
+
{ p: [b.x, b.y, half], n: normal, uv: [u1, 1] },
|
|
248
|
+
);
|
|
249
|
+
pushTriangle(
|
|
250
|
+
vertices,
|
|
251
|
+
indices,
|
|
252
|
+
{ p: [a.x, a.y, -half], n: normal, uv: [u0, 0] },
|
|
253
|
+
{ p: [b.x, b.y, half], n: normal, uv: [u1, 1] },
|
|
254
|
+
{ p: [a.x, a.y, half], n: normal, uv: [u0, 1] },
|
|
255
|
+
);
|
|
256
|
+
} else {
|
|
257
|
+
pushTriangle(
|
|
258
|
+
vertices,
|
|
259
|
+
indices,
|
|
260
|
+
{ p: [a.x, a.y, -half], n: normal, uv: [u0, 0] },
|
|
261
|
+
{ p: [b.x, b.y, half], n: normal, uv: [u1, 1] },
|
|
262
|
+
{ p: [b.x, b.y, -half], n: normal, uv: [u1, 0] },
|
|
263
|
+
);
|
|
264
|
+
pushTriangle(
|
|
265
|
+
vertices,
|
|
266
|
+
indices,
|
|
267
|
+
{ p: [a.x, a.y, -half], n: normal, uv: [u0, 0] },
|
|
268
|
+
{ p: [a.x, a.y, half], n: normal, uv: [u0, 1] },
|
|
269
|
+
{ p: [b.x, b.y, half], n: normal, uv: [u1, 1] },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
distance += edge;
|
|
273
|
+
}
|
|
274
|
+
return meshFromInterleaved(new Float32Array(vertices), new Uint32Array(indices));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Sweep a circular profile along a 3D path. */
|
|
278
|
+
export function createSweepGeometry(
|
|
279
|
+
path: readonly Vec3Point[],
|
|
280
|
+
radius: number,
|
|
281
|
+
radialSegments = 12,
|
|
282
|
+
): Result<MeshAsset, AssetError> {
|
|
283
|
+
if (!Number.isFinite(radius) || radius <= 0)
|
|
284
|
+
return invalid('radius', 'must be positive and finite');
|
|
285
|
+
const segments = radialSegments | 0;
|
|
286
|
+
if (
|
|
287
|
+
path.length < 2 ||
|
|
288
|
+
path.some((point) => point.length !== 3 || point.some((value) => !Number.isFinite(value)))
|
|
289
|
+
)
|
|
290
|
+
return invalid('path', 'needs at least two finite 3D points');
|
|
291
|
+
if (segments < 3) return invalid('radialSegments', 'must be at least 3');
|
|
292
|
+
// A repeated path point makes the local tangent undefined. A repeated first
|
|
293
|
+
// and last point is the explicit closed-path form used by authored railings;
|
|
294
|
+
// retain that seam while rejecting every other repeat before mesh creation.
|
|
295
|
+
const first = path[0] as Vec3Point;
|
|
296
|
+
const last = path[path.length - 1] as Vec3Point;
|
|
297
|
+
const closed =
|
|
298
|
+
path.length > 3 &&
|
|
299
|
+
Math.hypot(first[0] - last[0], first[1] - last[1], first[2] - last[2]) <= 1e-8;
|
|
300
|
+
for (let left = 0; left < path.length; left++) {
|
|
301
|
+
const a = path[left] as Vec3Point;
|
|
302
|
+
for (let right = left + 1; right < path.length; right++) {
|
|
303
|
+
const b = path[right] as Vec3Point;
|
|
304
|
+
if (closed && left === 0 && right === path.length - 1) continue;
|
|
305
|
+
if (Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) <= 1e-8) {
|
|
306
|
+
return invalid('path', 'must not contain repeated points');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const lengths = [0];
|
|
311
|
+
for (let i = 1; i < path.length; i++) {
|
|
312
|
+
const previous = path[i - 1] as Vec3Point;
|
|
313
|
+
const current = path[i] as Vec3Point;
|
|
314
|
+
lengths.push(
|
|
315
|
+
(lengths[i - 1] as number) +
|
|
316
|
+
Math.hypot(current[0] - previous[0], current[1] - previous[1], current[2] - previous[2]),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const total = lengths[lengths.length - 1] as number;
|
|
320
|
+
if (!(total > 0)) return invalid('path', 'must contain distinct points');
|
|
321
|
+
const vertices: number[] = [];
|
|
322
|
+
const indices: number[] = [];
|
|
323
|
+
for (let row = 0; row < path.length; row++) {
|
|
324
|
+
const point = path[row] as Vec3Point;
|
|
325
|
+
const previous = path[Math.max(0, row - 1)] as Vec3Point;
|
|
326
|
+
const next = path[Math.min(path.length - 1, row + 1)] as Vec3Point;
|
|
327
|
+
let tx = next[0] - previous[0];
|
|
328
|
+
let ty = next[1] - previous[1];
|
|
329
|
+
let tz = next[2] - previous[2];
|
|
330
|
+
const tangentLength = Math.hypot(tx, ty, tz) || 1;
|
|
331
|
+
tx /= tangentLength;
|
|
332
|
+
ty /= tangentLength;
|
|
333
|
+
tz /= tangentLength;
|
|
334
|
+
const reference: Vec3Point = Math.abs(ty) < 0.95 ? [0, 1, 0] : [1, 0, 0];
|
|
335
|
+
let nx = ty * reference[2] - tz * reference[1];
|
|
336
|
+
let ny = tz * reference[0] - tx * reference[2];
|
|
337
|
+
let nz = tx * reference[1] - ty * reference[0];
|
|
338
|
+
const nLength = Math.hypot(nx, ny, nz) || 1;
|
|
339
|
+
nx /= nLength;
|
|
340
|
+
ny /= nLength;
|
|
341
|
+
nz /= nLength;
|
|
342
|
+
const bx = ty * nz - tz * ny;
|
|
343
|
+
const by = tz * nx - tx * nz;
|
|
344
|
+
const bz = tx * ny - ty * nx;
|
|
345
|
+
for (let column = 0; column <= segments; column++) {
|
|
346
|
+
const angle = (column / segments) * Math.PI * 2;
|
|
347
|
+
const radialX = Math.cos(angle) * nx + Math.sin(angle) * bx;
|
|
348
|
+
const radialY = Math.cos(angle) * ny + Math.sin(angle) * by;
|
|
349
|
+
const radialZ = Math.cos(angle) * nz + Math.sin(angle) * bz;
|
|
350
|
+
pushVertex(
|
|
351
|
+
vertices,
|
|
352
|
+
[point[0] + radialX * radius, point[1] + radialY * radius, point[2] + radialZ * radius],
|
|
353
|
+
[radialX, radialY, radialZ],
|
|
354
|
+
[column / segments, (lengths[row] as number) / total],
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const stride = segments + 1;
|
|
359
|
+
const position = (index: number): Vec3Point => [
|
|
360
|
+
vertices[index * 8] as number,
|
|
361
|
+
vertices[index * 8 + 1] as number,
|
|
362
|
+
vertices[index * 8 + 2] as number,
|
|
363
|
+
];
|
|
364
|
+
for (let row = 0; row < path.length - 1; row++) {
|
|
365
|
+
for (let column = 0; column < segments; column++) {
|
|
366
|
+
const a = row * stride + column;
|
|
367
|
+
const b = a + 1;
|
|
368
|
+
const c = (row + 1) * stride + column + 1;
|
|
369
|
+
const d = (row + 1) * stride + column;
|
|
370
|
+
const p = position(a);
|
|
371
|
+
const q = position(b);
|
|
372
|
+
const r = position(c);
|
|
373
|
+
const ab: Vec3Point = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
|
|
374
|
+
const ac: Vec3Point = [r[0] - p[0], r[1] - p[1], r[2] - p[2]];
|
|
375
|
+
const normal: Vec3Point = [
|
|
376
|
+
ab[1] * ac[2] - ab[2] * ac[1],
|
|
377
|
+
ab[2] * ac[0] - ab[0] * ac[2],
|
|
378
|
+
ab[0] * ac[1] - ab[1] * ac[0],
|
|
379
|
+
];
|
|
380
|
+
const center = path[row] as Vec3Point;
|
|
381
|
+
const outward: Vec3Point = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
|
|
382
|
+
const dot = normal[0] * outward[0] + normal[1] * outward[1] + normal[2] * outward[2];
|
|
383
|
+
if (dot > 0) indices.push(a, b, c, a, c, d);
|
|
384
|
+
else indices.push(a, c, b, a, d, c);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return meshFromInterleaved(new Float32Array(vertices), new Uint32Array(indices));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Revolve a (radius, height) profile around the Y axis. */
|
|
391
|
+
export function createRevolutionGeometry(
|
|
392
|
+
profile: readonly Vec2Point[],
|
|
393
|
+
radialSegments = 24,
|
|
394
|
+
): Result<MeshAsset, AssetError> {
|
|
395
|
+
const segments = radialSegments | 0;
|
|
396
|
+
if (profile.length < 2 || profile.some((point) => !finitePoint(point) || point.x < 0))
|
|
397
|
+
return invalid('profile', 'needs at least two finite points with non-negative radius');
|
|
398
|
+
if (segments < 3) return invalid('radialSegments', 'must be at least 3');
|
|
399
|
+
const first = profile[0];
|
|
400
|
+
if (first === undefined || !profile.some((point) => point.y !== first.y))
|
|
401
|
+
return invalid('profile', 'must span a non-zero height');
|
|
402
|
+
// A repeated profile point creates a zero-area ring strip. Letting that
|
|
403
|
+
// reach MeshAsset validation produces a seemingly valid mesh with collapsed
|
|
404
|
+
// triangles, so reject the authored degeneracy at the geometry boundary.
|
|
405
|
+
for (let left = 0; left < profile.length; left++) {
|
|
406
|
+
const a = profile[left] as Vec2Point;
|
|
407
|
+
for (let right = left + 1; right < profile.length; right++) {
|
|
408
|
+
const b = profile[right] as Vec2Point;
|
|
409
|
+
if (Math.hypot(a.x - b.x, a.y - b.y) <= 1e-8) {
|
|
410
|
+
return invalid('profile', 'must not contain repeated points');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const vertices: number[] = [];
|
|
415
|
+
const indices: number[] = [];
|
|
416
|
+
for (let row = 0; row < profile.length; row++) {
|
|
417
|
+
const point = profile[row] as Vec2Point;
|
|
418
|
+
const previous = profile[Math.max(0, row - 1)] as Vec2Point;
|
|
419
|
+
const next = profile[Math.min(profile.length - 1, row + 1)] as Vec2Point;
|
|
420
|
+
const dr = next.x - previous.x;
|
|
421
|
+
const dy = next.y - previous.y;
|
|
422
|
+
const normalLength = Math.hypot(dy, dr) || 1;
|
|
423
|
+
for (let column = 0; column <= segments; column++) {
|
|
424
|
+
const u = column / segments;
|
|
425
|
+
const angle = u * Math.PI * 2;
|
|
426
|
+
const c = Math.cos(angle);
|
|
427
|
+
const s = Math.sin(angle);
|
|
428
|
+
pushVertex(
|
|
429
|
+
vertices,
|
|
430
|
+
[point.x * c, point.y, point.x * s],
|
|
431
|
+
[(c * dy) / normalLength, -dr / normalLength, (s * dy) / normalLength],
|
|
432
|
+
[u, row / (profile.length - 1)],
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const stride = segments + 1;
|
|
437
|
+
for (let row = 0; row < profile.length - 1; row++) {
|
|
438
|
+
for (let column = 0; column < segments; column++) {
|
|
439
|
+
const a = row * stride + column;
|
|
440
|
+
const b = a + 1;
|
|
441
|
+
const c = (row + 1) * stride + column + 1;
|
|
442
|
+
const d = (row + 1) * stride + column;
|
|
443
|
+
indices.push(a, d, b, b, d, c);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return meshFromInterleaved(new Float32Array(vertices), new Uint32Array(indices));
|
|
447
|
+
}
|