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

433 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-31 18:21 +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 # a property must belong to an element declared before it; a header 

516 # with a property line before any element would otherwise reference 

517 # the unset `name` and raise an UnboundLocalError. 

518 if not elements: 

519 raise ValueError("Property defined before any element!") 

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

521 # `property float x` 

522 if len(line) == 3: 

523 dtype, field = line[1:] 

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

525 # is the property a painful list, like: 

526 # `property list uchar int vertex_indices` 

527 elif "list" in line[1]: 

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

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

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

531 ) 

532 # referenced as a file name 

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

534 # textures come listed like: 

535 # `comment TextureFile fuze_uv.jpg` 

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

537 # use the value from raw to preserve whitespace 

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

539 

540 return elements, is_ascii, image_name 

541 

542 

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

544 """ 

545 Given an elements data structure, extract the keyword 

546 arguments that a Trimesh object constructor will expect. 

547 

548 Parameters 

549 ------------ 

550 elements : OrderedDict object 

551 With fields and data loaded 

552 fix_texture : bool 

553 If True, will re- index vertices and faces 

554 so vertices with different UV coordinates 

555 are disconnected. 

556 image : PIL.Image 

557 Image to be viewed 

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

559 Which kind of color to prefer if both defined 

560 

561 Returns 

562 ----------- 

563 kwargs : dict 

564 Keyword arguments for Trimesh constructor 

565 """ 

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

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

568 

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

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

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

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

573 else: 

574 # return empty geometry if there are no vertices 

575 kwargs["geometry"] = {} 

576 return kwargs 

577 

578 try: 

579 vertex_normals = np.column_stack( 

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

581 ) 

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

583 kwargs["vertex_normals"] = vertex_normals 

584 except BaseException: 

585 pass 

586 

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

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

589 else: 

590 # some PLY files only include vertices 

591 face_data = None 

592 faces = None 

593 

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

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

596 texcoord = None 

597 

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

599 faces = face_data 

600 elif isinstance(face_data, dict): 

601 # get vertex indexes 

602 for i in index_names: 

603 if i in face_data: 

604 faces = face_data[i] 

605 break 

606 # if faces have UV coordinates defined use them 

607 if "texcoord" in face_data: 

608 texcoord = face_data["texcoord"] 

609 

610 elif isinstance(face_data, np.ndarray): 

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

612 # some exporters set this name to 'vertex_index' 

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

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

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

616 name = face_blob.dtype.names[0] 

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

618 # loop through options 

619 for i in face_blob.dtype.names: 

620 if i in index_names: 

621 name = i 

622 break 

623 # get faces 

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

625 

626 try: 

627 texcoord = face_blob["texcoord"]["f1"] 

628 except (ValueError, KeyError): 

629 # accessing numpy arrays with named fields 

630 # incorrectly is a ValueError 

631 pass 

632 

633 if faces is not None: 

634 shape = np.shape(faces) 

635 if len(shape) != 2: 

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

637 faces = triangulate_quads(faces) 

638 

639 if texcoord is None: 

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

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

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

643 for names in texcoord_names: 

644 # If texture coordinates are defined with vertices 

645 try: 

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

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

