@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
@@ -12,10 +12,188 @@ function getLayer(layers: any, i: number) {
12
12
  // https://projects.blender.org/blender/blender/issues/95967
13
13
  // https://projects.blender.org/blender/blender/pulls/106638
14
14
 
15
+ // Read a NUL-terminated C string from a resolved pointer block (e.g. an Attribute.name char*).
16
+ function readCString(block: any): string | null {
17
+ if (!block || block.__data_address__ === undefined || !block.__blender_file__) return null
18
+ const u8 = block.__blender_file__.byte
19
+ let s = '', o = block.__data_address__
20
+ while (o < u8.length && u8[o] !== 0 && s.length < 256) s += String.fromCharCode(u8[o++])
21
+ return s
22
+ }
23
+
24
+ // Blender 5.0 (.blend file_format_version 1) stores mesh attributes in `attribute_storage`
25
+ // (struct AttributeStorage { Attribute *dna_attributes; int dna_attributes_num; ... }) instead of
26
+ // the CustomData vdata/ldata layers used by 2.8-4.x. See
27
+ // .repos/blender/source/blender/makesdna/DNA_attribute_types.h and BKE_attribute_enums.hh.
28
+ // Each Attribute { char *name; int16 data_type; int8 domain; int8 storage_type; void *data; } where
29
+ // data -> AttributeArray { void *data; ...; int64 size; } and the void* points to the packed values.
30
+ const ATTR_TYPE_INT32 = 3, ATTR_TYPE_FLOAT2 = 6, ATTR_TYPE_FLOAT3 = 7 // bke::AttrType
31
+ const ATTR_DOMAIN_CORNER = 3 // bke::AttrDomain (Point=0, Edge=1, Face=2, Corner=3)
32
+ function getAttributes(meshData: any): any[] {
33
+ const as = meshData.attribute_storage
34
+ if (!as) return []
35
+ let attrs = as.dna_attributes
36
+ if (!attrs) return []
37
+ attrs = (Array.isArray(attrs) || attrs.length !== undefined) ? Array.from(attrs) : [attrs]
38
+ return attrs as any[]
39
+ }
40
+ function findAttribute(attrs: any[], name: string, type?: number): any {
41
+ for (const a of attrs) {
42
+ if (readCString(a.name) === name && (type === undefined || a.data_type === type)) return a
43
+ }
44
+ return null
45
+ }
46
+ // Element count actually stored for an attribute (AttributeArray.size). In Blender 5.0 the Mesh
47
+ // tot* fields are runtime (post-geometry-nodes) counts and DON'T match the stored array sizes — a
48
+ // geometry-nodes "Circle" can report totvert=15 while only 4 base vertices are stored. So the
49
+ // attribute's own size is authoritative; reading tot* elements overruns into adjacent blocks (garbage).
50
+ function attrSize(attr: any): number {
51
+ const arr = attr && attr.data
52
+ const n = arr && arr.size !== undefined ? Number(arr.size) : 0
53
+ return Number.isFinite(n) && n >= 0 ? n : 0
54
+ }
55
+ // Read the packed array for an Attribute as a TypedArray of `count` elements (alignment-safe).
56
+ function readAttrArray(attr: any, Ctor: any, count: number): any {
57
+ const arr = attr && attr.data // AttributeArray
58
+ const block = arr && arr.data // void* -> packed values block
59
+ if (!block || block.__data_address__ === undefined || !block.__blender_file__) return null
60
+ return block.__blender_file__.readTypedArray(Ctor, block.__data_address__, count)
61
+ }
62
+
63
+ // ── Per-face material slots -> BufferGeometry groups ─────────────────
64
+ type GroupRun = {start: number, count: number, mat: number}
65
+ // Append a triangle run [start, start+count) (index-buffer offsets) tagged with material slot `mat`,
66
+ // merging into the previous run when contiguous and same-slot so the group count stays minimal.
67
+ function mergeGroupRun(runs: GroupRun[], start: number, count: number, mat: number) {
68
+ if (count <= 0) return
69
+ const last = runs[runs.length - 1]
70
+ if (last && last.mat === mat && last.start + last.count === start) last.count += count
71
+ else runs.push({start, count, mat})
72
+ }
73
+ // Mid path (corner_vert): per-face material slot is the `pdata` CustomData layer "material_index"
74
+ // (CD_PROP_INT32, type 11), one int per face. `pdata.layers` is a single object when totlayer===1.
75
+ function readFaceMaterialIndex(meshData: any, faceCount: number): number[] | null {
76
+ const pd = meshData.pdata
77
+ if (!pd || !pd.layers || !pd.totlayer) return null
78
+ for (let i = 0; i < pd.totlayer; i++) {
79
+ const l = getLayer(pd.layers, i)
80
+ if (l && l.name === 'material_index' && l.data) {
81
+ const data = Array.isArray(l.data) ? l.data : (l.data.length !== undefined ? Array.from(l.data) : null)
82
+ if (!data) return null
83
+ const out: number[] = new Array(faceCount)
84
+ for (let f = 0; f < faceCount; f++) {
85
+ const e: any = data[f]
86
+ out[f] = (e && typeof e === 'object') ? (e.i ?? e.value ?? 0) : (e || 0)
87
+ }
88
+ return out
89
+ }
90
+ }
91
+ return null
92
+ }
93
+
94
+ // Build geometry from Blender 5.0 attribute_storage. Returns null if positions aren't available
95
+ // (so the caller can fall back to the vdata/ldata path).
96
+ function createBufferGeometryFromAttributes(meshData: any, ctx: Ctx): any {
97
+ const attrs = getAttributes(meshData)
98
+ if (!attrs.length) return null
99
+
100
+ const posAttr = findAttribute(attrs, 'position', ATTR_TYPE_FLOAT3)
101
+ const vertCount = attrSize(posAttr)
102
+ const posRaw = vertCount > 0 && readAttrArray(posAttr, Float32Array, vertCount * 3)
103
+ if (!posRaw) return null
104
+
105
+ const geometry = new ctx.BufferGeometry()
106
+ geometry.name = meshData.aname || ''
107
+
108
+ // Blender Z-up (x,y,z) -> three.js Y-up (x, z, -y). Sanitize non-finite components to 0 —
109
+ // real .blend files can contain NaN/Inf verts (e.g. from degenerate geometry-nodes ops), which
110
+ // Blender tolerates but three.js does not (computeBoundingBox/Sphere return NaN and break culling).
111
+ const positions = new Float32Array(vertCount * 3)
112
+ for (let j = 0; j < vertCount; j++) {
113
+ const x = posRaw[j * 3], y = posRaw[j * 3 + 1], z = posRaw[j * 3 + 2]
114
+ positions[j * 3] = Number.isFinite(x) ? x : 0
115
+ positions[j * 3 + 1] = Number.isFinite(z) ? z : 0
116
+ positions[j * 3 + 2] = Number.isFinite(y) ? -y : 0
117
+ }
118
+ geometry.setAttribute('position', new ctx.BufferAttribute(positions, 3))
119
+
120
+ // Faces: .corner_vert (vertex index per loop) + poly_offset_indices (loop range per face).
121
+ // Use the corner_vert array size for the loop count, and the poly_offset_indices DATA block's
122
+ // byte length for the face count (both are stored counts; Mesh.totloop/totpoly are runtime).
123
+ const cvAttr = findAttribute(attrs, '.corner_vert', ATTR_TYPE_INT32)
124
+ const loopCount = attrSize(cvAttr)
125
+ const cornerVerts = loopCount > 0 && readAttrArray(cvAttr, Int32Array, loopCount)
126
+ const poi = meshData.poly_offset_indices
127
+ const faceOffsetCount = poi && poi.__byte_length__ ? (poi.__byte_length__ >> 2) : 0
128
+ if (cornerVerts && faceOffsetCount > 1 && poi) {
129
+ const faceIndices = poi.__blender_file__.readTypedArray(Int32Array, poi.__data_address__, faceOffsetCount)
130
+ const faceCount = faceIndices.length - 1
131
+ let totalTriangles = 0
132
+ for (let i = 0; i < faceCount; i++) totalTriangles += Math.max(0, (faceIndices[i + 1] - faceIndices[i]) - 2)
133
+ const indexes = new Uint32Array(totalTriangles * 3)
134
+ const inRange = (v: number) => v >= 0 && v < vertCount
135
+ // Per-face material slot -> groups (only when >1 slot). material_index is a Face-domain Int32 attribute.
136
+ const matAttr = (meshData.totcol || 0) > 1 ? findAttribute(attrs, 'material_index', ATTR_TYPE_INT32) : null
137
+ const faceMat = matAttr && attrSize(matAttr) >= faceCount ? readAttrArray(matAttr, Int32Array, faceCount) : null
138
+ const groupRuns: GroupRun[] = []
139
+ let t = 0
140
+ for (let i = 0; i < faceCount; i++) {
141
+ const faceStart = faceIndices[i], faceVertCount = faceIndices[i + 1] - faceStart
142
+ const triStart = t
143
+ if (faceVertCount >= 3 && faceStart >= 0 && faceStart + faceVertCount <= loopCount) {
144
+ const firstVert = cornerVerts[faceStart] // todo: proper (ear-clip) triangulation for concave n-gons
145
+ for (let k = 1; k < faceVertCount - 1; k++) {
146
+ const b = cornerVerts[faceStart + k], c = cornerVerts[faceStart + k + 1]
147
+ // Skip triangles referencing out-of-range vertices (corrupt/partial geometry-nodes data).
148
+ if (inRange(firstVert) && inRange(b) && inRange(c)) {
149
+ indexes[t++] = firstVert
150
+ indexes[t++] = b
151
+ indexes[t++] = c
152
+ }
153
+ }
154
+ }
155
+ if (faceMat) mergeGroupRun(groupRuns, triStart, t - triStart, faceMat[i] || 0)
156
+ }
157
+ geometry.setIndex(new ctx.BufferAttribute(t === indexes.length ? indexes : indexes.slice(0, t), 1))
158
+ if (faceMat) for (const g of groupRuns) geometry.addGroup(g.start, g.count, g.mat)
159
+
160
+ // UVs: the first user UV map is a Float2 attribute on the Corner domain (named, not dot-prefixed;
161
+ // `.uv_select_*` are bools). UVs are per-corner but our geometry is per-vertex indexed, so we
162
+ // assign each vertex the UV of a corner referencing it (last write wins). This is exact away
163
+ // from UV seams; seam-correct UVs would need per-corner vertex expansion (todo).
164
+ // Blender and three.js share the same UV origin (bottom-left), so no V flip.
165
+ const uvAttr = attrs.find((a: any) => a.data_type === ATTR_TYPE_FLOAT2 && a.domain === ATTR_DOMAIN_CORNER && !(readCString(a.name) || '.').startsWith('.'))
166
+ const uvRaw = uvAttr && attrSize(uvAttr) >= loopCount && readAttrArray(uvAttr, Float32Array, loopCount * 2)
167
+ if (uvRaw) {
168
+ const uvs = new Float32Array(vertCount * 2)
169
+ for (let i = 0; i < loopCount; i++) {
170
+ const v = cornerVerts[i]
171
+ if (v >= 0 && v < vertCount) {
172
+ const u = uvRaw[i * 2], vv = uvRaw[i * 2 + 1]
173
+ uvs[v * 2] = Number.isFinite(u) ? u : 0
174
+ uvs[v * 2 + 1] = Number.isFinite(vv) ? vv : 0
175
+ }
176
+ }
177
+ geometry.setAttribute('uv', new ctx.BufferAttribute(uvs, 2))
178
+ }
179
+ }
180
+
181
+ if (geometry.attributes.position && !geometry.attributes.normal)
182
+ geometry.computeVertexNormals()
183
+ return geometry
184
+ }
185
+
15
186
  // https://github.com/blender/blender/blob/55e2fd2929b7577e0785c128c8f8069efd990c07/source/blender/blenkernel/intern/mesh.cc#L413
16
187
  export function createBufferGeometry(meshData: any, ctx: Ctx) {
17
188
 
18
189
  if (meshData.mpoly) return createBufferGeometryOld(meshData, ctx)
190
+
191
+ // Blender 5.0+ mesh attribute storage (file_format_version 1). Falls through to the
192
+ // vdata/ldata path below if positions aren't found there.
193
+ if (meshData.attribute_storage) {
194
+ const g = createBufferGeometryFromAttributes(meshData, ctx)
195
+ if (g) return g
196
+ }
19
197
  const geometry = new ctx.BufferGeometry()
20
198
  geometry.name = meshData.aname || ''
21
199
 
@@ -26,7 +204,8 @@ export function createBufferGeometry(meshData: any, ctx: Ctx) {
26
204
  let vertices
27
205
  let verticesData
28
206
  let indices
29
- let indicesData
207
+ let indicesData: any
208
+ let uvLayerData: any = null
30
209
 
31
210
  // Extract vertex positions from vdata layers
32
211
  if (meshData.vdata && meshData.vdata.layers && meshData.vdata.totlayer > 0) {
@@ -59,7 +238,15 @@ export function createBufferGeometry(meshData: any, ctx: Ctx) {
59
238
  const data = layer.data || []
60
239
  // if (layer.name === '.corner_vert') { // type = 11
61
240
  if (data.length === meshData.totloop) { // type = 11
62
- if (indices && (layer.name !== '.corner_vert' || indices.name === '.corner_vert')) {
241
+ const lname = layer.name || ''
242
+ // UV map: a Float2 corner layer (CD_PROP_FLOAT2, type 49), user-named (not dot-prefixed
243
+ // like `.corner_vert`/`.corner_edge`). Without this, textured meshes on the mid (3.6-4.x
244
+ // corner_vert) path render with no UVs and the base-colour texture cannot map.
245
+ if (!uvLayerData && (layer.type === 49 || (lname[0] !== '.' && /uv/i.test(lname)))) {
246
+ uvLayerData = data
247
+ continue
248
+ }
249
+ if (indices && (lname !== '.corner_vert' || indices.name === '.corner_vert')) {
63
250
  // console.warn('BlendLoader - multiple indices, ignoring', layer)
64
251
  continue
65
252
  }
@@ -103,14 +290,56 @@ export function createBufferGeometry(meshData: any, ctx: Ctx) {
103
290
 
104
291
  let faceIndices: number[] = []
105
292
  if (meshData.poly_offset_indices && meshData.totpoly > 0) {
106
- faceIndices = [...new Int32Array(meshData.poly_offset_indices.__blender_file__.AB, meshData.poly_offset_indices.__data_address__, meshData.totpoly + 1)]
293
+ // Use the parser's alignment-safe reader — Blender packs blocks unpadded, so on v1
294
+ // (Blender 5.0) and some v0 files this offset isn't 4-aligned and a raw Int32Array view throws.
295
+ faceIndices = [...meshData.poly_offset_indices.__blender_file__.readTypedArray(Int32Array, meshData.poly_offset_indices.__data_address__, meshData.totpoly + 1)]
107
296
  }
108
297
  // if (faceSize !== 3 && faceSize !== 4 && !faceIndices.length) {
109
298
  // console.error('not supported polygons with ', faceSize, 'vertices')
110
299
  // return geometry
111
300
  // }
112
301
 
113
- if (verticesData && verticesData.length > 0) {
302
+ // Per-corner vertex expansion for SEAM-CORRECT UVs (mid path). Blender stores UVs per corner (loop);
303
+ // three.js needs one UV per vertex. A Blender vertex shared by corners with DIFFERENT UVs is a UV seam —
304
+ // assigning it a single UV (last-write-wins) makes the faces across the seam interpolate the texture over
305
+ // the whole UV range, smearing/stretching it (classic symptom: the back of a UV-mapped sphere). Expand to
306
+ // one vertex per unique (blenderVert, uv) so seams split. Coincident split verts stay welded under the
307
+ // subsurf subdivision (same positions → same repositioning), so no crack. No UV layer ⇒ no expansion.
308
+ const vco = (vd: any): [number, number, number] => vd?.x !== undefined ? [vd.x, vd.y, vd.z] : (vd?.co ? [vd.co[0], vd.co[1], vd.co[2]] : [0, 0, 0])
309
+ let cornerToVert: Int32Array | null = null
310
+ let expandedPos: Float32Array | null = null
311
+ let expandedUv: Float32Array | null = null
312
+ if (uvLayerData && indicesData && verticesData && verticesData.length) {
313
+ cornerToVert = new Int32Array(indicesData.length)
314
+ const seen = new Map<string, number>()
315
+ const ps: number[] = [], us: number[] = []
316
+ const uvN = uvLayerData.length
317
+ for (let i = 0; i < indicesData.length; i++) {
318
+ const bv = indicesData[i]?.i
319
+ if (!(bv >= 0 && bv < verticesData.length)) { cornerToVert[i] = 0; continue }
320
+ const e = i < uvN ? uvLayerData[i] : null
321
+ const u = e && Number.isFinite(e.x) ? e.x : 0
322
+ const w = e && Number.isFinite(e.y) ? e.y : 0
323
+ const key = bv + '|' + Math.round(u * 4096) + '|' + Math.round(w * 4096)
324
+ let ev = seen.get(key)
325
+ if (ev === undefined) {
326
+ ev = us.length / 2
327
+ seen.set(key, ev)
328
+ const [x, y, z] = vco(verticesData[bv])
329
+ ps.push(x, z, -y) // Blender Z-up → three Y-up (same mapping as the non-expanded path)
330
+ us.push(u, w)
331
+ }
332
+ cornerToVert[i] = ev
333
+ }
334
+ expandedPos = new Float32Array(ps)
335
+ expandedUv = new Float32Array(us)
336
+ }
337
+ // Expanded-vertex index for a corner (loop) index; falls back to the raw Blender vertex when not expanding.
338
+ const vAt = (corner: number) => cornerToVert ? cornerToVert[corner] : indicesData[corner].i
339
+
340
+ if (expandedPos) {
341
+ geometry.setAttribute('position', new ctx.BufferAttribute(expandedPos, 3))
342
+ } else if (verticesData && verticesData.length > 0) {
114
343
  const positions = new Float32Array(verticesData.length * 3)
115
344
  for (let j = 0; j < verticesData.length; j++) {
116
345
  const {x, y, z, co} = verticesData[j] || {}
@@ -137,8 +366,9 @@ export function createBufferGeometry(meshData: any, ctx: Ctx) {
137
366
  const faceSize = meshData.totloop / meshData.totpoly
138
367
  if (faceIndices.length > 0) {
139
368
  // Use face offset indices for variable-sized faces
369
+ const faceCount = faceIndices.length - 1
140
370
  let totalTriangles = 0
141
- for (let i = 0; i < faceIndices.length - 1; i++) {
371
+ for (let i = 0; i < faceCount; i++) {
142
372
  const faceVertCount = faceIndices[i + 1] - faceIndices[i]
143
373
  totalTriangles += Math.max(0, faceVertCount - 2)
144
374
  }
@@ -146,26 +376,33 @@ export function createBufferGeometry(meshData: any, ctx: Ctx) {
146
376
  const indexes = new Uint32Array(totalTriangles * 3)
147
377
  let t = 0
148
378
 
149
- for (let i = 0; i < faceIndices.length - 1; i++) {
379
+ // Per-face material slot -> groups (only when >1 slot). material_index lives in the `pdata` layer.
380
+ const faceMat = (meshData.totcol || 0) > 1 ? readFaceMaterialIndex(meshData, faceCount) : null
381
+ const groupRuns: GroupRun[] = []
382
+
383
+ for (let i = 0; i < faceCount; i++) {
150
384
  const faceStart = faceIndices[i]
151
385
  const faceEnd = faceIndices[i + 1]
152
386
  const faceVertCount = faceEnd - faceStart
387
+ const triStart = t
153
388
 
154
389
  if (faceVertCount >= 3) {
155
390
  // todo better Triangulate the face using fan triangulation
156
- const firstVert = indicesData[faceStart].i
391
+ const firstVert = vAt(faceStart)
157
392
  for (let k = 1; k < faceVertCount - 1; k++) {
158
393
  indexes[t++] = firstVert
159
- indexes[t++] = indicesData[faceStart + k].i
160
- indexes[t++] = indicesData[faceStart + k + 1].i
394
+ indexes[t++] = vAt(faceStart + k)
395
+ indexes[t++] = vAt(faceStart + k + 1)
161
396
  }
162
397
  } else {
163
398
  // debugger
164
399
  }
400
+ if (faceMat) mergeGroupRun(groupRuns, triStart, t - triStart, faceMat[i] || 0)
165
401
  }
166
402
 
167
403
  // console.log(indexes)
168
404
  geometry.setIndex(new ctx.BufferAttribute(indexes, 1))
405
+ if (faceMat) for (const g of groupRuns) geometry.addGroup(g.start, g.count, g.mat)
169
406
  } else if (faceSize === 3 || faceSize === 4) {
170
407
  // Fall back to uniform face size approach
171
408
  const isQuad = faceSize === 4
@@ -174,15 +411,15 @@ export function createBufferGeometry(meshData: any, ctx: Ctx) {
174
411
 
175
412
  if (faceSize !== 3 && faceSize !== 4) return geometry
176
413
  for (let j = 0, t = 0; j < indices.length; j += faceSize) {
177
- const a = indices[j].i
178
- const b = indices[j + 1].i
179
- const c = indices[j + 2].i
414
+ const a = vAt(j)
415
+ const b = vAt(j + 1)
416
+ const c = vAt(j + 2)
180
417
  indexes[t++] = a
181
418
  indexes[t++] = b
182
419
  indexes[t++] = c
183
420
 
184
421
  if (isQuad) {
185
- const d = indices[j + 3].i
422
+ const d = vAt(j + 3)
186
423
  indexes[t++] = a
187
424
  indexes[t++] = c
188
425
  indexes[t++] = d
@@ -197,6 +434,12 @@ export function createBufferGeometry(meshData: any, ctx: Ctx) {
197
434
  // console.log(geometry.attributes.position)
198
435
  // console.log(geometry.index)
199
436
 
437
+ // UVs (mid corner_vert path): seam-correct, from the per-corner expansion above (one UV per split vertex).
438
+ // Blender and three.js share the same UV origin (bottom-left), so no V flip.
439
+ if (expandedUv && !geometry.attributes.uv) {
440
+ geometry.setAttribute('uv', new ctx.BufferAttribute(expandedUv, 2))
441
+ }
442
+
200
443
  // compute stuff not present
201
444
  if (geometry.attributes.position && !geometry.attributes.normal)
202
445
  geometry.computeVertexNormals()
@@ -238,9 +481,16 @@ export function createBufferGeometryOld(mesh: any, ctx: Ctx) {
238
481
  let currentIndex = 0
239
482
  let computeNormals = false
240
483
 
484
+ // Per-face material slot (mat_nr) -> geometry groups, but only when the mesh has >1 slot. Faces are
485
+ // emitted in order, so each face's triangles form a contiguous index range; we merge consecutive
486
+ // same-slot faces into one group. mesh.ts then assigns one material per slot.
487
+ const useGroups = (mesh.totcol || 0) > 1
488
+ const groupRuns: GroupRun[] = []
489
+
241
490
  for (const face of faces) {
242
491
  const len = face.totloop
243
492
  const start = face.loopstart
493
+ const faceStartIndex = currentIndex
244
494
  let indexi = 1
245
495
 
246
496
  while (indexi < len) {
@@ -283,6 +533,8 @@ export function createBufferGeometryOld(mesh: any, ctx: Ctx) {
283
533
 
284
534
  indexi += 2
285
535
  }
536
+
537
+ if (useGroups) mergeGroupRun(groupRuns, faceStartIndex, currentIndex - faceStartIndex, face.mat_nr || 0)
286
538
  }
287
539
 
288
540
  geometry.setAttribute('position', new ctx.BufferAttribute(positions, 3))
@@ -290,6 +542,11 @@ export function createBufferGeometryOld(mesh: any, ctx: Ctx) {
290
542
  geometry.setAttribute('normal', new ctx.BufferAttribute(normals, 3))
291
543
  geometry.setAttribute('uv', new ctx.BufferAttribute(uvs, 2))
292
544
 
545
+ // Emit groups for any multi-slot mesh: even a single run carries the correct slot index, so a mesh
546
+ // whose faces all use e.g. slot 2 renders with material[2] (not material[0]).
547
+ if (useGroups && groupRuns.length)
548
+ for (const g of groupRuns) geometry.addGroup(g.start, g.count, g.mat)
549
+
293
550
  if (computeNormals) {
294
551
  geometry.computeVertexNormals()
295
552
  }