partforge 0.150.0 → 0.151.0
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.
- package/docs/AUTHORING-PARTS.md +114 -4
- package/docs/KERNEL-CONTRACT.md +4 -2
- package/package.json +1 -1
- package/src/framework/app.css +6 -4
- package/src/framework/appearance-card.js +26 -2
- package/src/framework/export/bundle.js +57 -14
- package/src/framework/export/drawing.js +38 -0
- package/src/framework/export/dxf.js +45 -8
- package/src/framework/export/formats.js +7 -4
- package/src/framework/export/readme.js +24 -7
- package/src/framework/export/svg.js +14 -2
- package/src/framework/geometry/polygon.js +1 -0
- package/src/framework/oracle/measure.js +2 -2
- package/src/framework/process/cnc-router/descriptor.js +174 -0
- package/src/framework/process/cnc-router/export.js +74 -0
- package/src/framework/process/exporters.js +1 -0
- package/src/framework/process/registry.js +2 -1
- package/src/framework/sheet/constants.js +7 -0
- package/src/framework/sheet/part.js +19 -8
- package/src/framework/sheet/resolve.js +13 -2
- package/src/framework/sheet/routed-resolve.js +62 -0
- package/src/framework/sheet/routed.js +44 -0
- package/types/geometry.d.ts +44 -1
- package/types/index.d.ts +2 -2
- package/types/part.d.ts +6 -0
- package/types/testing.d.ts +21 -2
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1906,7 +1906,7 @@ plate: sheetPart({
|
|
|
1906
1906
|
It returns an ordinary sub-part: `build`, the pose's placement and a plain-data `sheet`. A field
|
|
1907
1907
|
needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d) => …`.
|
|
1908
1908
|
`material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
|
|
1909
|
-
`process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
|
|
1909
|
+
`process` (default `"laser"`; `"cnc-router"` is `routedPart`, below) are optional; `label`, `views`, `display`, `export`,
|
|
1910
1910
|
`enabled`, `exportable`, `reference` pass through; your `views` poses apply after the `pose`, in the viewer only. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` and
|
|
1911
1911
|
`quantity`; each error names the fix.
|
|
1912
1912
|
|
|
@@ -2020,7 +2020,7 @@ the assembled view, and `minWall` and the overhang check skip sheet parts.
|
|
|
2020
2020
|
|
|
2021
2021
|
### The kit
|
|
2022
2022
|
|
|
2023
|
-
A host downloads sheet parts as one ZIP, the **cut & print kit** (`format: "bundle"`): `README.txt`, `parts.csv`, laid-out `sheets/`, one `parts/` file per distinct piece (plus a `-marks.dxf` for a cutting service) and `print/` for printed parts. Identical pieces merge into one `-xN` file named from `export: { name }`, else the key. Destination (`"own-laser"` or `"
|
|
2023
|
+
A host downloads sheet parts as one ZIP, the **cut & print kit** (`format: "bundle"`): `README.txt`, `parts.csv`, laid-out `sheets/`, one `parts/` file per distinct piece (plus a `-marks.dxf` for a cutting service) and `print/` for printed parts. Identical pieces merge into one `-xN` file named from `export: { name }`, else the key. Destination (`"own-laser"`, `"service"` or `"cnc-router"`), kerf and stock size are chosen at download, so draw nominal and bind `thickness` to the measured control. A choice the kit cannot honour fails with `cut kit options:`.
|
|
2024
2024
|
|
|
2025
2025
|
### Limits
|
|
2026
2026
|
|
|
@@ -2150,6 +2150,112 @@ export default {
|
|
|
2150
2150
|
};
|
|
2151
2151
|
```
|
|
2152
2152
|
|
|
2153
|
+
## Routed parts
|
|
2154
|
+
|
|
2155
|
+
CNC-routed parts from flat stock — hardwood, plywood, MDF, acrylic — for a 2.5D router
|
|
2156
|
+
CAM (Vectric VCarve, Carbide Create, Easel, Carveco): a through cut, flat-floored
|
|
2157
|
+
pockets at a depth, drilled holes at a depth, and operations done with another tool
|
|
2158
|
+
afterwards. Build each piece with `routedPart()` from `partforge/geometry`; the cut &
|
|
2159
|
+
print kit's **CNC router** destination writes one DXF per piece with one layer per depth,
|
|
2160
|
+
and a README table of every operation. A routed part is also the 3D sub-part — assembly,
|
|
2161
|
+
renders and STEP all come from the one declaration — so model it once.
|
|
2162
|
+
|
|
2163
|
+
### When a sub-part is a routed part
|
|
2164
|
+
|
|
2165
|
+
When it is machined from one face of a flat board: an outline, pockets with flat floors,
|
|
2166
|
+
holes, all straight down. A sculpted surface, a part machined from both faces, or
|
|
2167
|
+
anything that is not flat stock is an ordinary `build` (export it as STEP). A laser-cut
|
|
2168
|
+
plate is `sheetPart`.
|
|
2169
|
+
|
|
2170
|
+
### routedPart
|
|
2171
|
+
|
|
2172
|
+
```js
|
|
2173
|
+
import { routedPart, roundedRectProfile } from "partforge/geometry";
|
|
2174
|
+
|
|
2175
|
+
plinth: routedPart({
|
|
2176
|
+
label: "Hardwood base", views: { assembly: true, base: true }, display: { color: 0x6e3d23 },
|
|
2177
|
+
material: "walnut", // stock label: groups pieces in the kit
|
|
2178
|
+
thickness: (p) => p.baseThickness, // the MEASURED board, from a control
|
|
2179
|
+
profile: (k, p) => k.shape2d(roundedRectProfile(p.baseLength, p.baseWidth, 10)), // the THROUGH cut
|
|
2180
|
+
pockets: (k, p, d) => [
|
|
2181
|
+
{ region: k.shape2d(roundedRectProfile(d.vaultW, d.vaultL, 2)).translate([0, -9]), depth: 11.43, label: "peg vault" },
|
|
2182
|
+
{ region: k.shape2d(roundedRectProfile(d.pocketW, d.pocketL, 4)).translate([-58, -10]), depth: p.inlayThickness, label: "inlay pocket" },
|
|
2183
|
+
],
|
|
2184
|
+
drills: (p, d) => d.pegHoles.map(([x, y]) => ({ at: [x, y], d: p.pegHoleDia, depth: 10 })),
|
|
2185
|
+
extra: (k, p, d) => [{
|
|
2186
|
+
solid: k.box({ size: [d.rackL, 12, 14] }).rotateX(-p.rackTilt).at([0, d.rackY, p.baseThickness - 9.5]),
|
|
2187
|
+
footprint: k.shape2d(roundedRectProfile(d.rackL, 12, 2)).translate([0, d.rackY]),
|
|
2188
|
+
label: "domino rack slot", method: `table saw, blade tilted ${p.rackTilt}°, 9.5 mm deep`,
|
|
2189
|
+
}],
|
|
2190
|
+
reference: (k, p) => [[[-p.baseLength / 2 + 10, 0], [p.baseLength / 2 - 10, 0]]], // a centerline
|
|
2191
|
+
}),
|
|
2192
|
+
```
|
|
2193
|
+
|
|
2194
|
+
`material`, `thickness` and `profile` are required; `pockets`, `drills`, `extra`,
|
|
2195
|
+
`reference` and `pose` are optional; `label`, `views`, `display`, `export`, `enabled`,
|
|
2196
|
+
`exportable` pass through. A field needing the kernel is `(k, p, d) => …`; `drills` is
|
|
2197
|
+
`(p, d) => …`. `build` is supplied — passing one throws, as do `kerf`, `score`,
|
|
2198
|
+
`engrave` and `process`; each error names the fix. The drawing is in XY with the
|
|
2199
|
+
machined face at z = `thickness`; `pose` places it exactly as a sheet part's does.
|
|
2200
|
+
|
|
2201
|
+
### Pockets, drills, extra ops and reference lines
|
|
2202
|
+
|
|
2203
|
+
- **`pockets`** — `[{ region, depth, label? }]`: a Shape2D or profile machined `depth`
|
|
2204
|
+
mm down from the machined face, flat-floored. `depth` may equal `thickness` (a
|
|
2205
|
+
through pocket); deeper is a build error naming the pocket. Pockets whose depths agree to
|
|
2206
|
+
two decimals share a DXF layer.
|
|
2207
|
+
- **`drills`** — `[{ at: [x, y], d, depth, label? }]`: a round hole of diameter `d`.
|
|
2208
|
+
Set `depth` to the thickness to drill through — deeper is a `routed-drill-depth`
|
|
2209
|
+
lint error. Written as a DXF `CIRCLE`, which is what lets
|
|
2210
|
+
the CAM offer a drilling toolpath instead of a profile cut.
|
|
2211
|
+
- **`extra`** — `[{ solid, footprint, label, method }]`: an operation the router cannot
|
|
2212
|
+
do straight down — a tilted slot, a scooped thumb hold, an undercut. `solid` is cut in
|
|
2213
|
+
the preview and the STEP; `footprint` (its outline on the machined face) is drawn on
|
|
2214
|
+
the `SECONDARY` layer; `label` and `method` are REQUIRED and go in the README's
|
|
2215
|
+
operations table so the operator knows what to make and how ("core box bit, 20 mm
|
|
2216
|
+
radius"). Do not put a flat-floored pocket here to avoid stating its depth.
|
|
2217
|
+
- **`reference`** — the same entries as a sheet part's `score`: two points is a line,
|
|
2218
|
+
anything else a shape whose boundaries are drawn. Centerlines and layout marks, on
|
|
2219
|
+
the `REFERENCE` layer; they cut nothing (the preview grooves them 0.2 mm so they show).
|
|
2220
|
+
|
|
2221
|
+
Keep these functions chainable — no branching on `isEmpty()`/`area()` — so pose-only
|
|
2222
|
+
sliders stay fast. `null` entries are skipped; an empty pocket region draws nothing and
|
|
2223
|
+
is counted by `routedEmpty`, as is an extra op whose footprint is empty.
|
|
2224
|
+
|
|
2225
|
+
### The drawing
|
|
2226
|
+
|
|
2227
|
+
One DXF per piece, R12, millimetres: `CUT` (colour 1) is the through cut — the outline and
|
|
2228
|
+
the profile's own holes; `POCKET_<depth>MM` one layer per distinct depth (colours 30, 40, 50, 20,
|
|
2229
|
+
10); `DRILL_D<dia>_<depth>MM` or `DRILL_D<dia>_THRU` per diameter
|
|
2230
|
+
and depth (colours 3, 4, 2, 140, 170); `SECONDARY` (6) the extra ops' footprints;
|
|
2231
|
+
`REFERENCE` (8) layout lines. Depth rides both the layer name and the colour, and colours
|
|
2232
|
+
are assigned per kit — pockets shallowest depth first, drills in the order they appear —
|
|
2233
|
+
so one layer name has one colour in every file. A palette cycles past five depths; the
|
|
2234
|
+
README's LAYERS legend (layer, colour, depth) is the authority. Kerf
|
|
2235
|
+
never applies — the CAM applies the tool offset, and the kit refuses a kerf for this
|
|
2236
|
+
destination. The README's ROUTER OPERATIONS table lists every pocket, drill group and
|
|
2237
|
+
extra op with its depth and method.
|
|
2238
|
+
|
|
2239
|
+
### What lint and verify check
|
|
2240
|
+
|
|
2241
|
+
Lint: `routed-drill-depth` (error) — a drill deeper than the stock at the defaults.
|
|
2242
|
+
Every sheet rule applies too (`sheet-thickness-literal`, `sheet-custom-build`, …); the
|
|
2243
|
+
laser's thickness range does not. A pocket deeper than the stock is a build error.
|
|
2244
|
+
|
|
2245
|
+
Verify volunteers three warnings on every routed part, none counting toward
|
|
2246
|
+
`declared`/`evaluated`: `routedOutside` (pocket, drill or footprint regions outside the
|
|
2247
|
+
profile, `0`), `routedEmpty` (pockets that resolved empty, `0`), `routedPieces` (`1`).
|
|
2248
|
+
Each measured row carries `sheet.routed` — the operations table — beside the stock facts.
|
|
2249
|
+
|
|
2250
|
+
### Limits
|
|
2251
|
+
|
|
2252
|
+
- One machined face. A part worked from both faces is two routed parts or a `build`.
|
|
2253
|
+
- No tool-radius, dogbone or inside-corner checks: the CAM owns those.
|
|
2254
|
+
- No depth per extra op beyond its footprint and the solid's deepest point; the
|
|
2255
|
+
`method` text is where the operator's instructions go.
|
|
2256
|
+
- A routed part is a sheet part of process `cnc-router` everywhere else: the sheet
|
|
2257
|
+
checks' budget, stock grouping and the kit's layout apply unchanged.
|
|
2258
|
+
|
|
2153
2259
|
## Convex hull
|
|
2154
2260
|
|
|
2155
2261
|
`k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
|
|
@@ -3147,8 +3253,9 @@ returns instead:
|
|
|
3147
3253
|
stock), `parts/<name>[-xN].svg` (one per distinct piece; identical pieces merge) and
|
|
3148
3254
|
`print/<name>[-xN].stl` or `.3mf` (the printed parts, in their export pose). With
|
|
3149
3255
|
`destination: "service"` each piece is a cut-only `.dxf` plus a `-marks.dxf` for its
|
|
3150
|
-
score and engrave lines, and the DXF sheets are for reference.
|
|
3151
|
-
|
|
3256
|
+
score and engrave lines, and the DXF sheets are for reference. With `destination: "cnc-router"` every routed piece is one DXF carrying all its layers
|
|
3257
|
+
(depth per layer), laser pieces are written as for a service, and kerf must be 0. Every `options` key is
|
|
3258
|
+
optional: `destination` (`"own-laser"`, the default, `"service"` or `"cnc-router"`), `kerf` (0–0.5 mm,
|
|
3152
3259
|
applied to the cut lines only), `stock: [{ group, size: [w, h] }]` (a row's
|
|
3153
3260
|
`sheet.group`; a `"*"` entry sizes every group that has no entry of its own; 300 × 300
|
|
3154
3261
|
mm when `stock` is omitted), `margin` (5 mm),
|
|
@@ -3958,6 +4065,9 @@ verify check". `no-buildable-parts` points a sub-part with a `sheet` but no `bui
|
|
|
3958
4065
|
at `sheetPart()`, and `verify-unknown-process` answers `process: "laser"` the same
|
|
3959
4066
|
way.
|
|
3960
4067
|
|
|
4068
|
+
**Routed parts** — `routed-drill-depth` (error): a `routedPart` drill deeper than
|
|
4069
|
+
the stock at the defaults. It carries `pattern: "routed-parts"`.
|
|
4070
|
+
|
|
3961
4071
|
A rule that itself throws yields an `internal-rule-error` **warning** and the run
|
|
3962
4072
|
continues: `lintPart` never throws and never blocks a part because of a linter bug.
|
|
3963
4073
|
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -765,13 +765,15 @@ the helpers come along unmodified. (`test/kernel-contract.test.js` asserts every
|
|
|
765
765
|
`arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep on OCCT and
|
|
766
766
|
facet at mesh LOD on Manifold.
|
|
767
767
|
- **Sheet parts** — `sheetPart` wraps a laser-cut piece into an ordinary sub-part
|
|
768
|
-
(a generated `build` and `place` plus a plain-data `sheet` declaration)
|
|
768
|
+
(a generated `build` and `place` plus a plain-data `sheet` declaration), and
|
|
769
|
+
`routedPart` is the same wrapper for a CNC-routed piece (process `cnc-router`); the
|
|
769
770
|
joinery helpers `fingers`, `tabs`, `tSlots`, `sheetPanel`, `matchingSlots`,
|
|
770
771
|
`fingerBox`, `printedTab`, `sheetHole` and the `JOINERY_SCREWS` table are pure
|
|
771
772
|
functions returning plain data in this contract's input format; `sheetToWorld` /
|
|
772
773
|
`worldToSheet` convert between a posed sheet's drawing and world coordinates. The
|
|
773
774
|
generated build calls only ops from the tables above (`shape2d`, `extrude`,
|
|
774
|
-
`offset`, `cut`, `cutAll`, `translate`, `rotate`
|
|
775
|
+
`offset`, `cut`, `cutAll`, `translate`, `rotate`, plus `cylinder` for a routed part's
|
|
776
|
+
drills), so a sheet part is portable by
|
|
775
777
|
construction too. None of these is a kernel op.
|
|
776
778
|
|
|
777
779
|
**Profile validation on the way in** (0.112). `prism`, `extrude`, `revolve`, `sweep`,
|
package/package.json
CHANGED
package/src/framework/app.css
CHANGED
|
@@ -835,14 +835,16 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
835
835
|
.pf-card-lab-row { display: flex; justify-content: space-between; align-items: baseline; }
|
|
836
836
|
.pf-card-lab { font-size: 12px; color: var(--pf-muted); }
|
|
837
837
|
.pf-card-val { font-size: 12px; color: var(--pf-text-2); font-variant-numeric: tabular-nums; }
|
|
838
|
-
.pf-card-
|
|
839
|
-
.pf-card-
|
|
838
|
+
.pf-card-val.preview { color: var(--pf-text); }
|
|
839
|
+
.pf-card-tiles { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px 2px; }
|
|
840
|
+
.pf-card-tile { display: grid; justify-items: center; align-content: start; gap: 4px; border: 0; background: none; padding: 4px 1px 3px; border-radius: 8px; cursor: pointer; font: inherit; color: var(--pf-text-2); }
|
|
840
841
|
.pf-card-tile:hover { background: var(--pf-surface-2); }
|
|
841
842
|
.pf-card-tile[aria-pressed="true"] { background: var(--pf-accent-soft); color: var(--pf-accent); }
|
|
842
|
-
.pf-card-ball { position: relative; width:
|
|
843
|
+
.pf-card-ball { position: relative; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; background: radial-gradient(circle at 35% 30%, color-mix(in oklab, var(--pf-swatch) 72%, white) 0, var(--pf-swatch) 48%, color-mix(in oklab, var(--pf-swatch) 68%, black) 100%); box-shadow: inset 0 -1px 2px rgb(0 0 0 / .25); }
|
|
843
844
|
.pf-card-ball img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
|
844
845
|
.pf-card-ball img[hidden] { display: none; }
|
|
845
|
-
|
|
846
|
+
/* Two lines at most; the Material row names the hovered tile in full. */
|
|
847
|
+
.pf-card-tile-label { font-size: 10.5px; line-height: 1.15; max-width: 100%; min-height: 2.3em; text-align: center; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; }
|
|
846
848
|
.pf-card-unknown { margin: 0; font-size: 11px; color: var(--pf-err); font-family: var(--pf-mono); }
|
|
847
849
|
.pf-card-unknown[hidden] { display: none; }
|
|
848
850
|
.pf-card-colors { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 6px; }
|
|
@@ -206,6 +206,30 @@ export function attachAppearanceCard(parts, { onBack, onClose, onGone, getSaveSt
|
|
|
206
206
|
const ownColor = known(id) && !PRESETS[id].tintable && typeof parts.getState(name)?.override?.color === "number";
|
|
207
207
|
parts.setDisplay(name, ownColor ? { material: id, color: null } : { material: id });
|
|
208
208
|
});
|
|
209
|
+
// The Material row names the tile under the pointer (or keyboard focus),
|
|
210
|
+
// then the part's material again: a tile's own label clamps to two lines,
|
|
211
|
+
// so this is where a long name reads in full. A touch names nothing — a tap
|
|
212
|
+
// applies the tile, and the row then names it as the material anyway.
|
|
213
|
+
let previewLabel = null;
|
|
214
|
+
const showMaterialName = () => {
|
|
215
|
+
matVal.textContent = previewLabel ?? row?.materialLabel ?? "None";
|
|
216
|
+
matVal.classList.toggle("preview", previewLabel !== null);
|
|
217
|
+
};
|
|
218
|
+
const previewFrom = (e) => {
|
|
219
|
+
const t = e.target.closest?.(".pf-card-tile");
|
|
220
|
+
previewLabel = t ? t.title : null;
|
|
221
|
+
showMaterialName();
|
|
222
|
+
};
|
|
223
|
+
const previewEnd = (e) => {
|
|
224
|
+
const t = e.target.closest?.(".pf-card-tile");
|
|
225
|
+
if (!t || t.contains(e.relatedTarget)) return;
|
|
226
|
+
previewLabel = null;
|
|
227
|
+
showMaterialName();
|
|
228
|
+
};
|
|
229
|
+
tiles.addEventListener("pointerover", (e) => { if (e.pointerType !== "touch") previewFrom(e); });
|
|
230
|
+
tiles.addEventListener("pointerout", (e) => { if (e.pointerType !== "touch") previewEnd(e); });
|
|
231
|
+
tiles.addEventListener("focusin", previewFrom);
|
|
232
|
+
tiles.addEventListener("focusout", previewEnd);
|
|
209
233
|
colors.addEventListener("click", (e) => {
|
|
210
234
|
const b = e.target.closest(".pf-card-color");
|
|
211
235
|
if (!b || !name) return;
|
|
@@ -238,7 +262,7 @@ export function attachAppearanceCard(parts, { onBack, onClose, onGone, getSaveSt
|
|
|
238
262
|
see.value = String(pct); seeVal.textContent = `${pct}%`;
|
|
239
263
|
const m = state.effective.material;
|
|
240
264
|
for (const t of tiles.children) t.setAttribute("aria-pressed", String(known(m) && t.dataset.material === m));
|
|
241
|
-
|
|
265
|
+
showMaterialName();
|
|
242
266
|
unknown.hidden = !(m != null && !known(m));
|
|
243
267
|
unknown.textContent = unknown.hidden ? "" : String(m);
|
|
244
268
|
const color = row.color;
|
|
@@ -320,7 +344,7 @@ export function attachAppearanceCard(parts, { onBack, onClose, onGone, getSaveSt
|
|
|
320
344
|
element: root,
|
|
321
345
|
show(next) {
|
|
322
346
|
if (!parts.list().some((r) => r.name === next)) return false;
|
|
323
|
-
name = next;
|
|
347
|
+
name = next; previewLabel = null;
|
|
324
348
|
render();
|
|
325
349
|
return true;
|
|
326
350
|
},
|
|
@@ -19,7 +19,7 @@ import { resolveSheet } from "../sheet/resolve.js";
|
|
|
19
19
|
import { processById, SHEET_METRICS } from "../process/registry.js";
|
|
20
20
|
import { loadExporter } from "../process/exporters.js";
|
|
21
21
|
import { validateKitOptions, resolveStock, KIT_OPTIONS_ERROR, KIT_LIMITS } from "./formats.js";
|
|
22
|
-
import { canonicalDrawingKey } from "./drawing.js";
|
|
22
|
+
import { canonicalDrawingKey, routedPalette } from "./drawing.js";
|
|
23
23
|
import { renderSvg } from "./svg.js";
|
|
24
24
|
import { renderDxf } from "./dxf.js";
|
|
25
25
|
import { layoutGroup, marginFitsStock } from "./layout.js";
|
|
@@ -140,7 +140,6 @@ const wordsOf = (s) => String(s ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").re
|
|
|
140
140
|
// are listed, and "display", "outfit" and "profit" are not.
|
|
141
141
|
const CLEARANCE_WORDS = new Set(["clearance", "clearances", "fit", "play"]);
|
|
142
142
|
const isClearance = (c) => [...wordsOf(c.key), ...wordsOf(c.label)].some((w) => CLEARANCE_WORDS.has(w));
|
|
143
|
-
const ALL_LAYERS = ["cut", "score", "engrave"];
|
|
144
143
|
|
|
145
144
|
// Every control that owns a parameter key, whatever the section shape: authored
|
|
146
145
|
// `controls` (groups nest), and the legacy `advanced`, `toggles`, `features` and a
|
|
@@ -193,6 +192,29 @@ function sheetMetaOrThrow(kernel, sp, p, d, name) {
|
|
|
193
192
|
throw new Error(`sheet part "${name}": its material or thickness could not be evaluated`);
|
|
194
193
|
}
|
|
195
194
|
|
|
195
|
+
// The README's layer legend: every routed layer name in the kit, once, with its colour
|
|
196
|
+
// (ACI in a DXF kit, hex in an SVG one) and depth — REFERENCE, SECONDARY, then drills in
|
|
197
|
+
// palette order, then pockets shallowest first. The palette is kit-wide, so a name has
|
|
198
|
+
// one colour; the legend is the authority when a palette cycles.
|
|
199
|
+
const LEGEND_RANK = { reference: 0, secondary: 1, drill: 2, pocket: 3 };
|
|
200
|
+
const legendKind = (l) => (l.id.startsWith("drill-") ? "drill" : l.id.startsWith("pocket-") ? "pocket" : l.id);
|
|
201
|
+
function routedLegend(pieces, cutExt) {
|
|
202
|
+
const rows = new Map();
|
|
203
|
+
for (const pc of pieces) {
|
|
204
|
+
for (const l of pc.drawing.layers) {
|
|
205
|
+
if (l.name === undefined || rows.has(l.name)) continue;
|
|
206
|
+
const kind = legendKind(l);
|
|
207
|
+
rows.set(l.name, { name: l.name, kind, order: rows.size,
|
|
208
|
+
colour: cutExt === "svg" ? l.svgColor : String(l.color),
|
|
209
|
+
depth: l.through ? "through" : typeof l.depth === "number" ? `${l.depth.toFixed(2)} mm` : "—", mm: l.depth ?? 0 });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return [...rows.values()]
|
|
213
|
+
.sort((a, b) => (LEGEND_RANK[a.kind] ?? 9) - (LEGEND_RANK[b.kind] ?? 9)
|
|
214
|
+
|| (a.kind === "pocket" ? a.mm - b.mm : 0) || a.order - b.order)
|
|
215
|
+
.map(({ name, colour, depth }) => ({ name, colour, depth }));
|
|
216
|
+
}
|
|
217
|
+
|
|
196
218
|
// One export-bundle job's kit. `names` is the job's selected() — exportable, enabled, in
|
|
197
219
|
// definition order — and `posed`/`label`/`exportName` are jobs.js's own, so a kit's
|
|
198
220
|
// printed parts are built exactly the way the STL and 3MF exports build them.
|
|
@@ -201,7 +223,8 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
201
223
|
// not wait behind a build (spec D.3). An absent options object means every default.
|
|
202
224
|
const o = validateKitOptions(msg.options ?? {});
|
|
203
225
|
const service = o.destination === "service";
|
|
204
|
-
const
|
|
226
|
+
const router = o.destination === "cnc-router";
|
|
227
|
+
const cutExt = o.cutFormat; // svg for own-laser, dxf otherwise
|
|
205
228
|
const say = (phase) => onProgress(clipPhase(phase));
|
|
206
229
|
const nameOf = (name) => cleanLabel(label(name));
|
|
207
230
|
if (names.length === 0) throw new Error("no exportable parts selected");
|
|
@@ -240,23 +263,36 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
240
263
|
|
|
241
264
|
// Draw every sheet part flat, from its 2-D declaration, with its 2-D facts under ONE
|
|
242
265
|
// budget for the whole kit.
|
|
266
|
+
// Resolved first, drawn second: a routed layer's colour comes from a palette over
|
|
267
|
+
// EVERY routed piece's depths (drawing.js routedPalette), so one depth is one colour
|
|
268
|
+
// in every file of the kit and on every sheet.
|
|
243
269
|
const deadline = Date.now() + SHEET_CHECK_BUDGET_MS;
|
|
244
|
-
const
|
|
270
|
+
const resolved = [];
|
|
245
271
|
for (const name of sheetNames) {
|
|
246
272
|
say(`building ${nameOf(name)}`);
|
|
247
273
|
const s = resolveSheet(kernel, part.parts[name], p, d);
|
|
248
274
|
const desc = processById(s.process);
|
|
249
275
|
if (!desc) throw new Error(`sheet part "${nameOf(name)}": unknown process "${s.process}"`);
|
|
250
|
-
|
|
276
|
+
resolved.push({ name, s, facts: desc.facts(s, { deadline }) });
|
|
277
|
+
}
|
|
278
|
+
const palette = routedPalette(resolved.filter((x) => x.s.process === "cnc-router").map((x) => x.s));
|
|
279
|
+
const drawn = [];
|
|
280
|
+
for (const { name, s, facts } of resolved) {
|
|
251
281
|
const exporter = await loadExporter(s.process);
|
|
252
|
-
const drawing = exporter.drawing(s, o, kernel, { label: nameOf(name), facts });
|
|
282
|
+
const drawing = exporter.drawing(s, o, kernel, { label: nameOf(name), facts, palette });
|
|
283
|
+
// A routed piece is known by its process, never guessed from its layers: one with
|
|
284
|
+
// only a profile draws no named layer and is still routed.
|
|
253
285
|
drawn.push({ name, label: nameOf(name), fileKey: exportName(name), group: groupOf.get(name),
|
|
254
|
-
drawing, facts, key: canonicalDrawingKey(drawing) });
|
|
286
|
+
drawing, facts, key: canonicalDrawingKey(drawing), routed: s.process === "cnc-router" });
|
|
255
287
|
}
|
|
256
288
|
|
|
257
289
|
// Identical pieces become one file ×N (spec D.4): the hash nominates; the canonical
|
|
258
290
|
// drawing and the stock group confirm.
|
|
259
|
-
|
|
291
|
+
// A routed piece's operations table is confirmed too: an extra op's depth and method,
|
|
292
|
+
// and the op labels, are not in its drawing — only the README carries them, so two
|
|
293
|
+
// pieces that draw alike but machine differently must stay two files.
|
|
294
|
+
const sameOps = (a, b) => JSON.stringify(a.facts?.routed ?? null) === JSON.stringify(b.facts?.routed ?? null);
|
|
295
|
+
const pieces = dedupe(drawn, (x) => h(x.key), (a, b) => a.key === b.key && a.group === b.group && sameOps(a, b))
|
|
260
296
|
.map(({ item, members }) => ({
|
|
261
297
|
...item, labels: members.map((m) => m.label), qty: members.length * o.sets,
|
|
262
298
|
hasMarks: item.drawing.layers.some((l) => l.id === "score" || l.id === "engrave"),
|
|
@@ -283,7 +319,8 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
283
319
|
};
|
|
284
320
|
for (const pc of pieces) {
|
|
285
321
|
const sx = pc.qty > 1 ? `-x${pc.qty}` : "";
|
|
286
|
-
|
|
322
|
+
// A routed piece is never split: its depths are one layered file for the CAM.
|
|
323
|
+
const marks = (service || router) && pc.hasMarks && !pc.routed;
|
|
287
324
|
const name = claim(kitName(pc.fileKey),
|
|
288
325
|
(n) => [`parts/${n}${sx}.${cutExt}`, ...(marks ? [`parts/${n}${sx}-marks.dxf`] : []), `parts/${n}/`], pc.labels);
|
|
289
326
|
pc.file = `parts/${name}${sx}.${cutExt}`;
|
|
@@ -301,7 +338,7 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
301
338
|
say(`laying out ${g.material} ${fmtMm(g.thickness)} mm (${count} pieces)`);
|
|
302
339
|
const stock = stockFor.get(g.group);
|
|
303
340
|
const laid = layoutGroup({
|
|
304
|
-
group: g.group, label: g.label, stock, margin: o.margin, spacing: o.spacing, strict: !service,
|
|
341
|
+
group: g.group, label: g.label, stock, margin: o.margin, spacing: o.spacing, strict: !(service || router),
|
|
305
342
|
pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.drawing, qty: pc.qty })),
|
|
306
343
|
});
|
|
307
344
|
if (laid.sheets.length > KIT_LIMITS.sheetsPerGroup) {
|
|
@@ -354,20 +391,23 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
354
391
|
if (!pp.bytes) pp.bytes = new Uint8Array(meshTo3MF([{ name: pp.fileKey, ...pp.mesh, color: pp.color }]));
|
|
355
392
|
}
|
|
356
393
|
|
|
357
|
-
// The files (spec D.8): own laser — an SVG per sheet and per piece; service
|
|
358
|
-
// cut-only DXF per piece, a -marks.dxf where it has marks, and
|
|
394
|
+
// The files (spec D.8): own laser — an SVG per sheet and per piece; service and CNC
|
|
395
|
+
// router — a cut-only DXF per laser piece, a -marks.dxf where it has marks, and
|
|
396
|
+
// reference DXF sheets. A routed piece is one file with every layer, whatever the
|
|
397
|
+
// destination.
|
|
359
398
|
say("writing cut files");
|
|
360
399
|
const title = cleanLabel(part.meta?.title ?? fileBase);
|
|
361
400
|
const files = [];
|
|
362
401
|
const put = (file, text) => files.push({ file, data: strToU8(text) });
|
|
363
402
|
for (const sh of sheets) {
|
|
364
403
|
const doc = { title: `${title} — ${sh.group.label} — sheet ${sh.index} of ${sh.count}`, size: sh.stock, placements: sh.placements };
|
|
365
|
-
put(sh.file,
|
|
404
|
+
put(sh.file, cutExt === "dxf" ? renderDxf(doc) : renderSvg(doc));
|
|
366
405
|
}
|
|
367
406
|
for (const pc of pieces) {
|
|
368
407
|
const doc = { title: pc.labels.join(", "), size: sizeOf(pc.drawing.bounds),
|
|
369
408
|
placements: [{ drawing: pc.drawing, at: [0, 0], rotated: false }] };
|
|
370
|
-
if (
|
|
409
|
+
if (cutExt === "svg") { put(pc.file, renderSvg(doc)); continue; }
|
|
410
|
+
if (pc.routed) { put(pc.file, renderDxf(doc)); continue; } // every layer, one file
|
|
371
411
|
put(pc.file, renderDxf(doc, { include: ["cut"] }));
|
|
372
412
|
if (pc.marksFile) put(pc.marksFile, renderDxf(doc, { include: ["score", "engrave"] }));
|
|
373
413
|
}
|
|
@@ -383,6 +423,9 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
383
423
|
size: sh.stock, pieces: sh.placements.length, used: sh.used })),
|
|
384
424
|
pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, labels: pc.labels, material: groups.get(pc.group).material,
|
|
385
425
|
thickness: groups.get(pc.group).thickness, size: pc.drawing.nominal, quantity: pc.qty })),
|
|
426
|
+
router: pieces.filter((pc) => pc.routed && pc.facts?.routed).map((pc) => ({
|
|
427
|
+
labels: pc.labels, material: groups.get(pc.group).material, thickness: groups.get(pc.group).thickness, ...pc.facts.routed })),
|
|
428
|
+
layers: routedLegend(pieces.filter((pc) => pc.routed), cutExt),
|
|
386
429
|
printed: prints.map((pp) => ({ file: pp.file, labels: pp.labels, quantity: pp.qty })),
|
|
387
430
|
clearances: controls.filter(isClearance)
|
|
388
431
|
.map((c) => ({ label: c.label ?? c.key, value: p[c.key], unit: c.unit ?? "" })),
|
|
@@ -354,3 +354,41 @@ export function applyKerf(regions, kerf, { label = "sheet part", gap = null } =
|
|
|
354
354
|
if (arcCount(out) < arcCount(regions)) throw new Error(intact);
|
|
355
355
|
return out;
|
|
356
356
|
}
|
|
357
|
+
|
|
358
|
+
// ── routed layers (process/cnc-router) ────────────────────────────────────────
|
|
359
|
+
// Depth in millimetres as a layer-name token: up to two decimals, trailing zeros
|
|
360
|
+
// dropped (11.43 → "11.43", 3 → "3"). Two depths that round alike share a layer.
|
|
361
|
+
export const depthToken = (mm) => String(Number(mm.toFixed(2)));
|
|
362
|
+
export function routedLayerName(kind, op) {
|
|
363
|
+
if (kind === "pocket") return `POCKET_${depthToken(op.depth)}MM`;
|
|
364
|
+
if (kind === "drill") return op.through ? `DRILL_D${depthToken(op.d)}_THRU` : `DRILL_D${depthToken(op.d)}_${depthToken(op.depth)}MM`;
|
|
365
|
+
throw new Error(`routedLayerName: unknown kind ${JSON.stringify(kind)}`);
|
|
366
|
+
}
|
|
367
|
+
// A drill's tool setting as a key: diameter and depth (or THRU), two-decimal tokens —
|
|
368
|
+
// one layer per key, and the kit-wide palette's drill key.
|
|
369
|
+
export const drillKey = (dr) => `D${depthToken(dr.d)}-${dr.through ? "THRU" : depthToken(dr.depth)}`;
|
|
370
|
+
// Palettes: ACI colours for DXF, hex for SVG. Drills cycle; pockets are assigned by
|
|
371
|
+
// ascending depth (shallowest first). In a kit the assignment is kit-wide
|
|
372
|
+
// (routedPalette), so one depth is one colour in every file.
|
|
373
|
+
export const ROUTED_COLORS = Object.freeze({
|
|
374
|
+
reference: { color: 8, svgColor: "#808080" },
|
|
375
|
+
secondary: { color: 6, svgColor: "#FF00FF" },
|
|
376
|
+
drill: Object.freeze([{ color: 3, svgColor: "#00AA00" }, { color: 4, svgColor: "#00AAAA" }, { color: 2, svgColor: "#AAAA00" }, { color: 140, svgColor: "#0055AA" }, { color: 170, svgColor: "#5500AA" }]),
|
|
377
|
+
pocket: Object.freeze([{ color: 30, svgColor: "#E07000" }, { color: 40, svgColor: "#E0A000" }, { color: 50, svgColor: "#C0C000" }, { color: 20, svgColor: "#A06000" }, { color: 10, svgColor: "#804000" }]),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// The kit-wide palette over every routed piece's resolved operations, so a layer name
|
|
381
|
+
// carries one colour in every file of the kit: pocket depth tokens ranked ascending
|
|
382
|
+
// (shallowest first), drill keys in first-seen order, each cycling its palette.
|
|
383
|
+
// `sheets` are resolved routed sheets, in definition order.
|
|
384
|
+
export function routedPalette(sheets) {
|
|
385
|
+
const pocketTokens = new Set(), drillKeys = new Set();
|
|
386
|
+
for (const s of sheets) {
|
|
387
|
+
for (const pk of s.routed.pockets) if (!pk.region.isEmpty()) pocketTokens.add(depthToken(pk.depth));
|
|
388
|
+
for (const dr of s.routed.drills) drillKeys.add(drillKey(dr));
|
|
389
|
+
}
|
|
390
|
+
const pocket = new Map([...pocketTokens].sort((a, b) => Number(a) - Number(b))
|
|
391
|
+
.map((tok, i) => [tok, ROUTED_COLORS.pocket[i % ROUTED_COLORS.pocket.length]]));
|
|
392
|
+
const drill = new Map([...drillKeys].map((key, i) => [key, ROUTED_COLORS.drill[i % ROUTED_COLORS.drill.length]]));
|
|
393
|
+
return { pocket, drill };
|
|
394
|
+
}
|
|
@@ -111,13 +111,50 @@ function writeEntity(g, layer, path, place) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
// `include` picks the layers: a cutting service gets one cut-only file per piece and a
|
|
114
|
-
// separate marks file (["score", "engrave"]). Only the
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
// separate marks file (["score", "engrave"]). Only the written layers are declared.
|
|
115
|
+
// A layer carrying its own `name` (a routed layer) is written under that name and
|
|
116
|
+
// colour; one without is a laser layer under LAYER_FOR/DXF_LAYERS. `include`
|
|
117
|
+
// undefined writes every layer; a list writes only the named laser classes, so a
|
|
118
|
+
// cutting service's cut-only and marks files never carry a routed layer by accident.
|
|
119
|
+
const NAME_RE = /^[A-Z0-9_.]{1,31}$/;
|
|
120
|
+
function layerNameOf(layer) {
|
|
121
|
+
if (layer.name === undefined) return LAYER_FOR[layer.id];
|
|
122
|
+
if (!NAME_RE.test(layer.name) || !Number.isInteger(layer.color)) throw new Error(`renderDxf: layer ${JSON.stringify(layer.id)} needs a DXF name (A-Z, 0-9, _ .) and an integer colour`);
|
|
123
|
+
return layer.name;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function renderDxf(doc, { include } = {}) {
|
|
127
|
+
const all = include === undefined;
|
|
128
|
+
for (const inc of include ?? [])
|
|
117
129
|
if (!INCLUDES.includes(inc)) throw new Error(`renderDxf: include lists "cut", "score" and "engrave" only, got ${JSON.stringify(inc)}`);
|
|
118
|
-
const want = new Set(include);
|
|
119
|
-
const
|
|
120
|
-
|
|
130
|
+
const want = new Set(include ?? INCLUDES);
|
|
131
|
+
const selected = (layer) => (layer.name !== undefined ? all : want.has(INCLUDE_FOR[layer.id]));
|
|
132
|
+
// Entity order: engrave, score, every custom layer in first-seen order across the
|
|
133
|
+
// placements, then cut-inner and cut-outer — a routed piece's holes and pockets are
|
|
134
|
+
// milled while the sheet still holds it, and the outline frees it last.
|
|
135
|
+
const custom = [];
|
|
136
|
+
for (const placement of doc.placements) for (const layer of placement.drawing.layers)
|
|
137
|
+
if (layer.name !== undefined && selected(layer) && !custom.includes(layer.id)) custom.push(layer.id);
|
|
138
|
+
const ids = [...ORDER.filter((id) => !id.startsWith("cut") && want.has(INCLUDE_FOR[id])), ...custom, ...ORDER.filter((id) => id.startsWith("cut") && want.has(INCLUDE_FOR[id]))];
|
|
139
|
+
// The LAYER table, in the entity order. A laser-only document (no named layer written)
|
|
140
|
+
// declares every INCLUDED laser class whether or not a placement draws on it — the
|
|
141
|
+
// laser writer's bytes, unchanged. A document with a named (routed) layer declares
|
|
142
|
+
// only the layers actually present, laser classes included, so a router file carries
|
|
143
|
+
// no empty ENGRAVE/SCORE rows.
|
|
144
|
+
const present = (id) => doc.placements.some((pl) => pl.drawing.layers.some((l) => l.id === id));
|
|
145
|
+
const onlyPresent = custom.length > 0;
|
|
146
|
+
const names = [], colors = new Map();
|
|
147
|
+
const declare = (name, color) => { if (!names.includes(name)) { names.push(name); colors.set(name, color); } };
|
|
148
|
+
for (const id of ids) {
|
|
149
|
+
if (LAYER_FOR[id] !== undefined) {
|
|
150
|
+
if (!onlyPresent || present(id)) declare(LAYER_FOR[id], DXF_LAYERS[LAYER_FOR[id]]);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const placement of doc.placements) {
|
|
154
|
+
const layer = placement.drawing.layers.find((l) => l.id === id);
|
|
155
|
+
if (layer) declare(layerNameOf(layer), layer.color);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
121
158
|
const out = [];
|
|
122
159
|
const g = (code, value) => { out.push(String(code).padStart(3), String(value)); };
|
|
123
160
|
|
|
@@ -131,7 +168,7 @@ export function renderDxf(doc, { include = INCLUDES } = {}) {
|
|
|
131
168
|
g(0, "LTYPE"); g(2, "CONTINUOUS"); g(70, 0); g(3, "Solid line"); g(72, 65); g(73, 0); g(40, "0.0");
|
|
132
169
|
g(0, "ENDTAB");
|
|
133
170
|
g(0, "TABLE"); g(2, "LAYER"); g(70, names.length);
|
|
134
|
-
for (const name of names) { g(0, "LAYER"); g(2, name); g(70, 0); g(62,
|
|
171
|
+
for (const name of names) { g(0, "LAYER"); g(2, name); g(70, 0); g(62, colors.get(name)); g(6, "CONTINUOUS"); }
|
|
135
172
|
g(0, "ENDTAB");
|
|
136
173
|
g(0, "ENDSEC");
|
|
137
174
|
|
|
@@ -141,7 +178,7 @@ export function renderDxf(doc, { include = INCLUDES } = {}) {
|
|
|
141
178
|
const layer = placement.drawing.layers.find((l) => l.id === id);
|
|
142
179
|
if (!layer) continue;
|
|
143
180
|
const place = placer(placement);
|
|
144
|
-
for (const path of layer.paths) writeEntity(g,
|
|
181
|
+
for (const path of layer.paths) writeEntity(g, layerNameOf(layer), path, place);
|
|
145
182
|
}
|
|
146
183
|
}
|
|
147
184
|
g(0, "ENDSEC");
|
|
@@ -21,9 +21,10 @@ export const EXPORT_FORMATS = Object.freeze([
|
|
|
21
21
|
Object.freeze({ id: "bundle", label: "Cut and print kit", ext: "zip", mime: "application/zip", needsSheet: true }),
|
|
22
22
|
]);
|
|
23
23
|
|
|
24
|
-
// Destination → the cut-file format it writes. Pinned equal to the
|
|
25
|
-
// `destinations` by test/kit
|
|
26
|
-
|
|
24
|
+
// Destination → the cut-file format it writes. Pinned equal to the union of every
|
|
25
|
+
// process descriptor's `destinations` by test/routed-kit.test.js and
|
|
26
|
+
// test/kit-formats.test.js — one fact, two spellings.
|
|
27
|
+
export const DESTINATIONS = Object.freeze({ "own-laser": "svg", service: "dxf", "cnc-router": "dxf" });
|
|
27
28
|
|
|
28
29
|
// 300 × 300 mm until the paying user's machine is known (design spec decision 12).
|
|
29
30
|
export const DEFAULT_STOCK_SIZE = Object.freeze([300, 300]);
|
|
@@ -64,7 +65,7 @@ export function validateKitOptions(o) {
|
|
|
64
65
|
|
|
65
66
|
const destination = pick("destination");
|
|
66
67
|
if (typeof destination !== "string" || !Object.hasOwn(DESTINATIONS, destination))
|
|
67
|
-
throw fail(`destination must be "own-laser" or "
|
|
68
|
+
throw fail(`destination must be "own-laser", "service" or "cnc-router", got ${JSON.stringify(destination)}`);
|
|
68
69
|
const cutFormat = DESTINATIONS[destination];
|
|
69
70
|
if (opts.cutFormat !== undefined && opts.cutFormat !== cutFormat)
|
|
70
71
|
throw fail(`cutFormat ${JSON.stringify(opts.cutFormat)} does not match destination "${destination}" (which writes ${cutFormat})`);
|
|
@@ -72,6 +73,8 @@ export function validateKitOptions(o) {
|
|
|
72
73
|
const kerf = pick("kerf");
|
|
73
74
|
if (!inRange(kerf, KIT_LIMITS.kerf))
|
|
74
75
|
throw fail(`kerf must be a number from ${KIT_LIMITS.kerf[0]} to ${KIT_LIMITS.kerf[1]} mm, got ${JSON.stringify(kerf)}`);
|
|
76
|
+
if (destination === "cnc-router" && kerf > 0)
|
|
77
|
+
throw fail("kerf does not apply to a CNC router — the CAM applies the tool offset; set kerf to 0");
|
|
75
78
|
const colors = pick("colors");
|
|
76
79
|
if (colors !== "lightburn") throw fail(`colors must be "lightburn", got ${JSON.stringify(colors)}`);
|
|
77
80
|
const stock = normalizeStock(opts.stock);
|