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.
@@ -26,105 +26,63 @@ module Laser
26
26
  # We always want to create a symmetric path that has a notch in the middle
27
27
  # (for center_out = true) or dip in the middle (center_out = false)
28
28
  def generate
29
- shifts = define_shifts
30
- vertices = []
31
29
  lines = []
30
+ lines << corner_box_sides if corners
31
+ lines << notch_lines
32
+ lines.flatten
33
+ end
32
34
 
33
- if corners
34
- lines << corner_box_sides
35
- end
36
-
35
+ # The zigzag alone, without the corner boxes.
36
+ #
37
+ # @return [Array<Geometry::Line>]
38
+ def notch_lines
37
39
  point = starting_point
38
-
39
- vertices << point
40
- adjust_for_kerf(vertices, -1) if adjust_corners && !first_notch_out?
41
- shifts.each do |shift|
40
+ vertices = [point]
41
+ adjust_for_kerf(vertices, -1) if adjust_end?(1)
42
+ define_shifts.each do |shift|
42
43
  point = shift.next_point_after point
43
44
  vertices << point
44
45
  end
45
- adjust_for_kerf(vertices, 1) if adjust_corners && !first_notch_out?
46
- lines << create_lines(vertices)
47
- lines.flatten
46
+ adjust_for_kerf(vertices, 1) if adjust_end?(2)
47
+ create_lines(vertices)
48
48
  end
49
49
 
50
50
  def adjust_for_kerf(vertices, direction)
51
51
  return unless kerf?
52
52
 
53
53
  point = vertices.pop
54
- point = point.plus(2 * direction * shift_vector(1)) if corners
54
+ point = point.plus(2 * direction * shift_vector(1))
55
55
  vertices << point
56
56
  end
57
57
 
58
58
  def corner_box_sides
59
- boxes = []
60
- extra_lines = []
59
+ ends = Edge::ENDS.select { |end_index| edge.corner_at?(end_index) }
61
60
 
62
- # These two boxes occupy the corners of the 3D box. They do not match
61
+ # These boxes occupy the corners of the 3D box. They do not match
63
62
  # in width to our notches because they are usually merged with them.
64
63
  # It's just an aesthetic choice I guess.
65
- boxes << Geometry::Rect[edge.inside.p1.clone, edge.outside.p1.clone]
66
- boxes << Geometry::Rect[edge.inside.p2.clone, edge.outside.p2.clone]
67
-
68
- if kerf? && adjust_corners
69
- if first_notch_out?
70
- k = 2
71
- direction = -1
72
- dim_index = 1
73
- extra_lines << add_corners_when_out(dim_index, direction, k)
74
- else
75
- k = -2
76
- direction = 1
77
- dim_index = 0
78
- extra_lines << add_boxes_when_in(dim_index, direction, k)
79
- end
80
- end
81
- sides = boxes.flatten.map(&:relocate!).map(&:sides)
82
- sides << extra_lines if !extra_lines.empty?
64
+ boxes = ends.map { |end_index| Geometry::Rect[inside_point(end_index).clone, outside_point(end_index).clone] }
65
+ sides = boxes.map(&:relocate!).map(&:sides)
66
+ sides << ends.map { |end_index| kerf_strip(end_index) } if kerf? && adjust_corners
83
67
  sides.flatten
84
68
  end
85
69
 
