@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/readModel.ts
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One dispatcher from a model file to meshes, shared by the baker and by a browser.
|
|
3
|
+
*
|
|
4
|
+
* **It exists because there were nearly two of them.** The baker grew the only mapping from
|
|
5
|
+
* an extension to a reader, and it grew it against Node: `readFileSync` for a `.gltf`'s
|
|
6
|
+
* external buffers, `readFileSync` again for an `.obj`'s `.mtl`, `node:zlib` for the two
|
|
7
|
+
* compressed containers. None of that is about *which reader a file needs*, and a second
|
|
8
|
+
* host wanting the same answer would have had to copy the dispatch and keep the copy in step
|
|
9
|
+
* with every format added afterwards. So the dispatch moved here and the IO stayed with the
|
|
10
|
+
* caller, which is the split `assetPath.ts` and the injected `Inflate` already make.
|
|
11
|
+
*
|
|
12
|
+
* **Every capability is optional, and what needs one says so when it is missing.** A `.glb`,
|
|
13
|
+
* a `.stl`, a `.usdz` and a `.usda` need nothing at all, so the common case runs anywhere
|
|
14
|
+
* with no configuration. A `.gltf` and an `.obj` may name a file beside them; a `.fbx` and a
|
|
15
|
+
* `.3mf` store compressed arrays. Asking for those without supplying the capability fails
|
|
16
|
+
* with the name of what to pass rather than with a parse error thirty frames later.
|
|
17
|
+
*
|
|
18
|
+
* The rule from docs/FORMAT.md §3.3 is unchanged by this: reading a source format at runtime
|
|
19
|
+
* is not the default, never belongs on the main thread, and never belongs inside a frame.
|
|
20
|
+
* This module makes it *possible* from a browser. Where it is allowed to run is the caller's
|
|
21
|
+
* decision, and `demo/showroom.ts` runs it in a worker.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { MeshData } from '@driftengine/drft';
|
|
25
|
+
import type { AnimationClip, DrftMaterial, DrftNode, DrftSkin } from '@driftengine/drft';
|
|
26
|
+
import { DrftError } from '@driftengine/drft';
|
|
27
|
+
import type { AssetReference } from './assetPath.ts';
|
|
28
|
+
import { assetCandidates } from './assetPath.ts';
|
|
29
|
+
import type { Handedness, UpAxis } from './orient.ts';
|
|
30
|
+
import type { Inflate } from './fbx.ts';
|
|
31
|
+
import { fbxToMeshes } from './fbx.ts';
|
|
32
|
+
import { gltfToMeshes, readGlb } from './gltf.ts';
|
|
33
|
+
import { readGltfSkins } from './gltfSkin.ts';
|
|
34
|
+
import type { GltfDocument } from './gltf.ts';
|
|
35
|
+
import { parseMtl, parseObj } from './obj.ts';
|
|
36
|
+
import { parseStl } from './stl.ts';
|
|
37
|
+
import { parseUsda, usdzToMeshes } from './usd.ts';
|
|
38
|
+
import { threeMfToMeshes } from './threemf.ts';
|
|
39
|
+
import { kn5ToMeshes, toDrftNodes } from './kn5.ts';
|
|
40
|
+
import { describeRecognised, recognise } from './recognise.ts';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every extension this project reads, in the order a bundle should be picked from.
|
|
44
|
+
*
|
|
45
|
+
* Sorted by tier first and then by how much of a scene the format can carry. `.glb` beats
|
|
46
|
+
* `.gltf` only because one file cannot half-arrive; both read identically. It is data rather
|
|
47
|
+
* than a chain of ifs because two callers need to *inventory* a folder against it, and a
|
|
48
|
+
* chain of ifs cannot be enumerated.
|
|
49
|
+
*/
|
|
50
|
+
export const MODEL_FORMATS: readonly { ext: string; tier: number; note: string }[] = [
|
|
51
|
+
{ ext: '.glb', tier: 1, note: 'glTF 2.0, packed' },
|
|
52
|
+
{ ext: '.gltf', tier: 1, note: 'glTF 2.0' },
|
|
53
|
+
{ ext: '.obj', tier: 1, note: 'Wavefront OBJ' },
|
|
54
|
+
{ ext: '.stl', tier: 1, note: 'STL, geometry only, with no colour or materials' },
|
|
55
|
+
{ ext: '.usdz', tier: 1, note: 'USD, zipped' },
|
|
56
|
+
{ ext: '.usda', tier: 1, note: 'USD, ASCII' },
|
|
57
|
+
{ ext: '.3mf', tier: 1, note: '3MF, geometry and colour, with no UVs in the core format' },
|
|
58
|
+
{ ext: '.fbx', tier: 2, note: 'FBX, experimental reader, geometry, materials, skins and clips' },
|
|
59
|
+
{ ext: '.kn5', tier: 2, note: 'experimental reader, geometry, materials and hierarchy' },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Whether this project has a reader for an extension. Lower case or not. */
|
|
63
|
+
export function readerFor(ext: string): (typeof MODEL_FORMATS)[number] | undefined {
|
|
64
|
+
return MODEL_FORMATS.find((reader) => reader.ext === ext.toLowerCase());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The extension of a file name, lower case, including the dot. Empty when it has none. */
|
|
68
|
+
export function extensionOf(name: string): string {
|
|
69
|
+
const at = name.lastIndexOf('.');
|
|
70
|
+
return at < 0 ? '' : name.slice(at).toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A model file, plus the capabilities its format may need to be read. */
|
|
74
|
+
export interface ModelSource {
|
|
75
|
+
/**
|
|
76
|
+
* The file's own name, used for its extension and in messages.
|
|
77
|
+
*
|
|
78
|
+
* The extension is a claim and the magic bytes are the fact, which is why `recognise` runs
|
|
79
|
+
* before the dispatch below rather than after it.
|
|
80
|
+
*/
|
|
81
|
+
readonly name: string;
|
|
82
|
+
readonly bytes: Uint8Array;
|
|
83
|
+
/**
|
|
84
|
+
* Fetch a file named *beside* this one, or null where there is none.
|
|
85
|
+
*
|
|
86
|
+
* Needed by a `.gltf` naming external buffers and by an `.obj` naming an `.mtl`. The path
|
|
87
|
+
* is relative and comes from inside the model, so a host that fetches it is choosing to
|
|
88
|
+
* resolve a name the file supplied: resolve it against the model's own directory and
|
|
89
|
+
* nothing above it.
|
|
90
|
+
*/
|
|
91
|
+
readonly beside?: (relativePath: string) => Promise<Uint8Array | null>;
|
|
92
|
+
/** zlib-wrapped inflate, for `.fbx`. See `fbxInflate.ts` for the browser's. */
|
|
93
|
+
readonly inflate?: Inflate;
|
|
94
|
+
/** Raw deflate inflate, for the zip inside a `.3mf`. Not the same function as `inflate`. */
|
|
95
|
+
readonly inflateRaw?: Inflate;
|
|
96
|
+
/**
|
|
97
|
+
* Derive a tangent frame where a material declares a normal map and the file supplies none.
|
|
98
|
+
*
|
|
99
|
+
* **On by default. A caller that is about to weld should pass false** and call
|
|
100
|
+
* `deriveTangentsFor` afterwards, which the baker does: the derivation accumulates per index, so
|
|
101
|
+
* on a mesh stored as one vertex per triangle corner it gives every corner its own frame and the
|
|
102
|
+
* weld can merge none of them. Measured at 9,600 corners welding to 9,482 rather than 1,681.
|
|
103
|
+
*
|
|
104
|
+
* Only glTF derives one at all, so this is inert for every other format. A frame the file
|
|
105
|
+
* supplies is kept either way.
|
|
106
|
+
*/
|
|
107
|
+
readonly deriveTangents?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What a reader found, before anything is oriented, welded or written. */
|
|
111
|
+
export interface ModelImport {
|
|
112
|
+
meshes: MeshData[];
|
|
113
|
+
/** Caveats a person should read: an experimental reader, a missing axis convention. */
|
|
114
|
+
warnings: string[];
|
|
115
|
+
/** Informational lines, worth printing and not worth worrying about. */
|
|
116
|
+
notes: string[];
|
|
117
|
+
/** What the *file* says is up, where the format says anything at all. */
|
|
118
|
+
declaredUp?: UpAxis;
|
|
119
|
+
/** One per mesh, by ordinal, when the format carries materials. */
|
|
120
|
+
materials?: DrftMaterial[];
|
|
121
|
+
/**
|
|
122
|
+
* Which substance each material is made of, from a glTF material's `extras.substance`.
|
|
123
|
+
*
|
|
124
|
+
* Absent for every other format and for a glTF that labels nothing, which is the same
|
|
125
|
+
* all-or-nothing shape `materials` and `textures` have beside it. The baker writes these into the
|
|
126
|
+
* `SUBS` chunk; `§16` of the chemistry design is why they exist.
|
|
127
|
+
*/
|
|
128
|
+
substances?: { material: number; substance: string }[];
|
|
129
|
+
/** Textures the model named, with bytes in hand where it carried them inline. */
|
|
130
|
+
textures?: AssetReference[];
|
|
131
|
+
/** Metres per source unit, when the format states one. USD and FBX do; nothing else here does. */
|
|
132
|
+
unitScale?: number;
|
|
133
|
+
/**
|
|
134
|
+
* Skins and clips, where the format carries them. glTF and FBX do; nothing else here does.
|
|
135
|
+
*
|
|
136
|
+
* Optional, so every reader that knows nothing about rigs stays exactly as it was — the same
|
|
137
|
+
* reason `materials` and `textures` are optional beside it.
|
|
138
|
+
*/
|
|
139
|
+
skins?: readonly DrftSkin[];
|
|
140
|
+
clips?: readonly AnimationClip[];
|
|
141
|
+
/**
|
|
142
|
+
* The asset's own hierarchy, where the format carries one. Absent for a format with none.
|
|
143
|
+
*
|
|
144
|
+
* **`MESH` stays world-space beside it**, which is what makes this additive: §4.4 rule 4 says a
|
|
145
|
+
* minor version never changes the meaning of an existing byte, and moving positions into
|
|
146
|
+
* node-local space would change the meaning of every vertex in the format while leaving the
|
|
147
|
+
* bytes identical. It would also defeat rule 2, which promises that a reader skipping an
|
|
148
|
+
* optional chunk still gets a usable asset — a local-space model whose `NODE` was skipped draws
|
|
149
|
+
* as a heap at the origin. So a reader that skips `NODE` still draws the model, and one that
|
|
150
|
+
* reads it calls `localiseNodes` to get parts it can turn.
|
|
151
|
+
*/
|
|
152
|
+
nodes?: readonly DrftNode[];
|
|
153
|
+
/** Which way round the source's coordinate system was. `orient.ts` is what acts on it. */
|
|
154
|
+
declaredHand?: Handedness;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function needed(ext: string, capability: string): DrftError {
|
|
158
|
+
return new DrftError(
|
|
159
|
+
`${ext} needs a "${capability}" to be read. Bake it with \`npm run bake\`, which supplies one, ` +
|
|
160
|
+
`or pass ${capability} to readModel.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read one model file into meshes.
|
|
166
|
+
*
|
|
167
|
+
* Asynchronous because two formats may name a file beside them, and resolving that is IO
|
|
168
|
+
* whichever host is doing it. Every reader underneath stays synchronous: the sidecars are
|
|
169
|
+
* fetched first and the parse then runs in one pass, which is also what lets a caller put the
|
|
170
|
+
* whole call in a worker and keep the parse off the main thread.
|
|
171
|
+
*/
|
|
172
|
+
export async function readModel(source: ModelSource): Promise<ModelImport> {
|
|
173
|
+
return withTextures(await dispatch(source), source);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Fetch the images a model named, through the same hook a `.gltf`'s buffers and an `.obj`'s
|
|
178
|
+
* `.mtl` already come through.
|
|
179
|
+
*
|
|
180
|
+
* **This used to be left to the caller and the caller is where it went missing.** A reader
|
|
181
|
+
* reports a texture as a *name* and fills `bytes` only where the format carried the image
|
|
182
|
+
* inline, so a `.glb` was fine and every other format handed back references pointing at
|
|
183
|
+
* nothing. Two hosts then wrote the same loop to fix that: `scripts/bake.ts` walks
|
|
184
|
+
* `assetCandidates` against the model's folder and a search root, and a browser consumer walked
|
|
185
|
+
* the same candidates against a zip's entry list — after shipping for months without it, which
|
|
186
|
+
* is what made a bought archive draw untextured with its images two folders away inside itself.
|
|
187
|
+
* One of those hosts writing the walk is a host doing its job; both of them writing it means the
|
|
188
|
+
* walk was in the wrong place.
|
|
189
|
+
*
|
|
190
|
+
* **The split `assetPath.ts` describes is unchanged, and that is the point.** The engine decides
|
|
191
|
+
* *what to look for*, which is the same everywhere and is `assetCandidates`; the host decides
|
|
192
|
+
* *where*, which is `beside` and is the only half that knows whether it is holding a folder, a
|
|
193
|
+
* zip or a URL. What moved here is the walk and nothing else.
|
|
194
|
+
*
|
|
195
|
+
* **What it costs: one `beside` call per candidate until one answers**, and `assetCandidates`
|
|
196
|
+
* offers up to seven for a name with a folder in it. For a host reading a filesystem or an
|
|
197
|
+
* in-memory archive that is nothing. For a host whose `beside` is a network request, a texture
|
|
198
|
+
* that is genuinely absent costs seven round trips before the reference is left alone — so a
|
|
199
|
+
* remote host should answer from an index it already holds rather than probing. What would make
|
|
200
|
+
* this wrong: a `beside` with a side effect, since it is now called speculatively rather than
|
|
201
|
+
* only for a name the model insisted on.
|
|
202
|
+
*
|
|
203
|
+
* A reference that resolves to nothing is left exactly as it was rather than dropped, because
|
|
204
|
+
* `DrftMaterial.albedo` is an ordinal into this list and removing an entry renumbers every one
|
|
205
|
+
* after it, which silently repaints the model.
|
|
206
|
+
*/
|
|
207
|
+
async function withTextures(imported: ModelImport, source: ModelSource): Promise<ModelImport> {
|
|
208
|
+
const references = imported.textures;
|
|
209
|
+
const beside = source.beside;
|
|
210
|
+
if (references === undefined || beside === undefined) return imported;
|
|
211
|
+
if (references.every((reference) => reference.bytes !== undefined)) return imported;
|
|
212
|
+
|
|
213
|
+
const notes = [...imported.notes];
|
|
214
|
+
const resolved: AssetReference[] = [];
|
|
215
|
+
for (const reference of references) {
|
|
216
|
+
if (reference.bytes !== undefined) {
|
|
217
|
+
resolved.push(reference);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
let found: { candidate: string; bytes: Uint8Array } | null = null;
|
|
221
|
+
for (const candidate of assetCandidates(reference.name)) {
|
|
222
|
+
const bytes = await beside(candidate);
|
|
223
|
+
if (bytes !== null) {
|
|
224
|
+
found = { candidate, bytes };
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (found === null) {
|
|
229
|
+
resolved.push(reference);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
/* Only worth saying when it was somewhere other than where the model looked, which is the
|
|
233
|
+
case somebody would otherwise go hunting for the file over. */
|
|
234
|
+
if (found.candidate !== reference.name) {
|
|
235
|
+
notes.push(`texture "${reference.name}" was found as "${found.candidate}"`);
|
|
236
|
+
}
|
|
237
|
+
resolved.push({ ...reference, bytes: found.bytes });
|
|
238
|
+
}
|
|
239
|
+
return { ...imported, textures: resolved, notes };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function dispatch(source: ModelSource): Promise<ModelImport> {
|
|
243
|
+
const { name, bytes } = source;
|
|
244
|
+
/* Only glTF reads this today, and it is passed as the whole option object so a second field
|
|
245
|
+
costs one line here rather than one per reader. */
|
|
246
|
+
const options = { deriveTangents: source.deriveTangents ?? true };
|
|
247
|
+
const buffer = bytes.buffer.slice(
|
|
248
|
+
bytes.byteOffset,
|
|
249
|
+
bytes.byteOffset + bytes.byteLength,
|
|
250
|
+
) as ArrayBuffer;
|
|
251
|
+
const ext = extensionOf(name);
|
|
252
|
+
|
|
253
|
+
/*
|
|
254
|
+
* Tier 4 first, and by content rather than by extension. A file this project has no reader
|
|
255
|
+
* for should say so and name the export that works, rather than reaching a parser that
|
|
256
|
+
* fails on bytes it was never going to understand.
|
|
257
|
+
*/
|
|
258
|
+
const foreign = recognise(bytes);
|
|
259
|
+
if (foreign !== null) throw new DrftError(describeRecognised(foreign, name));
|
|
260
|
+
|
|
261
|
+
if (ext === '.glb') {
|
|
262
|
+
const { json, binary } = readGlb(buffer);
|
|
263
|
+
const parts = binary === null ? [] : [binary];
|
|
264
|
+
/*
|
|
265
|
+
* The rig alongside the geometry, and read from the same document. `gltfToMeshes` reads it too
|
|
266
|
+
* — to remap a mesh's joint indices — and both go through `readGltfSkins`, so there is one
|
|
267
|
+
* sort rather than two that could disagree about which joint is which.
|
|
268
|
+
*/
|
|
269
|
+
return complete({ ...gltfToMeshes(json, parts, options), ...readGltfSkins(json, parts) });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (ext === '.gltf') {
|
|
273
|
+
const doc = JSON.parse(new TextDecoder().decode(bytes)) as GltfDocument;
|
|
274
|
+
const buffers: Uint8Array[] = [];
|
|
275
|
+
for (const entry of doc.buffers ?? []) {
|
|
276
|
+
if (entry.uri === undefined) throw new DrftError('gltf: a buffer with no uri needs a .glb');
|
|
277
|
+
if (entry.uri.startsWith('data:')) {
|
|
278
|
+
const comma = entry.uri.indexOf(',');
|
|
279
|
+
buffers.push(base64Bytes(entry.uri.slice(comma + 1)));
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (source.beside === undefined) throw needed('.gltf with external buffers', 'beside');
|
|
283
|
+
const found = await source.beside(decodeURIComponent(entry.uri));
|
|
284
|
+
if (found === null)
|
|
285
|
+
throw new DrftError(`gltf: the buffer "${entry.uri}" was not found beside the model`);
|
|
286
|
+
buffers.push(found);
|
|
287
|
+
}
|
|
288
|
+
return complete({ ...gltfToMeshes(doc, buffers, options), ...readGltfSkins(doc, buffers) });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (ext === '.obj') {
|
|
292
|
+
/* The `.mtl` is named inside the file, and is optional: a bare OBJ still imports. */
|
|
293
|
+
const text = new TextDecoder().decode(bytes);
|
|
294
|
+
const named = /^mtllib\s+(.+)$/m.exec(text)?.[1]?.trim();
|
|
295
|
+
let materials = new Map<
|
|
296
|
+
string,
|
|
297
|
+
ReturnType<typeof parseMtl> extends Map<string, infer V> ? V : never
|
|
298
|
+
>();
|
|
299
|
+
const notes: string[] = [];
|
|
300
|
+
const missing: string[] = [];
|
|
301
|
+
if (named !== undefined && source.beside !== undefined) {
|
|
302
|
+
const found = await source.beside(named);
|
|
303
|
+
if (found === null) {
|
|
304
|
+
/*
|
|
305
|
+
* A **warning**, not a note, and the distinction is the whole point of the change.
|
|
306
|
+
*
|
|
307
|
+
* A consumer cannot tell "this model has no textures" from "this model's textures could
|
|
308
|
+
* not be found", and the two want opposite handling: the first is a model to paint by
|
|
309
|
+
* material name, the second is a file to go looking for. The engine is the only party
|
|
310
|
+
* that knows which happened, because it is what called `beside` and got null back.
|
|
311
|
+
*
|
|
312
|
+
* It was a note, which is the channel for things that are merely true. Reported from
|
|
313
|
+
* outside after an hour spent treating a packing mistake, a bundle shipped without its
|
|
314
|
+
* `.mtl` at all, as a model with no maps.
|
|
315
|
+
*/
|
|
316
|
+
missing.push(
|
|
317
|
+
`obj: the material library "${named}" was not found beside the model, so every ` +
|
|
318
|
+
'surface takes a default and no material declares a map. If this asset should be ' +
|
|
319
|
+
'textured, the file is missing from the bundle rather than absent from the model.',
|
|
320
|
+
);
|
|
321
|
+
} else {
|
|
322
|
+
materials = parseMtl(new TextDecoder().decode(found));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const parsed = parseObj(text, materials);
|
|
326
|
+
return complete({ ...parsed, warnings: [...missing, ...parsed.warnings] }, notes);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (ext === '.stl') {
|
|
330
|
+
/*
|
|
331
|
+
* STL carries no axis convention, no units and no materials, so there is nothing to read
|
|
332
|
+
* here and nothing to trust. It is also, overwhelmingly, a printing format, and printing
|
|
333
|
+
* is Z-up almost everywhere, so the common case lands on its face in a Y-up engine. Said
|
|
334
|
+
* once, plainly, rather than guessed at: guessing is what put a car upside down and
|
|
335
|
+
* reported it as verified.
|
|
336
|
+
*/
|
|
337
|
+
return {
|
|
338
|
+
meshes: [parseStl(buffer)],
|
|
339
|
+
warnings: ['stl states no up axis. Most are Z-up; pass --up z if this lands on its side.'],
|
|
340
|
+
notes: [],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (ext === '.usdz' || ext === '.usda') {
|
|
345
|
+
/*
|
|
346
|
+
* No inflate is passed and that is the format speaking rather than an omission: a `.usdz`
|
|
347
|
+
* must store its entries uncompressed so a runtime can memory map them, so it is the one
|
|
348
|
+
* zip container that needs no decompressor at all.
|
|
349
|
+
*/
|
|
350
|
+
const result =
|
|
351
|
+
ext === '.usdz' ? usdzToMeshes(buffer) : parseUsda(new TextDecoder().decode(bytes));
|
|
352
|
+
const notes =
|
|
353
|
+
result.unitScale === 1
|
|
354
|
+
? []
|
|
355
|
+
: /* Carried into HEAD rather than multiplied into the vertices: the format has a field
|
|
356
|
+
for exactly this, and scaling here would throw away what the file said in favour of
|
|
357
|
+
a number nobody could check afterwards. */
|
|
358
|
+
[`usd: metersPerUnit ${result.unitScale}, carried into HEAD.`];
|
|
359
|
+
return {
|
|
360
|
+
meshes: result.meshes,
|
|
361
|
+
warnings: result.warnings,
|
|
362
|
+
notes,
|
|
363
|
+
...(result.declaredUp === undefined ? {} : { declaredUp: result.declaredUp }),
|
|
364
|
+
materials: result.materials,
|
|
365
|
+
textures: result.textures,
|
|
366
|
+
unitScale: result.unitScale,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (ext === '.3mf') {
|
|
371
|
+
/*
|
|
372
|
+
* Ordinary deflate, unlike usdz, so this is the zip path that needs the decompressor, and
|
|
373
|
+
* it is the *raw* one. A zip stores a bare deflate stream with no zlib header while FBX
|
|
374
|
+
* embeds a wrapped one, so the two paths take different functions. Passing the wrong one
|
|
375
|
+
* fails cleanly rather than decoding to plausible noise, which is the good kind of
|
|
376
|
+
* mistake, and is why they are two fields here rather than one.
|
|
377
|
+
*/
|
|
378
|
+
if (source.inflateRaw === undefined) throw needed('.3mf', 'inflateRaw');
|
|
379
|
+
const result = threeMfToMeshes(buffer, source.inflateRaw);
|
|
380
|
+
return {
|
|
381
|
+
meshes: result.meshes,
|
|
382
|
+
warnings: result.warnings,
|
|
383
|
+
notes: [`3mf: ${result.meshes.length} object(s), Z-up by specification.`],
|
|
384
|
+
declaredUp: result.declaredUp,
|
|
385
|
+
materials: result.materials,
|
|
386
|
+
unitScale: result.unitScale,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (ext === '.fbx') {
|
|
391
|
+
if (source.inflate === undefined) throw needed('.fbx', 'inflate');
|
|
392
|
+
const result = fbxToMeshes(buffer, source.inflate);
|
|
393
|
+
return {
|
|
394
|
+
meshes: result.meshes,
|
|
395
|
+
warnings: [
|
|
396
|
+
...result.warnings,
|
|
397
|
+
`fbx: version ${result.version}, experimental reader (tier 2), not covered by the compatibility promise.`,
|
|
398
|
+
],
|
|
399
|
+
notes: [],
|
|
400
|
+
declaredUp: result.declaredUp,
|
|
401
|
+
materials: result.materials,
|
|
402
|
+
textures: result.textures,
|
|
403
|
+
/*
|
|
404
|
+
* The rig, on the same terms glTF's is: absent where the file carries none, so every static
|
|
405
|
+
* `.fbx` returns exactly what it always did. `.fbx` is how characters are *sold* — it is the
|
|
406
|
+
* only thing Mixamo exports — so a reader that returned geometry alone made the one format
|
|
407
|
+
* most rigs arrive in the one format a rig could not be read from.
|
|
408
|
+
*/
|
|
409
|
+
skins: result.skins,
|
|
410
|
+
clips: result.clips,
|
|
411
|
+
/*
|
|
412
|
+
* **Carried rather than multiplied in**, which is the ruling `.usdz` already made in this
|
|
413
|
+
* file: the format has a field for exactly this, and scaling here would throw away what the
|
|
414
|
+
* file said in favour of a number nobody could check afterwards.
|
|
415
|
+
*/
|
|
416
|
+
unitScale: result.unitScale,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (ext === '.kn5') {
|
|
421
|
+
/*
|
|
422
|
+
* No capability of any kind: a `.kn5` embeds every texture it uses, so this is the one binary
|
|
423
|
+
* format here with nothing to inject and no sidecar to resolve.
|
|
424
|
+
*/
|
|
425
|
+
const result = kn5ToMeshes(buffer);
|
|
426
|
+
/*
|
|
427
|
+
* **Nothing is mirrored here, and until 3.26.0 everything was.** The frame was declared
|
|
428
|
+
* left-handed on the strength of the simulator being a DirectX title, and a consumer importing
|
|
429
|
+
* a second car found the mirror by eye: a rear badge reading backwards, a number plate written
|
|
430
|
+
* backwards, and the steering wheel on the wrong side of the cabin. `kn5.ts` carries the
|
|
431
|
+
* measurement that settles it. Nothing numeric had caught it in between, because a mirrored car
|
|
432
|
+
* has the same bounds, the same triangle count and the same consistent winding as an
|
|
433
|
+
* unmirrored one.
|
|
434
|
+
*
|
|
435
|
+
* A reader that does meet a left-handed format applies **both** halves — `convertHandedness`
|
|
436
|
+
* over the meshes and `mirrorNodes` over the graph — or it desynchronises two individually
|
|
437
|
+
* well-formed halves. The half that was missing while this branch existed put a wheel hub
|
|
438
|
+
* 1.385 m from its own geometry, on the other side of the car.
|
|
439
|
+
*/
|
|
440
|
+
const nodes = toDrftNodes(result.nodes);
|
|
441
|
+
return {
|
|
442
|
+
meshes: result.meshes,
|
|
443
|
+
warnings: [
|
|
444
|
+
...result.warnings,
|
|
445
|
+
'kn5: experimental reader (tier 2), not covered by the compatibility promise.',
|
|
446
|
+
],
|
|
447
|
+
notes: [`kn5: ${result.nodes.length} nodes, ${result.meshes.length} meshes.`],
|
|
448
|
+
declaredUp: result.declaredUp,
|
|
449
|
+
declaredHand: result.declaredHand,
|
|
450
|
+
materials: result.materials,
|
|
451
|
+
textures: result.textures,
|
|
452
|
+
nodes,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
throw new DrftError(
|
|
457
|
+
`no reader for "${ext}". This project reads ${MODEL_FORMATS.map((entry) => entry.ext).join(', ')}.`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Give a reader's result the two fields every caller reads, without each reader restating them. */
|
|
462
|
+
function complete(
|
|
463
|
+
result: { meshes: MeshData[]; warnings: string[] } & Omit<
|
|
464
|
+
ModelImport,
|
|
465
|
+
'meshes' | 'warnings' | 'notes'
|
|
466
|
+
>,
|
|
467
|
+
notes: string[] = [],
|
|
468
|
+
): ModelImport {
|
|
469
|
+
return { ...result, notes };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Base64 without Node's Buffer, since this module runs in a browser too. */
|
|
473
|
+
function base64Bytes(base64: string): Uint8Array {
|
|
474
|
+
const binary = atob(base64);
|
|
475
|
+
const out = new Uint8Array(binary.length);
|
|
476
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The collision hull a vehicle folder ships beside its model, or null.
|
|
482
|
+
*
|
|
483
|
+
* **A convention, not a statement in any file**, which is why it is one named function instead of
|
|
484
|
+
* a rule spread through the baker: a folder naming its hull differently gets no collider and the
|
|
485
|
+
* baker says what it looked for, rather than leaving a consumer to wonder why physics has nothing
|
|
486
|
+
* to work with.
|
|
487
|
+
*
|
|
488
|
+
* It is read by the ordinary reader and baked as its own asset. Carrying a hull *inside* the
|
|
489
|
+
* model's own file would mean defining `COLL`, which is a declared FourCC with no payload, no
|
|
490
|
+
* reader and no writer — a format decision about whether a hull is triangle soup or a set of
|
|
491
|
+
* convex hulls, and not one an import should make in passing.
|
|
492
|
+
*/
|
|
493
|
+
export function levelsBeside(model: string, names: readonly string[]): string[] {
|
|
494
|
+
/*
|
|
495
|
+
* `<name>_lod_<letter>.kn5` beside `<name>.kn5`, finest first, which is the order the letters
|
|
496
|
+
* already give. A **convention and not a statement in any file**, so a bundle naming its levels
|
|
497
|
+
* differently gets the model alone and the baker prints what it looked for.
|
|
498
|
+
*/
|
|
499
|
+
const stem = model.replace(/\.[^.]*$/, '');
|
|
500
|
+
const pattern = new RegExp(
|
|
501
|
+
`^${stem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_lod_([a-z])\\.kn5$`,
|
|
502
|
+
'i',
|
|
503
|
+
);
|
|
504
|
+
return names
|
|
505
|
+
.map((name) => ({ name, match: pattern.exec(name) }))
|
|
506
|
+
.filter((entry): entry is { name: string; match: RegExpExecArray } => entry.match !== null)
|
|
507
|
+
.sort((a, b) =>
|
|
508
|
+
(a.match[1] as string).toLowerCase().localeCompare((b.match[1] as string).toLowerCase()),
|
|
509
|
+
)
|
|
510
|
+
.map((entry) => entry.name);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function colliderBeside(names: readonly string[]): string | null {
|
|
514
|
+
return names.find((name) => name.toLowerCase() === 'collider.kn5') ?? null;
|
|
515
|
+
}
|
package/src/recognise.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 4: identify a container this project has no reader for, and say what to do instead.
|
|
3
|
+
*
|
|
4
|
+
* **The whole value is in the last part.** `.mb` and `.max` are proprietary binaries with no
|
|
5
|
+
* public specification, so there is nothing here that could grow into a reader, and pretending
|
|
6
|
+
* otherwise would be worse than refusing. What this turns is a confusing failure — an unknown
|
|
7
|
+
* extension, or a parser tripping over bytes it was never going to understand — into an
|
|
8
|
+
* instruction somebody can act on in the next thirty seconds.
|
|
9
|
+
*
|
|
10
|
+
* **Identified by content, not by extension.** A renamed file is common enough to matter: a
|
|
11
|
+
* `.max` that is really an FBX should be read, not refused, and a `.mb` renamed to `.fbx`
|
|
12
|
+
* should be refused rather than fed to a reader that will fail obscurely. The magic bytes
|
|
13
|
+
* are the fact; the extension is a claim.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** What a file turned out to be, and what a person should do with it. */
|
|
17
|
+
export interface Recognised {
|
|
18
|
+
/** Short name of the container, for the message. */
|
|
19
|
+
readonly format: string;
|
|
20
|
+
/** Why there is no reader, stated plainly. */
|
|
21
|
+
readonly because: string;
|
|
22
|
+
/** The export that does work, named specifically enough to follow. */
|
|
23
|
+
readonly instead: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Every byte sequence that identifies a container this project deliberately does not read. */
|
|
27
|
+
const SIGNATURES: readonly {
|
|
28
|
+
readonly format: string;
|
|
29
|
+
readonly bytes: readonly number[];
|
|
30
|
+
readonly at: number;
|
|
31
|
+
readonly because: string;
|
|
32
|
+
readonly instead: string;
|
|
33
|
+
}[] = [
|
|
34
|
+
{
|
|
35
|
+
/*
|
|
36
|
+
* Maya binary is an IFF container. Autodesk's own writes `FOR4`/`FOR8` with a `Maya`
|
|
37
|
+
* form type at byte 8, which is enough to be sure without claiming to understand a
|
|
38
|
+
* single chunk inside it.
|
|
39
|
+
*/
|
|
40
|
+
format: 'Maya binary (.mb)',
|
|
41
|
+
bytes: [0x46, 0x4f, 0x52], // 'FOR', then '4' or '8'
|
|
42
|
+
at: 0,
|
|
43
|
+
because:
|
|
44
|
+
'Maya binary is an IFF container with no public specification, so its chunks can be ' +
|
|
45
|
+
'walked but a scene cannot be reconstructed from them without guessing.',
|
|
46
|
+
instead:
|
|
47
|
+
'In Maya, File > Export All and choose FBX or glTF. Or save as .ma, the ASCII form, ' +
|
|
48
|
+
'which this project reads as a tier 2 experimental format.',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
/*
|
|
52
|
+
* 3ds Max writes an OLE2 compound document, the same container old Office files use, so
|
|
53
|
+
* the signature identifies the *wrapper* rather than the format. Said as much in the
|
|
54
|
+
* message: a `.doc` renamed to `.max` would land here too, and a reader that claimed
|
|
55
|
+
* otherwise would be lying about what it knows.
|
|
56
|
+
*/
|
|
57
|
+
format: '3ds Max (.max)',
|
|
58
|
+
bytes: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1],
|
|
59
|
+
at: 0,
|
|
60
|
+
because:
|
|
61
|
+
'A .max is an OLE compound document with no public specification for what is inside ' +
|
|
62
|
+
'it, and Autodesk ships no free scriptable converter worth depending on.',
|
|
63
|
+
instead:
|
|
64
|
+
'In 3ds Max, File > Export and choose glTF 2.0 (.gltf/.glb) or FBX. Both are read here, ' +
|
|
65
|
+
'and glTF is tier 1.',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** Containers that *are* read, so a renamed file is sent to its reader rather than refused. */
|
|
70
|
+
const READABLE: readonly {
|
|
71
|
+
readonly format: string;
|
|
72
|
+
readonly bytes: readonly number[];
|
|
73
|
+
readonly at: number;
|
|
74
|
+
}[] = [
|
|
75
|
+
{ format: 'FBX', bytes: [0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61], at: 0 }, // 'Kaydara'
|
|
76
|
+
{ format: 'glTF binary (.glb)', bytes: [0x67, 0x6c, 0x54, 0x46], at: 0 },
|
|
77
|
+
{ format: 'a zip container (.usdz, .3mf)', bytes: [0x50, 0x4b, 0x03, 0x04], at: 0 },
|
|
78
|
+
{ format: 'DRFT', bytes: [0x44, 0x52, 0x46, 0x54], at: 0 },
|
|
79
|
+
{ format: '.kn5', bytes: [0x73, 0x63, 0x36, 0x39, 0x36, 0x39], at: 0 }, // 'sc6969'
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
function matches(bytes: Uint8Array, signature: readonly number[], at: number): boolean {
|
|
83
|
+
if (bytes.length < at + signature.length) return false;
|
|
84
|
+
return signature.every((byte, index) => bytes[at + index] === byte);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What this file actually is, when it is something with no reader here.
|
|
89
|
+
*
|
|
90
|
+
* Returns null for anything readable, including a file whose extension is misleading — the
|
|
91
|
+
* caller then carries on and lets the real reader have it.
|
|
92
|
+
*/
|
|
93
|
+
export function recognise(bytes: Uint8Array): Recognised | null {
|
|
94
|
+
for (const known of READABLE) {
|
|
95
|
+
if (matches(bytes, known.bytes, known.at)) return null;
|
|
96
|
+
}
|
|
97
|
+
for (const signature of SIGNATURES) {
|
|
98
|
+
if (!matches(bytes, signature.bytes, signature.at)) continue;
|
|
99
|
+
if (signature.format.startsWith('Maya')) {
|
|
100
|
+
/* `FOR4`/`FOR8`, and Maya's own form type sits four bytes past the length. */
|
|
101
|
+
const digit = bytes[3];
|
|
102
|
+
const isForm = digit === 0x34 || digit === 0x38;
|
|
103
|
+
const maya = matches(bytes, [0x4d, 0x61, 0x79, 0x61], 8);
|
|
104
|
+
if (!isForm || !maya) continue;
|
|
105
|
+
}
|
|
106
|
+
return { format: signature.format, because: signature.because, instead: signature.instead };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The refusal, as one block of text a person can act on.
|
|
113
|
+
*
|
|
114
|
+
* Written here rather than at the call site so the CLI, a future editor and anything else
|
|
115
|
+
* that meets one of these files says the same thing. A refusal that varies by caller is a
|
|
116
|
+
* refusal somebody has to read twice.
|
|
117
|
+
*/
|
|
118
|
+
export function describeRecognised(found: Recognised, filename: string): string {
|
|
119
|
+
return (
|
|
120
|
+
`${filename} is ${found.format}, and there is no reader for it here (tier 4: recognised only).\n` +
|
|
121
|
+
` ${found.because}\n` +
|
|
122
|
+
` What works: ${found.instead}`
|
|
123
|
+
);
|
|
124
|
+
}
|