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

383 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +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 while len(face_tuples) > 0: 

134 # consume the next chunk of text 

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

136 # do wangling in string form 

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

138 # using builtin functions in a list comprehension 

139 # is pretty fast relative to other options 

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

141 # slower due to the tight-loop conditional: 

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

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

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

145 # maxsplit=1 means that it can stop working 

146 # after it finds the first newline 

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

148 face_lines = [ 

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

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

151 ] 

152 

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

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

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

156 

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

158 if flat_array: 

159 # the fastest way to get to a numpy array 

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

161 array = np.fromstring( 

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

163 ) 

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

165 # only applies to positive indices 

166 array[array > 0] -= 1 

167 

168 # everything is a nice 2D array 

169 faces, faces_tex, faces_norm = _parse_faces_vectorized( 

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

171 ) 

172 else: 

173 # if we had something annoying like mixed in quads 

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

175 # i.e. something like: 

176 # '31407 31406 31408', 

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

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

179 faces, faces_tex, faces_norm = _parse_faces_fallback(face_lines) 

180 

181 # build name from components 

182 name_parts = [] 

183 if split_objects and current_object is not None: 

184 name_parts.append(current_object) 

185 if split_groups and current_group is not None: 

186 name_parts.append(current_group) 

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

188 name_parts.append(str(material)) 

189 

190 # join parts or fall back to defaults 

191 if name_parts: 

192 name = "_".join(name_parts) 

193 elif current_object is not None: 

194 name = current_object 

195 else: 

196 # try to use the file name from the resolver 

197 # or file object if possible before defaulting 

198 name = next( 

199 i 

200 for i in ( 

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

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

203 "geometry", 

204 ) 

205 if i is not None 

206 ) 

207 

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

209 name = util.unique_name(name, geometry) 

210 

211 # try to get usable texture 

212 mesh = kwargs.copy() 

213 if faces_tex is not None: 

214 # convert faces referencing vertices and 

215 # faces referencing vertex texture to new faces 

216 # where each face 

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

218 new_faces, mask_v, mask_vt, mask_vn = unmerge_faces( 

219 faces, faces_tex, faces_norm, maintain_faces=maintain_order 

220 ) 

221 else: 

222 mask_vn = None 

223 # no face normals but face texturre 

224 new_faces, mask_v, mask_vt = unmerge_faces( 

225 faces, faces_tex, maintain_faces=maintain_order 

226 ) 

227 

228 if tol.strict: 

229 # we should NOT have messed up the faces 

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

231 # float comparisons so only run this in unit tests 

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

233 # faces should all be in bounds of vertives 

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

235 try: 

236 # survive index errors as sometimes we 

237 # want materials without UV coordinates 

238 uv = vt[mask_vt] 

239 except BaseException: 

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

241 uv = None 

242 

243 # mask vertices and use new faces 

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

245 

246 else: 

247 # otherwise just use unmasked vertices 

248 uv = None 

249 # check to make sure indexes are in bounds 

250 if tol.strict: 

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

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

253 # do the crazy unmerging logic for split indices 

254 new_faces, mask_v, mask_vn = unmerge_faces( 

255 faces, faces_norm, maintain_faces=maintain_order 

256 ) 

257 else: 

258 # face_tex is None and 

259 # generate the mask so we only include 

260 # referenced vertices in every new mesh 

261 if maintain_order: 

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

263 else: 

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

265 mask_v[faces] = True 

266 

267 # reconstruct the faces with the new vertex indices 

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

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

270 new_faces = inverse[faces] 

271 # no normals 

272 mask_vn = None 

273 

274 # start with vertices and faces 

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

276 

277 # if colors and normals are OK save them 

278 if vc is not None: 

279 try: 

280 # may fail on a malformed color mask 

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

282 except BaseException: 

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

284 if mask_vn is not None: 

285 try: 

286 # may fail on a malformed mask 

287 normals = vn[mask_vn] 

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

289 raise ValueError( 

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

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

292 ) 

293 ) 

294 mesh["vertex_normals"] = normals 

295 except BaseException: 

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

297 

298 visual = None 

299 if material in materials: 

300 # use the material with the UV coordinates 

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

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

303 # create a texture with an empty materials 

