@threepipe/plugin-blend-importer 0.0.9 → 0.3.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/CHANGELOG.md +51 -2
- package/README.md +25 -1
- package/dist/BlendLoadPlugin.d.ts +63 -3
- package/dist/BlendLoadPlugin.d.ts.map +1 -1
- package/dist/decompress.d.ts +10 -0
- package/dist/decompress.d.ts.map +1 -0
- package/dist/dependencies.txt +31 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1351 -89
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1349 -87
- package/dist/index.mjs.map +1 -1
- package/dist/loader/camera.d.ts +1 -1
- package/dist/loader/ctx.d.ts +9 -1
- package/dist/loader/ctx.d.ts.map +1 -1
- package/dist/loader/geometry.d.ts +2 -2
- package/dist/loader/geometry.d.ts.map +1 -1
- package/dist/loader/index.d.ts +1 -1
- package/dist/loader/light.d.ts +1 -1
- package/dist/loader/material.d.ts +4 -1
- package/dist/loader/material.d.ts.map +1 -1
- package/dist/loader/mesh.d.ts +1 -1
- package/dist/loader/mesh.d.ts.map +1 -1
- package/docs/assets/highlight.css +49 -0
- package/docs/assets/icons.js +1 -1
- package/docs/assets/icons.svg +1 -1
- package/docs/assets/main.js +2 -2
- package/docs/assets/navigation.js +1 -1
- package/docs/assets/search.js +1 -1
- package/docs/assets/style.css +3 -3
- package/docs/classes/BlendLoadPlugin.html +6 -3
- package/docs/index.html +1 -1
- package/docs/interfaces/BlendFile.html +18 -0
- package/docs/interfaces/BlendLoadOptions.html +12 -0
- package/package.json +4 -3
- package/src/BlendLoadPlugin.ts +204 -13
- package/src/decompress.ts +59 -0
- package/src/index.ts +1 -0
- package/src/js-blend/main.js +4 -1
- package/src/js-blend/parser/parser.js +192 -52
- package/src/loader/ctx.ts +9 -0
- package/src/loader/geometry.ts +270 -13
- package/src/loader/material.ts +324 -8
- package/src/loader/mesh.ts +34 -8
- package/tsconfig.json +42 -0
- package/docs/.nojekyll +0 -1
package/src/BlendLoadPlugin.ts
CHANGED
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
BaseImporterPlugin,
|
|
5
5
|
BufferAttribute,
|
|
6
6
|
BufferGeometry2,
|
|
7
|
+
DataTexture,
|
|
7
8
|
DirectionalLight2,
|
|
8
9
|
FileLoader,
|
|
10
|
+
IAssetImporter,
|
|
9
11
|
ILoader,
|
|
10
12
|
Importer, Mesh,
|
|
11
13
|
Mesh2,
|
|
@@ -15,14 +17,185 @@ import {
|
|
|
15
17
|
PerspectiveCamera0,
|
|
16
18
|
PhysicalMaterial,
|
|
17
19
|
PointLight2,
|
|
18
|
-
Scene,
|
|
20
|
+
Scene,
|
|
21
|
+
Source,
|
|
22
|
+
SpotLight2,
|
|
23
|
+
SRGBColorSpace,
|
|
24
|
+
Texture,
|
|
19
25
|
UnlitMaterial,
|
|
20
26
|
} from 'threepipe'
|
|
21
27
|
import {parseBlend} from './js-blend/main.js'
|
|
22
28
|
import {createObjects} from './loader'
|
|
29
|
+
import {decompressBlend} from './decompress'
|
|
30
|
+
|
|
31
|
+
interface ExternalTextureRequest { texture: Texture, url: string, srgb: boolean, path: string }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a Blender Image file path to an absolute URL relative to the `.blend`, and return a placeholder
|
|
35
|
+
* {@link Texture} plus a request to import it later. Resolution happens NOW, while the importer's root
|
|
36
|
+
* context for this `.blend` is active — the actual import is deferred (see {@link importExternalTexture})
|
|
37
|
+
* because importing re-enters the AssetImporter and would clobber that root context for sibling textures.
|
|
38
|
+
*
|
|
39
|
+
* Blender path conventions: leading `//` means relative to the `.blend`'s directory; `\` separators and a
|
|
40
|
+
* leading `./` are normalised. Returns `null` in Node (no DOM) or for an empty path.
|
|
41
|
+
*/
|
|
42
|
+
function resolveExternalTexture(blenderPath: string, srgb: boolean, importer: IAssetImporter, blendUrl: string): ExternalTextureRequest | null {
|
|
43
|
+
if (typeof document === 'undefined') return null // image decoding needs the DOM
|
|
44
|
+
const path = blenderPath.replace(/^\/\//, '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
45
|
+
if (!path) return null
|
|
46
|
+
let url = importer.resolveURL(path)
|
|
47
|
+
// Fall back to joining against the .blend's directory if the importer didn't make it absolute
|
|
48
|
+
// (e.g. the root context wasn't applied) — keeps relative sibling textures resolvable.
|
|
49
|
+
if (url === path && !/^([a-z][a-z0-9+.-]*:|\/|blob:|data:)/i.test(url)) {
|
|
50
|
+
url = blendUrl.replace(/[?#].*$/, '').replace(/[^/]*$/, '') + path
|
|
51
|
+
}
|
|
52
|
+
// Start as a benign 1x1 texture so a failed/unsupported import leaves a harmless map (white) instead of
|
|
53
|
+
// an undefined-image texture that crashes the GPU upload. The placeholder must match the imported
|
|
54
|
+
// texture's CLASS: EXR/HDR import as a DataTexture (float data, different GPU upload path) — a regular
|
|
55
|
+
// Texture placeholder would crash on upload even after copying. Replaced by the real image on success.
|
|
56
|
+
// Detect EXR/HDR from the Blender PATH's extension, not the resolved `url`: a dropped/registered file
|
|
57
|
+
// (folder drop, importFiles) resolves to a blob: URL with the real path in the #fragment
|
|
58
|
+
// (`blob:…uuid#/dir/x.exr`), so testing `url` would miss the extension and wrongly pick a non-data
|
|
59
|
+
// Texture placeholder — breaking the GPU upload (texSubImage2D) and the UI preview (putImageData) of
|
|
60
|
+
// EXR/HDR maps. `path` always carries the true extension.
|
|
61
|
+
const isData = /\.(exr|hdr|rgbe)$/i.test(path.split(/[?#]/)[0])
|
|
62
|
+
let texture: Texture
|
|
63
|
+
if (isData) {
|
|
64
|
+
texture = new DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1)
|
|
65
|
+
} else {
|
|
66
|
+
const canvas = document.createElement('canvas')
|
|
67
|
+
canvas.width = canvas.height = 1
|
|
68
|
+
const c2d = canvas.getContext('2d')
|
|
69
|
+
if (c2d) { c2d.fillStyle = '#ffffff'; c2d.fillRect(0, 0, 1, 1) }
|
|
70
|
+
texture = new Texture(canvas)
|
|
71
|
+
}
|
|
72
|
+
texture.needsUpdate = true
|
|
73
|
+
if (srgb) texture.colorSpace = SRGBColorSpace
|
|
74
|
+
return {texture, url, srgb, path}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Vertically flip a DataTexture's pixel rows into a PRIVATE copy and reseat its `source`.
|
|
79
|
+
*
|
|
80
|
+
* Why: EXR/HDR import as `DataTexture`s with `flipY=false`, while the blend importer's raster maps
|
|
81
|
+
* (jpg/png via the AssetImporter) import as image textures with `flipY=true`. WebGL's `UNPACK_FLIP_Y`
|
|
82
|
+
* only applies to DOM/ImageBitmap uploads, NOT to `ArrayBufferView` (DataTexture) uploads — so a data
|
|
83
|
+
* map ends up sampled vertically mirrored relative to the raster maps on the SAME UVs. On a material
|
|
84
|
+
* that mixes them (e.g. jpg base color + EXR normal/roughness) the normal/rough/displacement detail
|
|
85
|
+
* lands upside-down and fights the diffuse — the normal map appears not to work. Pre-flipping the data
|
|
86
|
+
* makes a `flipY=false` data texture sample identically to a `flipY=true` raster, restoring alignment;
|
|
87
|
+
* three + Blender share the OpenGL (`nor_gl`) green-up convention, so `normalScale` stays positive.
|
|
88
|
+
*
|
|
89
|
+
* The flip goes into a fresh typed array + `Source` so we never mutate the AssetImporter-CACHED source
|
|
90
|
+
* (the same EXR is reused across materials — Plane and Sphere here share one normal map).
|
|
91
|
+
*/
|
|
92
|
+
function flipDataTextureRowsY(tex: DataTexture): void {
|
|
93
|
+
const img: any = tex.image
|
|
94
|
+
const data: any = img?.data
|
|
95
|
+
if (!data?.length || !img.width || !img.height) return
|
|
96
|
+
const {width, height} = img
|
|
97
|
+
const elemsPerRow = data.length / height // width * channels-per-pixel
|
|
98
|
+
if (!Number.isInteger(elemsPerRow)) return
|
|
99
|
+
const flipped = new (data.constructor as new (n: number) => typeof data)(data.length)
|
|
100
|
+
for (let y = 0; y < height; y++) {
|
|
101
|
+
flipped.set(data.subarray(y * elemsPerRow, (y + 1) * elemsPerRow), (height - 1 - y) * elemsPerRow)
|
|
102
|
+
}
|
|
103
|
+
const newImage = {data: flipped, width, height}
|
|
104
|
+
tex.image = newImage
|
|
105
|
+
tex.source = new Source(newImage)
|
|
106
|
+
tex.needsUpdate = true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Import a resolved external texture through threepipe's {@link IAssetImporter} — which selects the correct
|
|
111
|
+
* loader per format (EXR/HDR/KTX2/JPG/PNG/WebP/...) and applies its cache and progress tracking, so any
|
|
112
|
+
* format threepipe supports works, not just browser-native rasters. Fills the placeholder texture from the
|
|
113
|
+
* result. Missing/unsupported files log a warning and resolve (no throw) so one bad path doesn't fail the
|
|
114
|
+
* whole import.
|
|
115
|
+
*/
|
|
116
|
+
async function importExternalTexture(req: ExternalTextureRequest, importer: IAssetImporter): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
const imported: any = await importer.importSingle<Texture>(req.url)
|
|
119
|
+
if (imported && imported.isTexture && imported.image) {
|
|
120
|
+
// Copy the FULL texture state — source, type, format, filters, flipY — not just the image, so a
|
|
121
|
+
// HalfFloat/Float DataTexture (EXR/HDR) uploads with the correct type (copying only the source
|
|
122
|
+
// onto a default UnsignedByte texture crashes the GPU upload). But preserve the wrap mode the
|
|
123
|
+
// loader set from the Blender node's Extension (copy would overwrite it with the importer's
|
|
124
|
+
// ClampToEdge default → tiled UVs outside [0,1] would clamp/smear instead of repeat).
|
|
125
|
+
const wrapS = req.texture.wrapS, wrapT = req.texture.wrapT
|
|
126
|
+
req.texture.copy(imported)
|
|
127
|
+
req.texture.wrapS = wrapS; req.texture.wrapT = wrapT
|
|
128
|
+
req.texture.colorSpace = req.srgb ? SRGBColorSpace : imported.colorSpace
|
|
129
|
+
// Align flipY=false data textures (EXR/HDR) to the flipY=true raster convention — see helper.
|
|
130
|
+
if (imported.isDataTexture && imported.flipY === false) flipDataTextureRowsY(req.texture as DataTexture)
|
|
131
|
+
req.texture.needsUpdate = true
|
|
132
|
+
} else {
|
|
133
|
+
console.warn('BlendLoadPlugin - external texture not found/unsupported:', req.path)
|
|
134
|
+
}
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.warn('BlendLoadPlugin - external texture failed:', req.path, e)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Shape of the parsed Blender file passed to {@link BlendLoadOptions.onBlendLoad}.
|
|
142
|
+
*
|
|
143
|
+
* This is the raw `js.blend` FILE object — see `src/js-blend/parser/parser.js` for the
|
|
144
|
+
* full DNA tree. Notable fields are typed here; everything else is untyped under `[k: string]`.
|
|
145
|
+
*/
|
|
146
|
+
export interface BlendFile {
|
|
147
|
+
/** Three.js root constructed from the file's Blender objects. Same object returned as the Scene. */
|
|
148
|
+
scene: Object3D
|
|
149
|
+
/**
|
|
150
|
+
* Embedded preview thumbnail (RGBA8, packed one pixel per `Uint32`). Absent on very old saves
|
|
151
|
+
* or files saved with preview disabled. Typical size 128×128.
|
|
152
|
+
* Convert via `<canvas>.createImageData` for display.
|
|
153
|
+
*/
|
|
154
|
+
thumbnail?: {width: number, height: number, data: Uint32Array}
|
|
155
|
+
/** Parsed Blender objects keyed by SDNA struct type, e.g. `objects.Object`, `objects.Mesh`, `objects.Material`. */
|
|
156
|
+
objects: Record<string, any[]>
|
|
157
|
+
/** SDNA template — Blender's runtime struct definitions parsed out of the DNA1 block. */
|
|
158
|
+
template: any
|
|
159
|
+
/** Memory-address-keyed lookup of every parsed block. */
|
|
160
|
+
memory_lookup: Record<string, any>
|
|
161
|
+
/** Raw `ArrayBuffer` of the decompressed `.blend` payload. */
|
|
162
|
+
AB: ArrayBuffer
|
|
163
|
+
[k: string]: any
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Loader options consumed by `BlendLoadPlugin`. Pass via `viewer.load(url, options)`.
|
|
168
|
+
*/
|
|
169
|
+
export interface BlendLoadOptions {
|
|
170
|
+
/**
|
|
171
|
+
* Called once after parsing, before the Scene is returned to the asset pipeline. Use to read
|
|
172
|
+
* metadata that isn't represented in the three.js Scene — Blender's embedded thumbnail, raw
|
|
173
|
+
* DNA struct data, library/kinematics references, etc.
|
|
174
|
+
*
|
|
175
|
+
* The callback's return value is ignored. Don't put data here onto `userData` (the import/export
|
|
176
|
+
* pipeline will round-trip it as JSON — large typed arrays like the thumbnail will balloon
|
|
177
|
+
* exports or fail serialization).
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```ts
|
|
181
|
+
* viewer.load('scene.blend', {
|
|
182
|
+
* onBlendLoad: (blend) => {
|
|
183
|
+
* if (blend.thumbnail) {
|
|
184
|
+
* // render the embedded preview to a <canvas>...
|
|
185
|
+
* }
|
|
186
|
+
* },
|
|
187
|
+
* })
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
onBlendLoad?: (blend: BlendFile) => void
|
|
191
|
+
}
|
|
23
192
|
|
|
24
193
|
/**
|
|
25
|
-
* Adds support for loading
|
|
194
|
+
* Adds support for loading Blender `.blend`, `application/x-blender` files and data URIs.
|
|
195
|
+
*
|
|
196
|
+
* Handles uncompressed, gzip-compressed (Blender ≤ 2.93), and zstd-compressed (Blender 3.0+ default)
|
|
197
|
+
* files. See {@link BlendLoadOptions} for callback hooks, including access to the embedded thumbnail
|
|
198
|
+
* and the raw parsed DNA tree.
|
|
26
199
|
*/
|
|
27
200
|
export class BlendLoadPlugin extends BaseImporterPlugin {
|
|
28
201
|
public static readonly PluginType = 'BlendLoadPlugin'
|
|
@@ -30,11 +203,23 @@ export class BlendLoadPlugin extends BaseImporterPlugin {
|
|
|
30
203
|
super()
|
|
31
204
|
}
|
|
32
205
|
protected _importer = new Importer(class extends FileLoader implements ILoader {
|
|
206
|
+
// The AssetImporter that constructed this loader (injected via the Importer onCtor below). Used to
|
|
207
|
+
// load external textures through the full pipeline (correct loader per format + cache).
|
|
208
|
+
assetImporter?: IAssetImporter
|
|
33
209
|
async loadAsync(url: string, onProgress?: (event: ProgressEvent) => void): Promise<any> {
|
|
34
210
|
this.setResponseType('arraybuffer')
|
|
35
|
-
|
|
36
|
-
const
|
|
211
|
+
let res: ArrayBuffer | null = (await super.loadAsync(url, onProgress)) as ArrayBuffer
|
|
212
|
+
const decompressed = decompressBlend(res)
|
|
213
|
+
// Drop the reference to the original (possibly compressed) buffer so it can be
|
|
214
|
+
// garbage-collected while the parser works on the decompressed copy.
|
|
215
|
+
res = null
|
|
216
|
+
const blend = await parseBlend(decompressed)
|
|
37
217
|
// console.log(bakeGetters(blend))
|
|
218
|
+
// External textures: resolve each path NOW (the importer's root context for this .blend is
|
|
219
|
+
// active during createObjects), then import them all AFTER createObjects — importing re-enters
|
|
220
|
+
// the AssetImporter and would otherwise clobber the root context for sibling textures.
|
|
221
|
+
const importer = this.assetImporter
|
|
222
|
+
const texReqs: ExternalTextureRequest[] = []
|
|
38
223
|
const ctx = {
|
|
39
224
|
Object3D: Object3D2,
|
|
40
225
|
Mesh: Mesh2 as typeof Mesh,
|
|
@@ -48,25 +233,31 @@ export class BlendLoadPlugin extends BaseImporterPlugin {
|
|
|
48
233
|
AmbientLight: AmbientLight2,
|
|
49
234
|
BufferGeometry: BufferGeometry2,
|
|
50
235
|
BufferAttribute: BufferAttribute,
|
|
236
|
+
loadExternalTexture: importer
|
|
237
|
+
? (p: string, srgb: boolean) => {
|
|
238
|
+
const req = resolveExternalTexture(p, srgb, importer, url)
|
|
239
|
+
if (req) texReqs.push(req)
|
|
240
|
+
return req ? req.texture : null
|
|
241
|
+
}
|
|
242
|
+
: undefined,
|
|
51
243
|
}
|
|
52
244
|
const objects = await createObjects(blend, ctx)
|
|
245
|
+
if (importer && texReqs.length) await Promise.all(texReqs.map(r => importExternalTexture(r, importer)))
|
|
53
246
|
const root = new Object3D()
|
|
54
247
|
root.add(...objects)
|
|
55
|
-
// root.userData.autoScaled = true
|
|
56
|
-
// root.userData.autoCentered = true
|
|
57
|
-
// console.log(res, blend, root)
|
|
58
248
|
blend.scene = root
|
|
59
249
|
return blend
|
|
60
250
|
}
|
|
61
251
|
|
|
62
|
-
transform(res:
|
|
63
|
-
// console.log(res)
|
|
64
|
-
// res.scene.userData.kinematics = res.kinematics
|
|
65
|
-
// res.scene.userData.library = res.library
|
|
252
|
+
transform(res: BlendFile, options: AnyOptions & BlendLoadOptions): Scene {
|
|
66
253
|
if (typeof options.onBlendLoad === 'function') {
|
|
67
254
|
options.onBlendLoad(res)
|
|
68
255
|
}
|
|
69
|
-
return res.scene
|
|
256
|
+
return res.scene as unknown as Scene
|
|
70
257
|
}
|
|
71
|
-
}, ['blend'], ['application/x-blender'], true)
|
|
258
|
+
}, ['blend'], ['application/x-blender'], true, (loader, assetImporter) => {
|
|
259
|
+
// Inject the AssetImporter so the loader can import external textures through the full pipeline.
|
|
260
|
+
if (loader) (loader as any).assetImporter = assetImporter
|
|
261
|
+
return loader
|
|
262
|
+
})
|
|
72
263
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {gunzipSync} from 'threepipe'
|
|
2
|
+
import {decompress as zstdDecompress} from 'fzstd'
|
|
3
|
+
|
|
4
|
+
type BlendCompression = 'none' | 'gzip' | 'zstd' | 'unknown'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect compression of a .blend file by its first 4-7 bytes.
|
|
8
|
+
* Mirrors Blender's own `BLO_file_reader_uncompressed` logic
|
|
9
|
+
* (`source/blender/blenloader_core/intern/blo_core_file_reader.cc`).
|
|
10
|
+
*/
|
|
11
|
+
function detectBlendCompression(u8: Uint8Array): BlendCompression {
|
|
12
|
+
// gzip needs 3 bytes, zstd 4, BLENDER literal 7 — check each at its own minimum.
|
|
13
|
+
if (u8.length >= 3 && u8[0] === 0x1f && u8[1] === 0x8b && u8[2] === 0x08) return 'gzip'
|
|
14
|
+
if (u8.length >= 4) {
|
|
15
|
+
// zstd frame magic: 28 b5 2f fd (LE 0xFD2FB528)
|
|
16
|
+
if (u8[0] === 0x28 && u8[1] === 0xb5 && u8[2] === 0x2f && u8[3] === 0xfd) return 'zstd'
|
|
17
|
+
// zstd skippable frame magic: 50..5f 2a 4d 18 ((LE u32 >> 4) === 0x184D2A5)
|
|
18
|
+
if ((u8[0] & 0xf0) === 0x50 && u8[1] === 0x2a && u8[2] === 0x4d && u8[3] === 0x18) return 'zstd'
|
|
19
|
+
}
|
|
20
|
+
if (u8.length >= 7 &&
|
|
21
|
+
u8[0] === 0x42 && u8[1] === 0x4c && u8[2] === 0x45 && u8[3] === 0x4e &&
|
|
22
|
+
u8[4] === 0x44 && u8[5] === 0x45 && u8[6] === 0x52) return 'none'
|
|
23
|
+
return 'unknown'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toArrayBuffer(u8: Uint8Array): ArrayBuffer {
|
|
27
|
+
// Short-circuit when the Uint8Array exactly spans its backing buffer
|
|
28
|
+
// (the common case for fresh gunzipSync / fzstd.decompress allocations).
|
|
29
|
+
if (u8.byteOffset === 0 && u8.byteLength === u8.buffer.byteLength) return u8.buffer as ArrayBuffer
|
|
30
|
+
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decompress a `.blend` payload if needed and return a plain `ArrayBuffer`.
|
|
35
|
+
* Returns the input unchanged for uncompressed files.
|
|
36
|
+
*
|
|
37
|
+
* Throws if the file magic is unrecognised, or if a decompressor produces
|
|
38
|
+
* output too short to be a valid .blend header (which would otherwise hang
|
|
39
|
+
* the downstream parser).
|
|
40
|
+
*/
|
|
41
|
+
export function decompressBlend(buf: ArrayBuffer): ArrayBuffer {
|
|
42
|
+
const u8 = new Uint8Array(buf)
|
|
43
|
+
const kind = detectBlendCompression(u8)
|
|
44
|
+
if (kind === 'none') return buf
|
|
45
|
+
let out: ArrayBuffer
|
|
46
|
+
if (kind === 'gzip') out = toArrayBuffer(gunzipSync(u8))
|
|
47
|
+
else if (kind === 'zstd') out = toArrayBuffer(zstdDecompress(u8))
|
|
48
|
+
else {
|
|
49
|
+
const hex = [u8[0], u8[1], u8[2], u8[3]].map(b => (b ?? 0).toString(16).padStart(2, '0')).join(' ')
|
|
50
|
+
throw new Error(`BlendLoadPlugin: unrecognised file magic (first 4 bytes: ${hex}). Expected 'BLENDER' literal, gzip, or zstd.`)
|
|
51
|
+
}
|
|
52
|
+
// Parser reads the BLENDER-v### header (12 bytes) unconditionally; reject
|
|
53
|
+
// truncated/malformed decompressor output here so parseBlend doesn't throw
|
|
54
|
+
// a RangeError inside its sync onmessage handler (which would never resolve).
|
|
55
|
+
if (out.byteLength < 12) {
|
|
56
|
+
throw new Error(`BlendLoadPlugin: ${kind} decompression produced ${out.byteLength} bytes, too short to be a valid .blend file.`)
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
package/src/index.ts
CHANGED
package/src/js-blend/main.js
CHANGED
|
@@ -13,7 +13,10 @@ import parser from './parser/parser.js';
|
|
|
13
13
|
export async function parseBlend (buffer, name = '') {
|
|
14
14
|
return new Promise((res, rej) => {
|
|
15
15
|
parser.onParseReady = (file, error) => {
|
|
16
|
-
//
|
|
16
|
+
// Soft-error: log but still resolve with the partial result. Several
|
|
17
|
+
// .blend files (notably geometry-nodes scenes in Blender 4.x) trigger
|
|
18
|
+
// mid-parse errors but produce a usable partial scene; rejecting would
|
|
19
|
+
// strip all rendered geometry. todo Re-evaluate if we should throw once the parser is hardened.
|
|
17
20
|
if (error) console.error(error)
|
|
18
21
|
res(file)
|
|
19
22
|
}
|