@threepipe/plugin-blend-importer 0.1.0 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +45 -3
  2. package/README.md +25 -1
  3. package/dist/BlendLoadPlugin.d.ts +63 -3
  4. package/dist/BlendLoadPlugin.d.ts.map +1 -1
  5. package/dist/decompress.d.ts +10 -0
  6. package/dist/decompress.d.ts.map +1 -0
  7. package/dist/dependencies.txt +31 -1
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1351 -89
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1349 -87
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/loader/camera.d.ts +1 -1
  15. package/dist/loader/ctx.d.ts +9 -1
  16. package/dist/loader/ctx.d.ts.map +1 -1
  17. package/dist/loader/geometry.d.ts +2 -2
  18. package/dist/loader/geometry.d.ts.map +1 -1
  19. package/dist/loader/index.d.ts +1 -1
  20. package/dist/loader/light.d.ts +1 -1
  21. package/dist/loader/material.d.ts +4 -1
  22. package/dist/loader/material.d.ts.map +1 -1
  23. package/dist/loader/mesh.d.ts +1 -1
  24. package/dist/loader/mesh.d.ts.map +1 -1
  25. package/docs/assets/highlight.css +49 -0
  26. package/docs/assets/icons.js +1 -1
  27. package/docs/assets/icons.svg +1 -1
  28. package/docs/assets/main.js +2 -2
  29. package/docs/assets/navigation.js +1 -1
  30. package/docs/assets/search.js +1 -1
  31. package/docs/assets/style.css +3 -3
  32. package/docs/classes/BlendLoadPlugin.html +6 -3
  33. package/docs/index.html +1 -1
  34. package/docs/interfaces/BlendFile.html +18 -0
  35. package/docs/interfaces/BlendLoadOptions.html +12 -0
  36. package/package.json +1 -1
  37. package/src/BlendLoadPlugin.ts +204 -13
  38. package/src/decompress.ts +59 -0
  39. package/src/index.ts +1 -0
  40. package/src/js-blend/main.js +4 -1
  41. package/src/js-blend/parser/parser.js +192 -52
  42. package/src/loader/ctx.ts +9 -0
  43. package/src/loader/geometry.ts +270 -13
  44. package/src/loader/material.ts +324 -8
  45. package/src/loader/mesh.ts +34 -8
  46. package/tsconfig.json +1 -0
  47. package/docs/.nojekyll +0 -1
@@ -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, SpotLight2,
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 Blend `.blend`, `application/x-blender` files and data uris
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
- const res = (await super.loadAsync(url, onProgress)) as ArrayBuffer
36
- const blend = await parseBlend(res)
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: any, options: AnyOptions): Scene {
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
@@ -1 +1,2 @@
1
1
  export {BlendLoadPlugin} from './BlendLoadPlugin'
2
+ export type {BlendFile, BlendLoadOptions} from './BlendLoadPlugin'
@@ -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
- // todo throw error if no objects?
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
  }