@forgeax/engine-geometry
v0.2.1
Published
Procedural mesh geometry factories + vertex attribute layout SSOT for forgeax-engine.
Downloads
6,747
Readme
@forgeax/engine-geometry
Pure-function procedural mesh geometry factories + the vertex-attribute-layout SSOT for
forgeax-engine. A leaf package: depends on @forgeax/engine-ecs (Result),
@forgeax/engine-math (AABB derivation), and @forgeax/engine-types (MeshAsset /
AssetError / VertexAttributeMap), and never imports the renderer or RHI.
30-second self-introduction
- Surface: 10 reusable 3D procedural factories
(
createBoxGeometry/createCapsuleGeometry/createConeGeometry/createCylinderGeometry/createPlaneGeometry/createSphereGeometry/createTorusGeometry/createExtrusionGeometry/createSweepGeometry/createRevolutionGeometry), each returningResult<MeshAsset, AssetError>; the vertex-attribute-layout SSOT (deriveVertexBufferLayout/buildMeshAttributeMapForUvSets/GpuVertexBufferLayoutEntry); and the tangent + interleave helpers (computeTangentVec4/meshFromInterleaved/PROCEDURAL_FLOATS_PER_VERTEX). A single entry-pointimport { ... } from '@forgeax/engine-geometry'. - Edge conversion:
createWireframeGeometry(source)keeps every unique triangle edge, whilecreateEdgesGeometry(source, thresholdAngleDegrees?)keeps boundary, non-manifold, and threshold-surviving surface edges. Both return the sameResult<MeshAsset, AssetError>shape from the public barrel. - 2D primitives:
create2dGeometryturns the twelve Bevy2d_shapesprimitive definitions into triangle-list or line-listMeshAssetvalues, whilecreate2dRingGeometrybuilds the reusable ring variants used by the same gallery. - Style: pure functions -- no classes, no mutation, no side effects. Every
factory returns
Result<MeshAsset, AssetError>(charter P3 explicit failure). Caller owns the returnedMeshAsset; the package never touches GPU or ECS. - Errors: degenerate parameters (
dim <= 0,segments < minimum, etc.) fail-fast withAssetError({ code: 'asset-parse-failed' })carrying a structured.detail.field/.value/.reason. No silent fallback, noconsole.warn, nonullreturn (charter P3).
The canonical vertex layout is interned by its finite attribute-presence mask. Mesh maps and packed masks resolve to the same deeply frozen projection; changing an authoring map's attributes derives a different mask without retaining the map or its buffers. Layout offsets, formats, and digests still derive from the one canonical attribute schema.
MeshBuilder
MeshBuilder is the incremental authoring owner for custom geometry. Append
canonical position/normal/UV/tangent data and indexed submeshes, then call
build() once to obtain a Result<MeshAsset, AssetError>. The builder derives
the interleaved vertex layout, index width, submesh ranges, and AABB from the
same attribute-layout and bounds owners used by procedural factories; callers
do not maintain a second stride, bounds, or layout table. Malformed cardinality,
non-finite attributes, out-of-range indices, and empty commits fail with the
existing structured asset-parse-failed detail. A rejected build can be
repaired and retried without publishing a partial mesh.
Edge conversion factories
Import both operations from the package barrel:
import {
createEdgesGeometry,
createWireframeGeometry,
} from '@forgeax/engine-geometry';
const wireframe = createWireframeGeometry(source);
const edges = createEdgesGeometry(source, 30);
if (!wireframe.ok || !edges.ok) {
// Branch on the structured AssetError; do not parse error.message.
const failure = !wireframe.ok ? wireframe.error : edges.error;
if (failure.code === 'asset-parse-failed') {
console.error(failure.detail?.field, failure.detail?.reason);
}
return;
}| Factory | Edge rule | Default | Input contract |
|:--|:--|:--|:--|
| createWireframeGeometry(source) | Every unique triangle edge, including coplanar diagonals and subdivided-grid edges; exact normalized-f32 endpoint keys are sorted lexicographically. | N/A | Indexed triangle-list submeshes or exactly one complete non-indexed triangle-list submesh. |
| createEdgesGeometry(source, thresholdAngleDegrees?) | Fixed 1e-4 position weld; the minimum exact-f32 representative is selected for each welded endpoint. Boundary and non-manifold edges are kept; two-face edges use dot(n0, n1) <= cos(thresholdAngleDegrees * pi / 180). | 1 degree | The same MeshAsset contract, plus finite thresholdAngleDegrees in [0, 180]. |
Both factories preflight attributes.position as finite Float32Array data or a
4-byte-aligned ArrayBuffer, validate triangle cardinality, submesh ranges,
index storage and index bounds, and skip zero-area faces. Invalid input returns
AssetError with code === 'asset-parse-failed' and
detail.field/value/reason; threshold values are rejected rather than clamped.
Successful output is one non-indexed line-list submesh with the Default
material slot. attributes.position contains the emitted endpoints; normal,
uv, and tangent are zero fillers with matching cardinality. The existing
packInterleavedVertexAttributes layout owner produces vertices, and
box3.fromPositions derives the output AABB from those emitted positions. An
empty semantic result is valid and carries the inverted-empty AABB.
The operations are pure CPU transformations: they do not mutate or retain the
source MeshAsset, register handles, or import Render/RHI policy. Canonical edge
ordering makes repeated calls, default versus explicit threshold 1, equivalent
indexed versus non-indexed triangle soups, and triangle/submesh permutations
produce byte-stable output.
Vertex color contract
MeshAsset.attributes.color is the optional geometry-owned vertex color: a
linear RGBA Float32Array, with exactly four finite values per vertex. It is
not an sRGB input and does not add a material flag. Procedural authors can set
the field directly; glTF COLOR_0 is normalized into this same field at the
import boundary. Missing color is a real absence and keeps the color stream
out of the packed bytes.
Use deriveVertexLayoutProjection and
packInterleavedVertexAttributes(attributes, vertexCount) for layout and
packing. The packer returns a Result; callers branch on ok before
publishing vertex bytes. The immutable projection owns canonical offsets, stride, mask and
digest; color is host @location(13) (float32x4, 16 bytes), while existing
locations 0..12 remain unchanged. Render and asset consumers carry this
projection instead of calculating offsets or a parallel stride. See
VertexAttributeMap for the public POD shape.
30s hands-on example
import { createBoxGeometry, createSphereGeometry } from '@forgeax/engine-geometry';
// 1. Build a unit box (6 faces, 1 segment per edge)
const box = createBoxGeometry(1, 1, 1);
if (!box.ok) return; // box.error.code === 'asset-parse-failed'
console.log(box.value.vertexCount); // 24 (4 vertices * 6 faces)
// 2. Build a UV sphere and hand the MeshAsset to the AssetRegistry for a handle
const sphere = createSphereGeometry(1, 32, 24);
// ---- ⬆ the factory returns an unregistered MeshAsset POD ----
// To spawn an entity: register via renderer.assets.register(sphere.value).unwrap(),
// then hand the resulting Handle<MeshAsset> to MeshFilter.assetHandle.The primitive factories cover the common built-in shapes. For reusable authored
geometry, createExtrusionGeometry ear-clips a simple concave 2D contour and
adds cap and side normals, createSweepGeometry follows a 3D path with a
circular profile, and createRevolutionGeometry turns a (radius, height)
profile around the Y axis. Each operation validates finite input before
allocating a MeshAsset and returns through the same tangent, layout, and AABB
owners. For an imported
glTF mesh, use @forgeax/engine-gltf; FBX follows the same source-plus-Meta
route through @forgeax/engine-fbx. Runtime consumption is
@forgeax/engine-assets-runtime after the owning importer has cooked the asset.
2D primitive geometry
Shape2d is the closed input union for circles, sectors, segments, ellipses,
annuli, capsules, rhombi, rectangles, regular polygons, triangles, segments, and
polylines. create2dGeometry(shape) returns a filled mesh for closed shapes and a
line-list mesh for segment / polyline. create2dRingGeometry(shape, thickness)
returns the ring form for closed non-annulus shapes; ellipse and sector rings follow
the same visual approximation as Bevy's source example. compute2dBounds(shape, pose)
derives the transformed AABB and bounding circle from that same shape contract.
import { create2dGeometry, create2dRingGeometry } from '@forgeax/engine-geometry';
const filled = create2dGeometry({ kind: 'circle', radius: 50 });
const ring = create2dRingGeometry({ kind: 'rectangle', width: 50, height: 100 }, 5);
const arc = create2dGeometry(
{ kind: 'circular-sector', radius: 40, angle: Math.PI / 2 },
{ uv: { kind: 'circular-mask', angle: -Math.PI / 4 } },
);
if (!filled.ok || !ring.ok) return;API surface
10 procedural geometry factories
Each returns Result<MeshAsset, AssetError>. Interleaved vertex layout:
position (3xf32) + normal (3xf32) + uv (2xf32) = 8 floats/vertex at creation
time; expanded to the 12-float runtime layout (adds tangent vec4) by
meshFromInterleaved.
| Factory | Signature | Minimum segments |
|:--|:--|:--|
| createBoxGeometry | (w, h, d, wSeg?, hSeg?, dSeg?) | 1 / dim |
| createCapsuleGeometry | (radius, length, capSeg?, radSeg?) | capSeg >= 1, radSeg >= 3; total height = length + 2*radius (Bevy Capsule3d convention) |
| createConeGeometry | (radius, height, radSeg?, hSeg?) | radSeg=16; delegates to cylinder with top=0 |
| createCylinderGeometry | (rTop, rBottom, h, radSeg?, hSeg?) | radSeg=16; at least one radius > 0 |
| createPlaneGeometry | (w, h, wSeg?, hSeg?) | 1 / dim; XY plane, +Z normal |
| createSphereGeometry | (radius, wSeg?, hSeg?) | wSeg >= 3, hSeg >= 2 |
| createTorusGeometry | (radius, tube, radSeg?, tubSeg?) | radSeg >= 3, tubSeg >= 3 |
| createExtrusionGeometry | (contour, depth) | 3+ finite simple contour points, depth > 0 |
| createSweepGeometry | (path, radius, radialSeg?) | 2+ distinct finite 3D points, radius > 0, radialSeg >= 3 |
| createRevolutionGeometry | (profile, radialSeg?) | 2+ finite non-negative-radius points spanning height, radialSeg >= 3 |
All factories populate position / normal / uv attributes with lowercase
Three.js-r184 key naming. VertexAttributeMap also accepts optional color
linear RGBA data; see
the attribute layout section.
Tangent and interleave helpers
| Symbol | Kind | Purpose |
|:--|:--|:--|
| computeTangentVec4(positions, normals, uvs, indices?) | fn | Preflights attribute cardinality, triangle topology, and index range, then returns Result<Float32Array, AssetError>. Success is the per-vertex tangent (vec4): face-area-weighted average + Gram-Schmidt orthogonalise + handedness sign packed into .w. |
| meshFromInterleaved(vertices, indices) | fn | Preflights the 8-float interleaved stride and triangle index topology before slicing or allocating output, then returns Result<MeshAsset, AssetError> with the 12-float runtime layout. |
| PROCEDURAL_FLOATS_PER_VERTEX | const | 12 -- the runtime interleaved stride: position (3) + normal (3) + uv (2) + tangent (4). |
Both helpers return the existing AssetError vocabulary with
error.code === 'asset-parse-failed' for malformed input. The error detail
identifies the rejected field, observed value, and reason; no tangent,
mesh, or working buffer is allocated before this preflight completes. The
helpers are stateless, so corrected arrays can be passed to the next call and
produce the normal vec4 or mesh result.
| Preflight | Rejected input |
|:--|:--|
| Attribute cardinality | positions.length not divisible by 3, or normals / uvs not matching the derived vertex count |
| Triangle cardinality | Non-indexed vertex count or indexed index count not divisible by 3 |
| Index range | Any index that is not an integer in [0, vertexCount) |
Vertex attribute layout SSOT
| Symbol | Kind | Purpose |
|:--|:--|:--|
| deriveVertexBufferLayout(map, opts?) | fn | Derives a fixed-order GpuVertexBufferLayoutEntry[] from a VertexAttributeMap. The canonical 14-key order (position / normal / uv / tangent / skinIndex / skinWeight / uv1..uv7 / color) assigns @location(N) per key. Absent keys reserve no space; opts.shaderUvSetCount enables clamp-to-last alias for UV sets. |
| deriveVertexLayoutProjection(map) | fn | Creates the immutable canonical projection (schemaVersion, attributes, mask, stride, digest). color is fixed at host location 13 and float32x4. |
| packInterleavedVertexAttributes(map, vertexCount) | fn | Packs attribute arrays using that projection and returns Result<{ projection, vertices }, VertexAttributePackError>; closed detail reasons identify storage, cardinality, vertex-count, or non-finite failures. |
| DEFAULT_VERTEX_ATTRIBUTE_MAP / SKIN_VERTEX_ATTRIBUTE_MAP | const | Canonical presence maps for ordinary and skinned interleaved meshes. Render, recovery, and feature assembly use these maps instead of spelling a fallback layout locally. |
| deriveVertexCount(vertices, projection) | fn | Validates vertices.byteLength % projection.arrayStride === 0 and derives the integer vertex count shared by upload, LOD, update, morph, and draw-range consumers. |
| deriveVertexLayoutProjectionFromMask(mask) | fn | Reconstructs a wire projection through the same canonical key owner; returns a typed error for empty or unknown mask bits. |
| buildMeshAttributeMapForUvSets(uvSetCount) | fn | Synthesise a VertexAttributeMap with empty Float32Array(0) placeholders for uv + uv1..uvN-1. Used by the forward record stage to pre-declare UV-set slots before interleaving. |
| GpuVertexBufferLayoutEntry | type | { shaderLocation: number; offset: number; format: GPUVertexFormat } -- one GPU vertex buffer layout entry. |
import { deriveVertexBufferLayout, type GpuVertexBufferLayoutEntry } from '@forgeax/engine-geometry';
// Given a MeshAsset.attributes map, derive the GPU vertex buffer layout
const layout: GpuVertexBufferLayoutEntry[] = deriveVertexBufferLayout(mesh.attributes, {
shaderUvSetCount: 2, // shader expects uv + uv1; if mesh only has uv, uv1 clamps to uv
});
// layout[n].shaderLocation matches WGSL @location(N) declarations.The canonical key order and per-key GPUVertexFormat mapping is the SSOT in
packages/geometry/src/vertex-attribute-layout.ts (ATTRIBUTE_FORMAT_MAP).
Shader @location(N) declarations and naga reflection deep-equal tests keep
them in sync.
[!IMPORTANT] Keep vertex bytes and
VertexLayoutProjectiontogether across every GPU owner. Derive the count withderiveVertexCount; a non-divisible payload is rejected before GPU resource construction instead of being truncated into a fractional draw range.
Three.js r184 mental alignment
The seven primitive factory signatures mirror Three.js BufferGeometry constructors
byte-for-byte: parameter order, attribute key lowercasing (position / normal
/ uv), and segment-count defaults. AI users migrating from Three.js can swap
the import path and the return-shape check:
// Three.js:
// const geo = new THREE.BoxGeometry(1, 1, 1, 2, 2, 2);
// const pos = geo.getAttribute('position');
// forgeax:
import { createBoxGeometry } from '@forgeax/engine-geometry';
const res = createBoxGeometry(1, 1, 1, 2, 2, 2);
if (!res.ok) return;
const pos = res.value.attributes.position; // Float32Array, same dataKey differences from Three.js:
| Aspect | Three.js r184 | @forgeax/engine-geometry |
|:--|:--|:--|
| Return shape | mutable BufferGeometry instance | Result<MeshAsset, AssetError> (immutable POD) |
| Tangent population | computeTangents() (separate call) | meshFromInterleaved bakes tangent into the interleaved buffer |
| Index buffer | Uint16Array by default | Uint32Array when vertex count > 65535, Uint16Array otherwise |
| Error on degenerate | console.warn + best-effort | Result.err(AssetError) fail-fast |
Non-goals
| Not doing | Why |
|:--|:--|
| Loading external mesh formats (glTF, FBX, OBJ) | glTF and FBX are owned by @forgeax/engine-gltf / @forgeax/engine-fbx; runtime consumption uses @forgeax/engine-assets-runtime after the source-plus-Meta sidecar pipeline |
| GPU upload / handle minting | Geometry is pure-function CPU POD; registration and GPU residency are AssetRegistry + GpuResourceStore in @forgeax/engine-runtime |
| Procedural mesh editing (extrude, bevel, CSG) | Out of scope for the leaf geometry package; these are future @forgeax/engine-geometry-edit or equivalent |
| Non-procedural mesh data (skinned vertices, morph targets) | Skin data lives on the MeshAsset POD after import; this package only populates position / normal / uv |
| Result.unwrap() convenience | Caller calls .unwrap() from @forgeax/engine-ecs; this package only produces Result |
Route map
| Task | Package / skill |
|:--|:--|
| "I want a box / sphere / plane / cylinder / cone / torus from code" | @forgeax/engine-geometry (this package) |
| "I want to register the mesh and get a handle" | @forgeax/engine-runtime (renderer.assets.register(meshAsset)) |
| "I want to spawn an entity with this mesh" | @forgeax/engine-runtime (MeshFilter + MeshRenderer + MaterialAsset) -- see forgeax-engine-material skill |
| "I want to load a glTF file" | @forgeax/engine-gltf importer, then @forgeax/engine-assets-runtime (loadByGuid) |
| "I want to read the world-space position / forward / up from a spawned entity" | @forgeax/engine-math (mat4.getTranslation / getForward / getUp) |
Knowledge-base references
Cross-vendor reading for contributors:
../../.forgeax-harness/knowledge-base/wiki/typescript-branded-types.md-- brand pattern SSOT../math/README.md-- leaf-package README paradigm (progressive disclosure)../runtime/README.md-- MeshFilter / MeshRenderer / AssetRegistry API SSOT../types/src/index.ts--MeshAsset/AssetError/VertexAttributeMaptype definitions
License
Same as workspace root.