304 visual = TextureVisuals(uv=uv) 

305 elif material is not None: 

306 # case where material is specified but not available 

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

308 # assign the visual 

309 mesh["visual"] = visual 

310 # store geometry by name 

311 geometry[name] = mesh 

312 

313 # add an identity transform for every geometry 

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

315 

316 # convert to scene kwargs 

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

318 

319 

320def parse_mtl(mtl, resolver=None): 

321 """ 

322 Parse a loaded MTL file. 

323 

324 Parameters 

325 ------------- 

326 mtl : str or bytes 

327 Data from an MTL file 

328 resolver : trimesh.Resolver 

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

330 

331 Returns 

332 ------------ 

333 mtllibs : list of dict 

334 Each dict has keys: newmtl, map_Kd, Kd 

335 """ 

336 # decode bytes into string if necessary 

337 mtl = util.decode_text(mtl) 

338 

339 # current material 

340 material = None 

341 # materials referenced by name 

342 materials = {} 

343 # use universal newline splitting 

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

345 

346 # remap OBJ property names to kwargs for SimpleMaterial 

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

348 

349 for line in lines: 

350 # split by white space 

351 split = line.strip().split() 

352 # needs to be at least two values 

353 if len(split) <= 1: 

354 continue 

355 # the first value is the parameter name 

356 key = split[0].lower() 

357 # start a new material 

358 if key == "newmtl": 

359 # material name extracted from line like: 

360 # newmtl material_0 

361 if material is not None: 

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

363 materials[material["name"]] = material 

364 # start a fresh new material 

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

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

367 

368 elif key == "map_kd": 

369 # represents the file name of the texture image 

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

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

372 try: 

373 file_data = resolver.get(file_name) 

374 # load the bytes into a PIL image 

375 # an image file name 

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

377 # record the texture reference as written in the MTL 

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

379 

380 except BaseException: 

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

382 

383 elif key in mapped.keys(): 

384 try: 

385 # diffuse, ambient, and specular float RGB 

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

387 # if there is only one value return that 

388 if len(value) == 1: 

389 value = value[0] 

390 if material is not None: 

391 # store the key by mapped value 

392 material[mapped[key]] = value 

393 # also store key by OBJ name 

394 material[key] = value 

395 except BaseException: 

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

397 # pass everything as kwargs to material constructor 

398 elif material is not None: 

399 # save any other unspecified keys 

400 material[key] = split[1:] 

401 # reached EOF so save any existing materials 

402 if material: 

403 materials[material["name"]] = material 

404 

405 return materials 

406 

407 

408def _parse_faces_vectorized(array, columns, sample_line): 

409 """ 

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

411 vectorized manner. 

412 

413 Parameters 

414 ------------ 

415 array : (n,) int 

416 Indices in order 

417 columns : int 

418 Number of columns in the file 

419 sample_line : str 

420 A single line so we can assess the ordering 

421 

422 Returns 

423 -------------- 

424 faces : (n, d) int 

425 Faces in space 

426 faces_tex : (n, d) int or None 

427 Texture for each vertex in face 

428 faces_norm : (n, d) int or None 

429 Normal index for each vertex in face 

430 """ 

431 # reshape to columns 

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

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

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

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

436 # how many elements are there for each vertex reference 

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

438 per_ref = int(columns / group_count) 

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

440 index = np.arange(group_count) * per_ref 

441 # slice the faces out of the blob array 

442 faces = array[:, index] 

443 

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

445 # or do something more general 

446 faces_tex, faces_norm = None, None 

447 if columns == group_count * 2: 

448 # if we have two values per vertex the second 

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

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

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

452 # do splitting to clip off leading/trailing slashes 

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

454 if count == columns: 

455 # case where each face line looks like: 

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

457 # which is vertex/nothing/normal 

458 faces_norm = array[:, index + 1] 

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

460 # case where each face line looks like: 

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

462 # which is vertex/texture 

463 faces_tex = array[:, index + 1] 

464 else: 

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

466 elif columns == group_count * 3: 

467 # if we have three values per vertex 

468 # second value is always texture 

469 faces_tex = array[:, index + 1] 

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

471 faces_norm = array[:, index + 2] 

