partforge 0.156.0 → 0.157.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 +41 -16
- package/package.json +1 -1
- package/src/framework/export/bundle.js +47 -26
- package/src/framework/export/drawing.js +35 -2
- package/src/framework/export/formats.js +10 -2
- package/src/framework/export/readme.js +20 -7
- package/src/framework/process/cnc-router/descriptor.js +41 -19
- package/src/framework/process/cnc-router/export.js +54 -31
- package/src/framework/sheet/routed-resolve.js +16 -4
- package/types/geometry.d.ts +6 -4
- package/types/index.d.ts +6 -0
- package/types/part.d.ts +2 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -2037,7 +2037,7 @@ the assembled view, and `minWall` and the overhang check skip sheet parts.
|
|
|
2037
2037
|
|
|
2038
2038
|
### The kit
|
|
2039
2039
|
|
|
2040
|
-
|
|
2040
|
+
Sheet parts download as one ZIP, the **cut & print kit** (`format: "bundle"`): `README.txt`, `parts.csv`, laid-out `sheets/`, a `parts/` file per distinct piece (plus `-marks.dxf` for a cutting service), `print/` for printed parts. Identical pieces share one `-xN` file named from `export: { name }`, else the key. Destination (`"own-laser"`, `"service"`, `"cnc-router"`), kerf, stock size and a two-sided routed piece's flip and file count are chosen at download: draw nominal, bind `thickness` to the measured control. A choice it cannot honour fails with `cut kit options:`.
|
|
2041
2041
|
|
|
2042
2042
|
### Limits
|
|
2043
2043
|
|
|
@@ -2179,8 +2179,8 @@ renders and STEP all come from the one declaration — so model it once.
|
|
|
2179
2179
|
|
|
2180
2180
|
### When a sub-part is a routed part
|
|
2181
2181
|
|
|
2182
|
-
When it is machined from one
|
|
2183
|
-
holes, all straight down. A sculpted surface
|
|
2182
|
+
When it is machined from one or both faces of a flat board: an outline, pockets with
|
|
2183
|
+
flat floors, holes, all straight down from whichever face. A sculpted surface or
|
|
2184
2184
|
anything that is not flat stock is an ordinary `build` (export it as STEP). A laser-cut
|
|
2185
2185
|
plate is `sheetPart`.
|
|
2186
2186
|
|
|
@@ -2197,8 +2197,12 @@ plinth: routedPart({
|
|
|
2197
2197
|
pockets: (k, p, d) => [
|
|
2198
2198
|
{ region: k.shape2d(roundedRectProfile(d.vaultW, d.vaultL, 2)).translate([0, -9]), depth: 11.43, label: "peg vault" },
|
|
2199
2199
|
{ region: k.shape2d(roundedRectProfile(d.pocketW, d.pocketL, 4)).translate([-58, -10]), depth: p.inlayThickness, label: "inlay pocket" },
|
|
2200
|
+
{ region: k.shape2d(roundedRectProfile(40, 20, 3)).translate([60, 0]), depth: 4, face: "far", label: "felt recess" },
|
|
2201
|
+
],
|
|
2202
|
+
drills: (p, d) => [
|
|
2203
|
+
...d.pegHoles.map(([x, y]) => ({ at: [x, y], d: p.pegHoleDia, depth: 10 })),
|
|
2204
|
+
{ at: [-60, 30], d: 8, depth: 6, face: "far", label: "foot socket" }, // from the underside
|
|
2200
2205
|
],
|
|
2201
|
-
drills: (p, d) => d.pegHoles.map(([x, y]) => ({ at: [x, y], d: p.pegHoleDia, depth: 10 })),
|
|
2202
2206
|
extra: (k, p, d) => [{
|
|
2203
2207
|
solid: k.box({ size: [d.rackL, 12, 14] }).rotateX(-p.rackTilt).at([0, d.rackY, p.baseThickness - 9.5]),
|
|
2204
2208
|
footprint: k.shape2d(roundedRectProfile(d.rackL, 12, 2)).translate([0, d.rackY]),
|
|
@@ -2213,21 +2217,22 @@ plinth: routedPart({
|
|
|
2213
2217
|
`exportable` pass through. A field needing the kernel is `(k, p, d) => …`; `drills` is
|
|
2214
2218
|
`(p, d) => …`. `build` is supplied — passing one throws, as do `kerf`, `score`,
|
|
2215
2219
|
`engrave` and `process`; each error names the fix. The drawing is in XY with the
|
|
2216
|
-
|
|
2220
|
+
near face at z = `thickness` and the far face at z = 0; `pose` places it exactly as a
|
|
2221
|
+
sheet part's does.
|
|
2217
2222
|
|
|
2218
2223
|
### Pockets, drills, extra ops and reference lines
|
|
2219
2224
|
|
|
2220
|
-
- **`pockets`** — `[{ region, depth, label? }]`: a Shape2D or profile machined
|
|
2221
|
-
mm
|
|
2225
|
+
- **`pockets`** — `[{ region, depth, label?, face? }]`: a Shape2D or profile machined
|
|
2226
|
+
`depth` mm into the stock from its face, flat-floored. `depth` may equal `thickness` (a
|
|
2222
2227
|
through pocket); deeper is a build error naming the pocket. Pockets whose depths agree to
|
|
2223
2228
|
two decimals share a DXF layer.
|
|
2224
|
-
- **`drills`** — `[{ at: [x, y], d, depth, label? }]`: a round hole of diameter `d`.
|
|
2229
|
+
- **`drills`** — `[{ at: [x, y], d, depth, label?, face? }]`: a round hole of diameter `d`.
|
|
2225
2230
|
Set `depth` to the thickness to drill through — deeper is a `routed-drill-depth`
|
|
2226
2231
|
lint error. Written as a DXF `CIRCLE`, which is what lets
|
|
2227
2232
|
the CAM offer a drilling toolpath instead of a profile cut.
|
|
2228
|
-
- **`extra`** — `[{ solid, footprint, label, method }]`: an operation the router
|
|
2229
|
-
do straight down — a tilted slot, a scooped thumb hold, an undercut. `solid` is
|
|
2230
|
-
the preview and the STEP; `footprint` (its outline on
|
|
2233
|
+
- **`extra`** — `[{ solid, footprint, label, method, face? }]`: an operation the router
|
|
2234
|
+
cannot do straight down — a tilted slot, a scooped thumb hold, an undercut. `solid` is
|
|
2235
|
+
cut in the preview and the STEP; `footprint` (its outline on its face) is drawn on
|
|
2231
2236
|
the `SECONDARY` layer; `label` and `method` are REQUIRED and go in the README's
|
|
2232
2237
|
operations table so the operator knows what to make and how ("core box bit, 20 mm
|
|
2233
2238
|
radius"). Do not put a flat-floored pocket here to avoid stating its depth.
|
|
@@ -2248,10 +2253,27 @@ and depth (colours 3, 4, 2, 140, 170); `SECONDARY` (6) the extra ops' footprints
|
|
|
2248
2253
|
`REFERENCE` (8) layout lines. Depth rides both the layer name and the colour, and colours
|
|
2249
2254
|
are assigned per kit — pockets shallowest depth first, drills in the order they appear —
|
|
2250
2255
|
so one layer name has one colour in every file. A palette cycles past five depths; the
|
|
2251
|
-
README's LAYERS legend (layer, colour, depth) is the authority. Kerf
|
|
2256
|
+
README's LAYERS legend (layer, face, colour, depth) is the authority. Kerf
|
|
2252
2257
|
never applies — the CAM applies the tool offset, and the kit refuses a kerf for this
|
|
2253
2258
|
destination. The README's ROUTER OPERATIONS table lists every pocket, drill group and
|
|
2254
|
-
extra op with its depth and method.
|
|
2259
|
+
extra op with its face, depth and method.
|
|
2260
|
+
|
|
2261
|
+
### Both faces
|
|
2262
|
+
|
|
2263
|
+
Every pocket, drill and extra op takes `face`: `"near"` (the default, machined down
|
|
2264
|
+
from z = `thickness`) or `"far"` (machined up from z = 0 — the board turned over); any
|
|
2265
|
+
other value is a build error. `depth` is measured from the op's own face. Model the part
|
|
2266
|
+
in place, as assembled; the kit does the flipping. Far ops go on their own layers —
|
|
2267
|
+
`FAR_POCKET_<depth>MM`, `FAR_DRILL_D<dia>_…`, `FAR_SECONDARY` — mirrored about the
|
|
2268
|
+
piece's centre so they read as the router sees the far face. Two kit options apply to a
|
|
2269
|
+
piece with far work: `flip` — `"left-right"` (the default, VCarve's Flip Direction =
|
|
2270
|
+
Horizontal) or `"end-over-end"` (Vertical) — sets the mirror axis; `twoSided` —
|
|
2271
|
+
`"one-file"` (the default: both faces' layers in the piece's one DXF) or `"two-files"`
|
|
2272
|
+
(the far layers in `parts/<name>-far.dxf`, behind the mirrored outline on `REFERENCE`
|
|
2273
|
+
for alignment). In the CAM, line the far vectors' outline up on the piece's outline as
|
|
2274
|
+
seen from the bottom; the README says so and orders the work far side first, through
|
|
2275
|
+
cut last. `routedBreakthrough` counts a near and a far pocket that overlap and together
|
|
2276
|
+
go deeper than the stock.
|
|
2255
2277
|
|
|
2256
2278
|
### What lint and verify check
|
|
2257
2279
|
|
|
@@ -2259,14 +2281,17 @@ Lint: `routed-drill-depth` (error) — a drill deeper than the stock at the defa
|
|
|
2259
2281
|
Every sheet rule applies too (`sheet-thickness-literal`, `sheet-custom-build`, …); the
|
|
2260
2282
|
laser's thickness range does not. A pocket deeper than the stock is a build error.
|
|
2261
2283
|
|
|
2262
|
-
Verify volunteers
|
|
2284
|
+
Verify volunteers four warnings on every routed part, none counting toward
|
|
2263
2285
|
`declared`/`evaluated`: `routedOutside` (pocket, drill or footprint regions outside the
|
|
2264
|
-
profile, `0`), `routedEmpty` (pockets that resolved empty, `0`), `routedPieces` (`1`)
|
|
2286
|
+
profile, `0`), `routedEmpty` (pockets that resolved empty, `0`), `routedPieces` (`1`),
|
|
2287
|
+
`routedBreakthrough` (a near-face and a far-face pocket that overlap and together go
|
|
2288
|
+
deeper than the stock, `0`).
|
|
2265
2289
|
Each measured row carries `sheet.routed` — the operations table — beside the stock facts.
|
|
2266
2290
|
|
|
2267
2291
|
### Limits
|
|
2268
2292
|
|
|
2269
|
-
-
|
|
2293
|
+
- Far-side `reference` lines and far-side work in the sheets/ layouts are not drawn;
|
|
2294
|
+
two-sided jobs are per-piece files.
|
|
2270
2295
|
- No tool-radius, dogbone or inside-corner checks: the CAM owns those.
|
|
2271
2296
|
- No depth per extra op beyond its footprint and the solid's deepest point; the
|
|
2272
2297
|
`method` text is where the operator's instructions go.
|
package/package.json
CHANGED
|
@@ -19,15 +19,17 @@ 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, routedPalette } from "./drawing.js";
|
|
22
|
+
import { canonicalDrawingKey, routedPalette, splitFaces } 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";
|
|
26
26
|
import { renderReadme, renderPartsCsv, cleanLabel } from "./readme.js";
|
|
27
27
|
|
|
28
28
|
// Every entry name the kit may write (spec D.10). test/kit-bundle.test.js pins every
|
|
29
|
-
// kit it builds against it, and buildBundle asserts it again before zipping.
|
|
30
|
-
|
|
29
|
+
// kit it builds against it, and buildBundle asserts it again before zipping. A piece's
|
|
30
|
+
// files are `<name>[-x<n>]` plus `-far` (a two-sided routed piece's far-face file, in
|
|
31
|
+
// the two-files mode) or `-marks` (a laser piece's score and engrave lines).
|
|
32
|
+
export const KIT_ENTRY_RE = /^(README\.txt|parts\.csv|sheets\/[a-z0-9][a-z0-9._-]*\/sheet-\d+-of-\d+\.(svg|dxf)|parts\/[a-z0-9][a-z0-9._-]*(-x\d+)?(-far)?(-marks)?\.(svg|dxf)|print\/[a-z0-9][a-z0-9._-]*(-x\d+)?\.(stl|3mf))$/;
|
|
31
33
|
|
|
32
34
|
// A path segment from an author string. safeName already lowercases, collapses runs and
|
|
33
35
|
// refuses a leading dot or dash; folding every run of dots on top means no entry name
|
|
@@ -192,27 +194,32 @@ function sheetMetaOrThrow(kernel, sp, p, d, name) {
|
|
|
192
194
|
throw new Error(`sheet part "${name}": its material or thickness could not be evaluated`);
|
|
193
195
|
}
|
|
194
196
|
|
|
195
|
-
// The README's layer legend: every routed layer name in the kit, once, with its
|
|
196
|
-
// (ACI in a DXF kit, hex in an SVG one) and depth — REFERENCE, SECONDARY, then
|
|
197
|
-
// palette order, then pockets shallowest first
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
197
|
+
// The README's layer legend: every routed layer name in the kit, once, with its face,
|
|
198
|
+
// colour (ACI in a DXF kit, hex in an SVG one) and depth — REFERENCE, SECONDARY, then
|
|
199
|
+
// drills in palette order, then pockets shallowest first; the far face's layers follow
|
|
200
|
+
// in the same order. The palette is kit-wide, so a name has one colour; the legend is
|
|
201
|
+
// the authority when a palette cycles. It reads each piece's whole drawing, so the
|
|
202
|
+
// far file's alignment outline (splitFaces' `far-outline`) is never listed.
|
|
203
|
+
const LEGEND_RANK = { reference: 0, secondary: 1, drill: 2, pocket: 3, "far-secondary": 4, "far-drill": 5, "far-pocket": 6 };
|
|
204
|
+
const legendKind = (l) => {
|
|
205
|
+
const m = /^(far-)?(drill|pocket)-/.exec(l.id);
|
|
206
|
+
return m ? `${m[1] ?? ""}${m[2]}` : l.id;
|
|
207
|
+
};
|
|
201
208
|
function routedLegend(pieces, cutExt) {
|
|
202
209
|
const rows = new Map();
|
|
203
210
|
for (const pc of pieces) {
|
|
204
211
|
for (const l of pc.drawing.layers) {
|
|
205
212
|
if (l.name === undefined || rows.has(l.name)) continue;
|
|
206
213
|
const kind = legendKind(l);
|
|
207
|
-
rows.set(l.name, { name: l.name, kind, order: rows.size,
|
|
214
|
+
rows.set(l.name, { name: l.name, kind, face: l.face === "far" ? "far" : "near", order: rows.size,
|
|
208
215
|
colour: cutExt === "svg" ? l.svgColor : String(l.color),
|
|
209
216
|
depth: l.through ? "through" : typeof l.depth === "number" ? `${l.depth.toFixed(2)} mm` : "—", mm: l.depth ?? 0 });
|
|
210
217
|
}
|
|
211
218
|
}
|
|
212
219
|
return [...rows.values()]
|
|
213
220
|
.sort((a, b) => (LEGEND_RANK[a.kind] ?? 9) - (LEGEND_RANK[b.kind] ?? 9)
|
|
214
|
-
|| (a.kind
|
|
215
|
-
.map(({ name, colour, depth }) => ({ name, colour, depth }));
|
|
221
|
+
|| (a.kind.endsWith("pocket") ? a.mm - b.mm : 0) || a.order - b.order)
|
|
222
|
+
.map(({ name, face, colour, depth }) => ({ name, face, colour, depth }));
|
|
216
223
|
}
|
|
217
224
|
|
|
218
225
|
// One export-bundle job's kit. `names` is the job's selected() — exportable, enabled, in
|
|
@@ -292,11 +299,14 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
292
299
|
// and the op labels, are not in its drawing — only the README carries them, so two
|
|
293
300
|
// pieces that draw alike but machine differently must stay two files.
|
|
294
301
|
const sameOps = (a, b) => JSON.stringify(a.facts?.routed ?? null) === JSON.stringify(b.facts?.routed ?? null);
|
|
302
|
+
// A routed piece with far-face work is two-sided: its sheets carry the near face only
|
|
303
|
+
// (`faces.near`), and the two-files mode writes its far face to a file of its own.
|
|
295
304
|
const pieces = dedupe(drawn, (x) => h(x.key), (a, b) => a.key === b.key && a.group === b.group && sameOps(a, b))
|
|
296
|
-
.map(({ item, members }) =>
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
305
|
+
.map(({ item, members }) => {
|
|
306
|
+
const faces = splitFaces(item.drawing);
|
|
307
|
+
return { ...item, labels: members.map((m) => m.label), qty: members.length * o.sets, faces, twoSided: faces.far !== null,
|
|
308
|
+
hasMarks: item.drawing.layers.some((l) => l.id === "score" || l.id === "engrave") };
|
|
309
|
+
});
|
|
300
310
|
|
|
301
311
|
// Names, in definition order. A name that was taken moves to name-2; the README's
|
|
302
312
|
// RENAMED FILES says which file it was and who held the name (spec D.10). A piece
|
|
@@ -321,10 +331,12 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
321
331
|
const sx = pc.qty > 1 ? `-x${pc.qty}` : "";
|
|
322
332
|
// A routed piece is never split: its depths are one layered file for the CAM.
|
|
323
333
|
const marks = (service || router) && pc.hasMarks && !pc.routed;
|
|
324
|
-
const
|
|
325
|
-
|
|
334
|
+
const far = pc.twoSided && o.twoSided === "two-files";
|
|
335
|
+
const name = claim(kitName(pc.fileKey), (n) => [`parts/${n}${sx}.${cutExt}`, ...(marks ? [`parts/${n}${sx}-marks.dxf`] : []),
|
|
336
|
+
...(far ? [`parts/${n}${sx}-far.${cutExt}`] : []), `parts/${n}/`], pc.labels);
|
|
326
337
|
pc.file = `parts/${name}${sx}.${cutExt}`;
|
|
327
338
|
pc.marksFile = marks ? `parts/${name}${sx}-marks.dxf` : null;
|
|
339
|
+
pc.farFile = far ? `parts/${name}${sx}-far.${cutExt}` : null;
|
|
328
340
|
}
|
|
329
341
|
|
|
330
342
|
// Lay out each stock group. An own laser needs every piece to fit (layoutGroup
|
|
@@ -339,7 +351,7 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
339
351
|
const stock = stockFor.get(g.group);
|
|
340
352
|
const laid = layoutGroup({
|
|
341
353
|
group: g.group, label: g.label, stock, margin: o.margin, spacing: o.spacing, strict: !(service || router),
|
|
342
|
-
pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.
|
|
354
|
+
pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.faces.near, qty: pc.qty })),
|
|
343
355
|
});
|
|
344
356
|
if (laid.sheets.length > KIT_LIMITS.sheetsPerGroup) {
|
|
345
357
|
throw new Error(`${KIT_OPTIONS_ERROR} ${g.material} ${fmtMm(g.thickness)} mm needs ${laid.sheets.length} sheets — at most ${KIT_LIMITS.sheetsPerGroup} per material; use larger stock or fewer sets`);
|
|
@@ -394,7 +406,8 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
394
406
|
// The files (spec D.8): own laser — an SVG per sheet and per piece; service and CNC
|
|
395
407
|
// router — a cut-only DXF per laser piece, a -marks.dxf where it has marks, and
|
|
396
408
|
// reference DXF sheets. A routed piece is one file with every layer, whatever the
|
|
397
|
-
// destination
|
|
409
|
+
// destination — or, two-sided in the two-files mode, its near face and its far face
|
|
410
|
+
// (behind a REFERENCE outline) as two.
|
|
398
411
|
say("writing cut files");
|
|
399
412
|
const title = cleanLabel(part.meta?.title ?? fileBase);
|
|
400
413
|
const files = [];
|
|
@@ -404,8 +417,11 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
404
417
|
put(sh.file, cutExt === "dxf" ? renderDxf(doc) : renderSvg(doc));
|
|
405
418
|
}
|
|
406
419
|
for (const pc of pieces) {
|
|
407
|
-
const
|
|
408
|
-
placements: [{ drawing
|
|
420
|
+
const docOf = (drawing) => ({ title: pc.labels.join(", "), size: sizeOf(drawing.bounds),
|
|
421
|
+
placements: [{ drawing, at: [0, 0], rotated: false }] });
|
|
422
|
+
const render = cutExt === "svg" ? renderSvg : renderDxf;
|
|
423
|
+
if (pc.farFile) { put(pc.file, render(docOf(pc.faces.near))); put(pc.farFile, render(docOf(pc.faces.far))); continue; }
|
|
424
|
+
const doc = docOf(pc.drawing);
|
|
409
425
|
if (cutExt === "svg") { put(pc.file, renderSvg(doc)); continue; }
|
|
410
426
|
if (pc.routed) { put(pc.file, renderDxf(doc)); continue; } // every layer, one file
|
|
411
427
|
put(pc.file, renderDxf(doc, { include: ["cut"] }));
|
|
@@ -416,15 +432,17 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
416
432
|
const controls = collectControls(part.parameters);
|
|
417
433
|
const readme = renderReadme({
|
|
418
434
|
title, destination: o.destination, cutFormat: o.cutFormat, kerf: o.kerf,
|
|
435
|
+
flip: o.flip, twoSided: o.twoSided, anyTwoSided: pieces.some((pc) => pc.twoSided),
|
|
419
436
|
stockNotes: [...groups.values()].map((g) => ({ material: g.material, thickness: g.thickness,
|
|
420
437
|
control: thicknessControlLabel(controls, part.parts[g.first], p, d) })),
|
|
421
438
|
scale: pieces.map((pc) => ({ label: pc.labels.join(", "), nominal: pc.drawing.nominal, drawn: sizeOf(pc.drawing.bounds) })),
|
|
422
439
|
sheets: sheets.map((sh) => ({ file: sh.file, material: sh.group.material, thickness: sh.group.thickness,
|
|
423
440
|
size: sh.stock, pieces: sh.placements.length, used: sh.used })),
|
|
424
|
-
pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, labels: pc.labels, material: groups.get(pc.group).material,
|
|
441
|
+
pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, farFile: pc.farFile, labels: pc.labels, material: groups.get(pc.group).material,
|
|
425
442
|
thickness: groups.get(pc.group).thickness, size: pc.drawing.nominal, quantity: pc.qty })),
|
|
426
443
|
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
|
|
444
|
+
labels: pc.labels, material: groups.get(pc.group).material, thickness: groups.get(pc.group).thickness, ...pc.facts.routed,
|
|
445
|
+
twoSided: pc.twoSided, farFile: pc.farFile })),
|
|
428
446
|
layers: routedLegend(pieces.filter((pc) => pc.routed), cutExt),
|
|
429
447
|
printed: prints.map((pp) => ({ file: pp.file, labels: pp.labels, quantity: pp.qty })),
|
|
430
448
|
clearances: controls.filter(isClearance)
|
|
@@ -434,8 +452,11 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
|
|
|
434
452
|
oversized, renamed,
|
|
435
453
|
});
|
|
436
454
|
const csv = renderPartsCsv([
|
|
437
|
-
...pieces.
|
|
438
|
-
|
|
455
|
+
...pieces.flatMap((pc) => {
|
|
456
|
+
const row = { file: pc.file, kind: "sheet", labels: pc.labels, material: groups.get(pc.group).material,
|
|
457
|
+
thickness: groups.get(pc.group).thickness, width: pc.drawing.nominal[0], height: pc.drawing.nominal[1], quantity: pc.qty };
|
|
458
|
+
return pc.farFile ? [row, { ...row, file: pc.farFile, kind: "far" }] : [row];
|
|
459
|
+
}),
|
|
439
460
|
...prints.map((pp) => ({ file: pp.file, kind: "print", labels: pp.labels, material: pp.material, thickness: null,
|
|
440
461
|
width: pp.size[0], height: pp.size[1], quantity: pp.qty })),
|
|
441
462
|
]);
|
|
@@ -359,11 +359,44 @@ export function applyKerf(regions, kerf, { label = "sheet part", gap = null } =
|
|
|
359
359
|
// Depth in millimetres as a layer-name token: up to two decimals, trailing zeros
|
|
360
360
|
// dropped (11.43 → "11.43", 3 → "3"). Two depths that round alike share a layer.
|
|
361
361
|
export const depthToken = (mm) => String(Number(mm.toFixed(2)));
|
|
362
|
+
// A far-face op's layer carries a FAR_ prefix; near names are unchanged.
|
|
362
363
|
export function routedLayerName(kind, op) {
|
|
363
|
-
|
|
364
|
-
if (kind === "
|
|
364
|
+
const far = op.face === "far" ? "FAR_" : "";
|
|
365
|
+
if (kind === "pocket") return `${far}POCKET_${depthToken(op.depth)}MM`;
|
|
366
|
+
if (kind === "drill") return far + (op.through ? `DRILL_D${depthToken(op.d)}_THRU` : `DRILL_D${depthToken(op.d)}_${depthToken(op.depth)}MM`);
|
|
365
367
|
throw new Error(`routedLayerName: unknown kind ${JSON.stringify(kind)}`);
|
|
366
368
|
}
|
|
369
|
+
// How a piece is turned over to cut its far face, as the mirror axis of its drawing.
|
|
370
|
+
export const FLIP_AXIS = Object.freeze({ "left-right": "x", "end-over-end": "y" });
|
|
371
|
+
// A ring mirrored about the line `axis = c` ("x": x -> 2c - x; "y": y -> 2c - y), every
|
|
372
|
+
// point mapped (start, to, via, c1, c2), then walked backwards so a CCW ring stays CCW:
|
|
373
|
+
// an arc keeps its mirrored via, a cubic swaps its mirrored c1/c2. Other fields (closed)
|
|
374
|
+
// carry over. Returns a new ring; the input is not touched.
|
|
375
|
+
export function mirrorRing(ring, axis, c) {
|
|
376
|
+
const m = axis === "x" ? ([x, y]) => [2 * c - x, y] : ([x, y]) => [x, 2 * c - y];
|
|
377
|
+
const pts = [ring.start, ...ring.segments.map((s) => s.to)].map(m);
|
|
378
|
+
const segments = [];
|
|
379
|
+
for (let i = ring.segments.length - 1; i >= 0; i--) {
|
|
380
|
+
const s = ring.segments[i], to = pts[i];
|
|
381
|
+
if (s.via) segments.push({ to, via: m(s.via) });
|
|
382
|
+
else if (s.c1) segments.push({ to, c1: m(s.c2), c2: m(s.c1) });
|
|
383
|
+
else segments.push({ to });
|
|
384
|
+
}
|
|
385
|
+
return { ...ring, start: pts[pts.length - 1], segments };
|
|
386
|
+
}
|
|
387
|
+
export const mirrorRings = (rings, axis, c) => rings.map((r) => mirrorRing(r, axis, c));
|
|
388
|
+
// A two-sided piece's drawing split by face. `near` keeps every layer not marked
|
|
389
|
+
// face "far" (the input itself for a one-sided piece); `far` is null for a one-sided
|
|
390
|
+
// piece, else the far layers behind a REFERENCE layer of `drawing.farOutline` (the
|
|
391
|
+
// mirrored outer rings the exporter sets), both with the input's bounds/nominal/kerf.
|
|
392
|
+
export function splitFaces(drawing) {
|
|
393
|
+
const farLayers = drawing.layers.filter((l) => l.face === "far");
|
|
394
|
+
if (farLayers.length === 0) return { near: drawing, far: null };
|
|
395
|
+
const { bounds, nominal, kerf } = drawing;
|
|
396
|
+
const near = { layers: drawing.layers.filter((l) => l.face !== "far"), bounds, nominal, kerf };
|
|
397
|
+
const outline = { id: "far-outline", name: "REFERENCE", ...ROUTED_COLORS.reference, face: "far", paths: drawing.farOutline };
|
|
398
|
+
return { near, far: { layers: [outline, ...farLayers], bounds, nominal, kerf } };
|
|
399
|
+
}
|
|
367
400
|
// A drill's tool setting as a key: diameter and depth (or THRU), two-decimal tokens —
|
|
368
401
|
// one layer per key, and the kit-wide palette's drill key.
|
|
369
402
|
export const drillKey = (dr) => `D${depthToken(dr.d)}-${dr.through ? "THRU" : depthToken(dr.depth)}`;
|
|
@@ -44,9 +44,10 @@ export const KIT_LIMITS = Object.freeze({
|
|
|
44
44
|
export const KIT_DEFAULTS = Object.freeze({
|
|
45
45
|
destination: "own-laser", kerf: 0, colors: "lightburn", stock: null,
|
|
46
46
|
margin: 5, spacing: 3, printFormat: "stl", sets: 1,
|
|
47
|
+
flip: "left-right", twoSided: "one-file",
|
|
47
48
|
});
|
|
48
49
|
|
|
49
|
-
const OPTION_KEYS = ["destination", "cutFormat", "kerf", "colors", "stock", "margin", "spacing", "printFormat", "sets"];
|
|
50
|
+
const OPTION_KEYS = ["destination", "cutFormat", "kerf", "colors", "stock", "margin", "spacing", "printFormat", "sets", "flip", "twoSided"];
|
|
50
51
|
|
|
51
52
|
const fail = (message) => new Error(`${KIT_OPTIONS_ERROR} ${message}`);
|
|
52
53
|
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
@@ -91,7 +92,14 @@ export function validateKitOptions(o) {
|
|
|
91
92
|
if (!Number.isInteger(sets) || sets < KIT_LIMITS.sets[0] || sets > KIT_LIMITS.sets[1])
|
|
92
93
|
throw fail(`sets must be a whole number from ${KIT_LIMITS.sets[0]} to ${KIT_LIMITS.sets[1]}, got ${JSON.stringify(sets)}`);
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
const flip = pick("flip");
|
|
96
|
+
if (flip !== "left-right" && flip !== "end-over-end")
|
|
97
|
+
throw fail(`flip must be "left-right" or "end-over-end", got ${JSON.stringify(flip)}`);
|
|
98
|
+
const twoSided = pick("twoSided");
|
|
99
|
+
if (twoSided !== "one-file" && twoSided !== "two-files")
|
|
100
|
+
throw fail(`twoSided must be "one-file" or "two-files", got ${JSON.stringify(twoSided)}`);
|
|
101
|
+
|
|
102
|
+
return { destination, cutFormat, kerf, colors, stock, margin, spacing, printFormat, sets, flip, twoSided };
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
// null/undefined → null ("every group gets DEFAULT_STOCK_SIZE"); otherwise fresh
|
|
@@ -15,7 +15,16 @@ export const CSV_HEADER = "file,kind,labels,material,thickness_mm,width_mm,heigh
|
|
|
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
|
+
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. FAR_* layers are the same operations on the far face, drawn mirrored (see the flip note under each two-sided piece). Laser pieces keep CUT/SCORE/ENGRAVE.";
|
|
19
|
+
// A two-sided routed piece: how its far layers were mirrored, how to line them up, and
|
|
20
|
+
// where its through cut is made.
|
|
21
|
+
const FLIP_NOTE = {
|
|
22
|
+
"left-right": "Far-side layers (FAR_*) are drawn as seen after flipping the board left-right, matching a VCarve two-sided job with Flip Direction = Horizontal.",
|
|
23
|
+
"end-over-end": "Far-side layers (FAR_*) are drawn as seen after flipping the board end over end, matching a VCarve two-sided job with Flip Direction = Vertical.",
|
|
24
|
+
};
|
|
25
|
+
const ALIGN_NOTE = "On the bottom side, move the imported far-side vectors so their outline sits on the piece's outline as the CAM shows it from that side. Shortcut: make the job's material the piece's size, then centre the imported vectors on the material (VCarve: Align Selected Objects → Centre); because the far layers are mirrored about the piece's own centre, they then land exactly.";
|
|
26
|
+
const ONE_FILE_NOTE = "On the bottom side the CUT outline is for alignment only — cut the piece out from the near side.";
|
|
27
|
+
const TWO_SIDED_ORDER = "Two-sided pieces: machine the far side first, flip, then the near side and the through cut last, so the board holds the piece until the end. Sheet layouts in sheets/ carry near-side work only; far-side work is in the per-piece files.";
|
|
19
28
|
const LIMITS_LINE = "Layout packs bounding boxes only: no nesting into holes, and grain direction is ignored.";
|
|
20
29
|
|
|
21
30
|
// C0 controls, DEL and C1 controls out of an author string, so a label can never ring a
|
|
@@ -48,6 +57,7 @@ function settingValue(v) {
|
|
|
48
57
|
|
|
49
58
|
const table = (header, rows) => (rows.length ? [header.join(" | "), ...rows.map((r) => r.join(" | "))] : []);
|
|
50
59
|
const mm2 = ([w, h]) => `${w.toFixed(2)} × ${h.toFixed(2)} mm`;
|
|
60
|
+
const faceOf = (op) => (op.face === "far" ? "far" : "near");
|
|
51
61
|
const labelsOf = (labels) => labels.map(cleanLabel).join(", ");
|
|
52
62
|
|
|
53
63
|
export function renderReadme(m) {
|
|
@@ -72,6 +82,7 @@ export function renderReadme(m) {
|
|
|
72
82
|
...m.scale.map((s) => `${cleanLabel(s.label)}: nominal ${s.nominal[0].toFixed(2)} × ${s.nominal[1].toFixed(2)} mm; this file draws ${
|
|
73
83
|
kerfed ? `${s.drawn[0].toFixed(2)} × ${s.drawn[1].toFixed(2)} with ${k.toFixed(2)} mm kerf` : "the same (no kerf applied)"}`),
|
|
74
84
|
router ? ROUTER_LEGEND : m.cutFormat === "dxf" ? DXF_LEGEND : SVG_LEGEND,
|
|
85
|
+
...(m.anyTwoSided ? [TWO_SIDED_ORDER] : []),
|
|
75
86
|
...(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(", ")}.`] : []),
|
|
76
87
|
// A service kit whose every piece is oversized writes no sheets/ at all.
|
|
77
88
|
...(m.destination === "service" && m.sheets.length ? ["The sheets in sheets/ are for reference only — a cutting service lays out the pieces itself."] : []),
|
|
@@ -83,16 +94,18 @@ export function renderReadme(m) {
|
|
|
83
94
|
PIECES: table(["file", "labels", "material", "thickness", "size", "quantity"], m.pieces.map((pc) => [
|
|
84
95
|
pc.file, labelsOf(pc.labels), cleanLabel(pc.material), `${pc.thickness.toFixed(2)} mm`, mm2(pc.size), String(pc.quantity)])),
|
|
85
96
|
"ROUTER OPERATIONS": [...(m.router ?? []).flatMap((r) => [
|
|
86
|
-
`${labelsOf(r.labels)} (${cleanLabel(r.material)} ${r.thickness.toFixed(2)} mm) — depths from
|
|
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)]),
|
|
97
|
+
`${labelsOf(r.labels)} (${cleanLabel(r.material)} ${r.thickness.toFixed(2)} mm) — depths from each op's own face:`,
|
|
98
|
+
...table(["op", "face", "label", "size", "depth", "method"], [
|
|
99
|
+
...r.pockets.map((pk) => ["pocket", faceOf(pk), pk.label ? cleanLabel(pk.label) : "—", mm2([pk.w, pk.h]), `${pk.depth.toFixed(2)} mm`, "mill"]),
|
|
100
|
+
...r.drills.map((dr) => ["drill", faceOf(dr), dr.label ? cleanLabel(dr.label) : "—", `Ø${fmtMm(dr.d)} mm × ${dr.count}`, dr.through ? "through" : `${dr.depth.toFixed(2)} mm`, "drill"]),
|
|
101
|
+
...r.extra.map((x) => ["extra", faceOf(x), cleanLabel(x.label), mm2([x.w, x.h]), `to ${x.maxDepth.toFixed(2)} mm`, cleanLabel(x.method)]),
|
|
91
102
|
]),
|
|
103
|
+
...(r.twoSided ? [FLIP_NOTE[m.flip] ?? FLIP_NOTE["left-right"], ALIGN_NOTE,
|
|
104
|
+
r.farFile ? `Far-side work is in ${r.farFile}; its REFERENCE outline is for alignment, not cutting.` : ONE_FILE_NOTE] : []),
|
|
92
105
|
]),
|
|
93
106
|
// The layer legend: one colour per layer name across the whole kit.
|
|
94
107
|
...((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]))]
|
|
108
|
+
? ["LAYERS (one colour per layer across the kit):", ...table(["layer", "face", "colour", "depth"], m.layers.map((l) => [l.name, faceOf(l), l.colour, l.depth]))]
|
|
96
109
|
: [])],
|
|
97
110
|
"PRINTED PARTS": table(["file", "labels", "quantity"], m.printed.map((pp) => [pp.file, labelsOf(pp.labels), String(pp.quantity)])),
|
|
98
111
|
"CLEARANCES (design fit, not kerf)": m.clearances.map((c) =>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// CNC routing — process module #2. Flat stock machined from
|
|
1
|
+
// CNC routing — process module #2. Flat stock machined from either face — the near
|
|
2
|
+
// face (z = t, the default) or the far face (z = 0, per op `face: "far"`): through cuts
|
|
2
3
|
// (profile), flat-floored pockets at a depth, drilled holes at a depth, and `extra`
|
|
3
4
|
// operations the operator does with another tool after the flat milling. The
|
|
4
5
|
// drawing is one layered DXF per piece, depth on the layer name and colour.
|
|
@@ -12,8 +13,9 @@ import {
|
|
|
12
13
|
} from "../../sheet/constants.js";
|
|
13
14
|
import { err } from "../../lint/finding.js";
|
|
14
15
|
|
|
15
|
-
// The preview: the profile extruded to the thickness, each pocket cut `depth`
|
|
16
|
-
//
|
|
16
|
+
// The preview: the profile extruded to the thickness, each pocket cut `depth` into the
|
|
17
|
+
// stock from its face (down from z = t on the near face, up from z = 0 on the far
|
|
18
|
+
// face), each drill as a cylinder from its face to its depth —
|
|
17
19
|
// through holes and through pockets (depth = thickness) clear BOTH faces by MARK_OVERCUT so no cap is coplanar with a face
|
|
18
20
|
// (OCCT's coplanar booleans are fragile) — each extra solid subtracted as given, and
|
|
19
21
|
// the reference grooves cut like a laser score. A tool whose region turned out empty
|
|
@@ -29,10 +31,14 @@ function routedPreview(k, s) {
|
|
|
29
31
|
const tools = [
|
|
30
32
|
...pockets.map((pk) => orEmpty(() => (pk.depth >= t
|
|
31
33
|
? pk.region.extrude({ h: t + 2 * MARK_OVERCUT }).translate([0, 0, -MARK_OVERCUT])
|
|
32
|
-
: pk.
|
|
34
|
+
: pk.face === "far"
|
|
35
|
+
? pk.region.extrude({ h: pk.depth + MARK_OVERCUT }).translate([0, 0, -MARK_OVERCUT])
|
|
36
|
+
: pk.region.extrude({ h: pk.depth + MARK_OVERCUT }).translate([0, 0, t - pk.depth])))),
|
|
33
37
|
...drills.map((dr) => (dr.through
|
|
34
38
|
? k.cylinder({ d: dr.d, h: t + 2 * MARK_OVERCUT }).translate([dr.at[0], dr.at[1], -MARK_OVERCUT])
|
|
35
|
-
:
|
|
39
|
+
: dr.face === "far"
|
|
40
|
+
? k.cylinder({ d: dr.d, h: dr.depth + MARK_OVERCUT }).translate([dr.at[0], dr.at[1], -MARK_OVERCUT])
|
|
41
|
+
: k.cylinder({ d: dr.d, h: dr.depth + MARK_OVERCUT }).translate([dr.at[0], dr.at[1], t - dr.depth]))),
|
|
36
42
|
...extra.map((x) => x.solid),
|
|
37
43
|
...s.grooves.map((g) => orEmpty(() => g.extrude({ h: MARK_DEPTH + MARK_OVERCUT }).translate([0, 0, t - MARK_DEPTH]))),
|
|
38
44
|
].filter(Boolean);
|
|
@@ -74,21 +80,21 @@ function routedFacts(s) {
|
|
|
74
80
|
for (const pk of pockets) {
|
|
75
81
|
if (pk.region.isEmpty()) { empty++; continue; }
|
|
76
82
|
const [w, h] = sizeOf(pk.region);
|
|
77
|
-
pocketRows.push({ label: pk.label, depth: pk.depth, w: round2(w), h: round2(h), area: round2(pk.region.area()) });
|
|
83
|
+
pocketRows.push({ label: pk.label, depth: pk.depth, w: round2(w), h: round2(h), area: round2(pk.region.area()), face: pk.face });
|
|
78
84
|
noteOutside(pk.region);
|
|
79
85
|
}
|
|
80
|
-
// Drills grouped by (d, depth, through): one row per tool setting, labels joined.
|
|
86
|
+
// Drills grouped by (d, depth, through, face): one row per tool setting, labels joined.
|
|
81
87
|
const drillGroups = new Map();
|
|
82
88
|
for (const dr of drills) {
|
|
83
|
-
const key = `${dr.d}|${dr.depth}|${dr.through}`;
|
|
84
|
-
const g = drillGroups.get(key) ?? { labels: [], d: dr.d, depth: dr.depth, through: dr.through, count: 0 };
|
|
89
|
+
const key = `${dr.d}|${dr.depth}|${dr.through}|${dr.face}`;
|
|
90
|
+
const g = drillGroups.get(key) ?? { labels: [], d: dr.d, depth: dr.depth, through: dr.through, face: dr.face, count: 0 };
|
|
85
91
|
g.count++;
|
|
86
92
|
if (dr.label && !g.labels.includes(dr.label)) g.labels.push(dr.label);
|
|
87
93
|
drillGroups.set(key, g);
|
|
88
94
|
noteOutside(dr.shape);
|
|
89
95
|
}
|
|
90
|
-
const drillRows = [...drillGroups.values()].map(({ labels, d, depth, through, count }) =>
|
|
91
|
-
({ label: labels.length ? labels.join(", ") : null, d, depth, through, count }));
|
|
96
|
+
const drillRows = [...drillGroups.values()].map(({ labels, d, depth, through, count, face }) =>
|
|
97
|
+
({ label: labels.length ? labels.join(", ") : null, d, depth, through, count, face }));
|
|
92
98
|
// An extra op whose footprint resolved to nothing draws nothing: counted under
|
|
93
99
|
// `empty` with the empty pockets, never a row (its size has no bounding box).
|
|
94
100
|
const extraRows = [];
|
|
@@ -96,12 +102,25 @@ function routedFacts(s) {
|
|
|
96
102
|
if (x.footprint.isEmpty()) { empty++; continue; }
|
|
97
103
|
const [w, h] = sizeOf(x.footprint);
|
|
98
104
|
noteOutside(x.footprint);
|
|
99
|
-
// How deep the op reaches
|
|
100
|
-
//
|
|
105
|
+
// How deep the op reaches into the stock from its face — below z = t on the near
|
|
106
|
+
// face, above z = 0 on the far face — within the stock. An empty solid has no
|
|
107
|
+
// bounding box and reaches nothing.
|
|
101
108
|
const bb = x.solid.boundingBox();
|
|
102
|
-
const
|
|
103
|
-
const reach = Number.isFinite(
|
|
104
|
-
extraRows.push({ label: x.label, method: x.method, w: round2(w), h: round2(h), maxDepth: round2(Math.max(0, Math.min(t, reach))) });
|
|
109
|
+
const bound = x.face === "far" ? bb?.max?.[2] : bb?.min?.[2];
|
|
110
|
+
const reach = Number.isFinite(bound) ? (x.face === "far" ? bound : t - bound) : 0;
|
|
111
|
+
extraRows.push({ label: x.label, method: x.method, w: round2(w), h: round2(h), maxDepth: round2(Math.max(0, Math.min(t, reach))), face: x.face });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Breakthrough: a near pocket and a far pocket whose regions overlap and whose depths
|
|
115
|
+
// together exceed the stock — the router breaks through between them. Depths adding
|
|
116
|
+
// up to exactly the thickness are a deliberate opening and never counted.
|
|
117
|
+
const live = pockets.filter((pk) => !pk.region.isEmpty());
|
|
118
|
+
const near = live.filter((pk) => pk.face !== "far"), far = live.filter((pk) => pk.face === "far");
|
|
119
|
+
let breakthrough = 0;
|
|
120
|
+
for (const a of near) {
|
|
121
|
+
for (const b of far) {
|
|
122
|
+
if (a.depth + b.depth > t + 1e-6 && a.region.intersect(b.region).area() > LOSS_TOL_MM2) breakthrough++;
|
|
123
|
+
}
|
|
105
124
|
}
|
|
106
125
|
|
|
107
126
|
return {
|
|
@@ -112,7 +131,7 @@ function routedFacts(s) {
|
|
|
112
131
|
at: { bridge: null, gap: null, marks: null, outside: null },
|
|
113
132
|
readErrors: { bridge: null, gap: null, marks: null, marksArea: null },
|
|
114
133
|
evaluated: true,
|
|
115
|
-
routed: { profile: { w: round2(flat[0]), h: round2(flat[1]) }, pockets: pocketRows, drills: drillRows, extra: extraRows, outside, empty },
|
|
134
|
+
routed: { profile: { w: round2(flat[0]), h: round2(flat[1]) }, pockets: pocketRows, drills: drillRows, extra: extraRows, outside, empty, breakthrough },
|
|
116
135
|
};
|
|
117
136
|
}
|
|
118
137
|
|
|
@@ -130,10 +149,13 @@ const METRICS = {
|
|
|
130
149
|
routedPieces: { kind: "warn", doc: ROUTED_DOC_ID,
|
|
131
150
|
extract: (s) => s.sheet?.pieces ?? null,
|
|
132
151
|
hint: "the profile is not exactly one piece — a routed part must cut out as one region; join the pieces or split them into separate routedPart sub-parts" },
|
|
152
|
+
routedBreakthrough: { kind: "warn", doc: ROUTED_DOC_ID,
|
|
153
|
+
extract: (s) => s.sheet?.routed?.breakthrough ?? null,
|
|
154
|
+
hint: "a pocket on the near face and one on the far face overlap and together go deeper than the stock, so the router breaks through between them — make the overlap a through cut in `profile`, or lower one depth; if the opening is intended, set the two depths to add up to exactly the thickness" },
|
|
133
155
|
};
|
|
134
156
|
|
|
135
157
|
// ── lint ──────────────────────────────────────────────────────────────────────
|
|
136
|
-
const DRILL_DEPTH_HINT = "A drill's depth is measured from the
|
|
158
|
+
const DRILL_DEPTH_HINT = "A drill's depth is measured from the op's own face (near or far); a depth at or past the stock thickness is a through hole. Lower the depth, raise the stock thickness control, or set depth to the thickness to drill through.";
|
|
137
159
|
const ROUTER_RULES = [{
|
|
138
160
|
id: "routed-drill-depth",
|
|
139
161
|
run: ({ part, p, d, deriveError }) => {
|
|
@@ -168,7 +190,7 @@ export const ROUTER = {
|
|
|
168
190
|
preview: routedPreview,
|
|
169
191
|
destinations: [{ id: "cnc-router", cutFormat: "dxf" }],
|
|
170
192
|
facts: routedFacts,
|
|
171
|
-
checks: () => ({ routedOutside: "0", routedEmpty: "0", routedPieces: "1" }),
|
|
193
|
+
checks: () => ({ routedOutside: "0", routedEmpty: "0", routedPieces: "1", routedBreakthrough: "0" }),
|
|
172
194
|
metrics: METRICS,
|
|
173
195
|
lintRules: ROUTER_RULES,
|
|
174
196
|
};
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Colours: `ctx.palette` (drawing.js routedPalette, built by the kit over EVERY routed
|
|
8
8
|
// piece) when given, so one depth is one colour across the kit; without it — a direct
|
|
9
|
-
// call — the piece
|
|
9
|
+
// call — routedPalette over the piece alone. Each named layer also carries its `depth` (mm,
|
|
10
10
|
// two decimals) and drills `through`, which the README's layer legend reads.
|
|
11
11
|
import {
|
|
12
12
|
refitRing, ringsOf, cutRings, drawingBounds, routedLayerName, depthToken, drillKey, ROUTED_COLORS,
|
|
13
|
+
routedPalette, FLIP_AXIS, mirrorRings,
|
|
13
14
|
} from "../../export/drawing.js";
|
|
14
15
|
import { KIT_OPTIONS_ERROR } from "../../export/formats.js";
|
|
15
16
|
|
|
@@ -18,57 +19,79 @@ const refitRegions = (regions) => regions.map((rg) => ({ outer: refitRing(rg.out
|
|
|
18
19
|
const allRings = (regions) => { const r = ringsOf(regions); return [...r.outer, ...r.holes]; };
|
|
19
20
|
const ringsOfShape = (shape) => allRings(refitRegions(shape.toContours())).map(closedPath);
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const {
|
|
28
|
-
|
|
22
|
+
// One face's operation layers: SECONDARY, then drills grouped by (d, depth, through) in
|
|
23
|
+
// first-seen order, then pockets grouped by two-decimal depth, shallowest first. `place`
|
|
24
|
+
// maps each layer's paths (identity for the near face, the flip's mirror for the far);
|
|
25
|
+
// a far layer's id is prefixed `far-` and it carries `face: "far"` — a near layer has
|
|
26
|
+
// no face key, so a one-sided piece draws exactly as before two-sided parts existed.
|
|
27
|
+
function opLayers({ pockets, drills, extra }, palette, place) {
|
|
28
|
+
const tag = place ? { face: "far" } : {};
|
|
29
|
+
const id = (base) => (place ? `far-${base}` : base);
|
|
30
|
+
const at = place ?? ((paths) => paths);
|
|
29
31
|
const layers = [];
|
|
30
|
-
const reference = [
|
|
31
|
-
...s.score.lines.map(([a, b]) => ({ start: [a[0], a[1]], segments: [{ to: [b[0], b[1]] }], closed: false })),
|
|
32
|
-
...s.score.shapes.flatMap(ringsOfShape),
|
|
33
|
-
];
|
|
34
|
-
if (reference.length) layers.push({ id: "reference", name: "REFERENCE", ...ROUTED_COLORS.reference, paths: reference });
|
|
35
32
|
const secondary = extra.flatMap((x) => ringsOfShape(x.footprint));
|
|
36
|
-
if (secondary.length) layers.push({ id: "secondary", name: "SECONDARY", ...ROUTED_COLORS.secondary, paths: secondary });
|
|
33
|
+
if (secondary.length) layers.push({ id: id("secondary"), name: place ? "FAR_SECONDARY" : "SECONDARY", ...ROUTED_COLORS.secondary, ...tag, paths: at(secondary) });
|
|
37
34
|
|
|
38
|
-
//
|
|
39
|
-
// of two exact arcs, which dxf.js writes as a CIRCLE.
|
|
40
|
-
const palette = ctx.palette ?? null;
|
|
35
|
+
// Each hole a closed ring of two exact arcs, which dxf.js writes as a CIRCLE.
|
|
41
36
|
const drillGroups = new Map();
|
|
42
37
|
drills.forEach((dr) => {
|
|
43
38
|
const key = drillKey(dr);
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
g.paths.push(...ringsOfShape(dr.shape));
|
|
39
|
+
const g = drillGroups.get(key) ?? { id: id(`drill-${key}`), name: routedLayerName("drill", dr), ...palette.drill.get(key),
|
|
40
|
+
depth: Number(depthToken(dr.depth)), through: dr.through, ...tag, paths: [] };
|
|
41
|
+
g.paths.push(...at(ringsOfShape(dr.shape)));
|
|
48
42
|
drillGroups.set(key, g);
|
|
49
43
|
});
|
|
50
44
|
layers.push(...drillGroups.values());
|
|
51
45
|
|
|
52
|
-
//
|
|
46
|
+
// An empty region draws nothing.
|
|
53
47
|
const pocketGroups = new Map();
|
|
54
48
|
for (const pk of pockets) {
|
|
55
49
|
const paths = ringsOfShape(pk.region);
|
|
56
50
|
if (paths.length === 0) continue;
|
|
57
51
|
const key = depthToken(pk.depth);
|
|
58
|
-
const g = pocketGroups.get(key) ?? { id: `pocket-${key}
|
|
59
|
-
g.paths.push(...paths);
|
|
52
|
+
const g = pocketGroups.get(key) ?? { id: id(`pocket-${key}`), name: routedLayerName("pocket", pk), depth: Number(key), ...tag, paths: [] };
|
|
53
|
+
g.paths.push(...at(paths));
|
|
60
54
|
pocketGroups.set(key, g);
|
|
61
55
|
}
|
|
62
|
-
[...pocketGroups.values()].sort((a, b) => a.depth - b.depth)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
[...pocketGroups.values()].sort((a, b) => a.depth - b.depth)
|
|
57
|
+
.forEach((g) => layers.push({ ...g, ...palette.pocket.get(depthToken(g.depth)) }));
|
|
58
|
+
return layers;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function drawing(s, o, k, ctx = {}) {
|
|
62
|
+
const label = ctx.label ?? s.label ?? "routed part";
|
|
63
|
+
if (o?.kerf > 0) throw new Error(`${KIT_OPTIONS_ERROR} kerf does not apply to a CNC router — the CAM applies the tool offset; set kerf to 0 ("${label}")`);
|
|
64
|
+
const nominal = refitRegions(s.profile.toContours());
|
|
65
|
+
if (nominal.length === 0) throw new Error(`cut kit: "${label}" has an empty profile — nothing to cut`);
|
|
66
|
+
const cut = cutRings(nominal);
|
|
67
|
+
const { pockets, drills, extra } = s.routed;
|
|
68
|
+
|
|
69
|
+
const layers = [];
|
|
70
|
+
const reference = [
|
|
71
|
+
...s.score.lines.map(([a, b]) => ({ start: [a[0], a[1]], segments: [{ to: [b[0], b[1]] }], closed: false })),
|
|
72
|
+
...s.score.shapes.flatMap(ringsOfShape),
|
|
73
|
+
];
|
|
74
|
+
if (reference.length) layers.push({ id: "reference", name: "REFERENCE", ...ROUTED_COLORS.reference, paths: reference });
|
|
75
|
+
// Near ops as they are; far ops (cut after the piece is turned over by the kit's flip)
|
|
76
|
+
// on their own `far-` layers, every ring mirrored about the piece's centre so the far
|
|
77
|
+
// face is drawn as the router sees it. One palette for both faces: a depth or drill
|
|
78
|
+
// setting is one colour whichever face it is on.
|
|
79
|
+
const palette = ctx.palette ?? routedPalette([s]);
|
|
80
|
+
const nb = drawingBounds([{ id: "cut-outer", paths: ringsOf(nominal).outer.map(closedPath) }]);
|
|
81
|
+
const axis = FLIP_AXIS[o?.flip ?? "left-right"];
|
|
82
|
+
const centre = axis === "x" ? (nb.min[0] + nb.max[0]) / 2 : (nb.min[1] + nb.max[1]) / 2;
|
|
83
|
+
const isFar = (op) => op.face === "far";
|
|
84
|
+
const near = opLayers({ pockets: pockets.filter((op) => !isFar(op)), drills: drills.filter((op) => !isFar(op)), extra: extra.filter((op) => !isFar(op)) }, palette);
|
|
85
|
+
const far = opLayers({ pockets: pockets.filter(isFar), drills: drills.filter(isFar), extra: extra.filter(isFar) }, palette,
|
|
86
|
+
(paths) => mirrorRings(paths, axis, centre));
|
|
87
|
+
layers.push(...near, ...far);
|
|
66
88
|
|
|
67
89
|
if (cut.inner.length) layers.push({ id: "cut-inner", paths: cut.inner.map(closedPath) });
|
|
68
90
|
layers.push({ id: "cut-outer", paths: cut.outer.map(closedPath) });
|
|
69
91
|
|
|
70
|
-
const
|
|
71
|
-
|
|
92
|
+
const out = { layers, bounds: drawingBounds(layers), nominal: [nb.max[0] - nb.min[0], nb.max[1] - nb.min[1]], kerf: 0 };
|
|
93
|
+
if (far.length) out.farOutline = mirrorRings(ringsOf(nominal).outer, axis, centre).map(closedPath);
|
|
94
|
+
return out;
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
export default { drawing };
|
|
@@ -16,6 +16,13 @@ const depthOf = (sp, entry, who) => {
|
|
|
16
16
|
if (!isNum(entry.depth) || entry.depth <= 0) throw fail(sp, `${who} depth must be a finite number > 0 (mm), got ${JSON.stringify(entry.depth)}`);
|
|
17
17
|
return entry.depth;
|
|
18
18
|
};
|
|
19
|
+
// Which face an op is machined from: "near" (z = thickness, the default) or "far" (z = 0).
|
|
20
|
+
const faceOf = (sp, entry, who) => {
|
|
21
|
+
const f = entry.face;
|
|
22
|
+
if (f === undefined || f === null) return "near";
|
|
23
|
+
if (f === "near" || f === "far") return f;
|
|
24
|
+
throw fail(sp, `${who} face must be "near" or "far", got ${JSON.stringify(f)}`);
|
|
25
|
+
};
|
|
19
26
|
const labelOf = (entry) => (typeof entry.label === "string" && entry.label.trim() ? entry.label.trim() : null);
|
|
20
27
|
const list = (sp, fn, args, what, shape) => {
|
|
21
28
|
if (!fn) return [];
|
|
@@ -26,7 +33,9 @@ const list = (sp, fn, args, what, shape) => {
|
|
|
26
33
|
};
|
|
27
34
|
|
|
28
35
|
// `base` is resolveSheet's ResolvedSheet so far (profile, thickness, score…); the
|
|
29
|
-
// return value is merged onto it as `routed`.
|
|
36
|
+
// return value is merged onto it as `routed`. Every op carries `face`: "near" (machined
|
|
37
|
+
// from z = thickness, the default) or "far" (from z = 0, the stock flipped over); depth
|
|
38
|
+
// is measured from that face either way.
|
|
30
39
|
export function resolveRouted(k, sp, p, d, base) {
|
|
31
40
|
const s = sp.sheet;
|
|
32
41
|
const t = base.thickness;
|
|
@@ -35,8 +44,9 @@ export function resolveRouted(k, sp, p, d, base) {
|
|
|
35
44
|
const who = name(e, i, "pocket");
|
|
36
45
|
if (!e || typeof e !== "object" || e.region === null || e.region === undefined) throw fail(sp, `${who} needs a region (a Shape2D or profile)`);
|
|
37
46
|
const depth = depthOf(sp, e, who);
|
|
47
|
+
const face = faceOf(sp, e, who);
|
|
38
48
|
if (depth > t) throw fail(sp, `${who} is ${e.depth} mm deep in ${t} mm stock — a pocket may be at most the thickness; make it a through cut in \`profile\` if it should go through`);
|
|
39
|
-
return { region: k.shape2d(e.region), depth, label: labelOf(e) };
|
|
49
|
+
return { region: k.shape2d(e.region), depth, label: labelOf(e), face };
|
|
40
50
|
});
|
|
41
51
|
|
|
42
52
|
const drills = list(sp, s.drills, [p, d], "drills(p, d)", "{ at, d, depth }").map((e, i) => {
|
|
@@ -44,18 +54,20 @@ export function resolveRouted(k, sp, p, d, base) {
|
|
|
44
54
|
if (!e || typeof e !== "object" || !isPoint(e.at)) throw fail(sp, `${who} needs at: [x, y]`);
|
|
45
55
|
if (!isNum(e.d) || e.d <= 0) throw fail(sp, `${who} d must be a finite number > 0 (mm), got ${JSON.stringify(e.d)}`);
|
|
46
56
|
const depth = depthOf(sp, e, who);
|
|
57
|
+
const face = faceOf(sp, e, who);
|
|
47
58
|
const through = depth >= t;
|
|
48
|
-
return { shape: k.shape2d(circleRing(e.at, e.d / 2)), at: [e.at[0], e.at[1]], d: e.d, depth: through ? t : depth, through, label: labelOf(e) };
|
|
59
|
+
return { shape: k.shape2d(circleRing(e.at, e.d / 2)), at: [e.at[0], e.at[1]], d: e.d, depth: through ? t : depth, through, label: labelOf(e), face };
|
|
49
60
|
});
|
|
50
61
|
|
|
51
62
|
const extra = list(sp, s.extra, [k, p, d], "extra(k, p, d)", "{ solid, footprint, label, method }").map((e, i) => {
|
|
52
63
|
const label = labelOf(e);
|
|
53
64
|
if (!label) throw fail(sp, `extra op ${i + 1} needs a label — say what it is ("12° domino slot")`);
|
|
54
65
|
const who = `extra op "${label}"`;
|
|
66
|
+
const face = faceOf(sp, e, who);
|
|
55
67
|
if (typeof e.method !== "string" || !e.method.trim()) throw fail(sp, `${who} needs a method — say how the operator makes it ("table saw, blade tilted 12°")`);
|
|
56
68
|
if (!isSolid(e.solid)) throw fail(sp, `${who} needs a solid — the geometry it removes, for the preview and the STEP`);
|
|
57
69
|
if (e.footprint === null || e.footprint === undefined) throw fail(sp, `${who} needs a footprint — its outline on the machined face, for the drawing`);
|
|
58
|
-
return { solid: e.solid, footprint: k.shape2d(e.footprint), label, method: e.method.trim() };
|
|
70
|
+
return { solid: e.solid, footprint: k.shape2d(e.footprint), label, method: e.method.trim(), face };
|
|
59
71
|
});
|
|
60
72
|
|
|
61
73
|
return { pockets, drills, extra };
|
package/types/geometry.d.ts
CHANGED
|
@@ -338,12 +338,14 @@ export function sheetPart<P = ResolvedParams, D = Derived>(spec: SheetPartSpec<P
|
|
|
338
338
|
|
|
339
339
|
// --- routed parts (docs/AUTHORING-PARTS.md "Routed parts") --------------------
|
|
340
340
|
|
|
341
|
-
/**
|
|
342
|
-
export
|
|
341
|
+
/** Which face of the stock an op is machined from: `"near"` (z = thickness, the default) or `"far"` (z = 0). */
|
|
342
|
+
export type RoutedFace = "near" | "far";
|
|
343
|
+
/** A flat-floored pocket machined `depth` mm into the stock from its face. */
|
|
344
|
+
export interface RoutedPocket { region: Shape2D | ProfileInput; depth: number; label?: string; face?: RoutedFace }
|
|
343
345
|
/** A drilled hole; `depth` equal to the stock thickness drills through — deeper is a `routed-drill-depth` lint error. */
|
|
344
|
-
export interface RoutedDrill { at: Point2; d: number; depth: number; label?: string }
|
|
346
|
+
export interface RoutedDrill { at: Point2; d: number; depth: number; label?: string; face?: RoutedFace }
|
|
345
347
|
/** An operation done with another tool after the flat milling: the solid is cut in the preview and the STEP, the footprint is drawn, label and method go in the README. */
|
|
346
|
-
export interface RoutedExtra { solid: Solid; footprint: Shape2D | ProfileInput; label: string; method: string }
|
|
348
|
+
export interface RoutedExtra { solid: Solid; footprint: Shape2D | ProfileInput; label: string; method: string; face?: RoutedFace }
|
|
347
349
|
|
|
348
350
|
/**
|
|
349
351
|
* What `routedPart` accepts: `sheetPart`'s stock, profile and pose, plus pockets,
|
package/types/index.d.ts
CHANGED
|
@@ -67,6 +67,10 @@ export interface KitOptions {
|
|
|
67
67
|
printFormat?: "stl" | "3mf";
|
|
68
68
|
/** Whole number, 1–20. Default 1. */
|
|
69
69
|
sets?: number;
|
|
70
|
+
/** Which way a two-sided board is turned over between sides — the axis far-side layers are mirrored about. Default `"left-right"`. */
|
|
71
|
+
flip?: "left-right" | "end-over-end";
|
|
72
|
+
/** Two-sided pieces: one layered file per piece (`"one-file"`, default), or a near file plus a `-far` file (`"two-files"`). */
|
|
73
|
+
twoSided?: "one-file" | "two-files";
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
/**
|
|
@@ -830,6 +834,8 @@ export const KIT_DEFAULTS: Readonly<{
|
|
|
830
834
|
spacing: number;
|
|
831
835
|
printFormat: "stl";
|
|
832
836
|
sets: number;
|
|
837
|
+
flip: "left-right";
|
|
838
|
+
twoSided: "one-file";
|
|
833
839
|
}>;
|
|
834
840
|
|
|
835
841
|
/** Normalise kit options — defaults filled, `cutFormat` derived — or throw a `"cut kit options: …"` error. */
|
package/types/part.d.ts
CHANGED
|
@@ -576,6 +576,8 @@ export interface SubPartExpectations {
|
|
|
576
576
|
routedEmpty?: Expectation;
|
|
577
577
|
/** Routed parts only: separate regions in the cut profile (volunteered check: `1`). */
|
|
578
578
|
routedPieces?: Expectation;
|
|
579
|
+
/** Routed parts only: overlapping near- and far-face pocket pairs whose depths together exceed the stock (volunteered check: `0`). */
|
|
580
|
+
routedBreakthrough?: Expectation;
|
|
579
581
|
}
|
|
580
582
|
|
|
581
583
|
/**
|