Coverage for trimesh/exchange/ply.py: 90%

431 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1import collections 

2import subprocess 

3import tempfile 

4from string import Template 

5 

6import numpy as np 

7from numpy.lib.recfunctions import structured_to_unstructured, unstructured_to_structured 

8 

9from .. import grouping, resources, util, visual 

10from ..constants import log 

11from ..geometry import triangulate_quads 

12from ..resolvers import Resolver 

13from ..typed import ArrayLike 

14 

15# from ply specification, and additional dtypes found in the wild 

16_dtypes = { 

17 "char": "i1", 

18 "uchar": "u1", 

19 "short": "i2", 

20 "ushort": "u2", 

21 "int": "i4", 

22 "int8": "i1", 

23 "int16": "i2", 

24 "int32": "i4", 

25 "int64": "i8", 

26 "uint": "u4", 

27 "uint8": "u1", 

28 "uint16": "u2", 

29 "uint32": "u4", 

30 "uint64": "u8", 

31 "float": "f4", 

32 "float16": "f2", 

33 "float32": "f4", 

34 "float64": "f8", 

35 "double": "f8", 

36} 

37 

38# Inverse of the above dict, collisions on numpy type were removed 

39_inverse_dtypes = { 

40 "i1": "char", 

41 "u1": "uchar", 

42 "i2": "short", 

43 "u2": "ushort", 

44 "i4": "int", 

45 "i8": "int64", 

46 "u4": "uint", 

47 "u8": "uint64", 

48 "f4": "float", 

49 "f2": "float16", 

50 "f8": "double", 

51} 

52 

53 

54def _numpy_type_to_ply_type(_numpy_type): 

55 """ 

56 Returns the closest ply equivalent of a numpy type 

57 

58 Parameters 

59 --------- 

60 _numpy_type : a numpy datatype 

61 

62 Returns 

63 --------- 

64 ply_type : string 

65 """ 

66 return _inverse_dtypes[_numpy_type.str[1:]] 

67 

68 

69def load_ply( 

70 file_obj, 

71 resolver: Resolver | None = None, 

72 fix_texture: bool = True, 

73 prefer_color: str | None = None, 

74 skip_materials: bool = False, 

75 *args, 

76 **kwargs, 

77): 

78 """ 

79 Load a PLY file from an open file object. 

80 

81 Parameters 

82 --------- 

83 file_obj : an open file- like object 

84 Source data, ASCII or binary PLY 

85 resolver 

86 Object which can resolve assets 

87 fix_texture 

88 If True, will re- index vertices and faces 

89 so vertices with different UV coordinates 

90 are disconnected. 

91 skip_materials 

92 If True, will not load texture (if present). 

93 prefer_color 

94 None, 'vertex', or 'face' 

95 Which kind of color to prefer if both defined 

96 

97 Returns 

98 --------- 

99 mesh_kwargs : dict 

100 Data which can be passed to 

101 Trimesh constructor, eg: a = Trimesh(**mesh_kwargs) 

102 """ 

103 

104 # OrderedDict which is populated from the header 

105 elements, is_ascii, image_name = _parse_header(file_obj) 

106 

107 # functions will fill in elements from file_obj 

108 if is_ascii: 

109 _ply_ascii(elements, file_obj) 

110 else: 

111 _ply_binary(elements, file_obj) 

112 

113 # try to load the referenced image 

114 image = None 

115 if not skip_materials: 

116 try: 

117 # soft dependency 

118 import PIL.Image 

119 

120 # if an image name is passed try to load it 

121 if image_name is not None: 

122 data = resolver.get(image_name) 

123 image = PIL.Image.open(util.wrap_as_stream(data)) 

124 except ImportError: 

125 log.debug("textures require `pip install pillow`") 

126 except BaseException: 

127 log.warning("unable to load image!", exc_info=True) 

128 

129 # translate loaded PLY elements to kwargs 

130 kwargs = _elements_to_kwargs( 

131 image=image, elements=elements, fix_texture=fix_texture, prefer_color=prefer_color 

132 ) 

133 

134 return kwargs 

135 

136 

137def _add_attributes_to_dtype(dtype, attributes): 

138 """ 

139 Parses attribute datatype to populate a numpy dtype list 

140 

141 Parameters 

142 ---------- 

143 dtype : list of numpy datatypes 

144 operated on in place 

145 attributes : dict 

146 contains all the attributes to parse 

147 

148 Returns 

149 ---------- 

150 dtype : list of numpy datatypes 

151 """ 

152 for name, data in attributes.items(): 

153 # force little-endian to match PLY binary format 

154 field_dtype = data.dtype.newbyteorder("<") 

155 if data.ndim > 1: 

156 dtype.extend([(f"{name}_count", "<u1"), (name, field_dtype, data.shape[1])]) 

157 else: 

158 dtype.append((name, field_dtype)) 

159 return dtype 

160 

