Mercurial > lasercutter
comparison graster/graster/lib/graster.rb @ 11:f952052e37b7
trying a fix.
author | Robert McIntyre <rlm@mit.edu> |
---|---|
date | Tue, 24 Aug 2010 19:06:45 -0400 |
parents | |
children |
comparison
equal
deleted
inserted
replaced
10:ef7dbbd6452c | 11:f952052e37b7 |
---|---|
1 #!/usr/bin/env ruby | |
2 | |
3 require 'rubygems' | |
4 require 'yaml' | |
5 require 'RMagick' | |
6 | |
7 class Graster | |
8 | |
9 autoload :Runner, File.join(File.dirname(__FILE__), 'graster', 'runner') | |
10 autoload :Image, File.join(File.dirname(__FILE__), 'graster', 'image') | |
11 autoload :GcodeFile, File.join(File.dirname(__FILE__), 'graster', 'gcode_file') | |
12 autoload :GmaskFile, File.join(File.dirname(__FILE__), 'graster', 'gmask_file') | |
13 | |
14 ROOT2 = Math.sqrt(2) | |
15 | |
16 OPTIONS = { | |
17 :dpi => [[Float],"X,Y","Dots per inch of your device"], | |
18 :on_range => [[Float], | |
19 "MIN,MAX","Luminosity range for which the", | |
20 "laser should be on"], | |
21 :overshoot => [Float,"INCHES", | |
22 "Distance the X axis should travel", | |
23 "past the outer boundaries of the outer", | |
24 "images. This needs to be wide enough", | |
25 "so that the X axis doesn't start", | |
26 "decelerating until after it has", | |
27 "cleared the image"], | |
28 :offset => [[Float],"X,Y", | |
29 "Location for the bottom left corner", | |
30 "of the bottom left tile. The X", | |
31 "component of this setting must be", | |
32 "equal to or greater than overshoot"], | |
33 :repeat => [[Integer],"X,Y", | |
34 "Number of times to repeat the image", | |
35 "in the X and Y axes, respectively.", | |
36 "Size of the tile(s) inches. Any nil", | |
37 "value is calculated from the size of", | |
38 "the bitmap"], | |
39 :tile_spacing => [[Float],"X,Y", | |
40 "X,Y gap between repeated tiles in", | |
41 "inches"], | |
42 :feed => [Float,"N", | |
43 "Speed to move the X axis while", | |
44 "burning, in inches/minute"], | |
45 :cut_feed => [Float,"N", | |
46 "Speed at which to cut out tiles"], | |
47 :corner_radius => [Float,"N", | |
48 "Radius of rounded corners for", | |
49 "cutout, 0 for pointy corners"] | |
50 } | |
51 | |
52 DEFAULTS = { | |
53 :dpi => [500,500], # X,Y dots per inch of your device | |
54 :on_range => [0.0,0.5], # Luminosity range for which the laser should be on | |
55 :overshoot => 0.5, # Distance the X axis should travel past the outer boundaries of the outer images. | |
56 # This needs to be wide enough so that the X axis doesn't start decelerating | |
57 # until after it has cleared the image. | |
58 :offset => [1.0,1.0], # X,Y location for the bottom left corner of the bottom left tile. | |
59 # The X component of this setting must be equal to or greater than :overshoot. | |
60 :repeat => [1,1], # Number of times to repeat the image in the X and Y axes, respectively. | |
61 :tile_size => [false,false], # Size of the tile(s) inches. Any nil value is calculated from | |
62 # the size of the bitmap. | |
63 :tile_spacing => [0.125,0.125], # X,Y gap between repeated tiles in inches | |
64 :feed => 120, # Speed to move the X axis while burning, in inches/minute | |
65 :cut_feed => 20, # Speed at which to cut out tiles | |
66 :corner_radius => 0 # Radius of rounded corners for cutout, 0 for pointy corners | |
67 } | |
68 | |
69 class InvalidConfig < Exception; end | |
70 def update_config | |
71 @scale = @config[:dpi].map{|n| 1.0/n } | |
72 @offset = @config[:offset] | |
73 | |
74 if @image | |
75 2.times {|i| @config[:tile_size][i] ||= @image.size[i]*@scale[i] } | |
76 @tile_interval = [] | |
77 2.times {|i| | |
78 @tile_interval << @config[:tile_size][i] + @config[:tile_spacing][i] | |
79 } | |
80 @tile_interval | |
81 end | |
82 | |
83 @on_range = Range.new Image.f_to_pix(@config[:on_range].first), | |
84 Image.f_to_pix(@config[:on_range].last) | |
85 end | |
86 | |
87 def validate_config | |
88 raise InvalidConfig.new "X offset (#{@config[:offset][0]}) must be greater or equal to overshoot (#{@config[:overshoot]})" | |
89 end | |
90 | |
91 def config= h | |
92 @config = {} | |
93 DEFAULTS.each {|k,v| @config[k] = h[k] || v } | |
94 update_config | |
95 return h | |
96 end | |
97 | |
98 def merge_config h | |
99 @config ||= DEFAULTS.dup | |
100 h.each {|k,v| @config[k] = v if DEFAULTS[k] } | |
101 update_config | |
102 return h | |
103 end | |
104 | |
105 attr_reader :config | |
106 | |
107 def image= img | |
108 debug "image set to #{img.filename} #{img.size.inspect} #{img.pixels.size} pixels" | |
109 @image = img | |
110 @image.build_spans @on_range | |
111 update_config | |
112 build_tiled_rows | |
113 return img | |
114 end | |
115 | |
116 attr_reader :image | |
117 | |
118 def try_load_config_file pn | |
119 if File.exist?(pn) | |
120 c = {} | |
121 YAML.load_file(pn).each {|k,v| c[k.intern] = v } | |
122 return c | |
123 end | |
124 end | |
125 | |
126 def try_load_default_config_file | |
127 try_load_config_file './graster.yml' | |
128 end | |
129 | |
130 def load_config_file pn | |
131 try_load_config_file pn or raise "config file not found '#{pn}'" | |
132 end | |
133 | |
134 def load_image_file pn | |
135 self.image = Image.from_file(pn) | |
136 end | |
137 | |
138 # convert tile + pixel coordinates to inches | |
139 def axis_inches axis, tile, pixel | |
140 @offset[axis] + tile*@tile_interval[axis] + pixel*@scale[axis] | |
141 end | |
142 | |
143 def x_inches tile, pixel | |
144 axis_inches 0, tile, pixel | |
145 end | |
146 | |
147 def y_inches tile, pixel | |
148 axis_inches 1, tile, pixel | |
149 end | |
150 | |
151 # return a complete tiled row of spans converted to inches | |
152 def tiled_row_spans y, forward=true | |
153 spans = @image.spans[y] | |
154 return spans if spans.empty? | |
155 tiled_spans = [] | |
156 | |
157 if forward | |
158 @config[:repeat][0].times do |tile| | |
159 spans.each do |span| | |
160 tiled_spans << [x_inches(tile,span[0]), x_inches(tile,span[1])] | |
161 end | |
162 end | |
163 else | |
164 (0...@config[:repeat][0]).to_a.reverse.each do |tile| | |
165 spans.reverse.each do |span| | |
166 tiled_spans << [x_inches(tile,span[1]), x_inches(tile,span[0])] | |
167 end | |
168 end | |
169 end | |
170 | |
171 return tiled_spans | |
172 end | |
173 | |
174 def build_tiled_rows | |
175 forward = false | |
176 @tiled_rows = [] | |
177 @image.size[1].times {|y| @tiled_rows << tiled_row_spans(y, (forward = !forward)) } | |
178 end | |
179 | |
180 # generate a unique id for this job | |
181 def job_hash | |
182 [@image,@config].hash | |
183 end | |
184 | |
185 # render a complete tiled image to gcode and gmask streams | |
186 def render_tiled_image gcode, gmask | |
187 debug "rendering tiled image" | |
188 job_id = job_hash | |
189 hyst = -@scale[0]/2 | |
190 gcode.comment "raster gcode for job #{job_id}" | |
191 gcode.comment "image: #{@image.filename} #{@image.size.inspect}" | |
192 gcode.comment "config: #{@config.inspect}" | |
193 | |
194 gcode.preamble :feed => @config[:feed], :mask => true | |
195 gmask.preamble | |
196 | |
197 @config[:repeat][1].times do |ytile| | |
198 debug "begin tile row #{ytile}" | |
199 ypix = 0 | |
200 (0...@tiled_rows).each do |spans| | |
201 debug "pixel row #{ypix} is empty" if spans.empty? | |
202 unless spans.empty? | |
203 yinches = y_inches(ytile, ypix) | |
204 forward = spans[0][0] < spans[-1][1] | |
205 dir = forward ? 1 : -1 | |
206 | |
207 debug "pixel row #{ypix} at #{yinches} inches going #{forward ? 'forward' : 'backward'} with #{spans.size} spans" | |
208 | |
209 gcode.g0 :x => spans[0][0] - dir*@config[:overshoot], :y => yinches | |
210 gcode.g1 :x => spans[-1][1] + dir*@config[:overshoot], :y => yinches | |
211 gmask.begin_row forward | |
212 # G0 X0.606 Y1.976 | |
213 # G1 X3.396 Y1.976 | |
214 # G0 X3.392 Y1.978 | |
215 # G1 X0.610 Y1.978 | |
216 # G0 X0.614 Y1.980 | |
217 # G1 X3.388 Y1.980 | |
218 | |
219 spans.each {|span| gmask.span forward, span[0]+hyst, span[1]+hyst } | |
220 end # unless spans.empty? | |
221 ypix += 1 | |
222 end # @image.each_row | |
223 debug "end tile row #{ytile}" | |
224 end # @config[:repeat][i].times | |
225 | |
226 gcode.epilogue | |
227 end # def render_tiled_image | |
228 | |
229 # cut out the tile with bottom left at x,y | |
230 def render_cut gcode, x, y | |
231 radius = @config[:corner_radius] | |
232 left = x | |
233 bottom = y | |
234 right = x+@config[:tile_size][0] | |
235 top = y+@config[:tile_size][1] | |
236 | |
237 gcode.instance_eval do | |
238 if radius && radius > 0 | |
239 jog :x => left, :y => bottom+radius | |
240 move :x => left, :y => top-radius, :laser => true | |
241 turn_cw :x => left+radius, :y => top, :i => radius | |
242 move :x => right-radius, :y => top | |
243 turn_cw :x => right, :y => top-radius, :j => -radius | |
244 move :x => right, :y => bottom+radius | |
245 turn_cw :x => right-radius, :y => bottom, :i => -radius | |
246 move :x => left+radius, :y => bottom | |
247 turn_cw :x => left, :y => bottom+radius, :j => radius | |
248 nc :laser => false | |
249 else | |
250 jog :x => left, :y => bottom | |
251 move :x => left, :y => top, :laser => true | |
252 move :x => right, :y => top | |
253 move :x => right, :y => bottom | |
254 move :x => left, :y => bottom | |
255 nc :laser => false | |
256 end | |
257 end | |
258 end | |
259 | |
260 # render gcode to cut out the tiles | |
261 def render_all_cuts gcode | |
262 gcode.preamble :feed => @config[:cut_feed] | |
263 @config[:repeat][1].times do |ytile| | |
264 @config[:repeat][0].times do |xtile| | |
265 render_cut gcode, x_inches(xtile, 0), y_inches(ytile, 0) | |
266 end | |
267 end | |
268 gcode.epilogue | |
269 end | |
270 | |
271 def render_all gcode, gmask, cuts | |
272 render_tiled_image gcode, gmask | |
273 render_all_cuts cuts | |
274 end | |
275 | |
276 def open_gcode_file &block | |
277 io = GcodeFile.open "#{@image.filename}.raster.ngc", "w", &block | |
278 end | |
279 | |
280 def open_gmask_file &block | |
281 io = GmaskFile.open "#{@image.filename}.raster.gmask", "w", &block | |
282 end | |
283 | |
284 def open_cut_file &block | |
285 io = GcodeFile.open "#{@image.filename}.cut.ngc", "w", &block | |
286 end | |
287 | |
288 def generate_all_files | |
289 open_gcode_file do |gcode| | |
290 open_gmask_file do |gmask| | |
291 render_tiled_image gcode, gmask | |
292 end | |
293 end | |
294 | |
295 open_cut_file do |cut| | |
296 render_all_cuts cut | |
297 end | |
298 end | |
299 | |
300 def config_to_yaml | |
301 @config.map {|k,v| "#{k}: #{v.inspect}\n" }.join | |
302 end | |
303 | |
304 def debug msg | |
305 STDERR.puts msg if @debug | |
306 end | |
307 | |
308 def initialize opts={} | |
309 self.config = DEFAULTS.dup | |
310 | |
311 if opts[:config_file] | |
312 self.merge_config load_config_file opts[:config_file] | |
313 elsif opts[:default_config_file] && c = try_load_default_config_file | |
314 self.merge_config c | |
315 end | |
316 | |
317 self.merge_config opts[:config] if opts[:config] | |
318 | |
319 @debug = opts[:debug] | |
320 | |
321 if opts[:image] | |
322 image = opts[:image] | |
323 elsif opts[:image_file] | |
324 load_image_file opts[:image_file] | |
325 end | |
326 end | |
327 | |
328 end # class Graster |