@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +51 -2
  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 +4 -3
  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 +42 -0
  47. package/docs/.nojekyll +0 -1
@@ -1,14 +1,330 @@
1
- // todo see blender gltf exporter and convert to js. structure is the same
2
- // https://github.com/KhronosGroup/glTF-Blender-IO/blob/ed5100ab6c40472b7c3254fddfe0dd0d76d60644/addons/io_scene_gltf2/blender/exp/material/materials.py#L60
1
+ import {ClampToEdgeWrapping, DoubleSide, FrontSide, MirroredRepeatWrapping, RepeatWrapping, SRGBColorSpace, Texture} from 'threepipe'
3
2
  import {Ctx} from './ctx'
4
3
 
4
+ // Material.blend_flag bit for the "Backface Culling" toggle (DNA_material_types.h: MA_BL_CULL_BACKFACE).
5
+ // The Python `use_backface_culling` property binds to this exact bit (rna_material.cc:1136).
6
+ const MA_BL_CULL_BACKFACE = 1 << 2
7
+ // Material.blend_method: alpha-clip cutout (DNA_material_types.h: MA_BM_CLIP). HASHED(4)/BLEND(5)/SOLID(0)
8
+ // map to alpha blend; only CLIP becomes an alpha test.
9
+ const MA_BM_CLIP = 3
10
+
11
+ // Faithful-ish port of Blender's glTF exporter material extraction (Principled BSDF -> glTF PBR),
12
+ // mapped onto three.js MeshPhysicalMaterial (which is glTF-first). Reference:
13
+ // .repos/gltf-blender-io/addons/io_scene_gltf2/blender/exp/material/{pbr_metallic_roughness,materials,search_node_tree}.py
14
+ // We read the shader that feeds the active Material Output, take each property from its named socket
15
+ // (constant default or a packed Image Texture), and apply the same defaults/clamps the exporter uses.
16
+ // Only packed images load (external file-path images are skipped). Linear vs sRGB matches glTF convention:
17
+ // base color + emissive factors are linear (Blender scene-linear == three.js linear); base/emissive
18
+ // textures are sRGB, all other maps (roughness/metallic/normal) are linear.
19
+
20
+ // ── Node-tree helpers ───────────────────────────────────────────────
21
+ function listBaseToArray(lb: any): any[] {
22
+ const out: any[] = []
23
+ if (!lb) return out
24
+ let n = lb.first, guard = 0
25
+ while (n && guard++ < 8192) { out.push(n); n = n.next }
26
+ return out
27
+ }
28
+ const idnameOf = (n: any): string => (n && typeof n.idname === 'string' ? n.idname : '')
29
+ const nameOf = (s: any): string => (s && typeof s.name === 'string' ? s.name : '')
30
+
31
+ function socketByName(sockets: any[], ...names: string[]): any {
32
+ for (const name of names) {
33
+ const s = sockets.find(x => nameOf(x) === name)
34
+ if (s) return s
35
+ }
36
+ return null
37
+ }
38
+ function inputsOf(node: any): any[] { return node ? listBaseToArray(node.inputs) : [] }
39
+
40
+ // A socket's constant value (bNodeSocketValueRGBA/Float/Vector expose default_value.value).
41
+ function socketConst(sock: any): any {
42
+ const dv = sock && sock.default_value
43
+ return dv ? dv.value : undefined
44
+ }
45
+ const num = (v: any): number | undefined => (typeof v === 'number' ? v : undefined)
46
+ const clamp01 = (c: number) => Math.max(0, Math.min(1, c))
47
+
48
+ // Follow the link into `socket`, hopping through Reroute nodes, and return the source node.
49
+ function nodeFeeding(links: any[], socket: any): any {
50
+ if (!socket) return null
51
+ let link = links.find(l => l.tosock === socket)
52
+ let guard = 0
53
+ while (link && idnameOf(link.fromnode).includes('Reroute') && guard++ < 32) {
54
+ const re = inputsOf(link.fromnode)[0]
55
+ link = links.find(l => l.tosock === re)
56
+ }
57
+ return link ? link.fromnode : null
58
+ }
59
+
60
+ // ── Packed image -> three.js Texture ────────────────────────────────
61
+ function imageMime(b: Uint8Array): string | null {
62
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
63
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
64
+ if (b[0] === 0x42 && b[1] === 0x4d) return 'image/bmp'
65
+ if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45) return 'image/webp'
66
+ return null
67
+ }
68
+ // Load the packed image of an Image Texture node. `srgb` for color maps (base/emissive); false for data maps.
69
+ function packedTexture(imageNode: any, srgb: boolean): Texture | null {
70
+ const img = imageNode && imageNode.id
71
+ if (!img) return null
72
+ const pf = img.packedfile || (img.packedfiles && img.packedfiles.first && img.packedfiles.first.packedfile)
73
+ const block = pf && pf.data
74
+ const size = pf && typeof pf.size === 'number' ? pf.size : 0
75
+ if (!block || block.__data_address__ === undefined || size <= 0) return null
76
+ // Bounds-check before constructing the view: a stale/corrupt PackedFile.size or address would otherwise
77
+ // make `new Uint8Array(buffer, offset, size)` throw a RangeError that bubbles up and fails the whole
78
+ // import. Skip the one bad texture instead.
79
+ const buf = block.__blender_file__.byte.buffer
80
+ if (block.__data_address__ + size > buf.byteLength) return null
81
+ const bytes = new Uint8Array(buf, block.__data_address__, size).slice()
82
+ const mime = imageMime(bytes)
83
+ if (!mime || typeof Blob === 'undefined' || typeof URL === 'undefined' || typeof Image === 'undefined') return null
84
+ const url = URL.createObjectURL(new Blob([bytes], {type: mime}))
85
+ const el = new Image()
86
+ const texture = new Texture(el)
87
+ if (srgb) texture.colorSpace = SRGBColorSpace
88
+ texture.name = (typeof img.aname === 'string' ? img.aname : '') || 'blendTexture'
89
+ el.onload = () => { texture.needsUpdate = true; URL.revokeObjectURL(url) }
90
+ el.onerror = () => { URL.revokeObjectURL(url) }
91
+ el.src = url
92
+ return texture
93
+ }
94
+ // Image source enum (DNA_image_types.h): GENERATED = 4, VIEWER = 5 have no backing file.
95
+ const IMA_SRC_GENERATED = 4, IMA_SRC_VIEWER = 5
96
+ // Resolve an Image Texture node's image to a Texture: packed bytes if embedded, else an external file
97
+ // load through threepipe's pipeline (ctx.loadExternalTexture, when provided). External images reference a
98
+ // file by path in `Image.name` (e.g. "//tex/wood.png"). `source` is usually IMA_SRC_FILE (1), but some
99
+ // files store 0 for a file reference that wasn't loaded at save time — so treat any non-packed image with
100
+ // a path as external, excluding only GENERATED/VIEWER (which have no real file).
101
+ // Map the Image Texture node's "Extension" (NodeTexImage.extension) to a three.js wrap mode. Blender's
102
+ // DEFAULT is Repeat (0) — three.js defaults to ClampToEdge, so without this any mesh whose UVs fall outside
103
+ // [0,1] (very common: tiled materials, the Poly Haven preview sphere whose unwrap spans U[-0.5,1.5]) clamps
104
+ // to the edge texel and smears it into stripes instead of tiling. SHD_IMAGE_EXTENSION_*: 0 Repeat, 1 Extend,
105
+ // 2 Clip, 3 Mirror.
106
+ function applyWrap(tex: Texture | null, imageNode: any): Texture | null {
107
+ if (!tex) return tex
108
+ const ext = imageNode?.storage?.extension
109
+ tex.wrapS = tex.wrapT = ext === 1 || ext === 2 ? ClampToEdgeWrapping : ext === 3 ? MirroredRepeatWrapping : RepeatWrapping
110
+ tex.needsUpdate = true
111
+ return tex
112
+ }
113
+ function imageNodeTexture(imageNode: any, srgb: boolean, ctx: Ctx): Texture | null {
114
+ const packed = packedTexture(imageNode, srgb)
115
+ if (packed) return applyWrap(packed, imageNode)
116
+ const img = imageNode && imageNode.id
117
+ if (img && img.source !== IMA_SRC_GENERATED && img.source !== IMA_SRC_VIEWER
118
+ && typeof img.name === 'string' && img.name && ctx.loadExternalTexture) {
119
+ return applyWrap(ctx.loadExternalTexture(img.name, srgb), imageNode)
120
+ }
121
+ return null
122
+ }
123
+ // The Image Texture node feeding a socket (directly, or through a Normal Map node for normals), or null.
124
+ function imageNodeFeeding(links: any[], socket: any): any {
125
+ let src = nodeFeeding(links, socket)
126
+ if (src && idnameOf(src).includes('NormalMap')) src = nodeFeeding(links, socketByName(inputsOf(src), 'Color'))
127
+ return src && idnameOf(src).includes('TexImage') ? src : null
128
+ }
129
+ // Trace a socket to its Image Texture node and load it.
130
+ function textureFromSocket(links: any[], socket: any, srgb: boolean, ctx: Ctx): Texture | null {
131
+ const src = imageNodeFeeding(links, socket)
132
+ return src ? imageNodeTexture(src, srgb, ctx) : null
133
+ }
134
+
135
+ // ── Surface shader resolution ───────────────────────────────────────
136
+ // Prefer the Principled BSDF feeding the active Material Output (matches the exporter's
137
+ // check_if_is_linked_to_active_output); fall back to any Principled in the tree.
138
+ function resolveShaders(nodes: any[], links: any[]): {principled: any, emission: any} {
139
+ const outputs = nodes.filter(n => idnameOf(n).includes('OutputMaterial'))
140
+ const activeOut = outputs.find(n => n.is_active_output) || outputs[0]
141
+ let principled: any = null, emission: any = null
142
+ if (activeOut) {
143
+ let surf = nodeFeeding(links, socketByName(inputsOf(activeOut), 'Surface'))
144
+ // hop through a single Mix/Add Shader to find a Principled, best-effort
145
+ let guard = 0
146
+ while (surf && !idnameOf(surf).includes('BsdfPrincipled') && guard++ < 4) {
147
+ if (idnameOf(surf).includes('Emission') && !emission) emission = surf
148
+ const next = inputsOf(surf).map(s => nodeFeeding(links, s)).find(n => n && idnameOf(n).includes('BsdfPrincipled'))
149
+ if (!next) break
150
+ surf = next
151
+ }
152
+ if (surf && idnameOf(surf).includes('BsdfPrincipled')) principled = surf
153
+ }
154
+ if (!principled) principled = nodes.find(n => idnameOf(n).includes('BsdfPrincipled'))
155
+ if (!emission) emission = nodes.find(n => idnameOf(n) === 'ShaderNodeEmission')
156
+ return {principled, emission}
157
+ }
158
+
159
+ /**
160
+ * Build a PhysicalMaterial from a Blender material, following Blender's glTF exporter mapping.
161
+ */
5
162
  export function createMaterial(mat: any, ctx: Ctx) {
6
163
  const material = new ctx.MeshPhysicalMaterial()
7
- material.color.setRGB(mat.r, mat.g, mat.b)
8
- material.roughness = mat.roughness !== undefined ? mat.roughness : 0.4
9
- material.metalness = mat.metallic !== undefined ? mat.metallic : 0.0
10
- // material.opacity = mat.alpha !== undefined ? mat.alpha : 0.0
11
- material.opacity = 1
12
- material.transparent = material.opacity < 1.0
164
+
165
+ // Blender materials default to backface culling OFF, i.e. they render double-sided. The glTF exporter
166
+ // maps this directly: doubleSided = not use_backface_culling (materials.py:289). three.js defaults to
167
+ // FrontSide, so without this most thin/open geometry (planes, leaves, interiors) is invisible from
168
+ // behind. Only the explicit "Backface Culling" toggle (MA_BL_CULL_BACKFACE) forces single-sided.
169
+ material.side = (typeof mat.blend_flag === 'number' && (mat.blend_flag & MA_BL_CULL_BACKFACE)) ? FrontSide : DoubleSide
170
+
171
+ const nodes = mat.nodetree ? listBaseToArray(mat.nodetree.nodes) : []
172
+ const links = mat.nodetree ? listBaseToArray(mat.nodetree.links) : []
173
+ const {principled, emission} = resolveShaders(nodes, links)
174
+
175
+ let applied = false
176
+ if (principled) {
177
+ const inp = inputsOf(principled)
178
+ applied = true
179
+
180
+ // Base color: texture takes precedence; factor (clamped) otherwise. (pbr_metallic_roughness.py:73)
181
+ const baseSock = socketByName(inp, 'Base Color', 'BaseColor')
182
+ const baseImgNode = imageNodeFeeding(links, baseSock)
183
+ const baseTex = baseImgNode ? imageNodeTexture(baseImgNode, true, ctx) : null
184
+ if (baseTex) {
185
+ material.map = baseTex
186
+ material.color.setRGB(1, 1, 1)
187
+ } else {
188
+ const base = socketConst(baseSock)
189
+ if (base && base.length >= 3) material.color.setRGB(clamp01(base[0]), clamp01(base[1]), clamp01(base[2]))
190
+ }
191
+
192
+ // Roughness / Metallic (factor or grayscale map; three.js reads G/B but greyscale maps work).
193
+ const roughSock = socketByName(inp, 'Roughness')
194
+ const roughTex = textureFromSocket(links, roughSock, false, ctx)
195
+ if (roughTex) material.roughnessMap = roughTex
196
+ else { const r = num(socketConst(roughSock)); if (r !== undefined) material.roughness = r }
197
+ const metalSock = socketByName(inp, 'Metallic')
198
+ const metalTex = textureFromSocket(links, metalSock, false, ctx)
199
+ if (metalTex) material.metalnessMap = metalTex
200
+ else { const m = num(socketConst(metalSock)); if (m !== undefined) material.metalness = m }
201
+
202
+ // Normal map + strength (materials.py:376; Normal Map node "Strength").
203
+ const normSrc = nodeFeeding(links, socketByName(inp, 'Normal'))
204
+ if (normSrc) {
205
+ const normTex = textureFromSocket(links, socketByName(inp, 'Normal'), false, ctx)
206
+ if (normTex) {
207
+ material.normalMap = normTex
208
+ const strength = num(socketConst(socketByName(inputsOf(normSrc), 'Strength')))
209
+ if (strength !== undefined && material.normalScale) material.normalScale.set(strength, strength)
210
+ }
211
+ }
212
+
213
+ // Alpha -> opacity (constant) or alphaMap (texture), plus the alpha *mode* from the material's
214
+ // Eevee blend_method. Only applied when the material actually has an alpha input, so opaque
215
+ // materials are never made see-through (notably the default material, which reports HASHED but
216
+ // is fully opaque at alpha=1). Mode mapping is the canonical glTF one: CLIP -> alpha test
217
+ // (cutout, using alpha_threshold); HASHED/BLEND/SOLID -> alpha blend. The 4.2+ glTF exporter
218
+ // derives this from the node graph, but blend_method is the authoritative stored value for <=4.1
219
+ // and a usable hint after (gather_alpha_info — search_node_tree.py).
220
+ const alphaSock = socketByName(inp, 'Alpha')
221
+ const alphaImgNode = imageNodeFeeding(links, alphaSock)
222
+ // If Alpha comes from the SAME image as Base Color (the common cutout setup: Image.Color -> Base
223
+ // Color, Image.Alpha -> Alpha), three.js material.map (RGBA) already supplies per-pixel opacity from
224
+ // the texture's alpha channel. A separate material.alphaMap would instead read the GREEN channel
225
+ // (three.js samples alphaMap.g), giving wrong cutouts — so only use a distinct alphaMap when the
226
+ // alpha image differs from the base image.
227
+ const alphaFromBase = !!(alphaImgNode && baseImgNode && alphaImgNode === baseImgNode)
228
+ const alphaTex = (alphaImgNode && !alphaFromBase) ? imageNodeTexture(alphaImgNode, false, ctx) : null
229
+ const alphaC = num(socketConst(alphaSock))
230
+ const hasAlpha = !!alphaTex || alphaFromBase || (alphaC !== undefined && alphaC < 1.0)
231
+ if (alphaTex) material.alphaMap = alphaTex
232
+ else if (!alphaFromBase && alphaC !== undefined && alphaC < 1.0) material.opacity = alphaC
233
+ if (hasAlpha) {
234
+ if (mat.blend_method === MA_BM_CLIP) {
235
+ const thr = num(mat.alpha_threshold)
236
+ material.alphaTest = thr !== undefined && thr > 0 ? thr : 0.5
237
+ } else material.transparent = true
238
+ }
239
+
240
+ // IOR (extensions/ior.py — default 1.5).
241
+ const ior = num(socketConst(socketByName(inp, 'IOR')))
242
+ if (ior !== undefined && (material as any).ior !== undefined) (material as any).ior = ior
243
+
244
+ // Transmission (extensions/transmission.py — "Transmission Weight"/"Transmission").
245
+ const trans = num(socketConst(socketByName(inp, 'Transmission Weight', 'Transmission')))
246
+ if (trans !== undefined && trans > 0 && (material as any).transmission !== undefined) {
247
+ (material as any).transmission = trans
248
+ }
249
+
250
+ // Clearcoat / Coat (extensions/clearcoat.py — "Coat Weight"/"Clearcoat", roughness default 0.03).
251
+ const coat = num(socketConst(socketByName(inp, 'Coat Weight', 'Clearcoat')))
252
+ if (coat !== undefined && coat > 0 && (material as any).clearcoat !== undefined) {
253
+ (material as any).clearcoat = coat
254
+ const cr = num(socketConst(socketByName(inp, 'Coat Roughness', 'Clearcoat Roughness')))
255
+ if (cr !== undefined) (material as any).clearcoatRoughness = cr
256
+ }
257
+
258
+ // Sheen (extensions/sheen.py — "Sheen Weight"/"Sheen" gates; color + roughness).
259
+ const sheen = num(socketConst(socketByName(inp, 'Sheen Weight', 'Sheen')))
260
+ if (sheen !== undefined && sheen > 0 && (material as any).sheen !== undefined) {
261
+ (material as any).sheen = sheen
262
+ const stint = socketConst(socketByName(inp, 'Sheen Tint'))
263
+ if (stint && stint.length >= 3 && (material as any).sheenColor) (material as any).sheenColor.setRGB(stint[0], stint[1], stint[2])
264
+ const sr = num(socketConst(socketByName(inp, 'Sheen Roughness')))
265
+ if (sr !== undefined) (material as any).sheenRoughness = sr
266
+ }
267
+
268
+ // Specular IOR Level -> specularIntensity (extensions/specular.py — factor is ×2, three.js range 0..1).
269
+ const spec = num(socketConst(socketByName(inp, 'Specular IOR Level', 'Specular')))
270
+ if (spec !== undefined && (material as any).specularIntensity !== undefined) {
271
+ (material as any).specularIntensity = Math.min(1, spec * 2)
272
+ }
273
+ }
274
+
275
+ // Displacement: Material Output "Displacement" <- Displacement node <- Height image. three.js
276
+ // `displacementMap` moves vertices along the normal (needs tessellated geometry); we also drive
277
+ // `bumpMap` from the same height so the relief is visible on coarse meshes, but only when there is no
278
+ // normal map (bump + normal would double-perturb). Scale comes from the Displacement node, clamped.
279
+ const matOutput = nodes.find(n => idnameOf(n).includes('OutputMaterial') && n.is_active_output)
280
+ || nodes.find(n => idnameOf(n).includes('OutputMaterial'))
281
+ const dispNode = matOutput ? nodeFeeding(links, socketByName(inputsOf(matOutput), 'Displacement')) : null
282
+ if (dispNode && idnameOf(dispNode).includes('Displacement')) {
283
+ const heightTex = textureFromSocket(links, socketByName(inputsOf(dispNode), 'Height'), false, ctx)
284
+ if (heightTex) {
285
+ const scale = num(socketConst(socketByName(inputsOf(dispNode), 'Scale')))
286
+ const s = scale !== undefined && scale > 0 ? scale : 1
287
+ const mid = num(socketConst(socketByName(inputsOf(dispNode), 'Midlevel')))
288
+ if ((material as any).displacementMap !== undefined) {
289
+ (material as any).displacementMap = heightTex
290
+ ;(material as any).displacementScale = s
291
+ ;(material as any).displacementBias = -(mid ?? 0.5) * s
292
+ }
293
+ // Bump fallback for un-tessellated meshes — only when the material has no normal map.
294
+ if (!material.normalMap && (material as any).bumpMap !== undefined) {
295
+ (material as any).bumpMap = heightTex
296
+ ;(material as any).bumpScale = s
297
+ }
298
+ }
299
+ }
300
+
301
+ // Emission: dedicated Emission node "Color"/"Strength", else Principled "Emission Color"/"Emission".
302
+ const emSock = emission ? socketByName(inputsOf(emission), 'Color')
303
+ : (principled ? socketByName(inputsOf(principled), 'Emission Color', 'Emission') : null)
304
+ const emStrSock = emission ? socketByName(inputsOf(emission), 'Strength')
305
+ : (principled ? socketByName(inputsOf(principled), 'Emission Strength') : null)
306
+ const emTex = textureFromSocket(links, emSock, true, ctx)
307
+ let emCol = socketConst(emSock)
308
+ // When a texture drives emission, the Emission Color factor multiplies it. Blender's default factor is
309
+ // black [0,0,0] (and stays black even with a texture linked), which would zero out the map — so treat a
310
+ // missing/short/all-black factor as white so the emissive map is actually visible.
311
+ if (emTex && (!emCol || emCol.length < 3 || (!emCol[0] && !emCol[1] && !emCol[2]))) emCol = [1, 1, 1]
312
+ if (emCol && emCol.length >= 3 && (emCol[0] || emCol[1] || emCol[2] || emTex)) {
313
+ const f = [emCol[0], emCol[1], emCol[2]]
314
+ const strength = num(socketConst(emStrSock))
315
+ if (typeof strength === 'number') for (let i = 0; i < 3; i++) f[i] *= strength
316
+ // Split intensity from color like the exporter (materials.py:149): keep color <=1, push the rest into intensity.
317
+ const peak = Math.max(f[0], f[1], f[2])
318
+ if (peak > 1) { material.emissiveIntensity = peak; material.emissive.setRGB(f[0] / peak, f[1] / peak, f[2] / peak) }
319
+ else material.emissive.setRGB(f[0], f[1], f[2])
320
+ }
321
+
322
+ if (!applied) {
323
+ // Legacy fallback — Blender material's viewport diffuse color (often just 0.8 grey).
324
+ if (mat.r !== undefined) material.color.setRGB(mat.r, mat.g, mat.b)
325
+ material.roughness = mat.roughness !== undefined ? mat.roughness : 0.4
326
+ material.metalness = mat.metallic !== undefined ? mat.metallic : 0.0
327
+ }
328
+
13
329
  return material
14
330
  }