161 

162def _add_attributes_to_header(header, attributes): 

163 """ 

164 Parses attributes in to ply header entries 

165 

166 Parameters 

167 ---------- 

168 header : list of ply header entries 

169 operated on in place 

170 attributes : dict 

171 contains all the attributes to parse 

172 

173 Returns 

174 ---------- 

175 header : list 

176 Contains ply header entries 

177 """ 

178 for name, data in attributes.items(): 

179 if data.ndim == 1: 

180 header.append(f"property {_numpy_type_to_ply_type(data.dtype)} {name}\n") 

181 else: 

182 header.append( 

183 f"property list uchar {_numpy_type_to_ply_type(data.dtype)} {name}\n" 

184 ) 

185 return header 

186 

187 

188def _add_attributes_to_data_array(data_array, attributes): 

189 """ 

190 Parses attribute data in to a custom array, assumes datatype has been defined 

191 appropriately 

192 

193 Parameters 

194 ---------- 

195 data_array : numpy array with custom datatype 

196 datatype reflects all the data to be stored for a given ply element 

197 attributes : dict 

198 contains all the attributes to parse 

199 

200 Returns 

201 ---------- 

202 data_array : numpy array with custom datatype 

203 """ 

204 for name, data in attributes.items(): 

205 if data.ndim > 1: 

206 data_array[f"{name}_count"] = data.shape[1] * np.ones(data.shape[0]) 

207 data_array[name] = data 

208 return data_array 

209 

210 

211def _assert_attributes_valid(attributes): 

212 """ 

213 Asserts that a set of attributes is valid for PLY export. 

214 

215 Parameters 

216 ---------- 

217 attributes : dict 

218 Contains the attributes to validate 

219 

220 Raises 

221 -------- 

222 ValueError 

223 If passed attributes aren't valid. 

224 """ 

225 for data in attributes.values(): 

226 if data.ndim not in [1, 2]: 

227 raise ValueError("PLY attributes are limited to 1 or 2 dimensions") 

228 # Inelegant test for structured arrays, reference: 

229 # https://numpy.org/doc/stable/user/basics.rec.html 

230 if data.dtype.names is not None: 

231 raise ValueError("PLY attributes must be of a single datatype") 

232 

233 

234def export_ply( 

235 mesh, 

236 encoding="binary", 

237 vertex_normal: bool | None = None, 

238 include_attributes: bool = True, 

239): 

240 """ 

241 Export a mesh in the PLY format. 

242 

243 Parameters 

244 ---------- 

245 mesh : trimesh.Trimesh 

246 Mesh to export. 

247 encoding : str 

248 PLY encoding: 'ascii' or 'binary_little_endian' 

249 vertex_normal : None or include vertex normals 

250 

251 Returns 

252 ---------- 

253 export : bytes of result 

254 """ 

255 # evaluate input args 

256 # allow a shortcut for binary 

257 if encoding == "binary": 

258 encoding = "binary_little_endian" 

259 elif encoding not in ["binary_little_endian", "ascii"]: 

260 raise ValueError("encoding must be binary or ascii") 

261 # if vertex normals aren't specifically asked for 

262 # only export them if they are stored in cache 

263 if vertex_normal is None: 

264 vertex_normal = "vertex_normals" in mesh._cache 

265 

266 # if we want to include mesh attributes in the export 

267 if include_attributes: 

268 if hasattr(mesh, "vertex_attributes"): 

269 # make sure to export texture coordinates as well 

270 if ( 

271 hasattr(mesh, "visual") 

272 and hasattr(mesh.visual, "uv") 

273 and np.shape(mesh.visual.uv) == (len(mesh.vertices), 2) 

274 ): 

275 mesh.vertex_attributes["s"] = mesh.visual.uv[:, 0] 

276 mesh.vertex_attributes["t"] = mesh.visual.uv[:, 1] 

277 _assert_attributes_valid(mesh.vertex_attributes) 

278 if hasattr(mesh, "face_attributes"): 

279 _assert_attributes_valid(mesh.face_attributes) 

280 

281 # custom numpy dtypes for exporting 

282 dtype_face = [("count", "<u1"), ("index", "<i4", (3))] 

283 dtype_vertex = [("vertex", "<f4", (3))] 

284 # will be appended to main dtype if needed 

285 dtype_vertex_normal = ("normals", "<f4", (3)) 

286 dtype_color = ("rgba", "<u1", (4)) 

287 # for Path objects. 

288 dtype_edge = [("index", "<i4", (2))] 

289 

290 # get template strings in dict 

291 templates = resources.get_json("templates/ply.json") 

292 # start collecting elements into a string for the header 

293 header = [templates["intro"]] 

294 header_params = {"encoding": encoding} 

295 

296 # structured arrays for exports 

297 pack_edges: ArrayLike | None = None 

298 pack_vertex: ArrayLike | None = None 

