@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
|
@@ -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
|
+
}
|