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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4cb71396bbb2f8c49f903912ebe28e43ccf2fa278f7fc390bb68f4002dd678a5
4
- data.tar.gz: 51bee7e480b3fd7578a6668a9f9c82ef3894e2f388bc3db336b76eefcff89098
3
+ metadata.gz: 140ec85d7822779ce6cf52830e4d462252bda1d9b03f0f45c433a68c381e08ac
4
+ data.tar.gz: 6f103d783a2b0de67eeba6491fac047029e52fec5ffe824dd0508481d8fcd41a
5
5
  SHA512:
6
- metadata.gz: a146752a70a7b9f73c6cf46c0472ec5a7456b50e2623188c7665e057c0cf98bd687d05946137c968995783b569a03fb7c99afca3528e084f712d46298e9ef261
7
- data.tar.gz: e8b3eecd4f8fe18078599f989f3a14e94bb9c4df0ff6504619f196849b1c016ebbcb48dad5153465e810cbcce38566cf00d5e2c67ce9bb8fbfca9aa4d80b0455
6
+ metadata.gz: 1e465b0c64a35fbbc397e0d98fccb520ae1fb0e8bb6fc073be86cf4fa80b141461ee238f7dcd8d77e8380f9c4043ac86a86efbecf9a380a4b1db2bcd0a7a0fd5
7
+ data.tar.gz: 9946ba8635c743ca01a7d458d3622037ea7bf811631bcda87c96a8c94398941ff6931809a46ec57624eefd1d1827036fff50155b8dd8492d60b23265bb3f1376
@@ -0,0 +1,45 @@
1
+ # 002.00 Lid options: notched on one side, or plain
2
+
3
+ Branch `kig/add-lid-options`, off `master`; the PR targets `master`. Released as 2.0.1.
4
+
5
+ ## Goal
6
+
7
+ A box whose lid lifts off. Today the lid (the `top` panel) is notched on all four sides and can only be glued shut.
8
+
9
+ ## Decisions
10
+
11
+ Answered by Konstantin on 2026-09-30:
12
+
13
+ - The lid rests on top of the walls. Where a lid edge is plain, the wall edge under it is straight at the inner height `H`, and the lid reaches the outer footprint.
14
+ - The lid notched on one side joins the **back** wall, always.
15
+ - The option is `--lid full|back|plain`, `-L`, `full` by default. `full` draws exactly what 2.0.0 drew.
16
+
17
+ Decided without an answer (he had gone to bed), so worth a look in review:
18
+
19
+ - In `back` and `plain` the lid owns all four top corner squares, so no wall rises above `H` at a corner. In `back` the lid's two back corners are the feet either side of its notched edge.
20
+ - The internal height stays `H` in every mode: the underside of the lid is at `H`.
21
+ - The layout does not move. A wall without its tabs leaves its old space unused.
22
+
23
+ ## Geometry
24
+
25
+ Faces are laid out in a column (`top`, `front`, `bottom`, `back`) with `left` and `right` beside `front`. A `Rect`'s sides run 0 bottom, 1 right, 2 top, 3 left. The lid's joints:
26
+
27
+ | Lid side | Wall | Wall side |
28
+ | :------- | :------ | :-------- |
29
+ | 0 | `back` | 2 |
30
+ | 1 | `right` | 0 |
31
+ | 2 | `front` | 0 |
32
+ | 3 | `left` | 0 |
33
+
34
+ - A plain wall edge is the edge's **inside** line. The sides next to it lose the corner box, and its kerf fix-ups, at that end only.
35
+ - A plain lid edge is the edge's **outside** line.
36
+ - Kerf grows every outline by half the kerf, as it does for notches.
37
+
38
+ ## Work
39
+
40
+ - [x] `Configuration`: `lid`, default `full`, rejected by `validate!` when unknown
41
+ - [x] `Notching::Edge` and `PathGenerator`: a corner box per end, not per edge
42
+ - [x] `Box`: per-face outlines; plain and back lids; straight wall edges
43
+ - [x] `generate --lid`, README, CLAUDE.md
44
+ - [x] Specs: each outline against an independent even-odd oracle, with and without kerf
45
+ - [x] Version 2.0.1
@@ -0,0 +1,37 @@
1
+ # 003.00 A Ruby API for MakeABox.io
2
+
3
+ Branch `kig/add-ruby-api-facade`, off `master` once 002.00 had merged; the API carries the lid that plan added.
4
+
5
+ ## Goal
6
+
7
+ MakeABox.io, a Rails application, draws boxes in-process. It must not shell out to `laser-cutter`, and it must be able to ask for any lid.
8
+
9
+ ## What 2.0.0 already allowed
10
+
11
+ `Configuration.new(hash)`, `validate!`, `Renderer::LayoutRenderer.new(config).render` and `PageManager#page_size_values` are what the website calls today against 1.0.3, and all still work. Two things were missing:
12
+
13
+ - A renderer could only write to `config.file`. A controller wants the bytes.
14
+ - `validate!` demanded `file`, so a caller with no file had to invent one.
15
+ - `Configuration` is a Mash: it takes any key and any value, and says nothing until a renderer trips over it.
16
+
17
+ ## Decisions
18
+
19
+ - Asked for by Konstantin mid-way: the facade takes every option as a **typed class**. That is `Laser::Cutter::Options`, a strict `Dry::Struct` with its types in `Laser::Cutter::Types`. dry-struct becomes a runtime dependency.
20
+ - `Laser::Cutter.render(options)` returns the document as a String. No file is needed or written.
21
+ - `Laser::Cutter.write(options)` writes it to `options.file`. Without a format, the extension of the file decides.
22
+ - Both take an `Options`, or a Hash they turn into one, and pass a block through as the per-line callback.
23
+ - `Options` coerces Strings, reads a blank String as left out, and refuses unknown keys. A missing dimension raises `MissingOption`, with the message `Configuration#validate!` gives, since the website rewrites that message. Anything else raises `InvalidOption`.
24
+ - The `--box` shorthand is not an attribute: it is a way to type four options, not a fifth.
25
+ - Renderers answer `document`, the String; `render` writes it to `config.file` as before.
26
+ - `Configuration` also reads a blank lid as the default, for callers still building one by hand.
27
+ - "The two lid types (or none)" is read as `back`, `plain`, or no lid setting at all, which is `full`. A box with no lid panel is not part of this.
28
+ - Requiring the gem still loads dry-cli and the commands. Harmless in a Rails process; splitting the CLI out of the default require is left for later.
29
+ - The command line keeps building a `Configuration` itself; it is not moved onto `Options` here.
30
+
31
+ ## Work
32
+
33
+ - [x] `document` on both renderers
34
+ - [x] `Types` and `Options`
35
+ - [x] `Laser::Cutter.render` and `Laser::Cutter.write`
36
+ - [x] Specs, including Strings from a form and the calls the website makes today
37
+ - [x] README section, CLAUDE.md
data/CLAUDE.md CHANGED
@@ -30,17 +30,25 @@ Everything lives under `Laser::Cutter`, in `lib/laser/cutter/`. `lib/laser/cutte
30
30
 
31
31
  Pipeline, from config to file:
32
32
 
33
- 1. **`Configuration`** is a `Hashie::Mash` with symbolized keys. It parses the `--box WxHxD/T[/N]` shorthand, casts numeric strings to floats, and merges per-unit defaults for kerf, margin, padding and stroke. Notch defaults to `3 × thickness`. `validate!` raises `MissingOption` / `ZeroValueNotAllowed`. `units` defaults to the Symbol `:in`, and arrives as a String from the command line, so compare it with `to_s` or `to_sym`.
33
+ 1. **`Configuration`** is a `Hashie::Mash` with symbolized keys. It parses the `--box WxHxD/T[/N]` shorthand, casts numeric strings to floats, and merges per-unit defaults for kerf, margin, padding and stroke. Notch defaults to `3 × thickness`. `validate!` raises `MissingOption` / `ZeroValueNotAllowed`, and `InvalidOption` for a lid it does not know. `units` defaults to the Symbol `:in`, and arrives as a String from the command line, so compare it with `to_s` or `to_sym`.
34
34
  1. **`Renderer.for(format, config)`** picks `LayoutRenderer` (PDF, Prawn) or `SvgRenderer` (SVG, Victor). Both answer `total` (lines to draw) and `render { |line| }`, which yields after each line. That block drives the progress bar.
35
35
  - `LayoutRenderer` draws a `BoxRenderer`, a `MetaRenderer` when `config.metadata` is set, and a second red `BoxRenderer` without kerf when `config.debug` is set. It sizes the page from the box enclosure unless `page_size` is given.
36
36
  - `SvgRenderer` fits the page to the box, flips y (SVG counts down from the top), and writes the metadata as a `<desc>`.
37
37
  1. **`Box`** models the six faces as `Geometry::Rect`s. `position_faces!` lays them out in a cross (see the ASCII diagram in that method). `generate_notches` pairs each side of a face with the matching side of its outer bounding rect (face grown by `thickness`) as a **`Notching::Edge`**. The `conf` table sets per-face alignment: `valign`/`halign` decide whether a side's center notch points `:out` or `:in`. `corners` plus `pick_corners_face` decide which face fills the corner squares.
38
+ - `lid` (`full`, `back`, `plain`) sets how the `top` panel joins the walls. A plain lid edge is the edge's **outside** line, and the wall side under it (`LID_SIDES`) is the edge's **inside** line, so the lid lies on walls that end at the inner height. The lid then owns the corners above the walls: `corner_ends` strips the corner box from the wall sides that touch it.
39
+ - `outlines` holds the merged lines of each face by name; `notches` is all of them, flattened.
38
40
  1. **`Notching::Edge`** holds the inside and outside lines of one side, both shifted by `kerf / 2`. `calculate_notch_width!` forces an **odd** notch count of at least 3 and recomputes the real notch width, so the requested notch is only a guide. It rounds `length / notch` to `RATIO_DIGITS` before `ceil`: two panels meeting at a joint must get the same count, and float noise used to split them when the notch divided the side exactly.
39
- 1. **`Notching::PathGenerator`** turns an `Edge` into `Geometry::Line`s. It zigzags between the inside and outside lines using `Shift` deltas from two alternating `InfiniteIterator`s. It widens or narrows notches by `kerf` and adds the corner boxes.
41
+ 1. **`Notching::PathGenerator`** turns an `Edge` into `Geometry::Line`s. It zigzags between the inside and outside lines using `Shift` deltas from two alternating `InfiniteIterator`s. It widens or narrows notches by `kerf` and adds the corner boxes, at the ends the edge names in `corner_ends`. Kerf grows every outline by half the kerf on every side; `spec/laser/cutter/box_lid_spec.rb` checks exactly that, point by point, through `spec/support/outline.rb`.
40
42
  1. **`Aggregator`** merges the lines of a face into the outline to cut. Neighbouring edges draw shared stretches twice, and a shared stretch runs through the material, so each collinear group is combined as a symmetric difference: a point is cut when an odd number of lines cover it. It groups lines by axis and offset and sweeps each group once, so a face takes n log n; the old pairwise version made a 100×80×60 box take 55 seconds.
41
43
 
42
44
  Geometry is unitless, in the config's units. Conversion to PDF points happens only at render time (`value.send(:in)` / `.send(:mm)`). `PageManager#value_from_units` converts PDF points back.