299 pack_faces: ArrayLike | None = None 

300 

301 # check if scene has geometry 

302 # check if this is a `trimesh.path.Path` object. 

303 if hasattr(mesh, "entities"): 

304 if len(mesh.vertices) and mesh.vertices.shape[-1] != 3: 

305 raise ValueError("only Path3D export is supported for ply") 

306 

307 if len(mesh.vertices) > 0: 

308 # run the discrete curve step for each entity 

309 discrete = [e.discrete(mesh.vertices) for e in mesh.entities] 

310 

311 # how long was each discrete curve 

312 discrete_len = np.array([d.shape[0] for d in discrete]) 

313 # what's the index offset based on these lengths 

314 discrete_off = np.concatenate(([0], np.cumsum(discrete_len)[:-1])) 

315 

316 # pre-stack edges we can slice and offset 

317 longest = discrete_len.max() 

318 stack = np.column_stack((np.arange(0, longest - 1), np.arange(1, longest))) 

319 

320 # get the indexes that reconstruct the discrete curves when stacked 

321 edges = np.vstack( 

322 [ 

323 stack[:length] + offset 

324 for length, offset in zip(discrete_len - 1, discrete_off) 

325 ] 

326 ) 

327 

328 vertices = np.vstack(discrete) 

329 # create and populate the custom dtype for vertices 

330 num_vertices = len(vertices) 

331 # put mesh edge data into custom dtype to export 

332 num_edges = len(edges) 

333 

334 if num_edges > 0 and num_vertices > 0: 

335 header.append(templates["vertex"]) 

336 pack_vertex = np.zeros(num_vertices, dtype=dtype_vertex) 

337 pack_vertex["vertex"] = np.asarray(vertices, dtype=np.float32) 

338 

339 # add the edge info to the header 

340 header.append(templates["edge"]) 

341 # pack edges into our dtype 

342 pack_edges = unstructured_to_structured(edges, dtype=dtype_edge) 

343 

344 # add the values for the header 

345 header_params.update( 

346 {"edge_count": num_edges, "vertex_count": num_vertices} 

347 ) 

348 

349 elif hasattr(mesh, "vertices"): 

350 header.append(templates["vertex"]) 

351 

352 num_vertices = len(mesh.vertices) 

353 header_params["vertex_count"] = num_vertices 

354 # if we're exporting vertex normals add them 

355 # to the header and dtype 

356 if vertex_normal: 

357 header.append(templates["vertex_normal"]) 

358 dtype_vertex.append(dtype_vertex_normal) 

359 

360 # if mesh has a vertex color add it to the header 

361 vertex_color = ( 

362 hasattr(mesh, "visual") 

363 and mesh.visual.kind == "vertex" 

364 and len(mesh.visual.vertex_colors) == len(mesh.vertices) 

365 ) 

366 if vertex_color: 

367 header.append(templates["color"]) 

368 dtype_vertex.append(dtype_color) 

369 

370 if include_attributes: 

371 if hasattr(mesh, "vertex_attributes"): 

372 vertex_count = len(mesh.vertices) 

373 vertex_attributes = { 

374 k: v 

375 for k, v in mesh.vertex_attributes.items() 

376 if hasattr(v, "__len__") and len(v) == vertex_count 

377 } 

378 _add_attributes_to_header(header, vertex_attributes) 

379 _add_attributes_to_dtype(dtype_vertex, vertex_attributes) 

380 else: 

381 vertex_attributes = None 

382 

383 # create and populate the custom dtype for vertices 

384 pack_vertex = np.zeros(num_vertices, dtype=dtype_vertex) 

385 pack_vertex["vertex"] = mesh.vertices 

386 if vertex_normal: 

387 pack_vertex["normals"] = mesh.vertex_normals 

388 if vertex_color: 

389 pack_vertex["rgba"] = mesh.visual.vertex_colors 

390 

391 if include_attributes and vertex_attributes is not None: 

392 _add_attributes_to_data_array(pack_vertex, vertex_attributes) 

393 

394 if hasattr(mesh, "faces"): 

395 header.append(templates["face"]) 

396 if mesh.visual.kind == "face" and encoding != "ascii": 

397 header.append(templates["color"]) 

398 dtype_face.append(dtype_color) 

399 

400 if include_attributes and hasattr(mesh, "face_attributes"): 

401 _add_attributes_to_header(header, mesh.face_attributes) 

402 _add_attributes_to_dtype(dtype_face, mesh.face_attributes) 

403 

404 # put mesh face data into custom dtype to export 

405 pack_faces = np.zeros(len(mesh.faces), dtype=dtype_face) 

406 pack_faces["count"] = 3 

407 pack_faces["index"] = mesh.faces 

408 if mesh.visual.kind == "face" and encoding != "ascii": 

409 pack_faces["rgba"] = mesh.visual.face_colors 

410 header_params["face_count"] = len(mesh.faces) 

411 

