@driftengine/assets 3.61.0
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/NOTICE +9 -0
- package/README.md +9 -0
- package/dist/assetPath.d.ts +51 -0
- package/dist/assetPath.js +59 -0
- package/dist/coarseLevel.d.ts +94 -0
- package/dist/coarseLevel.js +606 -0
- package/dist/dds.d.ts +41 -0
- package/dist/dds.js +425 -0
- package/dist/drawKey.d.ts +64 -0
- package/dist/drawKey.js +42 -0
- package/dist/drftLoader.d.ts +420 -0
- package/dist/drftLoader.js +996 -0
- package/dist/drftTextures.d.ts +87 -0
- package/dist/drftTextures.js +138 -0
- package/dist/fbx.d.ts +86 -0
- package/dist/fbx.js +792 -0
- package/dist/fbxHarness.d.ts +68 -0
- package/dist/fbxHarness.js +185 -0
- package/dist/fbxInflate.d.ts +60 -0
- package/dist/fbxInflate.js +204 -0
- package/dist/fbxMaterial.d.ts +87 -0
- package/dist/fbxMaterial.js +345 -0
- package/dist/fbxRig.d.ts +130 -0
- package/dist/fbxRig.js +601 -0
- package/dist/gltf.d.ts +242 -0
- package/dist/gltf.js +904 -0
- package/dist/gltfAccessor.d.ts +2 -0
- package/dist/gltfAccessor.js +106 -0
- package/dist/gltfSkin.d.ts +32 -0
- package/dist/gltfSkin.js +144 -0
- package/dist/iesPhotometry.d.ts +44 -0
- package/dist/iesPhotometry.js +94 -0
- package/dist/imageInfo.d.ts +31 -0
- package/dist/imageInfo.js +119 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +132 -0
- package/dist/kn5.d.ts +158 -0
- package/dist/kn5.js +557 -0
- package/dist/loadProgress.d.ts +118 -0
- package/dist/loadProgress.js +1 -0
- package/dist/localise.d.ts +35 -0
- package/dist/localise.js +253 -0
- package/dist/obj.d.ts +54 -0
- package/dist/obj.js +335 -0
- package/dist/orient.d.ts +104 -0
- package/dist/orient.js +241 -0
- package/dist/radianceHdr.d.ts +36 -0
- package/dist/radianceHdr.js +172 -0
- package/dist/readModel.d.ts +153 -0
- package/dist/readModel.js +386 -0
- package/dist/recognise.d.ts +38 -0
- package/dist/recognise.js +97 -0
- package/dist/stl.d.ts +22 -0
- package/dist/stl.js +131 -0
- package/dist/tangentOrder.d.ts +24 -0
- package/dist/tangentOrder.js +31 -0
- package/dist/threemf.d.ts +46 -0
- package/dist/threemf.js +254 -0
- package/dist/uploadBudget.d.ts +82 -0
- package/dist/uploadBudget.js +86 -0
- package/dist/usd.d.ts +49 -0
- package/dist/usd.js +542 -0
- package/dist/weld.d.ts +44 -0
- package/dist/weld.js +237 -0
- package/dist/zip.d.ts +35 -0
- package/dist/zip.js +110 -0
- package/package.json +61 -0
- package/src/assetPath.ts +79 -0
- package/src/coarseLevel.ts +720 -0
- package/src/dds.ts +507 -0
- package/src/drawKey.ts +91 -0
- package/src/drftLoader.ts +1197 -0
- package/src/drftTextures.ts +153 -0
- package/src/fbx.ts +934 -0
- package/src/fbxHarness.ts +215 -0
- package/src/fbxInflate.ts +258 -0
- package/src/fbxMaterial.ts +410 -0
- package/src/fbxRig.ts +652 -0
- package/src/gltf.ts +1161 -0
- package/src/gltfAccessor.ts +127 -0
- package/src/gltfSkin.ts +210 -0
- package/src/iesPhotometry.ts +136 -0
- package/src/imageInfo.ts +130 -0
- package/src/index.ts +162 -0
- package/src/kn5.ts +705 -0
- package/src/loadProgress.ts +131 -0
- package/src/localise.ts +275 -0
- package/src/obj.ts +386 -0
- package/src/orient.ts +262 -0
- package/src/radianceHdr.ts +195 -0
- package/src/readModel.ts +515 -0
- package/src/recognise.ts +124 -0
- package/src/stl.ts +147 -0
- package/src/tangentOrder.ts +32 -0
- package/src/threemf.ts +303 -0
- package/src/uploadBudget.ts +90 -0
- package/src/usd.ts +642 -0
- package/src/weld.ts +264 -0
- package/src/zip.ts +130 -0
package/src/obj.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wavefront OBJ, with its `.mtl` companion.
|
|
3
|
+
*
|
|
4
|
+
* Tier 1, and the cheapest thing on the list to put there: the format was specified in the
|
|
5
|
+
* eighties, has not changed since, and is plain text. There is no version to track and no
|
|
6
|
+
* vendor to follow, so a complete reader written once stays correct — which is worth more
|
|
7
|
+
* than a partial reader for a format that moves.
|
|
8
|
+
*
|
|
9
|
+
* One `MeshData` per material group, matching the glTF path: a group is the unit carrying
|
|
10
|
+
* a single material, and `MeshData` holds one material's worth of vertex attributes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { MeshData } from '@driftengine/drft';
|
|
14
|
+
import { DrftError } from '@driftengine/drft';
|
|
15
|
+
import type { DrftMaterial } from '@driftengine/drft';
|
|
16
|
+
import type { AssetReference } from './assetPath.ts';
|
|
17
|
+
|
|
18
|
+
interface ObjMaterial {
|
|
19
|
+
diffuse: [number, number, number];
|
|
20
|
+
emissive: [number, number, number];
|
|
21
|
+
/** Highlight strength, from `Ks`/`Ns` or the PBR `Pm` extension. */
|
|
22
|
+
specular: number;
|
|
23
|
+
/** Highlight width, from the PBR `Pr` extension, or derived from `Ns`. */
|
|
24
|
+
roughness: number;
|
|
25
|
+
/** The colour map this material names, as declared, or null. */
|
|
26
|
+
albedo: string | null;
|
|
27
|
+
/** 1 is opaque. `d` states it directly and `Tr` states its inverse. */
|
|
28
|
+
opacity: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_MATERIAL: ObjMaterial = {
|
|
32
|
+
diffuse: [0.8, 0.8, 0.8],
|
|
33
|
+
emissive: [0, 0, 0],
|
|
34
|
+
specular: 0,
|
|
35
|
+
roughness: 0.5,
|
|
36
|
+
albedo: null,
|
|
37
|
+
opacity: 1,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const clamp01 = (value: number): number =>
|
|
41
|
+
Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 1;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The filename out of a `map_*` line, past whatever options precede it.
|
|
45
|
+
*
|
|
46
|
+
* A map line is not just a path: `map_Kd -s 1 1 1 -bm 0.2 brick.png` is legal and common,
|
|
47
|
+
* and taking `parts[1]` gets `-s`. The options are skipped by shape rather than by a table
|
|
48
|
+
* of every flag, since the flags differ per exporter and their arity is not consistent:
|
|
49
|
+
* anything that begins with a dash, parses as a number, or is `on`/`off` is an option or an
|
|
50
|
+
* option's argument, and the first token that is none of those begins the name.
|
|
51
|
+
*
|
|
52
|
+
* The rest is joined rather than taken as one token, because a texture path may contain
|
|
53
|
+
* spaces and the line has already been split on them.
|
|
54
|
+
*/
|
|
55
|
+
function mapPath(parts: readonly string[]): string {
|
|
56
|
+
let at = 1;
|
|
57
|
+
while (at < parts.length) {
|
|
58
|
+
const token = parts[at] as string;
|
|
59
|
+
if (
|
|
60
|
+
token.startsWith('-') ||
|
|
61
|
+
token === 'on' ||
|
|
62
|
+
token === 'off' ||
|
|
63
|
+
!Number.isNaN(Number(token))
|
|
64
|
+
) {
|
|
65
|
+
at++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
const name = parts.slice(at).join(' ').trim();
|
|
71
|
+
return name;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a `.mtl`.
|
|
76
|
+
*
|
|
77
|
+
* `Pr` and `Pm` are the widely used PBR extension rather than the original specification,
|
|
78
|
+
* and they are preferred where present because they say directly what this engine shades
|
|
79
|
+
* with. Where they are absent, `Ns` — a Phong exponent — is converted, since an exponent
|
|
80
|
+
* and a roughness describe the same thing from opposite ends.
|
|
81
|
+
*
|
|
82
|
+
* `d` and `Tr` are the same quantity written in opposite directions: `d` is how much of the
|
|
83
|
+
* surface is *there* and `Tr` is how much of it is not. Reading either as the other inverts
|
|
84
|
+
* every transparent material in a file, turning solid geometry invisible and glass solid,
|
|
85
|
+
* so both are read explicitly rather than one being assumed.
|
|
86
|
+
*/
|
|
87
|
+
export function parseMtl(text: string): Map<string, ObjMaterial> {
|
|
88
|
+
const materials = new Map<string, ObjMaterial>();
|
|
89
|
+
let current: ObjMaterial | null = null;
|
|
90
|
+
|
|
91
|
+
for (const rawLine of text.split('\n')) {
|
|
92
|
+
const line = rawLine.trim();
|
|
93
|
+
if (line.length === 0 || line.startsWith('#')) continue;
|
|
94
|
+
const parts = line.split(/\s+/);
|
|
95
|
+
const keyword = parts[0] as string;
|
|
96
|
+
const numbers = parts.slice(1).map(Number);
|
|
97
|
+
|
|
98
|
+
if (keyword === 'newmtl') {
|
|
99
|
+
current = { ...DEFAULT_MATERIAL, diffuse: [0.8, 0.8, 0.8], emissive: [0, 0, 0] };
|
|
100
|
+
materials.set(parts.slice(1).join(' '), current);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (current === null) continue;
|
|
104
|
+
|
|
105
|
+
if (keyword === 'Kd') current.diffuse = [numbers[0] ?? 0, numbers[1] ?? 0, numbers[2] ?? 0];
|
|
106
|
+
else if (keyword === 'Ke')
|
|
107
|
+
current.emissive = [numbers[0] ?? 0, numbers[1] ?? 0, numbers[2] ?? 0];
|
|
108
|
+
else if (keyword === 'Pr') current.roughness = numbers[0] ?? 0.5;
|
|
109
|
+
else if (keyword === 'Pm') current.specular = numbers[0] ?? 0;
|
|
110
|
+
else if (keyword === 'Ns') {
|
|
111
|
+
/*
|
|
112
|
+
* A Phong exponent is a width expressed backwards: 0 is a mirror-flat surface with a
|
|
113
|
+
* broad lobe and 1000 is a pinpoint. Mapped rather than ignored, so a file with no
|
|
114
|
+
* PBR extension still arrives with its highlights roughly the right size.
|
|
115
|
+
*/
|
|
116
|
+
const exponent = Math.max(numbers[0] ?? 0, 0);
|
|
117
|
+
current.roughness = Math.min(1, Math.max(0.02, Math.sqrt(2 / (exponent + 2))));
|
|
118
|
+
} else if (keyword === 'map_Kd') current.albedo = mapPath(parts);
|
|
119
|
+
else if (keyword === 'd') current.opacity = clamp01(numbers[0] ?? 1);
|
|
120
|
+
else if (keyword === 'Tr') current.opacity = clamp01(1 - (numbers[0] ?? 0));
|
|
121
|
+
else if (keyword === 'Ks' && current.specular === 0) {
|
|
122
|
+
current.specular = Math.max(numbers[0] ?? 0, numbers[1] ?? 0, numbers[2] ?? 0);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return materials;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A face vertex: indices into the three pools, already resolved to zero-based. */
|
|
129
|
+
interface FaceVertex {
|
|
130
|
+
position: number;
|
|
131
|
+
uv: number;
|
|
132
|
+
normal: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveIndex(raw: string, poolLength: number): number {
|
|
136
|
+
if (raw === '') return -1;
|
|
137
|
+
const value = Number.parseInt(raw, 10);
|
|
138
|
+
if (Number.isNaN(value)) return -1;
|
|
139
|
+
/* Negative indices count back from the end, which the format allows and exporters use. */
|
|
140
|
+
return value < 0 ? poolLength + value : value - 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parse an OBJ. `materials` is the parsed `.mtl`, if the caller resolved one.
|
|
145
|
+
*
|
|
146
|
+
* Faces with more than three vertices are triangulated as a fan. That is correct for the
|
|
147
|
+
* convex polygons OBJ files actually contain, and the format offers nothing that would let
|
|
148
|
+
* a reader do better for a concave one.
|
|
149
|
+
*/
|
|
150
|
+
export function parseObj(
|
|
151
|
+
text: string,
|
|
152
|
+
materials: Map<string, ObjMaterial> = new Map(),
|
|
153
|
+
): {
|
|
154
|
+
meshes: MeshData[];
|
|
155
|
+
warnings: string[];
|
|
156
|
+
materials: DrftMaterial[];
|
|
157
|
+
textures: AssetReference[];
|
|
158
|
+
} {
|
|
159
|
+
const positions: number[] = [];
|
|
160
|
+
const normals: number[] = [];
|
|
161
|
+
const uvs: number[] = [];
|
|
162
|
+
const warnings: string[] = [];
|
|
163
|
+
|
|
164
|
+
/* Faces accumulate per material, so each group becomes one mesh. */
|
|
165
|
+
const groups = new Map<string, FaceVertex[]>();
|
|
166
|
+
let currentMaterial = '';
|
|
167
|
+
const faceFor = (name: string): FaceVertex[] => {
|
|
168
|
+
let list = groups.get(name);
|
|
169
|
+
if (list === undefined) {
|
|
170
|
+
list = [];
|
|
171
|
+
groups.set(name, list);
|
|
172
|
+
}
|
|
173
|
+
return list;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
for (const rawLine of text.split('\n')) {
|
|
177
|
+
const line = rawLine.trim();
|
|
178
|
+
if (line.length === 0 || line.startsWith('#')) continue;
|
|
179
|
+
const parts = line.split(/\s+/);
|
|
180
|
+
const keyword = parts[0] as string;
|
|
181
|
+
|
|
182
|
+
if (keyword === 'v') {
|
|
183
|
+
positions.push(Number(parts[1]), Number(parts[2]), Number(parts[3]));
|
|
184
|
+
} else if (keyword === 'vn') {
|
|
185
|
+
normals.push(Number(parts[1]), Number(parts[2]), Number(parts[3]));
|
|
186
|
+
} else if (keyword === 'vt') {
|
|
187
|
+
uvs.push(Number(parts[1]), Number(parts[2] ?? 0));
|
|
188
|
+
} else if (keyword === 'usemtl') {
|
|
189
|
+
currentMaterial = parts.slice(1).join(' ');
|
|
190
|
+
} else if (keyword === 'f') {
|
|
191
|
+
const corners: FaceVertex[] = [];
|
|
192
|
+
for (let i = 1; i < parts.length; i++) {
|
|
193
|
+
const [p = '', t = '', n = ''] = (parts[i] as string).split('/');
|
|
194
|
+
corners.push({
|
|
195
|
+
position: resolveIndex(p, positions.length / 3),
|
|
196
|
+
uv: resolveIndex(t, uvs.length / 2),
|
|
197
|
+
normal: resolveIndex(n, normals.length / 3),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (corners.length < 3) {
|
|
201
|
+
warnings.push(`skipped a face with ${corners.length} vertices`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const list = faceFor(currentMaterial);
|
|
205
|
+
for (let i = 1; i + 1 < corners.length; i++) {
|
|
206
|
+
list.push(corners[0] as FaceVertex, corners[i] as FaceVertex, corners[i + 1] as FaceVertex);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (positions.length === 0) throw new DrftError('obj: no vertices');
|
|
212
|
+
|
|
213
|
+
const meshes: MeshData[] = [];
|
|
214
|
+
const perMesh: DrftMaterial[] = [];
|
|
215
|
+
/* Deduplicated: several materials commonly share one map, and embedding it twice would
|
|
216
|
+
put the same megabytes in the file twice and give a consumer two names for one image. */
|
|
217
|
+
const textures: AssetReference[] = [];
|
|
218
|
+
const textureIndex = new Map<string, number>();
|
|
219
|
+
|
|
220
|
+
for (const [name, corners] of groups) {
|
|
221
|
+
if (corners.length === 0) continue;
|
|
222
|
+
const material = materials.get(name) ?? DEFAULT_MATERIAL;
|
|
223
|
+
let albedo = -1;
|
|
224
|
+
if (material.albedo !== null && material.albedo !== '') {
|
|
225
|
+
const existing = textureIndex.get(material.albedo);
|
|
226
|
+
if (existing === undefined) {
|
|
227
|
+
albedo = textures.length;
|
|
228
|
+
textureIndex.set(material.albedo, albedo);
|
|
229
|
+
textures.push({ name: material.albedo });
|
|
230
|
+
} else {
|
|
231
|
+
albedo = existing;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
meshes.push(buildGroup(corners, positions, normals, uvs, material, warnings, name));
|
|
235
|
+
perMesh.push({
|
|
236
|
+
name,
|
|
237
|
+
color: material.diffuse,
|
|
238
|
+
specular: material.specular,
|
|
239
|
+
roughness: material.roughness,
|
|
240
|
+
emissive: Math.min(
|
|
241
|
+
1,
|
|
242
|
+
Math.max(material.emissive[0], material.emissive[1], material.emissive[2]),
|
|
243
|
+
),
|
|
244
|
+
emissiveColor:
|
|
245
|
+
Math.max(material.emissive[0], material.emissive[1], material.emissive[2]) > 0
|
|
246
|
+
? material.emissive
|
|
247
|
+
: [-1, -1, -1],
|
|
248
|
+
opacity: material.opacity,
|
|
249
|
+
/* `Pm` is the PBR extension's metalness and the only thing an mtl says about
|
|
250
|
+
reflection, so it feeds this as well as the highlight. */
|
|
251
|
+
reflectivity: Math.min(1, Math.max(0, material.specular)),
|
|
252
|
+
normalMap: -1,
|
|
253
|
+
ormMap: -1,
|
|
254
|
+
emissiveMap: -1,
|
|
255
|
+
roughnessScale: 1,
|
|
256
|
+
metallicScale: 1,
|
|
257
|
+
occlusionStrength: 0,
|
|
258
|
+
cutout: 0,
|
|
259
|
+
albedo,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (meshes.length === 0) throw new DrftError('obj: no faces');
|
|
263
|
+
return { meshes, warnings, materials: perMesh, textures };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function buildGroup(
|
|
267
|
+
corners: readonly FaceVertex[],
|
|
268
|
+
positions: readonly number[],
|
|
269
|
+
normals: readonly number[],
|
|
270
|
+
uvs: readonly number[],
|
|
271
|
+
material: ObjMaterial,
|
|
272
|
+
warnings: string[],
|
|
273
|
+
name: string,
|
|
274
|
+
): MeshData {
|
|
275
|
+
const count = corners.length;
|
|
276
|
+
const outPositions = new Float32Array(count * 3);
|
|
277
|
+
const outNormals = new Float32Array(count * 3);
|
|
278
|
+
const outColors = new Float32Array(count * 3);
|
|
279
|
+
const outUvs = new Float32Array(count * 2);
|
|
280
|
+
const indices = new Uint32Array(count);
|
|
281
|
+
let hasUvs = false;
|
|
282
|
+
let missingNormals = false;
|
|
283
|
+
|
|
284
|
+
for (let i = 0; i < count; i++) {
|
|
285
|
+
const corner = corners[i] as FaceVertex;
|
|
286
|
+
const p = corner.position * 3;
|
|
287
|
+
outPositions[i * 3] = positions[p] ?? 0;
|
|
288
|
+
outPositions[i * 3 + 1] = positions[p + 1] ?? 0;
|
|
289
|
+
outPositions[i * 3 + 2] = positions[p + 2] ?? 0;
|
|
290
|
+
|
|
291
|
+
if (corner.normal >= 0) {
|
|
292
|
+
const n = corner.normal * 3;
|
|
293
|
+
outNormals[i * 3] = normals[n] ?? 0;
|
|
294
|
+
outNormals[i * 3 + 1] = normals[n + 1] ?? 0;
|
|
295
|
+
outNormals[i * 3 + 2] = normals[n + 2] ?? 0;
|
|
296
|
+
} else {
|
|
297
|
+
missingNormals = true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (corner.uv >= 0) {
|
|
301
|
+
hasUvs = true;
|
|
302
|
+
const t = corner.uv * 2;
|
|
303
|
+
outUvs[i * 2] = uvs[t] ?? 0;
|
|
304
|
+
/*
|
|
305
|
+
* V is flipped, once, here.
|
|
306
|
+
*
|
|
307
|
+
* **OBJ puts the texture origin at the bottom left by specification** and this engine
|
|
308
|
+
* uploads with it at the top left (`surfaceTexture.ts` sets `UNPACK_FLIP_Y_WEBGL`), which
|
|
309
|
+
* is the same single correction `fbx.ts` and `usd.ts` both make and document. This reader
|
|
310
|
+
* was the one of the four that did not, so every textured `.obj` imported upside down on
|
|
311
|
+
* its maps while the same asset's `.fbx` sibling was correct, which is exactly what made
|
|
312
|
+
* it read as a model fault rather than a reader one.
|
|
313
|
+
*
|
|
314
|
+
* Found from outside on a bought Formula 1 bundle, whose airbox lettering was inverted.
|
|
315
|
+
* glTF needs no flip and has none: its origin is top left already.
|
|
316
|
+
*/
|
|
317
|
+
outUvs[i * 2 + 1] = 1 - (uvs[t + 1] ?? 0);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
outColors[i * 3] = material.diffuse[0];
|
|
321
|
+
outColors[i * 3 + 1] = material.diffuse[1];
|
|
322
|
+
outColors[i * 3 + 2] = material.diffuse[2];
|
|
323
|
+
indices[i] = i;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/*
|
|
327
|
+
* A file with no `vn` gets face normals computed here rather than a buffer of zeroes.
|
|
328
|
+
* A zero normal is not a neutral value — every lighting term multiplies by it, so the
|
|
329
|
+
* geometry would arrive black and look like a shading bug rather than a missing
|
|
330
|
+
* attribute.
|
|
331
|
+
*/
|
|
332
|
+
if (missingNormals) {
|
|
333
|
+
warnings.push(`${name === '' ? 'obj' : name}: no vertex normals, so faces are shaded flat`);
|
|
334
|
+
for (let i = 0; i + 2 < count; i += 3) {
|
|
335
|
+
const ax = outPositions[i * 3] as number;
|
|
336
|
+
const ay = outPositions[i * 3 + 1] as number;
|
|
337
|
+
const az = outPositions[i * 3 + 2] as number;
|
|
338
|
+
const ux = (outPositions[(i + 1) * 3] as number) - ax;
|
|
339
|
+
const uy = (outPositions[(i + 1) * 3 + 1] as number) - ay;
|
|
340
|
+
const uz = (outPositions[(i + 1) * 3 + 2] as number) - az;
|
|
341
|
+
const vx = (outPositions[(i + 2) * 3] as number) - ax;
|
|
342
|
+
const vy = (outPositions[(i + 2) * 3 + 1] as number) - ay;
|
|
343
|
+
const vz = (outPositions[(i + 2) * 3 + 2] as number) - az;
|
|
344
|
+
let nx = uy * vz - uz * vy;
|
|
345
|
+
let ny = uz * vx - ux * vz;
|
|
346
|
+
let nz = ux * vy - uy * vx;
|
|
347
|
+
const length = Math.hypot(nx, ny, nz) || 1;
|
|
348
|
+
nx /= length;
|
|
349
|
+
ny /= length;
|
|
350
|
+
nz /= length;
|
|
351
|
+
for (let c = 0; c < 3; c++) {
|
|
352
|
+
if (
|
|
353
|
+
outNormals[(i + c) * 3] === 0 &&
|
|
354
|
+
outNormals[(i + c) * 3 + 1] === 0 &&
|
|
355
|
+
outNormals[(i + c) * 3 + 2] === 0
|
|
356
|
+
) {
|
|
357
|
+
outNormals[(i + c) * 3] = nx;
|
|
358
|
+
outNormals[(i + c) * 3 + 1] = ny;
|
|
359
|
+
outNormals[(i + c) * 3 + 2] = nz;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const emissiveStrength = Math.max(...material.emissive);
|
|
366
|
+
const emissive = new Float32Array(count).fill(emissiveStrength);
|
|
367
|
+
const emissiveColor = new Float32Array(count * 3);
|
|
368
|
+
for (let i = 0; i < count; i++) {
|
|
369
|
+
const named = emissiveStrength > 0;
|
|
370
|
+
emissiveColor[i * 3] = named ? material.emissive[0] : -1;
|
|
371
|
+
emissiveColor[i * 3 + 1] = named ? material.emissive[1] : -1;
|
|
372
|
+
emissiveColor[i * 3 + 2] = named ? material.emissive[2] : -1;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
positions: outPositions,
|
|
377
|
+
normals: outNormals,
|
|
378
|
+
colors: outColors,
|
|
379
|
+
emissive,
|
|
380
|
+
specular: new Float32Array(count).fill(material.specular),
|
|
381
|
+
roughness: new Float32Array(count).fill(material.roughness),
|
|
382
|
+
emissiveColor,
|
|
383
|
+
indices,
|
|
384
|
+
...(hasUvs ? { uvs: outUvs } : {}),
|
|
385
|
+
};
|
|
386
|
+
}
|
package/src/orient.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn an imported asset into the engine's frame. One responsibility: orientation.
|
|
3
|
+
*
|
|
4
|
+
* **This lives on the intermediate, not in a reader, and that placement is the point.** Every
|
|
5
|
+
* format arrives here eventually — glTF, OBJ, STL, FBX, and whatever is added next — and each
|
|
6
|
+
* one has its own idea of which way is up: glTF specifies Y-up and means it, STL specifies
|
|
7
|
+
* nothing at all, FBX records a convention per file and its exporters disagree about how to
|
|
8
|
+
* honour it. If each reader carried its own correction, the engine would slowly acquire one
|
|
9
|
+
* format's habits as its defaults, and the next reader would be written to match the last
|
|
10
|
+
* one's quirks rather than its own specification. So a reader's job is to report what its
|
|
11
|
+
* file says, faithfully and with no opinion, and the single opinionated step happens once,
|
|
12
|
+
* here, where it is written down and testable.
|
|
13
|
+
*
|
|
14
|
+
* **Why an override exists at all.** A file can be internally consistent and still disagree
|
|
15
|
+
* with itself: an asset whose `GlobalSettings` declare Y-up while every node in it was
|
|
16
|
+
* exported a half turn away is not malformed, and no amount of reading it more carefully
|
|
17
|
+
* reveals which of the two statements the author meant. That is not a defect a reader can
|
|
18
|
+
* repair, because the information is genuinely absent from the file — so it is supplied from
|
|
19
|
+
* outside, by somebody who has looked at the model, and the baker prints what it was told.
|
|
20
|
+
*
|
|
21
|
+
* Every turn here is a **rotation**, never a mirror. A reflection would flip the handedness
|
|
22
|
+
* of the geometry, which turns every triangle inside out and shades the model dark where it
|
|
23
|
+
* should be lit, and it is not obvious in a bounding box — the numbers look right and the
|
|
24
|
+
* render does not. Each entry in `TURNS` has determinant +1, so the winding a reader produced
|
|
25
|
+
* is still the winding the rasteriser wants, and the index buffers are passed through
|
|
26
|
+
* untouched.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { DrftNode, MeshData } from '@driftengine/drft';
|
|
30
|
+
import { DrftError } from '@driftengine/drft';
|
|
31
|
+
|
|
32
|
+
/** Which way is up in the asset, as a signed axis of the engine's own frame. */
|
|
33
|
+
export type UpAxis = '+x' | '-x' | '+y' | '-y' | '+z' | '-z';
|
|
34
|
+
|
|
35
|
+
export const UP_AXES: readonly UpAxis[] = ['+x', '-x', '+y', '-y', '+z', '-z'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The rotation that carries each axis onto the engine's `+y`, row-major 3x3.
|
|
39
|
+
*
|
|
40
|
+
* `-y` is the one genuinely ambiguous case: a half turn about X and a half turn about Z both
|
|
41
|
+
* stand the asset up, and they differ by a half turn of yaw, which no amount of measurement
|
|
42
|
+
* on the model can decide. X is the choice, because for an asset whose parts were exported
|
|
43
|
+
* with a quarter turn already baked into them it reduces those to exactly the conventional
|
|
44
|
+
* Z-up conversion rather than to that conversion plus an unexplained yaw.
|
|
45
|
+
*/
|
|
46
|
+
const TURNS: Readonly<Record<UpAxis, { readonly m: readonly number[]; readonly says: string }>> = {
|
|
47
|
+
'+y': { m: [1, 0, 0, 0, 1, 0, 0, 0, 1], says: 'already the engine frame, so nothing is turned' },
|
|
48
|
+
'-y': { m: [1, 0, 0, 0, -1, 0, 0, 0, -1], says: 'a half turn about X' },
|
|
49
|
+
'+z': {
|
|
50
|
+
m: [1, 0, 0, 0, 0, 1, 0, -1, 0],
|
|
51
|
+
says: 'a quarter turn about X, the conventional Z-up conversion',
|
|
52
|
+
},
|
|
53
|
+
'-z': { m: [1, 0, 0, 0, 0, -1, 0, 1, 0], says: 'a quarter turn about X, the other way' },
|
|
54
|
+
'+x': { m: [0, -1, 0, 1, 0, 0, 0, 0, 1], says: 'a quarter turn about Z' },
|
|
55
|
+
'-x': { m: [0, 1, 0, -1, 0, 0, 0, 0, 1], says: 'a quarter turn about Z, the other way' },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Read an axis a person typed. `z` and `+z` mean the same thing; anything else is refused. */
|
|
59
|
+
export function parseUpAxis(text: string): UpAxis {
|
|
60
|
+
const cleaned = text.trim().toLowerCase();
|
|
61
|
+
const signed = cleaned.startsWith('+') || cleaned.startsWith('-') ? cleaned : `+${cleaned}`;
|
|
62
|
+
const found = UP_AXES.find((axis) => axis === signed);
|
|
63
|
+
if (found === undefined) {
|
|
64
|
+
throw new DrftError(`"${text}" is not an axis. Use one of: ${UP_AXES.join(', ')}.`);
|
|
65
|
+
}
|
|
66
|
+
return found;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What the turn does, in words, for the line the baker prints. */
|
|
70
|
+
export function describeUpAxis(up: UpAxis): string {
|
|
71
|
+
return (TURNS[up] as { says: string }).says;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function turnTriples(source: Float32Array, m: readonly number[]): Float32Array {
|
|
75
|
+
const out = new Float32Array(source.length);
|
|
76
|
+
const m0 = m[0] as number;
|
|
77
|
+
const m1 = m[1] as number;
|
|
78
|
+
const m2 = m[2] as number;
|
|
79
|
+
const m3 = m[3] as number;
|
|
80
|
+
const m4 = m[4] as number;
|
|
81
|
+
const m5 = m[5] as number;
|
|
82
|
+
const m6 = m[6] as number;
|
|
83
|
+
const m7 = m[7] as number;
|
|
84
|
+
const m8 = m[8] as number;
|
|
85
|
+
for (let i = 0; i + 2 < source.length; i += 3) {
|
|
86
|
+
const x = source[i] as number;
|
|
87
|
+
const y = source[i + 1] as number;
|
|
88
|
+
const z = source[i + 2] as number;
|
|
89
|
+
out[i] = m0 * x + m1 * y + m2 * z;
|
|
90
|
+
out[i + 1] = m3 * x + m4 * y + m5 * z;
|
|
91
|
+
out[i + 2] = m6 * x + m7 * y + m8 * z;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stand an asset up, given which of its axes points at the sky.
|
|
98
|
+
*
|
|
99
|
+
* Normals take the same matrix as positions rather than an inverse transpose, and that is
|
|
100
|
+
* exact rather than an approximation: for a rotation the two are the same matrix. There is
|
|
101
|
+
* no scale here by construction, which is the other half of why.
|
|
102
|
+
*/
|
|
103
|
+
export function orientMeshes(meshes: readonly MeshData[], assetUp: UpAxis): MeshData[] {
|
|
104
|
+
const { m } = TURNS[assetUp];
|
|
105
|
+
return meshes.map((mesh) => ({
|
|
106
|
+
...mesh,
|
|
107
|
+
positions: turnTriples(mesh.positions, m),
|
|
108
|
+
normals: turnTriples(mesh.normals, m),
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Which way round a format's coordinate system is.
|
|
114
|
+
*
|
|
115
|
+
* **Every format read here is right-handed**, including the one that was declared left-handed for
|
|
116
|
+
* two releases on the strength of the graphics API its authoring tool targets, and whose frame is
|
|
117
|
+
* measured in the reader that reports it. The pair below is what a genuinely left-handed format
|
|
118
|
+
* gets.
|
|
119
|
+
*/
|
|
120
|
+
export type Handedness = 'right' | 'left';
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Mirror an asset into the engine's right-handed frame, in place.
|
|
124
|
+
*
|
|
125
|
+
* **This is the one thing the `TURNS` table above deliberately cannot do**, and the paragraph at
|
|
126
|
+
* the top of this file explaining why is still correct: an up-axis correction must be a rotation,
|
|
127
|
+
* or it flips geometry that was already the right way round. A *handedness* conversion is the
|
|
128
|
+
* opposite case — the geometry is genuinely mirrored, and refusing to mirror it is what would
|
|
129
|
+
* leave it wrong. Keeping the two apart is what lets each stay honest about what it does.
|
|
130
|
+
*
|
|
131
|
+
* **Three changes, and all three or none.** Negating X alone turns every triangle inside out;
|
|
132
|
+
* reversing the winding without flipping the bitangent sign lights every normal-mapped surface
|
|
133
|
+
* backwards. Doing them together, here, is why this is a function and not three lines inside a
|
|
134
|
+
* reader — the next left-handed format gets the same conversion and the same tests.
|
|
135
|
+
*
|
|
136
|
+
* UVs are untouched. A mirror moves the surface, not its parameterisation.
|
|
137
|
+
*/
|
|
138
|
+
export function convertHandedness(meshes: readonly MeshData[]): void {
|
|
139
|
+
for (const mesh of meshes) {
|
|
140
|
+
for (let at = 0; at < mesh.positions.length; at += 3) {
|
|
141
|
+
mesh.positions[at] = -(mesh.positions[at] as number);
|
|
142
|
+
}
|
|
143
|
+
for (let at = 0; at < mesh.normals.length; at += 3) {
|
|
144
|
+
mesh.normals[at] = -(mesh.normals[at] as number);
|
|
145
|
+
}
|
|
146
|
+
const tangents = mesh.tangents;
|
|
147
|
+
if (tangents !== undefined) {
|
|
148
|
+
for (let at = 0; at < tangents.length; at += 4) {
|
|
149
|
+
tangents[at] = -(tangents[at] as number);
|
|
150
|
+
tangents[at + 3] = -(tangents[at + 3] as number);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (let at = 0; at + 2 < mesh.indices.length; at += 3) {
|
|
154
|
+
const middle = mesh.indices[at + 1] as number;
|
|
155
|
+
mesh.indices[at + 1] = mesh.indices[at + 2] as number;
|
|
156
|
+
mesh.indices[at + 2] = middle;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Mirror an asset's hierarchy by the same reflection `convertHandedness` mirrors its geometry by.
|
|
163
|
+
*
|
|
164
|
+
* **The pair to `convertHandedness`, exactly as `orientNodes` is the pair to `orientMeshes`**, and
|
|
165
|
+
* it did not exist: the one caller that mirrored a graph negated each node's translation X inline
|
|
166
|
+
* and left the rotations alone. A hierarchy with rotations then places its parts where the
|
|
167
|
+
* geometry is not — measured on a real car at **1.385 m apart and on the other side of the
|
|
168
|
+
* model** — while both halves stay individually well formed, which is the worst shape this can
|
|
169
|
+
* take and the reason the two functions now sit together.
|
|
170
|
+
*
|
|
171
|
+
* **The rotation is conjugated, not negated.** Reflecting a rotation `R` by a mirror `M` gives
|
|
172
|
+
* `M R M`, which is a rotation about the mirrored axis in the opposite sense; as a quaternion that
|
|
173
|
+
* is `(x, −y, −z, w)` for a reflection about the YZ plane. A scale is untouched, because `M S M`
|
|
174
|
+
* is `S` for any diagonal.
|
|
175
|
+
*
|
|
176
|
+
* **Every node, not only the roots**, which is the one place this differs from `orientNodes`. A
|
|
177
|
+
* rotation applied to a root already carries its descendants, so applying it to a child would
|
|
178
|
+
* apply it twice. A reflection is a conjugation and conjugation distributes over the product —
|
|
179
|
+
* `M (A B) M = (M A M)(M B M)` — so leaving a child alone drops the mirror out of the composition
|
|
180
|
+
* rather than avoiding a double application.
|
|
181
|
+
*/
|
|
182
|
+
export function mirrorNodes(nodes: readonly DrftNode[]): DrftNode[] {
|
|
183
|
+
return nodes.map((node) => ({
|
|
184
|
+
...node,
|
|
185
|
+
translation: [-node.translation[0], node.translation[1], node.translation[2]],
|
|
186
|
+
rotation: [node.rotation[0], -node.rotation[1], -node.rotation[2], node.rotation[3]],
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Turn an asset's hierarchy by the same rotation `orientMeshes` turns its geometry by.
|
|
192
|
+
*
|
|
193
|
+
* **Without this, `--up` silently desynchronises the two.** The meshes rotate and the graph does
|
|
194
|
+
* not, so every node then claims a part is somewhere it no longer is — and nothing downstream can
|
|
195
|
+
* detect it, because both halves are individually well formed. That is the worst shape a bug can
|
|
196
|
+
* take in a bake, so the two turns happen together or the hierarchy is wrong.
|
|
197
|
+
*
|
|
198
|
+
* **Only roots are turned**, and that is not an optimisation: a child's transform is relative to
|
|
199
|
+
* its parent, so rotating the parent already carries every descendant with it. Turning a child too
|
|
200
|
+
* would apply the rotation twice.
|
|
201
|
+
*/
|
|
202
|
+
export function orientNodes(nodes: readonly DrftNode[], assetUp: UpAxis): DrftNode[] {
|
|
203
|
+
const { m } = TURNS[assetUp];
|
|
204
|
+
if (assetUp === '+y') return [...nodes];
|
|
205
|
+
const turn = (v: readonly [number, number, number]): [number, number, number] => [
|
|
206
|
+
(m[0] as number) * v[0] + (m[1] as number) * v[1] + (m[2] as number) * v[2],
|
|
207
|
+
(m[3] as number) * v[0] + (m[4] as number) * v[1] + (m[5] as number) * v[2],
|
|
208
|
+
(m[6] as number) * v[0] + (m[7] as number) * v[1] + (m[8] as number) * v[2],
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
/* The turn as a quaternion, so a root's own rotation composes with it. */
|
|
212
|
+
const trace = (m[0] as number) + (m[4] as number) + (m[8] as number);
|
|
213
|
+
let q: [number, number, number, number];
|
|
214
|
+
if (trace > 0) {
|
|
215
|
+
const s = Math.sqrt(trace + 1) * 2;
|
|
216
|
+
q = [
|
|
217
|
+
((m[7] as number) - (m[5] as number)) / s,
|
|
218
|
+
((m[2] as number) - (m[6] as number)) / s,
|
|
219
|
+
((m[3] as number) - (m[1] as number)) / s,
|
|
220
|
+
s / 4,
|
|
221
|
+
];
|
|
222
|
+
} else if ((m[0] as number) > (m[4] as number) && (m[0] as number) > (m[8] as number)) {
|
|
223
|
+
const s = Math.sqrt(1 + (m[0] as number) - (m[4] as number) - (m[8] as number)) * 2;
|
|
224
|
+
q = [
|
|
225
|
+
s / 4,
|
|
226
|
+
((m[1] as number) + (m[3] as number)) / s,
|
|
227
|
+
((m[2] as number) + (m[6] as number)) / s,
|
|
228
|
+
((m[7] as number) - (m[5] as number)) / s,
|
|
229
|
+
];
|
|
230
|
+
} else if ((m[4] as number) > (m[8] as number)) {
|
|
231
|
+
const s = Math.sqrt(1 + (m[4] as number) - (m[0] as number) - (m[8] as number)) * 2;
|
|
232
|
+
q = [
|
|
233
|
+
((m[1] as number) + (m[3] as number)) / s,
|
|
234
|
+
s / 4,
|
|
235
|
+
((m[5] as number) + (m[7] as number)) / s,
|
|
236
|
+
((m[2] as number) - (m[6] as number)) / s,
|
|
237
|
+
];
|
|
238
|
+
} else {
|
|
239
|
+
const s = Math.sqrt(1 + (m[8] as number) - (m[0] as number) - (m[4] as number)) * 2;
|
|
240
|
+
q = [
|
|
241
|
+
((m[2] as number) + (m[6] as number)) / s,
|
|
242
|
+
((m[5] as number) + (m[7] as number)) / s,
|
|
243
|
+
s / 4,
|
|
244
|
+
((m[3] as number) - (m[1] as number)) / s,
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return nodes.map((node) => {
|
|
249
|
+
if (node.parent >= 0) return node;
|
|
250
|
+
const [bx, by, bz, bw] = node.rotation;
|
|
251
|
+
return {
|
|
252
|
+
...node,
|
|
253
|
+
translation: turn(node.translation),
|
|
254
|
+
rotation: [
|
|
255
|
+
q[3] * bx + q[0] * bw + q[1] * bz - q[2] * by,
|
|
256
|
+
q[3] * by - q[0] * bz + q[1] * bw + q[2] * bx,
|
|
257
|
+
q[3] * bz + q[0] * by - q[1] * bx + q[2] * bw,
|
|
258
|
+
q[3] * bw - q[0] * bx - q[1] * by - q[2] * bz,
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
}
|