43
45
 
46
+ ### Ruby API
47
+
48
+ - `Laser::Cutter.render(options)` returns the document as a String, and `Laser::Cutter.write(options)` writes it to `options.file`. Both live in `lib/laser/cutter.rb`, take an `Options` or a Hash, and pass a block through as the per-line callback. MakeABox.io calls these; keep them working without a file, a terminal or the CLI classes.
49
+ - `Options` (`options.rb`) is a strict `Dry::Struct`, one attribute per `generate` option, with its types in `Types` (`types.rb`). It raises `MissingOption` for a missing dimension and `InvalidOption` for anything else, and turns into a `Configuration` with `to_configuration`. A new `generate` option needs an attribute here too.
50
+ - Renderers answer `document`, the String. `Renderer::Base#render` writes it to `config.file`.
51
+
44
52
  ### Command line
45
53
 
46
54
  - `exe/laser-cutter` (and `exe/lc`) call `Laser::Cutter::Launcher.new(ARGV).execute!`. The Launcher takes argv, the three streams and `kernel`, and exits only through `kernel.exit`. Aruba runs it in-process (`spec/support/aruba.rb`), so commands must write to `out` and `err`, never to `$stdout`.
@@ -51,9 +59,14 @@ Geometry is unitless, in the config's units. Conversion to PDF points happens on
51
59
 