@@ -1,3 +1,4 @@
1
+ import {DoubleSide} from 'threepipe'
1
2
  import {createBufferGeometry} from './geometry'
2
3
  import {createMaterial} from './material'
3
4
  import {Ctx} from './ctx'
@@ -7,17 +8,42 @@ export function createMesh(object: any, loaded: WeakMap<any, any>, ctx: Ctx) {
7
8
  return undefined
8
9
  }
9
10
 
10
- const geometry = loaded.get(object.data) ?? createBufferGeometry(object.data, ctx)
11
- loaded.set(object.data, geometry)
11
+ let geometry = loaded.get(object.data)
12
+ if (!geometry) {
13
+ geometry = createBufferGeometry(object.data, ctx)
14
+ // Modifiers (subdivision surface, mirror, array, solidify) are evaluated on the `blend-importer-modifiers` branch, not here yet.
15
+ loaded.set(object.data, geometry)
16
+ }
17
+
18
+ // Material slots: `object.data.mat` is the slot array (length `totcol`); faces reference slots by
19
+ // index. When a mesh uses more than one slot, the geometry builder tags triangles with their slot
20
+ // via `geometry.groups`; we then hand three.js the full material array (indexed by group.materialIndex).
21
+ // Otherwise a single material is used (the common case).
22
+ const matData = object.data.mat
23
+ const slots: any[] = Array.isArray(matData) ? matData : (matData ? [matData] : [])
12
24
 
