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.
Files changed (119) hide show
  1. checksums.yaml +5 -5
  2. data/.envrc +2 -0
  3. data/.github/workflows/lint.yml +22 -0
  4. data/.github/workflows/rspec.yml +22 -0
  5. data/.gitignore +5 -5
  6. data/.plans/001.00-dry-cli-migration/plan.md +29 -0
  7. data/.plans/002.00-lid-options/plan.md +45 -0
  8. data/.plans/003.00-ruby-api/plan.md +37 -0
  9. data/.relaxed_rubocop.yml +153 -0
  10. data/.rspec +1 -0
  11. data/.rubocop.yml +31 -0
  12. data/.rubocop_todo.yml +90 -0
  13. data/.ruby-version +1 -0
  14. data/CLAUDE.md +75 -0
  15. data/CONTRIBUTING.md +2 -4
  16. data/Gemfile +14 -0
  17. data/README.md +207 -79
  18. data/Rakefile +27 -13
  19. data/docs/badges/coverage_badge.svg +21 -0
  20. data/docs/images/box-lid-back.avif +0 -0
  21. data/docs/images/box-lid-full.avif +0 -0
  22. data/docs/images/box-lid-plain.avif +0 -0
  23. data/docs/images/box-metric.avif +0 -0
  24. data/docs/images/box-tray.avif +0 -0
  25. data/exe/laser-cutter +8 -0
  26. data/exe/lc +1 -0
  27. data/justfile +97 -0
  28. data/laser-cutter.gemspec +27 -14
  29. data/lib/laser/cutter/aggregator.rb +101 -0
  30. data/lib/laser/cutter/box.rb +251 -0
  31. data/lib/laser/cutter/cli/command.rb +46 -0
  32. data/lib/laser/cutter/cli/completion.rb +21 -0
  33. data/lib/laser/cutter/cli/config_file.rb +36 -0
  34. data/lib/laser/cutter/cli/examples.rb +47 -0
  35. data/lib/laser/cutter/cli/generate.rb +95 -0
  36. data/lib/laser/cutter/cli/help.rb +20 -0
  37. data/lib/laser/cutter/cli/page_sizes.rb +19 -0
  38. data/lib/laser/cutter/cli/version.rb +15 -0
  39. data/lib/laser/cutter/cli.rb +49 -0
  40. data/lib/laser/cutter/configuration.rb +107 -0
  41. data/lib/{laser-cutter → laser/cutter}/geometry/dimensions.rb +2 -3
  42. data/lib/{laser-cutter/geometry/shape → laser/cutter/geometry}/line.rb +19 -18
  43. data/lib/{laser-cutter → laser/cutter}/geometry/point.rb +4 -2
  44. data/lib/{laser-cutter/geometry/shape → laser/cutter/geometry}/rect.rb +6 -7
  45. data/lib/{laser-cutter → laser/cutter}/geometry/shape.rb +7 -7
  46. data/lib/{laser-cutter → laser/cutter}/geometry/tuple.rb +40 -36
  47. data/lib/laser/cutter/invalid_option.rb +8 -0
  48. data/lib/laser/cutter/launcher.rb +40 -0
  49. data/lib/laser/cutter/missing_option.rb +7 -0
  50. data/lib/laser/cutter/notching/base.rb +19 -0
  51. data/lib/{laser-cutter → laser/cutter}/notching/edge.rb +36 -20
  52. data/lib/laser/cutter/notching/infinite_iterator.rb +27 -0
  53. data/lib/laser/cutter/notching/path_generator.rb +187 -0
  54. data/lib/laser/cutter/notching/shift.rb +17 -0
  55. data/lib/laser/cutter/options.rb +154 -0
  56. data/lib/laser/cutter/page_manager.rb +50 -0
  57. data/lib/{laser-cutter → laser/cutter}/renderer/base.rb +10 -3
  58. data/lib/laser/cutter/renderer/box_renderer.rb +40 -0
  59. data/lib/laser/cutter/renderer/layout_renderer.rb +61 -0
  60. data/lib/laser/cutter/renderer/line_renderer.rb +21 -0
  61. data/lib/laser/cutter/renderer/meta_renderer.rb +83 -0
  62. data/lib/{laser-cutter → laser/cutter}/renderer/rect_renderer.rb +3 -1
  63. data/lib/laser/cutter/renderer/svg_renderer.rb +65 -0
  64. data/lib/laser/cutter/renderer.rb +24 -0
  65. data/lib/laser/cutter/types.rb +57 -0
  66. data/lib/laser/cutter/units_converter.rb +15 -0
  67. data/lib/laser/cutter/version.rb +7 -0
  68. data/lib/laser/cutter/zero_value_not_allowed.rb +7 -0
  69. data/lib/laser/cutter.rb +67 -0
  70. data/lib/laser-cutter.rb +2 -14
  71. data/lib/laser_cutter.rb +3 -0
  72. data/spec/laser/cutter/aggregator_spec.rb +70 -0
  73. data/spec/laser/cutter/box_lid_spec.rb +142 -0
  74. data/spec/laser/cutter/box_spec.rb +61 -0
  75. data/spec/laser/cutter/cli/config_file_spec.rb +31 -0
  76. data/spec/laser/cutter/cli_spec.rb +213 -0
  77. data/spec/laser/cutter/configuration_spec.rb +118 -0
  78. data/spec/{dimensions_spec.rb → laser/cutter/dimensions_spec.rb} +8 -6
  79. data/spec/laser/cutter/edge_spec.rb +64 -0
  80. data/spec/{line_spec.rb → laser/cutter/line_spec.rb} +26 -20
  81. data/spec/laser/cutter/options_spec.rb +120 -0
  82. data/spec/{page_manager_spec.rb → laser/cutter/page_manager_spec.rb} +27 -11
  83. data/spec/{path_generator_spec.rb → laser/cutter/path_generator_spec.rb} +18 -14
  84. data/spec/{point_spec.rb → laser/cutter/point_spec.rb} +27 -22
  85. data/spec/{rect_spec.rb → laser/cutter/rect_spec.rb} +9 -5
  86. data/spec/laser/cutter/renderer/layout_renderer_spec.rb +85 -0
  87. data/spec/laser/cutter/renderer/svg_renderer_spec.rb +51 -0
  88. data/spec/laser/cutter/renderer_spec.rb +19 -0
  89. data/spec/laser/cutter_spec.rb +114 -0
  90. data/spec/spec_helper.rb +35 -22
  91. data/spec/support/aruba.rb +10 -0
  92. data/spec/support/outline.rb +53 -0
  93. metadata +230 -76
  94. data/.travis.yml +0 -26
  95. data/BOXMAKER.md +0 -51
  96. data/LICENSE +0 -22
  97. data/bin/laser-cutter +0 -19
  98. data/lib/laser-cutter/aggregator.rb +0 -57
  99. data/lib/laser-cutter/box.rb +0 -183
  100. data/lib/laser-cutter/cli/opt_parser.rb +0 -131
  101. data/lib/laser-cutter/cli/serializer.rb +0 -51
  102. data/lib/laser-cutter/configuration.rb +0 -83
  103. data/lib/laser-cutter/face.rb +0 -18
  104. data/lib/laser-cutter/geometry.rb +0 -11
  105. data/lib/laser-cutter/notching/path_generator.rb +0 -234
  106. data/lib/laser-cutter/notching.rb +0 -9
  107. data/lib/laser-cutter/page_manager.rb +0 -43
  108. data/lib/laser-cutter/renderer/box_renderer.rb +0 -36
  109. data/lib/laser-cutter/renderer/layout_renderer.rb +0 -59
  110. data/lib/laser-cutter/renderer/line_renderer.rb +0 -17
  111. data/lib/laser-cutter/renderer/meta_renderer.rb +0 -77
  112. data/lib/laser-cutter/renderer.rb +0 -14
  113. data/lib/laser-cutter/version.rb +0 -5
  114. data/spec/aggregator_spec.rb +0 -65
  115. data/spec/box_spec.rb +0 -39
  116. data/spec/configuration_spec.rb +0 -81
  117. data/spec/edge_spec.rb +0 -43
  118. data/spec/renderer_spec.rb +0 -72
  119. /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.[](*self.v1)
26
- self.v2 = Vector.[](*self.v2)
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
- if kerf?
41
- self.inside = move_line_for_kerf(inside)
42
- self.outside = move_line_for_kerf(outside)
43
- end
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 line
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
- self.kerf > 0.0
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
- count = ((self.inside.length) / notch_width).to_f.ceil + 1
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
- def render
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 || :mm
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