52
60
  ## Known gaps
53
61
 
62
+ - `generate` declares defaults for `--units` and `--page-layout`, and they override what `-R` reads from a saved configuration. `--lid` has no dry-cli default for that reason.
63
+
54
64
  - `just build` is an empty recipe, so `just publish` builds nothing. `rake build` is the real build.
65
+
55
66
  - The `Rakefile` YARD title is copied from another project, and it references a `CHANGELOG.md` that does not exist.
67
+
56
68
  - The gemspec lists `tty-*` and `pastel` directly, though only dry-cli-ui uses them.
69
+
57
70
  - `Box` still carries the comment "badly needs refactoring and tests".
58
71
 
59
72
  ## Conventions
data/README.md CHANGED
@@ -61,6 +61,7 @@ laser-cutter COMMAND [OPTIONS]
61
61
  | `-w`, `-H`, `-d`, `-t` | Width, height, depth and thickness, one at a time |
62
62
  | `-n`, `--notch` | Notch length, a guide only |
63
63
  | `-k`, `--kerf` | Kerf, the width of the cut |
64
+ | `-L`, `--lid` | `full` (default), `back` or `plain`, see below |
64
65
  | `-u`, `--units` | `in` (default) or `mm` |
65
66
  | `-o`, `--file` | File to write, required |
