Coverage for trimesh/exchange/obj.py: 93%

386 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-24 01:00 +0000

1import itertools 

2import re 

3from collections import defaultdict, deque 

4 

5import numpy as np 

6 

7try: 

8 # `pip install pillow` 

9 # optional: used for textured meshes 

10 from PIL import Image 

11except BaseException as E: 

12 # if someone tries to use Image re-raise 

13 # the import error so they can debug easily 

14 from ..exceptions import ExceptionWrapper 

15 

16 Image = ExceptionWrapper(E) 

17 

18from .. import util 

19from ..constants import log, tol 

20from ..resolvers import ResolverLike 

21from ..typed import Loadable 

22from ..visual.color import to_float 

23from ..visual.material import SimpleMaterial 

24from ..visual.texture import TextureVisuals, unmerge_faces 

25 

26 

27def load_obj( 

28 file_obj: Loadable, 

29 resolver: ResolverLike | None = None, 

30 group_material: bool = True, 

31 skip_materials: bool = False, 

32 maintain_order: bool = False, 

33 metadata: dict | None = None, 

34 split_objects: bool = False, 

35 split_groups: bool = False, 

36 **kwargs, 

37) -> dict: 

38 """ 

39 Load a Wavefront OBJ file into kwargs for a trimesh.Scene 

40 object. 

41 

42 Parameters 

43 -------------- 

44 file_obj : file like object 

45 Contains OBJ data 

46 resolver : trimesh.visual.resolvers.Resolver 

47 Allow assets such as referenced textures and 

48 material files to be loaded 

49 group_material : bool 

50 Group faces that share the same material 

51 into the same mesh. 

52 skip_materials 

53 Don't load any materials. 

54 maintain_order 

55 Make the strongest attempt possible to not reorder faces 

56 or vertices which may result in visual artifacts and other 

57 odd behavior. The OBJ data structure is quite different than 

58 the "flat matching array" used by Trimesh and GLTF so this may 

59 not be completely possible. 

60 split_objects 

61 Whenever the loader encounters an `o` directive in the OBJ 

62 file, split the loaded result into a new Trimesh object. 

63 split_groups 

64 Whenever the loader encounters a `g` directive in the OBJ 

65 file, split the loaded result into a new Trimesh object. 

66 

67 Returns 

68 ------------- 

69 kwargs : dict 

70 Keyword arguments which can be loaded by 

71 trimesh.exchange.load.load_kwargs into a trimesh.Scene 

72 """ 

73 # get text as bytes or string blob 

74 text = file_obj.read() 

75 # if text was bytes decode into string 

76 text = util.decode_text(text) 

77 

78 # add leading and trailing newlines so we can use the 

79 # same logic even if they jump directly in to data lines 

80 text = "\n{}\n".format(text.strip().replace("\r\n", "\n")) 

81 

82 # remove backslash continuation characters and merge them into the same 

83 # line 

84 text = text.replace("\\\n", "") 

85 

86 # Load Materials 

87 materials = {} 

88 mtl_position = text.find("mtllib") 

89 if not skip_materials and mtl_position >= 0: 

90 # take the line of the material file after `mtllib` 

91 # which should be the file location of the .mtl file 

92 mtl_path = text[mtl_position + 6 : text.find("\n", mtl_position)].strip() 

93 try: 

94 # use the resolver to get the data 

95 material_kwargs = parse_mtl(resolver[mtl_path], resolver=resolver) 

96 # turn parsed kwargs into material objects 

97 materials = {k: SimpleMaterial(**v) for k, v in material_kwargs.items()} 

98 except (OSError, TypeError): 

99 # usually the resolver couldn't find the asset 

100 log.debug(f"unable to load materials from: {mtl_path}") 

101 except BaseException: 

102 # something else happened so log a warning 

103 log.debug(f"unable to load materials from: {mtl_path}", exc_info=True) 

104 

105 # extract vertices from raw text 

106 v, vn, vt, vc = _parse_vertices(text=text) 

107 

108 # get relevant chunks that have face data 

109 # in the form of (material, object, chunk) 

110 face_tuples = _preprocess_faces(text, split_objects, split_groups) 

111 

112 # combine chunks that have the same material 

113 # some meshes end up with a LOT of components 

114 # and will be much slower if you don't do this 

115 if group_material or split_objects or split_groups: 

116 face_tuples = _group_by(face_tuples, group_material, split_objects, split_groups) 

117 

118 # no faces but points given 

119 # return point cloud 

120 if not len(face_tuples) and v is not None: 

121 pc = {"vertices": v} 

122 if vn is not None: 

123 pc["vertex_normals"] = vn 

124 if vc is not None: 

125 pc["vertex_colors"] = vc 

126 return pc 

127 

128 # Load Faces 