13
- const mat = object.data.mat[0]
25
+ const buildMaterial = (m: any) => {
26
+ if (!m) { const d = new ctx.MeshPhysicalMaterial(); d.side = DoubleSide; return d } // empty slot -> Blender default
27
+ const cached = loaded.get(m)
28
+ if (cached) return cached
29
+ const created = createMaterial(m, ctx)
30
+ loaded.set(m, created)
31
+ return created
32
+ }
14
33
 
15
- // const material = mat ? createMaterial(mat) : undefined
16
- const material = mat ? loaded.get(mat) ?? createMaterial(mat, ctx) : new ctx.MeshPhysicalMaterial()
17
- if (mat) loaded.set(mat, material)
34
+ const hasGroups = !!(geometry.groups && geometry.groups.length)
35
+ let material: any
36
+ if (hasGroups) {
37
+ material = slots.map(buildMaterial) // multi-material: array indexed by geometry.groups' materialIndex
38
+ // Defensive: a stale/over-range material_index (corrupt data) would map to an undefined entry and
39
+ // three.js would silently skip those faces (invisible). Pad with a default so they stay visible.
40
+ let maxIdx = 0
41
+ for (const g of geometry.groups) if (g.materialIndex > maxIdx) maxIdx = g.materialIndex
42
+ while (material.length <= maxIdx) material.push(buildMaterial(null))
43
+ } else {
44
+ material = buildMaterial(slots[0])
45
+ }
18
46
 