66
67
  | `-f`, `--format` | `pdf` (default) or `svg`, in either case |
@@ -77,6 +78,30 @@ A box in inches, with the kerf set to 0.008", opened once it is written:
77
78
  laser-cutter generate -b 3x2x2/0.125 -k 0.008 -O -o box.pdf
78
79
  ```
79
80
 
81
+ A box with a lid that lifts off:
82
+
83
+ ```bash
84
+ laser-cutter generate -b 3x2x2/0.125 --lid plain -o box.pdf
85
+ ```
86
+
87
+ ### The lid
88
+
89
+ The lid is the top panel. `--lid` sets how it joins the walls:
90
+
91
+ | `--lid` | The lid | The walls under it |
92
+ | :------ | :----------------------------------- | :------------------------------------------------ |
93
+ | `full` | Notched on all four sides | Notched; the box is glued shut |
94
+ | `back` | Notched where it meets the back wall | The back is notched, the other three are straight |
95
+ | `plain` | A rectangle, no notches | All four are straight |
96
+
97
+ | `--lid full` | `--lid back` | `--lid plain` |
98
+ | :------------------------------------------------------ | :--------------------------------------------------------------------- | :-------------------------------------------------------- |
99
+ | ![A box with a full lid](docs/images/box-lid-full.avif) | ![A box with a lid notched at the back](docs/images/box-lid-back.avif) | ![A box with a plain lid](docs/images/box-lid-plain.avif) |
100
+
101
+ Each is `laser-cutter generate -b 3x2x2/0.125 --lid …`. The lid is the panel at the bottom of the page, the back wall the one at the top.
102
+
103
+ A lid edge without notches reaches the outside of the wall under it, and that wall ends at the internal height. So a `plain` lid is `W + 2T` by `D + 2T` and lies on top of the box, and the space inside is still `W` by `H` by `D`.
104
+
80
105
  The same box as an SVG:
81
106
 
82
107
  ```bash
@@ -103,6 +128,86 @@ laser-cutter generate -o box.pdf -R box-settings.json
103
128
  cat box-settings.json | laser-cutter generate -o box.pdf -R -
