laser-cutter 2.0.0 → 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.
@@ -148,6 +148,21 @@ RSpec.describe 'laser-cutter', type: :aruba do
148
148
  expect(last_command_started).to have_output(/"depth": 2\.0/)
149
149
  end
150
150
 
151
+ { 'plain' => 224, 'back' => 260, 'full' => 328 }.each do |lid, lines|
152
+ it "draws #{lines} lines for a #{lid} lid" do
153
+ run_command_and_stop("laser-cutter generate #{box} --lid #{lid} -o box.pdf")
154
+ expect(last_command_started).to have_output(%r{Lid:\s+#{lid}.*Drawing box\.pdf #{lines}/#{lines}}m)
155
+ end
156
+ end
157
+
158
+ it 'keeps the lid a saved configuration asks for' do
159
+ run_command_and_stop("laser-cutter generate #{box} -L plain -o box.pdf -W settings.json")
160
+ expect(JSON.parse(read('settings.json').join)).to include('lid' => 'plain')
161
+
162
+ run_command_and_stop('laser-cutter generate -R settings.json -o again.pdf')
163
+ expect(last_command_started).to have_output(%r{Drawing again\.pdf 224/224})
164
+ end
165
+
151
166
  it 'opens the file with --open' do
152
167
  allow_any_instance_of(Laser::Cutter::CLI::Generate).to receive(:system)
153
168
  run_command_and_stop("laser-cutter generate #{box} -o box.pdf --open")
@@ -172,6 +187,18 @@ RSpec.describe 'laser-cutter', type: :aruba do
172
187
  expect(last_command_started).to have_output(/unknown format "dxf", expected one of: pdf, svg/)
173
188
  end
174
189
 
190
+ it 'fails on an unknown lid' do
191
+ run_command_and_stop("laser-cutter generate #{box} --lid sliding -o box.pdf", fail_on_error: false)
192
+ expect(last_command_started).to have_exit_status(1)
193
+ expect(last_command_started.stderr).to match(/was called with arguments.*--lid sliding/)
194
+ end
195
+
196
+ it 'fails on an unknown lid in a saved configuration' do
197
+ write_file('settings.json', '{"box": "4x3x2/0.125/0.5", "lid": "sliding"}')
198
+ run_command_and_stop('laser-cutter generate -R settings.json -o box.pdf', fail_on_error: false)
199
+ expect(last_command_started.stderr).to match(/─ Error ─.*lid is "sliding", but must be one of: full, back, plain/m)
200
+ end
201
+
175
202
  it 'fails on a missing configuration file' do
176
203
  run_command_and_stop('laser-cutter generate -R nothing.json -o box.pdf', fail_on_error: false)
177
204
  expect(last_command_started).to have_output(/cannot read the configuration from nothing\.json/)
@@ -40,6 +40,23 @@ module Laser
40
40
  end
41
41
  end
42
42
 
43
+ describe 'lid' do
44
+ let(:opts) { { 'box' => '2x3x2/0.125/0.5', 'file' => '/tmp/a' } }
45
+
46
+ its(:lid) { is_expected.to eql('full') }
47
+
48
+ %w[full back plain].each do |lid|
49
+ it "accepts #{lid}" do
50
+ expect { described_class.new(opts.merge('lid' => lid)).validate! }.not_to raise_error
51
+ end
52
+ end
53
+
54
+ it 'rejects anything else' do
55
+ expect { described_class.new(opts.merge('lid' => 'sliding')).validate! }
56
+ .to raise_error(Laser::Cutter::InvalidOption, /lid is "sliding"/)
57
+ end
58
+ end
59
+
43
60
  context 'when notch is omitted' do
44
61
  let(:opts) { { 'box' => '2.0x1.0x2/0.125', 'file' => '/tmp/a' } }
45
62
 
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ module Laser
6
+ module Cutter
7
+ RSpec.describe Options do
8
+ subject(:options) { described_class.new(settings) }
9
+
10
+ let(:dimensions) { { width: 40, height: 30, depth: 20, thickness: 3 } }
11
+ let(:settings) { dimensions }
12
+
13
+ describe 'with the dimensions alone' do
14
+ its(:width) { is_expected.to eql(40.0) }
15
+ its(:thickness) { is_expected.to eql(3.0) }
16
+ its(:units) { is_expected.to eq('in') }
17
+ its(:lid) { is_expected.to eq('full') }
18
+ its(:page_layout) { is_expected.to eq('portrait') }
19
+ its(:metadata) { is_expected.to be(true) }
20
+ its(:inside_box) { is_expected.to be(false) }
21
+ its(:notch) { is_expected.to be_nil }
22
+ its(:kerf) { is_expected.to be_nil }
23
+ its(:format) { is_expected.to be_nil }
24
+ its(:file) { is_expected.to be_nil }
25
+ its(:page_size) { is_expected.to be_nil }
26
+
27
+ it { is_expected.not_to respond_to(:width=) }
28
+ end
29
+
30
+ describe 'with what a form sends: Strings, some of them blank' do
31
+ let(:settings) do
32
+ { 'width' => '70', 'height' => '20', 'depth' => '50', 'thickness' => '4.3', 'notch' => '', 'kerf' => '0.1',
33
+ 'units' => 'mm', 'lid' => 'plain', 'format' => 'SVG', 'page_size' => 'a4', 'page_layout' => '',
34
+ 'metadata' => '0', 'inside_box' => 'true', 'margin' => '', 'padding' => '2', 'stroke' => '0.2' }
35
+ end
36
+
37
+ its(:width) { is_expected.to eql(70.0) }
38
+ its(:thickness) { is_expected.to eql(4.3) }
39
+ its(:notch) { is_expected.to be_nil }
40
+ its(:kerf) { is_expected.to eql(0.1) }
41
+ its(:units) { is_expected.to eq('mm') }
42
+ its(:lid) { is_expected.to eq('plain') }
43
+ its(:format) { is_expected.to eq('svg') }
44
+ its(:page_size) { is_expected.to eq('A4') }
45
+ its(:page_layout) { is_expected.to eq('portrait') }
46
+ its(:metadata) { is_expected.to be(false) }
47
+ its(:inside_box) { is_expected.to be(true) }
48
+ its(:margin) { is_expected.to be_nil }
49
+ its(:padding) { is_expected.to eql(2.0) }
50
+ its(:stroke) { is_expected.to eql(0.2) }
51
+ end
52
+
53
+ describe 'with Symbols' do
54
+ let(:settings) { dimensions.merge(units: :mm, lid: :back, format: :pdf, page_layout: :landscape) }
55
+
56
+ its(:units) { is_expected.to eq('mm') }
57
+ its(:lid) { is_expected.to eq('back') }
58
+ its(:format) { is_expected.to eq('pdf') }
59
+ its(:page_layout) { is_expected.to eq('landscape') }
60
+ end
61
+
62
+ describe 'given an Options' do
63
+ it 'returns it' do
64
+ expect(described_class.new(options)).to be(options)
65
+ end
66
+ end
67
+
68
+ describe '#new' do
69
+ subject(:changed) { options.new(lid: :plain) }
70
+
71
+ its(:lid) { is_expected.to eq('plain') }
72
+ its(:width) { is_expected.to eql(40.0) }
73
+ end
74
+
75
+ describe 'what it refuses' do
76
+ {
77
+ { lid: 'sliding' } => 'lid cannot be "sliding", but must be one of: full, back, plain.',
78
+ { units: 'cm' } => 'units cannot be "cm", but must be one of: in, mm.',
79
+ { format: 'dxf' } => 'format cannot be "dxf", but must be one of: pdf, svg.',
80
+ { page_layout: 'sideways' } => 'page_layout cannot be "sideways", but must be one of: portrait, landscape.',
81
+ { page_size: 'A99' } => 'page_size cannot be "A99".',
82
+ { width: 'wide' } => 'width cannot be "wide".',
83
+ { depth: 0 } => 'depth cannot be 0.',
84
+ { kerf: -0.1 } => 'kerf cannot be -0.1.',
85
+ { metadata: 'maybe' } => 'metadata cannot be "maybe".'
86
+ }.each do |bad, message|
87
+ it "raises InvalidOption when #{bad.keys.first} is #{bad.values.first.inspect}" do
88
+ expect { described_class.new(dimensions.merge(bad)) }.to raise_error(InvalidOption, message)
89
+ end
90
+ end
91
+
92
+ it 'raises InvalidOption for a key that is not an option' do
93
+ expect { described_class.new(dimensions.merge(lids: :plain)) }.to raise_error(InvalidOption, /lids/)
94
+ end
95
+
96
+ it 'raises MissingOption naming every dimension left out' do
97
+ expect { described_class.new(width: 40, depth: '') }
98
+ .to raise_error(MissingOption, 'height, depth, thickness are required, but missing.')
99
+ end
100
+
101
+ it 'raises MissingOption naming the one dimension left out' do
102
+ expect { described_class.new(dimensions.except(:width)) }.to raise_error(MissingOption, 'width is required, but missing.')
103
+ end
104
+ end
105
+
106
+ describe '#to_configuration' do
107
+ subject(:config) { described_class.new(dimensions.merge(units: :mm, lid: :back, inside_box: true, format: :svg)).to_configuration }
108
+
109
+ it { is_expected.to be_a(Configuration) }
110
+
111
+ its(:width) { is_expected.to eql(40.0) }
112
+ its(:lid) { is_expected.to eq('back') }
113
+ its(:debug) { is_expected.to be(true) }
114
+ its(:notch) { is_expected.to eql(9.0) }
115
+ its(:kerf) { is_expected.to be_within(1e-6).of(0.06096) }
116
+ its(:format) { is_expected.to be_nil }
117
+ end
118
+ end
119
+ end
120
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'tmpdir'
4
5
 
5
6
  module Laser
6
7
  module Cutter
@@ -8,33 +9,15 @@ module Laser
8
9
  RSpec.describe LayoutRenderer do
9
10
  describe '#render' do
10
11
  let(:renderer) { LayoutRenderer.new(config) }
11
- let(:file) { File.expand_path("../../../laser-cutter-pdf-test.#{$$}.pdf", __FILE__) }
12
+ let(:dir) { Dir.mktmpdir }
13
+ let(:file) { File.join(dir, 'box.pdf') }
14
+
15
+ after { FileUtils.remove_entry(dir) }
12
16
 
13
17
  def render_file(filename)
14
- real_file = ENV['RSPEC_SAVE_PDF'] ? true : false
15
18
  config.validate!
16
- expect(!File.exist?(filename)) if real_file
17
19
  renderer.render
18
- expect(File.exist?(filename)) if real_file
19
- expect(File.size(filename) > 0) if real_file
20
- rescue Exception => e
21
- warn e.backtrace.join("\n")
22
- fail e.message
23
- ensure
24
- if real_file
25
- begin
26
- File.delete(filename)
27
- rescue StandardError
28
- nil
29
- end
30
- expect(!File.exist?(filename))
31
- end
32
- end
33
-
34
- before do
35
- unless ENV['RSPEC_SAVE_PDF']
36
- expect_any_instance_of(Prawn::Document).to receive(:render_file).once
37
- end
20
+ expect(File.binread(filename)).to start_with('%PDF')
38
21
  end
39
22
 
40
23
  context 'metric' do
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ # What a Ruby program, such as the MakeABox.io website, calls instead of the command line.
7
+ RSpec.describe Laser::Cutter do
8
+ let(:settings) { { width: 40, height: 30, depth: 20, thickness: 3, units: 'mm' } }
9
+ let(:options) { Laser::Cutter::Options.new(settings) }
10
+ let(:dir) { Dir.mktmpdir }
11
+
12
+ after { FileUtils.remove_entry(dir) }
13
+
14
+ describe '.render' do
15
+ subject(:document) { described_class.render(options) }
16
+
17
+ it { is_expected.to start_with('%PDF') }
18
+
19
+ it 'writes no file' do
20
+ expect { Dir.chdir(dir) { document } }.not_to(change { Dir.children(dir) })
21
+ end
22
+
23
+ it 'draws an SVG when the options ask for one' do
24
+ expect(described_class.render(options.new(format: :svg))).to include('<svg', 'width="109.46048mm"')
25
+ end
26
+
27
+ it 'takes a Hash, the way a form sends it' do
28
+ expect(described_class.render(settings.transform_keys(&:to_s).transform_values(&:to_s))).to start_with('%PDF')
29
+ end
30
+
31
+ it 'yields each line it draws' do
32
+ lines = []
33
+ described_class.render(options.new(format: 'svg')) { |line| lines << line }
34
+ expect(lines.size).to eq(280)
35
+ end
36
+
37
+ { nil => 280, '' => 280, 'full' => 280, :back => 208, 'back' => 208, :plain => 184, 'plain' => 184 }.each do |lid, lines|
38
+ it "draws #{lines} lines for the lid #{lid.inspect}" do
39
+ svg = described_class.render(settings.merge(lid: lid, format: :svg))
40
+ expect(svg.scan('<line').size).to eq(lines)
41
+ end
42
+ end
43
+
44
+ it 'names the lid in the description of an SVG' do
45
+ expect(described_class.render(settings.merge(lid: :plain, format: :svg))).to match(/<desc>.*lid: plain/m)
46
+ end
47
+
48
+ it 'leaves the description out of an SVG without metadata' do
49
+ expect(described_class.render(settings.merge(format: :svg, metadata: false))).not_to include('<desc>')
50
+ end
51
+
52
+ it 'draws the box without kerf as well in a PDF, when asked' do
53
+ lines = 0
54
+ described_class.render(options.new(inside_box: true)) { lines += 1 }
55
+ expect(lines).to eq(560)
56
+ end
57
+
58
+ it 'raises InvalidOption for a lid it does not know' do
59
+ expect { described_class.render(settings.merge(lid: 'sliding')) }.to raise_error(Laser::Cutter::InvalidOption, /lid/)
60
+ end
61
+
62
+ it 'raises MissingOption for a dimension left out' do
63
+ expect { described_class.render(settings.except(:depth)) }.to raise_error(Laser::Cutter::MissingOption, /depth is required/)
64
+ end
65
+ end
66
+
67
+ describe '.write' do
68
+ let(:path) { File.join(dir, 'box.svg') }
69
+
70
+ it 'takes the format from the extension, and returns the path' do
71
+ expect(described_class.write(options.new(file: path, lid: :back))).to eq(path)
72
+ expect(File.read(path).scan('<line').size).to eq(208)
73
+ end
74
+
75
+ it 'writes the format it is given, whatever the extension' do
76
+ described_class.write(settings.merge(file: path, format: :pdf))
77
+ expect(File.binread(path)).to start_with('%PDF')
78
+ end
79
+
80
+ it 'writes a PDF when the extension says nothing' do
81
+ described_class.write(settings.merge(file: File.join(dir, 'box')))
82
+ expect(File.binread(File.join(dir, 'box'))).to start_with('%PDF')
83
+ end
84
+
85
+ it 'raises MissingOption without a file' do
86
+ expect { described_class.write(options) }.to raise_error(Laser::Cutter::MissingOption, /file is required/)
87
+ end
88
+ end
89
+
90
+ # app/helpers/home_helper.rb and app/controllers/home_controller.rb, written against 1.0.3.
91
+ describe 'the calls MakeABox.io makes today' do
92
+ let(:form) { { 'width' => '40', 'height' => '30', 'depth' => '20', 'thickness' => '3', 'units' => 'mm', 'lid' => '' } }
93
+ let(:config) { Laser::Cutter::Configuration.new(form.merge(metadata: true)) }
94
+ let(:path) { File.join(dir, 'box.pdf') }
95
+
96
+ before { config['file'] = path }
97
+
98
+ it 'still draw a box' do
99
+ config.validate!
100
+ Laser::Cutter::Renderer::LayoutRenderer.new(config).render
101
+ expect(File.binread(path)).to start_with('%PDF')
102
+ end
103
+
104
+ it 'still list the page sizes' do
105
+ expect(Laser::Cutter::PageManager.new(config.units).page_size_values)
106
+ .to include(['A4', be_within(0.1).of(210), be_within(0.1).of(297)])
107
+ end
108
+
109
+ it 'still fail on a missing dimension with an error the website rescues' do
110
+ config.delete(:width)
111
+ expect { config.validate! }.to raise_error(Laser::Cutter::MissingOption)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reads the lines of one panel as the region they enclose, without using any
4
+ # of the code that drew them.
5
+ module Outline
6
+ module_function
7
+
8
+ # Whether a point is inside the outline, by the even-odd rule: a ray from
9
+ # the point crosses the outline an odd number of times.
10
+ #
11
+ # @param lines [Array<Laser::Cutter::Geometry::Line>] an outline of horizontal and vertical lines
12
+ # @return [Boolean]
13
+ def inside?(lines, x, y)
14
+ lines.count do |line|
15
+ next false unless line.p1.x == line.p2.x && line.p1.x > x
16
+
17
+ low, high = [line.p1.y, line.p2.y].sort
18
+ low < y && y < high
19
+ end.odd?
20
+ end
21
+
22
+ # Whether the outline grown by a margin on every side covers the point.
23
+ #
24
+ # @return [Boolean]
25
+ def within?(lines, x, y, margin)
26
+ offsets = [-margin, 0, margin]
27
+ offsets.product(offsets).any? { |dx, dy| inside?(lines, x + dx, y + dy) }
28
+ end
29
+
30
+ # Whether every line ends where another begins, so the outline has no gap.
31
+ #
32
+ # @return [Boolean]
33
+ def closed?(lines)
34
+ ends = lines.flat_map { |line| [line.p1, line.p2] }.map { |point| point.to_a.map { |c| c.round(6) + 0.0 } }
35
+ ends.tally.values.all?(&:even?)
36
+ end
37
+
38
+ # Points spread over a rectangle, off any round coordinate a line could sit on.
39
+ #
40
+ # @param rect [Laser::Cutter::Geometry::Rect]
41
+ # @param margin [Float] how far past the rectangle to reach
42
+ # @param steps [Integer] points per axis
43
+ # @return [Array<Array(Float, Float)>]
44
+ def samples(rect, margin:, steps: 61)
45
+ xs = spread(rect.p1.x - margin, rect.p2.x + margin, steps)
46
+ ys = spread(rect.p1.y - margin, rect.p2.y + margin, steps)
47
+ xs.product(ys)
48
+ end
49
+
50
+ def spread(from, to, steps)
51
+ Array.new(steps) { |i| from + ((to - from) * (i + 0.4142) / steps) }
52
+ end
53
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: laser-cutter
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: dry-struct
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.6'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.6'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: hashie
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -250,6 +264,8 @@ files:
250
264
  - ".github/workflows/rspec.yml"
251
265
  - ".gitignore"
252
266
  - ".plans/001.00-dry-cli-migration/plan.md"
267
+ - ".plans/002.00-lid-options/plan.md"
268
+ - ".plans/003.00-ruby-api/plan.md"
253
269
  - ".relaxed_rubocop.yml"
254
270
  - ".rspec"
255
271
  - ".rubocop.yml"
@@ -262,6 +278,11 @@ files:
262
278
  - README.md
263
279
  - Rakefile
264
280
  - docs/badges/coverage_badge.svg
281
+ - docs/images/box-lid-back.avif
282
+ - docs/images/box-lid-full.avif
283
+ - docs/images/box-lid-plain.avif
284
+ - docs/images/box-metric.avif
285
+ - docs/images/box-tray.avif
265
286
  - docs/images/comparison.jpg
266
287
  - exe/laser-cutter
267
288
  - exe/lc
@@ -287,6 +308,7 @@ files:
287
308
  - lib/laser/cutter/geometry/rect.rb
288
309
  - lib/laser/cutter/geometry/shape.rb
289
310
  - lib/laser/cutter/geometry/tuple.rb
311
+ - lib/laser/cutter/invalid_option.rb
290
312
  - lib/laser/cutter/launcher.rb
291
313
  - lib/laser/cutter/missing_option.rb
292
314
  - lib/laser/cutter/notching/base.rb
@@ -294,6 +316,7 @@ files:
294
316
  - lib/laser/cutter/notching/infinite_iterator.rb
295
317
  - lib/laser/cutter/notching/path_generator.rb
296
318
  - lib/laser/cutter/notching/shift.rb
319
+ - lib/laser/cutter/options.rb
297
320
  - lib/laser/cutter/page_manager.rb
298
321
  - lib/laser/cutter/renderer.rb
299
322
  - lib/laser/cutter/renderer/base.rb
@@ -303,11 +326,13 @@ files:
303
326
  - lib/laser/cutter/renderer/meta_renderer.rb
304
327
  - lib/laser/cutter/renderer/rect_renderer.rb
305
328
  - lib/laser/cutter/renderer/svg_renderer.rb
329
+ - lib/laser/cutter/types.rb
306
330
  - lib/laser/cutter/units_converter.rb
307
331
  - lib/laser/cutter/version.rb
308
332
  - lib/laser/cutter/zero_value_not_allowed.rb
309
333
  - lib/laser_cutter.rb
310
334
  - spec/laser/cutter/aggregator_spec.rb
335
+ - spec/laser/cutter/box_lid_spec.rb
311
336
  - spec/laser/cutter/box_spec.rb
312
337
  - spec/laser/cutter/cli/config_file_spec.rb
313
338
  - spec/laser/cutter/cli_spec.rb
@@ -315,6 +340,7 @@ files:
315
340
  - spec/laser/cutter/dimensions_spec.rb
316
341
  - spec/laser/cutter/edge_spec.rb
317
342
  - spec/laser/cutter/line_spec.rb
343
+ - spec/laser/cutter/options_spec.rb
318
344
  - spec/laser/cutter/page_manager_spec.rb
319
345
  - spec/laser/cutter/path_generator_spec.rb
320
346
  - spec/laser/cutter/point_spec.rb
@@ -322,8 +348,10 @@ files:
322
348
  - spec/laser/cutter/renderer/layout_renderer_spec.rb
323
349
  - spec/laser/cutter/renderer/svg_renderer_spec.rb
324
350
  - spec/laser/cutter/renderer_spec.rb
351
+ - spec/laser/cutter_spec.rb
325
352
  - spec/spec_helper.rb
326
353
  - spec/support/aruba.rb
354
+ - spec/support/outline.rb
327
355
  homepage: https://github.com/kigster/laser-cutter
328
356
  licenses:
329
357
  - MIT