129 # now we have clean- ish faces grouped by material and object 

130 # so now we have to turn them into numpy arrays and kwargs 

131 # for trimesh mesh and scene objects 

132 geometry = {} 

133 # guard against None vertices (e.g. malformed OBJ with mixed vertex formats) 

134 if v is None: 

135 log.debug("no vertices found, skipping geometry") 

136 return {"geometry": {}, "graph": []} 

137 while len(face_tuples) > 0: 

138 # consume the next chunk of text 

139 material, current_object, current_group, chunk = face_tuples.pop() 

140 # do wangling in string form 

141 # we need to only take the face line before a newline 

142 # using builtin functions in a list comprehension 

143 # is pretty fast relative to other options 

144 # this operation is the only one that is O(len(faces)) 

145 # slower due to the tight-loop conditional: 

146 # face_lines = [i[:i.find('\n')] 

147 # for i in chunk.split('\nf ')[1:] 

148 # if i.rfind('\n') >0] 

149 # maxsplit=1 means that it can stop working 

150 # after it finds the first newline 

151 # passed as arg as it's not a kwarg in python2 

152 face_lines = [ 

153 i.split("\n", 1)[0].strip() 

154 for i in re.split("^f", chunk, flags=re.MULTILINE)[1:] 

155 ] 

156 

157 # check every face for mixed tri-quad-ngon 

158 columns = len(face_lines[0].replace("/", " ").split()) 

159 flat_array = all(columns == len(f.replace("/", " ").split()) for f in face_lines) 

160 

161 # make sure we have the right number of values for vectorized 

162 if flat_array: 

163 # the fastest way to get to a numpy array 

164 # processes the whole string at once into a 1D array 

165 array = np.fromstring( 

166 " ".join(face_lines).replace("/", " "), sep=" ", dtype=np.int64 

167 ) 

168 # also wavefront is 1-indexed (vs 0-indexed) so offset 

169 # only applies to positive indices 

170 array[array > 0] -= 1 

171 

172 # everything is a nice 2D array 

173 faces, faces_tex, faces_norm = _parse_faces_vectorized( 

174 array=array, columns=columns, sample_line=face_lines[0] 

175 ) 

176 else: 

177 # if we had something annoying like mixed in quads 

178 # or faces that differ per-line we have to loop 

179 # i.e. something like: 

180 # '31407 31406 31408', 

181 # '32303/2469 32304/2469 32305/2469', 

182 log.debug("faces have mixed data: using slow fallback!") 

183 faces, faces_tex, faces_norm = _parse_faces_fallback(face_lines) 

184 

185 # build name from components 

186 name_parts = [] 

187 if split_objects and current_object is not None: 

188 name_parts.append(current_object) 

189 if split_groups and current_group is not None: 

190 name_parts.append(current_group) 

191 if group_material and len(materials) > 1 and material is not None: 

192 name_parts.append(str(material)) 

193 

194 # join parts or fall back to defaults 

195 if name_parts: 

196 name = "_".join(name_parts) 

197 elif current_object is not None: 

198 name = current_object 

199 else: 

200 # try to use the file name from the resolver 

201 # or file object if possible before defaulting 

202 name = next( 

203 i 

204 for i in ( 

205 getattr(resolver, "file_name", None), 

206 getattr(file_obj, "name", None), 

207 "geometry", 

208 ) 

209 if i is not None 

210 ) 

211 

212 # ensure the name is always unique in the geometry dict 

213 name = util.unique_name(name, geometry) 

214 

215 # try to get usable texture 

216 mesh = kwargs.copy() 

217 if faces_tex is not None: 

218 # convert faces referencing vertices and 

219 # faces referencing vertex texture to new faces 

220 # where each face 

221 if faces_norm is not None and len(faces_norm) == len(faces): 

222 new_faces, mask_v, mask_vt, mask_vn = unmerge_faces( 

223 faces, faces_tex, faces_norm, maintain_faces=maintain_order 

224 ) 

225 else: 

226 mask_vn = None 

227 # no face normals but face texturre 

228 new_faces, mask_v, mask_vt = unmerge_faces( 

229 faces, faces_tex, maintain_faces=maintain_order 

230 ) 

231 

232 if tol.strict: 

233 # we should NOT have messed up the faces 

234 # note: this is EXTREMELY slow due to all the 

235 # float comparisons so only run this in unit tests 

236 assert np.allclose(v[faces], v[mask_v][new_faces]) 

237 # faces should all be in bounds of vertives 

238 assert new_faces.max() < len(v[mask_v]) 

239 try: 

240 # survive index errors as sometimes we 

241 # want materials without UV coordinates 

242 uv = vt[mask_vt] 

243 except BaseException: 

244 log.debug("index failed on UV coordinates, skipping!") 

