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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 140ec85d7822779ce6cf52830e4d462252bda1d9b03f0f45c433a68c381e08ac
|
|
4
|
+
data.tar.gz: 6f103d783a2b0de67eeba6491fac047029e52fec5ffe824dd0508481d8fcd41a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
+
|  |  |  |
|
|
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
|
+

|
|
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
|
+

|
|
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'
|
data/lib/laser/cutter/box.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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|
|
|
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)
|
|
@@ -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
|