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.
- checksums.yaml +4 -4
- data/.plans/002.00-lid-options/plan.md +45 -0
- data/.plans/003.00-ruby-api/plan.md +37 -0
- data/CLAUDE.md +15 -2
- data/README.md +106 -1
- data/docs/images/box-lid-back.avif +0 -0
- data/docs/images/box-lid-full.avif +0 -0
- data/docs/images/box-lid-plain.avif +0 -0
- data/docs/images/box-metric.avif +0 -0
- data/docs/images/box-tray.avif +0 -0
- data/laser-cutter.gemspec +1 -0
- data/lib/laser/cutter/box.rb +97 -19
- data/lib/laser/cutter/cli/examples.rb +5 -0
- data/lib/laser/cutter/cli/generate.rb +7 -2
- data/lib/laser/cutter/configuration.rb +6 -0
- data/lib/laser/cutter/invalid_option.rb +8 -0
- data/lib/laser/cutter/notching/edge.rb +15 -0
- data/lib/laser/cutter/notching/path_generator.rb +46 -75
- data/lib/laser/cutter/options.rb +154 -0
- data/lib/laser/cutter/renderer/base.rb +7 -1
- data/lib/laser/cutter/renderer/layout_renderer.rb +3 -3
- data/lib/laser/cutter/renderer/meta_renderer.rb +1 -1
- data/lib/laser/cutter/renderer/svg_renderer.rb +3 -3
- data/lib/laser/cutter/types.rb +57 -0
- data/lib/laser/cutter/version.rb +1 -1
- data/lib/laser/cutter.rb +38 -0
- data/spec/laser/cutter/box_lid_spec.rb +142 -0
- data/spec/laser/cutter/cli_spec.rb +27 -0
- data/spec/laser/cutter/configuration_spec.rb +17 -0
- data/spec/laser/cutter/options_spec.rb +120 -0
- data/spec/laser/cutter/renderer/layout_renderer_spec.rb +6 -23
- data/spec/laser/cutter_spec.rb +114 -0
- data/spec/support/outline.rb +53 -0
- metadata +29 -1
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
40
|
-
|
|
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
|
|
46
|
-
|
|
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))
|
|
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
|
-
|
|
60
|
-
extra_lines = []
|
|
59
|
+
ends = Edge::ENDS.select { |end_index| edge.corner_at?(end_index) }
|
|
61
60
|
|
|
62
|
-
# These
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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] =
|
|
121
|
-
coords[d_index_across] =
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
22
|
-
def
|
|
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.
|
|
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
|
-
#
|
|
28
|
-
def
|
|
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
|
-
|
|
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
|
data/lib/laser/cutter/version.rb
CHANGED
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
|