86
- def add_boxes_when_in(dim_index, direction, k)
87
- v1 = k * direction * shift_vector(1, dim_index)
88
- v2 = k * direction * shift_vector(2, dim_index)
89
- p1 = edge.inside.p1.plus(v1)
90
- coords = []
91
- coords[d_index_along] = edge.inside.p1[d_index_along]
92
- coords[d_index_across] = edge.outside.p1[d_index_across]
93
- p2 = Geometry::Point[*coords]
94
- r1 = Geometry::Rect[p1, p2]
95
-
96
- p1 = edge.inside.p2.plus(v2)
97
- coords = []
98
- coords[d_index_along] = edge.inside.p2[d_index_along]
99
- coords[d_index_across] = edge.outside.p2[d_index_across]
100
- p2 = Geometry::Point[*coords]
101
- r2 = Geometry::Rect[p1, p2]
102
- lines = [r1, r2].map(&:sides).flatten
103
- lines << Geometry::Line[edge.inside.p1.plus(v1), edge.inside.p1.clone]
104
- lines << Geometry::Line[edge.inside.p2.plus(v2), edge.inside.p2.clone]
105
- lines
106
- end
107
-
108
- def add_corners_when_out(dim_index, direction, k)
109
- v1 = direction * k * shift_vector(1, dim_index)
110
- v2 = direction * k * shift_vector(2, dim_index)
111
- p1 = edge.inside.p1.plus(v1)
112
- coords = []
113
- coords[d_index_along] = edge.outside.p1[d_index_along]
114
- coords[d_index_across] = edge.inside.p1[d_index_across]
115
- p2 = Geometry::Point[*coords]
116
- r1 = Geometry::Rect[p1, p2]
70
+ # Widens the corner box at one end by the kerf, on the side where
71
+ # nothing is attached to it: along the edge when the first notch is a
72
+ # hole, across it when the first notch is a tab.
73
+ #
74
+ # @param end_index [Integer] 1 for p1, 2 for p2
75
+ # @return [Array<Geometry::Line>]
76
+ def kerf_strip(end_index)
77
+ inside = inside_point(end_index)
78
+ outside = outside_point(end_index)
79
+ along, across = first_notch_out? ? [outside, inside] : [inside, outside]
80
+ moved = inside.plus(-2 * shift_vector(end_index, first_notch_out? ? 1 : 0))
117
81
 
118
- p1 = edge.inside.p2.plus(v2)
119
82
  coords = []
120
- coords[d_index_along] = edge.outside.p2[d_index_along]
121
- coords[d_index_across] = edge.inside.p2[d_index_across]
122
- p2 = Geometry::Point[*coords]
123
- r2 = Geometry::Rect[p1, p2]
124
- lines = [r1, r2].map(&:sides).flatten
125
- lines << Geometry::Line[edge.inside.p1.plus(v1), edge.inside.p1.clone]
126
- lines << Geometry::Line[edge.inside.p2.plus(v2), edge.inside.p2.clone]
127
- lines
83
+ coords[d_index_along] = along[d_index_along]
84
+ coords[d_index_across] = across[d_index_across]
85
+ Geometry::Rect[moved, Geometry::Point[*coords]].sides << Geometry::Line[moved, inside.clone]
128
86
  end
129
87
 
130
88
  def shift_vector(index, dim_shift = 0)
@@ -157,6 +115,19 @@ module Laser
157
115
 
158
116
  private
159
117
 
118
+ def inside_point(end_index)
119
+ edge.inside.public_send(:"p#{end_index}")
120
+ end
121
+
122
+ def outside_point(end_index)
123
+ edge.outside.public_send(:"p#{end_index}")
124
+ end
125
+
126
+ # A corner box next to a hole is widened by the kerf, so the hole starts that much later.
127
+ def adjust_end?(end_index)
128
+ adjust_corners && !first_notch_out? && edge.corner_at?(end_index)
129
+ end
130
+
160
131
  # This method has the bulk of the logic: we create the list of path deltas
161
132
  # to be applied when we walk the edge next.