412 if include_attributes and hasattr(mesh, "face_attributes"): 

413 _add_attributes_to_data_array(pack_faces, mesh.face_attributes) 

414 

415 header.append(templates["outro"]) 

416 export = [Template("".join(header)).substitute(header_params).encode("utf-8")] 

417 

418 if encoding == "binary_little_endian": 

419 if pack_vertex is not None: 

420 export.append(pack_vertex.tobytes()) 

421 if pack_faces is not None: 

422 export.append(pack_faces.tobytes()) 

423 if pack_edges is not None: 

424 export.append(pack_edges.tobytes()) 

425 elif encoding == "ascii": 

426 if pack_vertex is not None: 

427 export.append( 

428 util.structured_array_to_string( 

429 pack_vertex, col_delim=" ", row_delim="\n" 

430 ).encode("utf-8"), 

431 ) 

432 

433 if pack_faces is not None: 

434 export.extend( 

435 [ 

436 b"\n", 

437 util.structured_array_to_string( 

438 pack_faces, col_delim=" ", row_delim="\n" 

439 ).encode("utf-8"), 

440 ] 

441 ) 

442 

443 if pack_edges is not None: 

444 export.extend( 

445 [ 

446 b"\n", 

447 util.structured_array_to_string( 

448 pack_edges, col_delim=" ", row_delim="\n" 

449 ).encode("utf-8"), 

450 ] 

451 ) 

452 export.append(b"\n") 

453 

454 else: 

455 raise ValueError("encoding must be ascii or binary!") 

456 

457 return b"".join(export) 

458 

459 

460def _parse_header(file_obj): 

461 """ 

462 Read the ASCII header of a PLY file, and leave the file object 

463 at the position of the start of data but past the header. 

464 

465 Parameters 

466 ----------- 

467 file_obj : open file object 

468 Positioned at the start of the file 

469 

470 Returns 

471 ----------- 

472 elements : collections.OrderedDict 

473 Fields and data types populated 

474 is_ascii : bool 

475 Whether the data is ASCII or binary 

476 image_name : None or str 

477 File name of TextureFile 

478 """ 

479 

480 if "ply" not in str(file_obj.readline()).lower(): 

481 raise ValueError("Not a ply file!") 

482 

483 # collect the encoding: binary or ASCII 

484 encoding = file_obj.readline().decode("utf-8").strip().lower() 

485 is_ascii = "ascii" in encoding 

486 

487 # big or little endian 

488 endian = ["<", ">"][int("big" in encoding)] 

489 elements = collections.OrderedDict() 

490 

491 # store file name of TextureFiles in the header 

492 image_name = None 

493 

494 while True: 

495 raw = file_obj.readline() 

496 if raw is None: 

497 raise ValueError("Header not terminated properly!") 

498 raw = raw.decode("utf-8").strip() 

499 line = raw.split() 

500 

501 # we're done 

502 if "end_header" in line: 

503 break 

504 

505 # elements are groups of properties 

506 if "element" in line[0]: 

507 # we got a new element so add it 

508 name, length = line[1:] 

509 elements[name] = { 

510 "length": int(length), 

511 "properties": collections.OrderedDict(), 

512 } 

513 # a property is a member of an element 

514 elif "property" in line[0]: 

515 # is the property a simple single value, like: 

516 # `property float x` 

517 if len(line) == 3: 

518 dtype, field = line[1:] 

519 elements[name]["properties"][str(field)] = endian + _dtypes[dtype] 

520 # is the property a painful list, like: 

521 # `property list uchar int vertex_indices` 

522 elif "list" in line[1]: 

523 dtype_count, dtype, field = line[2:] 

524 elements[name]["properties"][str(field)] = ( 

525 endian + _dtypes[dtype_count] + ", ($LIST,)" + endian + _dtypes[dtype] 

526 ) 

527 # referenced as a file name 

528 elif "texturefile" in raw.lower(): 

529 # textures come listed like: 

530 # `comment TextureFile fuze_uv.jpg` 

531 index = raw.lower().index("texturefile") + 11 

532 # use the value from raw to preserve whitespace 

533 image_name = raw[index:].strip() 

534 

535 return elements, is_ascii, image_name 

536 

537 

538def _elements_to_kwargs(elements, fix_texture, image, prefer_color=None): 

539 """ 

540 Given an elements data structure, extract the keyword 

541 arguments that a Trimesh object constructor will expect. 

542 

543 Parameters 

544 ------------ 

545 elements : OrderedDict object 

546 With fields and data loaded 

547 fix_texture : bool 

548 If True, will re- index vertices and faces 

549 so vertices with different UV coordinates 

550 are disconnected. 

551 image : PIL.Image 

552 Image to be viewed 

553 prefer_color : None, 'vertex', or 'face' 

554 Which kind of color to prefer if both defined 

555 

556 Returns 

557 ----------- 

558 kwargs : dict 

559 Keyword arguments for Trimesh constructor 

560 """ 