245 uv = None 

246 

247 # mask vertices and use new faces 

248 mesh.update({"vertices": v[mask_v].copy(), "faces": new_faces}) 

249 

250 else: 

251 # otherwise just use unmasked vertices 

252 uv = None 

253 # check to make sure indexes are in bounds 

254 if tol.strict: 

255 assert faces.max() < len(v) 

256 if vn is not None and np.shape(faces_norm) == faces.shape: 

257 # do the crazy unmerging logic for split indices 

258 new_faces, mask_v, mask_vn = unmerge_faces( 

259 faces, faces_norm, maintain_faces=maintain_order 

260 ) 

261 else: 

262 # face_tex is None and 

263 # generate the mask so we only include 

264 # referenced vertices in every new mesh 

265 if maintain_order: 

266 mask_v = np.ones(len(v), dtype=bool) 

267 else: 

268 mask_v = np.zeros(len(v), dtype=bool) 

269 mask_v[faces] = True 

270 

271 # reconstruct the faces with the new vertex indices 

272 inverse = np.zeros(len(v), dtype=np.int64) 

273 inverse[mask_v] = np.arange(mask_v.sum()) 

274 new_faces = inverse[faces] 

275 # no normals 

276 mask_vn = None 

277 

278 # start with vertices and faces 

279 mesh.update({"faces": new_faces, "vertices": v[mask_v].copy()}) 

280 

281 # if colors and normals are OK save them 

282 if vc is not None: 

283 try: 

284 # may fail on a malformed color mask 

285 mesh["vertex_colors"] = vc[mask_v] 

286 except BaseException: 

287 log.debug("failed to load vertex_colors", exc_info=True) 

288 if mask_vn is not None: 

289 try: 

290 # may fail on a malformed mask 

291 normals = vn[mask_vn] 

292 if normals.shape != mesh["vertices"].shape: 

293 raise ValueError( 

294 "incorrect normals {} != {}".format( 

295 str(normals.shape), str(mesh["vertices"].shape) 

296 ) 

297 ) 

298 mesh["vertex_normals"] = normals 

299 except BaseException: 

300 log.debug("failed to load vertex_normals", exc_info=True) 

301 

302 visual = None 

303 if material in materials: 

304 # use the material with the UV coordinates 

305 visual = TextureVisuals(uv=uv, material=materials[material]) 

306 elif uv is not None and len(uv) == len(mesh["vertices"]): 

307 # create a texture with an empty materials 

308 visual = TextureVisuals(uv=uv) 

309 elif material is not None: 

310 # case where material is specified but not available 

311 log.debug(f"specified material ({material}) not loaded!") 

312 # assign the visual 

313 mesh["visual"] = visual 

314 # store geometry by name 

315 geometry[name] = mesh 

316 

317 # add an identity transform for every geometry 

318 graph = [{"geometry": k, "frame_to": k} for k in geometry.keys()] 

319 

320 # convert to scene kwargs 

321 return {"geometry": geometry, "graph": graph} 

322 

323 

324def parse_mtl(mtl, resolver=None): 

325 """ 

326 Parse a loaded MTL file. 

327 

328 Parameters 

329 ------------- 

330 mtl : str or bytes 

331 Data from an MTL file 

332 resolver : trimesh.Resolver 

333 Fetch assets by name from file system, web, or other 

334 

335 Returns 

336 ------------ 

337 mtllibs : list of dict 

338 Each dict has keys: newmtl, map_Kd, Kd 

339 """ 

340 # decode bytes into string if necessary 

341 mtl = util.decode_text(mtl) 

342 

343 # current material 

344 material = None 

345 # materials referenced by name 

346 materials = {} 

347 # use universal newline splitting 

348 lines = str.splitlines(str(mtl).strip()) 

349 

350 # remap OBJ property names to kwargs for SimpleMaterial 

351 mapped = {"kd": "diffuse", "ka": "ambient", "ks": "specular", "ns": "glossiness"} 

352 

353 for line in lines: 

354 # split by white space 

355 split = line.strip().split() 

356 # needs to be at least two values 

357 if len(split) <= 1: 

358 continue 

359 # the first value is the parameter name 

360 key = split[0].lower() 

361 # start a new material 

362 if key == "newmtl": 

363 # material name extracted from line like: 

364 # newmtl material_0 

365 if material is not None: 

366 # save the old material by old name and remove key 

367 materials[material["name"]] = material 

368 # start a fresh new material 

369 # do we really want to support material names with whitespace? 

370 material = {"name": " ".join(split[1:])} 

371 

372 elif key == "map_kd": 

373 # represents the file name of the texture image 

374 index = line.lower().index("map_kd") + 6 

375 file_name = line[index:].strip() 

376 try: 

377 file_data = resolver.get(file_name) 

