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
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ # Merges the lines of one face into the outline to cut.
6
+ #
7
+ # Each edge draws its own notch path and its own corner boxes, so where
8
+ # two of them meet the same stretch is drawn twice, once from each side.
9
+ # That stretch runs through the material and must not be cut. So every
10
+ # collinear group of lines is combined as a symmetric difference: a point
11
+ # is cut when an odd number of lines cover it. Two identical lines cancel,
12
+ # two overlapping lines keep only the parts they do not share, and lines
13
+ # that touch or overlap end to end join into one.
14
+ #
15
+ # Lines are grouped by axis and offset, sorted, and swept once, so a face
16
+ # of n lines takes n log n. Lines on neither axis pass through untouched.
17
+ class Aggregator
18
+ # Coordinates this close together count as the same.
19
+ TOLERANCE = Math.sqrt(Geometry::Tuple::PRECISION)
20
+
21
+ # @return [Array<Geometry::Line>] the outline, sorted
22
+ attr_reader :lines
23
+
24
+ # @param lines [Array<Geometry::Line>]
25
+ def initialize(lines = [])
26
+ @lines = (sweep(lines, 0) + sweep(lines, 1) + skewed(lines)).sort
27
+ end
28
+
29
+ private
30
+
31
+ # Lines running along one axis: 0 for horizontal (constant y), 1 for vertical (constant x).
32
+ #
33
+ # @return [Array<Geometry::Line>]
34
+ def sweep(lines, axis)
35
+ across = (axis + 1) % 2
36
+ along = lines.select { |line| along?(line, axis) }
37
+ groups(along, across).flat_map { |offset, group| cut_intervals(group, axis, offset, across) }
38
+ end
39
+
40
+ def skewed(lines)
41
+ lines.reject { |line| along?(line, 0) || along?(line, 1) }
42
+ end
43
+
44
+ def along?(line, axis)
45
+ across = (axis + 1) % 2
46
+ (line.p1[across] - line.p2[across]).abs <= TOLERANCE && (line.p1[axis] - line.p2[axis]).abs > TOLERANCE
47
+ end
48
+
49
+ # Lines on the same row or column, keyed by the offset of the first one seen.
50
+ def groups(lines, across)
51
+ lines.sort_by { |line| line.p1[across] }.slice_when { |a, b| b.p1[across] - a.p1[across] > TOLERANCE }
52
+ .to_h { |group| [group.first.p1[across], group] }
53
+ end
54
+
55
+ # Sweeps the endpoints of one collinear group, keeping the stretches an
56
+ # odd number of lines cover.
57
+ def cut_intervals(group, axis, offset, across)
58
+ events = group.flat_map do |line|
59
+ from, to = [line.p1[axis], line.p2[axis]].sort
60
+ [[from, 1], [to, -1]]
61
+ end
62
+ events.sort_by! { |position, step| [position, step] }
63
+
64
+ intervals = []
65
+ covered = 0
66
+ started = nil
67
+ events.each do |position, step|
68
+ was = covered
69
+ covered += step
70
+ if was.even? && covered.odd?
71
+ started = position
72
+ elsif was.odd? && covered.even?
73
+ extend_or_add(intervals, started, position)
74
+ end
75
+ end
76
+ intervals.map { |from, to| line_between(from, to, axis, offset, across) }
77
+ end
78
+
79
+ # Joins an interval onto the last one when they touch, and drops one with no length.
80
+ def extend_or_add(intervals, from, to)
81
+ return if to - from <= TOLERANCE
82
+
83
+ last = intervals.last
84
+ if last && from - last[1] <= TOLERANCE
85
+ last[1] = to
86
+ else
87
+ intervals << [from, to]
88
+ end
89
+ end
90
+
91
+ def line_between(from, to, axis, offset, across)
92
+ a = []
93
+ b = []
94
+ a[axis] = from
95
+ b[axis] = to
96
+ a[across] = b[across] = offset
97
+ Geometry::Line.new(Geometry::Point[*a], Geometry::Point[*b])
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ # Note: this class badly needs refactoring and tests. Both are coming.
6
+
7
+ class Box
8
+ # How the lid, the top panel, joins the walls: notched on all four sides,
9
+ # on the side of the back wall only, or on none.
10
+ LIDS = %i[full back plain].freeze
11
+
12
+ # The side of each wall that meets the lid, as an index into Rect#sides.
13
+ LID_SIDES = { 'front' => 0, 'left' => 0, 'right' => 0, 'back' => 2 }.freeze
14
+
15
+ # Everything is in millimeters
16
+
17
+ attr_accessor :dim, :thickness, :notch_width, :kerf, :padding, :units, :inside_box, :front, :back, :top, :bottom, :left, :right, :faces, :bounds, :conf, :corner_face, :metadata, :notches
18
+
19
+ # @return [Symbol] one of LIDS
20
+ attr_accessor :lid
21
+
22
+ # @return [Hash{String => Array<Geometry::Line>}] the lines to cut for each face, by its name
23
+ attr_accessor :outlines
24
+
25
+ def initialize(config = {})
26
+ self.dim = Geometry::Dimensions.new(config['width'], config['height'], config['depth'])
27
+ self.thickness = config['thickness']
28
+
29
+ self.notch_width = config['notch'] || (1.0 * longest / 5.0)
30
+ self.kerf = config['kerf'] || 0.0
31
+ self.padding = config['padding']
32
+ self.units = config['units']
33
+ self.inside_box = config['inside_box']
34
+ self.lid = (config['lid'] || LIDS.first).to_sym
35
+
36
+ self.notches = []
37
+ self.outlines = {}
38
+
39
+ self.metadata = Geometry::Point[config['metadata_width'] || 0, config['metadata_height'] || 0]
40
+
41
+ create_faces! # generates dimensions for each side
42
+ self.faces = [top, front, bottom, back, left, right]
43
+
44
+ self.conf = {
45
+ valign: [:out, :out, :out, :out, :in, :in],
46
+ halign: [:in, :out, :in, :out, :in, :in],
47
+ corners: {
48
+ front: [:no, :yes, :no, :yes, :no, :no], # our default choice, but may not work
49
+ top: [:yes, :no, :yes, :no, :no, :no] # 2nd choice, has to work if 1st doesn't
50
+ },
51
+ }
52
+ end
53
+
54
+ def enclosure
55
+ generate_notches if notches.empty?
56
+ p1 = notches.first.p1.to_a
57
+ p2 = notches.first.p2.to_a
58
+
59
+ notches.each do |notch|
60
+ n = notch.normalized
61
+ n.p1.to_a.each_with_index { |c, i| p1[i] = c if c < p1[i] }
62
+ n.p2.to_a.each_with_index { |c, i| p2[i] = c if c > p2[i] }
63
+ end
64
+
65
+ Geometry::Rect[Geometry::Point.new(p1), Geometry::Point.new(p2)]
66
+ end
67
+
68
+ def generate_notches
69
+ position_faces!
70
+ corner_face = pick_corners_face
71
+ self.notches = []
72
+ self.outlines = {}
73
+ faces.each_with_index do |face, face_index|
74
+ edges = edges_of(face, face_index, corner_face)
75
+
76
+ if edges.any?(&:corners) && !edges.all?(&:first_notch_out?)
77
+ edges.each { |e| e.adjust_corners = true }
78
+ end
79
+
80
+ outlines[face.name] = Aggregator.new(lines_of(face, edges)).lines
81
+ notches << outlines[face.name]
82
+ end
83
+ notches.flatten!
84
+ end
85
+
86
+ def w; dim.w; end
87
+ def h; dim.h; end
88
+ def d; dim.d; end
89
+
90
+ def longest
91
+ [w, h, d].max
92
+ end
93
+
94
+ def to_s
95
+ "Box:\nH:#{dim.h} W:#{dim.w} D:#{dim.d}\nThickness:#{thickness}, Notch:#{notch_width}"
96
+ end
97
+
98
+ private
99
+
100
+ # One edge for each side of a face, pairing the side with the matching
101
+ # side of the face grown by the thickness.
102
+ #
103
+ # @return [Array<Notching::Edge>]
104
+ def edges_of(face, face_index, corner_face)
105
+ bound = face_bounding_rect(face)
106
+ bound.sides.each_with_index.map do |bounding_side, side_index|
107
+ include_corners = conf[:corners][corner_face][face_index] == :yes && side_index.odd?
108
+ key = side_index.odd? ? :valign : :halign
109
+ Notching::Edge.new(bounding_side,
110
+ face.sides[side_index],
111
+ { notch_width: notch_width,
112
+ thickness: thickness,
113
+ kerf: kerf,
114
+ center_out: conf[key][face_index] == :out,
115
+ corners: include_corners,
116
+ corner_ends: corner_ends(face, side_index) })
117
+ end
118
+ end
119
+
120
+ # A lid that lifts off covers the corners above the walls, so a wall
121
+ # keeps no corner box at the end of a side that touches the lid.
122
+ #
123
+ # @return [Array<Integer>] the ends of the side that may carry a corner box
124
+ def corner_ends(face, side_index)
125
+ lid_side = LID_SIDES[face.name]
126
+ return Notching::Edge::ENDS if lid == :full || lid_side.nil?
127
+
128
+ case (side_index - lid_side) % 4
129
+ when 1 then [2]
130
+ when 3 then [1]
131
+ else Notching::Edge::ENDS
132
+ end
133
+ end
134
+
135
+ # @return [Array<Geometry::Line>] the lines of a face before they are merged
136
+ def lines_of(face, edges)
137
+ return lid_lines(edges) if face.equal?(top)
138
+
139
+ edges.each_with_index.flat_map do |edge, side_index|
140
+ straight?(face, side_index) ? [edge.inside] : Notching::PathGenerator.new(edge).generate
141
+ end
142
+ end
143
+
144
+ # Whether a side of a wall lies under a lid edge that has no notches.
145
+ def straight?(face, side_index)
146
+ return false if lid == :full || (lid == :back && face.equal?(back))
147
+
148
+ LID_SIDES[face.name] == side_index
149
+ end
150
+
151
+ def lid_lines(edges)
152
+ case lid
153
+ when :plain then edges.map(&:outside)
154
+ when :back then back_lid_lines(edges)
155
+ else edges.flat_map { |edge| Notching::PathGenerator.new(edge).generate }
156
+ end
157
+ end
158
+
159
+ # The lid notched into the back wall only. Its other three edges are
160
+ # straight, along the outside of the walls. Either side of the notches
161
+ # it has a foot, the corner square above the side wall.
162
+ def back_lid_lines(edges)
163
+ joint = edges.first
164
+ joint.corners = true
165
+ joint.adjust_corners = true
166
+ path = Notching::PathGenerator.new(joint).notch_lines
167
+
168
+ path + foot(joint.outside.p1, path.first.p1) + foot(joint.outside.p2, path.last.p2) + edges.drop(1).map(&:outside)
169
+ end
170
+
171
+ # The two sides of a foot the notches do not draw: its bottom, from the
172
+ # outer corner of the lid, and the side that faces the first notch.
173
+ def foot(corner, notch)
174
+ turn = Geometry::Point[notch.x, corner.y]
175
+ [Geometry::Line[corner, turn], Geometry::Line[turn, notch]]
176
+ end
177
+
178
+ def face_bounding_rect(face)
179
+ b = face.clone
180
+ b.move_to(b.position.plus(-thickness, -thickness))
181
+ b.p2 = b.p2.plus(2 * thickness, 2 * thickness)
182
+ b.relocate!
183
+ end
184
+
185
+ # ___________________________________________________________________
186
+ #
187
+ # +-----------------+
188
+ # | |
189
+ # | back: W x H |
190
+ # | |
191
+ # +-----------------+
192
+ # +-----------------+
193
+ # | bottom: W x D |
194
+ # +-----------------+
195
+ # +--------+ +-----------------+ +--------+
196
+ # | | | | | |
197
+ # | left | | front: W x H | | right |
198
+ # | D x H | | | | D x H |
199
+ # +--------+ X-----------------+ +--------+
200
+ # +-----------------+
201
+ # | top : W x D |
202
+ # +-----------------+
203
+ #
204
+ # 0,0
205
+ # ___________________________________________________________________
206
+
207
+ def position_faces!
208
+ offset_x = [padding + d + (3 * thickness), metadata.x + (2 * thickness) + padding].max
209
+ offset_y = [padding + d + (3 * thickness), metadata.y + (2 * thickness) + padding].max
210
+
211
+ # X Coordinate
212
+ left.x = offset_x - d - (2 * thickness) - padding
213
+ right.x = offset_x + w + (2 * thickness) + padding
214
+
215
+ [bottom, front, top, back].each { |s| s.x = offset_x }
216
+
217
+ # Y Coordinate
218
+ top.y = offset_y - d - (2 * thickness) - padding
219
+ bottom.y = offset_y + h + (2 * thickness) + padding
220
+ back.y = bottom.y + d + (2 * thickness) + padding
221
+
222
+ [left, front, right].each { |s| s.y = offset_y }
223
+
224
+ faces.each(&:relocate!)
225
+ end
226
+
227
+ def create_faces!
228
+ zero = Geometry::Point.new(0, 0)
229
+ self.front = Geometry::Rect.create(zero, dim.w, dim.h, "front")
230
+ self.back = Geometry::Rect.create(zero, dim.w, dim.h, "back")
231
+
232
+ self.top = Geometry::Rect.create(zero, dim.w, dim.d, "top")
233
+ self.bottom = Geometry::Rect.create(zero, dim.w, dim.d, "bottom")
234
+
235
+ self.left = Geometry::Rect.create(zero, dim.d, dim.h, "left")
236
+ self.right = Geometry::Rect.create(zero, dim.d, dim.h, "right")
237
+ end
238
+
239
+ # Choose which face will be responsible for filling out the little square overlap
240
+ # in the corners. Only one of the 3 possible sides need to be picked.
241
+ def pick_corners_face
242
+ b = face_bounding_rect(front)
243
+ edges = []
244
+ front.sides[0..1].each_with_index do |face, index|
245
+ edges << Notching::Edge.new(b.sides[index], face, notch_width: notch_width, kerf: kerf )
246
+ end
247
+ edges.map(&:notch_count).all?{ |c| c % 4 == 3 } ? :top : :front
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ module CLI
6
+ # What every command inherits: the ui helpers, and the flags they all share.
7
+ class Command < Dry::CLI::Command
8
+ include Dry::CLI::UI
9
+
10
+ # Cells in a progress bar.
11
+ BAR_WIDTH = 60
12
+
13
+ def self.inherited(subclass)
14
+ super
15
+ subclass.option :verbose, type: :boolean, default: false, aliases: ['-v'],
16
+ desc: 'Print the configuration, and the full backtrace on failure'
17
+ end
18
+
19
+ # Boxes are as wide as help is.
20
+ def ui
21
+ @ui ||= Dry::CLI::UI::Console.new(out: out, err: err, box_width: CLI.help_width)
22
+ end
23
+
24
+ # A green bar of BAR_WIDTH cells, or fewer when the terminal is narrower.
25
+ #
26
+ # @param label [String]
27
+ # @param total [Integer]
28
+ # @yieldparam bar [#advance]
29
+ def progress(label, total:, &)
30
+ Dry::CLI::UI::Console
31
+ .new(out: out, err: err, box_width: CLI.help_width, width: progress_width(label))
32
+ .progress(label, total: total, color: :green, &)
33
+ end
34
+
35
+ private
36
+
37
+ # dry-cli-ui sizes a bar as the terminal width less the label and the
38
+ # counters around it, so the bar is capped by capping that width.
39
+ def progress_width(label)
40
+ wanted = BAR_WIDTH + label.length + Dry::CLI::UI::Widgets::Progress::CHROME
41
+ err.respond_to?(:tty?) && err.tty? ? [wanted, TTY::Screen.width].min : wanted
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ module CLI
6
+ # Dry::CLI::Autocomplete::Command[] builds an anonymous subclass, which
7
+ # loses the description and the examples. This one states its own.
8
+ class Completion < Dry::CLI::Autocomplete::Command
9
+ @registry = CLI
10
+ @program_name = PROGRAM
11
+
12
+ desc 'Generates auto-complete for BASH or ZSH'
13
+
14
+ example [
15
+ "bash > /usr/local/etc/bash_completion.d/#{PROGRAM}",
16
+ "zsh > \"${fpath[1]}/_#{PROGRAM}\""
17
+ ]
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ module CLI
6
+ # A configuration saved as JSON. The path '-' stands for STDIN when
7
+ # reading, and for the command's own output when writing.
8
+ class ConfigFile
9
+ STDIO = '-'
10
+
11
+ # @param path [String]
12
+ def initialize(path)
13
+ @path = path
14
+ end
15
+
16
+ # @return [Hash{String => Object}]
17
+ # @raise [Error] when the file is missing, or is not JSON
18
+ def read
19
+ JSON.parse(@path == STDIO ? $stdin.read : File.read(@path))
20
+ rescue SystemCallError, JSON::ParserError => e
21
+ raise Error, "cannot read the configuration from #{@path}: #{e.message}"
22
+ end
23
+
24
+ # @param settings [Hash]
25
+ # @param out [IO] where '-' writes to
26
+ # @raise [Error] when the file cannot be written
27
+ def write(settings, out)
28
+ json = JSON.pretty_generate(settings)
29
+ @path == STDIO ? out.puts(json) : File.write(@path, "#{json}\n")
30
+ rescue SystemCallError => e
31
+ raise Error, "cannot write the configuration to #{@path}: #{e.message}"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ module CLI
6
+ class Examples < Command
7
+ desc 'Show detailed usage examples'
8
+
9
+ TEXT = <<~EXAMPLES
10
+ 1. A box in inches, with the kerf set to 0.008", opened once it is written:
11
+
12
+ laser-cutter generate -b 3x2x2/0.125 -k 0.008 -O -o box.pdf
13
+
14
+ 2. A box in millimeters on a landscape A3 page, with a 0.5mm stroke:
15
+
16
+ laser-cutter generate -u mm -w 70 -H 20 -d 50 -t 4.3 -n 5 -i A3 -l landscape -s 0.5 -o box.pdf
17
+
18
+ 3. The same box as an SVG:
19
+
20
+ laser-cutter generate -b 3x2x2/0.125 -f svg -o box.svg
21
+
22
+ 4. Every page size, in millimeters:
23
+
24
+ laser-cutter page-sizes -u mm
25
+
26
+ 5. A box whose settings are saved for later:
27
+
28
+ laser-cutter generate -b 1.1x2.5x1.5/0.125/0.125 -p 0.1 -o box.pdf -W box-settings.json
29
+
30
+ 6. A box from settings saved earlier, from a file or from STDIN:
31
+
32
+ laser-cutter generate -o box.pdf -R box-settings.json
33
+ cat box-settings.json | laser-cutter generate -o box.pdf -R -
34
+
35
+ 7. A box with a lid that lifts off: a plain rectangle, or one notched into the back wall only:
36
+
37
+ laser-cutter generate -b 3x2x2/0.125 --lid plain -o box.pdf
38
+ laser-cutter generate -b 3x2x2/0.125 --lid back -o box.pdf
39
+ EXAMPLES
40
+
41
+ def call(**)
42
+ out.puts TEXT
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ module CLI
6
+ class Generate < Command
7
+ desc 'Draw the panels of a box into a PDF or an SVG file'
8
+
9
+ BANNER = "Laser-Cutter (ruby gem) Version #{Laser::Cutter::VERSION}, © 2015-2026 Konstantin Gredeskoul".freeze
10
+
11
+ # Columns a box spends on its borders and padding.
12
+ BOX_CHROME = 6
13
+
14
+ # What the opening box reports, in this order.
15
+ DIMENSIONS = %i[width height depth thickness notch kerf].freeze
16
+
17
+ # Options that describe the run rather than the box, kept out of a saved configuration.
18
+ RUN_ONLY = %i[verbose read write open format args].freeze
19
+
20
+ option :box, aliases: ['-b'], desc: 'WxHxD/T[/N]: width, height, depth, thickness and optional notch, in one'
21
+ option :width, aliases: ['-w'], desc: 'Internal width of the box'
22
+ option :height, aliases: ['-H'], desc: 'Internal height of the box'
23
+ option :depth, aliases: ['-d'], desc: 'Internal depth of the box'
24
+ option :thickness, aliases: ['-t'], desc: 'Thickness of the material'
25
+ option :notch, aliases: ['-n'], desc: 'Notch length, a guide only (default: three times the thickness)'
26
+ option :kerf, aliases: ['-k'], desc: 'Kerf, the width of the cut (default: 0.0024in)'
27
+ # No default here: one would override the lid a saved configuration asks for.
28
+ option :lid, values: %w[full back plain], aliases: ['-L'],
29
+ desc: 'Lid: notched on every side, into the back wall only, or on no side (default: full)'
30
+ option :units, default: 'in', values: %w[in mm], aliases: ['-u'], desc: 'Units every dimension is in'
31
+
32
+ option :file, aliases: ['-o'], desc: 'File to write (required)'
33
+ option :format, default: 'pdf', aliases: ['-f'], desc: 'Output format: pdf or svg, in either case'
34
+
35
+ option :margin, aliases: ['-m'], desc: 'Margin from the edge of the page'
36
+ option :padding, aliases: ['-p'], desc: 'Space between the panels'
37
+ option :stroke, aliases: ['-s'], desc: 'Stroke width of the lines'
38
+ option :page_size, aliases: ['-i'], desc: 'Page size, such as LETTER or A3 (default: fit the box; PDF only)'
39
+ option :page_layout, default: 'portrait', values: %w[portrait landscape], aliases: ['-l'], desc: 'Page layout (PDF only)'
40
+
41
+ option :metadata, type: :boolean, default: true, aliases: ['-M'], desc: 'Print the settings of the box on the page'
42
+ option :inside_box, type: :boolean, default: false, aliases: ['-B'],
43
+ desc: 'Also draw the box without kerf, in red, to check the kerf'
44
+ option :open, type: :boolean, default: false, aliases: ['-O'], desc: 'Open the file once it is written'
45
+ option :write, aliases: ['-W'], desc: "Save the configuration to a file, or to STDOUT with '-'"
46
+ option :read, aliases: ['-R'], desc: "Read the configuration from a file, or from STDIN with '-'"
47
+
48
+ example [
49
+ '-b 3x2x2/0.125 -o box.pdf # a box in inches',
50
+ '-b 3x2x2/0.125 -f svg -o box.svg # as an SVG',
51
+ '-b 3x2x2/0.125 --lid plain -o box.pdf # with a lid that lifts off',
52
+ '-u mm -w 70 -H 20 -d 50 -t 4.3 -o box.pdf'
53
+ ]
54
+
55
+ def call(format:, verbose:, **options)
56
+ config = configuration(options)
57
+ ui.debug('Configuration:', JSON.pretty_generate(config.to_hash)) if verbose
58
+ config.validate!
59
+ renderer = Renderer.for(format, config)
60
+ ui.info(BANNER, dimensions(config), "Format: #{format.upcase}")
61
+ ConfigFile.new(options[:write]).write(config.to_hash, out) if options[:write]
62
+
63
+ progress("Drawing #{File.basename(config.file)}", total: renderer.total) do |bar|
64
+ renderer.render { bar.advance }
65
+ end
66
+
67
+ report(format, File.expand_path(config.file))
68
+ system('open', config.file) if options[:open]
69
+ end
70
+
71
+ private
72
+
73
+ # A box wraps in the middle of a word, which would break a path nobody
74
+ # could then copy, so this one widens to fit a path longer than it.
75
+ def report(format, path)
76
+ ui.success("Generated #{format.upcase} file:", path, width: [CLI.help_width, path.length + BOX_CHROME].max)
77
+ end
78
+
79
+ # What was read from a file, overridden by what the command line gave.
80
+ def configuration(options)
81
+ given = options.except(*RUN_ONLY).compact
82
+ saved = options[:read] ? ConfigFile.new(options[:read]).read : {}
83
+ settings = saved.transform_keys(&:to_sym).merge(given)
84
+ Configuration.new(settings.merge(debug: settings.delete(:inside_box)))
85
+ end
86
+
87
+ # One line per dimension, aligned, in the units of the box, then the lid.
88
+ def dimensions(config)
89
+ rows = DIMENSIONS.map { |name| [name, "#{config[name]} #{config.units}"] } << [:lid, config.lid]
90
+ rows.map { |name, value| "#{"#{name.capitalize}:".ljust(11)} #{value}" }.join("\n")
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ module CLI
6
+ class Help < Command
7
+ desc 'Show help, for the program or for one command'
8
+
9
+ argument :command, required: false, desc: 'The command to explain'
10
+
11
+ example ['', 'generate']
12
+
13
+ # Asks dry-cli for the help it prints on --help, which exits by itself.
14
+ def call(command: nil, **)
15
+ Dry::CLI.new(CLI).call(arguments: [command, '--help'].compact, out: out, err: err)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ module CLI
6
+ class PageSizes < Command
7
+ desc 'List every page size, with its dimensions'
8
+
9
+ option :units, default: 'in', values: %w[in mm], aliases: ['-u'], desc: 'Units to print the dimensions in'
10
+
11
+ example ['', '--units mm']
12
+
13
+ def call(units:, **)
14
+ out.puts PageManager.new(units).all_page_sizes
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ module CLI
6
+ class Version < Command
7
+ desc 'Print the version'
8
+
9
+ def call(**)
10
+ out.puts Laser::Cutter::VERSION
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end