@toolpath/tool-scraper 2.1.0 → 2.3.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.
Files changed (43) hide show
  1. package/dist/conventions.d.ts +48 -2
  2. package/dist/conventions.js +41 -0
  3. package/dist/family.d.ts +16 -2
  4. package/dist/holding.d.ts +396 -0
  5. package/dist/holding.js +360 -0
  6. package/dist/index.d.ts +23 -13
  7. package/dist/index.js +23 -13
  8. package/dist/measure.d.ts +14 -10
  9. package/dist/measure.js +14 -13
  10. package/dist/node/cad-mirror.d.ts +58 -1
  11. package/dist/node/cad-mirror.js +56 -8
  12. package/dist/node/cli.d.ts +4 -1
  13. package/dist/node/cli.js +192 -18
  14. package/dist/node/holder-import.d.ts +223 -0
  15. package/dist/node/holder-import.js +379 -0
  16. package/dist/node/index.d.ts +1 -0
  17. package/dist/node/index.js +1 -0
  18. package/dist/node/paths.d.ts +16 -0
  19. package/dist/node/paths.js +20 -0
  20. package/dist/profiles.d.ts +275 -0
  21. package/dist/profiles.js +295 -0
  22. package/dist/provenance.d.ts +9 -1
  23. package/dist/provenance.js +9 -1
  24. package/dist/records.d.ts +60 -20
  25. package/dist/records.js +31 -19
  26. package/dist/registry.d.ts +61 -5
  27. package/dist/registry.js +109 -6
  28. package/dist/vendors/kennametal/holding.d.ts +35 -0
  29. package/dist/vendors/kennametal/holding.js +112 -0
  30. package/dist/vendors/kennametal/index.d.ts +1 -0
  31. package/dist/vendors/kennametal/index.js +1 -0
  32. package/dist/vendors/maritool/holding.d.ts +79 -0
  33. package/dist/vendors/maritool/holding.js +164 -0
  34. package/dist/vendors/maritool/index.d.ts +1 -0
  35. package/dist/vendors/maritool/index.js +1 -0
  36. package/dist/vendors/maritool/scrape.d.ts +37 -13
  37. package/dist/vendors/maritool/scrape.js +53 -14
  38. package/dist/vendors/regofix/holding.d.ts +35 -0
  39. package/dist/vendors/regofix/holding.js +108 -0
  40. package/dist/vendors/regofix/index.d.ts +1 -0
  41. package/dist/vendors/regofix/index.js +1 -0
  42. package/dist/vendors/regofix/scrape.js +2 -2
  43. package/package.json +3 -2
@@ -28,19 +28,25 @@
28
28
  */
29
29
  import { mkdirSync, writeFileSync } from 'node:fs';
30
30
  import { dirname, join } from 'node:path';
31
- import { CAD_COLUMN } from '../conventions.js';
31
+ import { CAD_COLUMN, CAD_DXF_COLUMN, catalogColumn } from '../conventions.js';
32
32
  import { REQUEST_DELAY_MS, consoleWarn, pause } from '../scrape.js';
33
33
  /**
34
- * A catalog number as one path segment.
34
+ * What one part's mirrored STEP model is called.
35
35
  *
36
36
  * REGO-FIX's catalog number is the vendor's own title — `BT 30 / PG 25 x 075`
37
37
  * — and a separator in it was being honoured as one: `downloadStep` creates
38
38
  * the parent directory, so the file landed in a `BT 30 ` subdirectory instead
39
39
  * of flat in `outDir`, against what this module promises. The number stays
40
40
  * readable, which is the whole reason the file is named for it.
41
+ *
42
+ * **Exported because two things now resolve it**, and two answers to "what is
43
+ * this part's file called" is one too many — the same argument
44
+ * {@link cadCoverage} makes for counting the column here. `node/holder-import.ts`
45
+ * reads back what this writes, and a mirror that flattened a separator while a
46
+ * reader did not would report every REGO-FIX holder as unmirrored.
41
47
  */