378 # load the bytes into a PIL image 

379 # an image file name 

380 material["image"] = Image.open(util.wrap_as_stream(file_data)) 

381 # record the texture reference as written in the MTL 

382 material["image"].info["file_path"] = file_name 

383 

384 except BaseException: 

385 log.debug("failed to load image", exc_info=True) 

386 

387 elif key in mapped.keys(): 

388 try: 

389 # diffuse, ambient, and specular float RGB 

390 value = [float(x) for x in split[1:]] 

391 # if there is only one value return that 

392 if len(value) == 1: 

393 value = value[0] 

394 if material is not None: 

395 # store the key by mapped value 

396 material[mapped[key]] = value 

397 # also store key by OBJ name 

398 material[key] = value 

399 except BaseException: 

400 log.debug("failed to convert color!", exc_info=True) 

401 # pass everything as kwargs to material constructor 

402 elif material is not None: 

403 # save any other unspecified keys 

404 material[key] = split[1:] 

405 # reached EOF so save any existing materials 

406 if material: 

407 materials[material["name"]] = material 

408 

409 return materials 

410 

411 

412def _parse_faces_vectorized(array, columns, sample_line): 

413 """ 

414 Parse loaded homogeneous (tri/quad) face data in a 

415 vectorized manner. 

416 

417 Parameters 

418 ------------ 

419 array : (n,) int 

420 Indices in order 

421 columns : int 

422 Number of columns in the file 

423 sample_line : str 

424 A single line so we can assess the ordering 

425 

426 Returns 

427 -------------- 

428 faces : (n, d) int 

429 Faces in space 

430 faces_tex : (n, d) int or None 

431 Texture for each vertex in face 

432 faces_norm : (n, d) int or None 

433 Normal index for each vertex in face 

434 """ 

435 # reshape to columns 

436 array = array.reshape((-1, columns)) 

437 # how many elements are in the first line of faces 

438 # i.e '13/1/13 14/1/14 2/1/2 1/2/1' is 4 

439 group_count = len(sample_line.strip().split()) 

440 # how many elements are there for each vertex reference 

441 # i.e. '12/1/13' is 3 

442 per_ref = int(columns / group_count) 

443 # create an index mask we can use to slice vertex references 

444 index = np.arange(group_count) * per_ref 

445 # slice the faces out of the blob array 

446 faces = array[:, index] 

447 

448 # TODO: probably need to support 8 and 12 columns for quads 

449 # or do something more general 

450 faces_tex, faces_norm = None, None 

451 if columns == group_count * 2: 

452 # if we have two values per vertex the second 

453 # one is index of texture coordinate (`vt`) 

454 # count how many delimiters are in the first face line 

455 # to see if our second value is texture or normals 

456 # do splitting to clip off leading/trailing slashes 

457 count = "".join(i.strip("/") for i in sample_line.split()).count("/") 

458 if count == columns: 

459 # case where each face line looks like: 

460 # ' 75//139 76//141 77//141' 

461 # which is vertex/nothing/normal 

462 faces_norm = array[:, index + 1] 

463 elif count == int(columns / 2): 

464 # case where each face line looks like: 

465 # '75/139 76/141 77/141' 

466 # which is vertex/texture 

467 faces_tex = array[:, index + 1] 

468 else: 

469 log.debug(f"face lines are weird: {sample_line}") 

470 elif columns == group_count * 3: 

471 # if we have three values per vertex 

472 # second value is always texture 

473 faces_tex = array[:, index + 1] 

474 # third value is reference to vertex normal (`vn`) 

475 faces_norm = array[:, index + 2] 

476 return faces, faces_tex, faces_norm 

477 

478 

479def _parse_faces_fallback(lines): 

480 """ 

481 Use a slow but more flexible looping method to process 

482 face lines as a fallback option to faster vectorized methods. 

483 

484 Parameters 

485 ------------- 

486 lines : (n,) str 

487 List of lines with face information 

488 

489 Returns 

490 ------------- 

491 faces : (m, 3) int 

492 Clean numpy array of face triangles 

493 """ 

494 

495 # collect vertex, texture, and vertex normal indexes 

496 v, vt, vn = [], [], [] 

497 

498 # loop through every line starting with a face 

499 for line in lines: 

500 # remove leading newlines then 

501 # take first bit before newline then split by whitespace 

502 split = line.strip().split("\n")[0].split() 

503 # split into: ['76/558/76', '498/265/498', '456/267/456'] 

504 len_split = len(split) 

505 if len_split == 3: 

506 pass 

507 elif len_split == 4: 

508 # triangulate quad face 

509 split = [split[0], split[1], split[2], split[2], split[3], split[0]] 

510 elif len_split > 4: 

511 # triangulate polygon as a triangles fan 