561 # store the raw ply structure as an internal key in metadata 

562 kwargs = {"metadata": {"_ply_raw": elements}} 

563 

564 if "vertex" in elements and elements["vertex"]["length"]: 

565 vertices = np.column_stack([elements["vertex"]["data"][i] for i in "xyz"]) 

566 if not util.is_shape(vertices, (-1, 3)): 

567 raise ValueError("Vertices were not (n,3)!") 

568 else: 

569 # return empty geometry if there are no vertices 

570 kwargs["geometry"] = {} 

571 return kwargs 

572 

573 try: 

574 vertex_normals = np.column_stack( 

575 [elements["vertex"]["data"][j] for j in ("nx", "ny", "nz")] 

576 ) 

577 if len(vertex_normals) == len(vertices): 

578 kwargs["vertex_normals"] = vertex_normals 

579 except BaseException: 

580 pass 

581 

582 if "face" in elements and elements["face"]["length"]: 

583 face_data = elements["face"]["data"] 

584 else: 

585 # some PLY files only include vertices 

586 face_data = None 

587 faces = None 

588 

589 # what keys do in-the-wild exporters use for vertices 

590 index_names = ["vertex_index", "vertex_indices"] 

591 texcoord = None 

592 

593 if util.is_shape(face_data, (-1, (3, 4))): 

594 faces = face_data 

595 elif isinstance(face_data, dict): 

596 # get vertex indexes 

597 for i in index_names: 

598 if i in face_data: 

599 faces = face_data[i] 

600 break 

601 # if faces have UV coordinates defined use them 

602 if "texcoord" in face_data: 

603 texcoord = face_data["texcoord"] 

604 

605 elif isinstance(face_data, np.ndarray): 

606 face_blob = elements["face"]["data"] 

607 # some exporters set this name to 'vertex_index' 

608 # and some others use 'vertex_indices' but we really 

609 # don't care about the name unless there are multiple 

610 if len(face_blob.dtype.names) == 1: 

611 name = face_blob.dtype.names[0] 

612 elif len(face_blob.dtype.names) > 1: 

613 # loop through options 

614 for i in face_blob.dtype.names: 

615 if i in index_names: 

616 name = i 

617 break 

618 # get faces 

619 faces = face_blob[name]["f1"] 

620 

621 try: 

622 texcoord = face_blob["texcoord"]["f1"] 

623 except (ValueError, KeyError): 

624 # accessing numpy arrays with named fields 

625 # incorrectly is a ValueError 

626 pass 

627 

628 if faces is not None: 

629 shape = np.shape(faces) 

630 if len(shape) != 2: 

631 # we may have mixed quads and triangles handle them with function 

632 faces = triangulate_quads(faces) 

633 

634 if texcoord is None: 

635 # ply has no clear definition of how texture coordinates are stored, 

636 # unfortunately there are many common names that we need to try 

637 texcoord_names = [("texture_u", "texture_v"), ("u", "v"), ("s", "t")] 

638 for names in texcoord_names: 

639 # If texture coordinates are defined with vertices 

640 try: 

641 t_u = elements["vertex"]["data"][names[0]] 

642 t_v = elements["vertex"]["data"][names[1]] 

643 texcoord = np.stack( 

644 (t_u[faces.reshape(-1)], t_v[faces.reshape(-1)]), axis=-1 

645 ).reshape((faces.shape[0], -1)) 

646 # stop trying once succeeded 

647 break 

648 except (ValueError, KeyError): 

649 # if the fields didn't exist 

650 pass 

651 

652 shape = np.shape(faces) 

653 

654 # PLY stores texture coordinates per-face which is 

655 # slightly annoying, as we have to then figure out 

656 # which vertices have the same position but different UV 

657 if ( 

658 texcoord is not None 

659 and len(shape) == 2 

660 and texcoord.shape == (faces.shape[0], faces.shape[1] * 2) 

661 ): 

662 # vertices with the same position but different 

663 # UV coordinates can't be merged without it 

664 # looking like it went through a woodchipper 

665 # in- the- wild PLY comes with things merged that 

666 # probably shouldn't be so disconnect vertices 

667 if fix_texture: 

668 # do import here 

669 from ..visual.texture import unmerge_faces 

670 

671 # reshape to correspond with flattened faces 

672 uv_all = texcoord.reshape((-1, 2)) 

673 # UV coordinates defined for every triangle have 

674 # duplicates which can be merged so figure out 

675 # which UV coordinates are the same here 

676 unique, inverse = grouping.unique_rows(uv_all) 

677 

678 # use the indices of faces and face textures 

679 # to only merge vertices where the position 

680 # AND uv coordinate are the same 

681 faces, mask_v, mask_vt = unmerge_faces( 

682 faces, inverse.reshape(faces.shape) 

683 ) 

684 # apply the mask to get resulting vertices 