648 texcoord = np.stack( 

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

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

651 # stop trying once succeeded 

652 break 

653 except (ValueError, KeyError): 

654 # if the fields didn't exist 

655 pass 

656 

657 shape = np.shape(faces) 

658 

659 # PLY stores texture coordinates per-face which is 

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

661 # which vertices have the same position but different UV 

662 if ( 

663 texcoord is not None 

664 and len(shape) == 2 

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

666 ): 

667 # vertices with the same position but different 

668 # UV coordinates can't be merged without it 

669 # looking like it went through a woodchipper 

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

671 # probably shouldn't be so disconnect vertices 

672 if fix_texture: 

673 # do import here 

674 from ..visual.texture import unmerge_faces 

675 

676 # reshape to correspond with flattened faces 

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

678 # UV coordinates defined for every triangle have 

679 # duplicates which can be merged so figure out 

680 # which UV coordinates are the same here 

681 unique, inverse = grouping.unique_rows(uv_all) 

682 

683 # use the indices of faces and face textures 

684 # to only merge vertices where the position 

685 # AND uv coordinate are the same 

686 faces, mask_v, mask_vt = unmerge_faces( 

687 faces, inverse.reshape(faces.shape) 

688 ) 

689 # apply the mask to get resulting vertices 

690 vertices = vertices[mask_v] 

691 # apply the mask to get UV coordinates 

692 uv = uv_all[unique][mask_vt] 

693 else: 

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

695 # if it was exported with vertices merged 

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

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

698 

699 # create the visuals object for the texture 

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

701 elif texcoord is not None: 

702 # create a texture with an empty material 

703 from ..visual.texture import TextureVisuals 

704 

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

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

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

708 # faces were not none so assign them 

709 kwargs["faces"] = faces 

710 # kwargs for Trimesh or PointCloud 

711 kwargs["vertices"] = vertices 

712 

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

714 if "face" in elements: 

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

716 if "vertex" in elements: 

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

718 

719 # check if we have gotten path elements 

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

721 if edge_data is not None: 

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

723 edges = None 

724 if isinstance(edge_data, dict): 

725 try: 

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

727 except BaseException: 

728 log.debug( 

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

730 exc_info=True, 

731 ) 

732 elif isinstance(edge_data, np.ndarray): 

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

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

735 edges = edge_data 

736 else: 

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

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

739 edges = structured_to_unstructured(edge_data) 

740 

741 if edges is not None: 

742 from ..path.exchange.misc import edges_to_path 

743 

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

745 return kwargs 

746 

747 

748def _element_colors(element): 

749 """ 

750 Given an element, try to extract RGBA color from 

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

752 

753 Parameters 

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

755 element : dict 

756 Containing color keys 

757 

758 Returns 

759 ------------ 

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

761 Colors extracted from the element 

762 signal : float 

763 Estimate of range 

764 """ 

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

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

767 if len(candidate_colors) >= 3: 

768 return np.column_stack(candidate_colors) 

769 return None 

770 

771 

772def _load_element_different(properties, data): 

773 """ 

774 Load elements which include lists of different lengths 

775 based on the element's property-definitions. 

776 

777 Parameters 

778 ------------ 

779 properties : dict 

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

781 and the property data type the value. 

782 data : array 

783 Data rows for this element. 

784 """ 

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

786 for row in data: 

787 start = 0 

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

789 length = 1 

790 if "$LIST" in dt: 

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

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

793 # in the list 

794 length = int(row[start]) 

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

796 start += 1 

797 end = start + length 

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

799 # start next property at the end of this one 

800 start = end 

801 

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

803 # squeeze/concatenate it into (n,) 

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

805 # squeeze and convert any clean 2D arrays 

806 squeeze.update( 

807 { 

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

809 for k, v in squeeze.items() 

810 if len(v.shape) == 2 

811 } 

812 ) 

813 

814 return squeeze 

815 

816 

817def _load_element_single(properties, data): 

818 """ 

819 Load element data with lists of a single length 

820 based on the element's property-definitions. 

821 

822 Parameters 

823 ------------ 

824 properties : dict 

825 Property definitions encoded in a dict where 

826 the property name is the key and the property 

827 data type the value. 

828 data : array 

829 Data rows for this element, if the data contains 

830 list-properties all lists belonging to one property 

831 must have the same length. 

832 """ 

833 

834 first = data[0] 

835 columns = {} 

836 current = 0 

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

838 # if the current index has gone past the number 

839 # of items we actually have exit the loop early 

840 if current >= len(first): 

841 break 

842 if "$LIST" in dt: 

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

844 # the first entry in a list-property 

845 # is the number of elements in the list 

846 

847 length = int(first[current]) 

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

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

850 current += length + 1 

851 else: 

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

853 current += 1 

854 

855 return columns 

856 

857 

858def _ply_ascii(elements, file_obj): 

859 """ 

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

861 

862 Parameters 

863 ------------ 

864 elements : OrderedDict 

865 Populated from the file header, data will 

866 be added in-place to this object 

867 file_obj : file-like-object 

868 Current position at the start 

869 of the data section (past the header). 

870 """ 

871 

872 # get the file contents as a string 

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

874 # split by newlines 

875 lines = str.splitlines(text) 

876 # get each line as an array split by whitespace 

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

878 # store the line position in the file 

879 row_pos = 0 

880 

881 # loop through data we need 

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

883 # if the element is empty ignore it 

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

885 continue 

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

887 row_pos += values["length"] 

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

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

890 try: 

891 data = np.vstack(data) 

892 col_count_equal = True 

893 except ValueError: 

894 col_count_equal = False 

895 

896 # number of list properties in this element 

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

898 if col_count_equal and list_count <= 1: 

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

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

901 # use the quick numpy-based loading. 

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

903 else: 

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

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

906 # slower than the variant above. 

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

908 

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

910 

911 

912def _ply_binary(elements, file_obj): 

913 """ 

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

915 

916 Parameters 

917 ------------ 

918 elements : OrderedDict 

919 Populated from the file header. 

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

921 file_obj : open file object 

922 With current position at the start 

923 of the data section (past the header) 

924 """ 

925 

926 def populate_listsize(file_obj, elements): 

927 """ 

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

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

930 

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

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

933 """ 

934 p_start = file_obj.tell() 

935 p_current = file_obj.tell() 

936 elem_pop = [] 

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

938 props = element["properties"] 

939 prior_data = "" 

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

941 prop_pop = [] 

942 if "$LIST" in dtype: 

943 # every list field has two data types: 

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

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

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

947 if len(prior_data) == 0: 

948 offset = 0 

949 else: 

950 offset = np.dtype(prior_data).itemsize 

951 file_obj.seek(p_current + offset) 

952 blob = file_obj.read(field_dtype.itemsize) 

953 if len(blob) == 0: 

954 # no data was read for property 

955 prop_pop.append(k) 

956 break 

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

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

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

960 if len(prop_pop) > 0: 

961 # if a property was empty remove it 

962 for pop in prop_pop: 

963 props.pop(pop) 

964 # if we've removed all properties from 

965 # an element remove the element later 

966 if len(props) == 0: 

967 elem_pop.append(element_key) 

968 continue 

969 # get the size of the items in bytes 

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

971 # offset the file based on read size 

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

973 # move the file back to where we found it 

974 file_obj.seek(p_start) 

975 # if there were elements without properties remove them 

976 for pop in elem_pop: 

977 elements.pop(pop) 

978 

979 def populate_data(file_obj, elements): 

980 """ 

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

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

983 """ 

984 for key in elements.keys(): 

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

986 dtype = np.dtype(items) 

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

988 try: 

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

990 except BaseException: 

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

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

993 return elements 

994 

995 def _elements_size(elements): 

996 """ 

997 Given an elements data structure populated from the header, 

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

999 """ 

1000 size = 0 

1001 for element in elements.values(): 

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

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

1004 return size 

1005 

1006 # some elements are passed where the list dimensions 

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

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

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

1010 populate_listsize(file_obj, elements) 

1011 

1012 # how many bytes are left in the file 

1013 size_file = util.distance_to_end(file_obj) 

1014 # how many bytes should the data structure described by 

1015 # the header take up 

1016 size_elements = _elements_size(elements) 

1017 

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

1019 if size_file != size_elements: 

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

1021 

1022 # with everything populated and a reasonable confidence the file 

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

1024 populate_data(file_obj, elements) 

1025 

1026 

1027def export_draco(mesh, bits=28): 

1028 """ 

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

1030 

1031 Only works if draco_encoder is in your PATH: 

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

1033 

1034 Parameters 

1035 ---------- 

1036 mesh : Trimesh object 

1037 Mesh to export 

1038 bits : int 

1039 Bits of quantization for position 

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

1041 

1042 Returns 

1043 ---------- 

1044 data : str or bytes 

1045 DRC file bytes 

1046 """ 

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

1048 temp_ply.write(export_ply(mesh)) 

1049 temp_ply.flush() 

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

1051 subprocess.check_output( 

1052 [ 

1053 draco_encoder, 

1054 "-qp", 

1055 str(int(bits)), 

1056 "-i", 

1057 temp_ply.name, 

1058 "-o", 

1059 encoded.name, 

1060 ] 

1061 ) 

1062 encoded.seek(0) 

1063 data = encoded.read() 

1064 return data 

1065 

1066 

1067def load_draco(file_obj, **kwargs): 

1068 """ 

1069 Load a mesh from Google's Draco format. 

1070 

1071 Parameters 

1072 ---------- 

1073 file_obj : file- like object 

1074 Contains data 

1075 

1076 Returns 

1077 ---------- 

1078 kwargs : dict 

1079 Keyword arguments to construct a Trimesh object 

1080 """ 

1081 

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

1083 temp_drc.write(file_obj.read()) 

1084 temp_drc.flush() 

1085 

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

1087 subprocess.check_output( 

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

1089 ) 

1090 temp_ply.seek(0) 

1091 kwargs = load_ply(temp_ply) 

1092 return kwargs 

1093 

1094 

1095_ply_loaders = {"ply": load_ply} 

1096_ply_exporters = {"ply": export_ply} 

1097 

1098draco_encoder = util.which("draco_encoder") 

1099draco_decoder = util.which("draco_decoder") 

1100if draco_decoder is not None: 

1101 _ply_loaders["drc"] = load_draco 

1102if draco_encoder is not None: 

1103 _ply_exporters["drc"] = export_draco