@toolpath/tool-scraper 2.1.0 → 2.2.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/dist/conventions.d.ts +36 -0
- package/dist/conventions.js +41 -0
- package/dist/family.d.ts +16 -2
- package/dist/holding.d.ts +396 -0
- package/dist/holding.js +360 -0
- package/dist/index.d.ts +23 -13
- package/dist/index.js +23 -13
- package/dist/node/cad-mirror.d.ts +58 -1
- package/dist/node/cad-mirror.js +56 -8
- package/dist/node/cli.d.ts +4 -1
- package/dist/node/cli.js +192 -18
- package/dist/node/holder-import.d.ts +223 -0
- package/dist/node/holder-import.js +379 -0
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.js +1 -0
- package/dist/node/paths.d.ts +16 -0
- package/dist/node/paths.js +20 -0
- package/dist/profiles.d.ts +265 -0
- package/dist/profiles.js +295 -0
- package/dist/registry.d.ts +61 -5
- package/dist/registry.js +109 -6
- package/dist/vendors/kennametal/holding.d.ts +35 -0
- package/dist/vendors/kennametal/holding.js +112 -0
- package/dist/vendors/kennametal/index.d.ts +1 -0
- package/dist/vendors/kennametal/index.js +1 -0
- package/dist/vendors/maritool/holding.d.ts +79 -0
- package/dist/vendors/maritool/holding.js +164 -0
- package/dist/vendors/maritool/index.d.ts +1 -0
- package/dist/vendors/maritool/index.js +1 -0
- package/dist/vendors/maritool/scrape.d.ts +37 -13
- package/dist/vendors/maritool/scrape.js +53 -14
- package/dist/vendors/regofix/holding.d.ts +35 -0
- package/dist/vendors/regofix/holding.js +108 -0
- package/dist/vendors/regofix/index.d.ts +1 -0
- package/dist/vendors/regofix/index.js +1 -0
- package/dist/vendors/regofix/scrape.js +2 -2
- package/package.json +1 -1
package/dist/node/cli.d.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* toolpath-scrape cad add the vendor CAD model column
|
|
13
13
|
* toolpath-scrape materials add the ISO workpiece-group column
|
|
14
14
|
* toolpath-scrape mirror-cad download the vendor STEP models
|
|
15
|
+
* toolpath-scrape coverage report which rows publish a CAD model
|
|
16
|
+
* toolpath-scrape profiles measure the mirrored models into profiles
|
|
15
17
|
* ```
|
|
16
18
|
*
|
|
17
19
|
* **One binary with subcommands.** Seven names in `node_modules/.bin` for one
|
|
@@ -27,12 +29,13 @@
|
|
|
27
29
|
* tree.
|
|
28
30
|
*/
|
|
29
31
|
import { type Fetcher } from '../fetch.js';
|
|
32
|
+
import { type HolderApi } from './holder-import.js';
|
|
30
33
|
/** Everything the CLI prints, injectable so a test is not stdout. */
|
|
31
34
|
export interface Console_ {
|
|
32
35
|
log: (message: string) => void;
|
|
33
36
|
error: (message: string) => void;
|
|
34
37
|
}
|
|
35
38
|
/** Run one command. Exported so the tests drive it without a subprocess. */
|
|
36
|
-
export declare function run(argv: string[], io?: Console_, fetcher?: Fetcher): Promise<number>;
|
|
39
|
+
export declare function run(argv: string[], io?: Console_, fetcher?: Fetcher, api?: HolderApi): Promise<number>;
|
|
37
40
|
/** The process entry point: run, and turn a refusal into an exit code. */
|
|
38
41
|
export declare function main(argv?: string[]): Promise<number>;
|
package/dist/node/cli.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* toolpath-scrape cad add the vendor CAD model column
|
|
13
13
|
* toolpath-scrape materials add the ISO workpiece-group column
|
|
14
14
|
* toolpath-scrape mirror-cad download the vendor STEP models
|
|
15
|
+
* toolpath-scrape coverage report which rows publish a CAD model
|
|
16
|
+
* toolpath-scrape profiles measure the mirrored models into profiles
|
|
15
17
|
* ```
|
|
16
18
|
*
|
|
17
19
|
* **One binary with subcommands.** Seven names in `node_modules/.bin` for one
|
|
@@ -26,18 +28,20 @@
|
|
|
26
28
|
* or an assembly catalog is a different product, and none of it is in this
|
|
27
29
|
* tree.
|
|
28
30
|
*/
|
|
29
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
30
|
-
import { basename, dirname } from 'node:path';
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { basename, dirname, join } from 'node:path';
|
|
31
33
|
import { ScraperConfigError, VendorResponseError } from '../errors.js';
|
|
32
34
|
import { familyBrand } from '../family.js';
|
|
33
35
|
import { ALL_FAMILIES, FAMILIES, HOLDER_FAMILIES, familyConfig } from '../families/index.js';
|
|
34
36
|
import { createFetcher } from '../fetch.js';
|
|
37
|
+
import { buildProfiles } from '../profiles.js';
|
|
35
38
|
import { AEM_BRANDS } from '../identity.js';
|
|
36
|
-
import { boundFamily } from '../registry.js';
|
|
39
|
+
import { boundFamily, toHolding } from '../registry.js';
|
|
37
40
|
import { pause, REQUEST_DELAY_MS } from '../scrape.js';
|
|
38
|
-
import { mirrorFamilySteps } from './cad-mirror.js';
|
|
41
|
+
import { cadCoverage, mirrorFamilySteps } from './cad-mirror.js';
|
|
39
42
|
import { parseCsv, toCsv } from './csv.js';
|
|
40
|
-
import {
|
|
43
|
+
import { API_KEY_ENV, API_URL_ENV, createHolderApi, describeApi, measureFamily, } from './holder-import.js';
|
|
44
|
+
import { describeRoot, familyCsv, profilesDir, profilesJson, stepDir } from './paths.js';
|
|
41
45
|
import * as receipts from './receipts.js';
|
|
42
46
|
import { scrapeFamily } from '../vendors/kennametal/scrape.js';
|
|
43
47
|
import { annotateCadUrls } from '../vendors/kennametal/cad.js';
|
|
@@ -59,6 +63,31 @@ import { scrapeCategory } from '../vendors/emuge/scrape.js';
|
|
|
59
63
|
* that fit nothing.
|
|
60
64
|
*/
|
|
61
65
|
const BT30_COLLET_SIZES = ['6', '10', '15', '25'];
|
|
66
|
+
/**
|
|
67
|
+
* Brand -> the step that fills `conventions.CAD_COLUMN` for it, where the
|
|
68
|
+
* vendor needs a second request to say where its model is.
|
|
69
|
+
*
|
|
70
|
+
* **Two brands are in here and three are deliberately not.** `annotateCadUrls`
|
|
71
|
+
* is Kennametal's CDS Visual lookup, not a vendor-neutral one: it queries
|
|
72
|
+
* product-config.net and rewrites the column on every row. Run against a
|
|
73
|
+
* REGO-FIX holder it would post that vendor's SKUs to Kennametal and blank the
|
|
74
|
+
* STEP URLs the REGO-FIX scrape had already filled in.
|
|
75
|
+
*
|
|
76
|
+
* A brand that is absent is **not** a brand this command refuses. REGO-FIX and
|
|
77
|
+
* MariTool write the column during the scrape itself, so for them there is
|
|
78
|
+
* nothing to annotate and the honest answer is to say so and report what the
|
|
79
|
+
* CSV already carries — see {@link cad}. It exited 2 until 2026-09-02, which
|
|
80
|
+
* made `cad <every holder family>` impossible to run over a catalog holding
|
|
81
|
+
* more than one vendor's.
|
|
82
|
+
*
|
|
83
|
+
* A table here rather than a check against `AEM_BRANDS`, because being on
|
|
84
|
+
* Kennametal's AEM platform and having a CAD lookup are two different facts
|
|
85
|
+
* that happen to coincide across two brands.
|
|
86
|
+
*/
|
|
87
|
+
const CAD_ANNOTATORS = {
|
|
88
|
+
kennametal: annotateCadUrls,
|
|
89
|
+
widia: annotateCadUrls,
|
|
90
|
+
};
|
|
62
91
|
const USAGE = `usage: toolpath-scrape <command> [args]
|
|
63
92
|
|
|
64
93
|
kennametal [--brand kennametal|widia] FAMILY_CODE OUTPUT_CSV [Name=Value ...]
|
|
@@ -123,6 +152,20 @@ const USAGE = `usage: toolpath-scrape <command> [args]
|
|
|
123
152
|
Downloads each row's STEP model into <root>/<brand>/step. Run \`cad\`
|
|
124
153
|
first — a CSV with no CAD column yields nothing and says so.
|
|
125
154
|
|
|
155
|
+
profiles HOLDERS.csv [more.csv ...]
|
|
156
|
+
Measures each mirrored STEP model through the Toolpath Engine API and
|
|
157
|
+
writes the gage-line profile document — one per family under
|
|
158
|
+
<root>/<brand>/profiles, plus the merged <root>/profiles.json.
|
|
159
|
+
Needs ${API_KEY_ENV} set, and ${API_URL_ENV} until the holder routes
|
|
160
|
+
reach production. Run \`mirror-cad\` first: a holder with no mirrored
|
|
161
|
+
model is reported and skipped, not measured.
|
|
162
|
+
|
|
163
|
+
coverage [HOLDERS.csv ...]
|
|
164
|
+
Reports how many rows of each holder family publish a STEP model and a
|
|
165
|
+
DXF. Reads the scraped CSVs and makes no requests at all. With no
|
|
166
|
+
arguments it reports every holder family, and says which ones have not
|
|
167
|
+
been scraped on this machine rather than failing on them.
|
|
168
|
+
|
|
126
169
|
An output path is used verbatim; scraped CSVs belong under the scrape root,
|
|
127
170
|
in <brand>/csv/. The in-place commands take a bare CSV name and resolve it
|
|
128
171
|
through the family's own brand.`;
|
|
@@ -183,7 +226,7 @@ function namesIn(argv, known, what) {
|
|
|
183
226
|
return names;
|
|
184
227
|
}
|
|
185
228
|
/** Run one command. Exported so the tests drive it without a subprocess. */
|
|
186
|
-
export async function run(argv, io = STDOUT, fetcher = createFetcher()) {
|
|
229
|
+
export async function run(argv, io = STDOUT, fetcher = createFetcher(), api) {
|
|
187
230
|
// Somebody reading the usage text is the person most likely to be about to
|
|
188
231
|
// point a scrape at the wrong place, so help gets the root too.
|
|
189
232
|
io.log(describeRoot());
|
|
@@ -213,6 +256,10 @@ export async function run(argv, io = STDOUT, fetcher = createFetcher()) {
|
|
|
213
256
|
return materials(rest, io, fetcher);
|
|
214
257
|
case 'mirror-cad':
|
|
215
258
|
return mirrorCad(rest, io, fetcher);
|
|
259
|
+
case 'coverage':
|
|
260
|
+
return coverage(rest, io);
|
|
261
|
+
case 'profiles':
|
|
262
|
+
return profiles(rest, io, api);
|
|
216
263
|
default:
|
|
217
264
|
io.error(`unknown command ${JSON.stringify(command)}\n\n${USAGE}`);
|
|
218
265
|
return 2;
|
|
@@ -419,25 +466,35 @@ function threadPitch(argv, io) {
|
|
|
419
466
|
}
|
|
420
467
|
return 0;
|
|
421
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Fill the CAD model column, for the vendors that need a second request to.
|
|
471
|
+
*
|
|
472
|
+
* Vendor-dispatched through {@link CAD_ANNOTATORS} rather than gated on
|
|
473
|
+
* `AEM_BRANDS`. A brand with no annotator is a **no-op with a message**, not a
|
|
474
|
+
* refusal: its scrape already wrote the column, so there is nothing this
|
|
475
|
+
* command could add, and exiting 2 on it meant `cad` could not be run across a
|
|
476
|
+
* catalog holding more than one vendor's holders. `mirror-cad` reads the column
|
|
477
|
+
* and is neutral; this writes it and is not.
|
|
478
|
+
*/
|
|
422
479
|
async function cad(argv, io, fetcher) {
|
|
423
480
|
if (argv.length === 0) {
|
|
424
481
|
io.error(USAGE);
|
|
425
482
|
return 2;
|
|
426
483
|
}
|
|
427
484
|
for (const name of namesIn(argv, HOLDER_FAMILIES, 'holder')) {
|
|
428
|
-
// `annotateCadUrls` is Kennametal's CDS lookup, not a vendor-neutral one —
|
|
429
|
-
// it queries product-config.net and rewrites `CAD_COLUMN` on every row. Run
|
|
430
|
-
// against a REGO-FIX holder it would post that vendor's SKUs to Kennametal
|
|
431
|
-
// and blank the STEP URLs the REGO-FIX scrape had already filled in.
|
|
432
|
-
// `mirror-cad` reads the column and *is* neutral; this writes it and is not.
|
|
433
485
|
const brand = familyBrand(familyConfig(name));
|
|
434
|
-
if (!AEM_BRANDS.includes(brand)) {
|
|
435
|
-
io.error(`${name}: the cad step is ${[...AEM_BRANDS].sort().join('/')}-only — ` +
|
|
436
|
-
`${brand} publishes its own CAD URLs with the scrape`);
|
|
437
|
-
return 2;
|
|
438
|
-
}
|
|
439
486
|
const path = familyCsv(name);
|
|
440
|
-
const
|
|
487
|
+
const annotate = CAD_ANNOTATORS[brand];
|
|
488
|
+
if (annotate === undefined) {
|
|
489
|
+
const found = coverageOf(name);
|
|
490
|
+
io.log(`${name}: nothing to annotate — ${brand} publishes its CAD URLs with ` +
|
|
491
|
+
`the scrape` +
|
|
492
|
+
(found === null
|
|
493
|
+
? ' (not scraped on this machine)'
|
|
494
|
+
: ` (${found.step} of ${found.rows} rows carry one)`));
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const { scrape, found } = await annotate(fetcher, readCsv(name, path));
|
|
441
498
|
writeCsv(path, scrape);
|
|
442
499
|
io.log(`${name}: ${found} CAD models`);
|
|
443
500
|
}
|
|
@@ -474,12 +531,129 @@ async function mirrorCad(argv, io, fetcher) {
|
|
|
474
531
|
for (const name of namesIn(argv, HOLDER_FAMILIES, 'holder')) {
|
|
475
532
|
const path = familyCsv(name);
|
|
476
533
|
const brand = familyBrand(familyConfig(name));
|
|
477
|
-
const written = await mirrorFamilySteps(fetcher, readCsv(name, path).rows, stepDir(brand), undefined, io.error);
|
|
534
|
+
const written = await mirrorFamilySteps(fetcher, readCsv(name, path).rows, brand, stepDir(brand), undefined, io.error);
|
|
478
535
|
const total = written.reduce((sum, f) => sum + f.bytes, 0);
|
|
479
536
|
io.log(`${name}: ${written.length} STEP files, ${Math.floor(total / 1024)} KB`);
|
|
480
537
|
}
|
|
481
538
|
return 0;
|
|
482
539
|
}
|
|
540
|
+
/** One family's CAD coverage, or null where it has not been scraped here. */
|
|
541
|
+
function coverageOf(name) {
|
|
542
|
+
const path = familyCsv(name);
|
|
543
|
+
if (!existsSync(path))
|
|
544
|
+
return null;
|
|
545
|
+
return cadCoverage(parseCsv(readFileSync(path, 'utf8')).rows);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* How much of the holder catalog publishes a model, before anything downloads
|
|
549
|
+
* one.
|
|
550
|
+
*
|
|
551
|
+
* **The one command here that makes no requests and writes no files.** It is
|
|
552
|
+
* the number that bounds anything built on measured geometry, and it is worth
|
|
553
|
+
* having before the mirror runs rather than after: MariTool is 527 of the 601
|
|
554
|
+
* holder rows and publishes a STEP for about two thirds of them.
|
|
555
|
+
*
|
|
556
|
+
* A family that has not been scraped on this machine is **reported, not
|
|
557
|
+
* refused**, even when it was asked for by name. The whole command is a report,
|
|
558
|
+
* and one absent CSV must not stop it printing the rest — which is exactly the
|
|
559
|
+
* shape the `cad` step had wrong.
|
|
560
|
+
*/
|
|
561
|
+
function coverage(argv, io) {
|
|
562
|
+
const names = argv.length === 0
|
|
563
|
+
? Object.keys(HOLDER_FAMILIES).sort()
|
|
564
|
+
: namesIn(argv, HOLDER_FAMILIES, 'holder');
|
|
565
|
+
const total = { rows: 0, step: 0, dxf: 0 };
|
|
566
|
+
let counted = 0;
|
|
567
|
+
for (const name of names) {
|
|
568
|
+
const found = coverageOf(name);
|
|
569
|
+
if (found === null) {
|
|
570
|
+
io.log(`${name}: not scraped on this machine`);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
counted += 1;
|
|
574
|
+
total.rows += found.rows;
|
|
575
|
+
total.step += found.step;
|
|
576
|
+
total.dxf += found.dxf;
|
|
577
|
+
io.log(`${name}: ${describeCoverage(found)}`);
|
|
578
|
+
}
|
|
579
|
+
if (counted > 1)
|
|
580
|
+
io.log(`${counted} families: ${describeCoverage(total)}`);
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
/** One coverage line: the counts, and the STEP share a mirror would get. */
|
|
584
|
+
function describeCoverage(found) {
|
|
585
|
+
// Guarded rather than assumed: an empty CSV is a scrape that produced a header
|
|
586
|
+
// and no parts, and a NaN percentage would read as a parsing fault here
|
|
587
|
+
// instead of as the empty file it is.
|
|
588
|
+
const share = found.rows === 0 ? '—' : `${Math.round((100 * found.step) / found.rows)}%`;
|
|
589
|
+
return `${found.rows} rows, ${found.step} STEP (${share}), ${found.dxf} DXF`;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Measure one holder family's mirrored models into a profiles document.
|
|
593
|
+
*
|
|
594
|
+
* **The one command that sends data to Toolpath rather than reading from a
|
|
595
|
+
* vendor.** The API takes a direct upload of the STEP file — it does not fetch
|
|
596
|
+
* the vendor's URL itself — so every measurement puts a vendor's binary in
|
|
597
|
+
* Toolpath object storage, which is why the API base URL is printed beside the
|
|
598
|
+
* scrape root before anything is uploaded.
|
|
599
|
+
*
|
|
600
|
+
* Per family *and* merged, because the two answer different questions: a family
|
|
601
|
+
* document is what a re-measure of that family replaces, and the merged one is
|
|
602
|
+
* what a consumer loads. Both are built by `profiles.buildProfiles`, so a guid
|
|
603
|
+
* that appeared twice would be refused rather than silently overwritten.
|
|
604
|
+
*/
|
|
605
|
+
async function profiles(argv, io, api) {
|
|
606
|
+
if (argv.length === 0) {
|
|
607
|
+
io.error(USAGE);
|
|
608
|
+
return 2;
|
|
609
|
+
}
|
|
610
|
+
const names = namesIn(argv, HOLDER_FAMILIES, 'holder');
|
|
611
|
+
io.log(describeApi());
|
|
612
|
+
const client = api ?? createHolderApi();
|
|
613
|
+
const everyMeasurement = [];
|
|
614
|
+
const everyHolder = [];
|
|
615
|
+
for (const name of names) {
|
|
616
|
+
const path = familyCsv(name);
|
|
617
|
+
const brand = familyBrand(familyConfig(name));
|
|
618
|
+
// Holders only: a collet publishes no CAD model and is not drawn, because
|
|
619
|
+
// it sits inside the nut the holder's own profile already includes.
|
|
620
|
+
const holders = toHolding(name, readCsv(name, path), { warn: io.error });
|
|
621
|
+
const run = await measureFamily(client, holders, stepDir(brand), undefined, undefined, io.error);
|
|
622
|
+
if (run.unmirrored.length > 0) {
|
|
623
|
+
io.log(`${name}: ${run.unmirrored.length} holders publish no mirrored model`);
|
|
624
|
+
}
|
|
625
|
+
if (run.failed.length > 0) {
|
|
626
|
+
io.log(`${name}: ${run.failed.length} imports the kernel refused`);
|
|
627
|
+
}
|
|
628
|
+
if (run.measured.length === 0) {
|
|
629
|
+
io.log(`${name}: nothing measured — run mirror-cad first`);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const document = buildProfiles(run.measured, holders);
|
|
633
|
+
writeJson(join(profilesDir(brand), `${basename(name, '.csv')}.json`), document);
|
|
634
|
+
io.log(`${name}: ${describeProfiles(document)}`);
|
|
635
|
+
everyMeasurement.push(...run.measured);
|
|
636
|
+
everyHolder.push(...holders);
|
|
637
|
+
}
|
|
638
|
+
if (everyMeasurement.length === 0)
|
|
639
|
+
return 0;
|
|
640
|
+
const merged = buildProfiles(everyMeasurement, everyHolder);
|
|
641
|
+
writeJson(profilesJson(), merged);
|
|
642
|
+
io.log(`${profilesJson()}: ${describeProfiles(merged)}`);
|
|
643
|
+
return 0;
|
|
644
|
+
}
|
|
645
|
+
/** One profiles line: how many holders, and how many agree with the vendor's L1. */
|
|
646
|
+
function describeProfiles(document) {
|
|
647
|
+
const complete = Object.values(document.holders).filter((p) => p.complete).length;
|
|
648
|
+
return (`${document.holderCount} profiles, ${complete} complete ` +
|
|
649
|
+
`(kernel ${document.kernelVersion}, tolerance ${document.options.tolerance}, ` +
|
|
650
|
+
`fillBays ${document.options.fillBays})`);
|
|
651
|
+
}
|
|
652
|
+
/** A derived document onto disk, its directory created and a trailing newline on it. */
|
|
653
|
+
function writeJson(path, document) {
|
|
654
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
655
|
+
writeFileSync(path, `${JSON.stringify(document, null, 1)}\n`);
|
|
656
|
+
}
|
|
483
657
|
/** The process entry point: run, and turn a refusal into an exit code. */
|
|
484
658
|
export async function main(argv = process.argv.slice(2)) {
|
|
485
659
|
try {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measuring a mirrored holder through the Toolpath Engine API.
|
|
3
|
+
*
|
|
4
|
+
* The step between `node/cad-mirror.ts`, which puts a vendor's STEP file on
|
|
5
|
+
* disk, and `profiles.ts`, which turns a measurement into a drawing's
|
|
6
|
+
* silhouette. Five calls per holder: create, upload, queue, poll, read.
|
|
7
|
+
*
|
|
8
|
+
* **This is what deleted the Fusion dependency.** The pipeline this replaces
|
|
9
|
+
* could only measure a holder on a machine running Fusion 360 with an MCP
|
|
10
|
+
* bridge attached to it — roughly 1,800 lines of Python driving a GUI
|
|
11
|
+
* application. The same numbers now come back in about two seconds over HTTP,
|
|
12
|
+
* to nanometre agreement, from something that can run in CI.
|
|
13
|
+
*
|
|
14
|
+
* ## Why it is in `node/` and not in the library half
|
|
15
|
+
*
|
|
16
|
+
* The same three reasons `cad-mirror.ts` gives for itself, and they all hold
|
|
17
|
+
* here: it reads a mirrored binary off disk, it is a batch job with pacing
|
|
18
|
+
* rather than a request-scoped call, and it is a maintainer's command rather
|
|
19
|
+
* than something a backend embeds.
|
|
20
|
+
*
|
|
21
|
+
* ## Why it does not go through `fetch.Fetcher`
|
|
22
|
+
*
|
|
23
|
+
* `Fetcher` is four GET-and-decode shapes for anonymous vendor endpoints. This
|
|
24
|
+
* needs a bearer token, a PUT of raw bytes to a presigned URL and a PATCH with
|
|
25
|
+
* an idempotency header, and widening a public interface every consumer may
|
|
26
|
+
* have implemented — to serve one maintainer's command — is a break bought for
|
|
27
|
+
* nothing. So the transport is small and local, and injectable the same way
|
|
28
|
+
* `fetch.FetcherOptions.fetch` is, which is what lets a test drive the whole
|
|
29
|
+
* five-call flow with no stack behind it.
|
|
30
|
+
*
|
|
31
|
+
* **`@toolpath/api` is deliberately not a dependency.** This package takes one
|
|
32
|
+
* runtime dependency today and a second is a decision every consumer inherits;
|
|
33
|
+
* the generated SDK is pinned to `openapi/openapi.json` at v1.1.0, which has no
|
|
34
|
+
* holder routes at all, so it could not make these calls until that pin moves.
|
|
35
|
+
*
|
|
36
|
+
* ## What refuses, and what is survivable
|
|
37
|
+
*
|
|
38
|
+
* The split is `holding.ts`'s, for the same reason: a failed **job** is one
|
|
39
|
+
* holder's model the kernel could not read, so it is an `IncompletePartError`
|
|
40
|
+
* and {@link measureFamily} warns and drops it — losing a 431-holder batch to
|
|
41
|
+
* one bad STEP file is not a trade worth making. Anything else — a transport
|
|
42
|
+
* failure, a non-2xx, a response whose shape this cannot read — is a
|
|
43
|
+
* `VendorResponseError` and stops the run.
|
|
44
|
+
*/
|
|
45
|
+
import type { HolderRecord } from '../holding.js';
|
|
46
|
+
import type { ImportOptions, MeasuredHolder } from '../profiles.js';
|
|
47
|
+
import { type Warn } from '../scrape.js';
|
|
48
|
+
/** Where the holder routes are. Set it to `http://localhost:4000` for development. */
|
|
49
|
+
export declare const API_URL_ENV = "TOOLPATH_API_URL";
|
|
50
|
+
/**
|
|
51
|
+
* The bearer token, **from the environment only**.
|
|
52
|
+
*
|
|
53
|
+
* Never a flag, never a file path this package reads, never logged, never
|
|
54
|
+
* written into a receipt and never echoed in an error — an API key that reaches
|
|
55
|
+
* a terminal reaches a shell history and a CI log with it.
|
|
56
|
+
*/
|
|
57
|
+
export declare const API_KEY_ENV = "TOOLPATH_API_KEY";
|
|
58
|
+
/** Where the API is when nothing says otherwise. */
|
|
59
|
+
export declare const DEFAULT_API_URL = "https://api.toolpath.com";
|
|
60
|
+
/**
|
|
61
|
+
* The import options this pipeline measures with.
|
|
62
|
+
*
|
|
63
|
+
* `tolerance` matches the reference implementation's, and there is deliberately
|
|
64
|
+
* no segment budget behind it: a real BT30-ER16 is 69 segments at 0.01 mm and
|
|
65
|
+
* still 51 at 1.0 mm, because those are grooves and thread reliefs rather than
|
|
66
|
+
* sampling noise, and relaxing toward a budget inflates the holder until the
|
|
67
|
+
* flange-to-taper step is swallowed and the cone stops being detectable.
|
|
68
|
+
*
|
|
69
|
+
* **`fillBays` is off, and that is the fork.** Raising each solid's enclosed
|
|
70
|
+
* bays to their brims — the V-flange groove, the thread relief — is right for a
|
|
71
|
+
* collision envelope and wrong for a drawing: the groove and the relief are
|
|
72
|
+
* what a machinist looks for, so the literal silhouette is the honest thing to
|
|
73
|
+
* store. The consumer here is `@toolpath/tool-drawing`, so it is off, and the
|
|
74
|
+
* option is recorded in the document so nothing downstream has to guess which
|
|
75
|
+
* it got. A second run with it on, for a Fusion collision library, is a later
|
|
76
|
+
* optional step rather than a fork in this code.
|
|
77
|
+
*
|
|
78
|
+
* `flipped` is an override for a holder the automatic orientation reads
|
|
79
|
+
* backwards, not a setting: the automatic pass reads the 7:24 taper and got
|
|
80
|
+
* every validated holder right.
|
|
81
|
+
*/
|
|
82
|
+
export declare const DEFAULT_IMPORT_OPTIONS: ImportOptions;
|
|
83
|
+
/** Milliseconds between polls of an import job. Imports finish in about two seconds. */
|
|
84
|
+
export declare const POLL_INTERVAL_MS = 500;
|
|
85
|
+
/** Milliseconds before an import job that never settles is abandoned. */
|
|
86
|
+
export declare const POLL_TIMEOUT_MS = 120000;
|
|
87
|
+
/**
|
|
88
|
+
* How many times one rate-limited request is retried before the run gives up.
|
|
89
|
+
*
|
|
90
|
+
* Generous on purpose: the window the API is asking the client to wait out is
|
|
91
|
+
* measured in tens of seconds, and the alternative to waiting is abandoning a
|
|
92
|
+
* batch that has already paid for every holder before this one.
|
|
93
|
+
*/
|
|
94
|
+
export declare const RATE_LIMIT_ATTEMPTS = 6;
|
|
95
|
+
/** What to wait when the API names no `Retry-After` of its own, in milliseconds. */
|
|
96
|
+
export declare const RATE_LIMIT_BACKOFF_MS = 2000;
|
|
97
|
+
/**
|
|
98
|
+
* How long to wait out a 429, preferring the API's own answer.
|
|
99
|
+
*
|
|
100
|
+
* `Retry-After` is in seconds and is the only number that knows when the
|
|
101
|
+
* window rolls, so it wins wherever it parses. A header that is absent, empty,
|
|
102
|
+
* or an HTTP-date rather than a delay yields `NaN`, and the fallback grows with
|
|
103
|
+
* the attempt so a client that cannot read the header still backs off.
|
|
104
|
+
*/
|
|
105
|
+
export declare function retryAfterMs(response: Response, attempt: number): number;
|
|
106
|
+
/** The resolved API base URL, without its trailing slash. */
|
|
107
|
+
export declare function apiUrl(): string;
|
|
108
|
+
/**
|
|
109
|
+
* One line naming the resolved API base URL and how it was resolved.
|
|
110
|
+
*
|
|
111
|
+
* Printed by every command that measures, for the reason `paths.describeRoot`
|
|
112
|
+
* is printed by every command that scrapes: production is still Engine API
|
|
113
|
+
* v1.1.0 and carries none of these routes, so a run that went somewhere
|
|
114
|
+
* surprising should say so on the way rather than be discovered in a 404.
|
|
115
|
+
*/
|
|
116
|
+
export declare function describeApi(): string;
|
|
117
|
+
/** How to reach the API. Every field has an environment or a constant behind it. */
|
|
118
|
+
export interface HolderApiOptions {
|
|
119
|
+
/** Defaults to {@link apiUrl}. */
|
|
120
|
+
baseUrl?: string;
|
|
121
|
+
/** Defaults to `process.env[API_KEY_ENV]`. */
|
|
122
|
+
apiKey?: string;
|
|
123
|
+
/** Injectable, so a test drives the five calls with no stack behind them. */
|
|
124
|
+
fetch?: typeof globalThis.fetch;
|
|
125
|
+
timeoutMs?: number;
|
|
126
|
+
pollIntervalMs?: number;
|
|
127
|
+
pollTimeoutMs?: number;
|
|
128
|
+
/** How many times one rate-limited request is retried. Zero never retries. */
|
|
129
|
+
rateLimitAttempts?: number;
|
|
130
|
+
}
|
|
131
|
+
/** The transport {@link measureHolder} makes its five calls through. */
|
|
132
|
+
export interface HolderApi {
|
|
133
|
+
/** One authenticated call to a path under the base URL, decoded as JSON. */
|
|
134
|
+
call<T>(method: string, path: string, headers?: Record<string, string>): Promise<T>;
|
|
135
|
+
/** One PUT of raw bytes to a presigned URL, which carries its own authentication. */
|
|
136
|
+
put(url: string, body: Uint8Array): Promise<void>;
|
|
137
|
+
readonly pollIntervalMs: number;
|
|
138
|
+
readonly pollTimeoutMs: number;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The transport, authenticated and timed out.
|
|
142
|
+
*
|
|
143
|
+
* Refuses at construction when no key is set rather than at the first request:
|
|
144
|
+
* a batch that authenticated per holder would fail on holder one after mirroring
|
|
145
|
+
* the whole family, and the message would be about a 401 instead of about an
|
|
146
|
+
* unset variable.
|
|
147
|
+
*/
|
|
148
|
+
export declare function createHolderApi(options?: HolderApiOptions): HolderApi;
|
|
149
|
+
/**
|
|
150
|
+
* A key that stops one retried queue call dispatching a second import.
|
|
151
|
+
*
|
|
152
|
+
* **Keyed on the holder the API just created, not on the catalog number**, and
|
|
153
|
+
* the distinction is the whole correctness of this function. The API binds a
|
|
154
|
+
* key to the holder it first saw and refuses a later request that reuses the
|
|
155
|
+
* key for a different one — `idempotency_key_reused`, 409. {@link measureHolder}
|
|
156
|
+
* creates a *fresh* holder on every call, so a key derived from the catalog
|
|
157
|
+
* number is the same string naming a different holder on the second run: the
|
|
158
|
+
* second measurement of any family 409s on its first part, permanently, for
|
|
159
|
+
* that organisation. That is what this was doing until 2026-09-02.
|
|
160
|
+
*
|
|
161
|
+
* So the scope this can honestly promise is **one run**: a `PATCH` retried
|
|
162
|
+
* after a transport blip replays the job it already dispatched instead of
|
|
163
|
+
* queueing a second. It cannot make re-running an interrupted family free — the
|
|
164
|
+
* earlier docstring claimed that, and the API's own dedupe rule makes it
|
|
165
|
+
* unreachable, because there is no way to ask for the holder a previous run
|
|
166
|
+
* created. Resuming cheaply is `profiles.ts`'s job, by not re-measuring what
|
|
167
|
+
* the store already holds.
|
|
168
|
+
*
|
|
169
|
+
* The options stay in the key: the same holder imported at two tolerances is
|
|
170
|
+
* two different measurements and must not deduplicate to one.
|
|
171
|
+
*/
|
|
172
|
+
export declare function idempotencyKey(holderId: string, options: ImportOptions): string;
|
|
173
|
+
/**
|
|
174
|
+
* A `HolderResponse` as the fields a profile needs, and nothing else.
|
|
175
|
+
*
|
|
176
|
+
* **The one place the API's shape is read**, so a route that changes fails here
|
|
177
|
+
* naming the field rather than as an undefined three transforms downstream. The
|
|
178
|
+
* quality signals it drops — `axisAreaFraction`, `faceCount`, `sampleCount` —
|
|
179
|
+
* are read and reported by {@link measureFamily} rather than carried, because
|
|
180
|
+
* they say something about a run and nothing about the shape of the holder.
|
|
181
|
+
*/
|
|
182
|
+
export declare function parseHolderResponse(body: unknown, part: {
|
|
183
|
+
brand: MeasuredHolder['brand'];
|
|
184
|
+
catalogNumber: string;
|
|
185
|
+
}): MeasuredHolder;
|
|
186
|
+
/**
|
|
187
|
+
* One mirrored STEP file, measured.
|
|
188
|
+
*
|
|
189
|
+
* The five calls of the contract, in order, with the poll in the middle: create
|
|
190
|
+
* a holder and take its presigned upload URL, PUT the bytes, queue the import,
|
|
191
|
+
* wait for the job, read the result.
|
|
192
|
+
*
|
|
193
|
+
* Polling rather than the job's SSE stream, because a batch of 431 wants a
|
|
194
|
+
* simple loop and every validated import settled in about two seconds.
|
|
195
|
+
*/
|
|
196
|
+
export declare function measureHolder(api: HolderApi, part: {
|
|
197
|
+
brand: MeasuredHolder['brand'];
|
|
198
|
+
catalogNumber: string;
|
|
199
|
+
}, step: Uint8Array, options?: ImportOptions): Promise<MeasuredHolder>;
|
|
200
|
+
/** One holder's mirrored STEP file, or null where the vendor published none. */
|
|
201
|
+
export declare function readMirroredStep(stepRoot: string, catalogNumber: string): Uint8Array | null;
|
|
202
|
+
/** What one family's measurement run produced, and what it did not. */
|
|
203
|
+
export interface MeasuredFamily {
|
|
204
|
+
readonly measured: MeasuredHolder[];
|
|
205
|
+
/** Holders whose STEP file is not mirrored on this machine. */
|
|
206
|
+
readonly unmirrored: string[];
|
|
207
|
+
/** Holders whose import the kernel refused. */
|
|
208
|
+
readonly failed: string[];
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Every holder of one family that has a mirrored model, measured, paced.
|
|
212
|
+
*
|
|
213
|
+
* **A holder with no mirrored file is counted, not failed.** MariTool publishes
|
|
214
|
+
* no STEP model for about a third of its parts and none at all for its CAT50
|
|
215
|
+
* line, so an absent file is the ordinary case rather than a fault, and
|
|
216
|
+
* `cad-mirror.cadCoverage` is what says how many there will be before any of
|
|
217
|
+
* this runs.
|
|
218
|
+
*
|
|
219
|
+
* The pace is the mirror's, and for the same reason: this is a maintainer's
|
|
220
|
+
* batch against a service, and 431 imports arriving as fast as a loop can issue
|
|
221
|
+
* them is a different kind of request than one holder being measured.
|
|
222
|
+
*/
|
|
223
|
+
export declare function measureFamily(api: HolderApi, holders: readonly HolderRecord[], stepRoot: string, options?: ImportOptions, delayMs?: number, warn?: Warn): Promise<MeasuredFamily>;
|