Coverage for tatlin/lib/model/stl/parser.py: 89%
171 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 05:56 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 05:56 +0000
1# -*- coding: utf-8 -*-
2# Copyright (C) 2011 Denis Kobozev
3#
4# This program is free software; you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation; either version 2 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program; if not, write to the Free Software Foundation,
16# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19"""
20Parser for STL (stereolithography) files.
21"""
22import struct
23import time
24import logging
25import math
26from io import StringIO
29ENCODING = "utf-8"
32class StlParseError(Exception):
33 pass
36class InvalidTokenError(StlParseError):
37 def __init__(self, line_no, msg):
38 full_msg = "parse error on line %d: %s" % (line_no, msg)
39 StlParseError.__init__(self, full_msg)
42class ParseEOF(StlParseError):
43 pass
46class StlAsciiParser(object):
47 """
48 Parse for ASCII STL files.
50 Points of interest are:
51 * create normal in _facet() method
52 * create vertex in _vertex() method
53 * create facet in _endfacet() method
55 The rest is boring parser stuff.
56 """
58 def __init__(self):
59 self.line_count = 0
60 self.line_no = 0
61 self.tokenized_peek_line = None
63 def load(self, stl):
64 if hasattr(stl, "read"):
65 for line in stl:
66 self.line_count += 1
67 stl.seek(0)
68 else:
69 stl = stl.split("\n")
70 self.line_count = len(stl)
72 self.stl = iter(stl)
74 def readline(self):
75 line = next(self.stl)
76 if line == "":
77 raise ParseEOF
78 return line.decode(ENCODING)
80 def next_line(self):
81 next_line = self.peek_line()
83 self.tokenized_peek_line = None # force peak line read on next call
84 self.line_no += 1
86 return next_line
88 def peek_line(self):
89 if self.tokenized_peek_line is None:
90 while True:
91 line = self.readline()
92 self.tokenized_peek_line = self._tokenize(line)
93 if len(self.tokenized_peek_line) > 0:
94 break
96 return self.tokenized_peek_line
98 def _tokenize(self, line):
99 line = line.strip().split()
100 return line
102 def parse(self, callback=None):
103 """
104 Parse the file into a tuple of normal and facet lists.
105 """
106 t_start = time.time()
108 self.callback = callback
109 self.callback_every = self.line_count // 50 # every 2 percent
110 self.callback_next = self.callback_every
112 self._solid()
114 if self.callback:
115 self.callback(self.line_no, self.line_count)
117 t_end = time.time()
118 logging.info("Parsed STL ASCII file in %.2f seconds" % (t_end - t_start))
120 return self.facet_list, self.normal_list
122 def _solid(self):
123 line = self.next_line()
124 if line[0] != "solid":
125 raise InvalidTokenError(
126 self.line_no, 'expected "%s", got "%s"' % ("solid", line[0])
127 )
129 self._facets()
130 self._endsolid()
132 def _endsolid(self):
133 line = self.next_line()
134 if line[0] != "endsolid":
135 raise InvalidTokenError(
136 self.line_no, 'expected "%s", got "%s"' % ("endsolid", line[0])
137 )
139 def _facets(self):
140 self.facet_list = []
141 self.normal_list = []
142 peek = self.peek_line()
143 while peek[0] != "endsolid":
144 self._facet()
145 peek = self.peek_line()
147 def _facet(self):
148 line = self.next_line()
149 if line[0] != "facet":
150 raise InvalidTokenError(
151 self.line_no, 'expected "%s", got "%s"' % ("facet", line[0])
152 )
154 if line[1] == "normal":
155 self.facet_normal = [float(line[2]), float(line[3]), float(line[4])]
156 else:
157 raise InvalidTokenError(
158 self.line_no, 'expected "%s", got "%s"' % ("normal", line[1])
159 )
161 self._outer_loop()
162 self._endfacet()
164 def _endfacet(self):
165 line = self.next_line()
166 if line[0] != "endfacet":
167 raise InvalidTokenError(
168 self.line_no, 'expected "%s", got "%s"' % ("endfacet", line[0])
169 )
171 self.facet_list.extend(self.vertex_list)
172 self.normal_list.extend([self.facet_normal] * len(self.vertex_list))
174 if self.callback and self.line_no >= self.callback_next:
175 self.callback_next += self.callback_every
176 self.callback(self.line_no, self.line_count)
178 def _outer_loop(self):
179 line = self.next_line()
180 if " ".join(line) != "outer loop":
181 raise InvalidTokenError(
182 self.line_no, 'expected "%s", got "%s"' % ("outer loop", " ".join(line))
183 )
185 self._vertices()
186 self._endloop()
188 def _endloop(self):
189 line = self.next_line()
190 if line[0] != "endloop":
191 raise InvalidTokenError(
192 self.line_no, 'expected "%s", got "%s"' % ("endloop", line[0])
193 )
195 def _vertices(self):
196 self.vertex_list = []
197 peek = self.peek_line()
198 while peek[0] != "endloop":
199 self._vertex()
200 peek = self.peek_line()
202 def _vertex(self):
203 line = self.next_line()
204 if line[0] != "vertex":
205 raise InvalidTokenError(
206 self.line_no, 'expected "%s", got "%s"' % ("vertex", line[0])
207 )
208 vertex = [float(line[1]), float(line[2]), float(line[3])]
209 self.vertex_list.append(vertex)
212class StlBinaryParser(object):
213 """
214 Read data from a binary STL file.
215 """
217 HEADER_LEN = 80
218 FACET_COUNT_LEN = 4 # one 32-bit unsigned int
219 FACET_LEN = 50 # twelve 32-bit floats + one 16-bit short unsigned int
221 def load(self, stl):
222 if not hasattr(stl, "read"):
223 stl = StringIO(stl)
224 self.stl = stl
226 def parse(self, callback=None):
227 """
228 Parse the file into a tuple of normal and facet lists.
229 """
230 t_start = time.time()
232 normal_list = []
233 facet_list = []
235 self._skip_header(self.stl)
236 fcount = self._facet_count(self.stl)
237 callback_every = max(1, int(math.floor(fcount / 100)))
238 for facet_idx in range(fcount):
239 vertices, normal = self._parse_facet(self.stl)
240 facet_list.extend(vertices)
241 normal_list.extend([normal] * len(vertices)) # one normal per vertex
243 if callback and (facet_idx + 1) % callback_every == 0:
244 callback(facet_idx + 1, fcount)
246 if callback:
247 callback(facet_idx + 1, fcount)
249 t_end = time.time()
250 logging.info("Parsed STL binary file in %.2f seconds" % (t_end - t_start))
252 return facet_list, normal_list
254 def _skip_header(self, fp):
255 fp.seek(self.HEADER_LEN)
257 def _facet_count(self, fp):
258 raw = fp.read(self.FACET_COUNT_LEN)
259 try:
260 (count,) = struct.unpack("<I", raw)
261 return count
262 except struct.error:
263 raise StlParseError("Error unpacking binary STL data")
265 def _parse_facet(self, fp):
266 raw = fp.read(self.FACET_LEN)
267 try:
268 vertex_data = struct.unpack("<ffffffffffffH", raw)
269 normal = [vertex_data[0], vertex_data[1], vertex_data[2]]
270 vertices = []
271 for i in range(3, 12, 3):
272 vertices.append(
273 [vertex_data[i], vertex_data[i + 1], vertex_data[i + 2]]
274 )
275 # ignore the attribute byte count...
276 return vertices, normal
277 except struct.error:
278 raise StlParseError("Error unpacking binary STL data")
281def is_stl_ascii(fp):
282 """
283 Guess whether file with the given name is plain ASCII STL file.
284 """
285 first_line = fp.readline().strip()
286 is_ascii = first_line.startswith(b"solid")
287 fp.seek(0)
288 return is_ascii
291def StlParser(fp):
292 """
293 STL parser that handles both ASCII and binary formats.
294 """
295 parser = StlAsciiParser if is_stl_ascii(fp) else StlBinaryParser
296 return parser()