512 collect = [] 

513 # we need a flat list so append inside 

514 # a list comprehension 

515 collect_append = collect.append 

516 [ 

517 [ 

518 collect_append(split[0]), 

519 collect_append(split[i + 1]), 

520 collect_append(split[i + 2]), 

521 ] 

522 for i in range(len(split) - 2) 

523 ] 

524 split = collect 

525 else: 

526 log.debug(f"face needs more values 3>{len(split)} skipping!") 

527 continue 

528 

529 # f is like: '76/558/76' 

530 for f in split: 

531 # vertex, vertex texture, vertex normal 

532 split = f.split("/") 

533 # we always have a vertex reference 

534 v.append(int(split[0])) 

535 

536 # faster to try/except than check in loop 

537 try: 

538 vt.append(int(split[1])) 

539 except BaseException: 

540 pass 

541 try: 

542 # vertex normal is the third index 

543 vn.append(int(split[2])) 

544 except BaseException: 

545 pass 

546 

547 # shape into triangles and switch to 0-indexed 

548 # 0-indexing only applies to positive indices 

549 faces = np.array(v, dtype=np.int64).reshape((-1, 3)) 

550 faces[faces > 0] -= 1 

551 faces_tex, normals = None, None 

552 if len(vt) == len(v): 

553 faces_tex = np.array(vt, dtype=np.int64).reshape((-1, 3)) 

554 faces_tex[faces_tex > 0] -= 1 

555 if len(vn) == len(v): 

556 normals = np.array(vn, dtype=np.int64).reshape((-1, 3)) 

557 normals[normals > 0] -= 1 

558 

559 return faces, faces_tex, normals 

560 

561 

562def _parse_vertices(text): 

563 """ 

564 Parse raw OBJ text into vertices, vertex normals, 

565 vertex colors, and vertex textures. 

566 

567 Parameters 

568 ------------- 

569 text : str 

570 Full text of an OBJ file 

571 

572 Returns 

573 ------------- 

574 v : (n, 3) float 

575 Vertices in space 

576 vn : (m, 3) float or None 

577 Vertex normals 

578 vt : (p, 2) float or None 

579 Vertex texture coordinates 

580 vc : (n, 3) float or None 

581 Per-vertex color 

582 """ 

583 

584 # the first position of a vertex in the text blob 

585 # we only really need to search from the start of the file 

586 # up to the location of out our first vertex but we 

587 # are going to use this check for "do we have texture" 

588 # determination later so search the whole stupid file 

589 starts = {k: text.find(f"\n{k} ") for k in ["v", "vt", "vn"]} 

590 

591 # no valid values so exit early 

592 if not any(v >= 0 for v in starts.values()): 

593 return None, None, None, None 

594 

595 # find the last position of each valid value 

596 ends = { 

597 k: text.find("\n", text.rfind(f"\n{k} ") + 2 + len(k)) 

598 for k, v in starts.items() 

599 if v >= 0 

600 } 

601 

602 # take the first and last position of any vertex property 

603 start = min(s for s in starts.values() if s >= 0) 

604 end = max(e for e in ends.values() if e >= 0) 

605 # get the chunk of test that contains vertex data 

606 chunk = text[start:end].replace("+e", "e").replace("-e", "e") 

607 

608 # get the clean-ish data from the file as python lists 

609 data = { 

610 k: [i.split("\n", 1)[0] for i in chunk.split(f"\n{k} ")[1:]] 

611 for k, v in starts.items() 

612 if v >= 0 

613 } 

614 

615 # count the number of data values per row on a sample row 

616 per_row = {k: len(v[0].split()) for k, v in data.items()} 

617 

618 # convert data values into numpy arrays 

619 result = defaultdict(lambda: None) 

620 for k, value in data.items(): 

621 # use joining and fromstring to get as numpy array 

622 array = np.fromstring(" ".join(value), sep=" ", dtype=np.float64) 

623 # what should our shape be 

624 shape = (len(value), per_row[k]) 

625 # check shape of flat data 

626 if len(array) == np.prod(shape): 

627 # we have a nice 2D array 

628 result[k] = array.reshape(shape) 

629 else: 

630 # we don't have a nice (n, d) array so fall back to a slow loop 

631 # this is where mixed "some of the values but not all have vertex colors" 

632 # problem is handled. 

633 lines = [] 

634 [[lines.append(v.strip().split()) for v in str.splitlines(i)] for i in value] 

635 # we need to make a 2D array so clip it to the shortest array 

636 count = min(len(L) for L in lines) 

637 # make a numpy array out of the cleaned up line data 

638 result[k] = np.array([L[:count] for L in lines], dtype=np.float64) 

639 

640 # vertices 

641 v = result["v"] 

642 # vertex colors are stored next to vertices 