162
133
  # @param [Object] shift
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ # Everything a box can be asked for, typed. This is what a Ruby program
6
+ # hands to {Laser::Cutter.render} or {Laser::Cutter.write}.
7
+ #
8
+ # Values are coerced, so the Strings of a web form will do, and an
9
+ # instance cannot be changed: #new(lid: :plain) returns a changed copy.
10
+ # Keys may be Strings or Symbols. A key it does not
11
+ # know, a value of the wrong type or a dimension left out all raise,
12
+ # at once, before anything is drawn.
13
+ #
14
+ # The distances left nil take the default for the units: a kerf of
15
+ # 0.0024in, a margin of 0.125in, a padding of 0.1in, a stroke of 0.001in,
16
+ # and a notch three times the thickness.
17
+ #
18
+ # @example A box in millimeters, with a plain lid, as an SVG
19
+ # options = Laser::Cutter::Options.new(width: 70, height: 20, depth: 50, thickness: 4.3,
20
+ # units: :mm, lid: :plain, format: :svg)
21
+ # options.lid # => "plain"
22
+ # Laser::Cutter.render(options) # => "<svg ..."
23
+ #
24
+ # @raise [MissingOption] when width, height, depth or thickness is left out
25
+ # @raise [InvalidOption] when a value cannot be used, or a key is not an option
26
+ class Options < Dry::Struct
27
+ # The dimensions a box cannot be drawn without.
28
+ REQUIRED = %i[width height depth thickness].freeze
29
+
30
+ # The longest list of allowed values an error spells out.
31
+ MAXIMUM_CHOICES = 5
32
+
33
+ transform_keys(&:to_sym)
34
+ schema schema.strict
35
+
36
+ # @!attribute [r] width
37
+ # @return [Float] internal width of the box
38
+ attribute :width, Types::Length
39
+
40
+ # @!attribute [r] height
41
+ # @return [Float] internal height of the box
42
+ attribute :height, Types::Length
43
+
44
+ # @!attribute [r] depth
45
+ # @return [Float] internal depth of the box
46
+ attribute :depth, Types::Length
47
+
48
+ # @!attribute [r] thickness
49
+ # @return [Float] thickness of the material
50
+ attribute :thickness, Types::Length
51
+
52
+ # @!attribute [r] notch
53
+ # @return [Float, nil] notch length, a guide only; nil for three times the thickness
54
+ attribute? :notch, Types.optional(Types::Length)
55
+
56
+ # @!attribute [r] kerf
57
+ # @return [Float, nil] width of the cut; nil for the default
58
+ attribute? :kerf, Types.optional(Types::Allowance)
59
+
60
+ # @!attribute [r] units
61
+ # @return [String] "in" or "mm", the units of every distance here
62
+ attribute :units, Types::Units
63
+
64
+ # @!attribute [r] lid
65
+ # @return [String] "full", "back" or "plain": notched on every side, into the back wall only, or on no side
66
+ attribute :lid, Types::Lid
67
+
68
+ # @!attribute [r] format
69
+ # @return [String, nil] "pdf" or "svg"; nil for a PDF, or for what the extension of the file says
70
+ attribute? :format, Types.optional(Types::Format)
71
+
72
+ # @!attribute [r] file
73
+ # @return [String, nil] the file {Laser::Cutter.write} writes
74
+ attribute? :file, Types.optional(Types::Coercible::String)
75
+
76
+ # @!attribute [r] margin
77
+ # @return [Float, nil] margin from the edge of the page; nil for the default
78
+ attribute? :margin, Types.optional(Types::Allowance)
79
+
80
+ # @!attribute [r] padding
81
+ # @return [Float, nil] space between the panels; nil for the default
82
+ attribute? :padding, Types.optional(Types::Allowance)
83
+
84
+ # @!attribute [r] stroke
85
+ # @return [Float, nil] stroke width of the lines; nil for the default
86
+ attribute? :stroke, Types.optional(Types::Length)
87
+
88
+ # @!attribute [r] page_size
89
+ # @return [String, nil] a page size such as "A4", for a PDF; nil to fit the box
90
+ attribute? :page_size, Types.optional(Types::PageSize)
91
+
92
+ # @!attribute [r] page_layout
93
+ # @return [String] "portrait" or "landscape", for a PDF
94
+ attribute :page_layout, Types::PageLayout
95
+
96
+ # @!attribute [r] metadata
97
+ # @return [Boolean] whether the settings of the box are printed on the page
98
+ attribute :metadata, Types::Params::Bool.default(true)
99
+
100
+ # @!attribute [r] inside_box
101
+ # @return [Boolean] whether the box is also drawn without kerf, in red
102
+ attribute :inside_box, Types::Params::Bool.default(false)
103
+
104
+ class << self
105
+ # @param attributes [Hash, Options] the options, by name
106
+ # @return [Options]
107
+ # @raise [MissingOption, InvalidOption]
108
+ def new(attributes = {}, *)
109
+ return attributes if attributes.is_a?(self)
110
+
111
+ require_dimensions!(attributes) if attributes.respond_to?(:to_h)
112
+ super
113
+ rescue Dry::Struct::Error => e
114
+ raise InvalidOption, explain(e)
115
+ end
116
+
117
+ private
118
+
119
+ def require_dimensions!(attributes)
120
+ given = attributes.to_h.transform_keys(&:to_sym)
121
+ missing = REQUIRED.select { |name| Types.blank?(given[name]) }
122
+ return if missing.empty?
123
+
124
+ raise MissingOption, "#{missing.join(', ')} #{missing.size > 1 ? 'are' : 'is'} required, but missing."
125
+ end
126
+
127
+ # Says which option was refused, and what it may be when that is a short list.
128
+ def explain(error)
129
+ cause = error.cause
130
+ return error.message.sub(/\A\[.*?\] /, '') unless cause.respond_to?(:key) && cause.respond_to?(:value)
131
+
132
+ choices = choices_of(schema.key(cause.key).type)
133
+ allowed = choices && choices.size <= MAXIMUM_CHOICES ? ", but must be one of: #{choices.join(', ')}" : ''
134
+ "#{cause.key} cannot be #{cause.value.inspect}#{allowed}."
135
+ end
136
+
137
+ # The words an attribute may be, found under whatever wraps its type.
138
+ #
139
+ # @return [Array<String>, nil] nil when the attribute is not one of a list
140
+ def choices_of(type)
141
+ return type.values if type.respond_to?(:values)
142
+
143
+ inner = %i[right type].find { |name| type.respond_to?(name) }
144
+ choices_of(type.public_send(inner)) if inner
145
+ end
146
+ end
147
+
148
+ # @return [Configuration] the same settings, as the renderers take them
149
+ def to_configuration
150
+ Configuration.new(to_h.except(:format, :inside_box).merge(debug: inside_box))
151
+ end
152
+ end
153
+ end
154
+ end
@@ -18,7 +18,13 @@ module Laser
18
18
  self.page_manager = Laser::Cutter::PageManager.new(config.units)
