laser-cutter 1.0.3 → 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.
Files changed (102) 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 -4
  6. data/.plans/001.00-dry-cli-migration/plan.md +29 -0
  7. data/.relaxed_rubocop.yml +153 -0
  8. data/.rspec +1 -1
  9. data/.rubocop.yml +31 -0
  10. data/.rubocop_todo.yml +90 -0
  11. data/.ruby-version +1 -0
  12. data/CLAUDE.md +62 -0
  13. data/CONTRIBUTING.md +2 -4
  14. data/Gemfile +14 -0
  15. data/README.md +83 -111
  16. data/Rakefile +41 -0
  17. data/docs/badges/coverage_badge.svg +21 -0
  18. data/exe/laser-cutter +8 -0
  19. data/exe/lc +1 -0
  20. data/justfile +97 -0
  21. data/laser-cutter.gemspec +26 -14
  22. data/lib/laser/cutter/aggregator.rb +101 -0
  23. data/lib/{laser-cutter → laser/cutter}/box.rb +44 -52
  24. data/lib/laser/cutter/cli/command.rb +46 -0
  25. data/lib/laser/cutter/cli/completion.rb +21 -0
  26. data/lib/laser/cutter/cli/config_file.rb +36 -0
  27. data/lib/laser/cutter/cli/examples.rb +42 -0
  28. data/lib/laser/cutter/cli/generate.rb +90 -0
  29. data/lib/laser/cutter/cli/help.rb +20 -0
  30. data/lib/laser/cutter/cli/page_sizes.rb +19 -0
  31. data/lib/laser/cutter/cli/version.rb +15 -0
  32. data/lib/laser/cutter/cli.rb +49 -0
  33. data/lib/laser/cutter/configuration.rb +101 -0
  34. data/lib/{laser-cutter → laser/cutter}/geometry/dimensions.rb +2 -3
  35. data/lib/{laser-cutter/geometry/shape → laser/cutter/geometry}/line.rb +19 -18
  36. data/lib/{laser-cutter → laser/cutter}/geometry/point.rb +4 -2
  37. data/lib/{laser-cutter/geometry/shape → laser/cutter/geometry}/rect.rb +6 -7
  38. data/lib/{laser-cutter → laser/cutter}/geometry/shape.rb +7 -7
  39. data/lib/{laser-cutter → laser/cutter}/geometry/tuple.rb +40 -36
  40. data/lib/laser/cutter/launcher.rb +40 -0
  41. data/lib/laser/cutter/missing_option.rb +7 -0
  42. data/lib/laser/cutter/notching/base.rb +19 -0
  43. data/lib/{laser-cutter → laser/cutter}/notching/edge.rb +21 -20
  44. data/lib/laser/cutter/notching/infinite_iterator.rb +27 -0
  45. data/lib/{laser-cutter → laser/cutter}/notching/path_generator.rb +27 -60
  46. data/lib/laser/cutter/notching/shift.rb +17 -0
  47. data/lib/laser/cutter/page_manager.rb +50 -0
  48. data/lib/{laser-cutter → laser/cutter}/renderer/base.rb +3 -2
  49. data/lib/laser/cutter/renderer/box_renderer.rb +40 -0
  50. data/lib/laser/cutter/renderer/layout_renderer.rb +61 -0
  51. data/lib/laser/cutter/renderer/line_renderer.rb +21 -0
  52. data/lib/laser/cutter/renderer/meta_renderer.rb +83 -0
  53. data/lib/{laser-cutter → laser/cutter}/renderer/rect_renderer.rb +3 -1
  54. data/lib/laser/cutter/renderer/svg_renderer.rb +65 -0
  55. data/lib/laser/cutter/renderer.rb +24 -0
  56. data/lib/laser/cutter/units_converter.rb +15 -0
  57. data/lib/laser/cutter/version.rb +7 -0
  58. data/lib/laser/cutter/zero_value_not_allowed.rb +7 -0
  59. data/lib/laser/cutter.rb +29 -0
  60. data/lib/laser-cutter.rb +2 -14
  61. data/lib/laser_cutter.rb +3 -0
  62. data/spec/laser/cutter/aggregator_spec.rb +70 -0
  63. data/spec/laser/cutter/box_spec.rb +61 -0
  64. data/spec/laser/cutter/cli/config_file_spec.rb +31 -0
  65. data/spec/laser/cutter/cli_spec.rb +186 -0
  66. data/spec/laser/cutter/configuration_spec.rb +101 -0
  67. data/spec/{dimensions_spec.rb → laser/cutter/dimensions_spec.rb} +8 -6
  68. data/spec/laser/cutter/edge_spec.rb +64 -0
  69. data/spec/{line_spec.rb → laser/cutter/line_spec.rb} +26 -20
  70. data/spec/{page_manager_spec.rb → laser/cutter/page_manager_spec.rb} +27 -11
  71. data/spec/{path_generator_spec.rb → laser/cutter/path_generator_spec.rb} +18 -14
  72. data/spec/{point_spec.rb → laser/cutter/point_spec.rb} +27 -22
  73. data/spec/{rect_spec.rb → laser/cutter/rect_spec.rb} +9 -5
  74. data/spec/laser/cutter/renderer/layout_renderer_spec.rb +102 -0
  75. data/spec/laser/cutter/renderer/svg_renderer_spec.rb +51 -0
  76. data/spec/laser/cutter/renderer_spec.rb +19 -0
  77. data/spec/spec_helper.rb +34 -25
  78. data/spec/support/aruba.rb +10 -0
  79. metadata +206 -80
  80. data/.travis.yml +0 -11
  81. data/LICENSE +0 -22
  82. data/bin/laser-cutter +0 -19
  83. data/lib/laser-cutter/aggregator.rb +0 -57
  84. data/lib/laser-cutter/cli/opt_parser.rb +0 -131
  85. data/lib/laser-cutter/cli/serializer.rb +0 -51
  86. data/lib/laser-cutter/configuration.rb +0 -83
  87. data/lib/laser-cutter/geometry.rb +0 -11
  88. data/lib/laser-cutter/notching/base.rb +0 -13
  89. data/lib/laser-cutter/notching.rb +0 -10
  90. data/lib/laser-cutter/page_manager.rb +0 -43
  91. data/lib/laser-cutter/renderer/box_renderer.rb +0 -36
  92. data/lib/laser-cutter/renderer/layout_renderer.rb +0 -59
  93. data/lib/laser-cutter/renderer/line_renderer.rb +0 -17
  94. data/lib/laser-cutter/renderer/meta_renderer.rb +0 -77
  95. data/lib/laser-cutter/renderer.rb +0 -14
  96. data/lib/laser-cutter/version.rb +0 -5
  97. data/spec/aggregator_spec.rb +0 -65
  98. data/spec/box_spec.rb +0 -40
  99. data/spec/configuration_spec.rb +0 -81
  100. data/spec/edge_spec.rb +0 -43
  101. data/spec/renderer_spec.rb +0 -72
  102. /data/{doc → docs/images}/comparison.jpg +0 -0
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Laser
2
4
  module Cutter