643 vc = None 

644 if v is not None and v.shape[1] >= 6: 

645 # vertex colors are stored after vertices 

646 v, vc = v[:, :3], v[:, 3:6] 

647 elif v is not None and v.shape[1] > 3: 

648 # we got a lot of something unknowable 

649 v = v[:, :3] 

650 

651 # vertex texture or None 

652 vt = result["vt"] 

653 if vt is not None: 

654 # sometimes UV coordinates come in as UVW 

655 vt = vt[:, :2] 

656 # vertex normals or None 

657 vn = result["vn"] 

658 

659 # check will generally only be run in unit tests 

660 # so we are allowed to do things that are slow 

661 if tol.strict: 

662 # check to make sure our subsetting 

663 # didn't miss any vertices or data 

664 assert text.count("\nv ") == (0 if v is None else len(v)) 

665 # make sure optional data matches file too 

666 if vn is not None: 

667 assert len(vn) == text.count("\nvn ") 

668 if vt is not None: 

669 assert len(vt) == text.count("\nvt ") 

670 

671 return v, vn, vt, vc 

672 

673 

674def _group_by(face_tuples, use_mtl: bool, use_obj: bool, use_group: bool): 

675 """ 

676 For chunks of faces split by material group 

677 the chunks that share the same material. 

678 

679 Parameters 

680 ------------ 

681 face_tuples : (n,) list of (material, obj, chunk) 

682 The data containing faces 

683 use_mtl 

684 Group tuples by `usemtl` commands 

685 use_obj 

686 Group tuples by `o` commands 

687 use_group 

688 Group tuples by `g` commands 

689 

690 Returns 

691 ------------ 

692 grouped : (m,) list of tuples containing: 

693 `(material name, objectname, group name, raw chunk)` 

694 """ 

695 

696 # store the chunks grouped by material 

697 grouped = defaultdict(lambda: ["", "", "", []]) 

698 # loop through existring 

699 for material, obj, group, chunk in face_tuples: 

700 # tuple key for the dict 

701 key = ( 

702 material if use_mtl else None, 

703 obj if use_obj else None, 

704 group if use_group else None, 

705 ) 

706 grouped[key][0] = material 

707 grouped[key][1] = obj 

708 grouped[key][2] = group 

709 # don't do a million string concatenations in loop 

710 grouped[key][3].append(chunk) 

711 # go back and do a join to make a single string 

712 for key in grouped.keys(): 

713 grouped[key][3] = "\n".join(grouped[key][3]) 

714 # return as list 

715 return list(grouped.values()) 

716 

717 

718def _preprocess_faces(text, use_obj=False, use_groups=False): 

719 """ 

720 Pre-Process Face Text 

721 

722 Rather than looking at each line in a loop we're 

723 going to split lines by directives which indicate 

724 a new mesh, specifically 'usemtl', 'o', and 'g' keys 

725 search for materials, objects, faces, or groups 

726 

727 Parameters 

728 ------------ 

729 text : str 

730 Raw file 

731 

732 Returns 

733 ------------ 

734 triple : (n, 3) tuple 

735 Tuples of (material, object, data-chunk) 

736 """ 

737 # see which chunk is relevant 

738 starters = ["\nusemtl ", "\no ", "\nf ", "\ng ", "\ns "] 

739 f_start = len(text) 

740 # first index of material, object, face, group, or smoother 

741 for st in starters: 

742 search = text.find(st, 0, f_start) 

743 # if not contained find will return -1 

744 if search < 0: 

745 continue 

746 # subtract the length of the key from the position 

747 # to make sure it's included in the slice of text 

748 if search < f_start: 

749 f_start = search 

750 # index in blob of the newline after the last face 

751 f_end = text.find("\n", text.rfind("\nf ") + 3) 

752 # get the chunk of the file that has face information 

753 if f_end >= 0: 

754 # clip to the newline after the last face 

755 f_chunk = text[f_start:f_end] 

756 else: 

757 # no newline after last face 

758 f_chunk = text[f_start:] 

759 

760 if tol.strict: 

761 # check to make sure our subsetting didn't miss any faces 

762 assert f_chunk.count("\nf ") == text.count("\nf ") 

763 

764 # two things cause new meshes to be created: 

765 # objects and materials 

766 # re.finditer was faster than find in a loop 

767 # find the index of every material change 

768 idx_mtl = np.array([m.start(0) for m in re.finditer("usemtl ", f_chunk)], dtype=int) 

769 # find the index of every new object 

770 split_idxs = [[0, len(f_chunk)], idx_mtl] 

771 

772 # NOTE: This used to split objects on every run, but now it does not 

773 if use_obj: 

774 split_idxs.append( 

775 np.array([m.start(0) for m in re.finditer("\no ", f_chunk)], dtype=int) 

776 ) 