685 vertices = vertices[mask_v] 

686 # apply the mask to get UV coordinates 

687 uv = uv_all[unique][mask_vt] 

688 else: 

689 # don't alter vertices, UV will look like crap 

690 # if it was exported with vertices merged 

691 uv = np.zeros((len(vertices), 2)) 

692 uv[faces.reshape(-1)] = texcoord.reshape((-1, 2)) 

693 

694 # create the visuals object for the texture 

695 kwargs["visual"] = visual.texture.TextureVisuals(uv=uv, image=image) 

696 elif texcoord is not None: 

697 # create a texture with an empty material 

698 from ..visual.texture import TextureVisuals 

699 

700 uv = np.zeros((len(vertices), 2)) 

701 uv[faces.reshape(-1)] = texcoord.reshape((-1, 2)) 

702 kwargs["visual"] = TextureVisuals(uv=uv) 

703 # faces were not none so assign them 

704 kwargs["faces"] = faces 

705 # kwargs for Trimesh or PointCloud 

706 kwargs["vertices"] = vertices 

707 

708 # if both vertex and face color are defined pick the one 

709 if "face" in elements: 

710 kwargs["face_colors"] = _element_colors(elements["face"]) 

711 if "vertex" in elements: 

712 kwargs["vertex_colors"] = _element_colors(elements["vertex"]) 

713 

714 # check if we have gotten path elements 

715 edge_data = elements.get("edge", {}).get("data", None) 

716 if edge_data is not None: 

717 # try to convert the data in the PLY file to (n, 2) edge indexes 

718 edges = None 

719 if isinstance(edge_data, dict): 

720 try: 

721 edges = np.column_stack((edge_data["vertex1"], edge_data["vertex2"])) 

722 except BaseException: 

723 log.debug( 

724 f"failed to convert PLY edges from keys: {edge_data.keys()}", 

725 exc_info=True, 

726 ) 

727 elif isinstance(edge_data, np.ndarray): 

728 # is this the best way to check for a structured dtype? 

729 if len(edge_data.shape) == 2 and edge_data.shape[1] == 2: 

730 edges = edge_data 

731 else: 

732 # we could also check `edge_data.dtype.kind in 'OV'` 

733 # but its not clear that that handles all the possibilities 

734 edges = structured_to_unstructured(edge_data) 

735 

736 if edges is not None: 

737 from ..path.exchange.misc import edges_to_path 

738 

739 kwargs.update(edges_to_path(edges, kwargs["vertices"])) 

740 return kwargs 

741 

742 

743def _element_colors(element): 

744 """ 

745 Given an element, try to extract RGBA color from 

746 properties and return them as an (n,3|4) array. 

747 

748 Parameters 

749 ------------- 

750 element : dict 

751 Containing color keys 

752 

753 Returns 

754 ------------ 

755 colors : (n, 3) or (n, 4) float 

756 Colors extracted from the element 

757 signal : float 

758 Estimate of range 

759 """ 

760 keys = ["red", "green", "blue", "alpha"] 

761 candidate_colors = [element["data"][i] for i in keys if i in element["properties"]] 

762 if len(candidate_colors) >= 3: 

763 return np.column_stack(candidate_colors) 

764 return None 

765 

766 

767def _load_element_different(properties, data): 

768 """ 

769 Load elements which include lists of different lengths 

770 based on the element's property-definitions. 

771 

772 Parameters 

773 ------------ 

774 properties : dict 

775 Property definitions encoded in a dict where the property name is the key 

776 and the property data type the value. 

777 data : array 

778 Data rows for this element. 

779 """ 

780 edata = {k: [] for k in properties.keys()} 

781 for row in data: 

782 start = 0 

783 for name, dt in properties.items(): 

784 length = 1 

785 if "$LIST" in dt: 

786 dt = dt.split("($LIST,)")[-1] 

787 # the first entry in a list-property is the number of elements 

788 # in the list 

789 length = int(row[start]) 

790 # skip the first entry (the length), when reading the data 

791 start += 1 

792 end = start + length 

793 edata[name].append(row[start:end].astype(dt)) 

794 # start next property at the end of this one 

795 start = end 

796 

797 # if the shape of any array is (n, 1) we want to 

798 # squeeze/concatenate it into (n,) 

799 squeeze = {k: np.array(v, dtype="object") for k, v in edata.items()} 

800 # squeeze and convert any clean 2D arrays 

801 squeeze.update( 

802 { 

803 k: v.squeeze().astype(edata[k][0].dtype) 

804 for k, v in squeeze.items() 

805 if len(v.shape) == 2 

806 } 

807 ) 

808 

809 return squeeze 

810 

811 

812def _load_element_single(properties, data): 