3
5
  # Note: this class badly needs refactoring and tests. Both are coming.
@@ -5,18 +7,13 @@ module Laser
5
7
  class Box
6
8
  # Everything is in millimeters
7
9
 
8
- attr_accessor :dim, :thickness, :notch_width, :kerf
9
- attr_accessor :padding, :units, :inside_box
10
-
11
- attr_accessor :front, :back, :top, :bottom, :left, :right
12
- attr_accessor :faces, :bounds, :conf, :corner_face
13
- attr_accessor :metadata, :notches
10
+ attr_accessor :dim, :thickness, :notch_width, :kerf, :padding, :units, :inside_box, :front, :back, :top, :bottom, :left, :right, :faces, :bounds, :conf, :corner_face, :metadata, :notches
14
11
 
15
12
  def initialize(config = {})
16
13
  self.dim = Geometry::Dimensions.new(config['width'], config['height'], config['depth'])
17
14
  self.thickness = config['thickness']
18
15
 
19
- self.notch_width = config['notch'] || (1.0 * self.longest / 5.0)
16
+ self.notch_width = config['notch'] || (1.0 * longest / 5.0)
20
17
  self.kerf = config['kerf'] || 0.0
21
18
  self.padding = config['padding']
22
19
  self.units = config['units']
@@ -27,28 +24,27 @@ module Laser
27
24
  self.metadata = Geometry::Point[config['metadata_width'] || 0, config['metadata_height'] || 0]