777 

778 if use_groups: 

779 split_idxs.append( 

780 np.array([m.start(0) for m in re.finditer("\ng ", f_chunk)], dtype=int) 

781 ) 

782 

783 # find all the indexes where we want to split 

784 splits = np.unique(np.concatenate(tuple(split_idxs))) 

785 

786 # track the current material and object ID 

787 current_obj = None 

788 current_mtl = None 

789 current_group = None 

790 # store (material, object, group, face lines) 

791 face_tuples = [] 

792 

793 for start, end in itertools.pairwise(splits): 

794 # ensure there's always a trailing newline 

795 chunk = f_chunk[start:end].strip() + "\n" 

796 if chunk.startswith("o "): 

797 current_obj, chunk = chunk.split("\n", 1) 

798 current_obj = current_obj[2:].strip() 

799 elif chunk.startswith("usemtl"): 

800 current_mtl, chunk = chunk.split("\n", 1) 

801 current_mtl = current_mtl[6:].strip() 

802 # Discard the g tag line in the list of faces 

803 elif chunk.startswith("g "): 

804 current_group, chunk = chunk.split("\n", 1) 

805 current_group = current_group[2:].strip() 

806 # If we have an f at the beginning of a line 

807 # then add it to the list of faces chunks 

808 if chunk.startswith("f ") or "\nf" in chunk: 

809 face_tuples.append((current_mtl, current_obj, current_group, chunk)) 

810 return face_tuples 

811 

812 

813def export_obj( 

814 mesh, 

815 include_normals=None, 

816 include_color=True, 

817 include_texture=True, 

818 return_texture=False, 

819 write_texture=True, 

820 resolver=None, 

821 digits=8, 

822 mtl_name=None, 

823 header="https://github.com/mikedh/trimesh", 

824): 

825 """ 

826 Export a mesh as a Wavefront OBJ file. 

827 TODO: scenes with textured meshes 

828 

829 Parameters 

830 ----------- 

831 mesh : trimesh.Trimesh 

832 Mesh to be exported 

833 include_normals : bool or None 

834 Include vertex normals in export. If None 

835 will only be included if vertex normals are in cache. 

836 include_color : bool 

837 Include vertex color in export 

838 include_texture : bool 

839 Include `vt` texture in file text 

840 return_texture : bool 

841 If True, return a dict with texture files 

842 write_texture : bool 

843 If True and a writable resolver is passed 

844 write the referenced texture files with resolver 

845 resolver : None or trimesh.resolvers.Resolver 

846 Resolver which can write referenced text objects 

847 digits : int 

848 Number of digits to include for floating point 

849 mtl_name : None or str 

850 If passed, the file name of the MTL file. 

851 header : str or None 

852 Header string for top of file or None for no header. 

853 

854 Returns 

855 ----------- 

856 export : str 

857 OBJ format output 

858 texture : dict 

859 Contains files that need to be saved in the same 

860 directory as the exported mesh: {file name : bytes} 

861 """ 

862 # store the multiple options for formatting 

863 # vertex indexes for faces 

864 face_formats = { 

865 ("v",): "{}", 

866 ("v", "vn"): "{}//{}", 

867 ("v", "vt"): "{}/{}", 

868 ("v", "vn", "vt"): "{}/{}/{}", 

869 } 

870 

871 # check the input 

872 if util.is_instance_named(mesh, "Trimesh"): 

873 meshes = [mesh] 

874 elif util.is_instance_named(mesh, "Scene"): 

875 meshes = mesh.dump() 

876 elif util.is_instance_named(mesh, "PointCloud"): 

877 meshes = [mesh] 

878 else: 

879 raise ValueError("must be Trimesh or Scene!") 

880 

881 # collect lines to export 

882 objects = deque() 

883 # keep track of the number of each export element 

884 counts = {"v": 0, "vn": 0, "vt": 0} 

885 # collect materials as we go 

886 materials = {} 

887 materials_name = set() 

888 

889 for current in meshes: 

890 # we are going to reference face_formats with this 

891 face_type = ["v"] 

892 # OBJ includes vertex color as RGB elements on the same line 

893 if ( 

894 include_color 

895 and current.visual.kind in ["vertex", "face"] 

896 and len(current.visual.vertex_colors) 

897 ): 

898 # create a stacked blob with position and color 

899 v_blob = np.column_stack( 

900 (current.vertices, to_float(current.visual.vertex_colors[:, :3])) 

901 ) 

902 else: 

903 # otherwise just export vertices 

904 v_blob = current.vertices 

905 

906 # add the first vertex key and convert the array 

907 # add the vertices 

908 export = deque( 

909 [ 

910 "v " 

911 + util.array_to_string( 

912 v_blob, col_delim=" ", row_delim="\nv ", digits=digits 

913 ) 

914 ] 

915 ) 