104
129
  ```
105
130
 
131
+ ### More boxes
132
+
133
+ A shallow box in millimeters, glued shut:
134
+
135
+ ```bash
136
+ laser-cutter generate -u mm -w 70 -H 20 -d 50 -t 4.3 -n 5 -f svg -o box.svg
137
+ ```
138
+
139
+ ![A 70 by 20 by 50 millimeter box](docs/images/box-metric.avif)
140
+
141
+ A tray with a lid that lifts off, from 3mm material with wide notches:
142
+
143
+ ```bash
144
+ laser-cutter generate -u mm -w 120 -H 25 -d 80 -t 3 -n 12 --lid plain -f svg -o tray.svg
145
+ ```
146
+
147
+ ![A 120 by 25 by 80 millimeter tray with a plain lid](docs/images/box-tray.avif)
148
+
149
+ The pictures on this page are the SVG files themselves, drawn with a thicker stroke (`-s 0.5`) and converted:
150
+
151
+ ```bash
152
+ magick -density 96 -background white box.svg -flatten box.avif
153
+ ```
154
+
155
+ ## Using it from Ruby
156
+
157
+ A Ruby program draws a box in its own process; nothing runs the command line. It needs three things:
158
+
159
+ | Call | What it does |
160
+ | :------------------------------ | :------------------------------------------------ |
161
+ | `Laser::Cutter::Options.new` | Every setting of a box, typed and checked |
162
+ | `Laser::Cutter.render(options)` | Returns the PDF or the SVG as a String |
163
+ | `Laser::Cutter.write(options)` | Writes it to `options.file`, and returns the path |
164
+
165
+ ```ruby
166
+ require "laser-cutter"
167
+
168
+ options = Laser::Cutter::Options.new(
169
+ width: 70, height: 20, depth: 50, thickness: 4.3,
170
+ units: :mm, lid: :plain, format: :svg
171
+ )
172
+
173
+ svg = Laser::Cutter.render(options) # a String, no file written
174
+ Laser::Cutter.write(options.new(file: "box.pdf", format: nil)) # the extension picks the format
175
+ ```
176
+
177
+ `Options` has an attribute for each option of `generate`:
178
+
179
+ | Attribute | Type | When left out |
180
+ | :-------------------------------------- | :---------------------- | :----------------------------- |
181
+ | `width`, `height`, `depth`, `thickness` | Float above zero | `MissingOption` is raised |
182
+ | `notch` | Float above zero | Three times the thickness |
183
+ | `kerf`, `margin`, `padding` | Float, zero or more | The default for the units |
184
+ | `stroke` | Float above zero | The default for the units |
185
+ | `units` | `in` or `mm` | `in` |
186
+ | `lid` | `full`, `back`, `plain` | `full` |
187
+ | `format` | `pdf` or `svg` | `pdf`, or the file's extension |
188
+ | `file` | String | Only `write` needs it |
189
+ | `page_size` | A name such as `A4` | The page fits the box |
190
+ | `page_layout` | `portrait`, `landscape` | `portrait` |
191
+ | `metadata` | Boolean | `true` |
192
+ | `inside_box` | Boolean | `false` |
193
+
194
+ - Values are coerced, so the Strings a web form sends will do, and a blank String counts as left out. Keys may be Strings or Symbols.
195
+ - A value it cannot use, or a key that is not an option, raises `Laser::Cutter::InvalidOption` with a message such as `lid cannot be "sliding", but must be one of: full, back, plain.` Both errors descend from `Laser::Cutter::Error`.
196
+ - An `Options` cannot be changed; `options.new(lid: :back)` returns a changed copy.
197
+
198
+ In a Rails controller:
199
+
200
+ ```ruby
201
+ def create
202
+ box = params.require(:box).permit(*Laser::Cutter::Options.attribute_names)
203
+ send_data Laser::Cutter.render(box.to_h), type: "application/pdf", filename: "box.pdf"
204
+ rescue Laser::Cutter::Error => e
205
+ redirect_to new_box_path, alert: e.message
206
+ end
207
+ ```
208
+
209
+ `Laser::Cutter::Box::LIDS` lists the lids, for a select. `Configuration`, `Renderer::LayoutRenderer#render` and `PageManager#page_size_values`, which MakeABox.io called in 1.0.3, still work.
210
+
106
211
  ## Feature Wish List
107
212
 