28
25
 
29
26
  create_faces! # generates dimensions for each side
30
- self.faces = [top, front, bottom, back, left, right]
31
-
32
- self.conf = {
33
- valign: [ :out, :out, :out, :out, :in, :in],
34
- halign: [ :in, :out, :in, :out, :in, :in],
35
- corners: {
36
- front: [ :no, :yes, :no, :yes, :no, :no], # our default choice, but may not work
37
- top: [ :yes, :no, :yes, :no, :no, :no] # 2nd choice, has to work if 1st doesn't
38
- },
27
+ self.faces = [top, front, bottom, back, left, right]
28
+
29
+ self.conf = {
30
+ valign: [:out, :out, :out, :out, :in, :in],
31
+ halign: [:in, :out, :in, :out, :in, :in],
32
+ corners: {
33
+ front: [:no, :yes, :no, :yes, :no, :no], # our default choice, but may not work
34
+ top: [:yes, :no, :yes, :no, :no, :no] # 2nd choice, has to work if 1st doesn't
35
+ },
39
36
  }
40
- self
41
37
  end
42
38
 
43
39
  def enclosure
44
- generate_notches if self.notches.empty?
40
+ generate_notches if notches.empty?
45
41
  p1 = notches.first.p1.to_a
46
42
  p2 = notches.first.p2.to_a
47
43
 
48
44
  notches.each do |notch|
49
45
  n = notch.normalized
50
- n.p1.to_a.each_with_index {|c, i| p1[i] = c if c < p1[i] }
51
- n.p2.to_a.each_with_index {|c, i| p2[i] = c if c > p2[i] }
46
+ n.p1.to_a.each_with_index { |c, i| p1[i] = c if c < p1[i] }
47
+ n.p2.to_a.each_with_index { |c, i| p2[i] = c if c > p2[i] }
52
48
  end
53
49
 
54
50
  Geometry::Rect[Geometry::Point.new(p1), Geometry::Point.new(p2)]
@@ -60,34 +56,31 @@ module Laser
60
56
  self.notches = []
61
57
  faces.each_with_index do |face, face_index|
62
58
  bound = face_bounding_rect(face)
63
- side_lines = []
64
59
  edges = []
65
- bound.sides.each_with_index do |bounding_side, side_index |
66
- include_corners = (self.conf[:corners][corner_face][face_index] == :yes && side_index.odd?)
60
+ bound.sides.each_with_index do |bounding_side, side_index|
61
+ include_corners = conf[:corners][corner_face][face_index] == :yes && side_index.odd?
67
62
  key = side_index.odd? ? :valign : :halign
68
- center_out = (self.conf[key][face_index] == :out)
69
- edges << Notching::Edge.new(bounding_side, face.sides[side_index],
70
- {:notch_width => notch_width,
71
- :thickness => thickness,
72
- :kerf => kerf,
73
- :center_out => center_out,
74
- :corners => include_corners
75
- })
63
+ center_out = (conf[key][face_index] == :out)
64
+ edges << Notching::Edge.new(bounding_side,
65
+ face.sides[side_index],
66
+ { notch_width: notch_width,
67
+ thickness: thickness,
68
+ kerf: kerf,
69
+ center_out: center_out,
70
+ corners: include_corners })
76
71
  end
77
72
 
78
- if edges.any?{|e| e.corners} && !edges.all?{|e| e.first_notch_out? }
79
- edges.each {|e| e.adjust_corners = true }
73
+ if edges.any?(&:corners) && !edges.all?(&:first_notch_out?)
74
+ edges.each { |e| e.adjust_corners = true }
80
75
  end
81
76
 
82
- edges.each do |edge|
83
- side_lines << Notching::PathGenerator.new(edge).generate
77
+ side_lines = edges.map do |edge|
78
+ Notching::PathGenerator.new(edge).generate
84
79
  end
85
80
 
86
- aggregator = Aggregator.new(side_lines.flatten)
87
- aggregator.dedup!.deoverlap!.dedup!
88
- self.notches << aggregator.lines
81
+ notches << Aggregator.new(side_lines.flatten).lines
89
82
  end
90
- self.notches.flatten!
83
+ notches.flatten!
91
84
  end
92
85
 
93
86
  def w; dim.w; end
@@ -95,7 +88,7 @@ module Laser
95
88
  def d; dim.d; end
96
89
 
97
90
  def longest
98
- [w, h, d].max()
91
+ [w, h, d].max
99
92
  end
100
93
 
101
94
  def to_s
@@ -111,7 +104,7 @@ module Laser
111
104
  b.relocate!
112
105
  end
113
106
 
114
- #___________________________________________________________________
107
+ # ___________________________________________________________________
115
108
  #
116
109
  # +-----------------+
117
110
  # | |
@@ -131,22 +124,22 @@ module Laser
131
124
  # +-----------------+
132
125
  #
133
126
  # 0,0
134
- #___________________________________________________________________
127
+ # ___________________________________________________________________
135
128
 
136
129
  def position_faces!
137
- offset_x = [padding + d + 3 * thickness, metadata.x + 2 * thickness + padding].max
138
- offset_y = [padding + d + 3 * thickness, metadata.y + 2 * thickness + padding].max
130
+ offset_x = [padding + d + (3 * thickness), metadata.x + (2 * thickness) + padding].max
131
+ offset_y = [padding + d + (3 * thickness), metadata.y + (2 * thickness) + padding].max
139
132
 
140
133
  # X Coordinate
141
- left.x = offset_x - d - 2 * thickness - padding
142
- right.x = offset_x + w + 2 * thickness + padding
134
+ left.x = offset_x - d - (2 * thickness) - padding
135
+ right.x = offset_x + w + (2 * thickness) + padding
143
136
 
144
137
  [bottom, front, top, back].each { |s| s.x = offset_x }
145
138
 
146
139
  # Y Coordinate
147
- top.y = offset_y - d - 2 * thickness - padding
148
- bottom.y = offset_y + h + 2 * thickness + padding
149
- back.y = bottom.y + d + 2 * thickness + padding
140
+ top.y = offset_y - d - (2 * thickness) - padding
141
+ bottom.y = offset_y + h + (2 * thickness) + padding
142
+ back.y = bottom.y + d + (2 * thickness) + padding
150
143
 
151
144
  [left, front, right].each { |s| s.y = offset_y }
152
145
 
@@ -171,11 +164,10 @@ module Laser
171
164
  b = face_bounding_rect(front)
172
165
  edges = []
173
166
  front.sides[0..1].each_with_index do |face, index|
174
- edges << Notching::Edge.new(b.sides[index], face, :notch_width => notch_width, :kerf => kerf )
167
+ edges << Notching::Edge.new(b.sides[index], face, notch_width: notch_width, kerf: kerf )
175
168
  end
176
- edges.map(&:notch_count).all?{|c| c % 4 == 3} ? :top : :front
169
+ edges.map(&:notch_count).all?{ |c| c % 4 == 3 } ? :top : :front
177
170
  end
178
-
179
171
  end
180
172
  end
181
173
  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,42 @@
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
+ EXAMPLES
35
+
36
+ def call(**)
37
+ out.puts TEXT
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,90 @@
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
+ option :units, default: 'in', values: %w[in mm], aliases: ['-u'], desc: 'Units every dimension is in'
28
+
29
+ option :file, aliases: ['-o'], desc: 'File to write (required)'
30
+ option :format, default: 'pdf', aliases: ['-f'], desc: 'Output format: pdf or svg, in either case'
31
+
32
+ option :margin, aliases: ['-m'], desc: 'Margin from the edge of the page'
33
+ option :padding, aliases: ['-p'], desc: 'Space between the panels'
34
+ option :stroke, aliases: ['-s'], desc: 'Stroke width of the lines'
35
+ option :page_size, aliases: ['-i'], desc: 'Page size, such as LETTER or A3 (default: fit the box; PDF only)'
36
+ option :page_layout, default: 'portrait', values: %w[portrait landscape], aliases: ['-l'], desc: 'Page layout (PDF only)'
37
+
38
+ option :metadata, type: :boolean, default: true, aliases: ['-M'], desc: 'Print the settings of the box on the page'
39
+ option :inside_box, type: :boolean, default: false, aliases: ['-B'],
40
+ desc: 'Also draw the box without kerf, in red, to check the kerf'
41
+ option :open, type: :boolean, default: false, aliases: ['-O'], desc: 'Open the file once it is written'
42
+ option :write, aliases: ['-W'], desc: "Save the configuration to a file, or to STDOUT with '-'"
43
+ option :read, aliases: ['-R'], desc: "Read the configuration from a file, or from STDIN with '-'"
44
+
45
+ example [
46
+ '-b 3x2x2/0.125 -o box.pdf # a box in inches',
47
+ '-b 3x2x2/0.125 -f svg -o box.svg # as an SVG',
48
+ '-u mm -w 70 -H 20 -d 50 -t 4.3 -o box.pdf'
49
+ ]
50
+
51
+ def call(format:, verbose:, **options)
52
+ config = configuration(options)
53
+ ui.debug('Configuration:', JSON.pretty_generate(config.to_hash)) if verbose
54
+ config.validate!
55
+ renderer = Renderer.for(format, config)
56
+ ui.info(BANNER, dimensions(config), "Format: #{format.upcase}")
57
+ ConfigFile.new(options[:write]).write(config.to_hash, out) if options[:write]
58
+
59
+ progress("Drawing #{File.basename(config.file)}", total: renderer.total) do |bar|
60
+ renderer.render { bar.advance }
61
+ end
62
+
63
+ report(format, File.expand_path(config.file))
64
+ system('open', config.file) if options[:open]
65
+ end
66
+
67
+ private
68
+
69
+ # A box wraps in the middle of a word, which would break a path nobody
70
+ # could then copy, so this one widens to fit a path longer than it.
71
+ def report(format, path)
72
+ ui.success("Generated #{format.upcase} file:", path, width: [CLI.help_width, path.length + BOX_CHROME].max)
73
+ end
74
+
75
+ # What was read from a file, overridden by what the command line gave.
76
+ def configuration(options)
77
+ given = options.except(*RUN_ONLY).compact
78
+ saved = options[:read] ? ConfigFile.new(options[:read]).read : {}
79
+ settings = saved.transform_keys(&:to_sym).merge(given)
80
+ Configuration.new(settings.merge(debug: settings.delete(:inside_box)))
81
+ end
82
+
83
+ # One line per dimension, aligned, in the units of the box.
84
+ def dimensions(config)
85
+ DIMENSIONS.map { |name| "#{"#{name.capitalize}:".ljust(11)} #{config[name]} #{config.units}" }.join("\n")
86
+ end
87
+ end
88
+ end
89
+ end
90
+ 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
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ # Every command the executable answers to.
6
+ module CLI
7
+ extend Dry::CLI::Registry
8
+
9
+ PROGRAM = 'laser-cutter'
10
+
11
+ # Help and boxes are never wider than this, however wide the terminal is.
12
+ HELP_WIDTH = 90
13
+
14
+ # Columns assumed when there is no terminal, or it reports none.
15
+ DEFAULT_COLUMNS = 80
16
+
17
+ # @return [Integer] columns help wraps at, and a box takes: the terminal's less 6,
18
+ # capped at HELP_WIDTH
19
+ def self.help_width(columns = IO.console&.winsize&.last)
20
+ columns = DEFAULT_COLUMNS unless columns&.positive?
21
+ [columns - 6, HELP_WIDTH].min
22
+ end
23
+
24
+ Dry::CLI::Help.configure do
25
+ title "laser-cutter #{Laser::Cutter::VERSION}"
26
+ description 'Draws the notched panels of a box, ready to cut on a laser cutter, as a PDF or an SVG.'
27
+ epilogue 'Documentation: https://github.com/kigster/laser-cutter'
28
+
29
+ width CLI.help_width
30
+ exit_code_without_arguments 0
31
+
32
+ styles do
33
+ heading :bold, :cyan, case: :UPPERCASE
34
+ usage :yellow
35
+ example :yellow
36
+ option :green
37
+ example_comment :bright_black
38
+ end
39
+ end
40
+
41
+ register 'generate', Generate, aliases: ['g']
42
+ register 'page-sizes', PageSizes
43
+ register 'examples', Examples
44
+ register 'help', Help
45
+ register 'version', Version, aliases: ['-V', '--version']
46
+ register 'completion', Completion
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ class Configuration < Hashie::Mash
6
+ include ::Hashie::Extensions::Mash::SymbolizeKeys
7
+
8
+ SIZE_REGEXP = %r{[\d.]+x[\d.]+x[\d.]+/[\d.]+(/[\d.]+)?}
9
+
10
+ FLOATS = %i(width height depth thickness notch margin padding stroke kerf).freeze
11
+ NON_ZERO = %i(width height depth thickness stroke).freeze
12
+ REQUIRED = %i(width height depth thickness notch file).freeze
13
+
14
+ @mutex = Mutex.new
15
+
16
+ class << self
17
+ attr_accessor :mutex, :defaults
18
+
19
+ def compute_defaults!
20
+ mutex.synchronize do
21
+ if defined?(@defaults)
22
+ @defaults
23
+ else
24
+ @defaults = compute_metric_units!
25
+ end
26
+ end.dup
27
+ end
28
+
29
+ def compute_metric_units!
30
+ defaults = Hashie::Mash.new({
31
+ units: :in,
32
+ page_layout: 'portrait',
33
+ metadata: true,
34
+ in: {
35
+ kerf: 0.0024, # smallest kerf for thin material, usually it's more than that.
36
+ margin: 0.125,
37
+ padding: 0.1,
38
+ stroke: 0.001,
39
+ }
40
+ })
41
+
42
+ defaults[:mm] = defaults[:in].map { |k, v| [k, UnitsConverter.in2mm(v)] }.to_h
43
+ defaults
44
+ end
45
+ end
46
+
47
+ compute_defaults!
48
+
49
+ def initialize(options = {})
50
+ ::Hashie::Extensions::SymbolizeKeys.symbolize_keys!(options)
51
+
52
+ options.delete_if { |_k, v| v.nil? }
53
+ if options[:units]
54
+ unit = options[:units].to_sym
55
+ unless self.class.defaults.key?(unit) || self.class.defaults.key?(unit.to_s)
56
+ options.delete(:units)
57
+ end
58
+ end
59
+
60
+ super(self.class.defaults.merge(options))
61
+
62
+ if box =~ SIZE_REGEXP
63
+ dim, self[:thickness], self[:notch] = self[:box].split('/')
64
+ self[:width], self[:height], self[:depth] = dim.split('x')
65
+ delete(:box)
66
+ end
67
+ FLOATS.each do |k|
68
+ self[k] = self[k].to_f if self[k].is_a?(String)
69
+ end
70
+ merge!(self.class.defaults[self[:units].to_sym].merge(self))
71
+ self[:notch] = (self[:thickness] * 3.0).round(5) if self[:thickness] && self[:notch].nil?
72
+ end
73
+
74
+ def validate!
75
+ missing = []
76
+ REQUIRED.each { |k| missing << k if self[k].nil? }
77
+ raise MissingOption, "#{missing.join(', ')} #{missing.size > 1 ? 'are' : 'is'} required, but missing." unless missing.empty?
78
+
79
+ zeros = []
80
+ NON_ZERO.each { |k| zeros << k if self[k] == 0 }
81
+ raise ZeroValueNotAllowed, "#{zeros.join(', ')} #{zeros.size > 1 ? 'are' : 'is'} required, but is zero." unless zeros.empty?
82
+ end
83
+
84
+ def change_units(new_units)
85
+ new_units = new_units.to_sym
86
+ return if new_units == units.to_sym
87
+ return unless self.class.defaults.key?(new_units)
88
+
89
+ k = units.to_sym == :in ? UnitsConverter.in2mm(1.0) : UnitsConverter.mm2in(1.0)
90
+
91
+ FLOATS.each do |field|
92
+ next if send(field).nil?
93
+
94
+ send("#{field}=", (send(field) * k).round(5))
95
+ end
96
+
97
+ self.units = new_units
98
+ end
99
+ end
100
+ end
101
+ end
@@ -1,8 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Laser
2
4
  module Cutter
3
5
  module Geometry
4
6
  class Dimensions < Tuple
5
-
6
7
  def w
7
8
  coords.[](0)
8
9
  end
@@ -22,9 +23,7 @@ module Laser
22
23
  def hash_keys
23
24
  [:w, :h, :d]
24
25
  end
25
-
26
26
  end
27
27
  end
28
28
  end
29
-
30
29
  end