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.
- checksums.yaml +5 -5
- data/.envrc +2 -0
- data/.github/workflows/lint.yml +22 -0
- data/.github/workflows/rspec.yml +22 -0
- data/.gitignore +5 -4
- data/.plans/001.00-dry-cli-migration/plan.md +29 -0
- data/.relaxed_rubocop.yml +153 -0
- data/.rspec +1 -1
- data/.rubocop.yml +31 -0
- data/.rubocop_todo.yml +90 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +62 -0
- data/CONTRIBUTING.md +2 -4
- data/Gemfile +14 -0
- data/README.md +83 -111
- data/Rakefile +41 -0
- data/docs/badges/coverage_badge.svg +21 -0
- data/exe/laser-cutter +8 -0
- data/exe/lc +1 -0
- data/justfile +97 -0
- data/laser-cutter.gemspec +26 -14
- data/lib/laser/cutter/aggregator.rb +101 -0
- data/lib/{laser-cutter → laser/cutter}/box.rb +44 -52
- data/lib/laser/cutter/cli/command.rb +46 -0
- data/lib/laser/cutter/cli/completion.rb +21 -0
- data/lib/laser/cutter/cli/config_file.rb +36 -0
- data/lib/laser/cutter/cli/examples.rb +42 -0
- data/lib/laser/cutter/cli/generate.rb +90 -0
- data/lib/laser/cutter/cli/help.rb +20 -0
- data/lib/laser/cutter/cli/page_sizes.rb +19 -0
- data/lib/laser/cutter/cli/version.rb +15 -0
- data/lib/laser/cutter/cli.rb +49 -0
- data/lib/laser/cutter/configuration.rb +101 -0
- data/lib/{laser-cutter → laser/cutter}/geometry/dimensions.rb +2 -3
- data/lib/{laser-cutter/geometry/shape → laser/cutter/geometry}/line.rb +19 -18
- data/lib/{laser-cutter → laser/cutter}/geometry/point.rb +4 -2
- data/lib/{laser-cutter/geometry/shape → laser/cutter/geometry}/rect.rb +6 -7
- data/lib/{laser-cutter → laser/cutter}/geometry/shape.rb +7 -7
- data/lib/{laser-cutter → laser/cutter}/geometry/tuple.rb +40 -36
- data/lib/laser/cutter/launcher.rb +40 -0
- data/lib/laser/cutter/missing_option.rb +7 -0
- data/lib/laser/cutter/notching/base.rb +19 -0
- data/lib/{laser-cutter → laser/cutter}/notching/edge.rb +21 -20
- data/lib/laser/cutter/notching/infinite_iterator.rb +27 -0
- data/lib/{laser-cutter → laser/cutter}/notching/path_generator.rb +27 -60
- data/lib/laser/cutter/notching/shift.rb +17 -0
- data/lib/laser/cutter/page_manager.rb +50 -0
- data/lib/{laser-cutter → laser/cutter}/renderer/base.rb +3 -2
- data/lib/laser/cutter/renderer/box_renderer.rb +40 -0
- data/lib/laser/cutter/renderer/layout_renderer.rb +61 -0
- data/lib/laser/cutter/renderer/line_renderer.rb +21 -0
- data/lib/laser/cutter/renderer/meta_renderer.rb +83 -0
- data/lib/{laser-cutter → laser/cutter}/renderer/rect_renderer.rb +3 -1
- data/lib/laser/cutter/renderer/svg_renderer.rb +65 -0
- data/lib/laser/cutter/renderer.rb +24 -0
- data/lib/laser/cutter/units_converter.rb +15 -0
- data/lib/laser/cutter/version.rb +7 -0
- data/lib/laser/cutter/zero_value_not_allowed.rb +7 -0
- data/lib/laser/cutter.rb +29 -0
- data/lib/laser-cutter.rb +2 -14
- data/lib/laser_cutter.rb +3 -0
- data/spec/laser/cutter/aggregator_spec.rb +70 -0
- data/spec/laser/cutter/box_spec.rb +61 -0
- data/spec/laser/cutter/cli/config_file_spec.rb +31 -0
- data/spec/laser/cutter/cli_spec.rb +186 -0
- data/spec/laser/cutter/configuration_spec.rb +101 -0
- data/spec/{dimensions_spec.rb → laser/cutter/dimensions_spec.rb} +8 -6
- data/spec/laser/cutter/edge_spec.rb +64 -0
- data/spec/{line_spec.rb → laser/cutter/line_spec.rb} +26 -20
- data/spec/{page_manager_spec.rb → laser/cutter/page_manager_spec.rb} +27 -11
- data/spec/{path_generator_spec.rb → laser/cutter/path_generator_spec.rb} +18 -14
- data/spec/{point_spec.rb → laser/cutter/point_spec.rb} +27 -22
- data/spec/{rect_spec.rb → laser/cutter/rect_spec.rb} +9 -5
- data/spec/laser/cutter/renderer/layout_renderer_spec.rb +102 -0
- data/spec/laser/cutter/renderer/svg_renderer_spec.rb +51 -0
- data/spec/laser/cutter/renderer_spec.rb +19 -0
- data/spec/spec_helper.rb +34 -25
- data/spec/support/aruba.rb +10 -0
- metadata +206 -80
- data/.travis.yml +0 -11
- data/LICENSE +0 -22
- data/bin/laser-cutter +0 -19
- data/lib/laser-cutter/aggregator.rb +0 -57
- data/lib/laser-cutter/cli/opt_parser.rb +0 -131
- data/lib/laser-cutter/cli/serializer.rb +0 -51
- data/lib/laser-cutter/configuration.rb +0 -83
- data/lib/laser-cutter/geometry.rb +0 -11
- data/lib/laser-cutter/notching/base.rb +0 -13
- data/lib/laser-cutter/notching.rb +0 -10
- data/lib/laser-cutter/page_manager.rb +0 -43
- data/lib/laser-cutter/renderer/box_renderer.rb +0 -36
- data/lib/laser-cutter/renderer/layout_renderer.rb +0 -59
- data/lib/laser-cutter/renderer/line_renderer.rb +0 -17
- data/lib/laser-cutter/renderer/meta_renderer.rb +0 -77
- data/lib/laser-cutter/renderer.rb +0 -14
- data/lib/laser-cutter/version.rb +0 -5
- data/spec/aggregator_spec.rb +0 -65
- data/spec/box_spec.rb +0 -40
- data/spec/configuration_spec.rb +0 -81
- data/spec/edge_spec.rb +0 -43
- data/spec/renderer_spec.rb +0 -72
- /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 *
|
|
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 =
|
|
31
|
-
|
|
32
|
-
self.conf
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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 =
|
|
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 = (
|
|
69
|
-
edges << Notching::Edge.new(bounding_side,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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?
|
|
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.
|
|
83
|
-
|
|
77
|
+
side_lines = edges.map do |edge|
|
|
78
|
+
Notching::PathGenerator.new(edge).generate
|
|
84
79
|
end
|
|
85
80
|
|
|
86
|
-
|
|
87
|
-
aggregator.dedup!.deoverlap!.dedup!
|
|
88
|
-
self.notches << aggregator.lines
|
|
81
|
+
notches << Aggregator.new(side_lines.flatten).lines
|
|
89
82
|
end
|
|
90
|
-
|
|
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, :
|
|
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,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
|