472 return faces, faces_tex, faces_norm 

473 

474 

475def _parse_faces_fallback(lines): 

476 """ 

477 Use a slow but more flexible looping method to process 

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

479 

480 Parameters 

481 ------------- 

482 lines : (n,) str 

483 List of lines with face information 

484 

485 Returns 

486 ------------- 

487 faces : (m, 3) int 

488 Clean numpy array of face triangles 

489 """ 

490 

491 # collect vertex, texture, and vertex normal indexes 

492 v, vt, vn = [], [], [] 

493 

494 # loop through every line starting with a face 

495 for line in lines: 

496 # remove leading newlines then 

497 # take first bit before newline then split by whitespace 

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

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

500 len_split = len(split) 

501 if len_split == 3: 

502 pass 

503 elif len_split == 4: 

504 # triangulate quad face 

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

506 elif len_split > 4: 

507 # triangulate polygon as a triangles fan 

508 collect = [] 

509 # we need a flat list so append inside 

510 # a list comprehension 

511 collect_append = collect.append 

512 [ 

513 [ 

514 collect_append(split[0]), 

515 collect_append(split[i + 1]), 

516 collect_append(split[i + 2]), 

517 ] 

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

519 ] 

520 split = collect 

521 else: 

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

523 continue 

524 

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

526 for f in split: 

527 # vertex, vertex texture, vertex normal 

528 split = f.split("/") 

529 # we always have a vertex reference 

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

531 

532 # faster to try/except than check in loop 

533 try: 

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

535 except BaseException: 

536 pass 

537 try: 

538 # vertex normal is the third index 

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

540 except BaseException: 

541 pass 

542 

543 # shape into triangles and switch to 0-indexed 

544 # 0-indexing only applies to positive indices 

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

546 faces[faces > 0] -= 1 

547 faces_tex, normals = None, None 

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

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

550 faces_tex[faces_tex > 0] -= 1 

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

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

553 normals[normals > 0] -= 1 

554 

555 return faces, faces_tex, normals 

556 

557 

558def _parse_vertices(text): 

559 """ 

560 Parse raw OBJ text into vertices, vertex normals, 

561 vertex colors, and vertex textures. 

562 

563 Parameters 

564 ------------- 

565 text : str 

566 Full text of an OBJ file 

567 

568 Returns 

569 ------------- 

570 v : (n, 3) float 

571 Vertices in space 

572 vn : (m, 3) float or None 

573 Vertex normals 

574 vt : (p, 2) float or None 

575 Vertex texture coordinates 

576 vc : (n, 3) float or None 

577 Per-vertex color 

578 """ 

579 

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

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

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

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

584 # determination later so search the whole stupid file 

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

586 

587 # no valid values so exit early 

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

589 return None, None, None, None 

590 

591 # find the last position of each valid value 

592 ends = { 

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

594 for k, v in starts.items() 

595 if v >= 0 

596 } 

597 

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

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

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

601 # get the chunk of test that contains vertex data 

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

603 

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

605 data = { 

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

607 for k, v in starts.items() 

608 if v >= 0 

609 } 

610 

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

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

613 

614 # convert data values into numpy arrays 

615 result = defaultdict(lambda: None) 

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

617 # use joining and fromstring to get as numpy array 

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

619 # what should our shape be 

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

621 # check shape of flat data 

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

623 # we have a nice 2D array 

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

625 else: 

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

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

628 # problem is handled. 

629 lines = [] 

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

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

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

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

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

635 

636 # vertices 

637 v = result["v"] 

638 # vertex colors are stored next to vertices 

639 vc = None 

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

641 # vertex colors are stored after vertices 

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

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

644 # we got a lot of something unknowable 

645 v = v[:, :3] 

646 

647 # vertex texture or None 

648 vt = result["vt"] 

649 if vt is not None: 

650 # sometimes UV coordinates come in as UVW 

651 vt = vt[:, :2] 

652 # vertex normals or None 

653 vn = result["vn"] 

654 

655 # check will generally only be run in unit tests 

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

657 if tol.strict: 

658 # check to make sure our subsetting 

659 # didn't miss any vertices or data 

660 assert len(v) == text.count("\nv ") 