19
- // console.log(material, mat)
20
- // material.side = DoubleSide
21
47
  const mesh = new ctx.Mesh(geometry, material)
22
48
 
23
49
  mesh.castShadow = true
package/tsconfig.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": "./src",
4
+ "rootDir": "./src",
5
+ "allowJs": false,
6
+ "checkJs": false,
7
+ "skipLibCheck": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "experimentalDecorators": true,
10
+ "isolatedModules": true,
11
+ "module": "es2020",
12
+ "noImplicitAny": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "declarationDir": "dist",
16
+ "outDir": "dist",
17
+ "noImplicitThis": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "removeComments": false,
21
+ "preserveConstEnums": true,
22
+ "moduleResolution": "bundler",
23
+ "emitDecoratorMetadata": false,
24
+ "sourceMap": true,
25
+ "target": "ES2020",
26
+ "strictNullChecks": true,
27
+ "allowImportingTsExtensions": true,
28
+ "lib": [
29
+ "es2020",
30
+ "esnext",
31
+ "dom"
32
+ ]
33
+ },
34
+ "include": [
35
+ "src/**/*"
36
+ ],
37
+ "exclude": [
38
+ "node_modules",
39
+ "**/*.spec.ts",
40
+ "dist"
41
+ ]
42
+ }
package/docs/.nojekyll DELETED
@@ -1 +0,0 @@
1
- TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false.