@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,90 @@
|
|
|
1
|
+
import { AssetError, type Result, type VertexAttributeMap, type VertexAttributePackDetail } from '@forgeax/engine-types';
|
|
2
|
+
declare const ATTRIBUTE_FORMAT_MAP: {
|
|
3
|
+
readonly position: "float32x3";
|
|
4
|
+
readonly normal: "float32x3";
|
|
5
|
+
readonly uv: "float32x2";
|
|
6
|
+
readonly tangent: "float32x4";
|
|
7
|
+
readonly skinIndex: "uint16x4";
|
|
8
|
+
readonly skinWeight: "float32x4";
|
|
9
|
+
readonly uv1: "float32x2";
|
|
10
|
+
readonly uv2: "float32x2";
|
|
11
|
+
readonly uv3: "float32x2";
|
|
12
|
+
readonly uv4: "float32x2";
|
|
13
|
+
readonly uv5: "float32x2";
|
|
14
|
+
readonly uv6: "float32x2";
|
|
15
|
+
readonly uv7: "float32x2";
|
|
16
|
+
readonly color: "float32x4";
|
|
17
|
+
};
|
|
18
|
+
type AttributeKey = keyof typeof ATTRIBUTE_FORMAT_MAP;
|
|
19
|
+
export interface GpuVertexBufferLayoutEntry {
|
|
20
|
+
readonly arrayStride: number;
|
|
21
|
+
readonly attributes: ReadonlyArray<{
|
|
22
|
+
readonly shaderLocation: number;
|
|
23
|
+
readonly offset: number;
|
|
24
|
+
readonly format: string;
|
|
25
|
+
}>;
|
|
26
|
+
readonly stepMode?: 'vertex' | 'instance' | undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build a non-skin `VertexAttributeMap` carrying exactly `uvSetCount` UV sets
|
|
30
|
+
* (set 0 = `uv`, then `uv1..uv{uvSetCount-1}`). `deriveVertexBufferLayout`
|
|
31
|
+
* reads only key presence, so the zero-length typed-array values are sentinels.
|
|
32
|
+
*
|
|
33
|
+
* feat-20260629-multi-uv-set-support: the forward record stage owns only the
|
|
34
|
+
* mesh's `uvSetCount` (a scalar threaded through MeshGpuHandles), not the
|
|
35
|
+
* original `MeshAsset.attributes` map. It synthesizes the map here so the
|
|
36
|
+
* material PSO's vertex layout includes the real @location(6+) attributes and
|
|
37
|
+
* its stride matches the interleaved buffer (a mesh with 2 real UV sets has a
|
|
38
|
+
* 56 B stride; a 48 B PSO layout against it puts every vertex after the first
|
|
39
|
+
* off-screen). uvSetCount <= 1 yields the canonical 4-attribute single-UV map.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildMeshAttributeMapForUvSets(uvSetCount: number): VertexAttributeMap;
|
|
42
|
+
export declare function deriveVertexBufferLayout(map: VertexAttributeMap, opts?: {
|
|
43
|
+
shaderUvSetCount?: number;
|
|
44
|
+
}): GpuVertexBufferLayoutEntry[];
|
|
45
|
+
/** Build the GPU descriptor from an immutable projection, adding only shader UV aliases. */
|
|
46
|
+
export declare function deriveVertexBufferLayoutFromProjection(projection: VertexLayoutProjection, opts?: {
|
|
47
|
+
shaderUvSetCount?: number;
|
|
48
|
+
}): GpuVertexBufferLayoutEntry[];
|
|
49
|
+
export interface VertexLayoutProjectionAttribute {
|
|
50
|
+
readonly key: AttributeKey;
|
|
51
|
+
readonly shaderLocation: number;
|
|
52
|
+
readonly offset: number;
|
|
53
|
+
readonly format: (typeof ATTRIBUTE_FORMAT_MAP)[AttributeKey];
|
|
54
|
+
readonly byteLength: number;
|
|
55
|
+
}
|
|
56
|
+
/** Immutable geometry-owned projection consumed by packers and GPU consumers. */
|
|
57
|
+
export interface VertexLayoutProjection {
|
|
58
|
+
readonly schemaVersion: 1;
|
|
59
|
+
readonly attributes: readonly VertexLayoutProjectionAttribute[];
|
|
60
|
+
readonly mask: number;
|
|
61
|
+
readonly arrayStride: number;
|
|
62
|
+
readonly digest: string;
|
|
63
|
+
}
|
|
64
|
+
/** Derive the one canonical immutable layout projection for a vertex map. */
|
|
65
|
+
export declare function deriveVertexLayoutProjection(map: VertexAttributeMap): VertexLayoutProjection;
|
|
66
|
+
export interface PackedVertexAttributes {
|
|
67
|
+
readonly projection: VertexLayoutProjection;
|
|
68
|
+
readonly vertices: Float32Array;
|
|
69
|
+
}
|
|
70
|
+
export declare class VertexAttributePackError extends AssetError {
|
|
71
|
+
readonly detail: VertexAttributePackDetail;
|
|
72
|
+
constructor(detail: VertexAttributePackDetail);
|
|
73
|
+
}
|
|
74
|
+
export interface VertexLayoutProjectionMaskError {
|
|
75
|
+
readonly code: 'vertex-layout-mask-invalid';
|
|
76
|
+
readonly expected: string;
|
|
77
|
+
readonly hint: string;
|
|
78
|
+
readonly detail: {
|
|
79
|
+
readonly mask: number;
|
|
80
|
+
readonly knownMask: number;
|
|
81
|
+
readonly unknownMask: number;
|
|
82
|
+
readonly reason: 'empty' | 'unknown-bits';
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Rebuild the canonical projection from a packed wire mask without a second schema. */
|
|
86
|
+
export declare function deriveVertexLayoutProjectionFromMask(mask: number): Result<VertexLayoutProjection, VertexLayoutProjectionMaskError>;
|
|
87
|
+
/** Pack tightly-owned attributes into the projection's canonical interleaved bytes. */
|
|
88
|
+
export declare function packInterleavedVertexAttributes(map: VertexAttributeMap, vertexCount: number): Result<PackedVertexAttributes, VertexAttributePackError>;
|
|
89
|
+
export {};
|
|
90
|
+
//# sourceMappingURL=vertex-attribute-layout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vertex-attribute-layout.d.ts","sourceRoot":"","sources":["../src/vertex-attribute-layout.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeax/engine-geometry",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"description": "Procedural mesh geometry factories + vertex attribute layout SSOT for forgeax-engine.",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.mjs",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@forgeax/engine-ecs": "0.1.2",
|
|
26
|
+
"@forgeax/engine-math": "0.1.2",
|
|
27
|
+
"@forgeax/engine-pack": "0.1.2",
|
|
28
|
+
"@forgeax/engine-types": "0.1.2"
|
|
29
|
+
},
|
|
30
|
+
"forgeax": {
|
|
31
|
+
"metrics": {
|
|
32
|
+
"bundle-size": {
|
|
33
|
+
"enabled": true,
|
|
34
|
+
"path": "dist/index.mjs",
|
|
35
|
+
"compression": "gzip"
|
|
36
|
+
},
|
|
37
|
+
"fps": {
|
|
38
|
+
"enabled": false,
|
|
39
|
+
"reason": "pure-function geometry library, no runtime canvas; fps reported by hello-triangle / hello-cube apps"
|
|
40
|
+
},
|
|
41
|
+
"bench": {
|
|
42
|
+
"enabled": false,
|
|
43
|
+
"reason": "procedural geometry factories run once at asset-build time, not per-frame; no CI micro-benchmark warranted"
|
|
44
|
+
},
|
|
45
|
+
"gate": {
|
|
46
|
+
"enabled": false,
|
|
47
|
+
"reason": "no package-level binary gate; smoke gate covered by hello-* / learn-render apps that consume the factories"
|
|
48
|
+
},
|
|
49
|
+
"spike-report": {
|
|
50
|
+
"enabled": false,
|
|
51
|
+
"reason": "not a spike package; geometry extracted from runtime in feat-20260704-runtime-tier1-decomposition"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsup",
|
|
57
|
+
"test": "vitest run"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { BUILTIN_BASE, handleSlot } from '@forgeax/engine-types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { meshAssetDecoder } from '../assets/mesh-decoder';
|
|
5
|
+
import { createPrimitiveMesh } from '../assets/primitive-mesh';
|
|
6
|
+
import { createBoxGeometry } from '../box';
|
|
7
|
+
import { deriveVertexBufferLayout } from '../vertex-attribute-layout';
|
|
8
|
+
|
|
9
|
+
describe('geometry asset owner direct contract', () => {
|
|
10
|
+
it('decodes the finite authored procedural descriptor into a fresh MeshAsset', async () => {
|
|
11
|
+
const result = await meshAssetDecoder.decode({
|
|
12
|
+
envelope: {
|
|
13
|
+
guid: '019f0000-0000-7000-8000-000000000001',
|
|
14
|
+
kind: 'mesh',
|
|
15
|
+
payload: { geometry: 'procedural-cube' } as never,
|
|
16
|
+
refs: [],
|
|
17
|
+
artifacts: {},
|
|
18
|
+
},
|
|
19
|
+
artifacts: { read: async () => ({ ok: false, error: new Error('unused') }) as never },
|
|
20
|
+
signal: new AbortController().signal,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result.ok).toBe(true);
|
|
24
|
+
if (!result.ok) return;
|
|
25
|
+
expect(result.value.kind).toBe('mesh');
|
|
26
|
+
expect(result.value.vertices.length).toBe(24 * 12);
|
|
27
|
+
expect(result.value.aabb).toEqual(Float32Array.from([-0.5, -0.5, -0.5, 0.5, 0.5, 0.5]));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('publishes primitive payload, AABB and the derived vertex layout together', () => {
|
|
31
|
+
const result = createBoxGeometry(2, 4, 6);
|
|
32
|
+
|
|
33
|
+
expect(result.ok).toBe(true);
|
|
34
|
+
if (!result.ok) return;
|
|
35
|
+
expect(result.value.kind).toBe('mesh');
|
|
36
|
+
expect(result.value.aabb).toEqual(Float32Array.from([-1, -2, -3, 1, 2, 3]));
|
|
37
|
+
expect(result.value.attributes.position).toBeInstanceOf(Float32Array);
|
|
38
|
+
const layout = deriveVertexBufferLayout(result.value.attributes);
|
|
39
|
+
expect(layout).toHaveLength(1);
|
|
40
|
+
expect(layout[0]).toMatchObject({ arrayStride: 48 });
|
|
41
|
+
expect(layout[0]?.attributes).toEqual(
|
|
42
|
+
expect.arrayContaining([
|
|
43
|
+
expect.objectContaining({ shaderLocation: 0, format: 'float32x3' }),
|
|
44
|
+
expect.objectContaining({ shaderLocation: 1, format: 'float32x3' }),
|
|
45
|
+
expect.objectContaining({ shaderLocation: 2, format: 'float32x2' }),
|
|
46
|
+
]),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns a structured error for an invalid primitive instead of a partial payload', () => {
|
|
51
|
+
const result = createBoxGeometry(0, 1, 1);
|
|
52
|
+
|
|
53
|
+
expect(result).toMatchObject({ ok: false, error: { code: 'asset-parse-failed' } });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('creates fresh primitive payloads and lets each World own its shared handle', () => {
|
|
57
|
+
const firstPayload = createPrimitiveMesh('cube').unwrap();
|
|
58
|
+
const secondPayload = createPrimitiveMesh('cube').unwrap();
|
|
59
|
+
const firstWorld = new World();
|
|
60
|
+
const secondWorld = new World();
|
|
61
|
+
|
|
62
|
+
expect(secondPayload).not.toBe(firstPayload);
|
|
63
|
+
|
|
64
|
+
const firstHandle = firstWorld.internSharedRef('MeshAsset', firstPayload);
|
|
65
|
+
const repeatedHandle = firstWorld.internSharedRef('MeshAsset', firstPayload);
|
|
66
|
+
const secondHandle = secondWorld.internSharedRef('MeshAsset', secondPayload);
|
|
67
|
+
|
|
68
|
+
expect(repeatedHandle).toBe(firstHandle);
|
|
69
|
+
expect(handleSlot(firstHandle)).toBe(BUILTIN_BASE);
|
|
70
|
+
expect(handleSlot(secondHandle)).toBe(BUILTIN_BASE);
|
|
71
|
+
expect(firstWorld.sharedRefs.resolve(firstHandle)).toEqual({ ok: true, value: firstPayload });
|
|
72
|
+
expect(secondWorld.sharedRefs.resolve(secondHandle)).toEqual({
|
|
73
|
+
ok: true,
|
|
74
|
+
value: secondPayload,
|
|
75
|
+
});
|
|
76
|
+
expect(firstWorld.sharedRefs.refcount(firstHandle)).toBe(1);
|
|
77
|
+
expect(secondWorld.sharedRefs.refcount(secondHandle)).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('preserves the six former primitive payload shapes without static handles', () => {
|
|
81
|
+
const expectations = {
|
|
82
|
+
cube: { vertices: 24 * 12, indices: 36 },
|
|
83
|
+
triangle: { vertices: 3 * 12, indices: 3 },
|
|
84
|
+
quad: { vertices: 4 * 12, indices: 6 },
|
|
85
|
+
sphere: { vertices: 17 * 13 * 12, indices: 1056 },
|
|
86
|
+
cylinder: { vertices: 70 * 12, indices: 192 },
|
|
87
|
+
'nine-slice-quad': { vertices: 16 * 12, indices: 54 },
|
|
88
|
+
} as const;
|
|
89
|
+
|
|
90
|
+
for (const [kind, expected] of Object.entries(expectations)) {
|
|
91
|
+
const mesh = createPrimitiveMesh(kind as keyof typeof expectations).unwrap();
|
|
92
|
+
expect(mesh.vertices.length, kind).toBe(expected.vertices);
|
|
93
|
+
expect(mesh.indices?.length, kind).toBe(expected.indices);
|
|
94
|
+
expect(mesh.aabb, kind).toBeDefined();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
compute2dBounds,
|
|
3
|
+
create2dGeometry,
|
|
4
|
+
create2dRingGeometry,
|
|
5
|
+
PROCEDURAL_FLOATS_PER_VERTEX,
|
|
6
|
+
type Shape2d,
|
|
7
|
+
} from '@forgeax/engine-geometry';
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
|
|
10
|
+
const FILLED_SHAPES: readonly Shape2d[] = [
|
|
11
|
+
{ kind: 'circle', radius: 1, resolution: 12 },
|
|
12
|
+
{ kind: 'circular-sector', radius: 1, angle: 1.25, resolution: 12 },
|
|
13
|
+
{ kind: 'circular-segment', radius: 1, angle: 1.25, resolution: 12 },
|
|
14
|
+
{ kind: 'ellipse', halfWidth: 1.5, halfHeight: 0.75, resolution: 12 },
|
|
15
|
+
{ kind: 'annulus', innerRadius: 0.5, outerRadius: 1, resolution: 12 },
|
|
16
|
+
{ kind: 'capsule', radius: 0.35, halfLength: 0.65, resolution: 12 },
|
|
17
|
+
{ kind: 'rhombus', halfWidth: 1.2, halfHeight: 0.8 },
|
|
18
|
+
{ kind: 'rectangle', width: 2, height: 1 },
|
|
19
|
+
{ kind: 'regular-polygon', radius: 1, sides: 6 },
|
|
20
|
+
{
|
|
21
|
+
kind: 'triangle',
|
|
22
|
+
vertices: [
|
|
23
|
+
[0, 1],
|
|
24
|
+
[-1, -1],
|
|
25
|
+
[1, -1],
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
describe('2D primitive geometry', () => {
|
|
31
|
+
it('builds every filled Bevy 2D shape as a triangle MeshAsset', () => {
|
|
32
|
+
for (const shape of FILLED_SHAPES) {
|
|
33
|
+
const result = create2dGeometry(shape);
|
|
34
|
+
expect(result.ok, shape.kind).toBe(true);
|
|
35
|
+
if (!result.ok) continue;
|
|
36
|
+
expect(result.value.submeshes).toEqual([
|
|
37
|
+
expect.objectContaining({ topology: 'triangle-list', indexCount: expect.any(Number) }),
|
|
38
|
+
]);
|
|
39
|
+
const submesh = result.value.submeshes[0];
|
|
40
|
+
expect(submesh).toBeDefined();
|
|
41
|
+
if (!submesh) continue;
|
|
42
|
+
expect(result.value.vertices.length).toBe(submesh.vertexCount * PROCEDURAL_FLOATS_PER_VERTEX);
|
|
43
|
+
const position = result.value.attributes.position;
|
|
44
|
+
expect(position).toBeInstanceOf(Float32Array);
|
|
45
|
+
if (!(position instanceof Float32Array)) continue;
|
|
46
|
+
expect(position.length).toBe(submesh.vertexCount * 3);
|
|
47
|
+
expect(result.value.aabb).toHaveLength(6);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('builds segment and polyline as line-list meshes', () => {
|
|
52
|
+
const segment = create2dGeometry({
|
|
53
|
+
kind: 'segment',
|
|
54
|
+
vertices: [
|
|
55
|
+
[-1, 0],
|
|
56
|
+
[1, 0],
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
const polyline = create2dGeometry({
|
|
60
|
+
kind: 'polyline',
|
|
61
|
+
vertices: [
|
|
62
|
+
[-1, 0],
|
|
63
|
+
[0, 1],
|
|
64
|
+
[1, 0],
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
expect(segment.ok).toBe(true);
|
|
68
|
+
expect(polyline.ok).toBe(true);
|
|
69
|
+
if (!segment.ok || !polyline.ok) return;
|
|
70
|
+
expect(segment.value.submeshes[0]?.topology).toBe('line-list');
|
|
71
|
+
expect(polyline.value.submeshes[0]?.indexCount).toBe(4);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('builds the closed ring variants from one shape contract', () => {
|
|
75
|
+
for (const shape of FILLED_SHAPES.filter((candidate) => candidate.kind !== 'annulus')) {
|
|
76
|
+
const result = create2dRingGeometry(shape, 0.1);
|
|
77
|
+
expect(result.ok, shape.kind).toBe(true);
|
|
78
|
+
if (!result.ok) continue;
|
|
79
|
+
expect(result.value.submeshes[0]?.topology).toBe('triangle-list');
|
|
80
|
+
expect(result.value.submeshes[0]?.indexCount).toBeGreaterThan(0);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('fails fast for invalid dimensions and unsupported rings', () => {
|
|
85
|
+
expect(create2dGeometry({ kind: 'circle', radius: 0 }).ok).toBe(false);
|
|
86
|
+
expect(create2dGeometry({ kind: 'regular-polygon', radius: 1, sides: 2 }).ok).toBe(false);
|
|
87
|
+
expect(create2dGeometry({ kind: 'polyline', vertices: [[0, 0]] }).ok).toBe(false);
|
|
88
|
+
expect(
|
|
89
|
+
create2dRingGeometry({ kind: 'annulus', innerRadius: 0.2, outerRadius: 1 }, 0.1).ok,
|
|
90
|
+
).toBe(false);
|
|
91
|
+
expect(create2dRingGeometry({ kind: 'circle', radius: 1 }, 1).ok).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('projects circular-mask UVs around a requested texture angle', () => {
|
|
95
|
+
const shape: Shape2d = {
|
|
96
|
+
kind: 'circular-sector',
|
|
97
|
+
radius: 2,
|
|
98
|
+
angle: Math.PI / 2,
|
|
99
|
+
resolution: 8,
|
|
100
|
+
};
|
|
101
|
+
const result = create2dGeometry(shape, { uv: { kind: 'circular-mask', angle: Math.PI / 2 } });
|
|
102
|
+
expect(result.ok).toBe(true);
|
|
103
|
+
if (!result.ok) return;
|
|
104
|
+
const uv = result.value.attributes.uv;
|
|
105
|
+
expect(uv).toBeInstanceOf(Float32Array);
|
|
106
|
+
if (!(uv instanceof Float32Array)) return;
|
|
107
|
+
for (const value of uv) expect(value).toBeGreaterThanOrEqual(0);
|
|
108
|
+
for (const value of uv) expect(value).toBeLessThanOrEqual(1);
|
|
109
|
+
expect(
|
|
110
|
+
create2dGeometry(
|
|
111
|
+
{ kind: 'rectangle', width: 1, height: 1 },
|
|
112
|
+
{ uv: { kind: 'circular-mask', angle: 0 } },
|
|
113
|
+
).ok,
|
|
114
|
+
).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('computes transformed AABB and bounding circle from the same shape contract', () => {
|
|
118
|
+
const result = compute2dBounds(
|
|
119
|
+
{ kind: 'circular-sector', radius: 2, angle: Math.PI / 2, resolution: 16 },
|
|
120
|
+
{ translation: [3, -4], rotation: Math.PI / 2 },
|
|
121
|
+
);
|
|
122
|
+
expect(result.ok).toBe(true);
|
|
123
|
+
if (!result.ok) return;
|
|
124
|
+
expect(result.value.aabb[0]).toBeLessThanOrEqual(3);
|
|
125
|
+
expect(result.value.aabb[1]).toBeLessThanOrEqual(-4);
|
|
126
|
+
expect(result.value.aabb[2]).toBeGreaterThanOrEqual(3);
|
|
127
|
+
expect(result.value.aabb[3]).toBeGreaterThanOrEqual(-4);
|
|
128
|
+
expect(result.value.circle[0]).toBeCloseTo(3, 4);
|
|
129
|
+
expect(result.value.circle[1]).toBeCloseTo(-4, 4);
|
|
130
|
+
expect(result.value.circle[2]).toBeCloseTo(2, 4);
|
|
131
|
+
});
|
|
132
|
+
});
|