partforge 0.155.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 +56 -17
- 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/geometry/probe.js +28 -9
- package/src/framework/gltf/decode-preview.js +38 -14
- package/src/framework/gltf/encode-preview.js +263 -17
- package/src/framework/lint/rules-build.js +18 -4
- package/src/framework/mount-preview.js +2 -1
- 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/gltf.d.ts +5 -2
- 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.
|
|
@@ -3937,7 +3962,21 @@ shares build's `(k, p, d)` contract — against a geometry-free probe —
|
|
|
3937
3962
|
`unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
|
|
3938
3963
|
`probe-throws`, `derive-throws`, `manifold-backend-uses-occt-op`,
|
|
3939
3964
|
`build-runaway` (errors); `nondeterministic-build` (warning, from diffing two
|
|
3940
|
-
probe runs).
|
|
3965
|
+
probe runs). The probe has no geometry, so every **query** answers with a
|
|
3966
|
+
placeholder — `boundingBox()` a unit box, `area()` and `volume()` 1,
|
|
3967
|
+
`Shape2D.contains()` always `false`, `corners()`/`toRegions()` a unit square,
|
|
3968
|
+
`isEmpty()` `false`. A `build()` that throws *after* reading one of those may
|
|
3969
|
+
only have believed the placeholder — a helper that scans `contains()` to place
|
|
3970
|
+
ribs finds none and throws its own guard — so that throw is reported as a
|
|
3971
|
+
`build-throws` **note** naming the queries it read ("could not be evaluated
|
|
3972
|
+
geometry-free"), never an error: it does not block loading, `measure`, or
|
|
3973
|
+
`--strict`. A throw with no query read before it is the build's own logic and
|
|
3974
|
+
stays an error. Lint cannot tell which throws depended on the placeholder, so any
|
|
3975
|
+
throw after a query is a note; a real fault still surfaces when the real build
|
|
3976
|
+
runs. The ops the build would have called after the throw were never run, so lint
|
|
3977
|
+
did not check them (unknown ops, option keys, the Manifold pin, determinism). A
|
|
3978
|
+
declared probe that throws stays a `probe-throws` error whatever it read — at
|
|
3979
|
+
runtime a throwing probe only degrades quietly to `{ error }`.
|
|
3941
3980
|
|
|
3942
3981
|
**Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
|
|
3943
3982
|
`verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
|
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) =>
|
|
@@ -42,8 +42,11 @@ export class ProbeRunawayError extends Error {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// Shared proxy construction. `onCall(scope, op, args)` observes every op; queries
|
|
45
|
-
// return realistic dummy values the build may read
|
|
46
|
-
|
|
45
|
+
// return realistic dummy values the build may read, and `onQuery(scope, op)` hears
|
|
46
|
+
// each one — every answer in the three query tables below is FABRICATED (a unit
|
|
47
|
+
// box, area 1, contains() always false, …), so a build that read one and then threw
|
|
48
|
+
// may only have believed the placeholder (lint's build-throws reads this).
|
|
49
|
+
function makeProbe(onCall, onQuery = () => {}) {
|
|
47
50
|
const solidQueries = {
|
|
48
51
|
boundingBox: () => ({ min: [0, 0, 0], max: [1, 1, 1], center: [0.5, 0.5, 0.5], size: [1, 1, 1] }),
|
|
49
52
|
volume: () => 1,
|
|
@@ -78,6 +81,7 @@ function makeProbe(onCall) {
|
|
|
78
81
|
toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
|
|
79
82
|
cleanup: () => {},
|
|
80
83
|
};
|
|
84
|
+
const NOT_DATA = new Set(["cleanup"]); // answers nothing a build could act on
|
|
81
85
|
|
|
82
86
|
// `ignore` keeps the proxy from masquerading as a thenable/internal/serializable
|
|
83
87
|
// handle: symbols, `then` (so it's never await-unwrapped), `_`-prefixed internals,
|
|
@@ -100,7 +104,13 @@ function makeProbe(onCall) {
|
|
|
100
104
|
const opProxy = (queries, scope, routeFor) => new Proxy({}, {
|
|
101
105
|
get(_t, key) {
|
|
102
106
|
if (ignore(key)) return undefined;
|
|
103
|
-
if (key in queries)
|
|
107
|
+
if (key in queries) {
|
|
108
|
+
return (...args) => {
|
|
109
|
+
onCall(scope, key, args);
|
|
110
|
+
if (!NOT_DATA.has(key)) onQuery(scope, key);
|
|
111
|
+
return queries[key](...args);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
104
114
|
return (...args) => { onCall(scope, key, args); return routeFor(key); };
|
|
105
115
|
},
|
|
106
116
|
});
|
|
@@ -135,6 +145,8 @@ export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
|
|
|
135
145
|
const issues = [];
|
|
136
146
|
const used = new Set();
|
|
137
147
|
const solidUsed = new Set(); // same split as createProbeKernel: non-Shape2D-handle ops
|
|
148
|
+
const queried = []; // fabricated data queries read, as "scope.op", first-read order;
|
|
149
|
+
// runValidatingProbe empties it before each build/probe call
|
|
138
150
|
let count = 0;
|
|
139
151
|
let solidProxy = null;
|
|
140
152
|
let shape2dProxy = null;
|
|
@@ -169,17 +181,23 @@ export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
|
|
|
169
181
|
calls.push({ scope, op, args: args.map(describe) });
|
|
170
182
|
};
|
|
171
183
|
|
|
172
|
-
const
|
|
184
|
+
const onQuery = (scope, op) => {
|
|
185
|
+
const name = `${scope === "shape2d" ? "Shape2D" : scope === "kernel" ? "k" : "Solid"}.${op}()`;
|
|
186
|
+
if (!queried.includes(name)) queried.push(name);
|
|
187
|
+
};
|
|
188
|
+
const { kernel, proxy, shape2d } = makeProbe(onCall, onQuery);
|
|
173
189
|
solidProxy = proxy;
|
|
174
190
|
shape2dProxy = shape2d;
|
|
175
|
-
return { kernel, calls, issues, used, solidUsed };
|
|
191
|
+
return { kernel, calls, issues, used, solidUsed, queried };
|
|
176
192
|
}
|
|
177
193
|
|
|
178
194
|
/**
|
|
179
195
|
* Execute every sub-part's build() — and every declared probe (`part.probes`,
|
|
180
196
|
* same (k, p, d) contract, see oracle/measure.js) — against a validating probe.
|
|
181
197
|
* Never throws: a build error becomes an entry in `throws` (probe entries land
|
|
182
|
-
* in `probeThrows`), a runaway sets `runaway`.
|
|
198
|
+
* in `probeThrows`), a runaway sets `runaway`. Each entry carries `queried`: the
|
|
199
|
+
* fabricated data queries THAT call read before it threw (tracked per call, so one
|
|
200
|
+
* sub-part's reads never excuse another's throw).
|
|
183
201
|
*/
|
|
184
202
|
export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {}) {
|
|
185
203
|
const probe = createValidatingProbe({ maxOps });
|
|
@@ -187,22 +205,23 @@ export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {})
|
|
|
187
205
|
const probeThrows = [];
|
|
188
206
|
let runaway = false;
|
|
189
207
|
const run = (fn, onThrow) => {
|
|
208
|
+
probe.queried.length = 0;
|
|
190
209
|
try {
|
|
191
210
|
fn(probe.kernel, p, d);
|
|
192
211
|
} catch (e) {
|
|
193
212
|
if (e instanceof ProbeRunawayError) { runaway = true; return false; }
|
|
194
|
-
onThrow(e?.message || String(e));
|
|
213
|
+
onThrow(e?.message || String(e), [...probe.queried]);
|
|
195
214
|
}
|
|
196
215
|
return true;
|
|
197
216
|
};
|
|
198
217
|
for (const [name, sp] of Object.entries(part?.parts ?? {})) {
|
|
199
218
|
if (typeof sp?.build !== "function") continue; // no-buildable-parts already reports this
|
|
200
|
-
if (!run(sp.build, (m) => throws.push({ subpart: name, message: m }))) break;
|
|
219
|
+
if (!run(sp.build, (m, queried) => throws.push({ subpart: name, message: m, queried }))) break;
|
|
201
220
|
}
|
|
202
221
|
if (!runaway) {
|
|
203
222
|
for (const [name, fn] of Object.entries(part?.probes ?? {})) {
|
|
204
223
|
if (typeof fn !== "function") continue; // invalid-probes already reports this
|
|
205
|
-
if (!run(fn, (m) => probeThrows.push({ probe: name, message: m }))) break;
|
|
224
|
+
if (!run(fn, (m, queried) => probeThrows.push({ probe: name, message: m, queried }))) break;
|
|
206
225
|
}
|
|
207
226
|
}
|
|
208
227
|
return { calls: probe.calls, issues: probe.issues, used: probe.used, solidUsed: probe.solidUsed, throws, probeThrows, runaway };
|