661 # make sure optional data matches file too 

662 if vn is not None: 

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

664 if vt is not None: 

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

666 

667 return v, vn, vt, vc 

668 

669 

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

671 """ 

672 For chunks of faces split by material group 

673 the chunks that share the same material. 

674 

675 Parameters 

676 ------------ 

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

678 The data containing faces 

679 use_mtl 

680 Group tuples by `usemtl` commands 

681 use_obj 

682 Group tuples by `o` commands 

683 use_group 

684 Group tuples by `g` commands 

685 

686 Returns 

687 ------------ 

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

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

690 """ 

691 

692 # store the chunks grouped by material 

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

694 # loop through existring 

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

696 # tuple key for the dict 

697 key = ( 

698 material if use_mtl else None, 

699 obj if use_obj else None, 

700 group if use_group else None, 

701 ) 

702 grouped[key][0] = material 

703 grouped[key][1] = obj 

704 grouped[key][2] = group 

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

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

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

708 for key in grouped.keys(): 

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

710 # return as list 

711 return list(grouped.values()) 

712 

713 

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

715 """ 

716 Pre-Process Face Text 

717 

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

719 going to split lines by directives which indicate 

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

721 search for materials, objects, faces, or groups 

722 

723 Parameters 

724 ------------ 

725 text : str 

726 Raw file 

727 

728 Returns 

729 ------------ 

730 triple : (n, 3) tuple 

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

732 """ 

733 # see which chunk is relevant 

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

735 f_start = len(text) 

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

737 for st in starters: 

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

739 # if not contained find will return -1 

740 if search < 0: 

741 continue 

742 # subtract the length of the key from the position 

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

744 if search < f_start: 

745 f_start = search 

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

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

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

749 if f_end >= 0: 

750 # clip to the newline after the last face 

751 f_chunk = text[f_start:f_end] 

752 else: 

753 # no newline after last face 

754 f_chunk = text[f_start:] 

755 

756 if tol.strict: 

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

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

759 

760 # two things cause new meshes to be created: 

761 # objects and materials 

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

763 # find the index of every material change 

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

765 # find the index of every new object 

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

767 

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

769 if use_obj: 

770 split_idxs.append( 

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

772 ) 

773 

774 if use_groups: 

775 split_idxs.append( 

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

777 ) 

778 

779 # find all the indexes where we want to split 

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

781 

782 # track the current material and object ID 

783 current_obj = None 

784 current_mtl = None 

785 current_group = None 

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

787 face_tuples = [] 

788 

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

790 # ensure there's always a trailing newline 

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

792 if chunk.startswith("o "): 

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

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

795 elif chunk.startswith("usemtl"): 

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

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

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

799 elif chunk.startswith("g "): 

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

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

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

803 # then add it to the list of faces chunks 

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

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

806 return face_tuples 

807 

808 

809def export_obj( 

810 mesh, 

811 include_normals=None, 

812 include_color=True, 

813 include_texture=True, 

814 return_texture=False, 

815 write_texture=True, 

816 resolver=None, 

817 digits=8, 

818 mtl_name=None, 

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

820): 

821 """ 

822 Export a mesh as a Wavefront OBJ file. 

823 TODO: scenes with textured meshes 

824 

825 Parameters 

826 ----------- 

827 mesh : trimesh.Trimesh 

828 Mesh to be exported 

829 include_normals : bool or None 

830 Include vertex normals in export. If None 

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

832 include_color : bool 

833 Include vertex color in export 

834 include_texture : bool 

835 Include `vt` texture in file text 

836 return_texture : bool 

837 If True, return a dict with texture files 

838 write_texture : bool 

839 If True and a writable resolver is passed 

840 write the referenced texture files with resolver 

841 resolver : None or trimesh.resolvers.Resolver 

842 Resolver which can write referenced text objects 

843 digits : int 

844 Number of digits to include for floating point 

845 mtl_name : None or str 

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

847 header : str or None 

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

849 

850 Returns 

851 ----------- 

852 export : str 

853 OBJ format output 

854 texture : dict 

855 Contains files that need to be saved in the same 

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

857 """ 

858 # store the multiple options for formatting 

859 # vertex indexes for faces 