108
213
  - Create T-style joins, using various standard sizes of nuts and bolts (such as common #4-40 and M2 sizes)
@@ -158,4 +263,4 @@ laser-cutter generate -b 1x1.5x2/0.125/0.125 -O -o box.pdf
158
263
 
159
264
  MIT License (MIT). Please see [LICENSE](LICENSE) for more information.
160
265
 
161
- Author: © 2015-2024 Konstantin Gredeskoul [@kigster](https://github.com/kigster)
266
+ Author: © 2015-2024 Konstantin Gredeskoul [@kigster](https://github.com/kigster)
Binary file
Binary file
Binary file
Binary file
Binary file
data/laser-cutter.gemspec CHANGED
@@ -25,6 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.add_dependency 'dry-cli-autocomplete'
26
26
  spec.add_dependency 'dry-cli-help'
27
27
  spec.add_dependency 'dry-cli-ui'
28
+ spec.add_dependency 'dry-struct', '~> 1.6'
28
29
  spec.add_dependency 'hashie'
29
30
  spec.add_dependency 'matrix'
30
31
  spec.add_dependency 'pastel'
@@ -5,10 +5,23 @@ module Laser
5
5
  # Note: this class badly needs refactoring and tests. Both are coming.
6
6
 
7
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
+
8
15
  # Everything is in millimeters
9
16
 
10
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
11
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
+
12
25
  def initialize(config = {})
13
26
  self.dim = Geometry::Dimensions.new(config['width'], config['height'], config['depth'])
14
27
  self.thickness = config['thickness']
@@ -18,8 +31,10 @@ module Laser
18
31
  self.padding = config['padding']
19
32
  self.units = config['units']
20
33
  self.inside_box = config['inside_box']
34
+ self.lid = (config['lid'] || LIDS.first).to_sym
21
35
 
22
36
  self.notches = []
37
+ self.outlines = {}
23
38
 
24
39
  self.metadata = Geometry::Point[config['metadata_width'] || 0, config['metadata_height'] || 0]
25
40
 
@@ -54,31 +69,16 @@ module Laser
54
69
  position_faces!
55
70
  corner_face = pick_corners_face
56
71
  self.notches = []
72
+ self.outlines = {}
57
73
  faces.each_with_index do |face, face_index|
58
- bound = face_bounding_rect(face)
59
- edges = []
60
- bound.sides.each_with_index do |bounding_side, side_index|
61
- include_corners = conf[:corners][corner_face][face_index] == :yes && side_index.odd?
62
- key = side_index.odd? ? :valign : :halign
63
- center_out = (conf[key][face_index] == :out)
64
- edges << Notching::Edge.new(bounding_side,
65
- face.sides[side_index],
66
- { notch_width: notch_width,
67
- thickness: thickness,
68
- kerf: kerf,
69
- center_out: center_out,
70
- corners: include_corners })
71
- end
74
+ edges = edges_of(face, face_index, corner_face)
72
75
 
73
76
  if edges.any?(&:corners) && !edges.all?(&:first_notch_out?)
74
77
  edges.each { |e| e.adjust_corners = true }
75
78
  end
76
79
 
77
- side_lines = edges.map do |edge|
78
- Notching::PathGenerator.new(edge).generate
79
- end
80
-
81
- notches << Aggregator.new(side_lines.flatten).lines
80
+ outlines[face.name] = Aggregator.new(lines_of(face, edges)).lines
81
+ notches << outlines[face.name]
82
82
  end
83
83
  notches.flatten!
84
84
  end
@@ -97,6 +97,84 @@ module Laser
97
97
 
98
98
  private
99
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
+
100
178
  def face_bounding_rect(face)
101
179
  b = face.clone
102
180
  b.move_to(b.position.plus(-thickness, -thickness))
@@ -31,6 +31,11 @@ module Laser
31
31
 
32
32
  laser-cutter generate -o box.pdf -R box-settings.json
33
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
34
39
  EXAMPLES
35
40
 
36
41
  def call(**)
@@ -24,6 +24,9 @@ module Laser
24
24
  option :thickness, aliases: ['-t'], desc: 'Thickness of the material'
25
25
  option :notch, aliases: ['-n'], desc: 'Notch length, a guide only (default: three times the thickness)'
26
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)'
27
30
  option :units, default: 'in', values: %w[in mm], aliases: ['-u'], desc: 'Units every dimension is in'
28
31
 
29
32
  option :file, aliases: ['-o'], desc: 'File to write (required)'
@@ -45,6 +48,7 @@ module Laser
45
48
  example [
46
49
  '-b 3x2x2/0.125 -o box.pdf # a box in inches',
47
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',
48
52
  '-u mm -w 70 -H 20 -d 50 -t 4.3 -o box.pdf'
49
53
  ]
50
54
 
@@ -80,9 +84,10 @@ module Laser
80
84
  Configuration.new(settings.merge(debug: settings.delete(:inside_box)))
81
85
  end
82
86
 
83
- # One line per dimension, aligned, in the units of the box.
87
+ # One line per dimension, aligned, in the units of the box, then the lid.
84
88
  def dimensions(config)
85
- DIMENSIONS.map { |name| "#{"#{name.capitalize}:".ljust(11)} #{config[name]} #{config.units}" }.join("\n")
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")
86
91
  end
87
92
  end
88
93
  end
@@ -30,6 +30,7 @@ module Laser
30
30
  defaults = Hashie::Mash.new({
31
31
  units: :in,
32
32
  page_layout: 'portrait',
33
+ lid: 'full',
33
34
  metadata: true,
34
35
  in: {
35
36
  kerf: 0.0024, # smallest kerf for thin material, usually it's more than that.
@@ -50,6 +51,7 @@ module Laser
50
51
  ::Hashie::Extensions::SymbolizeKeys.symbolize_keys!(options)
51
52
 
52
53
  options.delete_if { |_k, v| v.nil? }
54
+ options.delete(:lid) if options[:lid].to_s.empty? # a blank form field
53
55
  if options[:units]
54
56
  unit = options[:units].to_sym
55
57
  unless self.class.defaults.key?(unit) || self.class.defaults.key?(unit.to_s)
@@ -79,6 +81,10 @@ module Laser
79
81
  zeros = []
80
82
  NON_ZERO.each { |k| zeros << k if self[k] == 0 }
81
83
  raise ZeroValueNotAllowed, "#{zeros.join(', ')} #{zeros.size > 1 ? 'are' : 'is'} required, but is zero." unless zeros.empty?
84
+
85
+ return if Box::LIDS.include?(lid.to_s.to_sym)
86
+
87
+ raise InvalidOption, "lid is #{lid.to_s.inspect}, but must be one of: #{Box::LIDS.join(', ')}."
82
88
  end
83
89
 
84
90
  def change_units(new_units)
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laser
4
+ module Cutter
5
+ # An option has a value it cannot take.
6
+ class InvalidOption < Error; end
7
+ end
8
+ end
@@ -10,8 +10,14 @@ module Laser
10
10
  # and outside edge of the material. It's also responsible
11
11
  # for calculating the "perfect" notch width.
12
12
  class Edge
13
+ # Both ends of an edge: 1 for p1, 2 for p2.
14
+ ENDS = [1, 2].freeze
15
+
13
16
  attr_accessor :outside, :inside, :notch_width, :thickness, :kerf, :center_out, :corners, :adjust_corners, :notch_count, :v1, :v2
14
17
 
18
+ # @return [Array<Integer>] the ends that get a corner box when +corners+ is set: 1 for p1, 2 for p2
19
+ attr_accessor :corner_ends
20
+
15
21
  def initialize(outside, inside, options = {})
16
22
  self.outside = outside.clone
17
23
  self.inside = inside.clone
@@ -26,6 +32,7 @@ module Laser
26
32
  self.center_out = options[:center_out] || false
27
33
  self.thickness = options[:thickness]
28
34
  self.corners = options[:corners]
35
+ self.corner_ends = options[:corner_ends] || ENDS
29
36
  self.kerf = options[:kerf] || 0
30
37
  self.notch_width = options[:notch_width]
31
38
  self.adjust_corners = options[:adjust_corners]
@@ -52,6 +59,14 @@ module Laser
52
59
  kerf > 0.0
53
60
  end
54
61
 
62
+ # Whether this edge draws a corner box at the given end.
63
+ #
64
+ # @param end_index [Integer] 1 for p1, 2 for p2
65
+ # @return [Boolean]
66
+ def corner_at?(end_index)
67
+ corners ? corner_ends.include?(end_index) : false
68
+ end
69
+
55
70
  # face_setting determines if we want that face to have center notch
56
71
  # facing out (for a hole, etc). This works well when we have odd number
57
72
  # of notches, but