Tue, 19 Jan 2021 20:45:09 +0100
updated main files to new github master version
4 | 1 | #!/usr/bin/env python |
5 | 2 | # -*- coding: utf-8 -*- |
3 | ||
10 | 4 | import svg, sys, math |
11 | 5 | from pprint import pprint |
4 | 6 | from gcode import Gcode |
7 | from optparse import OptionParser | |
6
ff679c15cb0e
infill only on Polygon or Path which have fill style attribute
mbayer
parents:
5
diff
changeset
|
8 | from tinycss import CSS21Parser |
4 | 9 | |
11 | 10 | |
8 | 11 | from shapely.geometry import box, MultiLineString, Polygon |
5 | 12 | from shapely.affinity import rotate |
13 | from shapely import speedups | |
14 | from math import sqrt | |
11 | 15 | from functools import cmp_to_key |
5 | 16 | |
17 | # enable Shapely speedups, if possible | |
18 | if speedups.available: | |
11 | 19 | print "shapely speedups available and enabled!" |
5 | 20 | speedups.enable() |
21 | ||
22 | def hatchbox(rect, angle, spacing): | |
23 | """ | |
24 | returns a Shapely geometry (MULTILINESTRING, or more rarely, | |
25 | GEOMETRYCOLLECTION) for a simple hatched rectangle. | |
26 | ||
27 | args: | |
28 | rect - a Shapely geometry for the outer boundary of the hatch | |
29 | Likely most useful if it really is a rectangle | |
30 | ||
31 | angle - angle of hatch lines, conventional anticlockwise -ve | |
32 | ||
33 | spacing - spacing between hatch lines | |
34 | ||
35 | GEOMETRYCOLLECTION case occurs when a hatch line intersects with | |
36 | the corner of the clipping rectangle, which produces a point | |
37 | along with the usual lines. | |
38 | """ | |
39 | ||
40 | (llx, lly, urx, ury) = rect.bounds | |
41 | centre_x = (urx + llx) / 2 | |
42 | centre_y = (ury + lly) / 2 | |
43 | diagonal_length = sqrt((urx - llx) ** 2 + (ury - lly) ** 2) | |
44 | number_of_lines = 2 + int(diagonal_length / spacing) | |
45 | hatch_length = spacing * (number_of_lines - 1) | |
46 | ||
47 | # build a square (of side hatch_length) horizontal lines | |
48 | # centred on centroid of the bounding box, 'spacing' units apart | |
49 | coords = [] | |
50 | for i in range(number_of_lines): | |
51 | # alternate lines l2r and r2l to keep HP-7470A plotter happy ☺ | |
52 | if i % 2: | |
8 | 53 | coords.extend([(( |
54 | centre_x - hatch_length / 2, \ | |
55 | centre_y - hatch_length / 2 + i * spacing), ( | |
56 | centre_x + hatch_length / 2, \ | |
57 | centre_y - hatch_length / 2 + i * spacing))]) | |
5 | 58 | else: |
8 | 59 | coords.extend([(( \ |
60 | centre_x + hatch_length / 2, \ | |
61 | centre_y - hatch_length / 2 + i * spacing), ( | |
62 | centre_x - hatch_length / 2, \ | |
63 | centre_y - hatch_length / 2 + i * spacing))]) | |
5 | 64 | # turn array into Shapely object |
65 | lines = MultiLineString(coords) | |
66 | # Rotate by angle around box centre | |
67 | lines = rotate(lines, angle, origin='centroid', use_radians=False) | |
68 | # return clipped array | |
69 | return rect.intersection(lines) | |
70 | ||
6
ff679c15cb0e
infill only on Polygon or Path which have fill style attribute
mbayer
parents:
5
diff
changeset
|
71 | def parse_style(stylestr): |
10 | 72 | """ |
73 | Parse the given string containing CSS2.1 syntax | |
74 | Returns a dict with the keys/values | |
75 | """ | |
12 | 76 | if not stylestr or stylestr.strip() == '': |
6
ff679c15cb0e
infill only on Polygon or Path which have fill style attribute
mbayer
parents:
5
diff
changeset
|
77 | return None |
ff679c15cb0e
infill only on Polygon or Path which have fill style attribute
mbayer
parents:
5
diff
changeset
|
78 | parser = CSS21Parser() |
ff679c15cb0e
infill only on Polygon or Path which have fill style attribute
mbayer
parents:
5
diff
changeset
|
79 | style = parser.parse_style_attr(stylestr) |
10 | 80 | data = {} |
6
ff679c15cb0e
infill only on Polygon or Path which have fill style attribute
mbayer
parents:
5
diff
changeset
|
81 | for obj in style[0]: |
13 | 82 | val = obj.value[0] |
83 | if hasattr(val, 'value'): | |
84 | data[obj.name] = val.value | |
10 | 85 | return data |
6
ff679c15cb0e
infill only on Polygon or Path which have fill style attribute
mbayer
parents:
5
diff
changeset
|
86 | |
9 | 87 | class Image(object): |
10 | 88 | """ |
89 | SVG Image handler class | |
90 | """ | |
9 | 91 | def __init__(self, filename, options, gcoder): |
92 | self.gcoder = gcoder | |
93 | self.options = options | |
94 | self.svg = svg.parse(filename) | |
95 | self.bb1, self.bb2 = self.svg.bbox() | |
96 | self.width, self.height = self.bb2.coord() | |
97 | self.infill = None | |
8 | 98 | |
9 | 99 | self._check_dimensions() |
100 | self._generate_infill() | |
101 | ||
102 | def _check_dimensions(self): | |
10 | 103 | """ |
104 | Output image dimensions/scaling to console and gcode | |
105 | """ | |
9 | 106 | msg = "Original dimension: %.2f x %.2f" % (self.width, self.height) |
107 | print msg | |
108 | self.gcoder.comment(msg) | |
10 | 109 | self.gcoder.comment("Scale: %.2f" % (self.options.scale)) |
9 | 110 | width = self.width * self.gcoder.mm_pixel * self.options.scale |
111 | height = self.height * self.gcoder.mm_pixel * self.options.scale | |
112 | msg = "Print dimension: %.2fmm x %.2fmm" % (width, height) | |
113 | print msg | |
114 | self.gcoder.comment(msg) | |
8 | 115 | |
9 | 116 | def _generate_infill(self): |
10 | 117 | """ |
118 | Generates infill pattern image for later use | |
119 | """ | |
9 | 120 | b1x, b1y = self.bb1.coord() |
121 | b2x, b2y = self.bb2.coord() | |
122 | page = box(b1x, b1y, b2x, b2y) | |
13 | 123 | # TODO: Infill spacing needs to be calculated with proper scaling and gcode MM dimensions, when set to 0 (auto) |
124 | # Make infill angle 0, 45 or 90 degrees configurable to options parser (0° = X, 90° = Y, 45° = X and Y but half the speed/accel needed!) | |
125 | self.infill = hatchbox(page, self.options.infill_angle, self.options.infill_spacing) | |
4 | 126 | |
9 | 127 | def normalize(self, coord): |
10 | 128 | """ |
129 | Normalize X / Y Axis of coordinates | |
130 | At the moment only Y gets flipped to match Reprap coordinate system (0,0 is bottom left instead top left on SVG) | |
131 | """ | |
9 | 132 | c_x = coord[0] |
133 | c_y = coord[1] | |
134 | # flip y | |
135 | c_y = (self.height - c_y) | |
136 | return (c_x, c_y) | |
4 | 137 | |
9 | 138 | def get_drawings(self): |
139 | """ | |
140 | Returns a list of all svg drawings with segments attribute | |
141 | """ | |
142 | data = [] | |
143 | for dwg in self.svg.flatten(): | |
144 | if hasattr(dwg, "segments"): | |
145 | data.append(dwg) | |
146 | return data | |
147 | ||
11 | 148 | def cmp_smallest_distance(line1, line2, coords1=None, coords2=None): |
149 | if not coords1: | |
150 | coords1 = list(line1.coords) | |
151 | if not coords2: | |
152 | coords2 = list(line2.coords) | |
153 | ||
154 | dist = [ | |
155 | abs(math.hypot(coords1[0][0] - coords2[0][0], coords1[0][1] - coords2[0][1])), | |
156 | abs(math.hypot(coords1[0][0] - coords2[1][0], coords1[0][1] - coords2[1][1])), | |
157 | abs(math.hypot(coords1[1][0] - coords2[0][0], coords1[1][1] - coords2[0][1])), | |
158 | abs(math.hypot(coords1[1][0] - coords2[1][0], coords1[1][1] - coords2[1][1])) | |
159 | ] | |
160 | ||
161 | # return the smallest distance between the two lines | |
162 | # check both start and endpoints to each other | |
163 | return sorted(dist)[0] | |
164 | ||
165 | def slow_sort_lines_by_distance(multilines): | |
166 | lines = [] | |
167 | coords = [] | |
168 | for line in multilines: | |
169 | lines.append(line) | |
170 | # coords list for brutal speedup! | |
171 | # without this it would be terrible_slow_sort_lines_by_distance() | |
172 | coords.append(list(line.coords)) | |
173 | ||
174 | data = [lines.pop(0)] | |
175 | last = coords.pop(0) | |
176 | ||
177 | def pop_nearest(line, last): | |
178 | idx = -1 | |
179 | dist = 99999999 | |
180 | for test in lines: | |
181 | idx += 1 | |
182 | tmp = cmp_smallest_distance(line, test, last, coords[idx]) | |
183 | if tmp < dist: | |
184 | dist = tmp | |
185 | dist_idx = idx | |
186 | # nearest item found | |
187 | return (lines.pop(dist_idx), coords.pop(dist_idx)) | |
188 | ||
189 | print "Optimizing infill movement, please wait..." | |
190 | while len(lines) > 0: | |
191 | tmp = len(lines) | |
192 | if not (tmp % 10): | |
193 | sys.stdout.write("\r%d " % tmp) | |
194 | sys.stdout.flush() | |
195 | tmp = pop_nearest(data[-1], last) | |
196 | data.append(tmp[0]) | |
197 | last = tmp[1] | |
198 | print "\rdone" | |
199 | return data | |
200 | ||
9 | 201 | def svg2gcode(options, gcoder): |
202 | image = Image(options.filename, options, gcoder) | |
8 | 203 | |
9 | 204 | for dwg in image.get_drawings(): |
205 | for l in dwg.segments(1): | |
206 | # THE OUTLINE | |
207 | coord = image.normalize(l[0].coord()) | |
208 | gcoder.move(coord[0], coord[1]) | |
209 | for pt in l[1:]: | |
210 | coord = image.normalize(pt.coord()) | |
211 | gcoder.engrave(coord[0], coord[1]) | |
8 | 212 | |
9 | 213 | if options.outline: |
214 | continue | |
215 | ||
216 | if isinstance(dwg, svg.Polygon) or isinstance(dwg, svg.Path): | |
217 | #check if we should infill? | |
218 | style = parse_style(dwg.style) | |
219 | if not style: | |
220 | continue | |
221 | if not 'fill' in style.keys(): | |
222 | continue | |
223 | if style['fill'] == 'none': | |
224 | continue | |
6
ff679c15cb0e
infill only on Polygon or Path which have fill style attribute
mbayer
parents:
5
diff
changeset
|
225 | |
9 | 226 | # try to generate the infill poly complex |
227 | poly = None | |
228 | for l in dwg.segments(1): | |
229 | segments = [] | |
230 | for pnt in l: | |
231 | segments.append(pnt.coord()) | |
232 | shape = Polygon(segments) | |
12 | 233 | #if shape.is_valid: |
234 | if not poly: | |
235 | poly = shape | |
236 | else: | |
237 | try: | |
9 | 238 | if shape.within(poly): |
239 | poly = poly.difference(shape) | |
8 | 240 | else: |
9 | 241 | poly = poly.union(shape) |
12 | 242 | except Exception: |
243 | pass | |
8 | 244 | |
12 | 245 | if not poly: |
246 | continue | |
247 | ||
248 | try: | |
249 | lines = poly.intersection(image.infill) | |
250 | except Exception: | |
251 | lines = None | |
252 | ||
9 | 253 | if lines: |
11 | 254 | #pprint (dir(lines)) |
9 | 255 | # THE INFILL |
10 | 256 | prev_end = None |
11 | 257 | # sort lines by nearest |
258 | lines_ordered = slow_sort_lines_by_distance(lines) | |
259 | ||
260 | ||
261 | #lines_distances = [] | |
262 | #prev_line = lines[0] | |
263 | #for line in lines: | |
264 | # lines_distances.append(cmp_smallest_distance(line, prev_line)) | |
265 | # prev_line = line | |
266 | ##lines_ordered = sorted(lines, key=cmp_to_key(cmp_smallest_distance)) | |
267 | ## decorate, sort, undecorate: | |
268 | #lines_distances, lines = zip(*sorted(zip(lines_distances, lines))) | |
269 | ||
270 | ||
271 | for line in lines_ordered: | |
10 | 272 | coords = [ |
273 | image.normalize((line.coords[0][0], line.coords[0][1])), | |
274 | image.normalize((line.coords[1][0], line.coords[1][1])) | |
275 | ] | |
276 | if prev_end: | |
277 | # calculate distances to previous end, swap if current end is nearest | |
278 | dist = [ | |
279 | abs(math.hypot(coords[0][0] - prev_end[0], coords[0][1] - prev_end[1])), | |
280 | abs(math.hypot(coords[1][0] - prev_end[0], coords[1][1] - prev_end[1])) | |
281 | ] | |
282 | if dist[0] > dist[1]: | |
283 | coords = list(reversed(coords)) | |
284 | prev_end = coords[1] | |
285 | gcoder.move(coords[0][0], coords[0][1]) | |
286 | gcoder.engrave(coords[1][0], coords[1][1]) | |
6
ff679c15cb0e
infill only on Polygon or Path which have fill style attribute
mbayer
parents:
5
diff
changeset
|
287 | |
9 | 288 | def init_options(): |
8 | 289 | parser = OptionParser() |
290 | parser.add_option("-f", "--file", dest="filename", default=None, | |
291 | help="Load SVG file", metavar="FILE") | |
292 | parser.add_option("-s", "--scale", | |
293 | dest="scale", type="float", default=1.0, | |
294 | help="set scale factor (default 1.0)") | |
295 | parser.add_option("-e", "", | |
296 | dest="engrave_speed", type="float", default=20, | |
297 | help="engrave speed mm/sec (default 20)") | |
298 | parser.add_option("-t", "", | |
299 | dest="travel_speed", type="float", default=130, | |
300 | help="travel speed mm/sec (default 130)") | |
301 | parser.add_option("-o", "--outline", action="store_true", | |
302 | dest="outline", default=False, | |
303 | help="no infill, only outlines") | |
13 | 304 | parser.add_option("", "--infill-angle", |
305 | dest="infill_angle", type="int", default=45, | |
306 | help="infill angle: 0 = X, 90 = Y (default 45)") | |
307 | parser.add_option("", "--infill-spacing", | |
308 | dest="infill_spacing", type="float", default=1.5, | |
309 | help="infill spacing in SVG units (default 1.5)") | |
9 | 310 | return parser.parse_args() |
7 | 311 | |
9 | 312 | if __name__ == "__main__": |
313 | (OPTIONS, ARGS) = init_options() | |
314 | if not OPTIONS.filename: | |
8 | 315 | print "no filename given!" |
316 | sys.exit(1) | |
5 | 317 | |
8 | 318 | # initialize gcode worker |
9 | 319 | GCODER = Gcode(scale=OPTIONS.scale, travel_speed=OPTIONS.travel_speed, engrave_speed=OPTIONS.engrave_speed) |
8 | 320 | |
321 | # processing | |
9 | 322 | svg2gcode(OPTIONS, GCODER) |
8 | 323 | |
324 | # write gcode file | |
9 | 325 | GCODER.write(OPTIONS.filename + ".g") |