laser-cutter 1.0.5 → 2.0.1
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/.plans/002.00-lid-options/plan.md +45 -0
- data/.plans/003.00-ruby-api/plan.md +37 -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 +75 -0
- data/CONTRIBUTING.md +2 -4
- data/Gemfile +14 -0
- data/README.md +207 -79
- data/Rakefile +27 -13
- data/docs/badges/coverage_badge.svg +21 -0
- data/docs/images/box-lid-back.avif +0 -0
- data/docs/images/box-lid-full.avif +0 -0
- data/docs/images/box-lid-plain.avif +0 -0
- data/docs/images/box-metric.avif +0 -0
- data/docs/images/box-tray.avif +0 -0
- data/exe/laser-cutter +8 -0
- data/exe/lc +1 -0
- data/justfile +97 -0
- data/laser-cutter.gemspec +27 -14
- data/lib/laser/cutter/aggregator.rb +101 -0
- data/lib/laser/cutter/box.rb +251 -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 +47 -0
- data/lib/laser/cutter/cli/generate.rb +95 -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 +107 -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/invalid_option.rb +8 -0
- 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 +36 -20
- data/lib/laser/cutter/notching/infinite_iterator.rb +27 -0
- data/lib/laser/cutter/notching/path_generator.rb +187 -0
- data/lib/laser/cutter/notching/shift.rb +17 -0
- data/lib/laser/cutter/options.rb +154 -0
- data/lib/laser/cutter/page_manager.rb +50 -0
- data/lib/{laser-cutter → laser/cutter}/renderer/base.rb +10 -3
- 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/types.rb +57 -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 +67 -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_lid_spec.rb +142 -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 +213 -0
- data/spec/laser/cutter/configuration_spec.rb +118 -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/laser/cutter/options_spec.rb +120 -0
- 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 +85 -0
- data/spec/laser/cutter/renderer/svg_renderer_spec.rb +51 -0
- data/spec/laser/cutter/renderer_spec.rb +19 -0
- data/spec/laser/cutter_spec.rb +114 -0
- data/spec/spec_helper.rb +35 -22
- data/spec/support/aruba.rb +10 -0
- data/spec/support/outline.rb +53 -0
- metadata +230 -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/path_generator.rb +0 -234
- 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,49 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Laser
|
|
2
4
|
module Cutter
|
|
3
5
|
module Notching
|
|
4
6
|
MINIMUM_NOTCHES_PER_SIDE = 3
|
|
7
|
+
# Decimal places kept of length / notch width before counting notches.
|
|
8
|
+
RATIO_DIGITS = 6
|
|
5
9
|
# This class represents a single edge of one side: both inside
|
|
6
10
|
# and outside edge of the material. It's also responsible
|
|
7
11
|
# for calculating the "perfect" notch width.
|
|
8
12
|
class Edge
|
|
13
|
+
# Both ends of an edge: 1 for p1, 2 for p2.
|
|
14
|
+
ENDS = [1, 2].freeze
|
|
9
15
|
|
|
10
|
-
attr_accessor :outside, :inside
|
|
11
|
-
attr_accessor :notch_width
|
|
12
|
-
attr_accessor :thickness, :kerf
|
|
13
|
-
attr_accessor :center_out, :corners, :adjust_corners
|
|
14
|
-
attr_accessor :notch_count, :v1, :v2
|
|
16
|
+
attr_accessor :outside, :inside, :notch_width, :thickness, :kerf, :center_out, :corners, :adjust_corners, :notch_count, :v1, :v2
|
|
15
17
|
|
|
18
|
+
# @return [Array<Integer>] the ends that get a corner box when +corners+ is set: 1 for p1, 2 for p2
|
|
19
|
+
attr_accessor :corner_ends
|
|
16
20
|
|
|
17
21
|
def initialize(outside, inside, options = {})
|
|
18
22
|
self.outside = outside.clone
|
|
19
23
|
self.inside = inside.clone
|
|
20
24
|
|
|
21
25
|
# two vectors representing directions going from beginning of each inside line to the outside
|
|
22
|
-
self.v1 = [inside.p1.x - outside.p1.x, inside.p1.y - outside.p1.y].map{|e| -(e / e.abs).to_f }
|
|
23
|
-
self.v2 = [inside.p2.x - outside.p2.x, inside.p2.y - outside.p2.y].map{|e| -(e / e.abs).to_f }
|
|
26
|
+
self.v1 = [inside.p1.x - outside.p1.x, inside.p1.y - outside.p1.y].map{ |e| -(e / e.abs).to_f }
|
|
27
|
+
self.v2 = [inside.p2.x - outside.p2.x, inside.p2.y - outside.p2.y].map{ |e| -(e / e.abs).to_f }
|
|
24
28
|
|
|
25
|
-
self.v1 = Vector.[](*
|
|
26
|
-
self.v2 = Vector.[](*
|
|
29
|
+
self.v1 = Vector.[](*v1)
|
|
30
|
+
self.v2 = Vector.[](*v2)
|
|
27
31
|
|
|
28
32
|
self.center_out = options[:center_out] || false
|
|
29
33
|
self.thickness = options[:thickness]
|
|
30
34
|
self.corners = options[:corners]
|
|
35
|
+
self.corner_ends = options[:corner_ends] || ENDS
|
|
31
36
|
self.kerf = options[:kerf] || 0
|
|
32
37
|
self.notch_width = options[:notch_width]
|
|
33
38
|
self.adjust_corners = options[:adjust_corners]
|
|
34
39
|
|
|
35
|
-
calculate_notch_width!
|
|
36
40
|
adjust_for_kerf!
|
|
41
|
+
calculate_notch_width!
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
def adjust_for_kerf!
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
return unless kerf?
|
|
46
|
+
|
|
47
|
+
self.inside = move_line_for_kerf(inside)
|
|
48
|
+
self.outside = move_line_for_kerf(outside)
|
|
44
49
|
end
|
|
45
50
|
|
|
46
|
-
def move_line_for_kerf
|
|
51
|
+
def move_line_for_kerf(line)
|
|
47
52
|
k = kerf / 2.0
|
|
48
53
|
p1 = line.p1.plus(v1 * k)
|
|
49
54
|
p2 = line.p2.plus(v2 * k)
|
|
@@ -51,7 +56,15 @@ module Laser
|
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
def kerf?
|
|
54
|
-
|
|
59
|
+
kerf > 0.0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Whether this edge draws a corner box at the given end.
|
|
63
|
+
#
|
|
64
|
+
# @param end_index [Integer] 1 for p1, 2 for p2
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def corner_at?(end_index)
|
|
67
|
+
corners ? corner_ends.include?(end_index) : false
|
|
55
68
|
end
|
|
56
69
|
|
|
57
70
|
# face_setting determines if we want that face to have center notch
|
|
@@ -65,17 +78,20 @@ module Laser
|
|
|
65
78
|
def first_notch_out?
|
|
66
79
|
add_across_line?(center_out)
|
|
67
80
|
end
|
|
81
|
+
|
|
68
82
|
private
|
|
69
83
|
|
|
70
84
|
def calculate_notch_width!
|
|
71
|
-
|
|
85
|
+
length = kerf? ? inside.length - kerf : inside.length
|
|
86
|
+
# Rounded before #ceil: when the notch divides the side exactly,
|
|
87
|
+
# float noise in the coordinates would otherwise decide the count,
|
|
88
|
+
# and the two panels meeting at this joint would disagree.
|
|
89
|
+
count = (length / notch_width).to_f.round(RATIO_DIGITS).ceil + 1
|
|
72
90
|
count = (count / 2 * 2) + 1 # make count always an odd number
|
|
73
91
|
count = [MINIMUM_NOTCHES_PER_SIDE, count].max
|
|
74
|
-
|
|
75
|
-
self.notch_width = 1.0 * (self.inside.length) / count
|
|
92
|
+
self.notch_width = 1.0 * length / count
|
|
76
93
|
self.notch_count = count
|
|
77
94
|
end
|
|
78
|
-
|
|
79
95
|
end
|
|
80
96
|
end
|
|
81
97
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
module Notching
|
|
6
|
+
# Alternating iterator
|
|
7
|
+
InfiniteIterator = Struct.new(:array) do
|
|
8
|
+
attr_accessor :array, :next_index, :calls
|
|
9
|
+
|
|
10
|
+
def initialize(array)
|
|
11
|
+
self.array = array
|
|
12
|
+
self.calls = 0
|
|
13
|
+
self.next_index = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def next
|
|
17
|
+
item = array[next_index].clone
|
|
18
|
+
self.next_index += 1
|
|
19
|
+
self.next_index %= array.size
|
|
20
|
+
self.calls += 1
|
|
21
|
+
item = yield item, calls if block_given?
|
|
22
|
+
item
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
module Notching
|
|
6
|
+
class PathGenerator
|
|
7
|
+
extend ::Forwardable
|
|
8
|
+
|
|
9
|
+
%i(center_out thickness corners kerf kerf? notch_width first_notch_out? adjust_corners corners).each do |method_name|
|
|
10
|
+
def_delegator :@edge, method_name, method_name
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_accessor :edge
|
|
14
|
+
|
|
15
|
+
# This class generates lines that zigzag between two lines: the outside line, and the
|
|
16
|
+
# inside line of a single edge. Edge class encapsulates both of them with additional
|
|
17
|
+
# properties.
|
|
18
|
+
def initialize(edge)
|
|
19
|
+
@edge = edge
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Calculates a notched path that flows between the outer edge of the box
|
|
23
|
+
# (outside_line) and inner (inside_line). Relative location of these lines
|
|
24
|
+
# also defines the direction and orientation of the box, and hence the notches.
|
|
25
|
+
#
|
|
26
|
+
# We always want to create a symmetric path that has a notch in the middle
|
|
27
|
+
# (for center_out = true) or dip in the middle (center_out = false)
|
|
28
|
+
def generate
|
|
29
|
+
lines = []
|
|
30
|
+
lines << corner_box_sides if corners
|
|
31
|
+
lines << notch_lines
|
|
32
|
+
lines.flatten
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The zigzag alone, without the corner boxes.
|
|
36
|
+
#
|
|
37
|
+
# @return [Array<Geometry::Line>]
|
|
38
|
+
def notch_lines
|
|
39
|
+
point = starting_point
|
|
40
|
+
vertices = [point]
|
|
41
|
+
adjust_for_kerf(vertices, -1) if adjust_end?(1)
|
|
42
|
+
define_shifts.each do |shift|
|
|
43
|
+
point = shift.next_point_after point
|
|
44
|
+
vertices << point
|
|
45
|
+
end
|
|
46
|
+
adjust_for_kerf(vertices, 1) if adjust_end?(2)
|
|
47
|
+
create_lines(vertices)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def adjust_for_kerf(vertices, direction)
|
|
51
|
+
return unless kerf?
|
|
52
|
+
|
|
53
|
+
point = vertices.pop
|
|
54
|
+
point = point.plus(2 * direction * shift_vector(1))
|
|
55
|
+
vertices << point
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def corner_box_sides
|
|
59
|
+
ends = Edge::ENDS.select { |end_index| edge.corner_at?(end_index) }
|
|
60
|
+
|
|
61
|
+
# These boxes occupy the corners of the 3D box. They do not match
|
|
62
|
+
# in width to our notches because they are usually merged with them.
|
|
63
|
+
# It's just an aesthetic choice I guess.
|
|
64
|
+
boxes = ends.map { |end_index| Geometry::Rect[inside_point(end_index).clone, outside_point(end_index).clone] }
|
|
65
|
+
sides = boxes.map(&:relocate!).map(&:sides)
|
|
66
|
+
sides << ends.map { |end_index| kerf_strip(end_index) } if kerf? && adjust_corners
|
|
67
|
+
sides.flatten
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Widens the corner box at one end by the kerf, on the side where
|
|
71
|
+
# nothing is attached to it: along the edge when the first notch is a
|
|
72
|
+
# hole, across it when the first notch is a tab.
|
|
73
|
+
#
|
|
74
|
+
# @param end_index [Integer] 1 for p1, 2 for p2
|
|
75
|
+
# @return [Array<Geometry::Line>]
|
|
76
|
+
def kerf_strip(end_index)
|
|
77
|
+
inside = inside_point(end_index)
|
|
78
|
+
outside = outside_point(end_index)
|
|
79
|
+
along, across = first_notch_out? ? [outside, inside] : [inside, outside]
|
|
80
|
+
moved = inside.plus(-2 * shift_vector(end_index, first_notch_out? ? 1 : 0))
|
|
81
|
+
|
|
82
|
+
coords = []
|
|
83
|
+
coords[d_index_along] = along[d_index_along]
|
|
84
|
+
coords[d_index_across] = across[d_index_across]
|
|
85
|
+
Geometry::Rect[moved, Geometry::Point[*coords]].sides << Geometry::Line[moved, inside.clone]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def shift_vector(index, dim_shift = 0)
|
|
89
|
+
shift = []
|
|
90
|
+
shift[(d_index_across + dim_shift) % 2] = 0
|
|
91
|
+
shift[(d_index_along + dim_shift) % 2] = kerf / 2.0 * edge.send(:"v#{index}").[]((d_index_along + dim_shift) % 2)
|
|
92
|
+
Vector.[](*shift)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def starting_point
|
|
96
|
+
edge.inside.p1.clone # start
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# 0 = X, 1 = Y
|
|
100
|
+
def d_index_along
|
|
101
|
+
edge.inside.p1.x == edge.inside.p2.x ? 1 : 0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def d_index_across
|
|
105
|
+
(d_index_along + 1) % 2
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def direction_along
|
|
109
|
+
edge.inside.p1.coords.[](d_index_along) < edge.inside.p2.coords.[](d_index_along) ? 1 : -1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def direction_across
|
|
113
|
+
edge.inside.p1.coords.[](d_index_across) < edge.outside.p1.coords.[](d_index_across) ? 1 : -1
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def inside_point(end_index)
|
|
119
|
+
edge.inside.public_send(:"p#{end_index}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def outside_point(end_index)
|
|
123
|
+
edge.outside.public_send(:"p#{end_index}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A corner box next to a hole is widened by the kerf, so the hole starts that much later.
|
|
127
|
+
def adjust_end?(end_index)
|
|
128
|
+
adjust_corners && !first_notch_out? && edge.corner_at?(end_index)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# This method has the bulk of the logic: we create the list of path deltas
|
|
132
|
+
# to be applied when we walk the edge next.
|
|
133
|
+
# @param [Object] shift
|
|
134
|
+
def define_shifts
|
|
135
|
+
along_iter = create_iterator_along
|
|
136
|
+
across_iter = create_iterator_across
|
|
137
|
+
|
|
138
|
+
shifts = []
|
|
139
|
+
inner = true # false when we are drawing outer notch, true when inner
|
|
140
|
+
|
|
141
|
+
if first_notch_out?
|
|
142
|
+
shifts << across_iter.next
|
|
143
|
+
inner = !inner
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
(1..edge.notch_count).to_a.each do |notch_number|
|
|
147
|
+
shifts << along_iter.next do |shift, _index|
|
|
148
|
+
if inner && notch_number > 1 && notch_number < edge.notch_count
|
|
149
|
+
shift.delta -= kerf
|
|
150
|
+
elsif !inner
|
|
151
|
+
shift.delta += kerf
|
|
152
|
+
end
|
|
153
|
+
inner = !inner
|
|
154
|
+
shift
|
|
155
|
+
end
|
|
156
|
+
shifts << across_iter.next unless notch_number == edge.notch_count
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
shifts << across_iter.next if first_notch_out?
|
|
160
|
+
shifts
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# As we draw notches, shifts define the 'delta' – movement from one point
|
|
164
|
+
# to the next. This method defines three types of movements we'll be doing:
|
|
165
|
+
# one alongside the edge, and two across (towards the box and outward from the box)
|
|
166
|
+
def create_iterator_along
|
|
167
|
+
InfiniteIterator.new([Shift.new(notch_width, direction_along, d_index_along)])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def create_iterator_across
|
|
171
|
+
InfiniteIterator.new([Shift.new(thickness, direction_across, d_index_across),
|
|
172
|
+
Shift.new(thickness, -direction_across, d_index_across)])
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def create_lines(vertices)
|
|
176
|
+
lines = []
|
|
177
|
+
vertices.each_with_index do |v, i|
|
|
178
|
+
if v != vertices.last
|
|
179
|
+
lines << Geometry::Line.new(v, vertices[i + 1])
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
lines.flatten
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -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,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
# Everything a box can be asked for, typed. This is what a Ruby program
|
|
6
|
+
# hands to {Laser::Cutter.render} or {Laser::Cutter.write}.
|
|
7
|
+
#
|
|
8
|
+
# Values are coerced, so the Strings of a web form will do, and an
|
|
9
|
+
# instance cannot be changed: #new(lid: :plain) returns a changed copy.
|
|
10
|
+
# Keys may be Strings or Symbols. A key it does not
|
|
11
|
+
# know, a value of the wrong type or a dimension left out all raise,
|
|
12
|
+
# at once, before anything is drawn.
|
|
13
|
+
#
|
|
14
|
+
# The distances left nil take the default for the units: a kerf of
|
|
15
|
+
# 0.0024in, a margin of 0.125in, a padding of 0.1in, a stroke of 0.001in,
|
|
16
|
+
# and a notch three times the thickness.
|
|
17
|
+
#
|
|
18
|
+
# @example A box in millimeters, with a plain lid, as an SVG
|
|
19
|
+
# options = Laser::Cutter::Options.new(width: 70, height: 20, depth: 50, thickness: 4.3,
|
|
20
|
+
# units: :mm, lid: :plain, format: :svg)
|
|
21
|
+
# options.lid # => "plain"
|
|
22
|
+
# Laser::Cutter.render(options) # => "<svg ..."
|
|
23
|
+
#
|
|
24
|
+
# @raise [MissingOption] when width, height, depth or thickness is left out
|
|
25
|
+
# @raise [InvalidOption] when a value cannot be used, or a key is not an option
|
|
26
|
+
class Options < Dry::Struct
|
|
27
|
+
# The dimensions a box cannot be drawn without.
|
|
28
|
+
REQUIRED = %i[width height depth thickness].freeze
|
|
29
|
+
|
|
30
|
+
# The longest list of allowed values an error spells out.
|
|
31
|
+
MAXIMUM_CHOICES = 5
|
|
32
|
+
|
|
33
|
+
transform_keys(&:to_sym)
|
|
34
|
+
schema schema.strict
|
|
35
|
+
|
|
36
|
+
# @!attribute [r] width
|
|
37
|
+
# @return [Float] internal width of the box
|
|
38
|
+
attribute :width, Types::Length
|
|
39
|
+
|
|
40
|
+
# @!attribute [r] height
|
|
41
|
+
# @return [Float] internal height of the box
|
|
42
|
+
attribute :height, Types::Length
|
|
43
|
+
|
|
44
|
+
# @!attribute [r] depth
|
|
45
|
+
# @return [Float] internal depth of the box
|
|
46
|
+
attribute :depth, Types::Length
|
|
47
|
+
|
|
48
|
+
# @!attribute [r] thickness
|
|
49
|
+
# @return [Float] thickness of the material
|
|
50
|
+
attribute :thickness, Types::Length
|
|
51
|
+
|
|
52
|
+
# @!attribute [r] notch
|
|
53
|
+
# @return [Float, nil] notch length, a guide only; nil for three times the thickness
|
|
54
|
+
attribute? :notch, Types.optional(Types::Length)
|
|
55
|
+
|
|
56
|
+
# @!attribute [r] kerf
|
|
57
|
+
# @return [Float, nil] width of the cut; nil for the default
|
|
58
|
+
attribute? :kerf, Types.optional(Types::Allowance)
|
|
59
|
+
|
|
60
|
+
# @!attribute [r] units
|
|
61
|
+
# @return [String] "in" or "mm", the units of every distance here
|
|
62
|
+
attribute :units, Types::Units
|
|
63
|
+
|
|
64
|
+
# @!attribute [r] lid
|
|
65
|
+
# @return [String] "full", "back" or "plain": notched on every side, into the back wall only, or on no side
|
|
66
|
+
attribute :lid, Types::Lid
|
|
67
|
+
|
|
68
|
+
# @!attribute [r] format
|
|
69
|
+
# @return [String, nil] "pdf" or "svg"; nil for a PDF, or for what the extension of the file says
|
|
70
|
+
attribute? :format, Types.optional(Types::Format)
|
|
71
|
+
|
|
72
|
+
# @!attribute [r] file
|
|
73
|
+
# @return [String, nil] the file {Laser::Cutter.write} writes
|
|
74
|
+
attribute? :file, Types.optional(Types::Coercible::String)
|
|
75
|
+
|
|
76
|
+
# @!attribute [r] margin
|
|
77
|
+
# @return [Float, nil] margin from the edge of the page; nil for the default
|
|
78
|
+
attribute? :margin, Types.optional(Types::Allowance)
|
|
79
|
+
|
|
80
|
+
# @!attribute [r] padding
|
|
81
|
+
# @return [Float, nil] space between the panels; nil for the default
|
|
82
|
+
attribute? :padding, Types.optional(Types::Allowance)
|
|
83
|
+
|
|
84
|
+
# @!attribute [r] stroke
|
|
85
|
+
# @return [Float, nil] stroke width of the lines; nil for the default
|
|
86
|
+
attribute? :stroke, Types.optional(Types::Length)
|
|
87
|
+
|
|
88
|
+
# @!attribute [r] page_size
|
|
89
|
+
# @return [String, nil] a page size such as "A4", for a PDF; nil to fit the box
|
|
90
|
+
attribute? :page_size, Types.optional(Types::PageSize)
|
|
91
|
+
|
|
92
|
+
# @!attribute [r] page_layout
|
|
93
|
+
# @return [String] "portrait" or "landscape", for a PDF
|
|
94
|
+
attribute :page_layout, Types::PageLayout
|
|
95
|
+
|
|
96
|
+
# @!attribute [r] metadata
|
|
97
|
+
# @return [Boolean] whether the settings of the box are printed on the page
|
|
98
|
+
attribute :metadata, Types::Params::Bool.default(true)
|
|
99
|
+
|
|
100
|
+
# @!attribute [r] inside_box
|
|
101
|
+
# @return [Boolean] whether the box is also drawn without kerf, in red
|
|
102
|
+
attribute :inside_box, Types::Params::Bool.default(false)
|
|
103
|
+
|
|
104
|
+
class << self
|
|
105
|
+
# @param attributes [Hash, Options] the options, by name
|
|
106
|
+
# @return [Options]
|
|
107
|
+
# @raise [MissingOption, InvalidOption]
|
|
108
|
+
def new(attributes = {}, *)
|
|
109
|
+
return attributes if attributes.is_a?(self)
|
|
110
|
+
|
|
111
|
+
require_dimensions!(attributes) if attributes.respond_to?(:to_h)
|
|
112
|
+
super
|
|
113
|
+
rescue Dry::Struct::Error => e
|
|
114
|
+
raise InvalidOption, explain(e)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def require_dimensions!(attributes)
|
|
120
|
+
given = attributes.to_h.transform_keys(&:to_sym)
|
|
121
|
+
missing = REQUIRED.select { |name| Types.blank?(given[name]) }
|
|
122
|
+
return if missing.empty?
|
|
123
|
+
|
|
124
|
+
raise MissingOption, "#{missing.join(', ')} #{missing.size > 1 ? 'are' : 'is'} required, but missing."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Says which option was refused, and what it may be when that is a short list.
|
|
128
|
+
def explain(error)
|
|
129
|
+
cause = error.cause
|
|
130
|
+
return error.message.sub(/\A\[.*?\] /, '') unless cause.respond_to?(:key) && cause.respond_to?(:value)
|
|
131
|
+
|
|
132
|
+
choices = choices_of(schema.key(cause.key).type)
|
|
133
|
+
allowed = choices && choices.size <= MAXIMUM_CHOICES ? ", but must be one of: #{choices.join(', ')}" : ''
|
|
134
|
+
"#{cause.key} cannot be #{cause.value.inspect}#{allowed}."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The words an attribute may be, found under whatever wraps its type.
|
|
138
|
+
#
|
|
139
|
+
# @return [Array<String>, nil] nil when the attribute is not one of a list
|
|
140
|
+
def choices_of(type)
|
|
141
|
+
return type.values if type.respond_to?(:values)
|
|
142
|
+
|
|
143
|
+
inner = %i[right type].find { |name| type.respond_to?(name) }
|
|
144
|
+
choices_of(type.public_send(inner)) if inner
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @return [Configuration] the same settings, as the renderers take them
|
|
149
|
+
def to_configuration
|
|
150
|
+
Configuration.new(to_h.except(:format, :inside_box).merge(debug: inside_box))
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
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
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Laser
|
|
2
4
|
module Cutter
|
|
3
5
|
module Renderer
|
|
@@ -16,15 +18,20 @@ module Laser
|
|
|
16
18
|
self.page_manager = Laser::Cutter::PageManager.new(config.units)
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
# Writes the document to config.file, yielding after each line drawn.
|
|
22
|
+
def render(&)
|
|
23
|
+
File.binwrite(config.file, document(&))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String] the document, yielding after each line drawn
|
|
27
|
+
def document
|
|
20
28
|
raise 'Abstract method'
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
def units
|
|
24
|
-
config.units.to_sym
|
|
32
|
+
config.units.to_sym
|
|
25
33
|
end
|
|
26
34
|
end
|
|
27
35
|
end
|
|
28
36
|
end
|
|
29
37
|
end
|
|
30
|
-
|
|
@@ -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
|
+
# @return [String] the PDF, yielding after each line drawn
|
|
22
|
+
def document(&)
|
|
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
|
|
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
|