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