Coverage for trimesh/exchange/off.py: 97%

34 statements  

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

1import re 

2 

3import numpy as np 

4 

5from ..geometry import triangulate_quads 

6from ..util import array_to_string, comment_strip, decode_text 

7 

8 

9def load_off(file_obj, **kwargs) -> dict: 

10 """ 

11 Load an OFF file into the kwargs for a Trimesh constructor. 

12 

13 Parameters 

14 ---------- 

15 file_obj : file object 

16 Contains an OFF file 

17 

18 Returns 

19 ---------- 

20 loaded : dict 

21 kwargs for Trimesh constructor 

22 """ 

23 text = file_obj.read() 

24 # will magically survive weird encoding sometimes 

25 # comment strip will handle all cases of commenting 

26 text = comment_strip(decode_text(text)).strip() 

27 

28 # split the first key 

29 _, header, raw = re.split("(COFF|OFF)", text, maxsplit=1) 

30 if header.upper() not in ["OFF", "COFF"]: 

31 raise NameError(f"Not an OFF file! Header was: `{header}`") 

32 

33 # split into lines and remove whitespace 

34 splits = [i.strip() for i in str.splitlines(str(raw))] 

35 # remove empty lines 

36 splits = [i for i in splits if len(i) > 0] 

37 

38 # the first non-comment line should be the counts 

39 if not splits: 

40 raise ValueError("OFF file is missing the vertex/face count line") 

41 header = np.array(splits[0].split(), dtype=np.int64) 

42 if header.size < 2: 

43 raise ValueError("OFF file has a malformed vertex/face count line") 

44 vertex_count, face_count = header[:2] 

45 

46 vertices = np.array( 

47 [i.split()[:3] for i in splits[1 : vertex_count + 1]], dtype=np.float64 

48 ) 

49 

50 # will fail if incorrect number of vertices loaded 

51 vertices = vertices.reshape((vertex_count, 3)) 

52 

53 # get lines with face data 

54 faces = [i.split() for i in splits[vertex_count + 1 : vertex_count + face_count + 1]] 

55 # the first value is count 

56 faces = [line[1 : int(line[0]) + 1] for line in faces] 

57 

58 faces = triangulate_quads(faces) 

59 # faces must reference vertices that actually exist, otherwise the mesh 

60 # blows up later with a cryptic IndexError and a negative index silently 

61 # wraps into a wrong face 

62 if len(faces) and (faces.min() < 0 or faces.max() >= vertex_count): 

63 raise ValueError("OFF file has a face index outside the range of its vertices") 

64 

65 # save data as kwargs for a trimesh.Trimesh 

66 kwargs = {"vertices": vertices, "faces": faces} 

67 

68 return kwargs 

69 

70 

71def export_off(mesh, digits=10) -> str: 

72 """ 

73 Export a mesh as an OFF file, a simple text format 

74 

75 Parameters 

76 ----------- 

77 mesh : trimesh.Trimesh 

78 Geometry to export 

79 digits : int 

80 Number of digits to include on floats 

81 

82 Returns 

83 ----------- 

84 export : str 

85 OFF format output 

86 """ 

87 # make sure specified digits is an int 

88 digits = int(digits) 

89 # prepend a 3 (face count) to each face 

90 faces_stacked = np.column_stack((np.ones(len(mesh.faces)) * 3, mesh.faces)).astype( 

91 np.int64 

92 ) 

93 # the header is vertex count, face count, another number 

94 export = "\n".join( 

95 [ 

96 "OFF", 

97 str(len(mesh.vertices)) + " " + str(len(mesh.faces)) + " 0", 

98 array_to_string(mesh.vertices, col_delim=" ", row_delim="\n", digits=digits), 

99 array_to_string(faces_stacked, col_delim=" ", row_delim="\n"), 

100 "", 

101 ] 

102 ) 

103 

104 return export 

105 

106 

107_off_loaders = {"off": load_off} 

108_off_exporters = {"off": export_off}