laser-cutter 1.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) 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/.relaxed_rubocop.yml +153 -0
  8. data/.rspec +1 -0
  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 +110 -87
  16. data/Rakefile +27 -13
  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/box.rb +173 -0
  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 +23 -22
  44. data/lib/laser/cutter/notching/infinite_iterator.rb +27 -0
  45. data/lib/{laser-cutter → laser/cutter}/notching/path_generator.rb +76 -94
  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 +35 -22
  78. data/spec/support/aruba.rb +10 -0
  79. metadata +202 -76
  80. data/.travis.yml +0 -26
  81. data/BOXMAKER.md +0 -51
  82. data/LICENSE +0 -22
  83. data/bin/laser-cutter +0 -19
  84. data/lib/laser-cutter/aggregator.rb +0 -57
  85. data/lib/laser-cutter/box.rb +0 -183
  86. data/lib/laser-cutter/cli/opt_parser.rb +0 -131
  87. data/lib/laser-cutter/cli/serializer.rb +0 -51
  88. data/lib/laser-cutter/configuration.rb +0 -83
  89. data/lib/laser-cutter/face.rb +0 -18
  90. data/lib/laser-cutter/geometry.rb +0 -11
  91. data/lib/laser-cutter/notching.rb +0 -9
  92. data/lib/laser-cutter/page_manager.rb +0 -43
  93. data/lib/laser-cutter/renderer/box_renderer.rb +0 -36
  94. data/lib/laser-cutter/renderer/layout_renderer.rb +0 -59
  95. data/lib/laser-cutter/renderer/line_renderer.rb +0 -17
  96. data/lib/laser-cutter/renderer/meta_renderer.rb +0 -77
  97. data/lib/laser-cutter/renderer.rb +0 -14
  98. data/lib/laser-cutter/version.rb +0 -5
  99. data/spec/aggregator_spec.rb +0 -65
  100. data/spec/box_spec.rb +0 -39
  101. data/spec/configuration_spec.rb +0 -81
  102. data/spec/edge_spec.rb +0 -43
  103. data/spec/renderer_spec.rb +0 -72
  104. /data/docs/{comparison.jpg → images/comparison.jpg} +0 -0
@@ -0,0 +1,173 @@
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
+ # Everything is in millimeters
9
+
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
11
+
12
+ def initialize(config = {})
13
+ self.dim = Geometry::Dimensions.new(config['width'], config['height'], config['depth'])
14
+ self.thickness = config['thickness']
15
+
16
+ self.notch_width = config['notch'] || (1.0 * longest / 5.0)
17
+ self.kerf = config['kerf'] || 0.0
18
+ self.padding = config['padding']
19
+ self.units = config['units']
20
+ self.inside_box = config['inside_box']
21
+
22
+ self.notches = []
23
+
24
+ self.metadata = Geometry::Point[config['metadata_width'] || 0, config['metadata_height'] || 0]
25
+
26
+ create_faces! # generates dimensions for each side
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
+ },
36
+ }
37
+ end
38
+
39
+ def enclosure
40
+ generate_notches if notches.empty?
41
+ p1 = notches.first.p1.to_a
42
+ p2 = notches.first.p2.to_a
43
+
44
+ notches.each do |notch|
45
+ n = notch.normalized
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] }
48
+ end
49
+
50
+ Geometry::Rect[Geometry::Point.new(p1), Geometry::Point.new(p2)]
51
+ end
52
+
53
+ def generate_notches
54
+ position_faces!
55
+ corner_face = pick_corners_face
56
+ self.notches = []
57
+ faces.each_with_index do |face, face_index|
58
+ bound = face_bounding_rect(face)
59
+ edges = []
60
+ bound.sides.each_with_index do |bounding_side, side_index|
61
+ include_corners = conf[:corners][corner_face][face_index] == :yes && side_index.odd?
62
+ key = side_index.odd? ? :valign : :halign
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 })
71
+ end
72
+
73
+ if edges.any?(&:corners) && !edges.all?(&:first_notch_out?)
74
+ edges.each { |e| e.adjust_corners = true }
75
+ end
76
+
77
+ side_lines = edges.map do |edge|
78
+ Notching::PathGenerator.new(edge).generate
79
+ end
80
+
81
+ notches << Aggregator.new(side_lines.flatten).lines
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
+ def face_bounding_rect(face)
101
+ b = face.clone
102
+ b.move_to(b.position.plus(-thickness, -thickness))
103
+ b.p2 = b.p2.plus(2 * thickness, 2 * thickness)
104
+ b.relocate!
105
+ end
106
+
107
+ # ___________________________________________________________________
108
+ #
109
+ # +-----------------+
110
+ # | |
111
+ # | back: W x H |
112
+ # | |
113
+ # +-----------------+
114
+ # +-----------------+
115
+ # | bottom: W x D |
116
+ # +-----------------+
117
+ # +--------+ +-----------------+ +--------+
118
+ # | | | | | |
119
+ # | left | | front: W x H | | right |
120
+ # | D x H | | | | D x H |
121
+ # +--------+ X-----------------+ +--------+
122
+ # +-----------------+
123
+ # | top : W x D |
124
+ # +-----------------+
125
+ #
126
+ # 0,0
127
+ # ___________________________________________________________________
128
+
129
+ def position_faces!
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
132
+
133
+ # X Coordinate
134
+ left.x = offset_x - d - (2 * thickness) - padding
135
+ right.x = offset_x + w + (2 * thickness) + padding
136
+
137
+ [bottom, front, top, back].each { |s| s.x = offset_x }
138
+
139
+ # Y Coordinate
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
143
+
144
+ [left, front, right].each { |s| s.y = offset_y }
145
+
146
+ faces.each(&:relocate!)
147
+ end
148
+
149
+ def create_faces!
150
+ zero = Geometry::Point.new(0, 0)
151
+ self.front = Geometry::Rect.create(zero, dim.w, dim.h, "front")
152
+ self.back = Geometry::Rect.create(zero, dim.w, dim.h, "back")
153
+
154
+ self.top = Geometry::Rect.create(zero, dim.w, dim.d, "top")
155
+ self.bottom = Geometry::Rect.create(zero, dim.w, dim.d, "bottom")
156
+
157
+ self.left = Geometry::Rect.create(zero, dim.d, dim.h, "left")
158
+ self.right = Geometry::Rect.create(zero, dim.d, dim.h, "right")
159
+ end
160
+
161
+ # Choose which face will be responsible for filling out the little square overlap
162
+ # in the corners. Only one of the 3 possible sides need to be picked.
163
+ def pick_corners_face
164
+ b = face_bounding_rect(front)
165
+ edges = []
166
+ front.sides[0..1].each_with_index do |face, index|
167
+ edges << Notching::Edge.new(b.sides[index], face, notch_width: notch_width, kerf: kerf )
168
+ end
169
+ edges.map(&:notch_count).all?{ |c| c % 4 == 3 } ? :top : :front
170
+ end
171
+ end
172
+ end
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