laser-cutter 1.0.5 → 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 +5 -5
- data/.envrc +2 -0
- data/.github/workflows/lint.yml +22 -0
- data/.github/workflows/rspec.yml +22 -0
- data/.gitignore +5 -5
- data/.plans/001.00-dry-cli-migration/plan.md +29 -0
- data/.plans/002.00-lid-options/plan.md +45 -0
- data/.plans/003.00-ruby-api/plan.md +37 -0
- data/.relaxed_rubocop.yml +153 -0
- data/.rspec +1 -0
- data/.rubocop.yml +31 -0
- data/.rubocop_todo.yml +90 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +75 -0
- data/CONTRIBUTING.md +2 -4
- data/Gemfile +14 -0
- data/README.md +207 -79
- data/Rakefile +27 -13
- data/docs/badges/coverage_badge.svg +21 -0
- 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/exe/laser-cutter +8 -0
- data/exe/lc +1 -0
- data/justfile +97 -0
- data/laser-cutter.gemspec +27 -14
- data/lib/laser/cutter/aggregator.rb +101 -0
- data/lib/laser/cutter/box.rb +251 -0
- 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 +47 -0
- data/lib/laser/cutter/cli/generate.rb +95 -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 +107 -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/invalid_option.rb +8 -0
- 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 +36 -20
- data/lib/laser/cutter/notching/infinite_iterator.rb +27 -0
- data/lib/laser/cutter/notching/path_generator.rb +187 -0
- data/lib/laser/cutter/notching/shift.rb +17 -0
- data/lib/laser/cutter/options.rb +154 -0
- data/lib/laser/cutter/page_manager.rb +50 -0
- data/lib/{laser-cutter → laser/cutter}/renderer/base.rb +10 -3
- 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/types.rb +57 -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 +67 -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_lid_spec.rb +142 -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 +213 -0
- data/spec/laser/cutter/configuration_spec.rb +118 -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/laser/cutter/options_spec.rb +120 -0
- 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 +85 -0
- data/spec/laser/cutter/renderer/svg_renderer_spec.rb +51 -0
- data/spec/laser/cutter/renderer_spec.rb +19 -0
- data/spec/laser/cutter_spec.rb +114 -0
- data/spec/spec_helper.rb +35 -22
- data/spec/support/aruba.rb +10 -0
- data/spec/support/outline.rb +53 -0
- metadata +230 -76
- data/.travis.yml +0 -26
- data/BOXMAKER.md +0 -51
- data/LICENSE +0 -22
- data/bin/laser-cutter +0 -19
- data/lib/laser-cutter/aggregator.rb +0 -57
- data/lib/laser-cutter/box.rb +0 -183
- 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/face.rb +0 -18
- data/lib/laser-cutter/geometry.rb +0 -11
- data/lib/laser-cutter/notching/path_generator.rb +0 -234
- data/lib/laser-cutter/notching.rb +0 -9
- 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 -39
- data/spec/configuration_spec.rb +0 -81
- data/spec/edge_spec.rb +0 -43
- data/spec/renderer_spec.rb +0 -72
- /data/docs/{comparison.jpg → images/comparison.jpg} +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
# Merges the lines of one face into the outline to cut.
|
|
6
|
+
#
|
|
7
|
+
# Each edge draws its own notch path and its own corner boxes, so where
|
|
8
|
+
# two of them meet the same stretch is drawn twice, once from each side.
|
|
9
|
+
# That stretch runs through the material and must not be cut. So every
|
|
10
|
+
# collinear group of lines is combined as a symmetric difference: a point
|
|
11
|
+
# is cut when an odd number of lines cover it. Two identical lines cancel,
|
|
12
|
+
# two overlapping lines keep only the parts they do not share, and lines
|
|
13
|
+
# that touch or overlap end to end join into one.
|
|
14
|
+
#
|
|
15
|
+
# Lines are grouped by axis and offset, sorted, and swept once, so a face
|
|
16
|
+
# of n lines takes n log n. Lines on neither axis pass through untouched.
|
|
17
|
+
class Aggregator
|
|
18
|
+
# Coordinates this close together count as the same.
|
|
19
|
+
TOLERANCE = Math.sqrt(Geometry::Tuple::PRECISION)
|
|
20
|
+
|
|
21
|
+
# @return [Array<Geometry::Line>] the outline, sorted
|
|
22
|
+
attr_reader :lines
|
|
23
|
+
|
|
24
|
+
# @param lines [Array<Geometry::Line>]
|
|
25
|
+
def initialize(lines = [])
|
|
26
|
+
@lines = (sweep(lines, 0) + sweep(lines, 1) + skewed(lines)).sort
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Lines running along one axis: 0 for horizontal (constant y), 1 for vertical (constant x).
|
|
32
|
+
#
|
|
33
|
+
# @return [Array<Geometry::Line>]
|
|
34
|
+
def sweep(lines, axis)
|
|
35
|
+
across = (axis + 1) % 2
|
|
36
|
+
along = lines.select { |line| along?(line, axis) }
|
|
37
|
+
groups(along, across).flat_map { |offset, group| cut_intervals(group, axis, offset, across) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def skewed(lines)
|
|
41
|
+
lines.reject { |line| along?(line, 0) || along?(line, 1) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def along?(line, axis)
|
|
45
|
+
across = (axis + 1) % 2
|
|
46
|
+
(line.p1[across] - line.p2[across]).abs <= TOLERANCE && (line.p1[axis] - line.p2[axis]).abs > TOLERANCE
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Lines on the same row or column, keyed by the offset of the first one seen.
|
|
50
|
+
def groups(lines, across)
|
|
51
|
+
lines.sort_by { |line| line.p1[across] }.slice_when { |a, b| b.p1[across] - a.p1[across] > TOLERANCE }
|
|
52
|
+
.to_h { |group| [group.first.p1[across], group] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Sweeps the endpoints of one collinear group, keeping the stretches an
|
|
56
|
+
# odd number of lines cover.
|
|
57
|
+
def cut_intervals(group, axis, offset, across)
|
|
58
|
+
events = group.flat_map do |line|
|
|
59
|
+
from, to = [line.p1[axis], line.p2[axis]].sort
|
|
60
|
+
[[from, 1], [to, -1]]
|
|
61
|
+
end
|
|
62
|
+
events.sort_by! { |position, step| [position, step] }
|
|
63
|
+
|
|
64
|
+
intervals = []
|
|
65
|
+
covered = 0
|
|
66
|
+
started = nil
|
|
67
|
+
events.each do |position, step|
|
|
68
|
+
was = covered
|
|
69
|
+
covered += step
|
|
70
|
+
if was.even? && covered.odd?
|
|
71
|
+
started = position
|
|
72
|
+
elsif was.odd? && covered.even?
|
|
73
|
+
extend_or_add(intervals, started, position)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
intervals.map { |from, to| line_between(from, to, axis, offset, across) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Joins an interval onto the last one when they touch, and drops one with no length.
|
|
80
|
+
def extend_or_add(intervals, from, to)
|
|
81
|
+
return if to - from <= TOLERANCE
|
|
82
|
+
|
|
83
|
+
last = intervals.last
|
|
84
|
+
if last && from - last[1] <= TOLERANCE
|
|
85
|
+
last[1] = to
|
|
86
|
+
else
|
|
87
|
+
intervals << [from, to]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def line_between(from, to, axis, offset, across)
|
|
92
|
+
a = []
|
|
93
|
+
b = []
|
|
94
|
+
a[axis] = from
|
|
95
|
+
b[axis] = to
|
|
96
|
+
a[across] = b[across] = offset
|
|
97
|
+
Geometry::Line.new(Geometry::Point[*a], Geometry::Point[*b])
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laser
|
|
4
|
+
module Cutter
|
|
5
|
+
# Note: this class badly needs refactoring and tests. Both are coming.
|
|
6
|
+
|
|
7
|
+
class Box
|
|
8
|
+
# How the lid, the top panel, joins the walls: notched on all four sides,
|
|
9
|
+
# on the side of the back wall only, or on none.
|
|
10
|
+
LIDS = %i[full back plain].freeze
|
|
11
|
+
|
|
12
|
+
# The side of each wall that meets the lid, as an index into Rect#sides.
|
|
13
|
+
LID_SIDES = { 'front' => 0, 'left' => 0, 'right' => 0, 'back' => 2 }.freeze
|
|
14
|
+
|
|
15
|
+
# Everything is in millimeters
|
|
16
|
+
|
|
17
|
+
attr_accessor :dim, :thickness, :notch_width, :kerf, :padding, :units, :inside_box, :front, :back, :top, :bottom, :left, :right, :faces, :bounds, :conf, :corner_face, :metadata, :notches
|
|
18
|
+
|
|
19
|
+
# @return [Symbol] one of LIDS
|
|
20
|
+
attr_accessor :lid
|
|
21
|
+
|
|
22
|
+
# @return [Hash{String => Array<Geometry::Line>}] the lines to cut for each face, by its name
|
|
23
|
+
attr_accessor :outlines
|
|
24
|
+
|
|
25
|
+
def initialize(config = {})
|
|
26
|
+
self.dim = Geometry::Dimensions.new(config['width'], config['height'], config['depth'])
|
|
27
|
+
self.thickness = config['thickness']
|
|
28
|
+
|
|
29
|
+
self.notch_width = config['notch'] || (1.0 * longest / 5.0)
|
|
30
|
+
self.kerf = config['kerf'] || 0.0
|
|
31
|
+
self.padding = config['padding']
|
|
32
|
+
self.units = config['units']
|
|
33
|
+
self.inside_box = config['inside_box']
|
|
34
|
+
self.lid = (config['lid'] || LIDS.first).to_sym
|
|
35
|
+
|
|
36
|
+
self.notches = []
|
|
37
|
+
self.outlines = {}
|
|
38
|
+
|
|
39
|
+
self.metadata = Geometry::Point[config['metadata_width'] || 0, config['metadata_height'] || 0]
|
|
40
|
+
|
|
41
|
+
create_faces! # generates dimensions for each side
|
|
42
|
+
self.faces = [top, front, bottom, back, left, right]
|
|
43
|
+
|
|
44
|
+
self.conf = {
|
|
45
|
+
valign: [:out, :out, :out, :out, :in, :in],
|
|
46
|
+
halign: [:in, :out, :in, :out, :in, :in],
|
|
47
|
+
corners: {
|
|
48
|
+
front: [:no, :yes, :no, :yes, :no, :no], # our default choice, but may not work
|
|
49
|
+
top: [:yes, :no, :yes, :no, :no, :no] # 2nd choice, has to work if 1st doesn't
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def enclosure
|
|
55
|
+
generate_notches if notches.empty?
|
|
56
|
+
p1 = notches.first.p1.to_a
|
|
57
|
+
p2 = notches.first.p2.to_a
|
|
58
|
+
|
|
59
|
+
notches.each do |notch|
|
|
60
|
+
n = notch.normalized
|
|
61
|
+
n.p1.to_a.each_with_index { |c, i| p1[i] = c if c < p1[i] }
|
|
62
|
+
n.p2.to_a.each_with_index { |c, i| p2[i] = c if c > p2[i] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Geometry::Rect[Geometry::Point.new(p1), Geometry::Point.new(p2)]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def generate_notches
|
|
69
|
+
position_faces!
|
|
70
|
+
corner_face = pick_corners_face
|
|
71
|
+
self.notches = []
|
|
72
|
+
self.outlines = {}
|
|
73
|
+
faces.each_with_index do |face, face_index|
|
|
74
|
+
edges = edges_of(face, face_index, corner_face)
|
|
75
|
+
|
|
76
|
+
if edges.any?(&:corners) && !edges.all?(&:first_notch_out?)
|
|
77
|
+
edges.each { |e| e.adjust_corners = true }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
outlines[face.name] = Aggregator.new(lines_of(face, edges)).lines
|
|
81
|
+
notches << outlines[face.name]
|
|
82
|
+
end
|
|
83
|
+
notches.flatten!
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def w; dim.w; end
|
|
87
|
+
def h; dim.h; end
|
|
88
|
+
def d; dim.d; end
|
|
89
|
+
|
|
90
|
+
def longest
|
|
91
|
+
[w, h, d].max
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def to_s
|
|
95
|
+
"Box:\nH:#{dim.h} W:#{dim.w} D:#{dim.d}\nThickness:#{thickness}, Notch:#{notch_width}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# One edge for each side of a face, pairing the side with the matching
|
|
101
|
+
# side of the face grown by the thickness.
|
|
102
|
+
#
|
|
103
|
+
# @return [Array<Notching::Edge>]
|
|
104
|
+
def edges_of(face, face_index, corner_face)
|
|
105
|
+
bound = face_bounding_rect(face)
|
|
106
|
+
bound.sides.each_with_index.map do |bounding_side, side_index|
|
|
107
|
+
include_corners = conf[:corners][corner_face][face_index] == :yes && side_index.odd?
|
|
108
|
+
key = side_index.odd? ? :valign : :halign
|
|
109
|
+
Notching::Edge.new(bounding_side,
|
|
110
|
+
face.sides[side_index],
|
|
111
|
+
{ notch_width: notch_width,
|
|
112
|
+
thickness: thickness,
|
|
113
|
+
kerf: kerf,
|
|
114
|
+
center_out: conf[key][face_index] == :out,
|
|
115
|
+
corners: include_corners,
|
|
116
|
+
corner_ends: corner_ends(face, side_index) })
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A lid that lifts off covers the corners above the walls, so a wall
|
|
121
|
+
# keeps no corner box at the end of a side that touches the lid.
|
|
122
|
+
#
|
|
123
|
+
# @return [Array<Integer>] the ends of the side that may carry a corner box
|
|
124
|
+
def corner_ends(face, side_index)
|
|
125
|
+
lid_side = LID_SIDES[face.name]
|
|
126
|
+
return Notching::Edge::ENDS if lid == :full || lid_side.nil?
|
|
127
|
+
|
|
128
|
+
case (side_index - lid_side) % 4
|
|
129
|
+
when 1 then [2]
|
|
130
|
+
when 3 then [1]
|
|
131
|
+
else Notching::Edge::ENDS
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Array<Geometry::Line>] the lines of a face before they are merged
|
|
136
|
+
def lines_of(face, edges)
|
|
137
|
+
return lid_lines(edges) if face.equal?(top)
|
|
138
|
+
|
|
139
|
+
edges.each_with_index.flat_map do |edge, side_index|
|
|
140
|
+
straight?(face, side_index) ? [edge.inside] : Notching::PathGenerator.new(edge).generate
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Whether a side of a wall lies under a lid edge that has no notches.
|
|
145
|
+
def straight?(face, side_index)
|
|
146
|
+
return false if lid == :full || (lid == :back && face.equal?(back))
|
|
147
|
+
|
|
148
|
+
LID_SIDES[face.name] == side_index
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def lid_lines(edges)
|
|
152
|
+
case lid
|
|
153
|
+
when :plain then edges.map(&:outside)
|
|
154
|
+
when :back then back_lid_lines(edges)
|
|
155
|
+
else edges.flat_map { |edge| Notching::PathGenerator.new(edge).generate }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The lid notched into the back wall only. Its other three edges are
|
|
160
|
+
# straight, along the outside of the walls. Either side of the notches
|
|
161
|
+
# it has a foot, the corner square above the side wall.
|
|
162
|
+
def back_lid_lines(edges)
|
|
163
|
+
joint = edges.first
|
|
164
|
+
joint.corners = true
|
|
165
|
+
joint.adjust_corners = true
|
|
166
|
+
path = Notching::PathGenerator.new(joint).notch_lines
|
|
167
|
+
|
|
168
|
+
path + foot(joint.outside.p1, path.first.p1) + foot(joint.outside.p2, path.last.p2) + edges.drop(1).map(&:outside)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The two sides of a foot the notches do not draw: its bottom, from the
|
|
172
|
+
# outer corner of the lid, and the side that faces the first notch.
|
|
173
|
+
def foot(corner, notch)
|
|
174
|
+
turn = Geometry::Point[notch.x, corner.y]
|
|
175
|
+
[Geometry::Line[corner, turn], Geometry::Line[turn, notch]]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def face_bounding_rect(face)
|
|
179
|
+
b = face.clone
|
|
180
|
+
b.move_to(b.position.plus(-thickness, -thickness))
|
|
181
|
+
b.p2 = b.p2.plus(2 * thickness, 2 * thickness)
|
|
182
|
+
b.relocate!
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# ___________________________________________________________________
|
|
186
|
+
#
|
|
187
|
+
# +-----------------+
|
|
188
|
+
# | |
|
|
189
|
+
# | back: W x H |
|
|
190
|
+
# | |
|
|
191
|
+
# +-----------------+
|
|
192
|
+
# +-----------------+
|
|
193
|
+
# | bottom: W x D |
|
|
194
|
+
# +-----------------+
|
|
195
|
+
# +--------+ +-----------------+ +--------+
|
|
196
|
+
# | | | | | |
|
|
197
|
+
# | left | | front: W x H | | right |
|
|
198
|
+
# | D x H | | | | D x H |
|
|
199
|
+
# +--------+ X-----------------+ +--------+
|
|
200
|
+
# +-----------------+
|
|
201
|
+
# | top : W x D |
|
|
202
|
+
# +-----------------+
|
|
203
|
+
#
|
|
204
|
+
# 0,0
|
|
205
|
+
# ___________________________________________________________________
|
|
206
|
+
|
|
207
|
+
def position_faces!
|
|
208
|
+
offset_x = [padding + d + (3 * thickness), metadata.x + (2 * thickness) + padding].max
|
|
209
|
+
offset_y = [padding + d + (3 * thickness), metadata.y + (2 * thickness) + padding].max
|
|
210
|
+
|
|
211
|
+
# X Coordinate
|
|
212
|
+
left.x = offset_x - d - (2 * thickness) - padding
|
|
213
|
+
right.x = offset_x + w + (2 * thickness) + padding
|
|
214
|
+
|
|
215
|
+
[bottom, front, top, back].each { |s| s.x = offset_x }
|
|
216
|
+
|
|
217
|
+
# Y Coordinate
|
|
218
|
+
top.y = offset_y - d - (2 * thickness) - padding
|
|
219
|
+
bottom.y = offset_y + h + (2 * thickness) + padding
|
|
220
|
+
back.y = bottom.y + d + (2 * thickness) + padding
|
|
221
|
+
|
|
222
|
+
[left, front, right].each { |s| s.y = offset_y }
|
|
223
|
+
|
|
224
|
+
faces.each(&:relocate!)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def create_faces!
|
|
228
|
+
zero = Geometry::Point.new(0, 0)
|
|
229
|
+
self.front = Geometry::Rect.create(zero, dim.w, dim.h, "front")
|
|
230
|
+
self.back = Geometry::Rect.create(zero, dim.w, dim.h, "back")
|
|
231
|
+
|
|
232
|
+
self.top = Geometry::Rect.create(zero, dim.w, dim.d, "top")
|
|
233
|
+
self.bottom = Geometry::Rect.create(zero, dim.w, dim.d, "bottom")
|
|
234
|
+
|
|
235
|
+
self.left = Geometry::Rect.create(zero, dim.d, dim.h, "left")
|
|
236
|
+
self.right = Geometry::Rect.create(zero, dim.d, dim.h, "right")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Choose which face will be responsible for filling out the little square overlap
|
|
240
|
+
# in the corners. Only one of the 3 possible sides need to be picked.
|
|
241
|
+
def pick_corners_face
|
|
242
|
+
b = face_bounding_rect(front)
|
|
243
|
+
edges = []
|
|
244
|
+
front.sides[0..1].each_with_index do |face, index|
|
|
245
|
+
edges << Notching::Edge.new(b.sides[index], face, notch_width: notch_width, kerf: kerf )
|
|
246
|
+
end
|
|
247
|
+
edges.map(&:notch_count).all?{ |c| c % 4 == 3 } ? :top : :front
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
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,47 @@
|
|
|
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
|
+
|
|
35
|
+
7. A box with a lid that lifts off: a plain rectangle, or one notched into the back wall only:
|
|
36
|
+
|
|
37
|
+
laser-cutter generate -b 3x2x2/0.125 --lid plain -o box.pdf
|
|
38
|
+
laser-cutter generate -b 3x2x2/0.125 --lid back -o box.pdf
|
|
39
|
+
EXAMPLES
|
|
40
|
+
|
|
41
|
+
def call(**)
|
|
42
|
+
out.puts TEXT
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
# No default here: one would override the lid a saved configuration asks for.
|
|
28
|
+
option :lid, values: %w[full back plain], aliases: ['-L'],
|
|
29
|
+
desc: 'Lid: notched on every side, into the back wall only, or on no side (default: full)'
|
|
30
|
+
option :units, default: 'in', values: %w[in mm], aliases: ['-u'], desc: 'Units every dimension is in'
|
|
31
|
+
|
|
32
|
+
option :file, aliases: ['-o'], desc: 'File to write (required)'
|
|
33
|
+
option :format, default: 'pdf', aliases: ['-f'], desc: 'Output format: pdf or svg, in either case'
|
|
34
|
+
|
|
35
|
+
option :margin, aliases: ['-m'], desc: 'Margin from the edge of the page'
|
|
36
|
+
option :padding, aliases: ['-p'], desc: 'Space between the panels'
|
|
37
|
+
option :stroke, aliases: ['-s'], desc: 'Stroke width of the lines'
|
|
38
|
+
option :page_size, aliases: ['-i'], desc: 'Page size, such as LETTER or A3 (default: fit the box; PDF only)'
|
|
39
|
+
option :page_layout, default: 'portrait', values: %w[portrait landscape], aliases: ['-l'], desc: 'Page layout (PDF only)'
|
|
40
|
+
|
|
41
|
+
option :metadata, type: :boolean, default: true, aliases: ['-M'], desc: 'Print the settings of the box on the page'
|
|
42
|
+
option :inside_box, type: :boolean, default: false, aliases: ['-B'],
|
|
43
|
+
desc: 'Also draw the box without kerf, in red, to check the kerf'
|
|
44
|
+
option :open, type: :boolean, default: false, aliases: ['-O'], desc: 'Open the file once it is written'
|
|
45
|
+
option :write, aliases: ['-W'], desc: "Save the configuration to a file, or to STDOUT with '-'"
|
|
46
|
+
option :read, aliases: ['-R'], desc: "Read the configuration from a file, or from STDIN with '-'"
|
|
47
|
+
|
|
48
|
+
example [
|
|
49
|
+
'-b 3x2x2/0.125 -o box.pdf # a box in inches',
|
|
50
|
+
'-b 3x2x2/0.125 -f svg -o box.svg # as an SVG',
|
|
51
|
+
'-b 3x2x2/0.125 --lid plain -o box.pdf # with a lid that lifts off',
|
|
52
|
+
'-u mm -w 70 -H 20 -d 50 -t 4.3 -o box.pdf'
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
def call(format:, verbose:, **options)
|
|
56
|
+
config = configuration(options)
|
|
57
|
+
ui.debug('Configuration:', JSON.pretty_generate(config.to_hash)) if verbose
|
|
58
|
+
config.validate!
|
|
59
|
+
renderer = Renderer.for(format, config)
|
|
60
|
+
ui.info(BANNER, dimensions(config), "Format: #{format.upcase}")
|
|
61
|
+
ConfigFile.new(options[:write]).write(config.to_hash, out) if options[:write]
|
|
62
|
+
|
|
63
|
+
progress("Drawing #{File.basename(config.file)}", total: renderer.total) do |bar|
|
|
64
|
+
renderer.render { bar.advance }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
report(format, File.expand_path(config.file))
|
|
68
|
+
system('open', config.file) if options[:open]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# A box wraps in the middle of a word, which would break a path nobody
|
|
74
|
+
# could then copy, so this one widens to fit a path longer than it.
|
|
75
|
+
def report(format, path)
|
|
76
|
+
ui.success("Generated #{format.upcase} file:", path, width: [CLI.help_width, path.length + BOX_CHROME].max)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# What was read from a file, overridden by what the command line gave.
|
|
80
|
+
def configuration(options)
|
|
81
|
+
given = options.except(*RUN_ONLY).compact
|
|
82
|
+
saved = options[:read] ? ConfigFile.new(options[:read]).read : {}
|
|
83
|
+
settings = saved.transform_keys(&:to_sym).merge(given)
|
|
84
|
+
Configuration.new(settings.merge(debug: settings.delete(:inside_box)))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# One line per dimension, aligned, in the units of the box, then the lid.
|
|
88
|
+
def dimensions(config)
|
|
89
|
+
rows = DIMENSIONS.map { |name| [name, "#{config[name]} #{config.units}"] } << [:lid, config.lid]
|
|
90
|
+
rows.map { |name, value| "#{"#{name.capitalize}:".ljust(11)} #{value}" }.join("\n")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
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
|