@forgeax/engine-geometry 0.0.0-dev.8d955ade1c79
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 +225 -0
- package/dist/.tsbuildinfo +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__/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 +54 -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,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildMeshAttributeMapForUvSets,
|
|
3
|
+
deriveVertexBufferLayout,
|
|
4
|
+
deriveVertexLayoutProjection,
|
|
5
|
+
deriveVertexLayoutProjectionFromMask,
|
|
6
|
+
packInterleavedVertexAttributes,
|
|
7
|
+
} from '@forgeax/engine-geometry';
|
|
8
|
+
import type { VertexAttributeMap } from '@forgeax/engine-types';
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
|
|
11
|
+
const makeBuffer = (): Float32Array => new Float32Array(0);
|
|
12
|
+
|
|
13
|
+
function makeCompleteAttributeMap(): VertexAttributeMap {
|
|
14
|
+
return {
|
|
15
|
+
position: makeBuffer(),
|
|
16
|
+
normal: makeBuffer(),
|
|
17
|
+
uv: makeBuffer(),
|
|
18
|
+
tangent: makeBuffer(),
|
|
19
|
+
skinIndex: new Uint16Array(0).buffer,
|
|
20
|
+
skinWeight: makeBuffer(),
|
|
21
|
+
uv1: makeBuffer(),
|
|
22
|
+
uv2: makeBuffer(),
|
|
23
|
+
uv3: makeBuffer(),
|
|
24
|
+
uv4: makeBuffer(),
|
|
25
|
+
uv5: makeBuffer(),
|
|
26
|
+
uv6: makeBuffer(),
|
|
27
|
+
uv7: makeBuffer(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('vertex attribute layout owner', () => {
|
|
32
|
+
it('derives the complete 13-key layout from the format owner', () => {
|
|
33
|
+
expect(deriveVertexBufferLayout(makeCompleteAttributeMap())).toEqual([
|
|
34
|
+
{
|
|
35
|
+
arrayStride: 128,
|
|
36
|
+
attributes: [
|
|
37
|
+
{ shaderLocation: 0, offset: 0, format: 'float32x3' },
|
|
38
|
+
{ shaderLocation: 1, offset: 12, format: 'float32x3' },
|
|
39
|
+
{ shaderLocation: 2, offset: 24, format: 'float32x2' },
|
|
40
|
+
{ shaderLocation: 3, offset: 32, format: 'float32x4' },
|
|
41
|
+
{ shaderLocation: 4, offset: 48, format: 'uint16x4' },
|
|
42
|
+
{ shaderLocation: 5, offset: 56, format: 'float32x4' },
|
|
43
|
+
{ shaderLocation: 6, offset: 72, format: 'float32x2' },
|
|
44
|
+
{ shaderLocation: 7, offset: 80, format: 'float32x2' },
|
|
45
|
+
{ shaderLocation: 8, offset: 88, format: 'float32x2' },
|
|
46
|
+
{ shaderLocation: 9, offset: 96, format: 'float32x2' },
|
|
47
|
+
{ shaderLocation: 10, offset: 104, format: 'float32x2' },
|
|
48
|
+
{ shaderLocation: 11, offset: 112, format: 'float32x2' },
|
|
49
|
+
{ shaderLocation: 12, offset: 120, format: 'float32x2' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('projects color as host location 13 without moving the existing 0..12 keys', () => {
|
|
56
|
+
const map: VertexAttributeMap = {
|
|
57
|
+
position: new Float32Array([0, 0, 0]),
|
|
58
|
+
normal: new Float32Array([0, 1, 0]),
|
|
59
|
+
uv: new Float32Array([0, 0]),
|
|
60
|
+
tangent: new Float32Array([1, 0, 0, 1]),
|
|
61
|
+
color: new Float32Array([0.25, 0.5, 0.75, 1]),
|
|
62
|
+
};
|
|
63
|
+
const projection = deriveVertexLayoutProjection(map);
|
|
64
|
+
|
|
65
|
+
expect(projection.attributes.map((entry) => entry.key)).toEqual([
|
|
66
|
+
'position',
|
|
67
|
+
'normal',
|
|
68
|
+
'uv',
|
|
69
|
+
'tangent',
|
|
70
|
+
'color',
|
|
71
|
+
]);
|
|
72
|
+
expect(projection.attributes.find((entry) => entry.key === 'color')).toMatchObject({
|
|
73
|
+
shaderLocation: 13,
|
|
74
|
+
offset: 48,
|
|
75
|
+
format: 'float32x4',
|
|
76
|
+
});
|
|
77
|
+
expect(projection.arrayStride).toBe(64);
|
|
78
|
+
expect(projection.mask).toBe(0x200f);
|
|
79
|
+
expect(projection.digest).toBe('vlp-v1-6586e1c9');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('keeps digest independent of attribute bytes and changes it when layout changes', () => {
|
|
83
|
+
const a: VertexAttributeMap = {
|
|
84
|
+
position: new Float32Array([0, 0, 0]),
|
|
85
|
+
normal: new Float32Array([0, 1, 0]),
|
|
86
|
+
uv: new Float32Array([0, 0]),
|
|
87
|
+
tangent: new Float32Array([1, 0, 0, 1]),
|
|
88
|
+
color: new Float32Array([1, 0, 0, 1]),
|
|
89
|
+
};
|
|
90
|
+
const b: VertexAttributeMap = { ...a, color: new Float32Array([0, 1, 0, 1]) };
|
|
91
|
+
const c: VertexAttributeMap = { ...a, uv1: new Float32Array([0, 0]) };
|
|
92
|
+
|
|
93
|
+
expect(deriveVertexLayoutProjection(a).digest).toBe(deriveVertexLayoutProjection(b).digest);
|
|
94
|
+
expect(deriveVertexLayoutProjection(a).digest).not.toBe(deriveVertexLayoutProjection(c).digest);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('reconstructs wire masks through the same canonical projection owner', () => {
|
|
98
|
+
const color = deriveVertexLayoutProjectionFromMask(1 << 13);
|
|
99
|
+
expect(color.ok).toBe(true);
|
|
100
|
+
if (!color.ok) return;
|
|
101
|
+
expect(color.value.attributes.map((entry) => entry.key)).toEqual(['color']);
|
|
102
|
+
|
|
103
|
+
const empty = deriveVertexLayoutProjectionFromMask(0);
|
|
104
|
+
expect(empty.ok).toBe(false);
|
|
105
|
+
if (!empty.ok) expect(empty.error.detail.reason).toBe('empty');
|
|
106
|
+
|
|
107
|
+
const unknown = deriveVertexLayoutProjectionFromMask(1 << 14);
|
|
108
|
+
expect(unknown.ok).toBe(false);
|
|
109
|
+
if (!unknown.ok) expect(unknown.error.detail.reason).toBe('unknown-bits');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('packs color/no-color using one projection and keeps no-color bytes unchanged', () => {
|
|
113
|
+
const common: VertexAttributeMap = {
|
|
114
|
+
position: new Float32Array([0, 0, 0, 1, 0, 0]),
|
|
115
|
+
normal: new Float32Array([0, 1, 0, 0, 1, 0]),
|
|
116
|
+
uv: new Float32Array([0, 0, 1, 0]),
|
|
117
|
+
tangent: new Float32Array([1, 0, 0, 1, 1, 0, 0, 1]),
|
|
118
|
+
};
|
|
119
|
+
const plain = packInterleavedVertexAttributes(common, 2);
|
|
120
|
+
const colored = packInterleavedVertexAttributes(
|
|
121
|
+
{ ...common, color: new Float32Array([1, 0, 0, 1, 0, 1, 0, 1]) },
|
|
122
|
+
2,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(plain.ok).toBe(true);
|
|
126
|
+
expect(colored.ok).toBe(true);
|
|
127
|
+
if (!plain.ok || !colored.ok) return;
|
|
128
|
+
expect(plain.value.projection.arrayStride).toBe(48);
|
|
129
|
+
expect(colored.value.projection.arrayStride).toBe(64);
|
|
130
|
+
expect(plain.value.vertices.byteLength).toBe(96);
|
|
131
|
+
expect(colored.value.vertices.byteLength).toBe(128);
|
|
132
|
+
expect(Array.from(colored.value.vertices.slice(12, 16))).toEqual([1, 0, 0, 1]);
|
|
133
|
+
expect(Array.from(colored.value.vertices.slice(28, 32))).toEqual([0, 1, 0, 1]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('rejects non-RGBA, non-finite, and cardinality-mismatched color', () => {
|
|
137
|
+
expect(deriveVertexLayoutProjection({ color: new Float32Array([1, 0, 0]) }).arrayStride).toBe(
|
|
138
|
+
16,
|
|
139
|
+
);
|
|
140
|
+
const nonFinite = packInterleavedVertexAttributes(
|
|
141
|
+
{ color: new Float32Array([1, Number.NaN, 0, 1]) },
|
|
142
|
+
1,
|
|
143
|
+
);
|
|
144
|
+
expect(nonFinite.ok).toBe(false);
|
|
145
|
+
if (!nonFinite.ok)
|
|
146
|
+
expect(nonFinite.error.detail).toMatchObject({ reason: 'attribute-non-finite' });
|
|
147
|
+
const mismatched = packInterleavedVertexAttributes({ color: new Float32Array(4) }, 2);
|
|
148
|
+
expect(mismatched.ok).toBe(false);
|
|
149
|
+
if (!mismatched.ok)
|
|
150
|
+
expect(mismatched.error.detail).toMatchObject({ reason: 'attribute-cardinality-mismatch' });
|
|
151
|
+
const unaligned = packInterleavedVertexAttributes(
|
|
152
|
+
{ color: new ArrayBuffer(3) } as unknown as VertexAttributeMap,
|
|
153
|
+
1,
|
|
154
|
+
);
|
|
155
|
+
expect(unaligned.ok).toBe(false);
|
|
156
|
+
if (!unaligned.ok)
|
|
157
|
+
expect(unaligned.error.detail).toMatchObject({ reason: 'attribute-cardinality-mismatch' });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('derives multi-UV keys and clamps missing shader sets to the last mesh set', () => {
|
|
161
|
+
const map = buildMeshAttributeMapForUvSets(4);
|
|
162
|
+
|
|
163
|
+
expect(Object.keys(map)).toEqual(['position', 'normal', 'uv', 'tangent', 'uv1', 'uv2', 'uv3']);
|
|
164
|
+
expect(deriveVertexBufferLayout(map, { shaderUvSetCount: 6 })).toEqual([
|
|
165
|
+
{
|
|
166
|
+
arrayStride: 72,
|
|
167
|
+
attributes: [
|
|
168
|
+
{ shaderLocation: 0, offset: 0, format: 'float32x3' },
|
|
169
|
+
{ shaderLocation: 1, offset: 12, format: 'float32x3' },
|
|
170
|
+
{ shaderLocation: 2, offset: 24, format: 'float32x2' },
|
|
171
|
+
{ shaderLocation: 3, offset: 32, format: 'float32x4' },
|
|
172
|
+
{ shaderLocation: 6, offset: 48, format: 'float32x2' },
|
|
173
|
+
{ shaderLocation: 7, offset: 56, format: 'float32x2' },
|
|
174
|
+
{ shaderLocation: 8, offset: 64, format: 'float32x2' },
|
|
175
|
+
{ shaderLocation: 9, offset: 64, format: 'float32x2' },
|
|
176
|
+
{ shaderLocation: 10, offset: 64, format: 'float32x2' },
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeMeshBinHeader,
|
|
3
|
+
MESH_BIN_HEADER_V4_BYTES,
|
|
4
|
+
MESH_BIN_VERSION,
|
|
5
|
+
type MeshBinHeaderV4,
|
|
6
|
+
} from '@forgeax/engine-pack';
|
|
7
|
+
import type {
|
|
8
|
+
AssetGuid,
|
|
9
|
+
MeshAsset,
|
|
10
|
+
MeshMaterialSlot,
|
|
11
|
+
MorphTarget,
|
|
12
|
+
Submesh,
|
|
13
|
+
VertexAttributeMap,
|
|
14
|
+
} from '@forgeax/engine-types';
|
|
15
|
+
import { PROCEDURAL_FLOATS_PER_VERTEX } from '../box';
|
|
16
|
+
import {
|
|
17
|
+
deriveVertexLayoutProjectionFromMask,
|
|
18
|
+
type VertexLayoutProjection,
|
|
19
|
+
} from '../vertex-attribute-layout';
|
|
20
|
+
|
|
21
|
+
interface PackedMesh {
|
|
22
|
+
readonly version: 4;
|
|
23
|
+
readonly vertices: Float32Array;
|
|
24
|
+
readonly attributes?: VertexAttributeMap;
|
|
25
|
+
readonly indices?: Uint16Array | Uint32Array;
|
|
26
|
+
readonly submeshes?: readonly Record<string, unknown>[];
|
|
27
|
+
readonly materialSlots?: readonly {
|
|
28
|
+
readonly slotName: string;
|
|
29
|
+
readonly sourceKey?: string;
|
|
30
|
+
readonly defaultMaterialRef?: number;
|
|
31
|
+
}[];
|
|
32
|
+
readonly aabb?: Float32Array;
|
|
33
|
+
readonly skinIndex?: Uint16Array;
|
|
34
|
+
readonly skinWeight?: Float32Array;
|
|
35
|
+
readonly uvSetCount: number;
|
|
36
|
+
readonly floatsPerVertex: number;
|
|
37
|
+
readonly morphTargets?: readonly MorphTarget[];
|
|
38
|
+
readonly morphWeights?: Float32Array;
|
|
39
|
+
readonly projection?: VertexLayoutProjection;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseGuid(value: string): AssetGuid | undefined {
|
|
43
|
+
const compact = value.replaceAll('-', '');
|
|
44
|
+
if (!/^[0-9a-f]{32}$/i.test(compact)) return undefined;
|
|
45
|
+
const bytes = new Uint8Array(16);
|
|
46
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
47
|
+
const pair = compact.slice(index * 2, index * 2 + 2);
|
|
48
|
+
const parsed = Number.parseInt(pair, 16);
|
|
49
|
+
if (!Number.isInteger(parsed)) return undefined;
|
|
50
|
+
bytes[index] = parsed;
|
|
51
|
+
}
|
|
52
|
+
return bytes as AssetGuid;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function floatArray(value: unknown): Float32Array | undefined {
|
|
56
|
+
if (value instanceof Float32Array) return value;
|
|
57
|
+
if (Array.isArray(value)) return new Float32Array(value as number[]);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function indexArray(value: unknown): Uint16Array | Uint32Array | undefined {
|
|
62
|
+
if (value instanceof Uint16Array || value instanceof Uint32Array) {
|
|
63
|
+
return value.length === 0 ? undefined : value;
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(value)) return undefined;
|
|
66
|
+
if (value.length === 0) return undefined;
|
|
67
|
+
return value.some((entry) => entry > 0xffff)
|
|
68
|
+
? new Uint32Array(value as number[])
|
|
69
|
+
: new Uint16Array(value as number[]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function integerArray(value: unknown): Uint16Array | undefined {
|
|
73
|
+
if (value instanceof Uint16Array) return value;
|
|
74
|
+
if (Array.isArray(value)) return new Uint16Array(value as number[]);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function morphTargets(value: unknown): readonly MorphTarget[] | undefined {
|
|
79
|
+
if (value === undefined) return undefined;
|
|
80
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 8) return undefined;
|
|
81
|
+
const targets: MorphTarget[] = [];
|
|
82
|
+
for (const raw of value) {
|
|
83
|
+
if (raw === null || typeof raw !== 'object') return undefined;
|
|
84
|
+
const target: { position?: Float32Array; normal?: Float32Array; tangent?: Float32Array } = {};
|
|
85
|
+
for (const [key, stream] of Object.entries(raw as Record<string, unknown>)) {
|
|
86
|
+
if (key !== 'position' && key !== 'normal' && key !== 'tangent') return undefined;
|
|
87
|
+
const typed = floatArray(stream);
|
|
88
|
+
if (typed === undefined) return undefined;
|
|
89
|
+
if (key === 'position') target.position = typed;
|
|
90
|
+
else if (key === 'normal') target.normal = typed;
|
|
91
|
+
else target.tangent = typed;
|
|
92
|
+
}
|
|
93
|
+
if (Object.keys(target).length === 0) return undefined;
|
|
94
|
+
targets.push(target);
|
|
95
|
+
}
|
|
96
|
+
return targets;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function unpackMeshBinary(bytes: Uint8Array): PackedMesh | undefined {
|
|
100
|
+
const headerResult = decodeMeshBinHeader(bytes);
|
|
101
|
+
if (!headerResult.ok) return undefined;
|
|
102
|
+
const header: MeshBinHeaderV4 = headerResult.value;
|
|
103
|
+
const projectionResult = deriveVertexLayoutProjectionFromMask(header.mask);
|
|
104
|
+
if (!projectionResult.ok) return undefined;
|
|
105
|
+
const projection = projectionResult.value;
|
|
106
|
+
if (
|
|
107
|
+
projection.schemaVersion !== header.projectionVersion ||
|
|
108
|
+
projection.arrayStride !== header.stride ||
|
|
109
|
+
projection.digest !== header.digest
|
|
110
|
+
) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const payloadBytes = header.vertexBytes + header.indexBytes + header.jsonBytes;
|
|
114
|
+
if (MESH_BIN_HEADER_V4_BYTES + payloadBytes !== bytes.byteLength) return undefined;
|
|
115
|
+
|
|
116
|
+
let offset = MESH_BIN_HEADER_V4_BYTES;
|
|
117
|
+
const vertexBytes = bytes.subarray(offset, offset + header.vertexBytes);
|
|
118
|
+
const vertices = new Float32Array(vertexBytes.byteLength / 4);
|
|
119
|
+
new Uint8Array(vertices.buffer).set(vertexBytes);
|
|
120
|
+
offset += header.vertexBytes;
|
|
121
|
+
let indices: Uint16Array | Uint32Array | undefined;
|
|
122
|
+
if (header.indexCount > 0) {
|
|
123
|
+
const indexBytes = bytes.subarray(offset, offset + header.indexBytes);
|
|
124
|
+
if (header.indexWidth === 2) {
|
|
125
|
+
indices = new Uint16Array(header.indexCount);
|
|
126
|
+
new Uint8Array(indices.buffer).set(indexBytes);
|
|
127
|
+
} else if (header.indexWidth === 4) {
|
|
128
|
+
indices = new Uint32Array(header.indexCount);
|
|
129
|
+
new Uint8Array(indices.buffer).set(indexBytes);
|
|
130
|
+
} else {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
offset += header.indexBytes;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let metadata: {
|
|
137
|
+
readonly submeshes?: readonly Record<string, unknown>[];
|
|
138
|
+
readonly materialSlots?: PackedMesh['materialSlots'];
|
|
139
|
+
readonly aabb?: readonly number[];
|
|
140
|
+
readonly morphTargets?: readonly Record<string, readonly number[]>[];
|
|
141
|
+
readonly morphWeights?: readonly number[];
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
metadata = JSON.parse(
|
|
145
|
+
new TextDecoder().decode(bytes.subarray(offset, offset + header.jsonBytes)),
|
|
146
|
+
) as typeof metadata;
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
metadata.submeshes === undefined ||
|
|
152
|
+
metadata.submeshes.length === 0 ||
|
|
153
|
+
metadata.materialSlots === undefined
|
|
154
|
+
) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
const attributes: Record<string, Float32Array | Uint16Array> = {};
|
|
158
|
+
const view = new DataView(vertices.buffer);
|
|
159
|
+
for (const entry of projection.attributes) {
|
|
160
|
+
const components = entry.byteLength / (entry.format === 'uint16x4' ? 2 : 4);
|
|
161
|
+
const target =
|
|
162
|
+
entry.format === 'uint16x4'
|
|
163
|
+
? new Uint16Array(header.vertexCount * components)
|
|
164
|
+
: new Float32Array(header.vertexCount * components);
|
|
165
|
+
for (let vertex = 0; vertex < header.vertexCount; vertex += 1) {
|
|
166
|
+
for (let component = 0; component < components; component += 1) {
|
|
167
|
+
const sourceOffset =
|
|
168
|
+
vertex * header.stride + entry.offset + component * (entry.format === 'uint16x4' ? 2 : 4);
|
|
169
|
+
if (sourceOffset + (entry.format === 'uint16x4' ? 2 : 4) > vertices.byteLength) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
if (target instanceof Uint16Array) {
|
|
173
|
+
target[vertex * components + component] = view.getUint16(sourceOffset, true);
|
|
174
|
+
} else {
|
|
175
|
+
const value = view.getFloat32(sourceOffset, true);
|
|
176
|
+
if (!Number.isFinite(value)) return undefined;
|
|
177
|
+
target[vertex * components + component] = value;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
attributes[entry.key] = target;
|
|
182
|
+
}
|
|
183
|
+
const decodedMorphTargets = morphTargets(metadata.morphTargets);
|
|
184
|
+
const morphWeightValues =
|
|
185
|
+
metadata.morphWeights === undefined ? undefined : new Float32Array(metadata.morphWeights);
|
|
186
|
+
if (
|
|
187
|
+
decodedMorphTargets !== undefined &&
|
|
188
|
+
morphWeightValues !== undefined &&
|
|
189
|
+
decodedMorphTargets.length !== morphWeightValues.length
|
|
190
|
+
) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
version: MESH_BIN_VERSION,
|
|
195
|
+
vertices,
|
|
196
|
+
attributes: attributes as VertexAttributeMap,
|
|
197
|
+
projection,
|
|
198
|
+
...(indices === undefined ? {} : { indices }),
|
|
199
|
+
submeshes: metadata.submeshes,
|
|
200
|
+
materialSlots: metadata.materialSlots,
|
|
201
|
+
...(metadata.aabb === undefined ? {} : { aabb: new Float32Array(metadata.aabb) }),
|
|
202
|
+
// MeshAsset.vertices remains a Float32Array, so translate the wire stride
|
|
203
|
+
// (bytes) back to the legacy float-count input expected by meshFromParts.
|
|
204
|
+
floatsPerVertex: header.stride / Float32Array.BYTES_PER_ELEMENT,
|
|
205
|
+
uvSetCount: 1,
|
|
206
|
+
...(decodedMorphTargets === undefined ? {} : { morphTargets: decodedMorphTargets }),
|
|
207
|
+
...(morphWeightValues === undefined ? {} : { morphWeights: morphWeightValues }),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function materialSlotsFor(
|
|
212
|
+
raw: PackedMesh['materialSlots'],
|
|
213
|
+
submeshes: readonly Submesh[],
|
|
214
|
+
refs: readonly string[],
|
|
215
|
+
): readonly MeshMaterialSlot[] {
|
|
216
|
+
if (raw === undefined)
|
|
217
|
+
return submeshes.map((_submesh, index) => ({ slotName: `LegacySlot_${index}` }));
|
|
218
|
+
return raw.map((slot, index) => {
|
|
219
|
+
const reference =
|
|
220
|
+
slot.defaultMaterialRef === undefined ? undefined : refs[slot.defaultMaterialRef];
|
|
221
|
+
const defaultMaterial = reference === undefined ? undefined : parseGuid(reference);
|
|
222
|
+
return {
|
|
223
|
+
slotName:
|
|
224
|
+
typeof slot.slotName === 'string' && slot.slotName.trim().length > 0
|
|
225
|
+
? slot.slotName
|
|
226
|
+
: `LegacySlot_${index}`,
|
|
227
|
+
...(typeof slot.sourceKey === 'string' ? { sourceKey: slot.sourceKey } : {}),
|
|
228
|
+
...(defaultMaterial === undefined ? {} : { defaultMaterial }),
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function submeshesFor(
|
|
234
|
+
raw: readonly Record<string, unknown>[] | undefined,
|
|
235
|
+
vertexCount: number,
|
|
236
|
+
indexCount: number,
|
|
237
|
+
): readonly Submesh[] {
|
|
238
|
+
const values =
|
|
239
|
+
raw === undefined || raw.length === 0
|
|
240
|
+
? [{ indexOffset: 0, indexCount, vertexCount, topology: 'triangle-list' }]
|
|
241
|
+
: raw;
|
|
242
|
+
return values.map((value, index) => ({
|
|
243
|
+
indexOffset: typeof value.indexOffset === 'number' ? value.indexOffset : 0,
|
|
244
|
+
indexCount: typeof value.indexCount === 'number' ? value.indexCount : indexCount,
|
|
245
|
+
vertexCount: typeof value.vertexCount === 'number' ? value.vertexCount : vertexCount,
|
|
246
|
+
topology:
|
|
247
|
+
value.topology === 'line-list' ||
|
|
248
|
+
value.topology === 'line-strip' ||
|
|
249
|
+
value.topology === 'point-list' ||
|
|
250
|
+
value.topology === 'triangle-strip'
|
|
251
|
+
? value.topology
|
|
252
|
+
: 'triangle-list',
|
|
253
|
+
materialSlot: typeof value.materialSlot === 'number' ? value.materialSlot : index,
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function deriveAabb(vertices: Float32Array, stride: number): Float32Array | undefined {
|
|
258
|
+
if (stride < 3 || vertices.length < 3 || vertices.length % stride !== 0) return undefined;
|
|
259
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
260
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
261
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
262
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
263
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
264
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
265
|
+
for (let offset = 0; offset < vertices.length; offset += stride) {
|
|
266
|
+
const x = vertices[offset];
|
|
267
|
+
const y = vertices[offset + 1];
|
|
268
|
+
const z = vertices[offset + 2];
|
|
269
|
+
if (x === undefined || y === undefined || z === undefined) return undefined;
|
|
270
|
+
minX = Math.min(minX, x);
|
|
271
|
+
minY = Math.min(minY, y);
|
|
272
|
+
minZ = Math.min(minZ, z);
|
|
273
|
+
maxX = Math.max(maxX, x);
|
|
274
|
+
maxY = Math.max(maxY, y);
|
|
275
|
+
maxZ = Math.max(maxZ, z);
|
|
276
|
+
}
|
|
277
|
+
return Float32Array.of(minX, minY, minZ, maxX, maxY, maxZ);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function meshFromParts(
|
|
281
|
+
parts: {
|
|
282
|
+
readonly vertices: unknown;
|
|
283
|
+
readonly indices?: unknown;
|
|
284
|
+
readonly attributes?: unknown;
|
|
285
|
+
readonly aabb?: unknown;
|
|
286
|
+
readonly submeshes?: readonly Record<string, unknown>[];
|
|
287
|
+
readonly materialSlots?: PackedMesh['materialSlots'];
|
|
288
|
+
readonly morphTargets?: unknown;
|
|
289
|
+
readonly morphWeights?: unknown;
|
|
290
|
+
readonly floatsPerVertex?: number;
|
|
291
|
+
readonly uvSetCount?: number;
|
|
292
|
+
},
|
|
293
|
+
refs: readonly string[],
|
|
294
|
+
): MeshAsset | undefined {
|
|
295
|
+
const vertices = floatArray(parts.vertices);
|
|
296
|
+
if (vertices === undefined || vertices.length === 0) return undefined;
|
|
297
|
+
const indices = indexArray(parts.indices);
|
|
298
|
+
const sourceAttributes =
|
|
299
|
+
parts.attributes !== null && typeof parts.attributes === 'object'
|
|
300
|
+
? (parts.attributes as Record<string, unknown>)
|
|
301
|
+
: {};
|
|
302
|
+
const attributes: VertexAttributeMap = {};
|
|
303
|
+
for (const key of [
|
|
304
|
+
'position',
|
|
305
|
+
'normal',
|
|
306
|
+
'uv',
|
|
307
|
+
'tangent',
|
|
308
|
+
'uv1',
|
|
309
|
+
'uv2',
|
|
310
|
+
'uv3',
|
|
311
|
+
'uv4',
|
|
312
|
+
'uv5',
|
|
313
|
+
'uv6',
|
|
314
|
+
'uv7',
|
|
315
|
+
'color',
|
|
316
|
+
] as const) {
|
|
317
|
+
const value = floatArray(sourceAttributes[key]);
|
|
318
|
+
if (value !== undefined) attributes[key] = value;
|
|
319
|
+
}
|
|
320
|
+
const skinIndex = integerArray(sourceAttributes.skinIndex);
|
|
321
|
+
const skinWeight = floatArray(sourceAttributes.skinWeight);
|
|
322
|
+
if (skinIndex !== undefined) attributes.skinIndex = skinIndex;
|
|
323
|
+
if (skinWeight !== undefined) attributes.skinWeight = skinWeight;
|
|
324
|
+
const stride = parts.floatsPerVertex ?? 0;
|
|
325
|
+
const vertexCount = stride > 0 ? vertices.length / stride : vertices.length;
|
|
326
|
+
const submeshes = submeshesFor(parts.submeshes, vertexCount, indices?.length ?? 0);
|
|
327
|
+
const materialSlots = materialSlotsFor(parts.materialSlots, submeshes, refs);
|
|
328
|
+
const aabb = floatArray(parts.aabb) ?? deriveAabb(vertices, stride);
|
|
329
|
+
if (aabb === undefined) return undefined;
|
|
330
|
+
const morph = morphTargets(parts.morphTargets);
|
|
331
|
+
const weights = floatArray(parts.morphWeights);
|
|
332
|
+
if (morph !== undefined && weights !== undefined && morph.length !== weights.length)
|
|
333
|
+
return undefined;
|
|
334
|
+
const uvSetCount = parts.uvSetCount ?? 1;
|
|
335
|
+
if (stride > 0 && uvSetCount > 1) {
|
|
336
|
+
const hasSkin = stride === 18 + (uvSetCount - 1) * 2;
|
|
337
|
+
const firstOffset = hasSkin ? 18 : PROCEDURAL_FLOATS_PER_VERTEX;
|
|
338
|
+
const extraUvKeys = ['uv1', 'uv2', 'uv3', 'uv4', 'uv5', 'uv6', 'uv7'] as const;
|
|
339
|
+
for (let set = 1; set < uvSetCount; set += 1) {
|
|
340
|
+
const key = extraUvKeys[set - 1];
|
|
341
|
+
if (key === undefined) return undefined;
|
|
342
|
+
const values = new Float32Array(vertexCount * 2);
|
|
343
|
+
const sourceOffset = firstOffset + (set - 1) * 2;
|
|
344
|
+
for (let vertex = 0; vertex < vertexCount; vertex += 1) {
|
|
345
|
+
const source = vertex * stride + sourceOffset;
|
|
346
|
+
values[vertex * 2] = vertices[source] ?? 0;
|
|
347
|
+
values[vertex * 2 + 1] = vertices[source + 1] ?? 0;
|
|
348
|
+
}
|
|
349
|
+
attributes[key] = values;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
kind: 'mesh',
|
|
354
|
+
vertices,
|
|
355
|
+
...(indices === undefined ? {} : { indices }),
|
|
356
|
+
attributes,
|
|
357
|
+
aabb,
|
|
358
|
+
submeshes,
|
|
359
|
+
materialSlots,
|
|
360
|
+
...(morph === undefined ? {} : { morphTargets: morph }),
|
|
361
|
+
...(weights === undefined ? {} : { morphWeights: weights }),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function decodeMeshBinary(
|
|
366
|
+
bytes: Uint8Array,
|
|
367
|
+
refs: readonly string[],
|
|
368
|
+
): MeshAsset | undefined {
|
|
369
|
+
const decoded = unpackMeshBinary(bytes);
|
|
370
|
+
if (decoded === undefined) return undefined;
|
|
371
|
+
return meshFromParts(
|
|
372
|
+
{
|
|
373
|
+
vertices: decoded.vertices,
|
|
374
|
+
...(decoded.attributes === undefined ? {} : { attributes: decoded.attributes }),
|
|
375
|
+
...(decoded.indices === undefined ? {} : { indices: decoded.indices }),
|
|
376
|
+
...(decoded.aabb === undefined ? {} : { aabb: decoded.aabb }),
|
|
377
|
+
...(decoded.submeshes === undefined ? {} : { submeshes: decoded.submeshes }),
|
|
378
|
+
...(decoded.materialSlots === undefined ? {} : { materialSlots: decoded.materialSlots }),
|
|
379
|
+
...(decoded.morphTargets === undefined ? {} : { morphTargets: decoded.morphTargets }),
|
|
380
|
+
...(decoded.morphWeights === undefined ? {} : { morphWeights: decoded.morphWeights }),
|
|
381
|
+
...(decoded.skinIndex === undefined && decoded.skinWeight === undefined
|
|
382
|
+
? {}
|
|
383
|
+
: {
|
|
384
|
+
attributes: {
|
|
385
|
+
...(decoded.skinIndex === undefined ? {} : { skinIndex: decoded.skinIndex }),
|
|
386
|
+
...(decoded.skinWeight === undefined ? {} : { skinWeight: decoded.skinWeight }),
|
|
387
|
+
},
|
|
388
|
+
}),
|
|
389
|
+
floatsPerVertex: decoded.floatsPerVertex,
|
|
390
|
+
uvSetCount: decoded.uvSetCount,
|
|
391
|
+
},
|
|
392
|
+
refs,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function normalizeMeshPayload(
|
|
397
|
+
payload: unknown,
|
|
398
|
+
refs: readonly string[],
|
|
399
|
+
): MeshAsset | undefined {
|
|
400
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return undefined;
|
|
401
|
+
const source = payload as Record<string, unknown>;
|
|
402
|
+
if (source.kind !== 'mesh') return undefined;
|
|
403
|
+
return meshFromParts(
|
|
404
|
+
{
|
|
405
|
+
vertices: source.vertices,
|
|
406
|
+
...(source.indices === undefined ? {} : { indices: source.indices }),
|
|
407
|
+
...(source.attributes === undefined ? {} : { attributes: source.attributes }),
|
|
408
|
+
...(source.aabb === undefined ? {} : { aabb: source.aabb }),
|
|
409
|
+
submeshes: Array.isArray(source.submeshes)
|
|
410
|
+
? (source.submeshes as Record<string, unknown>[])
|
|
411
|
+
: [],
|
|
412
|
+
...(Array.isArray(source.materialSlots)
|
|
413
|
+
? { materialSlots: source.materialSlots as PackedMesh['materialSlots'] }
|
|
414
|
+
: {}),
|
|
415
|
+
...(source.morphTargets === undefined ? {} : { morphTargets: source.morphTargets }),
|
|
416
|
+
...(source.morphWeights === undefined ? {} : { morphWeights: source.morphWeights }),
|
|
417
|
+
floatsPerVertex: PROCEDURAL_FLOATS_PER_VERTEX,
|
|
418
|
+
},
|
|
419
|
+
refs,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AssetDecoder,
|
|
3
|
+
type AssetDecoderContribution,
|
|
4
|
+
type AssetKind,
|
|
5
|
+
type AssetLoadError,
|
|
6
|
+
err,
|
|
7
|
+
type MeshAsset,
|
|
8
|
+
ok,
|
|
9
|
+
type Result,
|
|
10
|
+
} from '@forgeax/engine-types';
|
|
11
|
+
import { decodeMeshBinary, normalizeMeshPayload } from './mesh-binary';
|
|
12
|
+
import { createPrimitiveMesh, type PrimitiveMeshKind } from './primitive-mesh';
|
|
13
|
+
|
|
14
|
+
export const meshAssetKind: AssetKind<MeshAsset, 'mesh'> = {
|
|
15
|
+
kind: 'mesh',
|
|
16
|
+
} as AssetKind<MeshAsset, 'mesh'>;
|
|
17
|
+
|
|
18
|
+
const PROCEDURAL_MESH_KINDS: Readonly<Record<string, PrimitiveMeshKind>> = {
|
|
19
|
+
'procedural-cube': 'cube',
|
|
20
|
+
'procedural-triangle': 'triangle',
|
|
21
|
+
'procedural-quad': 'quad',
|
|
22
|
+
'procedural-sphere': 'sphere',
|
|
23
|
+
'procedural-cylinder': 'cylinder',
|
|
24
|
+
'procedural-nine-slice-quad': 'nine-slice-quad',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function proceduralMesh(payload: unknown): Result<MeshAsset, AssetLoadError> | undefined {
|
|
28
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return undefined;
|
|
29
|
+
const geometry = (payload as { readonly geometry?: unknown }).geometry;
|
|
30
|
+
if (typeof geometry !== 'string') return undefined;
|
|
31
|
+
const kind = PROCEDURAL_MESH_KINDS[geometry];
|
|
32
|
+
if (kind === undefined) return undefined;
|
|
33
|
+
const result = createPrimitiveMesh(kind);
|
|
34
|
+
if (result.ok) return result;
|
|
35
|
+
return err({
|
|
36
|
+
code: 'asset-package-invalid',
|
|
37
|
+
expected: `a valid procedural ${kind} mesh payload`,
|
|
38
|
+
hint: 'recook the authored procedural mesh descriptor',
|
|
39
|
+
detail: { guid: '', reason: 'procedural mesh creation failed' },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Geometry validates mesh payload shape before any render owner consumes it. */
|
|
44
|
+
export const meshAssetDecoder: AssetDecoder<MeshAsset> = {
|
|
45
|
+
async decode({ envelope, artifacts }): Promise<Result<MeshAsset, AssetLoadError>> {
|
|
46
|
+
const payload = envelope.payload;
|
|
47
|
+
const procedural = proceduralMesh(payload);
|
|
48
|
+
if (procedural !== undefined) {
|
|
49
|
+
if (!procedural.ok) {
|
|
50
|
+
return err<AssetLoadError>({
|
|
51
|
+
code: 'asset-package-invalid',
|
|
52
|
+
expected: procedural.error.expected,
|
|
53
|
+
hint: procedural.error.hint,
|
|
54
|
+
detail: { guid: envelope.guid, reason: 'procedural mesh creation failed' },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return procedural;
|
|
58
|
+
}
|
|
59
|
+
const body = envelope.artifacts.body;
|
|
60
|
+
if (body !== undefined) {
|
|
61
|
+
const bytes = await artifacts.read(body);
|
|
62
|
+
if (!bytes.ok) return err<AssetLoadError>(bytes.error);
|
|
63
|
+
const decoded = decodeMeshBinary(bytes.value, envelope.refs);
|
|
64
|
+
if (decoded === undefined) {
|
|
65
|
+
return err({
|
|
66
|
+
code: 'asset-package-invalid',
|
|
67
|
+
expected: 'a valid mesh-binary/4 body artifact with a local-space AABB',
|
|
68
|
+
hint: 'recook the mesh binary and publish its validated geometry payload',
|
|
69
|
+
detail: { guid: envelope.guid, reason: 'mesh binary decode failed' },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return ok(decoded);
|
|
73
|
+
}
|
|
74
|
+
const normalized = normalizeMeshPayload(payload, envelope.refs);
|
|
75
|
+
if (normalized !== undefined) return ok(normalized);
|
|
76
|
+
if (
|
|
77
|
+
payload.kind !== 'mesh' ||
|
|
78
|
+
!(payload.vertices instanceof Float32Array) ||
|
|
79
|
+
payload.vertices.length === 0 ||
|
|
80
|
+
payload.aabb === undefined
|
|
81
|
+
) {
|
|
82
|
+
return err({
|
|
83
|
+
code: 'asset-package-invalid',
|
|
84
|
+
expected: 'a mesh payload with vertices and a local-space AABB',
|
|
85
|
+
hint: 'recook the mesh binary and publish its validated geometry payload',
|
|
86
|
+
detail: { guid: envelope.guid, reason: 'mesh payload failed geometry validation' },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return ok(payload);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const meshAssetContribution: AssetDecoderContribution<MeshAsset, 'mesh'> = {
|
|
94
|
+
kind: meshAssetKind,
|
|
95
|
+
decoder: meshAssetDecoder,
|
|
96
|
+
consumer: 'Geometry',
|
|
97
|
+
};
|