813 """ 

814 Load element data with lists of a single length 

815 based on the element's property-definitions. 

816 

817 Parameters 

818 ------------ 

819 properties : dict 

820 Property definitions encoded in a dict where 

821 the property name is the key and the property 

822 data type the value. 

823 data : array 

824 Data rows for this element, if the data contains 

825 list-properties all lists belonging to one property 

826 must have the same length. 

827 """ 

828 

829 first = data[0] 

830 columns = {} 

831 current = 0 

832 for name, dt in properties.items(): 

833 # if the current index has gone past the number 

834 # of items we actually have exit the loop early 

835 if current >= len(first): 

836 break 

837 if "$LIST" in dt: 

838 dtype = dt.split("($LIST,)")[-1] 

839 # the first entry in a list-property 

840 # is the number of elements in the list 

841 

842 length = int(first[current]) 

843 columns[name] = data[:, current + 1 : current + 1 + length].astype(dtype) 

844 # offset by length of array plus one for each uint index 

845 current += length + 1 

846 else: 

847 columns[name] = data[:, current : current + 1].astype(dt) 

848 current += 1 

849 

850 return columns 

851 

852 

853def _ply_ascii(elements, file_obj): 

854 """ 

855 Load data from an ASCII PLY file into an existing elements data structure. 

856 

857 Parameters 

858 ------------ 

859 elements : OrderedDict 

860 Populated from the file header, data will 

861 be added in-place to this object 

862 file_obj : file-like-object 

863 Current position at the start 

864 of the data section (past the header). 

865 """ 

866 

867 # get the file contents as a string 

868 text = str(file_obj.read().decode("utf-8")) 

869 # split by newlines 

870 lines = str.splitlines(text) 

871 # get each line as an array split by whitespace 

872 array = [np.fromstring(i, sep=" ") for i in lines] 

873 # store the line position in the file 

874 row_pos = 0 

875 

876 # loop through data we need 

877 for key, values in elements.items(): 

878 # if the element is empty ignore it 

879 if "length" not in values or values["length"] == 0: 

880 continue 

881 data = array[row_pos : row_pos + values["length"]] 

882 row_pos += values["length"] 

883 # try stacking the data, which simplifies column-wise access. this is only 

884 # possible, if all rows have the same length. 

885 try: 

886 data = np.vstack(data) 

887 col_count_equal = True 

888 except ValueError: 

889 col_count_equal = False 

890 

891 # number of list properties in this element 

892 list_count = sum(1 for dt in values["properties"].values() if "$LIST" in dt) 

893 if col_count_equal and list_count <= 1: 

894 # all rows have the same length and we only have at most one list 

895 # property where all entries have the same length. this means we can 

896 # use the quick numpy-based loading. 

897 element_data = _load_element_single(values["properties"], data) 

898 else: 

899 # there are lists of differing lengths. we need to fall back to loading 

900 # the data by iterating all rows and checking for list-lengths. this is 

901 # slower than the variant above. 

902 element_data = _load_element_different(values["properties"], data) 

903 

904 elements[key]["data"] = element_data 

905 

906 

907def _ply_binary(elements, file_obj): 

908 """ 

909 Load the data from a binary PLY file into the elements data structure. 

910 

911 Parameters 

912 ------------ 

913 elements : OrderedDict 

914 Populated from the file header. 

915 Object will be modified to add data by this function. 

916 file_obj : open file object 

917 With current position at the start 

918 of the data section (past the header) 

919 """ 

920 

921 def populate_listsize(file_obj, elements): 

922 """ 

923 Given a set of elements populated from the header if there are any 

924 list properties seek in the file the length of the list. 

925 

926 Note that if you have a list where each instance is different length 

927 (if for example you mixed triangles and quads) this won't work at all 

928 """ 

929 p_start = file_obj.tell() 

930 p_current = file_obj.tell() 

931 elem_pop = [] 

932 for element_key, element in elements.items(): 

933 props = element["properties"] 

934 prior_data = "" 

935 for k, dtype in props.items(): 

936 prop_pop = [] 

937 if "$LIST" in dtype: 

938 # every list field has two data types: 

939 # the list length (single value), and the list data (multiple) 

940 # here we are only reading the single value for list length 

941 field_dtype = np.dtype(dtype.split(",")[0]) 

942 if len(prior_data) == 0: 

943 offset = 0 

944 else: 

945 offset = np.dtype(prior_data).itemsize 

946 file_obj.seek(p_current + offset) 

947 blob = file_obj.read(field_dtype.itemsize) 

948 if len(blob) == 0: 

949 # no data was read for property 

950 prop_pop.append(k) 

951 break 

952 size = np.frombuffer(blob, dtype=field_dtype)[0] 

953 props[k] = props[k].replace("$LIST", str(size)) 

954 prior_data += props[k] + "," 

955 if len(prop_pop) > 0: 

956 # if a property was empty remove it 

957 for pop in prop_pop: 

958 props.pop(pop) 

959 # if we've removed all properties from 

960 # an element remove the element later 

961 if len(props) == 0: 