860 face_formats = { 

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

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

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

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

865 } 

866 

867 # check the input 

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

869 meshes = [mesh] 

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

871 meshes = mesh.dump() 

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

873 meshes = [mesh] 

874 else: 

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

876 

877 # collect lines to export 

878 objects = deque() 

879 # keep track of the number of each export element 

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

881 # collect materials as we go 

882 materials = {} 

883 materials_name = set() 

884 

885 for current in meshes: 

886 # we are going to reference face_formats with this 

887 face_type = ["v"] 

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

889 if ( 

890 include_color 

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

892 and len(current.visual.vertex_colors) 

893 ): 

894 # create a stacked blob with position and color 

895 v_blob = np.column_stack( 

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

897 ) 

898 else: 

899 # otherwise just export vertices 

900 v_blob = current.vertices 

901 

902 # add the first vertex key and convert the array 

903 # add the vertices 

904 export = deque( 

905 [ 

906 "v " 

907 + util.array_to_string( 

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

909 ) 

910 ] 

911 ) 

912 

913 # if include_normals is None then 

914 # only include if they're already stored 

915 if include_normals is None: 

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

917 

918 if include_normals: 

919 try: 

920 converted = util.array_to_string( 

921 current.vertex_normals, 

922 col_delim=" ", 

923 row_delim="\nvn ", 

924 digits=digits, 

925 ) 

926 # if vertex normals are stored in cache export them 

927 face_type.append("vn") 

928 export.append("vn " + converted) 

929 except BaseException: 

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

931 

932 # collect materials into a dict 

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

934 try: 

935 # get a SimpleMaterial 

936 material = current.visual.material 

937 if hasattr(material, "to_simple"): 

938 material = material.to_simple() 

939 

940 # hash the material to avoid duplicates 

941 hashed = hash(material) 

942 if hashed not in materials: 

943 # get a unique name for the material 

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

945 # add the name to our collection 

946 materials_name.add(name) 

947 # convert material to an OBJ MTL 

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

949 

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

951 tex_name = materials[hashed][1] 

952 

953 # export the UV coordinates 

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

955 converted = util.array_to_string( 

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

957 ) 

958 # if vertex texture exists and is the right shape 

959 face_type.append("vt") 

960 # add the uv coordinates 

961 export.append("vt " + converted) 

962 # add the directive to use the exported material 

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

964 except BaseException: 

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

966 

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

968 face_format = face_formats[tuple(face_type)] 

969 # add the exported faces to the export if available 

970 if hasattr(current, "faces"): 

971 export.append( 

972 "f " 

973 + util.array_to_string( 

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

975 col_delim=" ", 

976 row_delim="\nf ", 

977 value_format=face_format, 

978 ) 

979 ) 

980 # offset our vertex position 

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

982 

983 # add object name if found in metadata 

984 if "name" in current.metadata: 

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

986 # add this object 

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

988 

989 # collect files like images to write 

990 mtl_data = {} 

991 # combine materials 

992 if len(materials) > 0: 

993 # collect text for a single mtllib file 

994 mtl_lib = [] 

995 # now loop through: keys are garbage hash 

996 # values are (data, name) 

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

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

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

1000 # collect mtl lines into single file 

1001 mtl_lib.append(file_data) 

1002 elif file_name not in mtl_data: 

1003 # things like images 

1004 mtl_data[file_name] = file_data 

1005 else: 

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

1007 

1008 if mtl_name is None: 

1009 # if no name passed set a default 

1010 mtl_name = "material.mtl" 

1011 

1012 # prepend a header to the MTL text if requested 

1013 if header is not None: 

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

1015 else: 

1016 prepend = b"" 

1017 

1018 # save the material data 

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

1020 # add the reference to the MTL file 

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

1022 

1023 if header is not None: 

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

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

1026 

1027 # add a trailing newline 

1028 objects.append("\n") 

1029 

1030 # combine elements into a single string 

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

1032 

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

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

1035 # not all resolvers have a write method 

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

1037 

1038 # if we exported texture it changes returned values 

1039 if return_texture: 

1040 return text, mtl_data 

1041 

1042 return text 

1043 

1044 

1045_obj_loaders = {"obj": load_obj}