916 

917 # if include_normals is None then 

918 # only include if they're already stored 

919 if include_normals is None: 

920 include_normals = "vertex_normals" in current._cache.cache 

921 

922 if include_normals: 

923 try: 

924 converted = util.array_to_string( 

925 current.vertex_normals, 

926 col_delim=" ", 

927 row_delim="\nvn ", 

928 digits=digits, 

929 ) 

930 # if vertex normals are stored in cache export them 

931 face_type.append("vn") 

932 export.append("vn " + converted) 

933 except BaseException: 

934 log.debug("failed to convert vertex normals", exc_info=True) 

935 

936 # collect materials into a dict 

937 if include_texture and hasattr(current.visual, "uv"): 

938 try: 

939 # get a SimpleMaterial 

940 material = current.visual.material 

941 if hasattr(material, "to_simple"): 

942 material = material.to_simple() 

943 

944 # hash the material to avoid duplicates 

945 hashed = hash(material) 

946 if hashed not in materials: 

947 # get a unique name for the material 

948 name = util.unique_name(material.name, materials_name) 

949 # add the name to our collection 

950 materials_name.add(name) 

951 # convert material to an OBJ MTL 

952 materials[hashed] = material.to_obj(name=name) 

953 

954 # get the name of the current material as-stored 

955 tex_name = materials[hashed][1] 

956 

957 # export the UV coordinates 

958 if len(np.shape(getattr(current.visual, "uv", None))) == 2: 

959 converted = util.array_to_string( 

960 current.visual.uv, col_delim=" ", row_delim="\nvt ", digits=digits 

961 ) 

962 # if vertex texture exists and is the right shape 

963 face_type.append("vt") 

964 # add the uv coordinates 

965 export.append("vt " + converted) 

966 # add the directive to use the exported material 

967 export.appendleft(f"usemtl {tex_name}") 

968 except BaseException: 

969 log.debug("failed to convert UV coordinates", exc_info=True) 

970 

971 # the format for a single vertex reference of a face 

972 face_format = face_formats[tuple(face_type)] 

973 # add the exported faces to the export if available 

974 if hasattr(current, "faces"): 

975 export.append( 

976 "f " 

977 + util.array_to_string( 

978 current.faces + 1 + counts["v"], 

979 col_delim=" ", 

980 row_delim="\nf ", 

981 value_format=face_format, 

982 ) 

983 ) 

984 # offset our vertex position 

985 counts["v"] += len(current.vertices) 

986 

987 # add object name if found in metadata 

988 if "name" in current.metadata: 

989 export.appendleft("\no {}".format(current.metadata["name"])) 

990 # add this object 

991 objects.append("\n".join(export)) 

992 

993 # collect files like images to write 

994 mtl_data = {} 

995 # combine materials 

996 if len(materials) > 0: 

997 # collect text for a single mtllib file 

998 mtl_lib = [] 

999 # now loop through: keys are garbage hash 

1000 # values are (data, name) 

1001 for data, _ in materials.values(): 

1002 for file_name, file_data in data.items(): 

1003 if file_name.lower().endswith(".mtl"): 

1004 # collect mtl lines into single file 

1005 mtl_lib.append(file_data) 

1006 elif file_name not in mtl_data: 

1007 # things like images 

1008 mtl_data[file_name] = file_data 

1009 else: 

1010 log.warning(f"not writing {file_name}") 

1011 

1012 if mtl_name is None: 

1013 # if no name passed set a default 

1014 mtl_name = "material.mtl" 

1015 

1016 # prepend a header to the MTL text if requested 

1017 if header is not None: 

1018 prepend = f"# {header}\n\n".encode() 

1019 else: 

1020 prepend = b"" 

1021 

1022 # save the material data 

1023 mtl_data[mtl_name] = prepend + b"\n\n".join(mtl_lib) 

1024 # add the reference to the MTL file 

1025 objects.appendleft(f"mtllib {mtl_name}") 

1026 

1027 if header is not None: 

1028 # add a created-with header to the top of the file 

1029 objects.appendleft(f"# {header}") 

1030 

1031 # add a trailing newline 

1032 objects.append("\n") 

1033 

1034 # combine elements into a single string 

1035 text = "\n".join(objects) 

1036 

1037 # if we have a resolver and have asked to write texture 

1038 if write_texture and resolver is not None and len(materials) > 0: 

1039 # not all resolvers have a write method 

1040 [resolver.write(k, v) for k, v in mtl_data.items()] 

1041 

1042 # if we exported texture it changes returned values 

1043 if return_texture: 

1044 return text, mtl_data 

1045 

1046 return text 

1047 

1048 

1049_obj_loaders = {"obj": load_obj}