@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.
- package/LICENSE +202 -0
- package/README.md +215 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/asset-owner.unit.test.d.ts +2 -0
- package/dist/__tests__/asset-owner.unit.test.d.ts.map +1 -0
- package/dist/__tests__/dim2.test.d.ts +2 -0
- package/dist/__tests__/dim2.test.d.ts.map +1 -0
- package/dist/__tests__/geometry.unit.test.d.ts +2 -0
- package/dist/__tests__/geometry.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vertex-attribute-layout-owner.unit.test.d.ts +2 -0
- package/dist/__tests__/vertex-attribute-layout-owner.unit.test.d.ts.map +1 -0
- package/dist/assets/mesh-binary.d.ts +4 -0
- package/dist/assets/mesh-binary.d.ts.map +1 -0
- package/dist/assets/mesh-decoder.d.ts +6 -0
- package/dist/assets/mesh-decoder.d.ts.map +1 -0
- package/dist/assets/primitive-mesh.d.ts +5 -0
- package/dist/assets/primitive-mesh.d.ts.map +1 -0
- package/dist/box.d.ts +41 -0
- package/dist/box.d.ts.map +1 -0
- package/dist/capsule.d.ts +14 -0
- package/dist/capsule.d.ts.map +1 -0
- package/dist/cone.d.ts +4 -0
- package/dist/cone.d.ts.map +1 -0
- package/dist/cylinder.d.ts +4 -0
- package/dist/cylinder.d.ts.map +1 -0
- package/dist/dim2.d.ts +73 -0
- package/dist/dim2.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +2000 -0
- package/dist/index.mjs.map +1 -0
- package/dist/plane.d.ts +4 -0
- package/dist/plane.d.ts.map +1 -0
- package/dist/sphere.d.ts +4 -0
- package/dist/sphere.d.ts.map +1 -0
- package/dist/tangent.d.ts +34 -0
- package/dist/tangent.d.ts.map +1 -0
- package/dist/torus.d.ts +4 -0
- package/dist/torus.d.ts.map +1 -0
- package/dist/vertex-attribute-layout.d.ts +90 -0
- package/dist/vertex-attribute-layout.d.ts.map +1 -0
- package/package.json +59 -0
- package/src/__tests__/asset-owner.unit.test.ts +97 -0
- package/src/__tests__/dim2.test.ts +132 -0
- package/src/__tests__/geometry.unit.test.ts +1223 -0
- package/src/__tests__/vertex-attribute-layout-owner.unit.test.ts +181 -0
- package/src/assets/mesh-binary.ts +421 -0
- package/src/assets/mesh-decoder.ts +97 -0
- package/src/assets/primitive-mesh.ts +36 -0
- package/src/box.ts +437 -0
- package/src/capsule.ts +145 -0
- package/src/cone.ts +26 -0
- package/src/cylinder.ts +180 -0
- package/src/dim2.ts +505 -0
- package/src/index.ts +52 -0
- package/src/plane.ts +78 -0
- package/src/sphere.ts +82 -0
- package/src/tangent.ts +320 -0
- package/src/torus.ts +83 -0
- package/src/vertex-attribute-layout.ts +503 -0
package/src/cylinder.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @forgeax/engine-runtime - Procedural Cylinder geometry (M3 / w8).
|
|
2
|
+
//
|
|
3
|
+
// Mirrors Three.js r184 CylinderGeometry: createCylinderGeometry(radiusTop,
|
|
4
|
+
// radiusBottom, height, radialSegments?, heightSegments?) ->
|
|
5
|
+
// Result<MeshAsset, AssetError>.
|
|
6
|
+
// Degenerate parameters (both radii <= 0, height <= 0, radialSegments < 3,
|
|
7
|
+
// heightSegments < 1) fail-fast with AssetError('asset-parse-failed').
|
|
8
|
+
// Note: one radius may be zero (degenerate end collapses to a cone tip),
|
|
9
|
+
// but NOT both — a zero cylinder has no surface area.
|
|
10
|
+
//
|
|
11
|
+
// Related: requirements §AC-06 / §AC-14; plan-strategy §M3 + D-P5;
|
|
12
|
+
// plan-tasks.json w8 acceptanceCheck.
|
|
13
|
+
|
|
14
|
+
import type { AssetError, MeshAsset } from '@forgeax/engine-types';
|
|
15
|
+
import { err, type Result } from '@forgeax/engine-types';
|
|
16
|
+
import { degenerate, FACTORY_FLOATS_PER_VERTEX, meshFromInterleaved } from './box';
|
|
17
|
+
|
|
18
|
+
export function createCylinderGeometry(
|
|
19
|
+
radiusTop: number,
|
|
20
|
+
radiusBottom: number,
|
|
21
|
+
height: number,
|
|
22
|
+
radialSegments: number = 16,
|
|
23
|
+
heightSegments: number = 1,
|
|
24
|
+
): Result<MeshAsset, AssetError> {
|
|
25
|
+
if (radiusTop < 0 || radiusBottom < 0 || height <= 0) {
|
|
26
|
+
return err(
|
|
27
|
+
degenerate(`radiusTop=${radiusTop}, radiusBottom=${radiusBottom}, height=${height}`),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (radiusTop === 0 && radiusBottom === 0) {
|
|
31
|
+
return err(degenerate(`radiusTop=0 and radiusBottom=0; at least one must be > 0`));
|
|
32
|
+
}
|
|
33
|
+
const rs = radialSegments | 0;
|
|
34
|
+
const hs = heightSegments | 0;
|
|
35
|
+
if (rs < 3) return err(degenerate(`radialSegments=${rs}; minimum 3`));
|
|
36
|
+
if (hs < 1) return err(degenerate(`heightSegments=${hs}; minimum 1`));
|
|
37
|
+
|
|
38
|
+
const halfHeight = height / 2;
|
|
39
|
+
// Side: (rs + 1) * (hs + 1) vertices
|
|
40
|
+
const sideVertexCount = (rs + 1) * (hs + 1);
|
|
41
|
+
// Caps: each cap has 1 center + (rs + 1) ring vertices if the radius is > 0
|
|
42
|
+
const topCap = radiusTop > 0;
|
|
43
|
+
const bottomCap = radiusBottom > 0;
|
|
44
|
+
const topCount = topCap ? rs + 2 : 0;
|
|
45
|
+
const bottomCount = bottomCap ? rs + 2 : 0;
|
|
46
|
+
const vertexCount = sideVertexCount + topCount + bottomCount;
|
|
47
|
+
|
|
48
|
+
const sideIndexCount = rs * hs * 6;
|
|
49
|
+
const topIdxCount = topCap ? rs * 3 : 0;
|
|
50
|
+
const bottomIdxCount = bottomCap ? rs * 3 : 0;
|
|
51
|
+
const indexCount = sideIndexCount + topIdxCount + bottomIdxCount;
|
|
52
|
+
|
|
53
|
+
const vertices = new Float32Array(vertexCount * FACTORY_FLOATS_PER_VERTEX);
|
|
54
|
+
const indices = new Uint32Array(indexCount);
|
|
55
|
+
|
|
56
|
+
// --- side ---
|
|
57
|
+
let vIdx = 0;
|
|
58
|
+
const slope = (radiusBottom - radiusTop) / height;
|
|
59
|
+
for (let iy = 0; iy <= hs; iy++) {
|
|
60
|
+
const v = iy / hs;
|
|
61
|
+
const y = halfHeight - v * height;
|
|
62
|
+
const radius = v * (radiusBottom - radiusTop) + radiusTop;
|
|
63
|
+
for (let ix = 0; ix <= rs; ix++) {
|
|
64
|
+
const u = ix / rs;
|
|
65
|
+
const theta = u * Math.PI * 2;
|
|
66
|
+
const sinT = Math.sin(theta);
|
|
67
|
+
const cosT = Math.cos(theta);
|
|
68
|
+
const x = radius * sinT;
|
|
69
|
+
const z = radius * cosT;
|
|
70
|
+
const nx = sinT;
|
|
71
|
+
const ny = slope;
|
|
72
|
+
const nz = cosT;
|
|
73
|
+
// normalize
|
|
74
|
+
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
|
|
75
|
+
const base = vIdx * FACTORY_FLOATS_PER_VERTEX;
|
|
76
|
+
vertices[base + 0] = x;
|
|
77
|
+
vertices[base + 1] = y;
|
|
78
|
+
vertices[base + 2] = z;
|
|
79
|
+
vertices[base + 3] = nx / nlen;
|
|
80
|
+
vertices[base + 4] = ny / nlen;
|
|
81
|
+
vertices[base + 5] = nz / nlen;
|
|
82
|
+
vertices[base + 6] = u;
|
|
83
|
+
// UV.v uses the WebGPU top-left convention (V=0 = image top).
|
|
84
|
+
vertices[base + 7] = v;
|
|
85
|
+
vIdx++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let iIdx = 0;
|
|
90
|
+
for (let iy = 0; iy < hs; iy++) {
|
|
91
|
+
for (let ix = 0; ix < rs; ix++) {
|
|
92
|
+
const a = (rs + 1) * iy + ix;
|
|
93
|
+
const b = (rs + 1) * (iy + 1) + ix;
|
|
94
|
+
const c = (rs + 1) * (iy + 1) + ix + 1;
|
|
95
|
+
const d = (rs + 1) * iy + ix + 1;
|
|
96
|
+
indices[iIdx++] = a;
|
|
97
|
+
indices[iIdx++] = b;
|
|
98
|
+
indices[iIdx++] = d;
|
|
99
|
+
indices[iIdx++] = b;
|
|
100
|
+
indices[iIdx++] = c;
|
|
101
|
+
indices[iIdx++] = d;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- top cap ---
|
|
106
|
+
if (topCap) {
|
|
107
|
+
const centerIdx = vIdx;
|
|
108
|
+
const cBase = vIdx * FACTORY_FLOATS_PER_VERTEX;
|
|
109
|
+
vertices[cBase + 0] = 0;
|
|
110
|
+
vertices[cBase + 1] = halfHeight;
|
|
111
|
+
vertices[cBase + 2] = 0;
|
|
112
|
+
vertices[cBase + 3] = 0;
|
|
113
|
+
vertices[cBase + 4] = 1;
|
|
114
|
+
vertices[cBase + 5] = 0;
|
|
115
|
+
vertices[cBase + 6] = 0.5;
|
|
116
|
+
vertices[cBase + 7] = 0.5;
|
|
117
|
+
vIdx++;
|
|
118
|
+
const ringStart = vIdx;
|
|
119
|
+
for (let ix = 0; ix <= rs; ix++) {
|
|
120
|
+
const u = ix / rs;
|
|
121
|
+
const theta = u * Math.PI * 2;
|
|
122
|
+
const sinT = Math.sin(theta);
|
|
123
|
+
const cosT = Math.cos(theta);
|
|
124
|
+
const base = vIdx * FACTORY_FLOATS_PER_VERTEX;
|
|
125
|
+
vertices[base + 0] = radiusTop * sinT;
|
|
126
|
+
vertices[base + 1] = halfHeight;
|
|
127
|
+
vertices[base + 2] = radiusTop * cosT;
|
|
128
|
+
vertices[base + 3] = 0;
|
|
129
|
+
vertices[base + 4] = 1;
|
|
130
|
+
vertices[base + 5] = 0;
|
|
131
|
+
vertices[base + 6] = sinT * 0.5 + 0.5;
|
|
132
|
+
vertices[base + 7] = cosT * 0.5 + 0.5;
|
|
133
|
+
vIdx++;
|
|
134
|
+
}
|
|
135
|
+
for (let ix = 0; ix < rs; ix++) {
|
|
136
|
+
indices[iIdx++] = centerIdx;
|
|
137
|
+
indices[iIdx++] = ringStart + ix;
|
|
138
|
+
indices[iIdx++] = ringStart + ix + 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- bottom cap ---
|
|
143
|
+
if (bottomCap) {
|
|
144
|
+
const centerIdx = vIdx;
|
|
145
|
+
const cBase = vIdx * FACTORY_FLOATS_PER_VERTEX;
|
|
146
|
+
vertices[cBase + 0] = 0;
|
|
147
|
+
vertices[cBase + 1] = -halfHeight;
|
|
148
|
+
vertices[cBase + 2] = 0;
|
|
149
|
+
vertices[cBase + 3] = 0;
|
|
150
|
+
vertices[cBase + 4] = -1;
|
|
151
|
+
vertices[cBase + 5] = 0;
|
|
152
|
+
vertices[cBase + 6] = 0.5;
|
|
153
|
+
vertices[cBase + 7] = 0.5;
|
|
154
|
+
vIdx++;
|
|
155
|
+
const ringStart = vIdx;
|
|
156
|
+
for (let ix = 0; ix <= rs; ix++) {
|
|
157
|
+
const u = ix / rs;
|
|
158
|
+
const theta = u * Math.PI * 2;
|
|
159
|
+
const sinT = Math.sin(theta);
|
|
160
|
+
const cosT = Math.cos(theta);
|
|
161
|
+
const base = vIdx * FACTORY_FLOATS_PER_VERTEX;
|
|
162
|
+
vertices[base + 0] = radiusBottom * sinT;
|
|
163
|
+
vertices[base + 1] = -halfHeight;
|
|
164
|
+
vertices[base + 2] = radiusBottom * cosT;
|
|
165
|
+
vertices[base + 3] = 0;
|
|
166
|
+
vertices[base + 4] = -1;
|
|
167
|
+
vertices[base + 5] = 0;
|
|
168
|
+
vertices[base + 6] = sinT * 0.5 + 0.5;
|
|
169
|
+
vertices[base + 7] = cosT * 0.5 + 0.5;
|
|
170
|
+
vIdx++;
|
|
171
|
+
}
|
|
172
|
+
for (let ix = 0; ix < rs; ix++) {
|
|
173
|
+
indices[iIdx++] = centerIdx;
|
|
174
|
+
indices[iIdx++] = ringStart + ix + 1;
|
|
175
|
+
indices[iIdx++] = ringStart + ix;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return meshFromInterleaved(vertices, indices);
|
|
180
|
+
}
|
package/src/dim2.ts
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { box2, box3, circle2 } from '@forgeax/engine-math';
|
|
2
|
+
import type { AssetError, MeshAsset, PrimitiveTopology } from '@forgeax/engine-types';
|
|
3
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
4
|
+
import { degenerate, PROCEDURAL_FLOATS_PER_VERTEX } from './box';
|
|
5
|
+
|
|
6
|
+
export type Vec2 = readonly [number, number];
|
|
7
|
+
|
|
8
|
+
export type Shape2d =
|
|
9
|
+
| { readonly kind: 'circle'; readonly radius: number; readonly resolution?: number }
|
|
10
|
+
| {
|
|
11
|
+
readonly kind: 'circular-sector';
|
|
12
|
+
readonly radius: number;
|
|
13
|
+
readonly angle: number;
|
|
14
|
+
readonly resolution?: number;
|
|
15
|
+
}
|
|
16
|
+
| {
|
|
17
|
+
readonly kind: 'circular-segment';
|
|
18
|
+
readonly radius: number;
|
|
19
|
+
readonly angle: number;
|
|
20
|
+
readonly resolution?: number;
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
readonly kind: 'ellipse';
|
|
24
|
+
readonly halfWidth: number;
|
|
25
|
+
readonly halfHeight: number;
|
|
26
|
+
readonly resolution?: number;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
readonly kind: 'annulus';
|
|
30
|
+
readonly innerRadius: number;
|
|
31
|
+
readonly outerRadius: number;
|
|
32
|
+
readonly resolution?: number;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
readonly kind: 'capsule';
|
|
36
|
+
readonly radius: number;
|
|
37
|
+
readonly halfLength: number;
|
|
38
|
+
readonly resolution?: number;
|
|
39
|
+
}
|
|
40
|
+
| { readonly kind: 'rhombus'; readonly halfWidth: number; readonly halfHeight: number }
|
|
41
|
+
| { readonly kind: 'rectangle'; readonly width: number; readonly height: number }
|
|
42
|
+
| { readonly kind: 'regular-polygon'; readonly radius: number; readonly sides: number }
|
|
43
|
+
| { readonly kind: 'triangle'; readonly vertices: readonly [Vec2, Vec2, Vec2] }
|
|
44
|
+
| { readonly kind: 'segment'; readonly vertices: readonly [Vec2, Vec2] }
|
|
45
|
+
| { readonly kind: 'polyline'; readonly vertices: readonly Vec2[] };
|
|
46
|
+
|
|
47
|
+
export type Shape2dPose = {
|
|
48
|
+
readonly translation?: Vec2;
|
|
49
|
+
readonly rotation?: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type Shape2dMeshOptions = {
|
|
53
|
+
readonly uv?: { readonly kind: 'circular-mask'; readonly angle: number };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type Shape2dBounds = {
|
|
57
|
+
readonly aabb: box2.Box2;
|
|
58
|
+
readonly circle: circle2.Circle2;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type UvProjection = {
|
|
62
|
+
readonly kind: 'circular-mask';
|
|
63
|
+
readonly radius: number;
|
|
64
|
+
readonly angle: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const DEFAULT_RESOLUTION = 32;
|
|
68
|
+
|
|
69
|
+
function invalid(detail: string): Result<never, AssetError> {
|
|
70
|
+
return err(degenerate(detail));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolution(value: number | undefined): number | undefined {
|
|
74
|
+
const resolved = value ?? DEFAULT_RESOLUTION;
|
|
75
|
+
const integer = resolved | 0;
|
|
76
|
+
return integer >= 3 && integer === resolved ? integer : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function finitePoint(point: Vec2): boolean {
|
|
80
|
+
return Number.isFinite(point[0]) && Number.isFinite(point[1]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function maskRadius(shape: Shape2d): number | undefined {
|
|
84
|
+
switch (shape.kind) {
|
|
85
|
+
case 'circle':
|
|
86
|
+
case 'circular-sector':
|
|
87
|
+
case 'circular-segment':
|
|
88
|
+
return shape.radius;
|
|
89
|
+
case 'ellipse':
|
|
90
|
+
case 'annulus':
|
|
91
|
+
case 'capsule':
|
|
92
|
+
case 'rhombus':
|
|
93
|
+
case 'rectangle':
|
|
94
|
+
case 'regular-polygon':
|
|
95
|
+
case 'triangle':
|
|
96
|
+
case 'segment':
|
|
97
|
+
case 'polyline':
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateMeshOptions(
|
|
103
|
+
shape: Shape2d,
|
|
104
|
+
options: Shape2dMeshOptions | undefined,
|
|
105
|
+
): Result<UvProjection | undefined, AssetError> {
|
|
106
|
+
const uv = options?.uv;
|
|
107
|
+
if (uv === undefined) return ok(undefined);
|
|
108
|
+
const radius = maskRadius(shape);
|
|
109
|
+
return radius !== undefined && radius > 0 && Number.isFinite(uv.angle)
|
|
110
|
+
? ok({ kind: 'circular-mask', radius, angle: uv.angle })
|
|
111
|
+
: invalid(`circular-mask UV requires a circular shape and finite angle`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function area(points: readonly Vec2[]): number {
|
|
115
|
+
let value = 0;
|
|
116
|
+
for (let i = 0; i < points.length; i++) {
|
|
117
|
+
const a = points[i];
|
|
118
|
+
const b = points[(i + 1) % points.length];
|
|
119
|
+
if (!a || !b) continue;
|
|
120
|
+
value += a[0] * b[1] - b[0] * a[1];
|
|
121
|
+
}
|
|
122
|
+
return value / 2;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function centroid(points: readonly Vec2[]): Vec2 {
|
|
126
|
+
let x = 0;
|
|
127
|
+
let y = 0;
|
|
128
|
+
for (const point of points) {
|
|
129
|
+
x += point[0];
|
|
130
|
+
y += point[1];
|
|
131
|
+
}
|
|
132
|
+
const count = Math.max(1, points.length);
|
|
133
|
+
return [x / count, y / count];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function counterClockwise(points: readonly Vec2[]): Vec2[] {
|
|
137
|
+
return area(points) < 0 ? [...points].reverse() : [...points];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function circleBoundary(radius: number, count: number, start = -Math.PI / 2): Vec2[] {
|
|
141
|
+
return Array.from({ length: count }, (_, i) => {
|
|
142
|
+
const angle = start + (i / count) * Math.PI * 2;
|
|
143
|
+
return [Math.cos(angle) * radius, Math.sin(angle) * radius] as const;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function arcBoundary(radius: number, angle: number, count: number, start: number): Vec2[] {
|
|
148
|
+
return Array.from({ length: count + 1 }, (_, i) => {
|
|
149
|
+
const t = i / count;
|
|
150
|
+
const current = start + angle * t;
|
|
151
|
+
return [Math.cos(current) * radius, Math.sin(current) * radius] as const;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function ellipseBoundary(halfWidth: number, halfHeight: number, count: number): Vec2[] {
|
|
156
|
+
return Array.from({ length: count }, (_, i) => {
|
|
157
|
+
const angle = -Math.PI / 2 + (i / count) * Math.PI * 2;
|
|
158
|
+
return [Math.cos(angle) * halfWidth, Math.sin(angle) * halfHeight] as const;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function capsuleBoundary(radius: number, halfLength: number, count: number): Vec2[] {
|
|
163
|
+
const points: Vec2[] = [];
|
|
164
|
+
for (let i = 0; i <= count; i++) {
|
|
165
|
+
const angle = (i / count) * Math.PI;
|
|
166
|
+
points.push([Math.cos(angle) * radius, halfLength + Math.sin(angle) * radius]);
|
|
167
|
+
}
|
|
168
|
+
for (let i = 0; i <= count; i++) {
|
|
169
|
+
const angle = Math.PI + (i / count) * Math.PI;
|
|
170
|
+
points.push([Math.cos(angle) * radius, -halfLength + Math.sin(angle) * radius]);
|
|
171
|
+
}
|
|
172
|
+
points.pop();
|
|
173
|
+
points.shift();
|
|
174
|
+
return points;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildMesh(
|
|
178
|
+
points: readonly Vec2[],
|
|
179
|
+
indices: readonly number[],
|
|
180
|
+
topology: PrimitiveTopology,
|
|
181
|
+
uvProjection?: UvProjection,
|
|
182
|
+
): MeshAsset {
|
|
183
|
+
const positions = new Float32Array(points.length * 3);
|
|
184
|
+
const normals = new Float32Array(points.length * 3);
|
|
185
|
+
const uvs = new Float32Array(points.length * 2);
|
|
186
|
+
const tangents = new Float32Array(points.length * 4);
|
|
187
|
+
let minX = Infinity;
|
|
188
|
+
let minY = Infinity;
|
|
189
|
+
let maxX = -Infinity;
|
|
190
|
+
let maxY = -Infinity;
|
|
191
|
+
|
|
192
|
+
for (const point of points) {
|
|
193
|
+
minX = Math.min(minX, point[0]);
|
|
194
|
+
minY = Math.min(minY, point[1]);
|
|
195
|
+
maxX = Math.max(maxX, point[0]);
|
|
196
|
+
maxY = Math.max(maxY, point[1]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const width = Math.max(maxX - minX, 1);
|
|
200
|
+
const height = Math.max(maxY - minY, 1);
|
|
201
|
+
const vertices = new Float32Array(points.length * PROCEDURAL_FLOATS_PER_VERTEX);
|
|
202
|
+
for (let i = 0; i < points.length; i++) {
|
|
203
|
+
const point = points[i];
|
|
204
|
+
if (!point) continue;
|
|
205
|
+
const [x, y] = point;
|
|
206
|
+
const positionOffset = i * 3;
|
|
207
|
+
const uvOffset = i * 2;
|
|
208
|
+
const tangentOffset = i * 4;
|
|
209
|
+
const vertexOffset = i * PROCEDURAL_FLOATS_PER_VERTEX;
|
|
210
|
+
positions[positionOffset] = x;
|
|
211
|
+
positions[positionOffset + 1] = y;
|
|
212
|
+
normals[positionOffset + 2] = 1;
|
|
213
|
+
const cos = uvProjection === undefined ? 1 : Math.cos(uvProjection.angle);
|
|
214
|
+
const sin = uvProjection === undefined ? 0 : Math.sin(uvProjection.angle);
|
|
215
|
+
const projectedX = x * cos - y * sin;
|
|
216
|
+
const projectedY = x * sin + y * cos;
|
|
217
|
+
const u =
|
|
218
|
+
uvProjection === undefined
|
|
219
|
+
? (x - minX) / width
|
|
220
|
+
: 0.5 + projectedX / (2 * uvProjection.radius);
|
|
221
|
+
const v =
|
|
222
|
+
uvProjection === undefined
|
|
223
|
+
? (maxY - y) / height
|
|
224
|
+
: 0.5 - projectedY / (2 * uvProjection.radius);
|
|
225
|
+
uvs[uvOffset] = u;
|
|
226
|
+
uvs[uvOffset + 1] = v;
|
|
227
|
+
tangents[tangentOffset] = 1;
|
|
228
|
+
tangents[tangentOffset + 3] = 1;
|
|
229
|
+
vertices[vertexOffset] = x;
|
|
230
|
+
vertices[vertexOffset + 1] = y;
|
|
231
|
+
vertices[vertexOffset + 5] = 1;
|
|
232
|
+
vertices[vertexOffset + 6] = u;
|
|
233
|
+
vertices[vertexOffset + 7] = v;
|
|
234
|
+
vertices[vertexOffset + 8] = 1;
|
|
235
|
+
vertices[vertexOffset + 11] = 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
kind: 'mesh',
|
|
240
|
+
vertices,
|
|
241
|
+
indices: new Uint32Array(indices),
|
|
242
|
+
attributes: { position: positions, normal: normals, uv: uvs, tangent: tangents },
|
|
243
|
+
submeshes: [
|
|
244
|
+
{
|
|
245
|
+
indexOffset: 0,
|
|
246
|
+
indexCount: indices.length,
|
|
247
|
+
vertexCount: points.length,
|
|
248
|
+
topology,
|
|
249
|
+
materialSlot: 0,
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
253
|
+
aabb: box3.fromPositions(box3.create(), positions),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function fill(points: readonly Vec2[], uvProjection?: UvProjection): MeshAsset {
|
|
258
|
+
const boundary = counterClockwise(points);
|
|
259
|
+
const center = centroid(boundary);
|
|
260
|
+
const vertices = [center, ...boundary];
|
|
261
|
+
const indices: number[] = [];
|
|
262
|
+
for (let i = 0; i < boundary.length; i++) {
|
|
263
|
+
const next = (i + 1) % boundary.length;
|
|
264
|
+
indices.push(0, i + 1, next + 1);
|
|
265
|
+
}
|
|
266
|
+
return buildMesh(vertices, indices, 'triangle-list', uvProjection);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function ring(outer: readonly Vec2[], inner: readonly Vec2[]): MeshAsset {
|
|
270
|
+
const outside = counterClockwise(outer);
|
|
271
|
+
const inside = counterClockwise(inner);
|
|
272
|
+
const count = Math.min(outside.length, inside.length);
|
|
273
|
+
const vertices = [...outside.slice(0, count), ...inside.slice(0, count)];
|
|
274
|
+
const indices: number[] = [];
|
|
275
|
+
for (let i = 0; i < count; i++) {
|
|
276
|
+
const next = (i + 1) % count;
|
|
277
|
+
indices.push(i, next, count + i, next, count + next, count + i);
|
|
278
|
+
}
|
|
279
|
+
return buildMesh(vertices, indices, 'triangle-list');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function segment(points: readonly Vec2[]): MeshAsset {
|
|
283
|
+
const indices: number[] = [];
|
|
284
|
+
for (let i = 0; i + 1 < points.length; i += 1) indices.push(i, i + 1);
|
|
285
|
+
return buildMesh(points, indices, 'line-list');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function shapeBoundary(shape: Shape2d, count: number): Vec2[] | undefined {
|
|
289
|
+
switch (shape.kind) {
|
|
290
|
+
case 'circle':
|
|
291
|
+
return circleBoundary(shape.radius, count);
|
|
292
|
+
case 'circular-sector':
|
|
293
|
+
return [
|
|
294
|
+
...arcBoundary(shape.radius, shape.angle, count, -Math.PI / 2 - shape.angle / 2),
|
|
295
|
+
[0, 0],
|
|
296
|
+
];
|
|
297
|
+
case 'circular-segment':
|
|
298
|
+
return arcBoundary(shape.radius, shape.angle, count, -Math.PI / 2 - shape.angle / 2);
|
|
299
|
+
case 'ellipse':
|
|
300
|
+
return ellipseBoundary(shape.halfWidth, shape.halfHeight, count);
|
|
301
|
+
case 'capsule':
|
|
302
|
+
return capsuleBoundary(shape.radius, shape.halfLength, Math.max(2, Math.floor(count / 2)));
|
|
303
|
+
case 'rhombus':
|
|
304
|
+
return [
|
|
305
|
+
[0, shape.halfHeight],
|
|
306
|
+
[shape.halfWidth, 0],
|
|
307
|
+
[0, -shape.halfHeight],
|
|
308
|
+
[-shape.halfWidth, 0],
|
|
309
|
+
];
|
|
310
|
+
case 'rectangle':
|
|
311
|
+
return [
|
|
312
|
+
[-shape.width / 2, -shape.height / 2],
|
|
313
|
+
[shape.width / 2, -shape.height / 2],
|
|
314
|
+
[shape.width / 2, shape.height / 2],
|
|
315
|
+
[-shape.width / 2, shape.height / 2],
|
|
316
|
+
];
|
|
317
|
+
case 'regular-polygon':
|
|
318
|
+
return Array.from({ length: shape.sides }, (_, i) => {
|
|
319
|
+
const angle = -Math.PI / 2 + (i / shape.sides) * Math.PI * 2;
|
|
320
|
+
return [Math.cos(angle) * shape.radius, Math.sin(angle) * shape.radius] as const;
|
|
321
|
+
});
|
|
322
|
+
case 'triangle':
|
|
323
|
+
return [...shape.vertices];
|
|
324
|
+
case 'annulus':
|
|
325
|
+
case 'segment':
|
|
326
|
+
case 'polyline':
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function scaleAround(points: readonly Vec2[], factor: number): Vec2[] {
|
|
332
|
+
const center = centroid(points);
|
|
333
|
+
return points.map(([x, y]) => [
|
|
334
|
+
center[0] + (x - center[0]) * factor,
|
|
335
|
+
center[1] + (y - center[1]) * factor,
|
|
336
|
+
]);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function withResolution(
|
|
340
|
+
value: number | undefined,
|
|
341
|
+
valid: boolean,
|
|
342
|
+
detail: string,
|
|
343
|
+
): Result<number, AssetError> {
|
|
344
|
+
const count = resolution(value);
|
|
345
|
+
return valid && count !== undefined ? ok(count) : invalid(detail);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function validateCommon(shape: Shape2d): Result<number, AssetError> {
|
|
349
|
+
switch (shape.kind) {
|
|
350
|
+
case 'circle':
|
|
351
|
+
return withResolution(
|
|
352
|
+
shape.resolution,
|
|
353
|
+
shape.radius > 0 && Number.isFinite(shape.radius),
|
|
354
|
+
`circle radius=${shape.radius}, resolution=${shape.resolution}`,
|
|
355
|
+
);
|
|
356
|
+
case 'circular-sector':
|
|
357
|
+
case 'circular-segment':
|
|
358
|
+
return withResolution(
|
|
359
|
+
shape.resolution,
|
|
360
|
+
shape.radius > 0 &&
|
|
361
|
+
shape.angle > 0 &&
|
|
362
|
+
shape.angle <= Math.PI * 2 &&
|
|
363
|
+
Number.isFinite(shape.radius) &&
|
|
364
|
+
Number.isFinite(shape.angle),
|
|
365
|
+
`${shape.kind} radius=${shape.radius}, angle=${shape.angle}, resolution=${shape.resolution}`,
|
|
366
|
+
);
|
|
367
|
+
case 'ellipse':
|
|
368
|
+
return withResolution(
|
|
369
|
+
shape.resolution,
|
|
370
|
+
shape.halfWidth > 0 &&
|
|
371
|
+
shape.halfHeight > 0 &&
|
|
372
|
+
Number.isFinite(shape.halfWidth) &&
|
|
373
|
+
Number.isFinite(shape.halfHeight),
|
|
374
|
+
`ellipse halfWidth=${shape.halfWidth}, halfHeight=${shape.halfHeight}, resolution=${shape.resolution}`,
|
|
375
|
+
);
|
|
376
|
+
case 'annulus':
|
|
377
|
+
return withResolution(
|
|
378
|
+
shape.resolution,
|
|
379
|
+
shape.innerRadius >= 0 &&
|
|
380
|
+
shape.outerRadius > shape.innerRadius &&
|
|
381
|
+
Number.isFinite(shape.innerRadius) &&
|
|
382
|
+
Number.isFinite(shape.outerRadius),
|
|
383
|
+
`annulus innerRadius=${shape.innerRadius}, outerRadius=${shape.outerRadius}, resolution=${shape.resolution}`,
|
|
384
|
+
);
|
|
385
|
+
case 'capsule':
|
|
386
|
+
return withResolution(
|
|
387
|
+
shape.resolution,
|
|
388
|
+
shape.radius > 0 &&
|
|
389
|
+
shape.halfLength >= 0 &&
|
|
390
|
+
Number.isFinite(shape.radius) &&
|
|
391
|
+
Number.isFinite(shape.halfLength),
|
|
392
|
+
`capsule radius=${shape.radius}, halfLength=${shape.halfLength}, resolution=${shape.resolution}`,
|
|
393
|
+
);
|
|
394
|
+
case 'rhombus':
|
|
395
|
+
return shape.halfWidth > 0 &&
|
|
396
|
+
shape.halfHeight > 0 &&
|
|
397
|
+
Number.isFinite(shape.halfWidth) &&
|
|
398
|
+
Number.isFinite(shape.halfHeight)
|
|
399
|
+
? ok(0)
|
|
400
|
+
: invalid(`rhombus halfWidth=${shape.halfWidth}, halfHeight=${shape.halfHeight}`);
|
|
401
|
+
case 'rectangle':
|
|
402
|
+
return shape.width > 0 &&
|
|
403
|
+
shape.height > 0 &&
|
|
404
|
+
Number.isFinite(shape.width) &&
|
|
405
|
+
Number.isFinite(shape.height)
|
|
406
|
+
? ok(0)
|
|
407
|
+
: invalid(`rectangle width=${shape.width}, height=${shape.height}`);
|
|
408
|
+
case 'regular-polygon':
|
|
409
|
+
return shape.radius > 0 &&
|
|
410
|
+
Number.isFinite(shape.radius) &&
|
|
411
|
+
Number.isInteger(shape.sides) &&
|
|
412
|
+
shape.sides >= 3
|
|
413
|
+
? ok(0)
|
|
414
|
+
: invalid(`regular-polygon radius=${shape.radius}, sides=${shape.sides}`);
|
|
415
|
+
case 'triangle':
|
|
416
|
+
return shape.vertices.every(finitePoint) && Math.abs(area(shape.vertices)) > 1e-7
|
|
417
|
+
? ok(0)
|
|
418
|
+
: invalid('triangle vertices must be finite and non-collinear');
|
|
419
|
+
case 'segment':
|
|
420
|
+
return shape.vertices.every(finitePoint) &&
|
|
421
|
+
(shape.vertices[0][0] !== shape.vertices[1][0] ||
|
|
422
|
+
shape.vertices[0][1] !== shape.vertices[1][1])
|
|
423
|
+
? ok(0)
|
|
424
|
+
: invalid('segment endpoints must be finite and distinct');
|
|
425
|
+
case 'polyline':
|
|
426
|
+
return shape.vertices.length >= 2 && shape.vertices.every(finitePoint)
|
|
427
|
+
? ok(0)
|
|
428
|
+
: invalid('polyline needs at least two finite vertices');
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function create2dGeometry(
|
|
433
|
+
shape: Shape2d,
|
|
434
|
+
options?: Shape2dMeshOptions,
|
|
435
|
+
): Result<MeshAsset, AssetError> {
|
|
436
|
+
const checked = validateCommon(shape);
|
|
437
|
+
if (!checked.ok) return checked;
|
|
438
|
+
const meshOptions = validateMeshOptions(shape, options);
|
|
439
|
+
if (!meshOptions.ok) return meshOptions;
|
|
440
|
+
if (shape.kind === 'segment' || shape.kind === 'polyline') return ok(segment(shape.vertices));
|
|
441
|
+
if (shape.kind === 'annulus') {
|
|
442
|
+
const outer = circleBoundary(shape.outerRadius, checked.value);
|
|
443
|
+
const inner = circleBoundary(shape.innerRadius, checked.value);
|
|
444
|
+
return ok(ring(outer, inner));
|
|
445
|
+
}
|
|
446
|
+
const points = shapeBoundary(shape, checked.value);
|
|
447
|
+
if (!points) return invalid(`unsupported 2d shape kind=${shape.kind}`);
|
|
448
|
+
return ok(fill(points, meshOptions.value));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function boundsPoints(shape: Shape2d, count: number): Vec2[] | undefined {
|
|
452
|
+
if (shape.kind === 'annulus') return circleBoundary(shape.outerRadius, count);
|
|
453
|
+
if (shape.kind === 'segment' || shape.kind === 'polyline') return [...shape.vertices];
|
|
454
|
+
return shapeBoundary(shape, count);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function compute2dBounds(
|
|
458
|
+
shape: Shape2d,
|
|
459
|
+
pose: Shape2dPose = {},
|
|
460
|
+
): Result<Shape2dBounds, AssetError> {
|
|
461
|
+
const checked = validateCommon(shape);
|
|
462
|
+
if (!checked.ok) return checked;
|
|
463
|
+
const translation = pose.translation ?? [0, 0];
|
|
464
|
+
const rotation = pose.rotation ?? 0;
|
|
465
|
+
if (!finitePoint(translation) || !Number.isFinite(rotation)) {
|
|
466
|
+
return invalid('2d bounds pose must contain finite translation and rotation');
|
|
467
|
+
}
|
|
468
|
+
const points = boundsPoints(shape, checked.value);
|
|
469
|
+
if (points === undefined || points.length === 0)
|
|
470
|
+
return invalid(`unsupported 2d bounds kind=${shape.kind}`);
|
|
471
|
+
const cos = Math.cos(rotation);
|
|
472
|
+
const sin = Math.sin(rotation);
|
|
473
|
+
const transformed = points.map(
|
|
474
|
+
([x, y]) => [translation[0] + x * cos - y * sin, translation[1] + x * sin + y * cos] as const,
|
|
475
|
+
);
|
|
476
|
+
let radius = 0;
|
|
477
|
+
for (const [x, y] of points) radius = Math.max(radius, Math.hypot(x, y));
|
|
478
|
+
return ok({
|
|
479
|
+
aabb: box2.fromPoints(box2.create(), transformed),
|
|
480
|
+
circle: circle2.create(translation[0], translation[1], radius),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function create2dRingGeometry(
|
|
485
|
+
shape: Shape2d,
|
|
486
|
+
thickness: number,
|
|
487
|
+
resolutionOverride?: number,
|
|
488
|
+
): Result<MeshAsset, AssetError> {
|
|
489
|
+
if (!(thickness > 0) || !Number.isFinite(thickness))
|
|
490
|
+
return invalid(`ring thickness=${thickness}`);
|
|
491
|
+
const checked = validateCommon(shape);
|
|
492
|
+
if (!checked.ok) return checked;
|
|
493
|
+
if (shape.kind === 'segment' || shape.kind === 'polyline' || shape.kind === 'annulus') {
|
|
494
|
+
return invalid(`rings require a closed non-annulus shape, got ${shape.kind}`);
|
|
495
|
+
}
|
|
496
|
+
const count = resolutionOverride === undefined ? checked.value : resolution(resolutionOverride);
|
|
497
|
+
if (count === undefined) return invalid(`ring resolution=${resolutionOverride}`);
|
|
498
|
+
const outer = shapeBoundary(shape, count);
|
|
499
|
+
if (!outer) return invalid(`unsupported ring shape kind=${shape.kind}`);
|
|
500
|
+
const extent = Math.max(...outer.map(([x, y]) => Math.hypot(x, y)));
|
|
501
|
+
const factor = (extent - thickness) / extent;
|
|
502
|
+
if (!(factor > 1e-5))
|
|
503
|
+
return invalid(`ring thickness=${thickness} exceeds shape extent=${extent}`);
|
|
504
|
+
return ok(ring(outer, scaleAround(outer, factor)));
|
|
505
|
+
}
|