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
« 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
6import numpy as np
7from numpy.lib.recfunctions import structured_to_unstructured, unstructured_to_structured
9from .. import grouping, resources, util, visual
10from ..constants import log
11from ..geometry import triangulate_quads
12from ..resolvers import Resolver
13from ..typed import ArrayLike
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}
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}
54def _numpy_type_to_ply_type(_numpy_type):
55 """
56 Returns the closest ply equivalent of a numpy type
58 Parameters
59 ---------
60 _numpy_type : a numpy datatype
62 Returns
63 ---------
64 ply_type : string
65 """
66 return _inverse_dtypes[_numpy_type.str[1:]]
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.
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
97 Returns
98 ---------
99 mesh_kwargs : dict
100 Data which can be passed to
101 Trimesh constructor, eg: a = Trimesh(**mesh_kwargs)
102 """
104 # OrderedDict which is populated from the header
105 elements, is_ascii, image_name = _parse_header(file_obj)
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)
113 # try to load the referenced image
114 image = None
115 if not skip_materials:
116 try:
117 # soft dependency
118 import PIL.Image
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)
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 )
134 return kwargs
137def _add_attributes_to_dtype(dtype, attributes):
138 """
139 Parses attribute datatype to populate a numpy dtype list
141 Parameters
142 ----------
143 dtype : list of numpy datatypes
144 operated on in place
145 attributes : dict
146 contains all the attributes to parse
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
162def _add_attributes_to_header(header, attributes):
163 """
164 Parses attributes in to ply header entries
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
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
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
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
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
211def _assert_attributes_valid(attributes):
212 """
213 Asserts that a set of attributes is valid for PLY export.
215 Parameters
216 ----------
217 attributes : dict
218 Contains the attributes to validate
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")
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.
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
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
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)
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))]
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}
296 # structured arrays for exports
297 pack_edges: ArrayLike | None = None
298 pack_vertex: ArrayLike | None = None
299 pack_faces: ArrayLike | None = None
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")
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]
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]))
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)))
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 )
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)
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)
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)
344 # add the values for the header
345 header_params.update(
346 {"edge_count": num_edges, "vertex_count": num_vertices}
347 )
349 elif hasattr(mesh, "vertices"):
350 header.append(templates["vertex"])
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)
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)
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
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
391 if include_attributes and vertex_attributes is not None:
392 _add_attributes_to_data_array(pack_vertex, vertex_attributes)
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)
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)
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)
412 if include_attributes and hasattr(mesh, "face_attributes"):
413 _add_attributes_to_data_array(pack_faces, mesh.face_attributes)
415 header.append(templates["outro"])
416 export = [Template("".join(header)).substitute(header_params).encode("utf-8")]
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 )
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 )
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")
454 else:
455 raise ValueError("encoding must be ascii or binary!")
457 return b"".join(export)
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.
465 Parameters
466 -----------
467 file_obj : open file object
468 Positioned at the start of the file
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 """
480 if "ply" not in str(file_obj.readline()).lower():
481 raise ValueError("Not a ply file!")
483 # collect the encoding: binary or ASCII
484 encoding = file_obj.readline().decode("utf-8").strip().lower()
485 is_ascii = "ascii" in encoding
487 # big or little endian
488 endian = ["<", ">"][int("big" in encoding)]
489 elements = collections.OrderedDict()
491 # store file name of TextureFiles in the header
492 image_name = None
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()
501 # we're done
502 if "end_header" in line:
503 break
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()
535 return elements, is_ascii, image_name
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.
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
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}}
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
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
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
589 # what keys do in-the-wild exporters use for vertices
590 index_names = ["vertex_index", "vertex_indices"]
591 texcoord = None
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"]
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"]
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
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)
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
652 shape = np.shape(faces)
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
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)
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))
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
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
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"])
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)
736 if edges is not None:
737 from ..path.exchange.misc import edges_to_path
739 kwargs.update(edges_to_path(edges, kwargs["vertices"]))
740 return kwargs
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.
748 Parameters
749 -------------
750 element : dict
751 Containing color keys
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
767def _load_element_different(properties, data):
768 """
769 Load elements which include lists of different lengths
770 based on the element's property-definitions.
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
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 )
809 return squeeze
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.
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 """
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
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
850 return columns
853def _ply_ascii(elements, file_obj):
854 """
855 Load data from an ASCII PLY file into an existing elements data structure.
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 """
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
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
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)
904 elements[key]["data"] = element_data
907def _ply_binary(elements, file_obj):
908 """
909 Load the data from a binary PLY file into the elements data structure.
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 """
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.
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)
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
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
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)
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)
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!")
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)
1022def export_draco(mesh, bits=28):
1023 """
1024 Export a mesh using Google's Draco compressed format.
1026 Only works if draco_encoder is in your PATH:
1027 https://github.com/google/draco
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
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
1062def load_draco(file_obj, **kwargs):
1063 """
1064 Load a mesh from Google's Draco format.
1066 Parameters
1067 ----------
1068 file_obj : file- like object
1069 Contains data
1071 Returns
1072 ----------
1073 kwargs : dict
1074 Keyword arguments to construct a Trimesh object
1075 """
1077 with tempfile.NamedTemporaryFile(suffix=".drc") as temp_drc:
1078 temp_drc.write(file_obj.read())
1079 temp_drc.flush()
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
1090_ply_loaders = {"ply": load_ply}
1091_ply_exporters = {"ply": export_ply}
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