962 elem_pop.append(element_key) 

963 continue 

964 # get the size of the items in bytes 

965 itemsize = np.dtype(", ".join(props.values())).itemsize 

966 # offset the file based on read size 

967 p_current += element["length"] * itemsize 

968 # move the file back to where we found it 

969 file_obj.seek(p_start) 

970 # if there were elements without properties remove them 

971 for pop in elem_pop: 

972 elements.pop(pop) 

973 

974 def populate_data(file_obj, elements): 

975 """ 

976 Given the data type and field information from the header, 

977 read the data and add it to a 'data' field in the element. 

978 """ 

979 for key in elements.keys(): 

980 items = list(elements[key]["properties"].items()) 

981 dtype = np.dtype(items) 

982 data = file_obj.read(elements[key]["length"] * dtype.itemsize) 

983 try: 

984 elements[key]["data"] = np.frombuffer(data, dtype=dtype) 

985 except BaseException: 

986 log.warning(f"PLY failed to populate: {key}") 

987 elements[key]["data"] = None 

988 return elements 

989 

990 def _elements_size(elements): 

991 """ 

992 Given an elements data structure populated from the header, 

993 calculate how long the file should be if it is intact. 

994 """ 

995 size = 0 

996 for element in elements.values(): 

997 dtype = np.dtype(",".join(element["properties"].values())) 

998 size += element["length"] * dtype.itemsize 

999 return size 

1000 

1001 # some elements are passed where the list dimensions 

1002 # are not included in the header, so this function goes 

1003 # into the meat of the file and grabs the list dimensions 

1004 # before we to the main data read as a single operation 

1005 populate_listsize(file_obj, elements) 

1006 

1007 # how many bytes are left in the file 

1008 size_file = util.distance_to_end(file_obj) 

1009 # how many bytes should the data structure described by 

1010 # the header take up 

1011 size_elements = _elements_size(elements) 

1012 

1013 # if the number of bytes is not the same the file is probably corrupt 

1014 if size_file != size_elements: 

1015 raise ValueError("PLY is unexpected length!") 

1016 

1017 # with everything populated and a reasonable confidence the file 

1018 # is intact, read the data fields described by the header 

1019 populate_data(file_obj, elements) 

1020 

1021 

1022def export_draco(mesh, bits=28): 

1023 """ 

1024 Export a mesh using Google's Draco compressed format. 

1025 

1026 Only works if draco_encoder is in your PATH: 

1027 https://github.com/google/draco 

1028 

1029 Parameters 

1030 ---------- 

1031 mesh : Trimesh object 

1032 Mesh to export 

1033 bits : int 

1034 Bits of quantization for position 

1035 tol.merge=1e-8 is roughly 25 bits 

1036 

1037 Returns 

1038 ---------- 

1039 data : str or bytes 

1040 DRC file bytes 

1041 """ 

1042 with tempfile.NamedTemporaryFile(suffix=".ply") as temp_ply: 

1043 temp_ply.write(export_ply(mesh)) 

1044 temp_ply.flush() 

1045 with tempfile.NamedTemporaryFile(suffix=".drc") as encoded: 

1046 subprocess.check_output( 

1047 [ 

1048 draco_encoder, 

1049 "-qp", 

1050 str(int(bits)), 

1051 "-i", 

1052 temp_ply.name, 

1053 "-o", 

1054 encoded.name, 

1055 ] 

1056 ) 

1057 encoded.seek(0) 

1058 data = encoded.read() 

1059 return data 

1060 

1061 

1062def load_draco(file_obj, **kwargs): 

1063 """ 

1064 Load a mesh from Google's Draco format. 

1065 

1066 Parameters 

1067 ---------- 

1068 file_obj : file- like object 

1069 Contains data 

1070 

1071 Returns 

1072 ---------- 

1073 kwargs : dict 

1074 Keyword arguments to construct a Trimesh object 

1075 """ 

1076 

1077 with tempfile.NamedTemporaryFile(suffix=".drc") as temp_drc: 

1078 temp_drc.write(file_obj.read()) 

1079 temp_drc.flush() 

1080 

1081 with tempfile.NamedTemporaryFile(suffix=".ply") as temp_ply: 

1082 subprocess.check_output( 

1083 [draco_decoder, "-i", temp_drc.name, "-o", temp_ply.name] 

1084 ) 

1085 temp_ply.seek(0) 

1086 kwargs = load_ply(temp_ply) 

1087 return kwargs 

1088 

1089 

1090_ply_loaders = {"ply": load_ply} 

1091_ply_exporters = {"ply": export_ply} 

1092 

1093draco_encoder = util.which("draco_encoder") 

1094draco_decoder = util.which("draco_decoder") 

1095if draco_decoder is not None: 

1096 _ply_loaders["drc"] = load_draco 

1097if draco_encoder is not None: 

1098 _ply_exporters["drc"] = export_draco