19
19
  end
20
20
 
21
- def render
21
+ # Writes the document to config.file, yielding after each line drawn.
22
+ def render(&)
23
+ File.binwrite(config.file, document(&))
24
+ end
25
+
26
+ # @return [String] the document, yielding after each line drawn
27
+ def document
22
28
  raise 'Abstract method'
23
29
  end
24
30
 
@@ -18,8 +18,8 @@ module Laser
18
18
  box_renderers.sum { |renderer| renderer.lines.size }
19
19
  end
20
20
 
21
- # Writes the file, yielding after each line drawn.
22
- def render(&)
21
+ # @return [String] the PDF, yielding after each line drawn
22
+ def document(&)
23
23
  margin = config.margin.to_f.send(units)
24
24
  pdf = Prawn::Document.new(margin: margin,
25
25
  page_size: config.page_size || calculate_image_boundary(box_renderers.first, margin),
@@ -28,7 +28,7 @@ module Laser
28
28
  box_renderers.first.render(pdf, &)
29
29
  meta_renderer&.render(pdf)
30
30
  box_renderers.drop(1).each { |renderer| renderer.render(pdf, &) }
31
- pdf.render_file(config.file)
31
+ pdf.render
32
32
  end
33
33
 
34
34
  def calculate_image_boundary(box_renderer, margin)
@@ -4,7 +4,7 @@ module Laser
4
4
  module Cutter
5
5
  module Renderer
6
6
  class MetaRenderer < Laser::Cutter::Renderer::Base
7
- META_KEYS = %i(units width height depth thickness notch kerf stroke padding margin page_size page_layout).freeze
7
+ META_KEYS = %i(units width height depth thickness notch lid kerf stroke padding margin page_size page_layout).freeze
8
8
 
9
9
  def initialize(config = {})
10
10
  self.config = config
@@ -24,15 +24,15 @@ module Laser
24
24
  lines.size
25
25
  end
26
26
 
27
- # Writes the file, yielding after each line drawn.
28
- def render
27
+ # @return [String] the SVG, yielding after each line drawn
28
+ def document
29
29
  svg = Victor::SVG.new(width: "#{width}#{units}", height: "#{height}#{units}", viewBox: "0 0 #{width} #{height}")
30
30
  svg.element(:desc, description) if config.metadata
31
31
  lines.each do |line|
32
32
  svg.line(**coordinates(line), stroke: "##{config[:color] || BLACK}", stroke_width: config.stroke)
33
33
  yield line if block_given?
34
34
  end
35
- File.write(config.file, svg.render)
35
+ svg.render
36
36
  end
37
37
 
38
38
  private
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ # The types of the attributes of {Options}. Each one coerces what a form
6
+ # or a command line sends, Strings mostly, and rejects what it cannot use.
7
+ module Types
8
+ include Dry.Types()
9
+
10
+ # Whether a value was left out: nil, or the empty String of a blank form field.
11
+ #
12
+ # @param value [Object]
13
+ # @return [Boolean]
14
+ def self.blank?(value)
15
+ value.nil? || Dry::Types::Undefined.equal?(value) || (value.respond_to?(:empty?) && value.empty?)
16
+ end
17
+
18
+ # One of a few words, given as a String or a Symbol. Blank means the default.
19
+ #
20
+ # @param values [Array<String>] the words allowed
21
+ # @param default [String] the word a blank value stands for
22
+ # @return [Dry::Types::Type]
23
+ def self.choice(values, default:)
24
+ String.default(default).enum(*values).constructor { |value| blank?(value) ? Dry::Types::Undefined : value.to_s }
25
+ end
26
+
27
+ # A type that also takes nil, and reads a blank value as nil.
28
+ #
29
+ # @param type [Dry::Types::Type]
30
+ # @return [Dry::Types::Type]
31
+ def self.optional(type)
32
+ type.optional.constructor { |value| blank?(value) ? nil : value }
33
+ end
34
+
35
+ # A size of the box or of its material: a number above zero.
36
+ Length = Coercible::Float.constrained(gt: 0)
37
+
38
+ # A distance that may be zero, such as the kerf or a margin.
39
+ Allowance = Coercible::Float.constrained(gteq: 0)
40
+
41
+ # The units every dimension is in.
42
+ Units = choice(%w[in mm], default: 'in')
43
+
44
+ # How the lid joins the walls, see Box::LIDS.
45
+ Lid = choice(Box::LIDS.map(&:to_s), default: 'full')
46
+
47
+ # The way the page is turned, for a PDF.
48
+ PageLayout = choice(%w[portrait landscape], default: 'portrait')
49
+
50
+ # The kind of file to draw.
51
+ Format = Coercible::String.constructor(&:downcase).enum(*Renderer::FORMATS.keys)
52
+
53
+ # The name of a page size a PDF can take, such as A4 or LETTER.
54
+ PageSize = Coercible::String.constructor(&:upcase).enum(*PageManager::SIZES.keys)
55
+ end
56
+ end
57
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Laser
4
4
  module Cutter
5
- VERSION = "2.0.0"
5
+ VERSION = "2.0.1"
6
6
  end
7
7
  end
data/lib/laser/cutter.rb CHANGED
@@ -14,12 +14,50 @@ require "dry/cli"
14
14
  require "dry/cli/help"
15
15
  require "dry/cli/ui"
16
16
  require "dry/cli/autocomplete/command"
17
+ require "dry/struct"
17
18
  require "tty-screen"
18
19
  require "zeitwerk"
19
20
 
20
21
  module Laser
22
+ # Draws the notched panels of a box. A Ruby program needs {Options},
23
+ # {Cutter.render} and {Cutter.write}; the command line is one caller of many.
21
24
  module Cutter
22
25
  class Error < StandardError; end
26
+
27
+ class << self
28
+ # Draws a box, and returns the document without writing a file.
29
+ #
30
+ # @example In a Rails controller
31
+ # box = params.require(:box).permit(*Laser::Cutter::Options.attribute_names)
32
+ # options = Laser::Cutter::Options.new(box.to_h)
33
+ # send_data Laser::Cutter.render(options), type: "application/pdf", filename: "box.pdf"
34
+ #
35
+ # @param options [Options, Hash] a Hash becomes an {Options}
36
+ # @yieldparam line [Geometry::Line] each line, once it is drawn
37
+ # @return [String] the PDF, or the SVG when the options ask for one
38
+ # @raise [MissingOption, InvalidOption]
39
+ def render(options, &)
40
+ options = Options.new(options)
41
+ Renderer.for(options.format || Renderer::FORMATS.keys.first, options.to_configuration).document(&)
42
+ end
43
+
44
+ # Draws a box into the file the options name. Without a format, the
45
+ # extension of the file decides, and anything but .svg is a PDF.
46
+ #
47
+ # @param options [Options, Hash] a Hash becomes an {Options}
48
+ # @yieldparam line [Geometry::Line] each line, once it is drawn
49
+ # @return [String] the path of the file
50
+ # @raise [MissingOption, InvalidOption]
51
+ def write(options, &)
52
+ options = Options.new(options)
53
+ raise MissingOption, 'file is required, but missing.' unless options.file
54
+
55
+ extension = File.extname(options.file).delete('.').downcase
56
+ format = options.format || (Renderer::FORMATS.key?(extension) ? extension : nil)
57
+ File.binwrite(options.file, render(options.new(format: format), &))
58
+ options.file
59
+ end
60
+ end
23
61
  end
24
62
  end
25
63
 
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'digest'
5
+
6
+ module Laser
7
+ module Cutter
8
+ # Every outline here is checked as a region, point by point, against a
9
+ # description of the panel that owes nothing to the code that drew it.
10
+ RSpec.describe Box, 'lid' do
11
+ # Wide enough for a sample to land in every strip the kerf adds.
12
+ let(:kerf) { 0.5 }
13
+ let(:boxes) { Hash.new { |cache, (lid, cut)| cache[[lid, cut]] = build(lid, cut) } }
14
+
15
+ def build(lid, cut)
16
+ settings = dimensions.merge(units: 'mm', file: 'box.pdf', lid: lid, kerf: cut)
17
+ Box.new(Configuration.new(settings)).tap(&:generate_notches)
18
+ end
19
+
20
+ # @return [Array<Array(Float, Float)>] points over a face and the margin around it
21
+ def samples(face)
22
+ Outline.samples(face, margin: 1.5 * dimensions[:thickness])
23
+ end
24
+
25
+ # @return [Boolean] whether a point is inside the named panel of a box cut without kerf
26
+ def material?(lid, name, x, y)
27
+ Outline.inside?(boxes[[lid, 0.0]].outlines[name], x, y)
28
+ end
29
+
30
+ def between?(value, from, to)
31
+ from < value && value < to
32
+ end
33
+
34
+ # The region each panel should cover without kerf, given the box with a full lid.
35
+ def expected?(lid, face, x, y)
36
+ t = dimensions[:thickness]
37
+ full = material?(:full, face.name, x, y)
38
+ across = between?(x, face.p1.x, face.p2.x)
39
+ case face.name
40
+ when 'bottom' then full
41
+ when 'front', 'left', 'right' then full && y > face.p1.y
42
+ when 'back' then lid == :plain ? full && y < face.p2.y : full && (across || y < face.p2.y)
43
+ when 'top'
44
+ covered = between?(x, face.p1.x - t, face.p2.x + t) && between?(y, face.p1.y - t, face.p2.y + t)
45
+ lid == :plain || y > face.p1.y ? covered : covered && (full || !across)
46
+ end
47
+ end
48
+
49
+ shared_examples 'a box with a lid that lifts off' do |lid|
50
+ let(:box) { boxes[[lid, 0.0]] }
51
+ let(:cut) { boxes[[lid, kerf]] }
52
+
53
+ it 'closes every outline' do
54
+ expect([box, cut].flat_map { |b| b.outlines.values }).to all(satisfy { |lines| Outline.closed?(lines) })
55
+ end
56
+
57
+ it 'keeps every panel where the full lid has it' do
58
+ expect(box.faces.map(&:to_a)).to eq(boxes[[:full, 0.0]].faces.map(&:to_a))
59
+ end
60
+
61
+ %w[top front back left right bottom].each do |name|
62
+ it "cuts the #{name} panel to shape" do
63
+ face = box.public_send(name)
64
+ wrong = samples(face).reject { |x, y| material?(lid, name, x, y) == expected?(lid, face, x, y) }
65
+ expect(wrong).to be_empty
66
+ end
67
+
68
+ it "grows the #{name} panel by half the kerf on every side" do
69
+ wrong = samples(box.public_send(name)).reject do |x, y|
70
+ Outline.inside?(cut.outlines[name], x, y) == Outline.within?(box.outlines[name], x, y, kerf / 2)
71
+ end
72
+ expect(wrong).to be_empty
73
+ end
74
+ end
75
+ end
76
+
77
+ shared_examples 'a box of any size' do
78
+ describe 'with a plain lid' do
79
+ it_behaves_like 'a box with a lid that lifts off', :plain
80
+
81
+ it 'draws the lid as four lines' do
82
+ expect(boxes[[:plain, kerf]].outlines['top'].size).to eq(4)
83
+ end
84
+
85
+ it 'sizes the lid to the outside of the walls, plus the kerf' do
86
+ w, d, t = dimensions.values_at(:width, :depth, :thickness)
87
+ lengths = boxes[[:plain, kerf]].outlines['top'].map(&:length).sort
88
+ expect(lengths).to match_array([d, d, w, w].map { |side| be_within(1e-6).of(side + (2 * t) + kerf) })
89
+ end
90
+ end
91
+
92
+ describe 'with a lid notched into the back wall' do
93
+ it_behaves_like 'a box with a lid that lifts off', :back
94
+
95
+ it 'leaves the back wall its notches under the lid' do
96
+ back = boxes[[:back, 0.0]].back
97
+ tabs = samples(back).count { |x, y| y > back.p2.y && material?(:back, 'back', x, y) }
98
+ expect(tabs).to be_positive
99
+ end
100
+ end
101
+ end
102
+
103
+ context 'when the lid would own the corners anyway, and its notches start with a tab' do
104
+ let(:dimensions) { { width: 50, height: 60, depth: 70, thickness: 6, notch: 10 } }
105
+
106
+ it_behaves_like 'a box of any size'
107
+ end
108
+
109
+ context 'when the front would own the corners, and the notches of the lid start with a hole' do
110
+ let(:dimensions) { { width: 40, height: 30, depth: 20, thickness: 3, notch: 5 } }
111
+
112
+ it_behaves_like 'a box of any size'
113
+ end
114
+
115
+ context 'when the box is wide and its material thin' do
116
+ let(:dimensions) { { width: 100, height: 80, depth: 60, thickness: 3, notch: 9 } }
117
+
118
+ it_behaves_like 'a box of any size'
119
+ end
120
+
121
+ describe 'a full lid' do
122
+ # Digests of the lines 2.0.0 drew, before the lid became an option.
123
+ {
124
+ [{ width: 50, height: 60, depth: 70, thickness: 6, notch: 10 }, 0.0] => '35d6a17c6f524b00',
125
+ [{ width: 50, height: 60, depth: 70, thickness: 6, notch: 10 }, 0.1] => 'af287e64ac6a249f',
126
+ [{ width: 40, height: 30, depth: 20, thickness: 3, notch: 6 }, 0.15] => '1aed155401688002',
127
+ [{ width: 100, height: 80, depth: 60, thickness: 3, notch: 9 }, 0.1] => '06b3cbc4e66bd18b'
128
+ }.each do |(dimensions, cut), digest|
129
+ it "draws what 2.0.0 drew for #{dimensions.values.join('x')} with a #{cut} kerf" do
130
+ lines = Box.new(Configuration.new(dimensions.merge(units: 'mm', file: 'box.pdf', kerf: cut))).generate_notches
131
+ coordinates = lines.map { |l| [l.p1.x, l.p1.y, l.p2.x, l.p2.y].map { |v| v.round(6) + 0.0 } }.sort
132
+ expect(Digest::SHA256.hexdigest(coordinates.inspect)[0, 16]).to eq(digest)
133
+ end
134
+ end
135
+
136
+ it 'is the default' do
137
+ expect(Box.new(Configuration.new(box: '4x3x2/0.125', file: 'box.pdf')).lid).to eq(:full)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end