partforge 0.150.1 → 0.152.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 +125 -9
- package/docs/KERNEL-CONTRACT.md +4 -2
- package/package.json +1 -1
- 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/lint/rules-materials.js +18 -1
- package/src/framework/materials/assets/SOURCES.md +6 -2
- package/src/framework/materials/assets/pattern-pine-plywood-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-pine-plywood-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-pine-plywood-rough.jpg +0 -0
- package/src/framework/materials/assets.js +3 -0
- package/src/framework/materials/presets.js +8 -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/src/parts/material-swatches.js +2 -2
- 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
|
@@ -393,7 +393,9 @@ meta: { title: "…", units: "mm", environment: "studio" },
|
|
|
393
393
|
marked tintable are normally coloured this way (anodizing, plastic, paint);
|
|
394
394
|
the others have an intrinsic colour (brass) but still accept one. (A viewer
|
|
395
395
|
user PICKING an untintable preset drops an authored tint for that pick — see
|
|
396
|
-
the runtime section below.)
|
|
396
|
+
the runtime section below.) On a TEXTURED preset (`oak`, `walnut`, `plywood`,
|
|
397
|
+
`pine-plywood`) `color` multiplies the scanned wood and dulls its grain — leave
|
|
398
|
+
it unset unless you mean a stain (lint warns: `material-tint-textured`).
|
|
397
399
|
- Overrides — each clamped to its range: `roughness` (0–1), `metalness` (0–1),
|
|
398
400
|
`clearcoat` (0–1), `clearcoatRoughness` (0–1), `anisotropy` (0–1), `textureScale` (0.01–1000).
|
|
399
401
|
Transmission, index of refraction, sheen and iridescence come only from a preset.
|
|
@@ -434,10 +436,10 @@ modelled in its print orientation, not the material. Wood, carbon fibre and SLS
|
|
|
434
436
|
fixed to the sub-part, so they never slide when the camera or an animation
|
|
435
437
|
moves.
|
|
436
438
|
|
|
437
|
-
**Laser-cut wood.** In realistic mode a laser `sheetPart` in a wood — `plywood`, `oak` or
|
|
439
|
+
**Laser-cut wood.** In realistic mode a laser `sheetPart` in a wood — `plywood`, `pine-plywood`, `oak` or
|
|
438
440
|
`walnut`, named or its stock's default (so a plywood sheet that names no material burns too)
|
|
439
441
|
— shows what the laser did: its cut edges are charred, darker on thicker stock (on
|
|
440
|
-
`plywood` the plies show through), and its engraving and score lines are scorched, while its
|
|
442
|
+
`plywood` and `pine-plywood` the plies show through), and its engraving and score lines are scorched, while its
|
|
441
443
|
faces stay wood. There is nothing to set, and it is still one material: the char follows
|
|
442
444
|
from the sheet and the wood it is drawn in. It needs the sheet's own frame, so a sheet part
|
|
443
445
|
with a custom `build` or a `views` pose of its own shows plain wood. The CAD view and every export are
|
|
@@ -451,7 +453,7 @@ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
|
|
|
451
453
|
| --- | --- | --- | --- |
|
|
452
454
|
| layer lines | `pla-print`, `petg-print` | layer height | 0.2 |
|
|
453
455
|
| SLS grain | `nylon-sls` | grain size | 0.15 |
|
|
454
|
-
| wood | `oak`, `walnut`, `plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`) |
|
|
456
|
+
| wood | `oak`, `walnut`, `plywood`, `pine-plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`), 1000 (`pine-plywood`, sized for whole 4 × 8 sheets) |
|
|
455
457
|
| carbon weave | `carbon-fiber` | size of one texture tile | 48 |
|
|
456
458
|
|
|
457
459
|
Presets without a pattern ignore it.
|
|
@@ -488,6 +490,7 @@ the viewer's blue-grey.
|
|
|
488
490
|
| `oak` | Oak | — | Light oak with open grain. |
|
|
489
491
|
| `walnut` | Walnut | — | Dark oiled walnut. |
|
|
490
492
|
| `plywood` | Birch plywood | — | Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges. |
|
|
493
|
+
| `pine-plywood` | Sanded pine plywood | — | Hardware-store 4 × 8 sheet plywood (sanded pine/fir face): warm, bold grain; plies show on laser-cut edges. |
|
|
491
494
|
| `carbon-fiber` | Carbon fibre | — | 2x2 twill carbon fibre under clear coat. |
|
|
492
495
|
|
|
493
496
|
**Environments** (`meta.environment`, default `studio`; viewers can switch):
|
|
@@ -1906,7 +1909,7 @@ plate: sheetPart({
|
|
|
1906
1909
|
It returns an ordinary sub-part: `build`, the pose's placement and a plain-data `sheet`. A field
|
|
1907
1910
|
needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d) => …`.
|
|
1908
1911
|
`material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
|
|
1909
|
-
`process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
|
|
1912
|
+
`process` (default `"laser"`; `"cnc-router"` is `routedPart`, below) are optional; `label`, `views`, `display`, `export`,
|
|
1910
1913
|
`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
1914
|
`quantity`; each error names the fix.
|
|
1912
1915
|
|
|
@@ -2020,7 +2023,7 @@ the assembled view, and `minWall` and the overhang check skip sheet parts.
|
|
|
2020
2023
|
|
|
2021
2024
|
### The kit
|
|
2022
2025
|
|
|
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 `"
|
|
2026
|
+
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
2027
|
|
|
2025
2028
|
### Limits
|
|
2026
2029
|
|
|
@@ -2150,6 +2153,112 @@ export default {
|
|
|
2150
2153
|
};
|
|
2151
2154
|
```
|
|
2152
2155
|
|
|
2156
|
+
## Routed parts
|
|
2157
|
+
|
|
2158
|
+
CNC-routed parts from flat stock — hardwood, plywood, MDF, acrylic — for a 2.5D router
|
|
2159
|
+
CAM (Vectric VCarve, Carbide Create, Easel, Carveco): a through cut, flat-floored
|
|
2160
|
+
pockets at a depth, drilled holes at a depth, and operations done with another tool
|
|
2161
|
+
afterwards. Build each piece with `routedPart()` from `partforge/geometry`; the cut &
|
|
2162
|
+
print kit's **CNC router** destination writes one DXF per piece with one layer per depth,
|
|
2163
|
+
and a README table of every operation. A routed part is also the 3D sub-part — assembly,
|
|
2164
|
+
renders and STEP all come from the one declaration — so model it once.
|
|
2165
|
+
|
|
2166
|
+
### When a sub-part is a routed part
|
|
2167
|
+
|
|
2168
|
+
When it is machined from one face of a flat board: an outline, pockets with flat floors,
|
|
2169
|
+
holes, all straight down. A sculpted surface, a part machined from both faces, or
|
|
2170
|
+
anything that is not flat stock is an ordinary `build` (export it as STEP). A laser-cut
|
|
2171
|
+
plate is `sheetPart`.
|
|
2172
|
+
|
|
2173
|
+
### routedPart
|
|
2174
|
+
|
|
2175
|
+
```js
|
|
2176
|
+
import { routedPart, roundedRectProfile } from "partforge/geometry";
|
|
2177
|
+
|
|
2178
|
+
plinth: routedPart({
|
|
2179
|
+
label: "Plywood base", views: { assembly: true, base: true }, display: { material: "pine-plywood" },
|
|
2180
|
+
material: "pine plywood", // stock label: groups pieces in the kit
|
|
2181
|
+
thickness: (p) => p.baseThickness, // the MEASURED board, from a control
|
|
2182
|
+
profile: (k, p) => k.shape2d(roundedRectProfile(p.baseLength, p.baseWidth, 10)), // the THROUGH cut
|
|
2183
|
+
pockets: (k, p, d) => [
|
|
2184
|
+
{ region: k.shape2d(roundedRectProfile(d.vaultW, d.vaultL, 2)).translate([0, -9]), depth: 11.43, label: "peg vault" },
|
|
2185
|
+
{ region: k.shape2d(roundedRectProfile(d.pocketW, d.pocketL, 4)).translate([-58, -10]), depth: p.inlayThickness, label: "inlay pocket" },
|
|
2186
|
+
],
|
|
2187
|
+
drills: (p, d) => d.pegHoles.map(([x, y]) => ({ at: [x, y], d: p.pegHoleDia, depth: 10 })),
|
|
2188
|
+
extra: (k, p, d) => [{
|
|
2189
|
+
solid: k.box({ size: [d.rackL, 12, 14] }).rotateX(-p.rackTilt).at([0, d.rackY, p.baseThickness - 9.5]),
|
|
2190
|
+
footprint: k.shape2d(roundedRectProfile(d.rackL, 12, 2)).translate([0, d.rackY]),
|
|
2191
|
+
label: "domino rack slot", method: `table saw, blade tilted ${p.rackTilt}°, 9.5 mm deep`,
|
|
2192
|
+
}],
|
|
2193
|
+
reference: (k, p) => [[[-p.baseLength / 2 + 10, 0], [p.baseLength / 2 - 10, 0]]], // a centerline
|
|
2194
|
+
}),
|
|
2195
|
+
```
|
|
2196
|
+
|
|
2197
|
+
`material`, `thickness` and `profile` are required; `pockets`, `drills`, `extra`,
|
|
2198
|
+
`reference` and `pose` are optional; `label`, `views`, `display`, `export`, `enabled`,
|
|
2199
|
+
`exportable` pass through. A field needing the kernel is `(k, p, d) => …`; `drills` is
|
|
2200
|
+
`(p, d) => …`. `build` is supplied — passing one throws, as do `kerf`, `score`,
|
|
2201
|
+
`engrave` and `process`; each error names the fix. The drawing is in XY with the
|
|
2202
|
+
machined face at z = `thickness`; `pose` places it exactly as a sheet part's does.
|
|
2203
|
+
|
|
2204
|
+
### Pockets, drills, extra ops and reference lines
|
|
2205
|
+
|
|
2206
|
+
- **`pockets`** — `[{ region, depth, label? }]`: a Shape2D or profile machined `depth`
|
|
2207
|
+
mm down from the machined face, flat-floored. `depth` may equal `thickness` (a
|
|
2208
|
+
through pocket); deeper is a build error naming the pocket. Pockets whose depths agree to
|
|
2209
|
+
two decimals share a DXF layer.
|
|
2210
|
+
- **`drills`** — `[{ at: [x, y], d, depth, label? }]`: a round hole of diameter `d`.
|
|
2211
|
+
Set `depth` to the thickness to drill through — deeper is a `routed-drill-depth`
|
|
2212
|
+
lint error. Written as a DXF `CIRCLE`, which is what lets
|
|
2213
|
+
the CAM offer a drilling toolpath instead of a profile cut.
|
|
2214
|
+
- **`extra`** — `[{ solid, footprint, label, method }]`: an operation the router cannot
|
|
2215
|
+
do straight down — a tilted slot, a scooped thumb hold, an undercut. `solid` is cut in
|
|
2216
|
+
the preview and the STEP; `footprint` (its outline on the machined face) is drawn on
|
|
2217
|
+
the `SECONDARY` layer; `label` and `method` are REQUIRED and go in the README's
|
|
2218
|
+
operations table so the operator knows what to make and how ("core box bit, 20 mm
|
|
2219
|
+
radius"). Do not put a flat-floored pocket here to avoid stating its depth.
|
|
2220
|
+
- **`reference`** — the same entries as a sheet part's `score`: two points is a line,
|
|
2221
|
+
anything else a shape whose boundaries are drawn. Centerlines and layout marks, on
|
|
2222
|
+
the `REFERENCE` layer; they cut nothing (the preview grooves them 0.2 mm so they show).
|
|
2223
|
+
|
|
2224
|
+
Keep these functions chainable — no branching on `isEmpty()`/`area()` — so pose-only
|
|
2225
|
+
sliders stay fast. `null` entries are skipped; an empty pocket region draws nothing and
|
|
2226
|
+
is counted by `routedEmpty`, as is an extra op whose footprint is empty.
|
|
2227
|
+
|
|
2228
|
+
### The drawing
|
|
2229
|
+
|
|
2230
|
+
One DXF per piece, R12, millimetres: `CUT` (colour 1) is the through cut — the outline and
|
|
2231
|
+
the profile's own holes; `POCKET_<depth>MM` one layer per distinct depth (colours 30, 40, 50, 20,
|
|
2232
|
+
10); `DRILL_D<dia>_<depth>MM` or `DRILL_D<dia>_THRU` per diameter
|
|
2233
|
+
and depth (colours 3, 4, 2, 140, 170); `SECONDARY` (6) the extra ops' footprints;
|
|
2234
|
+
`REFERENCE` (8) layout lines. Depth rides both the layer name and the colour, and colours
|
|
2235
|
+
are assigned per kit — pockets shallowest depth first, drills in the order they appear —
|
|
2236
|
+
so one layer name has one colour in every file. A palette cycles past five depths; the
|
|
2237
|
+
README's LAYERS legend (layer, colour, depth) is the authority. Kerf
|
|
2238
|
+
never applies — the CAM applies the tool offset, and the kit refuses a kerf for this
|
|
2239
|
+
destination. The README's ROUTER OPERATIONS table lists every pocket, drill group and
|
|
2240
|
+
extra op with its depth and method.
|
|
2241
|
+
|
|
2242
|
+
### What lint and verify check
|
|
2243
|
+
|
|
2244
|
+
Lint: `routed-drill-depth` (error) — a drill deeper than the stock at the defaults.
|
|
2245
|
+
Every sheet rule applies too (`sheet-thickness-literal`, `sheet-custom-build`, …); the
|
|
2246
|
+
laser's thickness range does not. A pocket deeper than the stock is a build error.
|
|
2247
|
+
|
|
2248
|
+
Verify volunteers three warnings on every routed part, none counting toward
|
|
2249
|
+
`declared`/`evaluated`: `routedOutside` (pocket, drill or footprint regions outside the
|
|
2250
|
+
profile, `0`), `routedEmpty` (pockets that resolved empty, `0`), `routedPieces` (`1`).
|
|
2251
|
+
Each measured row carries `sheet.routed` — the operations table — beside the stock facts.
|
|
2252
|
+
|
|
2253
|
+
### Limits
|
|
2254
|
+
|
|
2255
|
+
- One machined face. A part worked from both faces is two routed parts or a `build`.
|
|
2256
|
+
- No tool-radius, dogbone or inside-corner checks: the CAM owns those.
|
|
2257
|
+
- No depth per extra op beyond its footprint and the solid's deepest point; the
|
|
2258
|
+
`method` text is where the operator's instructions go.
|
|
2259
|
+
- A routed part is a sheet part of process `cnc-router` everywhere else: the sheet
|
|
2260
|
+
checks' budget, stock grouping and the kit's layout apply unchanged.
|
|
2261
|
+
|
|
2153
2262
|
## Convex hull
|
|
2154
2263
|
|
|
2155
2264
|
`k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
|
|
@@ -3147,8 +3256,9 @@ returns instead:
|
|
|
3147
3256
|
stock), `parts/<name>[-xN].svg` (one per distinct piece; identical pieces merge) and
|
|
3148
3257
|
`print/<name>[-xN].stl` or `.3mf` (the printed parts, in their export pose). With
|
|
3149
3258
|
`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
|
-
|
|
3259
|
+
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
|
|
3260
|
+
(depth per layer), laser pieces are written as for a service, and kerf must be 0. Every `options` key is
|
|
3261
|
+
optional: `destination` (`"own-laser"`, the default, `"service"` or `"cnc-router"`), `kerf` (0–0.5 mm,
|
|
3152
3262
|
applied to the cut lines only), `stock: [{ group, size: [w, h] }]` (a row's
|
|
3153
3263
|
`sheet.group`; a `"*"` entry sizes every group that has no entry of its own; 300 × 300
|
|
3154
3264
|
mm when `stock` is omitted), `margin` (5 mm),
|
|
@@ -3858,7 +3968,10 @@ default to their stock"), `unknown-environment`
|
|
|
3858
3968
|
(`meta.environment` not known; realistic mode uses `studio`),
|
|
3859
3969
|
`material-key-unknown` (a `display` key that is not colour, opacity, material
|
|
3860
3970
|
or one of the six overrides; ignored), `material-override-clamped` (an override
|
|
3861
|
-
outside its range, or not a number; clamped)
|
|
3971
|
+
outside its range, or not a number; clamped), `material-tint-textured` (a
|
|
3972
|
+
`display.color` on a textured preset that is not tintable — `oak`, `walnut`,
|
|
3973
|
+
`plywood`, `pine-plywood`: the colour multiplies the scanned wood and dulls its
|
|
3974
|
+
grain; drop it unless a stain is intended).
|
|
3862
3975
|
|
|
3863
3976
|
**Geometry imports** — `import-unknown-name` (a build calls `k.import` with a
|
|
3864
3977
|
name the part's `imports` field doesn't declare — this throws at build time;
|
|
@@ -3958,6 +4071,9 @@ verify check". `no-buildable-parts` points a sub-part with a `sheet` but no `bui
|
|
|
3958
4071
|
at `sheetPart()`, and `verify-unknown-process` answers `process: "laser"` the same
|
|
3959
4072
|
way.
|
|
3960
4073
|
|
|
4074
|
+
**Routed parts** — `routed-drill-depth` (error): a `routedPart` drill deeper than
|
|
4075
|
+
the stock at the defaults. It carries `pattern: "routed-parts"`.
|
|
4076
|
+
|
|
3961
4077
|
A rule that itself throws yields an `internal-rule-error` **warning** and the run
|
|
3962
4078
|
continues: `lintPart` never throws and never blocks a part because of a linter bug.
|
|
3963
4079
|
|
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
|
@@ -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);
|
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
import { fmtMm } from "../sheet/constants.js";
|
|
9
9
|
|
|
10
10
|
export const README_HEADINGS = Object.freeze([
|
|
11
|
-
"BEFORE YOU CUT", "SHEETS", "PIECES", "PRINTED PARTS", "CLEARANCES (design fit, not kerf)",
|
|
11
|
+
"BEFORE YOU CUT", "SHEETS", "PIECES", "ROUTER OPERATIONS", "PRINTED PARTS", "CLEARANCES (design fit, not kerf)",
|
|
12
12
|
"SETTINGS", "CHECKS", "OVERSIZED PIECES", "RENAMED FILES", "LIMITS",
|
|
13
13
|
]);
|
|
14
14
|
export const CSV_HEADER = "file,kind,labels,material,thickness_mm,width_mm,height_mm,quantity";
|
|
15
15
|
|
|
16
16
|
const SVG_LEGEND = "Colours: engrave = filled black #000000, score = blue #0000FF, cut = red #FF0000 (inner cuts come before outer cuts).";
|
|
17
17
|
const DXF_LEGEND = "DXF layers: CUT (colour 1), SCORE (colour 5), ENGRAVE (colour 7) — set ENGRAVE to fill; R12 has no hatch.";
|
|
18
|
+
const ROUTER_LEGEND = "DXF layers: CUT (colour 1) is the through cut; POCKET_<depth>MM and DRILL_D<dia>_<depth>MM / _THRU carry one depth each; SECONDARY outlines operations done with another tool (see ROUTER OPERATIONS); REFERENCE is layout only. Laser pieces keep CUT/SCORE/ENGRAVE.";
|
|
18
19
|
const LIMITS_LINE = "Layout packs bounding boxes only: no nesting into holes, and grain direction is ignored.";
|
|
19
20
|
|
|
20
21
|
// C0 controls, DEL and C1 controls out of an author string, so a label can never ring a
|
|
@@ -52,6 +53,7 @@ const labelsOf = (labels) => labels.map(cleanLabel).join(", ");
|
|
|
52
53
|
export function renderReadme(m) {
|
|
53
54
|
const k = m.kerf;
|
|
54
55
|
const kerfed = k > 0;
|
|
56
|
+
const router = m.destination === "cnc-router";
|
|
55
57
|
// A cutting service gets each piece's score and engrave lines in a separate
|
|
56
58
|
// -marks.dxf, which neither table lists — so the files are named here, or a service
|
|
57
59
|
// order built from the cut files alone silently leaves the marks off.
|
|
@@ -60,14 +62,16 @@ export function renderReadme(m) {
|
|
|
60
62
|
"BEFORE YOU CUT": [
|
|
61
63
|
...m.stockNotes.map((g) =>
|
|
62
64
|
`This kit assumes ${g.thickness.toFixed(2)} mm ${cleanLabel(g.material)} — if yours differs, change "${cleanLabel(g.control ?? "the sheet thickness setting")}" and re-download.`),
|
|
63
|
-
|
|
64
|
-
?
|
|
65
|
-
:
|
|
66
|
-
|
|
65
|
+
router
|
|
66
|
+
? "Kerf: none — a CNC router's CAM applies the tool offset."
|
|
67
|
+
: kerfed
|
|
68
|
+
? `Kerf: ${k.toFixed(2)} mm applied to the cut lines only. Set kerf in ONE place — these download options, or your laser software / cutting service — never both.`
|
|
69
|
+
: "Kerf: none applied — your laser software or cutting service must compensate.",
|
|
70
|
+
...(kerfed || router ? [] : ["To have the files adjusted instead, download again with a kerf set, and turn compensation off in your laser software or service."]),
|
|
67
71
|
"Scale check — measure one cut piece against its line:",
|
|
68
72
|
...m.scale.map((s) => `${cleanLabel(s.label)}: nominal ${s.nominal[0].toFixed(2)} × ${s.nominal[1].toFixed(2)} mm; this file draws ${
|
|
69
73
|
kerfed ? `${s.drawn[0].toFixed(2)} × ${s.drawn[1].toFixed(2)} with ${k.toFixed(2)} mm kerf` : "the same (no kerf applied)"}`),
|
|
70
|
-
m.cutFormat === "dxf" ? DXF_LEGEND : SVG_LEGEND,
|
|
74
|
+
router ? ROUTER_LEGEND : m.cutFormat === "dxf" ? DXF_LEGEND : SVG_LEGEND,
|
|
71
75
|
...(marksFiles.length ? [`Score and engrave marks are in their own files, not in the cut files — send them with the cut files if you want the marks made: ${marksFiles.join(", ")}.`] : []),
|
|
72
76
|
// A service kit whose every piece is oversized writes no sheets/ at all.
|
|
73
77
|
...(m.destination === "service" && m.sheets.length ? ["The sheets in sheets/ are for reference only — a cutting service lays out the pieces itself."] : []),
|
|
@@ -78,12 +82,25 @@ export function renderReadme(m) {
|
|
|
78
82
|
String(s.pieces), `${Math.round(s.used)}%`])),
|
|
79
83
|
PIECES: table(["file", "labels", "material", "thickness", "size", "quantity"], m.pieces.map((pc) => [
|
|
80
84
|
pc.file, labelsOf(pc.labels), cleanLabel(pc.material), `${pc.thickness.toFixed(2)} mm`, mm2(pc.size), String(pc.quantity)])),
|
|
85
|
+
"ROUTER OPERATIONS": [...(m.router ?? []).flatMap((r) => [
|
|
86
|
+
`${labelsOf(r.labels)} (${cleanLabel(r.material)} ${r.thickness.toFixed(2)} mm) — depths from the machined face:`,
|
|
87
|
+
...table(["op", "label", "size", "depth", "method"], [
|
|
88
|
+
...r.pockets.map((pk) => ["pocket", pk.label ? cleanLabel(pk.label) : "—", mm2([pk.w, pk.h]), `${pk.depth.toFixed(2)} mm`, "mill"]),
|
|
89
|
+
...r.drills.map((dr) => ["drill", dr.label ? cleanLabel(dr.label) : "—", `Ø${fmtMm(dr.d)} mm × ${dr.count}`, dr.through ? "through" : `${dr.depth.toFixed(2)} mm`, "drill"]),
|
|
90
|
+
...r.extra.map((x) => ["extra", cleanLabel(x.label), mm2([x.w, x.h]), `to ${x.maxDepth.toFixed(2)} mm`, cleanLabel(x.method)]),
|
|
91
|
+
]),
|
|
92
|
+
]),
|
|
93
|
+
// The layer legend: one colour per layer name across the whole kit.
|
|
94
|
+
...((m.router ?? []).length && (m.layers ?? []).length
|
|
95
|
+
? ["LAYERS (one colour per layer across the kit):", ...table(["layer", "colour", "depth"], m.layers.map((l) => [l.name, l.colour, l.depth]))]
|
|
96
|
+
: [])],
|
|
81
97
|
"PRINTED PARTS": table(["file", "labels", "quantity"], m.printed.map((pp) => [pp.file, labelsOf(pp.labels), String(pp.quantity)])),
|
|
82
98
|
"CLEARANCES (design fit, not kerf)": m.clearances.map((c) =>
|
|
83
99
|
`${cleanLabel(c.label)}: ${settingValue(c.value)}${c.unit ? ` ${cleanLabel(c.unit)}` : ""}`),
|
|
84
100
|
SETTINGS: m.settings.map((s) => `${cleanLabel(s.key)} = ${settingValue(s.value)}`),
|
|
85
101
|
CHECKS: m.checks.length ? m.checks.map(cleanLabel) : ["none"],
|
|
86
|
-
|
|
102
|
+
// Only a non-strict layout (a service, a CNC router) can leave a piece off the sheets.
|
|
103
|
+
"OVERSIZED PIECES": m.destination === "service" || router ? m.oversized.map(cleanLabel) : [],
|
|
87
104
|
"RENAMED FILES": m.renamed.map(cleanLabel),
|
|
88
105
|
LIMITS: [LIMITS_LINE],
|
|
89
106
|
};
|