42
- function fileName(catalogNumber) {
43
- return catalogNumber.replaceAll(/[/\\]/g, '-');
48
+ export function stepFileName(catalogNumber) {
49
+ return `${catalogNumber.replaceAll(/[/\\]/g, '-')}.stp`;
44
50
  }
45
51
  /** One STEP file onto disk. Returns the bytes written. */
46
52
  export async function downloadStep(fetcher, url, dest) {
@@ -52,6 +58,36 @@ export async function downloadStep(fetcher, url, dest) {
52
58
  writeFileSync(dest, data);
53
59
  return data.byteLength;
54
60
  }
61
+ /**
62
+ * What a mirror of these rows would and would not get, without asking for any
63
+ * of it.
64
+ *
65
+ * **This is the number that bounds anything built on measured geometry**, and
66
+ * it is worth knowing before the download rather than after: two of the three
67
+ * toolholding vendors publish a STEP model for every part, and MariTool — which
68
+ * is 527 of the 601 holder rows — publishes one for about two thirds. A
69
+ * pipeline scoped against 601 and run against 431 is a surprise at the end.
70
+ *
71
+ * Counted here rather than in the CLI because it is the same column read the
72
+ * same way {@link mirrorFamilySteps} reads it, and two answers to "does this row
73
+ * have a model" is one too many. Pure, and it makes no requests.
74
+ *
75
+ * **The DXF is counted and is not a fallback.** `conventions.CAD_DXF_COLUMN`
76
+ * says why the two are different columns: a DXF is a drawing whose datum,
77
+ * projection and layer semantics are the vendor's business, and deriving a
78
+ * profile from one is a separate project rather than a way to cover the gap.
79
+ */
80
+ export function cadCoverage(rows) {
81
+ let step = 0;
82
+ let dxf = 0;
83
+ for (const row of rows) {
84
+ if ((row[CAD_COLUMN] ?? '').trim())
85
+ step += 1;
86
+ if ((row[CAD_DXF_COLUMN] ?? '').trim())
87
+ dxf += 1;
88
+ }
89
+ return { rows: rows.length, step, dxf };
90
+ }
55
91
  /**
56
92
  * Every STEP model a holder scrape names, into `outDir`, one file per row.
57
93
  *
@@ -59,6 +95,16 @@ export async function downloadStep(fetcher, url, dest) {
59
95
  * filename is what a human reads and `BT30ER16060M` says what the part is
60
96
  * where `1258023` does not.
61
97
  *
98
+ * **`brand` is what says which column that number is in, and it is an argument
99
+ * rather than a guess.** This read `row['ISO Catalog Number']` until 2026-09-02,
100
+ * which is Kennametal's pair and REGO-FIX's adopted copy of it. MariTool
101
+ * publishes one number per part, under `Material Number`, and no catalog
102
+ * designation at all — `conventions.IDENTITY_DEVIATIONS` records why — so every
103
+ * one of its 357 published models was skipped with a warning saying the row had
104
+ * no catalog number to name it. `conventions.catalogColumn` is the lookup, and
105
+ * it is a lookup rather than a fallback down a list of candidate columns for the
106
+ * reason stated there.
107
+ *
62
108
  * **`outDir` is a required argument and never inferred.** These files are a
63
109
  * local working copy, they are gitignored, and a default that pointed into a
64
110
  * tracked directory would be the one mistake that silently commits ~3 MB of
@@ -68,21 +114,23 @@ export async function downloadStep(fetcher, url, dest) {
68
114
  * `lightweightStepUrl`'s documented null case arriving here — and a skipped
69
115
  * row spends no delay, because the count that matters is downloads and a
70
116
  * family that is mostly blank should not sleep its way through the gaps.
117
+ * {@link cadCoverage} is how many of them there will be, before any of it runs.
71
118
  */
72
- export async function mirrorFamilySteps(fetcher, rows, outDir, delayMs = REQUEST_DELAY_MS, warn = consoleWarn) {
119
+ export async function mirrorFamilySteps(fetcher, rows, brand, outDir, delayMs = REQUEST_DELAY_MS, warn = consoleWarn) {
73
120
  const written = [];
121
+ const column = catalogColumn(brand);
74
122
  for (const row of rows) {
75
123
  const url = (row[CAD_COLUMN] ?? '').trim();
76
124
  if (!url)
77
125
  continue;
78
- const catalogNumber = row['ISO Catalog Number'] ?? '';
126
+ const catalogNumber = row[column] ?? '';
79
127
  if (!catalogNumber) {
80
- warn(` SKIPPED a row with a CAD URL and no catalog number to name it`);
128
+ warn(` SKIPPED a row with a CAD URL and no ${column} to name it`);
81
129
  continue;
82
130
  }
83
131
  if (written.length > 0)
84
132
  await pause(delayMs);
85
- const bytes = await downloadStep(fetcher, url, join(outDir, `${fileName(catalogNumber)}.stp`));
133
+ const bytes = await downloadStep(fetcher, url, join(outDir, stepFileName(catalogNumber)));
86
134
  written.push({ catalogNumber, bytes });
87
135
  }
88
136
  return written;
@@ -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 { describeRoot, familyCsv, stepDir } from './paths.js';
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 { scrape, found } = await annotateCadUrls(fetcher, readCsv(name, path));
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>;