laser-cutter 1.0.5 → 2.0.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 +5 -5
- data/.envrc +2 -0
- data/.github/workflows/lint.yml +22 -0
- data/.github/workflows/rspec.yml +22 -0
- data/.gitignore +5 -5
- data/.plans/001.00-dry-cli-migration/plan.md +29 -0
- data/.relaxed_rubocop.yml +153 -0
- data/.rspec +1 -0
- data/.rubocop.yml +31 -0
- data/.rubocop_todo.yml +90 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +62 -0
- data/CONTRIBUTING.md +2 -4
- data/Gemfile +14 -0
- data/README.md +110 -87
- data/Rakefile +27 -13
- data/docs/badges/coverage_badge.svg +21 -0
- data/exe/laser-cutter +8 -0
- data/exe/lc +1 -0
- data/justfile +97 -0
- data/laser-cutter.gemspec +26 -14
- data/lib/laser/cutter/aggregator.rb +101 -0
- data/lib/laser/cutter/box.rb +173 -0
- data/lib/laser/cutter/cli/command.rb +46 -0
- data/lib/laser/cutter/cli/completion.rb +21 -0
- data/lib/laser/cutter/cli/config_file.rb +36 -0
- data/lib/laser/cutter/cli/examples.rb +42 -0
- data/lib/laser/cutter/cli/generate.rb +90 -0
- data/lib/laser/cutter/cli/help.rb +20 -0
- data/lib/laser/cutter/cli/page_sizes.rb +19 -0
- data/lib/laser/cutter/cli/version.rb +15 -0
- data/lib/laser/cutter/cli.rb +49 -0
- data/lib/laser/cutter/configuration.rb +101 -0
- data/lib/{laser-cutter → laser/cutter}/geometry/dimensions.rb +2 -3
- data/lib/{laser-cutter/geometry/shape → laser/cutter/geometry}/line.rb +19 -18
- data/lib/{laser-cutter → laser/cutter}/geometry/point.rb +4 -2
- data/lib/{laser-cutter/geometry/shape → laser/cutter/geometry}/rect.rb +6 -7
- data/lib/{laser-cutter → laser/cutter}/geometry/shape.rb +7 -7
- data/lib/{laser-cutter → laser/cutter}/geometry/tuple.rb +40 -36
- data/lib/laser/cutter/launcher.rb +40 -0
- data/lib/laser/cutter/missing_option.rb +7 -0
- data/lib/laser/cutter/notching/base.rb +19 -0
- data/lib/{laser-cutter → laser/cutter}/notching/edge.rb +23 -22
- data/lib/laser/cutter/notching/infinite_iterator.rb +27 -0
- data/lib/{laser-cutter → laser/cutter}/notching/path_generator.rb +76 -94
- data/lib/laser/cutter/notching/shift.rb +17 -0
- data/lib/laser/cutter/page_manager.rb +50 -0
- data/lib/{laser-cutter → laser/cutter}/renderer/base.rb +3 -2
- data/lib/laser/cutter/renderer/box_renderer.rb +40 -0
- data/lib/laser/cutter/renderer/layout_renderer.rb +61 -0
- data/lib/laser/cutter/renderer/line_renderer.rb +21 -0
- data/lib/laser/cutter/renderer/meta_renderer.rb +83 -0
- data/lib/{laser-cutter → laser/cutter}/renderer/rect_renderer.rb +3 -1
- data/lib/laser/cutter/renderer/svg_renderer.rb +65 -0
- data/lib/laser/cutter/renderer.rb +24 -0
- data/lib/laser/cutter/units_converter.rb +15 -0
- data/lib/laser/cutter/version.rb +7 -0
- data/lib/laser/cutter/zero_value_not_allowed.rb +7 -0
- data/lib/laser/cutter.rb +29 -0
- data/lib/laser-cutter.rb +2 -14
- data/lib/laser_cutter.rb +3 -0
- data/spec/laser/cutter/aggregator_spec.rb +70 -0
- data/spec/laser/cutter/box_spec.rb +61 -0
- data/spec/laser/cutter/cli/config_file_spec.rb +31 -0
- data/spec/laser/cutter/cli_spec.rb +186 -0
- data/spec/laser/cutter/configuration_spec.rb +101 -0
- data/spec/{dimensions_spec.rb → laser/cutter/dimensions_spec.rb} +8 -6
- data/spec/laser/cutter/edge_spec.rb +64 -0
- data/spec/{line_spec.rb → laser/cutter/line_spec.rb} +26 -20
- data/spec/{page_manager_spec.rb → laser/cutter/page_manager_spec.rb} +27 -11
- data/spec/{path_generator_spec.rb → laser/cutter/path_generator_spec.rb} +18 -14
- data/spec/{point_spec.rb → laser/cutter/point_spec.rb} +27 -22
- data/spec/{rect_spec.rb → laser/cutter/rect_spec.rb} +9 -5
- data/spec/laser/cutter/renderer/layout_renderer_spec.rb +102 -0
- data/spec/laser/cutter/renderer/svg_renderer_spec.rb +51 -0
- data/spec/laser/cutter/renderer_spec.rb +19 -0
- data/spec/spec_helper.rb +35 -22
- data/spec/support/aruba.rb +10 -0
- metadata +202 -76
- data/.travis.yml +0 -26
- data/BOXMAKER.md +0 -51
- data/LICENSE +0 -22
- data/bin/laser-cutter +0 -19
- data/lib/laser-cutter/aggregator.rb +0 -57
- data/lib/laser-cutter/box.rb +0 -183
- data/lib/laser-cutter/cli/opt_parser.rb +0 -131
- data/lib/laser-cutter/cli/serializer.rb +0 -51
- data/lib/laser-cutter/configuration.rb +0 -83
- data/lib/laser-cutter/face.rb +0 -18
- data/lib/laser-cutter/geometry.rb +0 -11
- data/lib/laser-cutter/notching.rb +0 -9
- data/lib/laser-cutter/page_manager.rb +0 -43
- data/lib/laser-cutter/renderer/box_renderer.rb +0 -36
- data/lib/laser-cutter/renderer/layout_renderer.rb +0 -59
- data/lib/laser-cutter/renderer/line_renderer.rb +0 -17
- data/lib/laser-cutter/renderer/meta_renderer.rb +0 -77
- data/lib/laser-cutter/renderer.rb +0 -14
- data/lib/laser-cutter/version.rb +0 -5
- data/spec/aggregator_spec.rb +0 -65
- data/spec/box_spec.rb +0 -39
- data/spec/configuration_spec.rb +0 -81
- data/spec/edge_spec.rb +0 -43
- data/spec/renderer_spec.rb +0 -72
- /data/docs/{comparison.jpg → images/comparison.jpg} +0 -0
|
@@ -1,51 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Laser
|
|
3
4
|
module Cutter
|
|
4
5
|
module Notching
|
|
5
|
-
class Shift < Struct.new(:delta, :direction, :dim_index)
|
|
6
|
-
def next_point_after point
|
|
7
|
-
p = point.clone
|
|
8
|
-
shift = []
|
|
9
|
-
shift[dim_index] = delta * direction
|
|
10
|
-
shift[(dim_index + 1) % 2] = 0
|
|
11
|
-
p.plus *shift
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# Alternating iterator
|
|
16
|
-
class InfiniteIterator < Struct.new(:array)
|
|
17
|
-
attr_accessor :array, :next_index, :calls
|
|
18
|
-
|
|
19
|
-
def initialize(array)
|
|
20
|
-
self.array = array
|
|
21
|
-
self.calls = 0
|
|
22
|
-
self.next_index = 0
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def next
|
|
26
|
-
item = self.array[next_index].clone
|
|
27
|
-
self.next_index += 1
|
|
28
|
-
self.next_index %= array.size
|
|
29
|
-
self.calls += 1
|
|
30
|
-
item = yield item, self.calls if block_given?
|
|
31
|
-
item
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# One of the key "tricks" that this algorithm applies, is that it converts everything into
|
|
36
|
-
# pure set of lines in the end. It then tries to find all intersections of the lines so that
|
|
37
|
-
# we can remove duplicates. So any segment of any line that is covered by 2 lines or more is removed,
|
|
38
|
-
# cleared completely for an empty space. This turns out to be very useful indeed, because we can
|
|
39
|
-
# paint with wide brush strokes to get the carcass, and then fine tune it by adding or removing line
|
|
40
|
-
# segments. Some of the lines below are added to actually remove the lines that might have otherwise
|
|
41
|
-
# been there.
|
|
42
|
-
#
|
|
43
|
-
# This comes especially handy when drawing corner boxes, which are deliberately made not to match the notch
|
|
44
|
-
# width, but to match thickness of the material. The corner notces for these sides will therefore have
|
|
45
|
-
# length equal to the thickness + regular notch length.
|
|
46
6
|
class PathGenerator
|
|
47
|
-
|
|
48
7
|
extend ::Forwardable
|
|
8
|
+
|
|
49
9
|
%i(center_out thickness corners kerf kerf? notch_width first_notch_out? adjust_corners corners).each do |method_name|
|
|
50
10
|
def_delegator :@edge, method_name, method_name
|
|
51
11
|
end
|
|
@@ -77,7 +37,7 @@ module Laser
|
|
|
77
37
|
point = starting_point
|
|
78
38
|
|
|
79
39
|
vertices << point
|
|
80
|
-
adjust_for_kerf(vertices
|
|
40
|
+
adjust_for_kerf(vertices, -1) if adjust_corners && !first_notch_out?
|
|
81
41
|
shifts.each do |shift|
|
|
82
42
|
point = shift.next_point_after point
|
|
83
43
|
vertices << point
|
|
@@ -88,45 +48,99 @@ module Laser
|
|
|
88
48
|
end
|
|
89
49
|
|
|
90
50
|
def adjust_for_kerf(vertices, direction)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
51
|
+
return unless kerf?
|
|
52
|
+
|
|
53
|
+
point = vertices.pop
|
|
54
|
+
point = point.plus(2 * direction * shift_vector(1)) if corners
|
|
55
|
+
vertices << point
|
|
96
56
|
end
|
|
97
57
|
|
|
98
|
-
# These two boxes occupy the corners of the 3D box. They do not match
|
|
99
|
-
# in width to our notches because they are usually merged with them. Their
|
|
100
|
-
# size is equal to the thickness of the material (adjusted for kerf)
|
|
101
|
-
# It's just an aesthetic choice I guess.
|
|
102
58
|
def corner_box_sides
|
|
103
59
|
boxes = []
|
|
104
60
|
extra_lines = []
|
|
105
61
|
|
|
62
|
+
# These two boxes occupy the corners of the 3D box. They do not match
|
|
63
|
+
# in width to our notches because they are usually merged with them.
|
|
64
|
+
# It's just an aesthetic choice I guess.
|
|
106
65
|
boxes << Geometry::Rect[edge.inside.p1.clone, edge.outside.p1.clone]
|
|
107
66
|
boxes << Geometry::Rect[edge.inside.p2.clone, edge.outside.p2.clone]
|
|
108
67
|
|
|
109
|
-
|
|
68
|
+
if kerf? && adjust_corners
|
|
69
|
+
if first_notch_out?
|
|
70
|
+
k = 2
|
|
71
|
+
direction = -1
|
|
72
|
+
dim_index = 1
|
|
73
|
+
extra_lines << add_corners_when_out(dim_index, direction, k)
|
|
74
|
+
else
|
|
75
|
+
k = -2
|
|
76
|
+
direction = 1
|
|
77
|
+
dim_index = 0
|
|
78
|
+
extra_lines << add_boxes_when_in(dim_index, direction, k)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
110
81
|
sides = boxes.flatten.map(&:relocate!).map(&:sides)
|
|
111
82
|
sides << extra_lines if !extra_lines.empty?
|
|
112
83
|
sides.flatten
|
|
113
84
|
end
|
|
114
85
|
|
|
86
|
+
def add_boxes_when_in(dim_index, direction, k)
|
|
87
|
+
v1 = k * direction * shift_vector(1, dim_index)
|
|
88
|
+
v2 = k * direction * shift_vector(2, dim_index)
|
|
89
|
+
p1 = edge.inside.p1.plus(v1)
|
|
90
|
+
coords = []
|
|
91
|
+
coords[d_index_along] = edge.inside.p1[d_index_along]
|
|
92
|
+
coords[d_index_across] = edge.outside.p1[d_index_across]
|
|
93
|
+
p2 = Geometry::Point[*coords]
|
|
94
|
+
r1 = Geometry::Rect[p1, p2]
|
|
95
|
+
|
|
96
|
+
p1 = edge.inside.p2.plus(v2)
|
|
97
|
+
coords = []
|
|
98
|
+
coords[d_index_along] = edge.inside.p2[d_index_along]
|
|
99
|
+
coords[d_index_across] = edge.outside.p2[d_index_across]
|
|
100
|
+
p2 = Geometry::Point[*coords]
|
|
101
|
+
r2 = Geometry::Rect[p1, p2]
|
|
102
|
+
lines = [r1, r2].map(&:sides).flatten
|
|
103
|
+
lines << Geometry::Line[edge.inside.p1.plus(v1), edge.inside.p1.clone]
|
|
104
|
+
lines << Geometry::Line[edge.inside.p2.plus(v2), edge.inside.p2.clone]
|
|
105
|
+
lines
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def add_corners_when_out(dim_index, direction, k)
|
|
109
|
+
v1 = direction * k * shift_vector(1, dim_index)
|
|
110
|
+
v2 = direction * k * shift_vector(2, dim_index)
|
|
111
|
+
p1 = edge.inside.p1.plus(v1)
|
|
112
|
+
coords = []
|
|
113
|
+
coords[d_index_along] = edge.outside.p1[d_index_along]
|
|
114
|
+
coords[d_index_across] = edge.inside.p1[d_index_across]
|
|
115
|
+
p2 = Geometry::Point[*coords]
|
|
116
|
+
r1 = Geometry::Rect[p1, p2]
|
|
117
|
+
|
|
118
|
+
p1 = edge.inside.p2.plus(v2)
|
|
119
|
+
coords = []
|
|
120
|
+
coords[d_index_along] = edge.outside.p2[d_index_along]
|
|
121
|
+
coords[d_index_across] = edge.inside.p2[d_index_across]
|
|
122
|
+
p2 = Geometry::Point[*coords]
|
|
123
|
+
r2 = Geometry::Rect[p1, p2]
|
|
124
|
+
lines = [r1, r2].map(&:sides).flatten
|
|
125
|
+
lines << Geometry::Line[edge.inside.p1.plus(v1), edge.inside.p1.clone]
|
|
126
|
+
lines << Geometry::Line[edge.inside.p2.plus(v2), edge.inside.p2.clone]
|
|
127
|
+
lines
|
|
128
|
+
end
|
|
129
|
+
|
|
115
130
|
def shift_vector(index, dim_shift = 0)
|
|
116
131
|
shift = []
|
|
117
132
|
shift[(d_index_across + dim_shift) % 2] = 0
|
|
118
|
-
shift[(d_index_along + dim_shift) % 2] = kerf / 2.0 * edge.send("v#{index}"
|
|
133
|
+
shift[(d_index_along + dim_shift) % 2] = kerf / 2.0 * edge.send(:"v#{index}").[]((d_index_along + dim_shift) % 2)
|
|
119
134
|
Vector.[](*shift)
|
|
120
135
|
end
|
|
121
136
|
|
|
122
|
-
|
|
123
137
|
def starting_point
|
|
124
138
|
edge.inside.p1.clone # start
|
|
125
139
|
end
|
|
126
140
|
|
|
127
141
|
# 0 = X, 1 = Y
|
|
128
142
|
def d_index_along
|
|
129
|
-
|
|
143
|
+
edge.inside.p1.x == edge.inside.p2.x ? 1 : 0
|
|
130
144
|
end
|
|
131
145
|
|
|
132
146
|
def d_index_across
|
|
@@ -134,46 +148,14 @@ module Laser
|
|
|
134
148
|
end
|
|
135
149
|
|
|
136
150
|
def direction_along
|
|
137
|
-
|
|
151
|
+
edge.inside.p1.coords.[](d_index_along) < edge.inside.p2.coords.[](d_index_along) ? 1 : -1
|
|
138
152
|
end
|
|
139
153
|
|
|
140
154
|
def direction_across
|
|
141
|
-
|
|
155
|
+
edge.inside.p1.coords.[](d_index_across) < edge.outside.p1.coords.[](d_index_across) ? 1 : -1
|
|
142
156
|
end
|
|
143
157
|
|
|
144
158
|
private
|
|
145
|
-
# Helper method to calculate dimensions of our corners.
|
|
146
|
-
def add_corners
|
|
147
|
-
k, direction, dim_index, edge_along, edge_across = if first_notch_out?
|
|
148
|
-
[2, -1, 1, :inside, :outside]
|
|
149
|
-
else
|
|
150
|
-
[-2, 1, 0, :outside, :inside]
|
|
151
|
-
end
|
|
152
|
-
v1 = direction * k * shift_vector(1, dim_index)
|
|
153
|
-
v2 = direction * k * shift_vector(2, dim_index)
|
|
154
|
-
|
|
155
|
-
r1 = define_corner_rect(:p1, v1, edge_along, edge_across)
|
|
156
|
-
r2 = define_corner_rect(:p2, v2, edge_along, edge_across)
|
|
157
|
-
|
|
158
|
-
lines = [r1, r2].map(&:sides).flatten
|
|
159
|
-
|
|
160
|
-
# Our clever algorithm removes automatically duplicate lines. These lines
|
|
161
|
-
# below are added to actually clear out this space and remove the existing
|
|
162
|
-
# lines that are already there.
|
|
163
|
-
lines << Geometry::Line[edge.inside.p1.plus(v1), edge.inside.p1.clone]
|
|
164
|
-
lines << Geometry::Line[edge.inside.p2.plus(v2), edge.inside.p2.clone]
|
|
165
|
-
lines
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def define_corner_rect(point, delta, edge_along, edge_across)
|
|
169
|
-
p1 = edge.inside.send(point).plus(delta)
|
|
170
|
-
coords = []
|
|
171
|
-
coords[d_index_along] = edge.send(edge_along).send(point)[d_index_along]
|
|
172
|
-
coords[d_index_across] = edge.send(edge_across).send(point)[d_index_across]
|
|
173
|
-
p2 = Geometry::Point[*coords]
|
|
174
|
-
Geometry::Rect[p1, p2]
|
|
175
|
-
end
|
|
176
|
-
|
|
177
159
|
|
|
178
160
|
# This method has the bulk of the logic: we create the list of path deltas
|
|
179
161
|
# to be applied when we walk the edge next.
|
|
@@ -191,8 +173,8 @@ module Laser
|
|
|
191
173
|
end
|
|
192
174
|
|
|
193
175
|
(1..edge.notch_count).to_a.each do |notch_number|
|
|
194
|
-
shifts << along_iter.next do |shift,
|
|
195
|
-
if inner &&
|
|
176
|
+
shifts << along_iter.next do |shift, _index|
|
|
177
|
+
if inner && notch_number > 1 && notch_number < edge.notch_count
|
|
196
178
|
shift.delta -= kerf
|
|
197
179
|
elsif !inner
|
|
198
180
|
shift.delta += kerf
|
|
@@ -223,7 +205,7 @@ module Laser
|
|
|
223
205
|
lines = []
|
|
224
206
|
vertices.each_with_index do |v, i|
|
|
225
207
|
if v != vertices.last
|
|
226
|
-
lines << Geometry::Line.new(v, vertices[i+1])
|
|
208
|
+
lines << Geometry::Line.new(v, vertices[i + 1])
|
|
227
209
|
end
|
|
228
210
|
end
|
|
229
211
|
lines.flatten
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
module Notching
|
|
6
|
+
Shift = Struct.new(:delta, :direction, :dim_index) do
|
|
7
|
+
def next_point_after(point)
|
|
8
|
+
p = point.clone
|
|
9
|
+
shift = []
|
|
10
|
+
shift[dim_index] = delta * direction
|
|
11
|
+
shift[(dim_index + 1) % 2] = 0
|
|
12
|
+
p.plus(*shift)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
class PageManager
|
|
6
|
+
SIZES = PDF::Core::PageGeometry::SIZES.clone.freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :units
|
|
9
|
+
|
|
10
|
+
# @param units [String, Symbol] 'in' or 'mm'; the configuration's default is a Symbol
|
|
11
|
+
def initialize(units)
|
|
12
|
+
@units = units.to_s
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def all_page_sizes
|
|
16
|
+
output = +""
|
|
17
|
+
page_size_values.each do |k|
|
|
18
|
+
output << sprintf("\t%10s:\t%6.1f x %6.1f\n", *k)
|
|
19
|
+
end
|
|
20
|
+
output
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# if from_units is nil, we expect it to be in dots per inch (default
|
|
24
|
+
# measurements for Prawn
|
|
25
|
+
def value_from_units(value, from_units = nil)
|
|
26
|
+
multiplier = if from_units.nil?
|
|
27
|
+
if units.eql?('in')
|
|
28
|
+
1.0 / 72.0 # PDF units per inch
|
|
29
|
+
else
|
|
30
|
+
25.4 * 1.0 / 72.0
|
|
31
|
+
end
|
|
32
|
+
elsif units.eql?(from_units)
|
|
33
|
+
1.0
|
|
34
|
+
elsif units.eql?('in') && from_units.eql?('mm')
|
|
35
|
+
(1.0 / 25.4)
|
|
36
|
+
else
|
|
37
|
+
25.4
|
|
38
|
+
end
|
|
39
|
+
value.to_f * multiplier
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def page_size_values
|
|
43
|
+
h = Hashie::Mash.new(SIZES)
|
|
44
|
+
h.keys.sort.map do |k|
|
|
45
|
+
[k, value_from_units(h[k][0].to_f), value_from_units(h[k][1].to_f)]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
module Renderer
|
|
6
|
+
class BoxRenderer < Base
|
|
7
|
+
alias_method :box, :subject
|
|
8
|
+
|
|
9
|
+
def initialize(config)
|
|
10
|
+
super
|
|
11
|
+
self.subject = Laser::Cutter::Box.new(config)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def ensure_space_for(rect)
|
|
15
|
+
coords = [rect.p2.x, rect.p2.y].map{ |a| page_manager.value_from_units(a) }
|
|
16
|
+
box.metadata = Geometry::Point.new(coords)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def enclosure
|
|
20
|
+
box.enclosure
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<Geometry::Line>] every line to cut, computed once
|
|
24
|
+
def lines
|
|
25
|
+
@lines ||= box.generate_notches
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Draws the box, yielding after each line so a caller can count them.
|
|
29
|
+
def render(pdf = nil)
|
|
30
|
+
pdf.line_width = config.stroke.send(units)
|
|
31
|
+
pdf.stroke_color config[:color] || BLACK
|
|
32
|
+
lines.each do |line|
|
|
33
|
+
LineRenderer.new(config, line).render(pdf)
|
|
34
|
+
yield line if block_given?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
module Renderer
|
|
6
|
+
# Writes the PDF: the box, its metadata block, and the unkerfed outline
|
|
7
|
+
# when asked for.
|
|
8
|
+
class LayoutRenderer < Base
|
|
9
|
+
UNKERFED_COLOR = 'DD2211'
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
self.config = config
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Integer] how many lines {#render} draws, and yields
|
|
17
|
+
def total
|
|
18
|
+
box_renderers.sum { |renderer| renderer.lines.size }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Writes the file, yielding after each line drawn.
|
|
22
|
+
def render(&)
|
|
23
|
+
margin = config.margin.to_f.send(units)
|
|
24
|
+
pdf = Prawn::Document.new(margin: margin,
|
|
25
|
+
page_size: config.page_size || calculate_image_boundary(box_renderers.first, margin),
|
|
26
|
+
page_layout: config.page_layout.to_sym)
|
|
27
|
+
|
|
28
|
+
box_renderers.first.render(pdf, &)
|
|
29
|
+
meta_renderer&.render(pdf)
|
|
30
|
+
box_renderers.drop(1).each { |renderer| renderer.render(pdf, &) }
|
|
31
|
+
pdf.render_file(config.file)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def calculate_image_boundary(box_renderer, margin)
|
|
35
|
+
box_renderer.enclosure.to_a[1].map do |c|
|
|
36
|
+
c.send(units) + (2 * margin)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def meta_renderer
|
|
43
|
+
@meta_renderer ||= MetaRenderer.new(config) if config.metadata
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The box, then the same box without kerf when config.debug is set.
|
|
47
|
+
def box_renderers
|
|
48
|
+
@box_renderers ||= begin
|
|
49
|
+
configs = [config]
|
|
50
|
+
configs << Configuration.new(config.to_hash).merge!(kerf: 0.0, color: UNKERFED_COLOR) if config.debug
|
|
51
|
+
configs.map do |box_config|
|
|
52
|
+
BoxRenderer.new(box_config).tap do |renderer|
|
|
53
|
+
renderer.ensure_space_for(meta_renderer.enclosure) if meta_renderer
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
module Renderer
|
|
6
|
+
class LineRenderer < Base
|
|
7
|
+
alias_method :line, :subject
|
|
8
|
+
def render(pdf = nil)
|
|
9
|
+
pdf.stroke {
|
|
10
|
+
pdf.line([line.p1.x, line.p1.y].map{ |p| p.send(units) },
|
|
11
|
+
[line.p2.x, line.p2.y].map{ |p| p.send(units) })
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def enclosure
|
|
16
|
+
line
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
module Renderer
|
|
6
|
+
class MetaRenderer < Laser::Cutter::Renderer::Base
|
|
7
|
+
META_KEYS = %i(units width height depth thickness notch kerf stroke padding margin page_size page_layout).freeze
|
|
8
|
+
|
|
9
|
+
def initialize(config = {})
|
|
10
|
+
self.config = config
|
|
11
|
+
self.enclosure = Laser::Cutter::Geometry::Rect.create(Laser::Cutter::Geometry::Point[1, 1], 140, 150)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render(pdf = nil)
|
|
15
|
+
banner = <<-EOF
|
|
16
|
+
|
|
17
|
+
Made with Laser Cutter Ruby Gem (v#{Laser::Cutter::VERSION})
|
|
18
|
+
Credits to Prawn for ruby PDF generation,
|
|
19
|
+
and BoxMaker for inspiration.
|
|
20
|
+
|
|
21
|
+
Online: http://makeabox.io/
|
|
22
|
+
Source: https://github.com/kigster/laser-cutter
|
|
23
|
+
EOF
|
|
24
|
+
|
|
25
|
+
meta_color = BLUE
|
|
26
|
+
meta_top_height = 55
|
|
27
|
+
|
|
28
|
+
metadata = config.to_hash
|
|
29
|
+
metadata[:page_size] ||= 'custom'
|
|
30
|
+
metadata.delete(:page_layout) if metadata[:page_size].eql?('custom')
|
|
31
|
+
|
|
32
|
+
meta_fields = META_KEYS.find_all{ |k| metadata[k] }.join(": \n") + ": \n"
|
|
33
|
+
meta_values = META_KEYS.find_all{ |k| metadata[k] }.map{ |k| metadata[k].to_s }.join("\n")
|
|
34
|
+
|
|
35
|
+
rect = enclosure
|
|
36
|
+
|
|
37
|
+
pdf.instance_eval do
|
|
38
|
+
self.line_width = 0.2.mm
|
|
39
|
+
float do
|
|
40
|
+
bounding_box([rect.p1.x, rect.h + rect.p1.y], width: rect.w, height: rect.h) do
|
|
41
|
+
stroke_color meta_color
|
|
42
|
+
stroke_bounds
|
|
43
|
+
|
|
44
|
+
# Print banner
|
|
45
|
+
indent 10 do
|
|
46
|
+
font('Helvetica', size: 6) do
|
|
47
|
+
text banner, color: meta_color
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# print values of the config, in two parts – keys right aligned first, values left aligned second.
|
|
52
|
+
float do
|
|
53
|
+
bounding_box([0, rect.h - meta_top_height],
|
|
54
|
+
width: rect.w,
|
|
55
|
+
height: rect.h - meta_top_height) do
|
|
56
|
+
float do
|
|
57
|
+
bounding_box([0, rect.h - meta_top_height], width: 70, height: rect.h - meta_top_height) do
|
|
58
|
+
indent 10 do
|
|
59
|
+
font('Helvetica', size: 7) do
|
|
60
|
+
text meta_fields, color: meta_color, align: :right
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
float do
|
|
66
|
+
bounding_box([60, rect.h - meta_top_height], width: 70, height: rect.h - meta_top_height) do
|
|
67
|
+
indent 10 do
|
|
68
|
+
font('Helvetica', size: 7) do
|
|
69
|
+
text meta_values, color: meta_color
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'victor'
|
|
4
|
+
|
|
5
|
+
module Laser
|
|
6
|
+
module Cutter
|
|
7
|
+
module Renderer
|
|
8
|
+
# Writes the box as an SVG sized in the configuration's own units. It
|
|
9
|
+
# fits the page to the box, and carries the metadata as a <desc>
|
|
10
|
+
# instead of drawing it.
|
|
11
|
+
class SvgRenderer < Base
|
|
12
|
+
def initialize(config)
|
|
13
|
+
super
|
|
14
|
+
self.subject = Laser::Cutter::Box.new(config)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Array<Geometry::Line>] every line to cut, computed once
|
|
18
|
+
def lines
|
|
19
|
+
@lines ||= subject.generate_notches
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Integer] how many lines {#render} draws, and yields
|
|
23
|
+
def total
|
|
24
|
+
lines.size
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Writes the file, yielding after each line drawn.
|
|
28
|
+
def render
|
|
29
|
+
svg = Victor::SVG.new(width: "#{width}#{units}", height: "#{height}#{units}", viewBox: "0 0 #{width} #{height}")
|
|
30
|
+
svg.element(:desc, description) if config.metadata
|
|
31
|
+
lines.each do |line|
|
|
32
|
+
svg.line(**coordinates(line), stroke: "##{config[:color] || BLACK}", stroke_width: config.stroke)
|
|
33
|
+
yield line if block_given?
|
|
34
|
+
end
|
|
35
|
+
File.write(config.file, svg.render)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def margin
|
|
41
|
+
config.margin.to_f
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def width
|
|
45
|
+
(subject.enclosure.p2.x + (2 * margin)).round(5)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def height
|
|
49
|
+
(subject.enclosure.p2.y + (2 * margin)).round(5)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# SVG counts y down from the top, the box geometry up from the bottom.
|
|
53
|
+
def coordinates(line)
|
|
54
|
+
{ x1: (line.p1.x + margin).round(5), y1: (height - margin - line.p1.y).round(5),
|
|
55
|
+
x2: (line.p2.x + margin).round(5), y2: (height - margin - line.p2.y).round(5) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def description
|
|
59
|
+
fields = MetaRenderer::META_KEYS.filter_map { |key| "#{key}: #{config[key]}" if config[key] }
|
|
60
|
+
"Made with laser-cutter v#{VERSION}. #{fields.join(', ')}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
module Renderer
|
|
6
|
+
# The renderer that writes each output format.
|
|
7
|
+
FORMATS = {
|
|
8
|
+
'pdf' => 'LayoutRenderer',
|
|
9
|
+
'svg' => 'SvgRenderer'
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
# @param format [String, Symbol] 'pdf' or 'svg', in either case
|
|
13
|
+
# @param config [Configuration]
|
|
14
|
+
# @return [LayoutRenderer, SvgRenderer]
|
|
15
|
+
# @raise [Error] for a format nothing here writes
|
|
16
|
+
def self.for(format, config)
|
|
17
|
+
name = FORMATS.fetch(format.to_s.downcase) do
|
|
18
|
+
raise Error, "unknown format #{format.inspect}, expected one of: #{FORMATS.keys.join(', ')}"
|
|
19
|
+
end
|
|
20
|
+
const_get(name).new(config)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|