laser-cutter 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +27 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/.travis.yml +11 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/LICENSE.txt +22 -0
- data/README.md +67 -0
- data/Rakefile +2 -0
- data/bin/laser-cutter +110 -0
- data/laser-cutter.gemspec +29 -0
- data/lib/laser-cutter.rb +12 -0
- data/lib/laser-cutter/box.rb +136 -0
- data/lib/laser-cutter/configuration.rb +56 -0
- data/lib/laser-cutter/geometry.rb +14 -0
- data/lib/laser-cutter/geometry/dimensions.rb +30 -0
- data/lib/laser-cutter/geometry/edge.rb +33 -0
- data/lib/laser-cutter/geometry/notched_path.rb +46 -0
- data/lib/laser-cutter/geometry/path_generator.rb +132 -0
- data/lib/laser-cutter/geometry/point.rb +57 -0
- data/lib/laser-cutter/geometry/shape.rb +36 -0
- data/lib/laser-cutter/geometry/shape/line.rb +66 -0
- data/lib/laser-cutter/geometry/shape/rect.rb +59 -0
- data/lib/laser-cutter/geometry/tuple.rb +89 -0
- data/lib/laser-cutter/renderer.rb +19 -0
- data/lib/laser-cutter/renderer/box_renderer.rb +69 -0
- data/lib/laser-cutter/renderer/line_renderer.rb +16 -0
- data/lib/laser-cutter/renderer/rect_renderer.rb +17 -0
- data/lib/laser-cutter/version.rb +5 -0
- data/spec/box_spec.rb +31 -0
- data/spec/configuration_spec.rb +28 -0
- data/spec/dimensions_spec.rb +28 -0
- data/spec/line_spec.rb +79 -0
- data/spec/path_generator_spec.rb +94 -0
- data/spec/point_spec.rb +74 -0
- data/spec/rect_spec.rb +53 -0
- data/spec/renderer_spec.rb +60 -0
- data/spec/spec_helper.rb +26 -0
- metadata +177 -0
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'hashie/mash'
|
2
|
+
require 'prawn/measurement_extensions'
|
3
|
+
|
4
|
+
|
5
|
+
module Laser
|
6
|
+
module Cutter
|
7
|
+
class Configuration < Hashie::Mash
|
8
|
+
DEFAULTS = {
|
9
|
+
units: 'mm',
|
10
|
+
page_size: 'LETTER',
|
11
|
+
page_layout: 'portrait'
|
12
|
+
}
|
13
|
+
|
14
|
+
UNIT_SPECIFIC_DEFAULTS = {
|
15
|
+
'mm' => {
|
16
|
+
margin: 5,
|
17
|
+
padding: 5,
|
18
|
+
stroke: 0.0254,
|
19
|
+
},
|
20
|
+
'in' => {
|
21
|
+
margin: 0.125,
|
22
|
+
padding: 0.1,
|
23
|
+
stroke: 0.001,
|
24
|
+
}
|
25
|
+
}
|
26
|
+
|
27
|
+
SIZE_REGEXP = /[\d\.]+x[\d\.]+x[\d\.]+\/[\d\.]+\/[\d\.]+/
|
28
|
+
|
29
|
+
FLOATS = %w(width height depth thickness notch margin padding stroke)
|
30
|
+
REQUIRED = %w(width height depth thickness notch file)
|
31
|
+
|
32
|
+
def initialize(options = {})
|
33
|
+
options.delete_if{|k,v| v.nil?}
|
34
|
+
self.merge!(DEFAULTS)
|
35
|
+
self.merge!(options)
|
36
|
+
if self['size'] && self['size'] =~ SIZE_REGEXP
|
37
|
+
dim, self['thickness'], self['notch'] = self['size'].split('/')
|
38
|
+
self['width'],self['height'],self['depth'] = dim.split('x')
|
39
|
+
delete('size')
|
40
|
+
end
|
41
|
+
FLOATS.each do |k|
|
42
|
+
self[k] = self[k].to_f if (self[k] && self[k].is_a?(String))
|
43
|
+
end
|
44
|
+
self.merge!(UNIT_SPECIFIC_DEFAULTS[self['units']].merge(self))
|
45
|
+
end
|
46
|
+
|
47
|
+
def validate!
|
48
|
+
missing = []
|
49
|
+
REQUIRED.each do |k|
|
50
|
+
missing << k if self[k].nil?
|
51
|
+
end
|
52
|
+
raise "#{missing.join(', ')} #{missing.size > 1 ? 'are' : 'is'} required, but missing." unless missing.empty?
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Laser
|
2
|
+
module Cutter
|
3
|
+
module Geometry
|
4
|
+
MINIMUM_NOTCHES_PER_SIDE = 3
|
5
|
+
end
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
require 'laser-cutter/geometry/tuple'
|
10
|
+
require 'laser-cutter/geometry/dimensions'
|
11
|
+
require 'laser-cutter/geometry/point'
|
12
|
+
require 'laser-cutter/geometry/shape'
|
13
|
+
require 'laser-cutter/geometry/edge'
|
14
|
+
require 'laser-cutter/geometry/path_generator'
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Laser
|
2
|
+
module Cutter
|
3
|
+
module Geometry
|
4
|
+
class Dimensions < Tuple
|
5
|
+
|
6
|
+
def w
|
7
|
+
coords[0]
|
8
|
+
end
|
9
|
+
|
10
|
+
def h
|
11
|
+
coords[1]
|
12
|
+
end
|
13
|
+
|
14
|
+
def d
|
15
|
+
coords[2]
|
16
|
+
end
|
17
|
+
|
18
|
+
def separator
|
19
|
+
'x'
|
20
|
+
end
|
21
|
+
|
22
|
+
def hash_keys
|
23
|
+
[:w, :h, :d]
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Laser
|
2
|
+
module Cutter
|
3
|
+
module Geometry
|
4
|
+
# This class represents a single edge of one side: both inside
|
5
|
+
# and outside edge of the material. It's also responsible
|
6
|
+
# for calculating the "perfect" notch width.
|
7
|
+
class Edge < Struct.new(:outside, :inside, :notch_width)
|
8
|
+
attr_accessor :notch_count
|
9
|
+
|
10
|
+
def initialize(*args)
|
11
|
+
super(*args)
|
12
|
+
adjust_notch(self.inside)
|
13
|
+
end
|
14
|
+
|
15
|
+
def adjust_notch(line)
|
16
|
+
d = (line.length / notch_width).to_f.ceil
|
17
|
+
pairs = d / 2
|
18
|
+
d = pairs * 2 + 1
|
19
|
+
d = MINIMUM_NOTCHES_PER_SIDE if d < MINIMUM_NOTCHES_PER_SIDE
|
20
|
+
self.notch_width = line.length / (1.0 * d)
|
21
|
+
self.notch_count = d
|
22
|
+
end
|
23
|
+
|
24
|
+
# face_setting determines if we want that face to have center notch
|
25
|
+
# facing out (for a hole, etc). This works well when we have odd number
|
26
|
+
# of notches, but
|
27
|
+
def add_across_line?(face_setting)
|
28
|
+
notch_count % 4 == 1 ? face_setting : !face_setting
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module Laser
|
2
|
+
module Cutter
|
3
|
+
module Geometry
|
4
|
+
class NotchedPath
|
5
|
+
attr_accessor :lines, :vertices, :corner_boxes
|
6
|
+
def initialize(vertices = [])
|
7
|
+
@vertices = vertices
|
8
|
+
@lines = []
|
9
|
+
@corner_boxes = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def << value
|
13
|
+
self.vertices << value
|
14
|
+
end
|
15
|
+
|
16
|
+
def [] value
|
17
|
+
self.vertices[value]
|
18
|
+
end
|
19
|
+
|
20
|
+
def size
|
21
|
+
self.vertices.size
|
22
|
+
end
|
23
|
+
|
24
|
+
def create_lines
|
25
|
+
self.lines = []
|
26
|
+
self.vertices.each_with_index do |v, i|
|
27
|
+
if v != vertices.last
|
28
|
+
self.lines << Line.new(v, vertices[i+1])
|
29
|
+
end
|
30
|
+
end
|
31
|
+
self.corner_boxes.each do |box|
|
32
|
+
box.relocate!
|
33
|
+
self.lines << box.sides
|
34
|
+
end
|
35
|
+
|
36
|
+
self.lines.flatten!
|
37
|
+
self.lines
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require_relative 'notched_path'
|
2
|
+
|
3
|
+
module Laser
|
4
|
+
module Cutter
|
5
|
+
module Geometry
|
6
|
+
class Shift < Struct.new(:delta, :direction, :dim_index)
|
7
|
+
POINTERS = {[1, 0] => ' ->',
|
8
|
+
[-1, 0] => '<- ',
|
9
|
+
[1, 1] => ' V ',
|
10
|
+
[-1, 1] => ' ^ '}
|
11
|
+
|
12
|
+
def next(point1)
|
13
|
+
p = Point.new(point1.to_a)
|
14
|
+
p.coords[dim_index] += (delta * direction)
|
15
|
+
p
|
16
|
+
end
|
17
|
+
def to_s
|
18
|
+
"shift by:#{sprintf('%.2f', delta)}, #{POINTERS[[direction,dim_index]]}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Alternating iterator
|
23
|
+
class InfiniteIterator < Struct.new(:shift_array)
|
24
|
+
attr_accessor :current_index
|
25
|
+
def next
|
26
|
+
self.current_index = -1 if current_index.nil?
|
27
|
+
self.current_index += 1
|
28
|
+
self.current_index = current_index % shift_array.size
|
29
|
+
shift_array[current_index]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class PathGenerator
|
34
|
+
# Removes the items from the list that appear more than once
|
35
|
+
# Unlike uniq-ing which keeps all elements, just ensures that are not
|
36
|
+
# repeated, here we remove elements completely if they are seen more than once.
|
37
|
+
# This is used to remove lines
|
38
|
+
def self.deduplicate list
|
39
|
+
new_list = []
|
40
|
+
list.sort!
|
41
|
+
list.each_with_index do |e, i|
|
42
|
+
next if i < (list.size - 1) && e.eql?(list[i + 1])
|
43
|
+
next if i > 0 && e.eql?(list[i - 1])
|
44
|
+
new_list << e
|
45
|
+
end
|
46
|
+
new_list
|
47
|
+
end
|
48
|
+
|
49
|
+
attr_accessor :notch_width, :thickness
|
50
|
+
attr_accessor :center_out, :fill_corners
|
51
|
+
|
52
|
+
def initialize(options = {})
|
53
|
+
@notch_width = options[:notch_width] # only desired, will be adapted for each line
|
54
|
+
@center_out = options[:center_out] # when true, the notch in the middle of the edge is out, not in.
|
55
|
+
@thickness = options[:thickness]
|
56
|
+
|
57
|
+
# 2D array of booleans. If true first or second end of the edge is
|
58
|
+
# created with a corner filled in.
|
59
|
+
@fill_corners = options[:fill_corners]
|
60
|
+
end
|
61
|
+
|
62
|
+
# Calculates a notched path that flows between the outer edge of the box
|
63
|
+
# (outside_line) and inner (inside_line). Relative location of these lines
|
64
|
+
# also defines the direction and orientation of the box, and hence the notches.
|
65
|
+
#
|
66
|
+
# We always want to create a symmetric path that has a notch in the middle
|
67
|
+
# (for center_out = true) or dip in the middle (center_out = false)
|
68
|
+
def path(edge)
|
69
|
+
shifts = define_shifts(edge)
|
70
|
+
|
71
|
+
path = NotchedPath.new
|
72
|
+
|
73
|
+
if fill_corners
|
74
|
+
r1 = Geometry::Rect.new(edge.inside.p1, edge.outside.p1)
|
75
|
+
r2 = Geometry::Rect.new(edge.inside.p2, edge.outside.p2)
|
76
|
+
path.corner_boxes << r1
|
77
|
+
path.corner_boxes << r2
|
78
|
+
end
|
79
|
+
|
80
|
+
point = edge.inside.p1.clone
|
81
|
+
vertices = [point]
|
82
|
+
shifts.each do |shift|
|
83
|
+
point = shift.next(point)
|
84
|
+
vertices << point
|
85
|
+
end
|
86
|
+
path.vertices = vertices
|
87
|
+
path
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
# This method has the bulk of the logic: we create the list of path deltas
|
93
|
+
# to be applied when we walk the edge next.
|
94
|
+
def define_shifts(edge)
|
95
|
+
along_iterator, across_iterator = define_shift_iterators(edge)
|
96
|
+
shifts = []
|
97
|
+
|
98
|
+
shifts << across_iterator.next if edge.add_across_line?(center_out)
|
99
|
+
|
100
|
+
(1..edge.notch_count).to_a.each do |count|
|
101
|
+
shifts << along_iterator.next
|
102
|
+
shifts << across_iterator.next unless count == edge.notch_count
|
103
|
+
end
|
104
|
+
|
105
|
+
shifts << across_iterator.next if edge.add_across_line?(center_out)
|
106
|
+
shifts
|
107
|
+
end
|
108
|
+
|
109
|
+
# As we draw notches, shifts define the 'delta' – movement from one point
|
110
|
+
# to the next. This method defines three types of movements we'll be doing:
|
111
|
+
# one alongside the edge, and two across (towards the box and outward from the box)
|
112
|
+
def define_shift_iterators(edge)
|
113
|
+
alongside_dimension = (edge.inside.p1.x == edge.inside.p2.x) ? 1 : 0
|
114
|
+
alongside_direction = (edge.inside.p1.coords[alongside_dimension] <
|
115
|
+
edge.inside.p2.coords[alongside_dimension]) ? 1 : -1
|
116
|
+
|
117
|
+
across_dimension = (alongside_dimension + 1) % 2
|
118
|
+
across_direction = (edge.inside.p1.coords[across_dimension] >
|
119
|
+
edge.outside.p1.coords[across_dimension]) ? -1 : 1
|
120
|
+
|
121
|
+
[
|
122
|
+
InfiniteIterator.new(
|
123
|
+
[Shift.new(edge.notch_width, alongside_direction, alongside_dimension)]),
|
124
|
+
InfiniteIterator.new(
|
125
|
+
[Shift.new(thickness, across_direction, across_dimension),
|
126
|
+
Shift.new(thickness, -across_direction, across_dimension)])
|
127
|
+
]
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module Laser
|
2
|
+
module Cutter
|
3
|
+
module Geometry
|
4
|
+
class Point < Tuple
|
5
|
+
def self.[] *array
|
6
|
+
Point.new *array
|
7
|
+
end
|
8
|
+
|
9
|
+
def customize_args(args)
|
10
|
+
if args.first.is_a?(Point)
|
11
|
+
return args.first.to_a
|
12
|
+
end
|
13
|
+
args
|
14
|
+
end
|
15
|
+
|
16
|
+
def x= value
|
17
|
+
coords[0] = value
|
18
|
+
end
|
19
|
+
|
20
|
+
def x
|
21
|
+
coords[0]
|
22
|
+
end
|
23
|
+
|
24
|
+
def y= value
|
25
|
+
coords[1] = value
|
26
|
+
end
|
27
|
+
|
28
|
+
def y
|
29
|
+
coords[1]
|
30
|
+
end
|
31
|
+
|
32
|
+
def separator
|
33
|
+
','
|
34
|
+
end
|
35
|
+
|
36
|
+
def hash_keys
|
37
|
+
[:x, :y]
|
38
|
+
end
|
39
|
+
|
40
|
+
def move_by w, h
|
41
|
+
Point.new(x + w, y + h)
|
42
|
+
end
|
43
|
+
|
44
|
+
def <=>(other)
|
45
|
+
self.x == other.x ? self.y <=> other.y : self.x <=> other.x
|
46
|
+
end
|
47
|
+
|
48
|
+
def < (other)
|
49
|
+
self.x == other.x ? self.y < other.y : self.x < other.x
|
50
|
+
end
|
51
|
+
def > (other)
|
52
|
+
self.x == other.x ? self.y > other.y : self.x > other.x
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Laser
|
2
|
+
module Cutter
|
3
|
+
module Geometry
|
4
|
+
class Shape
|
5
|
+
attr_accessor :position, :name
|
6
|
+
|
7
|
+
def position
|
8
|
+
@position ||= Point.new(0, 0)
|
9
|
+
end
|
10
|
+
|
11
|
+
def x= value
|
12
|
+
position.x = value
|
13
|
+
end
|
14
|
+
|
15
|
+
def y= value
|
16
|
+
position.y = value
|
17
|
+
end
|
18
|
+
|
19
|
+
def move_to new_point
|
20
|
+
self.position = new_point
|
21
|
+
relocate!
|
22
|
+
self
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
# Implement in each shape to move to the new pointd
|
27
|
+
def relocate!
|
28
|
+
raise 'Abstract method'
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
require_relative 'shape/line'
|
36
|
+
require_relative 'shape/rect'
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Laser
|
2
|
+
module Cutter
|
3
|
+
module Geometry
|
4
|
+
class Line < Shape
|
5
|
+
attr_accessor :p1, :p2
|
6
|
+
|
7
|
+
def initialize(point1, point2 = nil)
|
8
|
+
if point1.is_a?(Hash)
|
9
|
+
options = point1
|
10
|
+
self.p1 = Point.new(options[:from])
|
11
|
+
self.p2 = Point.new(options[:to])
|
12
|
+
else
|
13
|
+
self.p1 = point1.clone
|
14
|
+
self.p2 = point2.clone
|
15
|
+
end
|
16
|
+
self.position = p1.clone
|
17
|
+
raise 'Both points are required for line definition' unless (p1 && p2)
|
18
|
+
end
|
19
|
+
|
20
|
+
def relocate!
|
21
|
+
dx = p2.x - p1.x
|
22
|
+
dy = p2.y - p1.y
|
23
|
+
|
24
|
+
self.p1 = position.clone
|
25
|
+
self.p2 = Point[p1.x + dx, p1.y + dy]
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
def center
|
30
|
+
Point.new((p2.x + p1.x) / 2, (p2.y + p1.y) / 2)
|
31
|
+
end
|
32
|
+
|
33
|
+
def length
|
34
|
+
Math.sqrt((p2.x - p1.x)**2 + (p2.y - p1.y)**2)
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_s
|
38
|
+
"#{self.class.name.gsub(/.*::/,'').downcase} #{p1}=>#{p2}"
|
39
|
+
end
|
40
|
+
|
41
|
+
def eql?(other)
|
42
|
+
(other.p1.eql?(p1) && other.p2.eql?(p2)) ||
|
43
|
+
(other.p2.eql?(p1) && other.p1.eql?(p2))
|
44
|
+
end
|
45
|
+
|
46
|
+
def normalized
|
47
|
+
p1 < p2 ? Line.new(p1, p2) : Line.new(p2, p1)
|
48
|
+
end
|
49
|
+
|
50
|
+
def <=>(other)
|
51
|
+
self.normalized.to_s <=> other.normalized.to_s
|
52
|
+
end
|
53
|
+
|
54
|
+
def hash
|
55
|
+
[p1.to_a, p2.to_a].sort.hash
|
56
|
+
end
|
57
|
+
|
58
|
+
def clone
|
59
|
+
self.class.new(p1, p2)
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|